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,147 @@
1
+ import type { CollectionEntry } from 'astro:content'
2
+ import type { Language } from '@/i18n/config'
3
+ import MarkdownIt from 'markdown-it'
4
+ import { defaultLocale } from '@/config'
5
+
6
+ type ExcerptScene = 'list' | 'meta' | 'og' | 'feed'
7
+
8
+ const markdownParser = new MarkdownIt()
9
+ const excerptLengths: Record<ExcerptScene, { cjk: number, other: number }> = {
10
+ list: {
11
+ cjk: 120,
12
+ other: 240,
13
+ },
14
+ meta: {
15
+ cjk: 120,
16
+ other: 240,
17
+ },
18
+ og: {
19
+ cjk: 70,
20
+ other: 140,
21
+ },
22
+ feed: {
23
+ cjk: 70,
24
+ other: 140,
25
+ },
26
+ }
27
+
28
+ const htmlEntityMap: Record<string, string> = {
29
+ '&lt;': '<',
30
+ '&gt;': '>',
31
+ '&amp;': '&',
32
+ '&quot;': '"',
33
+ '&apos;': '\'',
34
+ '&nbsp;': ' ',
35
+ }
36
+
37
+ // Cleans text by removing HTML tags and normalizing whitespace
38
+ function cleanTextContent(text: string): string {
39
+ // Remove HTML tags
40
+ let cleanText = text.replace(/<[^>]*>/g, '')
41
+
42
+ // Decode HTML entities
43
+ Object.entries(htmlEntityMap).forEach(([entity, char]) => {
44
+ cleanText = cleanText.replace(new RegExp(entity, 'g'), char)
45
+ })
46
+
47
+ // Normalize whitespace
48
+ cleanText = cleanText.replace(/\s+/g, ' ')
49
+
50
+ // Normalize CJK punctuation spacing
51
+ cleanText = cleanText.replace(/([。?!:"」』])\s+/g, '$1')
52
+
53
+ return cleanText.trim()
54
+ }
55
+
56
+ // Creates a clean text excerpt with length limits by language and scene
57
+ function getExcerpt(text: string, lang: Language, scene: ExcerptScene): string {
58
+ const isCJK = (lang: Language) => ['zh', 'zh-tw', 'ja', 'ko'].includes(lang)
59
+ const length = isCJK(lang)
60
+ ? excerptLengths[scene].cjk
61
+ : excerptLengths[scene].other
62
+
63
+ const cleanText = cleanTextContent(text)
64
+ const excerpt = cleanText.slice(0, length).trim()
65
+
66
+ // Remove trailing punctuation and add ellipsis
67
+ if (cleanText.length > length) {
68
+ return `${excerpt.replace(/\p{P}+$/u, '')}...`
69
+ }
70
+
71
+ return excerpt
72
+ }
73
+
74
+ // Generates post description from existing description or content
75
+ type DescribableEntry = {
76
+ data: {
77
+ description?: string
78
+ lang?: string
79
+ }
80
+ body?: string
81
+ }
82
+
83
+ function getEntryDescription(
84
+ entry: DescribableEntry,
85
+ scene: ExcerptScene,
86
+ ): string {
87
+ const lang = (entry.data.lang || defaultLocale) as Language
88
+
89
+ if (entry.data.description) {
90
+ // Only truncate for og scene, return full description for other scenes
91
+ return scene === 'og'
92
+ ? getExcerpt(entry.data.description, lang, scene)
93
+ : entry.data.description
94
+ }
95
+
96
+ const rawContent = entry.body || ''
97
+
98
+ // Check for <!-- more --> marker (Hexo-style excerpt boundary)
99
+ const moreMarkerRegex = /<!--\s*more\s*-->/i
100
+ const moreMatch = rawContent.match(moreMarkerRegex)
101
+ const hasMoreMarker = moreMatch && moreMatch.index !== undefined
102
+
103
+ // Get content to process (before <!-- more --> if exists)
104
+ let contentToProcess = rawContent
105
+ if (hasMoreMarker) {
106
+ contentToProcess = rawContent.substring(0, moreMatch.index)
107
+ }
108
+
109
+ const cleanContent = contentToProcess
110
+ .replace(/<!--[\s\S]*?-->/g, '') // Remove remaining HTML comments
111
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
112
+ .replace(/^\s*#{1,6}\s+\S.*$/gm, '') // Remove Markdown headings
113
+ .replace(/^\s*::.*$/gm, '') // Remove directive containers
114
+ .replace(/^\s*>\s*\[!.*\]$/gm, '') // Remove GitHub admonition markers
115
+ .replace(/\n{2,}/g, '\n\n') // Normalize newlines
116
+
117
+ const renderedContent = markdownParser.render(cleanContent)
118
+
119
+ // For 'list' scene with <!-- more --> marker, return full content without truncation
120
+ if (scene === 'list' && hasMoreMarker) {
121
+ return cleanTextContent(renderedContent)
122
+ }
123
+
124
+ // Otherwise, apply truncation
125
+ return getExcerpt(renderedContent, lang, scene)
126
+ }
127
+
128
+ export function getPostDescription(
129
+ post: CollectionEntry<'posts'>,
130
+ scene: ExcerptScene,
131
+ ): string {
132
+ return getEntryDescription(post, scene)
133
+ }
134
+
135
+ export function getNoteDescription(
136
+ note: CollectionEntry<'notes'>,
137
+ scene: ExcerptScene,
138
+ ): string {
139
+ return getEntryDescription(note, scene)
140
+ }
141
+
142
+ export function getJournalDescription(
143
+ journal: CollectionEntry<'journals'>,
144
+ scene: ExcerptScene,
145
+ ): string {
146
+ return getEntryDescription(journal, scene)
147
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Generic accessors for dynamically-registered content collections.
3
+ *
4
+ * Every folder discovered under `content/` (excluding built-ins and
5
+ * underscore-prefixed folders) is registered with the same schema as `posts`.
6
+ * Templates in `src/pages/_dynamic/` use these helpers to fetch and group
7
+ * entries the same way the posts/notes/journals utilities do — minus the
8
+ * tag/category bookkeeping that only makes sense for posts.
9
+ */
10
+
11
+ import type { CollectionEntry } from 'astro:content'
12
+ import type { Language } from '@/i18n/config'
13
+ import { getCollection, render } from 'astro:content'
14
+ import { allLocales, defaultLocale } from '@/config'
15
+
16
+ // All dynamic collections share the same loader+schema as `posts`, so a
17
+ // CollectionEntry<'posts'> is structurally identical. Cast through the
18
+ // `posts` slot so the rest of the codebase's types still line up.
19
+ export type DynamicEntry = CollectionEntry<'posts'>
20
+
21
+ export interface DynamicEntryWithMeta extends DynamicEntry {
22
+ remarkPluginFrontmatter: { minutes: number }
23
+ }
24
+
25
+ export interface DynamicGroup {
26
+ baseId: string
27
+ slug: string
28
+ supportedLangs: Language[]
29
+ byLang: Partial<Record<Language, DynamicEntry>>
30
+ }
31
+
32
+ function slugifyPathSegment(input: string): string {
33
+ return input
34
+ .normalize('NFKC')
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[\s_]+/g, '-')
38
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
39
+ .replace(/-+/g, '-')
40
+ .replace(/^-|-$/g, '')
41
+ }
42
+
43
+ function getBaseId(entry: DynamicEntry): string {
44
+ const id = entry.id.trim()
45
+ for (const lang of allLocales) {
46
+ const suffix = `.${lang}`
47
+ if (id.endsWith(suffix))
48
+ return id.slice(0, -suffix.length)
49
+ }
50
+ return id
51
+ }
52
+
53
+ /**
54
+ * Read entries from a dynamic collection. Returns `[]` if the collection
55
+ * is not registered (no folder, or `enabled: false`) instead of crashing —
56
+ * this matches the behaviour of the built-in note/journal utilities.
57
+ */
58
+ async function safeGetCollection(name: string): Promise<DynamicEntry[]> {
59
+ try {
60
+ const entries = await getCollection(
61
+ name as 'posts',
62
+ ({ data }: DynamicEntry) => !data.draft,
63
+ )
64
+ return entries
65
+ }
66
+ catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error)
68
+ if (
69
+ message.includes(`The collection "${name}" does not exist`)
70
+ || message.includes(`The collection "${name}" is empty`)
71
+ ) {
72
+ return []
73
+ }
74
+ throw error
75
+ }
76
+ }
77
+
78
+ export async function getDynamicGroups(collection: string): Promise<DynamicGroup[]> {
79
+ const entries = await safeGetCollection(collection)
80
+
81
+ const buckets = new Map<string, DynamicEntry[]>()
82
+ for (const entry of entries) {
83
+ const baseId = getBaseId(entry)
84
+ const list = buckets.get(baseId) ?? []
85
+ list.push(entry)
86
+ buckets.set(baseId, list)
87
+ }
88
+
89
+ const slugToBaseId = new Map<string, string>()
90
+ const results: DynamicGroup[] = []
91
+
92
+ for (const [baseId, members] of buckets) {
93
+ const slug = slugifyPathSegment(baseId) || baseId
94
+ const existing = slugToBaseId.get(slug)
95
+ if (existing && existing !== baseId) {
96
+ throw new Error(
97
+ `Duplicate slug "${slug}" in collection "${collection}" `
98
+ + `from "${existing}" and "${baseId}"`,
99
+ )
100
+ }
101
+ slugToBaseId.set(slug, baseId)
102
+
103
+ const baseEntry = members.find(e => !e.data.lang)
104
+ const zhEntry = members.find(e => e.data.lang === 'zh')
105
+ const enEntry = members.find(e => e.data.lang === 'en')
106
+ const jaEntry = members.find(e => e.data.lang === 'ja')
107
+
108
+ // Best-effort base-language inference, mirroring the post/note logic:
109
+ // a sole base file alongside an explicit `zh` translation is taken as en.
110
+ const inferredEnFromBase = !enEntry && !!zhEntry ? baseEntry : undefined
111
+ const inferredZhFromBase = !zhEntry ? baseEntry : undefined
112
+
113
+ const byLang: DynamicGroup['byLang'] = {
114
+ zh: zhEntry ?? inferredZhFromBase,
115
+ en: enEntry ?? inferredEnFromBase,
116
+ ja: jaEntry,
117
+ }
118
+
119
+ const supportedLangs = allLocales.filter(lang => byLang[lang])
120
+
121
+ results.push({ baseId, slug, supportedLangs, byLang })
122
+ }
123
+
124
+ return results
125
+ }
126
+
127
+ export function getDynamicSlug(entry: DynamicEntry): string {
128
+ return slugifyPathSegment(getBaseId(entry)) || getBaseId(entry)
129
+ }
130
+
131
+ async function attachMeta(entry: DynamicEntry): Promise<DynamicEntryWithMeta> {
132
+ const { remarkPluginFrontmatter } = await render(entry)
133
+ return {
134
+ ...entry,
135
+ remarkPluginFrontmatter: remarkPluginFrontmatter as { minutes: number },
136
+ }
137
+ }
138
+
139
+ export async function getDynamicEntries(
140
+ collection: string,
141
+ lang?: Language,
142
+ ): Promise<DynamicEntryWithMeta[]> {
143
+ const currentLang = lang && allLocales.includes(lang) ? lang : defaultLocale
144
+ const groups = await getDynamicGroups(collection)
145
+ const selected = groups
146
+ .map(group => group.byLang[currentLang])
147
+ .filter(Boolean) as DynamicEntry[]
148
+
149
+ const enhanced = await Promise.all(selected.map(attachMeta))
150
+
151
+ const sortKey = (entry: DynamicEntry) =>
152
+ (entry.data.updated ?? entry.data.published).valueOf()
153
+
154
+ return enhanced.sort((a, b) => sortKey(b) - sortKey(a))
155
+ }
@@ -0,0 +1,238 @@
1
+ import type { APIContext, ImageMetadata } from 'astro'
2
+ import type { Language } from '@/i18n/config'
3
+ import { getImage } from 'astro:assets'
4
+ import { Feed } from 'feed'
5
+ import MarkdownIt from 'markdown-it'
6
+ import { parse } from 'node-html-parser'
7
+ import sanitizeHtml from 'sanitize-html'
8
+ import { base, defaultLocale, themeConfig } from '@/config'
9
+ import { getPostPath } from '@/i18n/path'
10
+ import { ui } from '@/i18n/ui'
11
+ import { memoize } from '@/utils/cache'
12
+ import { getPosts, getPostSlug } from '@/utils/content'
13
+ import { getPostDescription } from '@/utils/description'
14
+
15
+ const markdownParser = new MarkdownIt()
16
+ const { title, description, i18nTitle, url, author } = themeConfig.site
17
+ const { follow } = themeConfig.seo ?? {}
18
+
19
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
20
+ // Dynamically import all images from /content/posts
21
+ const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
22
+ '/content/posts/**/*.{jpeg,jpg,png,gif,webp}',
23
+ )
24
+
25
+ /**
26
+ * Converts relative image paths to absolute URLs
27
+ *
28
+ * @param srcPath - Relative image path from markdown content
29
+ * @param baseUrl - Site base URL
30
+ * @returns Optimized image URL or null if processing fails
31
+ */
32
+ async function _getAbsoluteImageUrl(srcPath: string, baseUrl: string) {
33
+ // Remove relative path prefixes (../ and ./) from image source path
34
+ const prefixRemoved = srcPath.replace(/^(?:\.\.\/)+|^\.\//, '')
35
+ const absolutePath = `/content/posts/${prefixRemoved}`
36
+ const imageImporter = imagesGlob[absolutePath]
37
+
38
+ if (!imageImporter) {
39
+ return null
40
+ }
41
+
42
+ // Import image module and extract its metadata
43
+ const imageMetadata = await imageImporter()
44
+ .then(importedModule => importedModule.default)
45
+ .catch((error) => {
46
+ console.warn(`Failed to import image: ${absolutePath}`, error)
47
+ return null
48
+ })
49
+
50
+ if (!imageMetadata) {
51
+ return null
52
+ }
53
+
54
+ // Create optimized image from metadata
55
+ const optimizedImage = await getImage({ src: imageMetadata })
56
+ return new URL(optimizedImage.src, baseUrl).toString()
57
+ }
58
+
59
+ // Export memoized version
60
+ const getAbsoluteImageUrl = memoize(_getAbsoluteImageUrl)
61
+
62
+ /**
63
+ * Fix relative image paths in HTML content
64
+ *
65
+ * @param htmlContent HTML content string
66
+ * @param baseUrl Base URL of the site
67
+ * @returns Processed HTML string with all image paths converted to absolute URLs
68
+ */
69
+ async function fixRelativeImagePaths(htmlContent: string, baseUrl: string): Promise<string> {
70
+ const htmlDoc = parse(htmlContent)
71
+ const images = htmlDoc.getElementsByTagName('img')
72
+ const imagePromises = []
73
+
74
+ for (const img of images) {
75
+ const src = img.getAttribute('src')
76
+ if (!src) {
77
+ continue
78
+ }
79
+
80
+ imagePromises.push((async () => {
81
+ try {
82
+ // Skip absolute URLs and unsupported sources
83
+ if (!src.startsWith('./') && !src.startsWith('../') && !src.startsWith('_images/')) {
84
+ return
85
+ }
86
+
87
+ // Process images from content/posts
88
+ const absoluteImageUrl = await getAbsoluteImageUrl(src, baseUrl)
89
+ if (absoluteImageUrl) {
90
+ img.setAttribute('src', absoluteImageUrl)
91
+ }
92
+ }
93
+ catch (error) {
94
+ console.warn(`Failed to convert relative image path to absolute URL: ${src}`, error)
95
+ }
96
+ })())
97
+ }
98
+
99
+ await Promise.all(imagePromises)
100
+
101
+ return htmlDoc.toString()
102
+ }
103
+
104
+ /**
105
+ * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
106
+ * Generate a feed object supporting both RSS and Atom formats
107
+ *
108
+ * @param options Feed generation options
109
+ * @param options.lang Optional language code
110
+ * @returns A Feed instance ready for RSS or Atom output
111
+ */
112
+ export async function generateFeed({ lang }: { lang?: Language } = {}) {
113
+ const currentUI = ui[lang as keyof typeof ui] ?? ui[defaultLocale as keyof typeof ui] ?? {}
114
+ const siteURL = lang ? `${url}${base}/${lang}` : `${url}${base}/`
115
+
116
+ // Create Feed instance
117
+ const feed = new Feed({
118
+ title: i18nTitle ? currentUI.title : title,
119
+ description: i18nTitle ? currentUI.description : description,
120
+ id: siteURL,
121
+ link: siteURL,
122
+ language: lang ?? themeConfig.global.locale,
123
+ copyright: `Copyright © ${new Date().getFullYear()} ${author}`,
124
+ updated: new Date(),
125
+ generator: 'Astro-Theme-Retypeset with Feed for Node.js',
126
+
127
+ feedLinks: {
128
+ rss: new URL(lang ? `${base}/${lang}/rss.xml` : `${base}/rss.xml`, url).toString(),
129
+ atom: new URL(lang ? `${base}/${lang}/atom.xml` : `${base}/atom.xml`, url).toString(),
130
+ },
131
+
132
+ author: {
133
+ name: author,
134
+ link: `${url}${base}/`,
135
+ },
136
+ })
137
+
138
+ // Language-aware selection (includes inferred base-language posts)
139
+ const recentPosts = (await getPosts(lang))
140
+ .slice(0, 25)
141
+
142
+ // Add posts to feed
143
+ for (const post of recentPosts) {
144
+ const slug = getPostSlug(post)
145
+ const link = new URL(
146
+ getPostPath(slug, (lang ?? defaultLocale) as Language),
147
+ url,
148
+ ).toString()
149
+
150
+ // Optimize content processing
151
+ const postContent = post.body
152
+ ? sanitizeHtml(
153
+ await fixRelativeImagePaths(
154
+ // Remove HTML comments before rendering markdown
155
+ markdownParser.render(post.body.replace(/<!--[\s\S]*?-->/g, '')),
156
+ `${url}${base}/`,
157
+ ),
158
+ {
159
+ // Allow <img> tags in feed content
160
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
161
+ },
162
+ )
163
+ : ''
164
+
165
+ // publishDate -> Atom:<published>, RSS:<pubDate>
166
+ const publishDate = new Date(post.data.published)
167
+ // updateDate -> Atom:<updated>, RSS has no update tag
168
+ const updateDate = post.data.updated ? new Date(post.data.updated) : publishDate
169
+
170
+ feed.addItem({
171
+ title: post.data.title,
172
+ id: link,
173
+ link,
174
+ description: getPostDescription(post, 'feed'),
175
+ content: postContent,
176
+ author: [{
177
+ name: author,
178
+ link: `${url}${base}/`,
179
+ }],
180
+ published: publishDate,
181
+ date: updateDate,
182
+ })
183
+ }
184
+
185
+ // Add follow verification if available
186
+ if (follow?.feedID && follow?.userID) {
187
+ feed.addExtension({
188
+ name: 'follow_challenge',
189
+ objects: {
190
+ feedId: follow.feedID,
191
+ userId: follow.userID,
192
+ },
193
+ })
194
+ }
195
+
196
+ return feed
197
+ }
198
+
199
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
200
+ // Generate RSS 2.0 format feed
201
+ export async function generateRSS(context: APIContext) {
202
+ const feed = await generateFeed({
203
+ lang: context.params?.lang as Language | undefined,
204
+ })
205
+
206
+ // Add XSLT stylesheet to RSS feed
207
+ let rssXml = feed.rss2()
208
+ rssXml = rssXml.replace(
209
+ '<?xml version="1.0" encoding="utf-8"?>',
210
+ `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet href="${base}/feeds/rss-style.xsl" type="text/xsl"?>`,
211
+ )
212
+
213
+ return new Response(rssXml, {
214
+ headers: {
215
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
216
+ },
217
+ })
218
+ }
219
+
220
+ // Generate Atom 1.0 format feed
221
+ export async function generateAtom(context: APIContext) {
222
+ const feed = await generateFeed({
223
+ lang: context.params?.lang as Language | undefined,
224
+ })
225
+
226
+ // Add XSLT stylesheet to Atom feed
227
+ let atomXml = feed.atom1()
228
+ atomXml = atomXml.replace(
229
+ '<?xml version="1.0" encoding="utf-8"?>',
230
+ `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet href="${base}/feeds/atom-style.xsl" type="text/xsl"?>`,
231
+ )
232
+
233
+ return new Response(atomXml, {
234
+ headers: {
235
+ 'Content-Type': 'application/atom+xml; charset=utf-8',
236
+ },
237
+ })
238
+ }
@@ -0,0 +1,107 @@
1
+ import { base, moreLocales } from '@/config'
2
+ import { getLangFromPath } from '@/i18n/lang'
3
+ import { getLocalizedPath } from '@/i18n/path'
4
+
5
+ function stripHtmlExtension(path: string): string {
6
+ return path.endsWith('.html') ? path.slice(0, -'.html'.length) : path
7
+ }
8
+
9
+ // Determine if the path matches a specific page type
10
+ function matchPageType(path: string, prefix: string = '') {
11
+ // Remove base path if configured
12
+ const pathWithoutBase = base && path.startsWith(base)
13
+ ? path.slice(base.length)
14
+ : path
15
+
16
+ // Remove leading and trailing slashes from the path
17
+ const normalizedPath = stripHtmlExtension(pathWithoutBase.replace(/^\/|\/$/g, ''))
18
+
19
+ // Homepage check: matches root path ('') or language code ('en')
20
+ //
21
+ // Astro 6 with `build.format: 'file'` + `trailingSlash: 'never'` writes
22
+ // `dist/index.html` and reports `Astro.url.pathname === '/index.html'`
23
+ // for the homepage (Astro 5 used '/'). Normalising past the `.html` strip
24
+ // leaves us with 'index' or '<lang>/index' for the localised variants;
25
+ // treat those as homepage too so excerpt rendering keeps working.
26
+ if (prefix === '') {
27
+ if (normalizedPath === '' || normalizedPath === 'index') {
28
+ return true
29
+ }
30
+
31
+ const locales = moreLocales as readonly string[]
32
+ if (locales.includes(normalizedPath)) {
33
+ return true
34
+ }
35
+ if (locales.some(lang => normalizedPath === `${lang}/index`)) {
36
+ return true
37
+ }
38
+
39
+ // Pagination pages: /2, /3 ... and /en/2, /ja/3 ...
40
+ if (/^\d+$/.test(normalizedPath)) {
41
+ return true
42
+ }
43
+
44
+ return locales.some((lang) => {
45
+ if (!normalizedPath.startsWith(`${lang}/`)) {
46
+ return false
47
+ }
48
+ const rest = normalizedPath.slice(lang.length + 1)
49
+ return /^\d+$/.test(rest)
50
+ })
51
+ }
52
+
53
+ // Ensure strict segment boundary matching to prevent partial matches
54
+ const startsWithSegment = (target: string, segment: string) =>
55
+ target === segment || target.startsWith(`${segment}/`)
56
+
57
+ // Match both default language paths and localized paths
58
+ return startsWithSegment(normalizedPath, prefix)
59
+ || moreLocales.some(lang => startsWithSegment(normalizedPath, `${lang}/${prefix}`))
60
+ }
61
+
62
+ export function isHomePage(path: string) {
63
+ return matchPageType(path)
64
+ }
65
+
66
+ export function isPostPage(path: string) {
67
+ return matchPageType(path, 'posts')
68
+ }
69
+
70
+ export function isNotePage(path: string) {
71
+ return matchPageType(path, 'notes')
72
+ }
73
+
74
+ export function isJournalPage(path: string) {
75
+ return matchPageType(path, 'journals')
76
+ }
77
+
78
+ export function isTagPage(path: string) {
79
+ return matchPageType(path, 'tags')
80
+ }
81
+
82
+ export function isAboutPage(path: string) {
83
+ return matchPageType(path, 'about')
84
+ }
85
+
86
+ // Returns page context with language, page types and localization helper
87
+ export function getPageInfo(path: string) {
88
+ const currentLang = getLangFromPath(path)
89
+ const isHome = isHomePage(path)
90
+ const isPost = isPostPage(path)
91
+ const isNote = isNotePage(path)
92
+ const isJournal = isJournalPage(path)
93
+ const isTag = isTagPage(path)
94
+ const isAbout = isAboutPage(path)
95
+
96
+ return {
97
+ currentLang,
98
+ isHome,
99
+ isPost,
100
+ isNote,
101
+ isJournal,
102
+ isTag,
103
+ isAbout,
104
+ getLocalizedPath: (targetPath: string) =>
105
+ getLocalizedPath(targetPath, currentLang),
106
+ }
107
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "compilerOptions": {
4
+ "lib": ["esnext", "dom"],
5
+ "baseUrl": ".",
6
+ "paths": {
7
+ "@/*": ["src/*"]
8
+ },
9
+ "allowJs": true
10
+ },
11
+ "include": [".astro/types.d.ts", "**/*"],
12
+ "exclude": ["dist"]
13
+ }