@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -0,0 +1,64 @@
1
+ import type { Post } from "../types.js";
2
+ import { now, toISOString } from "./time.js";
3
+
4
+ /** Cache policy for dynamic Atom feed responses. */
5
+ export const RSS_FEED_CACHE_CONTROL = "public, max-age=60";
6
+
7
+ /**
8
+ * Convert an RSS publication delay into the exclusive upper bound expected by
9
+ * Post service queries.
10
+ *
11
+ * Posts published exactly `delaySeconds` ago are eligible. Because publication
12
+ * timestamps use whole seconds while `publishedBefore` is exclusive, the bound
13
+ * is one second after the latest eligible timestamp.
14
+ *
15
+ * @param delaySeconds - Non-negative publication delay in seconds
16
+ * @param currentTime - Current Unix timestamp, injectable for deterministic use
17
+ * @returns Exclusive `publishedBefore` timestamp for RSS queries
18
+ * @example
19
+ * ```ts
20
+ * getRssPublishedBefore(300, 1_000); // 701, so publishedAt <= 700 is eligible
21
+ * ```
22
+ */
23
+ export function getRssPublishedBefore(
24
+ delaySeconds: number,
25
+ currentTime = now(),
26
+ ): number {
27
+ return currentTime - delaySeconds + 1;
28
+ }
29
+
30
+ /**
31
+ * Resolve the Atom `updated` timestamp for a Thread entry from content that is
32
+ * actually present in the feed.
33
+ *
34
+ * @param root - Thread root used as a fallback when no Thread rows are loaded
35
+ * @param thread - Eligible Thread Posts included in the Atom entry
36
+ * @param additionalTimestamps - Other entry updates, such as Collection membership
37
+ * @returns Latest update timestamp as an ISO 8601 string
38
+ * @example
39
+ * ```ts
40
+ * getFeedEntryUpdatedAt(root, [root, reply], [collectedAt]);
41
+ * ```
42
+ */
43
+ export function getFeedEntryUpdatedAt(
44
+ root: Pick<Post, "publishedAt" | "updatedAt">,
45
+ thread: readonly Pick<Post, "publishedAt" | "updatedAt">[] | undefined,
46
+ additionalTimestamps: readonly (number | null | undefined)[] = [],
47
+ ): string {
48
+ let updatedAt = Math.max(root.updatedAt, root.publishedAt ?? root.updatedAt);
49
+
50
+ for (const post of thread ?? []) {
51
+ updatedAt = Math.max(
52
+ updatedAt,
53
+ post.updatedAt,
54
+ post.publishedAt ?? post.updatedAt,
55
+ );
56
+ }
57
+ for (const timestamp of additionalTimestamps) {
58
+ if (timestamp !== null && timestamp !== undefined) {
59
+ updatedAt = Math.max(updatedAt, timestamp);
60
+ }
61
+ }
62
+
63
+ return toISOString(updatedAt);
64
+ }
package/src/lib/feed.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { FeedData, FeedPostView, MediaView, PostView } from "../types.js";
14
+ import { getLinkPreviewProviderLabel } from "./link-preview.js";
14
15
  import { extractDisplayDomain } from "./url.js";
15
16
  import { getMediaCategory } from "./upload.js";
16
17
 
@@ -40,6 +41,49 @@ function escapeCdata(str: string): string {
40
41
  return str.replaceAll("]]>", "]]]]><![CDATA[>");
41
42
  }
42
43
 
