retypeset-odyssey 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/astro.config.ts +18 -0
- package/default-config.yaml +136 -0
- package/discover-collections.ts +160 -0
- package/integration.ts +394 -0
- package/package.json +105 -0
- package/patches/@qwik.dev__partytown@0.11.2.patch +98 -0
- package/public/_redirects +819 -0
- package/public/feeds/atom-style.xsl +105 -0
- package/public/feeds/rss-style.xsl +105 -0
- package/public/fonts/EarlySummer-VF-Split/00785494587e3487ac63a0e7e4fa30f0.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/08e5d941a4c76fad7b68e7a937ebb21f.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/1268e5072156188d601f1eeb4473655d.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/12a385475353c815d7a5add53ee51e37.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/12b11ca08223c65a21fc731d59dcfc11.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/16d6676d3cb645c520ee6df8a1f89afd.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/2912a75ffef95e7a5ae9e2b2311ad61d.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/298d96ea561e419a4104bc9fc18499ce.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/2a2c71acc17ec39f6780835899e53096.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/2a7e2d0e59d3f638074c50fab39fdef1.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/36931fc4370e1670ed76af5d3feccba2.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/3a68fdc792e4a9e0399a04e32d0cc2e3.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/4054d6a4d6b37719b51e0f71da6e7cd9.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/429cb25f825c3cbde6bfac5b36ae9675.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/42a9efc11298368ecdc1b85ab46f0b4f.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/432018d2bdc9df92a7662056eb2b1261.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/44a6fb782f2a01560faa0f95248b60ef.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/45367b060e8ba0aa2507e6b91b86620b.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/571db7564bda7c1a93542881b8976f4b.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/58d55eeef4cf455e86a1142b1f3110d3.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/59ea41e77309160a0f63cdc76a010202.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/5d19d9174e568db4755981aa2e4ab380.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/5e811eb3b4175ee93d7ec000bf4631c2.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/6268e0cd5d66d6fe05b331f259e7b9e4.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/6549844aa3d833ca06a68a8e839db465.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/714b459658a7321ceeb1e1386ce165c2.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/7511d97a469915013683eae06cb21cd9.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/7784b4ebe543d13f62f6f6e05beb0b2e.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/77c9bea70b3c6ab24e1497d5468c825b.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/789ebea9e81df623e930b86de98fbfab.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/885bb7ab0717e8a47fc17f953adcdbf1.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/896c58aff69a9a857764cee0663bc56d.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/8fb6fc01c59d1e3ad1910b58dec7f5e7.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/95be5462b91b9a0458797cdc89d94cb5.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/9a5b2724f983ca0fc0d5ff8d10c41396.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/9ffe17f9c0e4cc4356cb3f08ffdb9c6d.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/EarlySummer-VF-Subset.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/EarlySummerSerif License.txt +91 -0
- package/public/fonts/EarlySummer-VF-Split/a097ef49be62cd2565aca45600e1e3ac.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/a17a1ae6063088e5b3a48c06b816929a.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/a83fdcfc5ecf2f6996704b0c02758689.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/a8cf15ff9b71e59407d8406866ff6f99.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/af530ed51dd519e4456f8a5e259e908b.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/b195a8924915deec4aa9c3ec777cc93f.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/b4b6bb5df9239dd67b52ca858fd2a506.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/b7592e1e027923f19e0e55dfdac69668.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/b965859f69d8ccceaf0e2d6292afbcfb.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/bbe9333f1ff242bd96ecb23ff9e723b1.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/be758580e295339ea98f0240b9869f24.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/c07099e1d025617f6d40966986e1941b.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/c1b593dda62fdeb7dde3af02016da282.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/c89f0335910a68a0958f2846108370e8.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/ca49aa409fdedd3f2f894cd20a16640a.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/ccd4a28d2f63797e0183c87792e20b75.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/d2718da923fce8e7ea229d65e306e92c.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/d893e9b307d96041e9cfcbd03761b9f4.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/dafaedaee41b75e21479d4ff324b6a34.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/db392af65f1867e5fd580eed2195df99.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/dc7c73a9e5577143ccd11e05ab55cb39.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/de396881189f747eba67685298363242.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/df625b213228bba22a7733d4eff8f148.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/e6e60b384f220b893ef31a926ece829a.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/e6e8ce2c5972ab665630bb705383d0fb.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/e963c7ed7104c2d6d68fcb5f952fe2f5.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/e966b23b4cd7783f43e31032d41784f4.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/edaac57c3856ec13128f4c6c3e00975c.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/ee54e0d86edf068c6c9cbddb76a856fe.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/f612c78a5544ff2dd3e8296ac3e58344.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/f9e539bd9b7bf999c3da82f5403ec3b6.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/fa5863b923ac15993c52a619f699ee63.woff2 +0 -0
- package/public/fonts/EarlySummer-VF-Split/fc759e56ec6f6e6d3d4cb163d62fb557.woff2 +0 -0
- package/public/fonts/Font Subset List/CJK Common Characters.txt +7534 -0
- package/public/fonts/Font Subset List/EarlySummer Subset.txt +3 -0
- package/public/fonts/Font Subset List/Japanese Kana + Korean Letters.txt +6123 -0
- package/public/fonts/Font Subset List/Latin + Cyrillic + Greek + Arabic Glyphs.txt +121 -0
- package/public/fonts/Font Subset List/unicode_range.py +49 -0
- package/public/fonts/NotoSansSC-Bold.otf +0 -0
- package/public/fonts/NotoSansSC-Regular.otf +0 -0
- package/public/fonts/STIX-Italic-VF.woff2 +0 -0
- package/public/fonts/STIX-VF.woff2 +0 -0
- package/public/fonts/Snell-Black-SF.woff2 +0 -0
- package/public/fonts/Snell-Bold-SF.woff2 +0 -0
- package/public/giscus/theme-dark.css +208 -0
- package/public/giscus/theme-light.css +208 -0
- package/public/icons/favicon.svg +4 -0
- package/public/icons/og-logo.png +0 -0
- package/public/robots.txt +4 -0
- package/public/sounds/tap_01.wav +0 -0
- package/public/sounds/tap_02.wav +0 -0
- package/public/sounds/tap_03.wav +0 -0
- package/public/sounds/tap_04.wav +0 -0
- package/public/sounds/tap_05.wav +0 -0
- package/public/sounds/type_01.wav +0 -0
- package/public/sounds/type_02.wav +0 -0
- package/public/sounds/type_03.wav +0 -0
- package/public/sounds/type_04.wav +0 -0
- package/public/sounds/type_05.wav +0 -0
- package/scripts/apply-lqip.ts +276 -0
- package/scripts/format-posts.ts +105 -0
- package/scripts/migration/README.md +52 -0
- package/scripts/migration/migrate-hexo.ts +185 -0
- package/scripts/migration/validate-abbrlinks.ts +161 -0
- package/scripts/new-post.ts +52 -0
- package/scripts/seo/generate-legacy-redirects.ts +407 -0
- package/scripts/update-theme.ts +46 -0
- package/src/assets/icons/copy-check.svg +3 -0
- package/src/assets/icons/copy-icon.svg +4 -0
- package/src/assets/icons/go-back.svg +3 -0
- package/src/assets/icons/heading-anchor.svg +4 -0
- package/src/assets/icons/lang-en.svg +3 -0
- package/src/assets/icons/lang-ja.svg +5 -0
- package/src/assets/icons/lang-zh.svg +5 -0
- package/src/assets/icons/language-switcher.svg +3 -0
- package/src/assets/icons/pin-icon.svg +3 -0
- package/src/assets/icons/search-icon.svg +3 -0
- package/src/assets/icons/theme-toggle.svg +3 -0
- package/src/assets/icons/toc-icon.svg +3 -0
- package/src/assets/icons/top-icon.svg +3 -0
- package/src/assets/lqip-map.json +10 -0
- package/src/components/Button.astro +152 -0
- package/src/components/CategoryList.astro +66 -0
- package/src/components/Comment/Giscus.astro +119 -0
- package/src/components/Comment/Index.astro +30 -0
- package/src/components/Comment/Twikoo.astro +114 -0
- package/src/components/Comment/Waline.astro +149 -0
- package/src/components/FloatingButtons.astro +101 -0
- package/src/components/Footer.astro +74 -0
- package/src/components/Header.astro +62 -0
- package/src/components/JournalList.astro +56 -0
- package/src/components/Navbar.astro +69 -0
- package/src/components/NoteList.astro +56 -0
- package/src/components/Pagination.astro +267 -0
- package/src/components/PostDate.astro +80 -0
- package/src/components/PostList.astro +87 -0
- package/src/components/SearchModal.astro +340 -0
- package/src/components/TagList.astro +135 -0
- package/src/components/Widgets/BackButton.astro +43 -0
- package/src/components/Widgets/CodeCopyButton.astro +47 -0
- package/src/components/Widgets/GithubCard.astro +110 -0
- package/src/components/Widgets/ImageZoom.astro +135 -0
- package/src/components/Widgets/MediaEmbed.astro +127 -0
- package/src/components/Widgets/SoundEffect.astro +179 -0
- package/src/components/Widgets/TOC.astro +198 -0
- package/src/config-schema.ts +164 -0
- package/src/config.ts +127 -0
- package/src/config.ts.example +205 -0
- package/src/content/about/_example-about-en.md +6 -0
- package/src/content/about/about-en.md +21 -0
- package/src/content/about/about-ja.md +21 -0
- package/src/content/about/about-zh.md +24 -0
- package/src/content.config.ts +247 -0
- package/src/env.d.ts +25 -0
- package/src/i18n/config.ts +65 -0
- package/src/i18n/lang.ts +70 -0
- package/src/i18n/path.ts +160 -0
- package/src/i18n/ui.ts +214 -0
- package/src/layouts/Head.astro +203 -0
- package/src/layouts/Layout.astro +69 -0
- package/src/pages/404.astro +20 -0
- package/src/pages/[...lang]/[...page].astro +48 -0
- package/src/pages/[...lang]/about.astro +28 -0
- package/src/pages/[...lang]/atom.xml.ts +14 -0
- package/src/pages/[...lang]/categories/index.astro +35 -0
- package/src/pages/[...lang]/journals/[slug].astro +89 -0
- package/src/pages/[...lang]/journals/index.astro +55 -0
- package/src/pages/[...lang]/journals/page/[page].astro +66 -0
- package/src/pages/[...lang]/notes/[slug].astro +88 -0
- package/src/pages/[...lang]/notes/index.astro +55 -0
- package/src/pages/[...lang]/notes/page/[page].astro +66 -0
- package/src/pages/[...lang]/posts/[slug].astro +101 -0
- package/src/pages/[...lang]/rss.xml.ts +14 -0
- package/src/pages/[...lang]/search.astro +65 -0
- package/src/pages/[...lang]/tags/[tag].astro +53 -0
- package/src/pages/[...lang]/tags/index.astro +36 -0
- package/src/pages/_dynamic/list.astro +101 -0
- package/src/pages/_dynamic/slug.astro +100 -0
- package/src/pages/og/[...image].ts +114 -0
- package/src/pages/robots.txt.ts +20 -0
- package/src/plugins/rehype-code-copy-button.mjs +82 -0
- package/src/plugins/rehype-external-links.mjs +18 -0
- package/src/plugins/rehype-heading-anchor.mjs +55 -0
- package/src/plugins/rehype-image-processor.mjs +77 -0
- package/src/plugins/remark-container-directives.mjs +135 -0
- package/src/plugins/remark-leaf-directives.mjs +184 -0
- package/src/plugins/remark-reading-time.mjs +11 -0
- package/src/styles/comment.css +205 -0
- package/src/styles/extension.css +180 -0
- package/src/styles/font.css +111 -0
- package/src/styles/global.css +91 -0
- package/src/styles/lqip.css +71 -0
- package/src/styles/markdown.css +276 -0
- package/src/styles/transition.css +173 -0
- package/src/types/global.d.ts +22 -0
- package/src/types/index.d.ts +111 -0
- package/src/utils/cache.ts +32 -0
- package/src/utils/content.ts +819 -0
- package/src/utils/description.ts +147 -0
- package/src/utils/dynamic-collections.ts +155 -0
- package/src/utils/feed.ts +238 -0
- package/src/utils/page.ts +107 -0
- package/tsconfig.json +13 -0
- package/uno.config.ts +75 -0
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import type { CollectionEntry } from 'astro:content'
|
|
2
|
+
import type { Language } from '@/i18n/config'
|
|
3
|
+
import type { Journal, Note, Post } from '@/types'
|
|
4
|
+
import { getCollection, render } from 'astro:content'
|
|
5
|
+
import { allLocales, defaultLocale } from '@/config'
|
|
6
|
+
import { memoize } from '@/utils/cache'
|
|
7
|
+
|
|
8
|
+
const metaCache = new Map<string, { minutes: number }>()
|
|
9
|
+
const noteMetaCache = new Map<string, { minutes: number }>()
|
|
10
|
+
const journalMetaCache = new Map<string, { minutes: number }>()
|
|
11
|
+
|
|
12
|
+
function getPostBaseId(post: CollectionEntry<'posts'>): string {
|
|
13
|
+
const id = post.id.trim()
|
|
14
|
+
|
|
15
|
+
// Strip language suffix from filename (e.g. "foo.en" -> "foo")
|
|
16
|
+
for (const lang of allLocales) {
|
|
17
|
+
const suffix = `.${lang}`
|
|
18
|
+
if (id.endsWith(suffix)) {
|
|
19
|
+
return id.slice(0, -suffix.length)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return id
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slugifyPathSegment(input: string): string {
|
|
27
|
+
const slug = input
|
|
28
|
+
.normalize('NFKC')
|
|
29
|
+
.trim()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[\s_]+/g, '-')
|
|
32
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
33
|
+
.replace(/-+/g, '-')
|
|
34
|
+
.replace(/^-|-$/g, '')
|
|
35
|
+
|
|
36
|
+
return slug
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PostGroup {
|
|
40
|
+
baseId: string
|
|
41
|
+
slug: string
|
|
42
|
+
supportedLangs: Language[]
|
|
43
|
+
byLang: Partial<Record<Language, CollectionEntry<'posts'>>>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function _getPostGroups(): Promise<PostGroup[]> {
|
|
47
|
+
const posts = await getCollection(
|
|
48
|
+
'posts',
|
|
49
|
+
({ data }: CollectionEntry<'posts'>) => !data.draft,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const groups = new Map<string, CollectionEntry<'posts'>[]>()
|
|
53
|
+
for (const post of posts) {
|
|
54
|
+
const baseId = getPostBaseId(post)
|
|
55
|
+
const list = groups.get(baseId) ?? []
|
|
56
|
+
list.push(post)
|
|
57
|
+
groups.set(baseId, list)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const slugToBaseId = new Map<string, string>()
|
|
61
|
+
const results: PostGroup[] = []
|
|
62
|
+
|
|
63
|
+
for (const [baseId, entries] of groups) {
|
|
64
|
+
const slug = slugifyPathSegment(baseId) || baseId
|
|
65
|
+
const existing = slugToBaseId.get(slug)
|
|
66
|
+
if (existing && existing !== baseId) {
|
|
67
|
+
throw new Error(`Duplicate post slug "${slug}" from "${existing}" and "${baseId}"`)
|
|
68
|
+
}
|
|
69
|
+
slugToBaseId.set(slug, baseId)
|
|
70
|
+
|
|
71
|
+
const baseEntry = entries.find(e => !e.data.lang)
|
|
72
|
+
const zhEntry = entries.find(e => e.data.lang === 'zh')
|
|
73
|
+
const enEntry = entries.find(e => e.data.lang === 'en')
|
|
74
|
+
const jaEntry = entries.find(e => e.data.lang === 'ja')
|
|
75
|
+
|
|
76
|
+
// Base file language inference (best-effort):
|
|
77
|
+
// - If there is an explicit zh translation but no explicit en translation,
|
|
78
|
+
// treat base entry as English.
|
|
79
|
+
const inferredEnFromBase = !enEntry && !!zhEntry ? baseEntry : undefined
|
|
80
|
+
const inferredZhFromBase = !zhEntry ? baseEntry : undefined
|
|
81
|
+
|
|
82
|
+
const byLang: PostGroup['byLang'] = {
|
|
83
|
+
zh: zhEntry ?? inferredZhFromBase,
|
|
84
|
+
en: enEntry ?? inferredEnFromBase,
|
|
85
|
+
ja: jaEntry,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const supportedLangs = allLocales.filter(lang => byLang[lang])
|
|
89
|
+
|
|
90
|
+
results.push({
|
|
91
|
+
baseId,
|
|
92
|
+
slug,
|
|
93
|
+
supportedLangs,
|
|
94
|
+
byLang,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const getPostGroups = memoize(_getPostGroups)
|
|
102
|
+
|
|
103
|
+
function getNoteBaseId(note: CollectionEntry<'notes'>): string {
|
|
104
|
+
const id = note.id.trim()
|
|
105
|
+
|
|
106
|
+
// Strip language suffix from filename (e.g. "foo.en" -> "foo")
|
|
107
|
+
for (const lang of allLocales) {
|
|
108
|
+
const suffix = `.${lang}`
|
|
109
|
+
if (id.endsWith(suffix)) {
|
|
110
|
+
return id.slice(0, -suffix.length)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return id
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface NoteGroup {
|
|
118
|
+
baseId: string
|
|
119
|
+
slug: string
|
|
120
|
+
supportedLangs: Language[]
|
|
121
|
+
byLang: Partial<Record<Language, CollectionEntry<'notes'>>>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function _getNoteGroups(): Promise<NoteGroup[]> {
|
|
125
|
+
let notes: CollectionEntry<'notes'>[] = []
|
|
126
|
+
try {
|
|
127
|
+
notes = await getCollection(
|
|
128
|
+
'notes',
|
|
129
|
+
({ data }: CollectionEntry<'notes'>) => !data.draft,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
134
|
+
if (message.includes('The collection "notes" does not exist') || message.includes('The collection "notes" is empty')) {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const groups = new Map<string, CollectionEntry<'notes'>[]>()
|
|
141
|
+
for (const note of notes) {
|
|
142
|
+
const baseId = getNoteBaseId(note)
|
|
143
|
+
const list = groups.get(baseId) ?? []
|
|
144
|
+
list.push(note)
|
|
145
|
+
groups.set(baseId, list)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const slugToBaseId = new Map<string, string>()
|
|
149
|
+
const results: NoteGroup[] = []
|
|
150
|
+
|
|
151
|
+
for (const [baseId, entries] of groups) {
|
|
152
|
+
const slug = slugifyPathSegment(baseId) || baseId
|
|
153
|
+
const existing = slugToBaseId.get(slug)
|
|
154
|
+
if (existing && existing !== baseId) {
|
|
155
|
+
throw new Error(`Duplicate note slug "${slug}" from "${existing}" and "${baseId}"`)
|
|
156
|
+
}
|
|
157
|
+
slugToBaseId.set(slug, baseId)
|
|
158
|
+
|
|
159
|
+
const baseEntry = entries.find(e => !e.data.lang)
|
|
160
|
+
const zhEntry = entries.find(e => e.data.lang === 'zh')
|
|
161
|
+
const enEntry = entries.find(e => e.data.lang === 'en')
|
|
162
|
+
const jaEntry = entries.find(e => e.data.lang === 'ja')
|
|
163
|
+
|
|
164
|
+
// Base file language inference (best-effort):
|
|
165
|
+
// - If there is an explicit zh translation but no explicit en translation,
|
|
166
|
+
// treat base entry as English.
|
|
167
|
+
const inferredEnFromBase = !enEntry && !!zhEntry ? baseEntry : undefined
|
|
168
|
+
const inferredZhFromBase = !zhEntry ? baseEntry : undefined
|
|
169
|
+
|
|
170
|
+
const byLang: NoteGroup['byLang'] = {
|
|
171
|
+
zh: zhEntry ?? inferredZhFromBase,
|
|
172
|
+
en: enEntry ?? inferredEnFromBase,
|
|
173
|
+
ja: jaEntry,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const supportedLangs = allLocales.filter(lang => byLang[lang])
|
|
177
|
+
|
|
178
|
+
results.push({
|
|
179
|
+
baseId,
|
|
180
|
+
slug,
|
|
181
|
+
supportedLangs,
|
|
182
|
+
byLang,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return results
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const getNoteGroups = memoize(_getNoteGroups)
|
|
190
|
+
|
|
191
|
+
export function getNoteSlug(note: CollectionEntry<'notes'>): string {
|
|
192
|
+
const baseId = getNoteBaseId(note)
|
|
193
|
+
return slugifyPathSegment(baseId) || baseId
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function addMetaToNote(note: CollectionEntry<'notes'>): Promise<Note> {
|
|
197
|
+
const cacheKey = `${note.id}-${note.data.lang || 'universal'}`
|
|
198
|
+
const cachedMeta = noteMetaCache.get(cacheKey)
|
|
199
|
+
if (cachedMeta) {
|
|
200
|
+
return {
|
|
201
|
+
...note,
|
|
202
|
+
remarkPluginFrontmatter: cachedMeta,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { remarkPluginFrontmatter } = await render(note)
|
|
207
|
+
const meta = remarkPluginFrontmatter as { minutes: number }
|
|
208
|
+
noteMetaCache.set(cacheKey, meta)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
...note,
|
|
212
|
+
remarkPluginFrontmatter: meta,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function _getNotes(lang?: Language) {
|
|
217
|
+
const currentLang = lang && allLocales.includes(lang) ? lang : defaultLocale
|
|
218
|
+
const groups = await getNoteGroups()
|
|
219
|
+
const selected = groups
|
|
220
|
+
.map(group => group.byLang[currentLang])
|
|
221
|
+
.filter(Boolean) as CollectionEntry<'notes'>[]
|
|
222
|
+
|
|
223
|
+
const enhancedNotes = await Promise.all(selected.map(addMetaToNote))
|
|
224
|
+
|
|
225
|
+
const getSortKey = (note: CollectionEntry<'notes'>) =>
|
|
226
|
+
(note.data.updated ?? note.data.published).valueOf()
|
|
227
|
+
|
|
228
|
+
return enhancedNotes.sort((a, b) => getSortKey(b) - getSortKey(a))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const getNotes = memoize(_getNotes)
|
|
232
|
+
|
|
233
|
+
function getJournalBaseId(journal: CollectionEntry<'journals'>): string {
|
|
234
|
+
const id = journal.id.trim()
|
|
235
|
+
|
|
236
|
+
// Strip language suffix from filename (e.g. "foo.en" -> "foo")
|
|
237
|
+
for (const lang of allLocales) {
|
|
238
|
+
const suffix = `.${lang}`
|
|
239
|
+
if (id.endsWith(suffix)) {
|
|
240
|
+
return id.slice(0, -suffix.length)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return id
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface JournalGroup {
|
|
248
|
+
baseId: string
|
|
249
|
+
slug: string
|
|
250
|
+
supportedLangs: Language[]
|
|
251
|
+
byLang: Partial<Record<Language, CollectionEntry<'journals'>>>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function _getJournalGroups(): Promise<JournalGroup[]> {
|
|
255
|
+
let journals: CollectionEntry<'journals'>[] = []
|
|
256
|
+
try {
|
|
257
|
+
journals = await getCollection(
|
|
258
|
+
'journals',
|
|
259
|
+
({ data }: CollectionEntry<'journals'>) => !data.draft,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
264
|
+
if (message.includes('The collection "journals" does not exist') || message.includes('The collection "journals" is empty')) {
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
267
|
+
throw error
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const groups = new Map<string, CollectionEntry<'journals'>[]>()
|
|
271
|
+
for (const journal of journals) {
|
|
272
|
+
const baseId = getJournalBaseId(journal)
|
|
273
|
+
const list = groups.get(baseId) ?? []
|
|
274
|
+
list.push(journal)
|
|
275
|
+
groups.set(baseId, list)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const slugToBaseId = new Map<string, string>()
|
|
279
|
+
const results: JournalGroup[] = []
|
|
280
|
+
|
|
281
|
+
for (const [baseId, entries] of groups) {
|
|
282
|
+
const slug = slugifyPathSegment(baseId) || baseId
|
|
283
|
+
const existing = slugToBaseId.get(slug)
|
|
284
|
+
if (existing && existing !== baseId) {
|
|
285
|
+
throw new Error(`Duplicate journal slug "${slug}" from "${existing}" and "${baseId}"`)
|
|
286
|
+
}
|
|
287
|
+
slugToBaseId.set(slug, baseId)
|
|
288
|
+
|
|
289
|
+
const baseEntry = entries.find(e => !e.data.lang)
|
|
290
|
+
const zhEntry = entries.find(e => e.data.lang === 'zh')
|
|
291
|
+
const enEntry = entries.find(e => e.data.lang === 'en')
|
|
292
|
+
const jaEntry = entries.find(e => e.data.lang === 'ja')
|
|
293
|
+
|
|
294
|
+
// Base file language inference (best-effort):
|
|
295
|
+
// - If there is an explicit zh translation but no explicit en translation,
|
|
296
|
+
// treat base entry as English.
|
|
297
|
+
const inferredEnFromBase = !enEntry && !!zhEntry ? baseEntry : undefined
|
|
298
|
+
const inferredZhFromBase = !zhEntry ? baseEntry : undefined
|
|
299
|
+
|
|
300
|
+
const byLang: JournalGroup['byLang'] = {
|
|
301
|
+
zh: zhEntry ?? inferredZhFromBase,
|
|
302
|
+
en: enEntry ?? inferredEnFromBase,
|
|
303
|
+
ja: jaEntry,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const supportedLangs = allLocales.filter(lang => byLang[lang])
|
|
307
|
+
|
|
308
|
+
results.push({
|
|
309
|
+
baseId,
|
|
310
|
+
slug,
|
|
311
|
+
supportedLangs,
|
|
312
|
+
byLang,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return results
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const getJournalGroups = memoize(_getJournalGroups)
|
|
320
|
+
|
|
321
|
+
export function getJournalSlug(journal: CollectionEntry<'journals'>): string {
|
|
322
|
+
const baseId = getJournalBaseId(journal)
|
|
323
|
+
return slugifyPathSegment(baseId) || baseId
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function addMetaToJournal(journal: CollectionEntry<'journals'>): Promise<Journal> {
|
|
327
|
+
const cacheKey = `${journal.id}-${journal.data.lang || 'universal'}`
|
|
328
|
+
const cachedMeta = journalMetaCache.get(cacheKey)
|
|
329
|
+
if (cachedMeta) {
|
|
330
|
+
return {
|
|
331
|
+
...journal,
|
|
332
|
+
remarkPluginFrontmatter: cachedMeta,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const { remarkPluginFrontmatter } = await render(journal)
|
|
337
|
+
const meta = remarkPluginFrontmatter as { minutes: number }
|
|
338
|
+
journalMetaCache.set(cacheKey, meta)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
...journal,
|
|
342
|
+
remarkPluginFrontmatter: meta,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function _getJournals(lang?: Language) {
|
|
347
|
+
const currentLang = lang && allLocales.includes(lang) ? lang : defaultLocale
|
|
348
|
+
const groups = await getJournalGroups()
|
|
349
|
+
const selected = groups
|
|
350
|
+
.map(group => group.byLang[currentLang])
|
|
351
|
+
.filter(Boolean) as CollectionEntry<'journals'>[]
|
|
352
|
+
|
|
353
|
+
const enhancedJournals = await Promise.all(selected.map(addMetaToJournal))
|
|
354
|
+
|
|
355
|
+
const getSortKey = (journal: CollectionEntry<'journals'>) =>
|
|
356
|
+
(journal.data.updated ?? journal.data.published).valueOf()
|
|
357
|
+
|
|
358
|
+
return enhancedJournals.sort((a, b) => getSortKey(b) - getSortKey(a))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export const getJournals = memoize(_getJournals)
|
|
362
|
+
|
|
363
|
+
// Tags that indicate science/research content (checked first, highest priority)
|
|
364
|
+
const SCIENCE_TAGS = new Set([
|
|
365
|
+
'ocean color', 'remote sensing', 'oceanography', 'research',
|
|
366
|
+
'satellite', 'gis', 'earth observation',
|
|
367
|
+
'climate', 'environmental science', 'geospatial',
|
|
368
|
+
])
|
|
369
|
+
|
|
370
|
+
// Tags that indicate technical content
|
|
371
|
+
const TECH_TAGS = new Set([
|
|
372
|
+
// Programming & Development
|
|
373
|
+
'python', 'java', 'javascript', 'typescript', 'golang', 'rust',
|
|
374
|
+
'algorithm', 'leetcode', 'data structure',
|
|
375
|
+
'software engineering', 'programming', 'coding',
|
|
376
|
+
// AI & Machine Learning
|
|
377
|
+
'deep learning', 'machine learning', 'tensorflow', 'pytorch',
|
|
378
|
+
'ai', 'artificial intelligence', 'neural network',
|
|
379
|
+
'large language model', 'llm', 'agentic ai', 'claude code',
|
|
380
|
+
// DevOps & Tools
|
|
381
|
+
'docker', 'kubernetes', 'aws', 'cloud', 'devops',
|
|
382
|
+
'git', 'linux', 'database', 'sql',
|
|
383
|
+
])
|
|
384
|
+
|
|
385
|
+
export type PostCategory = 'tech' | 'life' | 'science'
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get the URL slug for a post.
|
|
389
|
+
*
|
|
390
|
+
* Category routing is disabled; slug comes from the (base) filename.
|
|
391
|
+
*/
|
|
392
|
+
export function getPostSlug(post: CollectionEntry<'posts'>): string {
|
|
393
|
+
const baseId = getPostBaseId(post)
|
|
394
|
+
return slugifyPathSegment(baseId) || post.data.abbrlink || baseId
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Determine the category of a post based on its tags
|
|
399
|
+
* Priority: Science > Tech > Life
|
|
400
|
+
* Science posts go to /science/, Tech posts go to /tech/, life posts go to /life/
|
|
401
|
+
*/
|
|
402
|
+
export function getPostCategory(post: CollectionEntry<'posts'>): PostCategory {
|
|
403
|
+
const tags = post.data.tags || []
|
|
404
|
+
const normalizedTags = tags.map((t: string) => t.toLowerCase())
|
|
405
|
+
|
|
406
|
+
// Check science tags first (highest priority)
|
|
407
|
+
for (const tag of normalizedTags) {
|
|
408
|
+
if (SCIENCE_TAGS.has(tag)) {
|
|
409
|
+
return 'science'
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Then check tech tags
|
|
414
|
+
for (const tag of normalizedTags) {
|
|
415
|
+
if (TECH_TAGS.has(tag)) {
|
|
416
|
+
return 'tech'
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return 'life'
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get the URL path for a post.
|
|
425
|
+
*
|
|
426
|
+
* NOTE: Category-based routing is currently disabled.
|
|
427
|
+
*/
|
|
428
|
+
export function getPostPath(post: CollectionEntry<'posts'>, langPrefix?: string): string {
|
|
429
|
+
const slug = getPostSlug(post)
|
|
430
|
+
const prefix = langPrefix ? `/${langPrefix}` : ''
|
|
431
|
+
return `${prefix}/posts/${slug}.html`
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Add metadata including reading time to a post
|
|
436
|
+
*
|
|
437
|
+
* @param post The post to enhance with metadata
|
|
438
|
+
* @returns Enhanced post with reading time information
|
|
439
|
+
*/
|
|
440
|
+
async function addMetaToPost(post: CollectionEntry<'posts'>): Promise<Post> {
|
|
441
|
+
const cacheKey = `${post.id}-${post.data.lang || 'universal'}`
|
|
442
|
+
const cachedMeta = metaCache.get(cacheKey)
|
|
443
|
+
if (cachedMeta) {
|
|
444
|
+
return {
|
|
445
|
+
...post,
|
|
446
|
+
remarkPluginFrontmatter: cachedMeta,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const { remarkPluginFrontmatter } = await render(post)
|
|
451
|
+
const meta = remarkPluginFrontmatter as { minutes: number }
|
|
452
|
+
metaCache.set(cacheKey, meta)
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
...post,
|
|
456
|
+
remarkPluginFrontmatter: meta,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Find duplicate post slugs within the same language
|
|
462
|
+
*
|
|
463
|
+
* @param posts Array of blog posts to check
|
|
464
|
+
* @returns Array of descriptive error messages for duplicate slugs
|
|
465
|
+
*/
|
|
466
|
+
export async function checkPostSlugDuplication(posts: CollectionEntry<'posts'>[]): Promise<string[]> {
|
|
467
|
+
const slugMap = new Map<string, Set<string>>()
|
|
468
|
+
const duplicates: string[] = []
|
|
469
|
+
|
|
470
|
+
posts.forEach((post) => {
|
|
471
|
+
const lang = post.data.lang
|
|
472
|
+
const slug = getPostSlug(post)
|
|
473
|
+
|
|
474
|
+
let slugSet = slugMap.get(lang)
|
|
475
|
+
if (!slugSet) {
|
|
476
|
+
slugSet = new Set()
|
|
477
|
+
slugMap.set(lang, slugSet)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!slugSet.has(slug)) {
|
|
481
|
+
slugSet.add(slug)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!lang) {
|
|
486
|
+
duplicates.push(`Duplicate slug "${slug}" found in universal post (applies to all languages)`)
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
duplicates.push(`Duplicate slug "${slug}" found in "${lang}" language post`)
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
return duplicates
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Get all posts (including pinned ones, excluding drafts in production)
|
|
498
|
+
*
|
|
499
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
500
|
+
* @returns Posts filtered by language, enhanced with metadata, sorted by date
|
|
501
|
+
*/
|
|
502
|
+
async function _getPosts(lang?: Language) {
|
|
503
|
+
const currentLang = lang && allLocales.includes(lang) ? lang : defaultLocale
|
|
504
|
+
const groups = await getPostGroups()
|
|
505
|
+
const selected = groups
|
|
506
|
+
.map(group => group.byLang[currentLang])
|
|
507
|
+
.filter(Boolean) as CollectionEntry<'posts'>[]
|
|
508
|
+
|
|
509
|
+
const enhancedPosts = await Promise.all(selected.map(addMetaToPost))
|
|
510
|
+
|
|
511
|
+
return enhancedPosts.sort((a, b) =>
|
|
512
|
+
b.data.published.valueOf() - a.data.published.valueOf(),
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export const getPosts = memoize(_getPosts)
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Get all non-pinned posts
|
|
520
|
+
*
|
|
521
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
522
|
+
* @returns Regular posts (non-pinned), filtered by language
|
|
523
|
+
*/
|
|
524
|
+
async function _getRegularPosts(lang?: Language) {
|
|
525
|
+
const posts = await getPosts(lang)
|
|
526
|
+
return posts.filter(post => !post.data.pin)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export const getRegularPosts = memoize(_getRegularPosts)
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Get pinned posts sorted by pin priority
|
|
533
|
+
*
|
|
534
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
535
|
+
* @returns Pinned posts sorted by pin value in descending order
|
|
536
|
+
*/
|
|
537
|
+
async function _getPinnedPosts(lang?: Language) {
|
|
538
|
+
const posts = await getPosts(lang)
|
|
539
|
+
return posts
|
|
540
|
+
.filter(post => post.data.pin && post.data.pin > 0)
|
|
541
|
+
.sort((a, b) => (b.data.pin ?? 0) - (a.data.pin ?? 0))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export const getPinnedPosts = memoize(_getPinnedPosts)
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Group posts by year and sort within each year
|
|
548
|
+
*
|
|
549
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
550
|
+
* @returns Map of posts grouped by year (descending), sorted by date within each year
|
|
551
|
+
*/
|
|
552
|
+
async function _getPostsByYear(lang?: Language): Promise<Map<number, Post[]>> {
|
|
553
|
+
const posts = await getRegularPosts(lang)
|
|
554
|
+
const yearMap = new Map<number, Post[]>()
|
|
555
|
+
|
|
556
|
+
posts.forEach((post: Post) => {
|
|
557
|
+
const year = post.data.published.getFullYear()
|
|
558
|
+
let yearPosts = yearMap.get(year)
|
|
559
|
+
if (!yearPosts) {
|
|
560
|
+
yearPosts = []
|
|
561
|
+
yearMap.set(year, yearPosts)
|
|
562
|
+
}
|
|
563
|
+
yearPosts.push(post)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
// Sort posts within each year by date
|
|
567
|
+
yearMap.forEach((yearPosts) => {
|
|
568
|
+
yearPosts.sort((a, b) => {
|
|
569
|
+
const aDate = a.data.published
|
|
570
|
+
const bDate = b.data.published
|
|
571
|
+
return bDate.getMonth() - aDate.getMonth() || bDate.getDate() - aDate.getDate()
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
return new Map([...yearMap.entries()].sort((a, b) => b[0] - a[0]))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export const getPostsByYear = memoize(_getPostsByYear)
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Group posts by their tags
|
|
582
|
+
*
|
|
583
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
584
|
+
* @returns Map where keys are tag names and values are arrays of posts with that tag
|
|
585
|
+
*/
|
|
586
|
+
async function _getPostsGroupByTags(lang?: Language) {
|
|
587
|
+
const posts = await getPosts(lang)
|
|
588
|
+
const tagMap = new Map<string, Post[]>()
|
|
589
|
+
|
|
590
|
+
posts.forEach((post: Post) => {
|
|
591
|
+
post.data.tags?.forEach((tag: string) => {
|
|
592
|
+
let tagPosts = tagMap.get(tag)
|
|
593
|
+
if (!tagPosts) {
|
|
594
|
+
tagPosts = []
|
|
595
|
+
tagMap.set(tag, tagPosts)
|
|
596
|
+
}
|
|
597
|
+
tagPosts.push(post)
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
return tagMap
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export const getPostsGroupByTags = memoize(_getPostsGroupByTags)
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Get all tags sorted by post count
|
|
608
|
+
*
|
|
609
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
610
|
+
* @returns Array of tags sorted by popularity (most posts first)
|
|
611
|
+
*/
|
|
612
|
+
async function _getAllTags(lang?: Language) {
|
|
613
|
+
const tagMap = await getPostsGroupByTags(lang)
|
|
614
|
+
const tagsWithCount = Array.from(tagMap.entries())
|
|
615
|
+
|
|
616
|
+
tagsWithCount.sort((a, b) => b[1].length - a[1].length)
|
|
617
|
+
return tagsWithCount.map(([tag]) => tag)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export const getAllTags = memoize(_getAllTags)
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Tag with post count interface
|
|
624
|
+
*/
|
|
625
|
+
export interface TagWithCount {
|
|
626
|
+
name: string
|
|
627
|
+
count: number
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get all tags with their post counts
|
|
632
|
+
*
|
|
633
|
+
* @param lang The language code to filter by
|
|
634
|
+
* @returns Array of tags with counts, sorted by popularity
|
|
635
|
+
*/
|
|
636
|
+
async function _getTagsWithCounts(lang?: Language): Promise<TagWithCount[]> {
|
|
637
|
+
const tagMap = await getPostsGroupByTags(lang)
|
|
638
|
+
return Array.from(tagMap.entries())
|
|
639
|
+
.map(([name, posts]) => ({ name, count: posts.length }))
|
|
640
|
+
.sort((a, b) => b.count - a.count)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export const getTagsWithCounts = memoize(_getTagsWithCounts)
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Get all posts that contain a specific tag
|
|
647
|
+
*
|
|
648
|
+
* @param tag The tag name to filter posts by
|
|
649
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
650
|
+
* @returns Array of posts that contain the specified tag
|
|
651
|
+
*/
|
|
652
|
+
async function _getPostsByTag(tag: string, lang?: Language) {
|
|
653
|
+
const tagMap = await getPostsGroupByTags(lang)
|
|
654
|
+
return tagMap.get(tag) ?? []
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export const getPostsByTag = memoize(_getPostsByTag)
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Check which languages support a specific tag
|
|
661
|
+
*
|
|
662
|
+
* @param tag The tag name to check language support for
|
|
663
|
+
* @returns Array of language codes that support the specified tag
|
|
664
|
+
*/
|
|
665
|
+
async function _getTagSupportedLangs(tag: string): Promise<Language[]> {
|
|
666
|
+
const groups = await getPostGroups()
|
|
667
|
+
return allLocales.filter(locale =>
|
|
668
|
+
groups.some(group => group.byLang[locale]?.data.tags?.includes(tag)),
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export const getTagSupportedLangs = memoize(_getTagSupportedLangs)
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Get posts filtered by category (tech, life, or science)
|
|
676
|
+
*
|
|
677
|
+
* @param category The category to filter by ('tech', 'life', or 'science')
|
|
678
|
+
* @param lang The language code to filter by, defaults to site's default language
|
|
679
|
+
* Note: Science category ignores language filtering and shows all posts
|
|
680
|
+
* @returns Posts filtered by category (and language for non-science), sorted by date
|
|
681
|
+
*/
|
|
682
|
+
async function _getPostsByCategory(category: PostCategory, lang?: Language) {
|
|
683
|
+
// Science category shows all posts regardless of language
|
|
684
|
+
if (category === 'science') {
|
|
685
|
+
const allPosts = await getCollection(
|
|
686
|
+
'posts',
|
|
687
|
+
({ data }: CollectionEntry<'posts'>) => {
|
|
688
|
+
return import.meta.env.DEV || !data.draft
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
const enhancedPosts = await Promise.all(allPosts.map(addMetaToPost))
|
|
692
|
+
return enhancedPosts
|
|
693
|
+
.filter(post => getPostCategory(post) === category)
|
|
694
|
+
.sort((a, b) => b.data.published.valueOf() - a.data.published.valueOf())
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Tech and Life categories filter by language
|
|
698
|
+
const posts = await getPosts(lang)
|
|
699
|
+
return posts.filter(post => getPostCategory(post) === category)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export const getPostsByCategory = memoize(_getPostsByCategory)
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Category with post count interface
|
|
706
|
+
*/
|
|
707
|
+
export interface CategoryWithCount {
|
|
708
|
+
name: PostCategory
|
|
709
|
+
count: number
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Get all categories with their post counts
|
|
714
|
+
*/
|
|
715
|
+
async function _getCategoriesWithCounts(lang?: Language): Promise<CategoryWithCount[]> {
|
|
716
|
+
const categories: PostCategory[] = ['tech', 'life', 'science']
|
|
717
|
+
const results = await Promise.all(
|
|
718
|
+
categories.map(async cat => ({
|
|
719
|
+
name: cat,
|
|
720
|
+
count: (await getPostsByCategory(cat, lang)).length,
|
|
721
|
+
})),
|
|
722
|
+
)
|
|
723
|
+
return results.sort((a, b) => b.count - a.count)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export const getCategoriesWithCounts = memoize(_getCategoriesWithCounts)
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Check which languages support a specific category
|
|
730
|
+
*
|
|
731
|
+
* @param category The category to check language support for
|
|
732
|
+
* @returns Array of language codes that have posts in the specified category
|
|
733
|
+
*/
|
|
734
|
+
async function _getCategorySupportedLangs(category: PostCategory): Promise<Language[]> {
|
|
735
|
+
const posts = await getCollection(
|
|
736
|
+
'posts',
|
|
737
|
+
({ data }) => !data.draft,
|
|
738
|
+
)
|
|
739
|
+
const { allLocales } = await import('@/config')
|
|
740
|
+
|
|
741
|
+
return allLocales.filter(locale =>
|
|
742
|
+
posts.some((post) => {
|
|
743
|
+
const postCategory = getPostCategory(post)
|
|
744
|
+
return postCategory === category
|
|
745
|
+
&& (post.data.lang === locale || post.data.lang === '')
|
|
746
|
+
}),
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export const getCategorySupportedLangs = memoize(_getCategorySupportedLangs)
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* User-defined category with post count interface
|
|
754
|
+
* These are categories defined in post frontmatter, not computed categories
|
|
755
|
+
*/
|
|
756
|
+
export interface UserCategoryWithCount {
|
|
757
|
+
name: string
|
|
758
|
+
count: number
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Group posts by their user-defined categories (from frontmatter)
|
|
763
|
+
*
|
|
764
|
+
* @param lang The language code to filter by
|
|
765
|
+
* @returns Map where keys are category names and values are arrays of posts
|
|
766
|
+
*/
|
|
767
|
+
async function _getPostsGroupByUserCategories(lang?: Language) {
|
|
768
|
+
const posts = await getPosts(lang)
|
|
769
|
+
const categoryMap = new Map<string, Post[]>()
|
|
770
|
+
|
|
771
|
+
posts.forEach((post: Post) => {
|
|
772
|
+
const categories = post.data.categories || []
|
|
773
|
+
// Handle both string and array formats
|
|
774
|
+
const categoryList = Array.isArray(categories) ? categories : [categories]
|
|
775
|
+
|
|
776
|
+
categoryList.forEach((category: string) => {
|
|
777
|
+
if (!category) return
|
|
778
|
+
let categoryPosts = categoryMap.get(category)
|
|
779
|
+
if (!categoryPosts) {
|
|
780
|
+
categoryPosts = []
|
|
781
|
+
categoryMap.set(category, categoryPosts)
|
|
782
|
+
}
|
|
783
|
+
categoryPosts.push(post)
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
return categoryMap
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export const getPostsGroupByUserCategories = memoize(_getPostsGroupByUserCategories)
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Get all user-defined categories with their post counts
|
|
794
|
+
*
|
|
795
|
+
* @param lang The language code to filter by
|
|
796
|
+
* @returns Array of categories with counts, sorted by popularity
|
|
797
|
+
*/
|
|
798
|
+
async function _getUserCategoriesWithCounts(lang?: Language): Promise<UserCategoryWithCount[]> {
|
|
799
|
+
const categoryMap = await getPostsGroupByUserCategories(lang)
|
|
800
|
+
return Array.from(categoryMap.entries())
|
|
801
|
+
.map(([name, posts]) => ({ name, count: posts.length }))
|
|
802
|
+
.sort((a, b) => b.count - a.count)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export const getUserCategoriesWithCounts = memoize(_getUserCategoriesWithCounts)
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Get all posts that belong to a specific user-defined category
|
|
809
|
+
*
|
|
810
|
+
* @param category The category name to filter posts by
|
|
811
|
+
* @param lang The language code to filter by
|
|
812
|
+
* @returns Array of posts in the specified category
|
|
813
|
+
*/
|
|
814
|
+
async function _getPostsByUserCategory(category: string, lang?: Language) {
|
|
815
|
+
const categoryMap = await getPostsGroupByUserCategories(lang)
|
|
816
|
+
return categoryMap.get(category) ?? []
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export const getPostsByUserCategory = memoize(_getPostsByUserCategory)
|