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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +168 -0
  3. package/astro.config.ts +18 -0
  4. package/default-config.yaml +136 -0
  5. package/discover-collections.ts +160 -0
  6. package/integration.ts +394 -0
  7. package/package.json +105 -0
  8. package/patches/@qwik.dev__partytown@0.11.2.patch +98 -0
  9. package/public/_redirects +819 -0
  10. package/public/feeds/atom-style.xsl +105 -0
  11. package/public/feeds/rss-style.xsl +105 -0
  12. package/public/fonts/EarlySummer-VF-Split/00785494587e3487ac63a0e7e4fa30f0.woff2 +0 -0
  13. package/public/fonts/EarlySummer-VF-Split/08e5d941a4c76fad7b68e7a937ebb21f.woff2 +0 -0
  14. package/public/fonts/EarlySummer-VF-Split/1268e5072156188d601f1eeb4473655d.woff2 +0 -0
  15. package/public/fonts/EarlySummer-VF-Split/12a385475353c815d7a5add53ee51e37.woff2 +0 -0
  16. package/public/fonts/EarlySummer-VF-Split/12b11ca08223c65a21fc731d59dcfc11.woff2 +0 -0
  17. package/public/fonts/EarlySummer-VF-Split/16d6676d3cb645c520ee6df8a1f89afd.woff2 +0 -0
  18. package/public/fonts/EarlySummer-VF-Split/2912a75ffef95e7a5ae9e2b2311ad61d.woff2 +0 -0
  19. package/public/fonts/EarlySummer-VF-Split/298d96ea561e419a4104bc9fc18499ce.woff2 +0 -0
  20. package/public/fonts/EarlySummer-VF-Split/2a2c71acc17ec39f6780835899e53096.woff2 +0 -0
  21. package/public/fonts/EarlySummer-VF-Split/2a7e2d0e59d3f638074c50fab39fdef1.woff2 +0 -0
  22. package/public/fonts/EarlySummer-VF-Split/36931fc4370e1670ed76af5d3feccba2.woff2 +0 -0
  23. package/public/fonts/EarlySummer-VF-Split/3a68fdc792e4a9e0399a04e32d0cc2e3.woff2 +0 -0
  24. package/public/fonts/EarlySummer-VF-Split/4054d6a4d6b37719b51e0f71da6e7cd9.woff2 +0 -0
  25. package/public/fonts/EarlySummer-VF-Split/429cb25f825c3cbde6bfac5b36ae9675.woff2 +0 -0
  26. package/public/fonts/EarlySummer-VF-Split/42a9efc11298368ecdc1b85ab46f0b4f.woff2 +0 -0
  27. package/public/fonts/EarlySummer-VF-Split/432018d2bdc9df92a7662056eb2b1261.woff2 +0 -0
  28. package/public/fonts/EarlySummer-VF-Split/44a6fb782f2a01560faa0f95248b60ef.woff2 +0 -0
  29. package/public/fonts/EarlySummer-VF-Split/45367b060e8ba0aa2507e6b91b86620b.woff2 +0 -0
  30. package/public/fonts/EarlySummer-VF-Split/571db7564bda7c1a93542881b8976f4b.woff2 +0 -0
  31. package/public/fonts/EarlySummer-VF-Split/58d55eeef4cf455e86a1142b1f3110d3.woff2 +0 -0
  32. package/public/fonts/EarlySummer-VF-Split/59ea41e77309160a0f63cdc76a010202.woff2 +0 -0
  33. package/public/fonts/EarlySummer-VF-Split/5d19d9174e568db4755981aa2e4ab380.woff2 +0 -0
  34. package/public/fonts/EarlySummer-VF-Split/5e811eb3b4175ee93d7ec000bf4631c2.woff2 +0 -0
  35. package/public/fonts/EarlySummer-VF-Split/6268e0cd5d66d6fe05b331f259e7b9e4.woff2 +0 -0
  36. package/public/fonts/EarlySummer-VF-Split/6549844aa3d833ca06a68a8e839db465.woff2 +0 -0
  37. package/public/fonts/EarlySummer-VF-Split/714b459658a7321ceeb1e1386ce165c2.woff2 +0 -0
  38. package/public/fonts/EarlySummer-VF-Split/7511d97a469915013683eae06cb21cd9.woff2 +0 -0
  39. package/public/fonts/EarlySummer-VF-Split/7784b4ebe543d13f62f6f6e05beb0b2e.woff2 +0 -0
  40. package/public/fonts/EarlySummer-VF-Split/77c9bea70b3c6ab24e1497d5468c825b.woff2 +0 -0
  41. package/public/fonts/EarlySummer-VF-Split/789ebea9e81df623e930b86de98fbfab.woff2 +0 -0
  42. package/public/fonts/EarlySummer-VF-Split/885bb7ab0717e8a47fc17f953adcdbf1.woff2 +0 -0
  43. package/public/fonts/EarlySummer-VF-Split/896c58aff69a9a857764cee0663bc56d.woff2 +0 -0
  44. package/public/fonts/EarlySummer-VF-Split/8fb6fc01c59d1e3ad1910b58dec7f5e7.woff2 +0 -0
  45. package/public/fonts/EarlySummer-VF-Split/95be5462b91b9a0458797cdc89d94cb5.woff2 +0 -0
  46. package/public/fonts/EarlySummer-VF-Split/9a5b2724f983ca0fc0d5ff8d10c41396.woff2 +0 -0
  47. package/public/fonts/EarlySummer-VF-Split/9ffe17f9c0e4cc4356cb3f08ffdb9c6d.woff2 +0 -0
  48. package/public/fonts/EarlySummer-VF-Split/EarlySummer-VF-Subset.woff2 +0 -0
  49. package/public/fonts/EarlySummer-VF-Split/EarlySummerSerif License.txt +91 -0
  50. package/public/fonts/EarlySummer-VF-Split/a097ef49be62cd2565aca45600e1e3ac.woff2 +0 -0
  51. package/public/fonts/EarlySummer-VF-Split/a17a1ae6063088e5b3a48c06b816929a.woff2 +0 -0
  52. package/public/fonts/EarlySummer-VF-Split/a83fdcfc5ecf2f6996704b0c02758689.woff2 +0 -0
  53. package/public/fonts/EarlySummer-VF-Split/a8cf15ff9b71e59407d8406866ff6f99.woff2 +0 -0
  54. package/public/fonts/EarlySummer-VF-Split/af530ed51dd519e4456f8a5e259e908b.woff2 +0 -0
  55. package/public/fonts/EarlySummer-VF-Split/b195a8924915deec4aa9c3ec777cc93f.woff2 +0 -0
  56. package/public/fonts/EarlySummer-VF-Split/b4b6bb5df9239dd67b52ca858fd2a506.woff2 +0 -0
  57. package/public/fonts/EarlySummer-VF-Split/b7592e1e027923f19e0e55dfdac69668.woff2 +0 -0
  58. package/public/fonts/EarlySummer-VF-Split/b965859f69d8ccceaf0e2d6292afbcfb.woff2 +0 -0
  59. package/public/fonts/EarlySummer-VF-Split/bbe9333f1ff242bd96ecb23ff9e723b1.woff2 +0 -0
  60. package/public/fonts/EarlySummer-VF-Split/be758580e295339ea98f0240b9869f24.woff2 +0 -0
  61. package/public/fonts/EarlySummer-VF-Split/c07099e1d025617f6d40966986e1941b.woff2 +0 -0
  62. package/public/fonts/EarlySummer-VF-Split/c1b593dda62fdeb7dde3af02016da282.woff2 +0 -0
  63. package/public/fonts/EarlySummer-VF-Split/c89f0335910a68a0958f2846108370e8.woff2 +0 -0
  64. package/public/fonts/EarlySummer-VF-Split/ca49aa409fdedd3f2f894cd20a16640a.woff2 +0 -0
  65. package/public/fonts/EarlySummer-VF-Split/ccd4a28d2f63797e0183c87792e20b75.woff2 +0 -0
  66. package/public/fonts/EarlySummer-VF-Split/d2718da923fce8e7ea229d65e306e92c.woff2 +0 -0
  67. package/public/fonts/EarlySummer-VF-Split/d893e9b307d96041e9cfcbd03761b9f4.woff2 +0 -0
  68. package/public/fonts/EarlySummer-VF-Split/dafaedaee41b75e21479d4ff324b6a34.woff2 +0 -0
  69. package/public/fonts/EarlySummer-VF-Split/db392af65f1867e5fd580eed2195df99.woff2 +0 -0
  70. package/public/fonts/EarlySummer-VF-Split/dc7c73a9e5577143ccd11e05ab55cb39.woff2 +0 -0
  71. package/public/fonts/EarlySummer-VF-Split/de396881189f747eba67685298363242.woff2 +0 -0
  72. package/public/fonts/EarlySummer-VF-Split/df625b213228bba22a7733d4eff8f148.woff2 +0 -0
  73. package/public/fonts/EarlySummer-VF-Split/e6e60b384f220b893ef31a926ece829a.woff2 +0 -0
  74. package/public/fonts/EarlySummer-VF-Split/e6e8ce2c5972ab665630bb705383d0fb.woff2 +0 -0
  75. package/public/fonts/EarlySummer-VF-Split/e963c7ed7104c2d6d68fcb5f952fe2f5.woff2 +0 -0
  76. package/public/fonts/EarlySummer-VF-Split/e966b23b4cd7783f43e31032d41784f4.woff2 +0 -0
  77. package/public/fonts/EarlySummer-VF-Split/edaac57c3856ec13128f4c6c3e00975c.woff2 +0 -0
  78. package/public/fonts/EarlySummer-VF-Split/ee54e0d86edf068c6c9cbddb76a856fe.woff2 +0 -0
  79. package/public/fonts/EarlySummer-VF-Split/f612c78a5544ff2dd3e8296ac3e58344.woff2 +0 -0
  80. package/public/fonts/EarlySummer-VF-Split/f9e539bd9b7bf999c3da82f5403ec3b6.woff2 +0 -0
  81. package/public/fonts/EarlySummer-VF-Split/fa5863b923ac15993c52a619f699ee63.woff2 +0 -0
  82. package/public/fonts/EarlySummer-VF-Split/fc759e56ec6f6e6d3d4cb163d62fb557.woff2 +0 -0
  83. package/public/fonts/Font Subset List/CJK Common Characters.txt +7534 -0
  84. package/public/fonts/Font Subset List/EarlySummer Subset.txt +3 -0
  85. package/public/fonts/Font Subset List/Japanese Kana + Korean Letters.txt +6123 -0
  86. package/public/fonts/Font Subset List/Latin + Cyrillic + Greek + Arabic Glyphs.txt +121 -0
  87. package/public/fonts/Font Subset List/unicode_range.py +49 -0
  88. package/public/fonts/NotoSansSC-Bold.otf +0 -0
  89. package/public/fonts/NotoSansSC-Regular.otf +0 -0
  90. package/public/fonts/STIX-Italic-VF.woff2 +0 -0
  91. package/public/fonts/STIX-VF.woff2 +0 -0
  92. package/public/fonts/Snell-Black-SF.woff2 +0 -0
  93. package/public/fonts/Snell-Bold-SF.woff2 +0 -0
  94. package/public/giscus/theme-dark.css +208 -0
  95. package/public/giscus/theme-light.css +208 -0
  96. package/public/icons/favicon.svg +4 -0
  97. package/public/icons/og-logo.png +0 -0
  98. package/public/robots.txt +4 -0
  99. package/public/sounds/tap_01.wav +0 -0
  100. package/public/sounds/tap_02.wav +0 -0
  101. package/public/sounds/tap_03.wav +0 -0
  102. package/public/sounds/tap_04.wav +0 -0
  103. package/public/sounds/tap_05.wav +0 -0
  104. package/public/sounds/type_01.wav +0 -0
  105. package/public/sounds/type_02.wav +0 -0
  106. package/public/sounds/type_03.wav +0 -0
  107. package/public/sounds/type_04.wav +0 -0
  108. package/public/sounds/type_05.wav +0 -0
  109. package/scripts/apply-lqip.ts +276 -0
  110. package/scripts/format-posts.ts +105 -0
  111. package/scripts/migration/README.md +52 -0
  112. package/scripts/migration/migrate-hexo.ts +185 -0
  113. package/scripts/migration/validate-abbrlinks.ts +161 -0
  114. package/scripts/new-post.ts +52 -0
  115. package/scripts/seo/generate-legacy-redirects.ts +407 -0
  116. package/scripts/update-theme.ts +46 -0
  117. package/src/assets/icons/copy-check.svg +3 -0
  118. package/src/assets/icons/copy-icon.svg +4 -0
  119. package/src/assets/icons/go-back.svg +3 -0
  120. package/src/assets/icons/heading-anchor.svg +4 -0
  121. package/src/assets/icons/lang-en.svg +3 -0
  122. package/src/assets/icons/lang-ja.svg +5 -0
  123. package/src/assets/icons/lang-zh.svg +5 -0
  124. package/src/assets/icons/language-switcher.svg +3 -0
  125. package/src/assets/icons/pin-icon.svg +3 -0
  126. package/src/assets/icons/search-icon.svg +3 -0
  127. package/src/assets/icons/theme-toggle.svg +3 -0
  128. package/src/assets/icons/toc-icon.svg +3 -0
  129. package/src/assets/icons/top-icon.svg +3 -0
  130. package/src/assets/lqip-map.json +10 -0
  131. package/src/components/Button.astro +152 -0
  132. package/src/components/CategoryList.astro +66 -0
  133. package/src/components/Comment/Giscus.astro +119 -0
  134. package/src/components/Comment/Index.astro +30 -0
  135. package/src/components/Comment/Twikoo.astro +114 -0
  136. package/src/components/Comment/Waline.astro +149 -0
  137. package/src/components/FloatingButtons.astro +101 -0
  138. package/src/components/Footer.astro +74 -0
  139. package/src/components/Header.astro +62 -0
  140. package/src/components/JournalList.astro +56 -0
  141. package/src/components/Navbar.astro +69 -0
  142. package/src/components/NoteList.astro +56 -0
  143. package/src/components/Pagination.astro +267 -0
  144. package/src/components/PostDate.astro +80 -0
  145. package/src/components/PostList.astro +87 -0
  146. package/src/components/SearchModal.astro +340 -0
  147. package/src/components/TagList.astro +135 -0
  148. package/src/components/Widgets/BackButton.astro +43 -0
  149. package/src/components/Widgets/CodeCopyButton.astro +47 -0
  150. package/src/components/Widgets/GithubCard.astro +110 -0
  151. package/src/components/Widgets/ImageZoom.astro +135 -0
  152. package/src/components/Widgets/MediaEmbed.astro +127 -0
  153. package/src/components/Widgets/SoundEffect.astro +179 -0
  154. package/src/components/Widgets/TOC.astro +198 -0
  155. package/src/config-schema.ts +164 -0
  156. package/src/config.ts +127 -0
  157. package/src/config.ts.example +205 -0
  158. package/src/content/about/_example-about-en.md +6 -0
  159. package/src/content/about/about-en.md +21 -0
  160. package/src/content/about/about-ja.md +21 -0
  161. package/src/content/about/about-zh.md +24 -0
  162. package/src/content.config.ts +247 -0
  163. package/src/env.d.ts +25 -0
  164. package/src/i18n/config.ts +65 -0
  165. package/src/i18n/lang.ts +70 -0
  166. package/src/i18n/path.ts +160 -0
  167. package/src/i18n/ui.ts +214 -0
  168. package/src/layouts/Head.astro +203 -0
  169. package/src/layouts/Layout.astro +69 -0
  170. package/src/pages/404.astro +20 -0
  171. package/src/pages/[...lang]/[...page].astro +48 -0
  172. package/src/pages/[...lang]/about.astro +28 -0
  173. package/src/pages/[...lang]/atom.xml.ts +14 -0
  174. package/src/pages/[...lang]/categories/index.astro +35 -0
  175. package/src/pages/[...lang]/journals/[slug].astro +89 -0
  176. package/src/pages/[...lang]/journals/index.astro +55 -0
  177. package/src/pages/[...lang]/journals/page/[page].astro +66 -0
  178. package/src/pages/[...lang]/notes/[slug].astro +88 -0
  179. package/src/pages/[...lang]/notes/index.astro +55 -0
  180. package/src/pages/[...lang]/notes/page/[page].astro +66 -0
  181. package/src/pages/[...lang]/posts/[slug].astro +101 -0
  182. package/src/pages/[...lang]/rss.xml.ts +14 -0
  183. package/src/pages/[...lang]/search.astro +65 -0
  184. package/src/pages/[...lang]/tags/[tag].astro +53 -0
  185. package/src/pages/[...lang]/tags/index.astro +36 -0
  186. package/src/pages/_dynamic/list.astro +101 -0
  187. package/src/pages/_dynamic/slug.astro +100 -0
  188. package/src/pages/og/[...image].ts +114 -0
  189. package/src/pages/robots.txt.ts +20 -0
  190. package/src/plugins/rehype-code-copy-button.mjs +82 -0
  191. package/src/plugins/rehype-external-links.mjs +18 -0
  192. package/src/plugins/rehype-heading-anchor.mjs +55 -0
  193. package/src/plugins/rehype-image-processor.mjs +77 -0
  194. package/src/plugins/remark-container-directives.mjs +135 -0
  195. package/src/plugins/remark-leaf-directives.mjs +184 -0
  196. package/src/plugins/remark-reading-time.mjs +11 -0
  197. package/src/styles/comment.css +205 -0
  198. package/src/styles/extension.css +180 -0
  199. package/src/styles/font.css +111 -0
  200. package/src/styles/global.css +91 -0
  201. package/src/styles/lqip.css +71 -0
  202. package/src/styles/markdown.css +276 -0
  203. package/src/styles/transition.css +173 -0
  204. package/src/types/global.d.ts +22 -0
  205. package/src/types/index.d.ts +111 -0
  206. package/src/utils/cache.ts +32 -0
  207. package/src/utils/content.ts +819 -0
  208. package/src/utils/description.ts +147 -0
  209. package/src/utils/dynamic-collections.ts +155 -0
  210. package/src/utils/feed.ts +238 -0
  211. package/src/utils/page.ts +107 -0
  212. package/tsconfig.json +13 -0
  213. 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)