44
+ /**
45
+ * Resolve a URL for use outside the feed document's browser context.
46
+ *
47
+ * Feed readers do not consistently resolve root-relative URLs found inside
48
+ * Atom HTML content or enclosure attributes, so every non-fragment URL must
49
+ * carry its own origin.
50
+ */
51
+ function toAbsoluteFeedUrl(url: string, siteUrl: string): string {
52
+ const normalizedUrl = url.trim();
53
+ if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
54
+
55
+ try {
56
+ const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
57
+ return new URL(normalizedUrl, baseUrl).toString();
58
+ } catch {
59
+ return normalizedUrl;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Resolve navigational and media URL attributes inside trusted post HTML.
65
+ *
66
+ * Fragment-only links stay local to the rendered feed entry so footnotes and
67
+ * other in-entry references continue to work.
68
+ */
69
+ function absolutizeFeedHtmlUrls(html: string, siteUrl: string): string {
70
+ return html.replaceAll(
71
+ /(\s)(href|poster|src)=(["'])([^"']*)\3/gi,
72
+ (
73
+ match,
74
+ whitespace: string,
75
+ attribute: string,
76
+ quote: string,
77
+ url: string,
78
+ ) => {
79
+ const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
80
+ return absoluteUrl
81
+ ? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}`
82
+ : match;
83
+ },
84
+ );
85
+ }
86
+
43
87
  /**
44
88
  * Strip embedded content that is unsafe or unsupported in feed readers.
45
89
  *
@@ -144,6 +188,35 @@ function renderRatingHtml(rating: number): string {
144
188
  return `<p>${filled}${empty} ${rating}/5</p>`;
145
189
  }
146
190
 
191
+ /**
192
+ * Render a Link post preview as feed-safe HTML.
193
+ *
194
+ * Feed readers commonly strip CSS overlays and embedded players, so video
195
+ * previews use a linked thumbnail plus a visible provider-aware action.
196
+ * Non-video Link previews keep the linked thumbnail without a video label.
197
+ */
198
+ function renderLinkPreviewForFeed(post: PostView, siteUrl: string): string {
199
+ if (post.format !== "link") return "";
200
+
201
+ const imageUrl = post.previewImageUrl?.trim();
202
+ const linkUrl = post.url?.trim();
203
+ if (!imageUrl || !linkUrl) return "";
204
+
205
+ const isVideo = post.previewKind?.trim().toLowerCase() === "video";
206
+ const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
207
+ const fallbackAlt = isVideo
208
+ ? providerLabel
209
+ ? `${providerLabel} video`
210
+ : "Video preview"
211
+ : "Link preview";
212
+ const altText = post.title?.trim() || fallbackAlt;
213
+ const caption = isVideo
214
+ ? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>`
215
+ : "";
216
+
217
+ return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
218
+ }
219
+
147
220
  function formatFeedBytes(bytes: number): string {
148
221
  if (bytes < 1024) return `${bytes} B`;
149
222
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -203,9 +276,13 @@ function buildAttachmentLinkText(
203
276
  * and duration metadata when known. Text attachments link to the rendered
204
277
  * preview page when a post permalink is available.
205
278
  */
206
- function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
279
+ function renderMediaItem(
280
+ item: MediaView,
281
+ siteUrl: string,
282
+ postPermalinkUrl?: string,
283
+ ): string {
207
284
  const category = getMediaCategory(item.mimeType);
208
- const url = escapeXml(item.url);
285
+ const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
209
286
  const name = item.originalName ?? "";
210
287
  const altText = item.altText ?? "";
211
288
  const caption = item.altText?.trim() || "";
@@ -223,7 +300,10 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
223
300
  }
224
301
 
225
302
  if (category === "video") {
226
- const poster = item.posterUrl || item.thumbnailUrl;
303
+ const poster = toAbsoluteFeedUrl(
304
+ item.posterUrl || item.thumbnailUrl,
305
+ siteUrl,
306
+ );
227
307
  const dims =
228
308
  item.width && item.height
229
309
  ? ` width="${item.width}" height="${item.height}"`
@@ -272,11 +352,12 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
272
352
  */
273
353
  function renderMediaForFeed(
274
354
  media: MediaView[],
355
+ siteUrl: string,
275
356
  postPermalinkUrl?: string,
276
357
  ): string {
277
358
  if (media.length === 0) return "";
278
359
  return media
279
- .map((item) => renderMediaItem(item, postPermalinkUrl))
360
+ .map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
280
361
  .join("\n");
281
362
  }
282
363
 
@@ -289,6 +370,7 @@ function renderMediaForFeed(
289
370
  */
290
371
  function buildSinglePostContent(
291
372
  post: PostView,
373
+ siteUrl: string,
292
374
  permalinkUrl?: string,
293
375
  options: SinglePostContentOptions = {},
294
376
  ): string {
@@ -314,11 +396,18 @@ function buildSinglePostContent(
314
396
  }
315
397
  }
316
398
 
399
+ const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
400
+ if (linkPreviewHtml) {
401
+ parts.push(linkPreviewHtml);
402
+ }
403
+
317
404
  if (post.bodyHtml) {
318
- parts.push(stripUnsafeFeedHtml(post.bodyHtml));
405
+ parts.push(
406
+ absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(post.bodyHtml), siteUrl),
407
+ );
319
408
  }
320
409
 
321
- const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
410
+ const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
322
411
  if (mediaHtml) {
323
412
  parts.push(mediaHtml);
324
413
  }
@@ -353,7 +442,7 @@ function buildFeedContent(
353
442
  siteUrl: string,
354
443
  permalinkUrl?: string,
355
444
  ): string {
356
- const rootContent = buildSinglePostContent(post, permalinkUrl);
445
+ const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
357
446
  const replies = post.threadReplies;
358
447
 
359
448
  if (!replies || replies.length === 0) {
@@ -368,7 +457,9 @@ function buildFeedContent(
368
457
  parts.push(
369
458
  `<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
370
459
  );
371
- parts.push(buildSinglePostContent(reply, replyPermalink, { inline: true }));
460
+ parts.push(
461
+ buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
462
+ );
372
463
  }
373
464
 
374
465
  return parts.join("\n");
@@ -421,7 +512,7 @@ export function defaultFeedRenderer(data: FeedData): string {
421
512
  const titleAttr = m.originalName
422
513
  ? ` title="${escapeXml(m.originalName)}"`
423
514
  : "";
424
- return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
515
+ return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
425
516
  })
426
517
  .join("");
427
518
 
@@ -438,7 +529,18 @@ export function defaultFeedRenderer(data: FeedData): string {
438
529
  })
439
530
  .join("");
440
531
 
441
- const now = new Date().toISOString();
532
+ // The feed's own <updated> is the newest entry timestamp, not the current
533
+ // time — stamping "now" on every render tells every reader the feed changed
534
+ // on every poll, which is both untrue and useless for change detection.
535
+ // ISO 8601 from toISOString() is UTC and zero-padded, so lexical max is
536
+ // chronological max. Empty feeds fall back to now, since Atom requires it.
537
+ const feedUpdated =
538
+ posts
539
+ .map((post) => post.feedUpdatedAt ?? post.updatedAt)
540
+ .reduce<
541
+ string | null
542
+ >((latest, value) => (latest === null || value > latest ? value : latest), null) ??
543
+ new Date().toISOString();
442
544
 
443
545
  return `<?xml version="1.0" encoding="UTF-8"?>
444
546
  <feed xmlns="http://www.w3.org/2005/Atom">
@@ -447,7 +549,7 @@ export function defaultFeedRenderer(data: FeedData): string {
447
549
  <link href="${escapeXml(siteUrl)}" rel="alternate"/>
448
550
  <link href="${escapeXml(selfUrl)}" rel="self"/>
449
551
  <id>${escapeXml(selfUrl)}</id>
450
- <updated>${now}</updated>
552
+ <updated>${feedUpdated}</updated>
451
553
  ${entries}
452
554
  </feed>`;
453
555
  }
@@ -18,6 +18,16 @@ export interface FootnoteDefinitionTokenData {
18
18
  contentMarkdown: string;
19
19
  }
20
20
 
21
+ export interface LegacyFootnoteUpgradeResult {
22
+ doc: JSONContent;
23
+ upgraded: boolean;
24
+ }
25
+
26
+ interface LegacyFootnoteReference {
27
+ label: string;
28
+ node: JSONContent;
29
+ }
30
+
21
31
  export function normalizeFootnoteLabel(label: unknown): string {
22
32
  if (typeof label !== "string") return "";
23
33
  return label.trim().replace(/\s+/g, " ");
@@ -82,6 +92,260 @@ export function normalizeFootnoteArtifacts(doc: JSONContent): JSONContent {
82
92
  return content.length === doc.content.length ? doc : { ...doc, content };
83
93
  }
84
94
 
95
+ function getLinkHref(node: JSONContent): string {
96
+ const linkMarks = (node.marks ?? []).filter((mark) => mark.type === "link");
97
+ if (linkMarks.length !== 1) return "";
98
+
99
+ const href = linkMarks[0]?.attrs?.href;
100
+ return typeof href === "string" ? href.trim() : "";
101
+ }
102
+
103
+ function decodeHashTarget(href: string): string {
104
+ if (!href.startsWith("#")) return "";
105
+
106
+ try {
107
+ return decodeURIComponent(href.slice(1));
108
+ } catch {
109
+ return href.slice(1);
110
+ }
111
+ }
112
+
113
+ function normalizeLegacyTargetId(value: string): string {
114
+ return value.replace(/^user-content-/i, "").toLowerCase();
115
+ }
116
+
117
+ function getLegacyReferenceLabel(node: JSONContent): string {
118
+ if (node.type !== "text" || typeof node.text !== "string") return "";
119
+
120
+ const text = node.text.trim();
121
+ const displayMatch = text.match(/^\[\^?([^\]\n]+)\]$/);
122
+ const label = normalizeFootnoteLabel(displayMatch?.[1] ?? text);
123
+ if (!label) return "";
124
+
125
+ const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
126
+ const expectedTargetId = `fn-${getFootnoteDomId(label)}`.toLowerCase();
127
+ return targetId === expectedTargetId ? label : "";
128
+ }
129
+
130
+ function collectLegacyReferences(
131
+ node: JSONContent,
132
+ references: LegacyFootnoteReference[],
133
+ ): void {
134
+ const label = getLegacyReferenceLabel(node);
135
+ if (label) {
136
+ references.push({ label, node });
137
+ return;
138
+ }
139
+
140
+ for (const child of node.content ?? []) {
141
+ collectLegacyReferences(child, references);
142
+ }
143
+ }
144
+
145
+ function isBacklinkText(value: string | undefined): boolean {
146
+ return /^(?:↩(?:︎)?|↵)(?:\s*\d+)?$/.test(value?.trim() ?? "");
147
+ }
148
+
149
+ function getLegacyBacklinkLabel(
150
+ node: JSONContent,
151
+ labels: readonly string[],
152
+ ): string {
153
+ if (node.type !== "text" || !isBacklinkText(node.text)) return "";
154
+
155
+ const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
156
+ if (!targetId.startsWith("fnref-")) return "";
157
+
158
+ for (const label of labels) {
159
+ const base = `fnref-${getFootnoteDomId(label)}`.toLowerCase();
160
+ const occurrence = targetId.startsWith(`${base}-`)
161
+ ? targetId.slice(base.length + 1)
162
+ : "";
163
+ if (targetId === base || /^\d+$/.test(occurrence)) {
164
+ return label;
165
+ }
166
+ }
167
+
168
+ return "";
169
+ }
170
+
171
+ function collectLegacyBacklinkLabels(
172
+ node: JSONContent,
173
+ labels: readonly string[],
174
+ found: Set<string>,
175
+ ): void {
176
+ const label = getLegacyBacklinkLabel(node, labels);
177
+ if (label) {
178
+ found.add(getFootnoteLabelKey(label));
179
+ return;
180
+ }
181
+
182
+ for (const child of node.content ?? []) {
183
+ collectLegacyBacklinkLabels(child, labels, found);
184
+ }
185
+ }
186
+
187
+ function removeLegacyBacklinks(
188
+ node: JSONContent,
189
+ label: string,
190
+ ): JSONContent | null {
191
+ if (
192
+ getFootnoteLabelKey(getLegacyBacklinkLabel(node, [label])) ===
193
+ getFootnoteLabelKey(label)
194
+ ) {
195
+ return null;
196
+ }
197
+
198
+ if (!node.content) return node;
199
+
200
+ const content = node.content.flatMap((child) => {
201
+ const normalized = removeLegacyBacklinks(child, label);
202
+ return normalized ? [normalized] : [];
203
+ });
204
+
205
+ if (content.length === node.content.length) {
206
+ const unchanged = content.every(
207
+ (child, index) => child === node.content?.[index],
208
+ );
209
+ if (unchanged) return node;
210
+ }
211
+
212
+ return content.length > 0 ? { ...node, content } : { ...node, content: [] };
213
+ }
214
+
215
+ function replaceLegacyReferences(
216
+ node: JSONContent,
217
+ labelsByNode: Map<JSONContent, string>,
218
+ ): JSONContent {
219
+ const label = labelsByNode.get(node);
220
+ if (label) {
221
+ return { type: "footnoteReference", attrs: { label } };
222
+ }
223
+
224
+ if (!node.content) return node;
225
+
226
+ const content = node.content.map((child) =>
227
+ replaceLegacyReferences(child, labelsByNode),
228
+ );
229
+ const unchanged = content.every(
230
+ (child, index) => child === node.content?.[index],
231
+ );
232
+ return unchanged ? node : { ...node, content };
233
+ }
234
+
235
+ function hasCanonicalFootnoteNode(node: JSONContent): boolean {
236
+ if (node.type === "footnoteReference" || node.type === "footnoteDefinition") {
237
+ return true;
238
+ }
239
+
240
+ return (node.content ?? []).some(hasCanonicalFootnoteNode);
241
+ }
242
+
243
+ /**
244
+ * Upgrades the historical footnote shape that old rich-text pastes stored as
245
+ * ordinary fragment links plus a trailing ordered list.
246
+ *
247
+ * Conversion is deliberately all-or-nothing. The document must end in an
248
+ * `hr` and ordered list, every definition item must contain a matching
249
+ * `#fnref-*` backlink, and every `#fn-*` reference must have exactly one
250
+ * definition label. This keeps normal fragment links and numbered lists
251
+ * untouched.
252
+ *
253
+ * @param doc - Parsed TipTap document
254
+ * @returns The canonical document and whether a legacy footnote set changed
255
+ * @example
256
+ * ```ts
257
+ * const result = upgradeLegacyFootnotes({
258
+ * type: "doc",
259
+ * content: [],
260
+ * });
261
+ * // { doc: { type: "doc", content: [] }, upgraded: false }
262
+ * ```
263
+ */
264
+ export function upgradeLegacyFootnotes(
265
+ doc: JSONContent,
266
+ ): LegacyFootnoteUpgradeResult {
267
+ if (
268
+ doc.type !== "doc" ||
269
+ !Array.isArray(doc.content) ||
270
+ doc.content.length < 3 ||
271
+ hasCanonicalFootnoteNode(doc)
272
+ ) {
273
+ return { doc, upgraded: false };
274
+ }
275
+
276
+ const definitionList = doc.content.at(-1);
277
+ const separator = doc.content.at(-2);
278
+ if (
279
+ definitionList?.type !== "orderedList" ||
280
+ separator?.type !== "horizontalRule" ||
281
+ !Array.isArray(definitionList.content) ||
282
+ definitionList.content.length === 0 ||
283
+ definitionList.content.some((item) => item.type !== "listItem")
284
+ ) {
285
+ return { doc, upgraded: false };
286
+ }
287
+
288
+ const bodyNodes = doc.content.slice(0, -2);
289
+ const references: LegacyFootnoteReference[] = [];
290
+ for (const node of bodyNodes) collectLegacyReferences(node, references);
291
+ if (references.length === 0) return { doc, upgraded: false };
292
+
293
+ const labelsByKey = new Map<string, string>();
294
+ for (const reference of references) {
295
+ const key = getFootnoteLabelKey(reference.label);
296
+ if (!labelsByKey.has(key)) labelsByKey.set(key, reference.label);
297
+ }
298
+ const labels = [...labelsByKey.values()].sort(
299
+ (left, right) =>
300
+ getFootnoteDomId(right).length - getFootnoteDomId(left).length,
301
+ );
302
+ if (labels.length !== definitionList.content.length) {
303
+ return { doc, upgraded: false };
304
+ }
305
+
306
+ const definitions: JSONContent[] = [];
307
+ const definedLabels = new Set<string>();
308
+ for (const item of definitionList.content) {
309
+ const backlinkLabels = new Set<string>();
310
+ collectLegacyBacklinkLabels(item, labels, backlinkLabels);
311
+ if (backlinkLabels.size !== 1) return { doc, upgraded: false };
312
+
313
+ const key = [...backlinkLabels][0];
314
+ if (!key) return { doc, upgraded: false };
315
+ const label = labelsByKey.get(key);
316
+ if (!label || definedLabels.has(key)) return { doc, upgraded: false };
317
+
318
+ definedLabels.add(key);
319
+ const content = (item.content ?? []).flatMap((child) => {
320
+ const normalized = removeLegacyBacklinks(child, label);
321
+ return normalized ? [normalized] : [];
322
+ });
323
+ definitions.push({
324
+ type: "footnoteDefinition",
325
+ attrs: { label },
326
+ content: content.length > 0 ? content : [{ type: "paragraph" }],
327
+ });
328
+ }
329
+
330
+ if (definedLabels.size !== labelsByKey.size) {
331
+ return { doc, upgraded: false };
332
+ }
333
+
334
+ const labelsByNode = new Map(
335
+ references.map((reference) => [reference.node, reference.label] as const),
336
+ );
337
+ return {
338
+ doc: {
339
+ ...doc,
340
+ content: [
341
+ ...bodyNodes.map((node) => replaceLegacyReferences(node, labelsByNode)),
342
+ ...definitions,
343
+ ],
344
+ },
345
+ upgraded: true,
346
+ };
347
+ }
348
+
85
349
  /**
86
350
  * Parses one Markdown footnote definition from the start of `src`.
87
351
  *
@@ -30,6 +30,8 @@ export async function buildSyncSiteConfig(c: {
30
30
  siteDescription: appConfig.siteDescription,
31
31
  siteLanguage: appConfig.siteLanguage,
32
32
  showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
33
+ publicApiEnabled: appConfig.publicApiEnabled,
34
+ rssFeedsEnabled: appConfig.rssFeedsEnabled,
33
35
  mainRssFeed: appConfig.mainRssFeed,
34
36
  siteFooter: appConfig.siteFooter,
35
37
  showHeaderAvatar: appConfig.showHeaderAvatar,
@@ -16,8 +16,9 @@
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  /**
19
- * Per-collection membership metadata. Preserved verbatim on round-trip so
20
- * curators keep their collected_at / position / pinned_at ordering.
19
+ * Per-Thread collection membership metadata. Current exports write this
20
+ * only on the Thread root bundle. Import also accepts legacy reply-level
21
+ * entries and folds them into the root without losing metadata.
21
22
  */
22
23
  export interface HugoCollectionRef {
23
24
  slug: string;
@@ -110,11 +111,20 @@ export interface HugoFrontMatter {
110
111
  rating?: number | null;
111
112
  featured_at?: string | null;
112
113
  pinned_at?: string | null;
114
+ /**
115
+ * Reply bundles only: the reply was published without announcing the Thread
116
+ * on Latest. Round-tripped so a restore rebuilds the same ordering.
117
+ */
118
+ quiet_reply?: boolean;
119
+
120
+ // Derived Featured Thread projection (root bundles only)
121
+ featured_post_ids?: string[];
122
+ featured_sort_at?: string;
113
123
 
114
124
  // Round-trip bookkeeping
115
125
  root_aliases?: string[];
116
126
 
117
- // Memberships + attachments
127
+ // Thread memberships (root bundles only) + attachments
118
128
  collections?: HugoCollectionRef[];
119
129
  media?: JantMedia[];
120
130
 
@@ -201,6 +211,8 @@ const FRONT_MATTER_KEY_ORDER: readonly string[] = [
201
211
  "quote_text",
202
212
  "rating",
203
213
  "featured_at",
214
+ "featured_post_ids",
215
+ "featured_sort_at",
204
216
  "pinned_at",
205
217
 
206
218
  // Bookkeeping / attachments
@@ -23,6 +23,7 @@ import { toPublicPath } from "./url.js";
23
23
 
24
24
  export { JANT_APP_ICON_CORNER_RADIUS, JANT_LOGO_PATH_DATA, JANT_LOGO_VIEW_BOX };
25
25
 
26
+ export const JANT_HOME_URL = "https://jant.me";
26
27
  export const JANT_REPO_URL = "https://github.com/jant-me/jant";
27
28
  export const HOME_BRANDING_PREFIX = "Build with";
28
29
  export const HOME_BRANDING_LINK_LABEL = "Jant";
@@ -0,0 +1,25 @@
1
+ const LINK_PREVIEW_PROVIDER_LABELS: Record<string, string> = {
2
+ youtube: "YouTube",
3
+ vimeo: "Vimeo",
4
+ bilibili: "Bilibili",
5
+ };
6
+
7
+ /**
8
+ * Return the display label for a recognized Link preview provider.
9
+ *
10
+ * @param provider - Stored provider identifier, such as `youtube`
11
+ * @returns A stable display label, or undefined for unknown providers
12
+ * @example
13
+ * ```ts
14
+ * getLinkPreviewProviderLabel("youtube"); // "YouTube"
15
+ * getLinkPreviewProviderLabel("unknown"); // undefined
16
+ * ```
17
+ */
18
+ export function getLinkPreviewProviderLabel(
19
+ provider?: string,
20
+ ): string | undefined {
21
+ const normalizedProvider = provider?.trim().toLowerCase();
22
+ return normalizedProvider
23
+ ? LINK_PREVIEW_PROVIDER_LABELS[normalizedProvider]
24
+ : undefined;
25
+ }