@jant/core 0.6.15 → 0.7.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 (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Announcing a site to its directory, from whichever route just gave it a
3
+ * reason to.
4
+ *
5
+ * Two routes have one: the settings page, whenever the owner turns Discover on
6
+ * or off, and first-run setup, when they answer the same question for the first
7
+ * time. Both send the same thing to the same place, so they send it through the
8
+ * same function rather than through two that drift.
9
+ *
10
+ * The transport lives in `lib/discover-ping.ts` and the protocol in
11
+ * `lib/discover.ts`. What is here is the part that needs a request: the
12
+ * deployment's endpoint, the site's own address, and the runtime's hook for
13
+ * work that outlives the response.
14
+ */
15
+
16
+ import type { Context } from "hono";
17
+ import type { Bindings } from "../types.js";
18
+ import type { AppVariables } from "../types/app-context.js";
19
+ import { runDeferred } from "../lib/deferred.js";
20
+ import {
21
+ getDiscoverFeedPath,
22
+ parseDiscoverSetting,
23
+ resolveDiscoverMode,
24
+ } from "../lib/discover.js";
25
+ import { getDiscoverDefault, getDiscoverPingUrl } from "../lib/env.js";
26
+ import { toAbsoluteSiteUrl } from "../lib/url.js";
27
+
28
+ type Env = { Bindings: Bindings; Variables: AppVariables };
29
+
30
+ /**
31
+ * Tell the configured directory where this site's feed is, in the background.
32
+ *
33
+ * Never awaited by its caller. A directory takes up to twelve seconds to give
34
+ * up on, and neither a settings save nor the last screen of setup may hang on
35
+ * one — nor fail because one is down. The outcome is recorded either way, so
36
+ * the owner can tell "announced" from "never got through"; the settings page's
37
+ * status block is where that shows, and it carries a Retry.
38
+ *
39
+ * @param c - Request context, for the runtime's background-work hook
40
+ * @param storedValue - The stored Discover choice. Passed in rather than read
41
+ * from `c.var.allSettings`, which is the snapshot taken before the request
42
+ * ran — on the save that triggers this it still holds the previous answer.
43
+ * @returns Whether an announcement was started at all
44
+ */
45
+ export function announceInBackground(
46
+ c: Context<Env>,
47
+ storedValue: string | undefined,
48
+ ): boolean {
49
+ const { appConfig } = c.var;
50
+ const endpoint = getDiscoverPingUrl(c.env);
51
+ if (!endpoint) return false;
52
+
53
+ // Read the mode back through the same derivation the feed uses, so a site
54
+ // that cannot actually be polled — feeds switched off, `noindex` set, demo
55
+ // mode — never announces an address that would answer 404.
56
+ const mode = resolveDiscoverMode({
57
+ storedValue,
58
+ defaultValue: getDiscoverDefault(c.env),
59
+ demoMode: appConfig.demoMode,
60
+ noindex: appConfig.noindex,
61
+ rssFeedsEnabled: appConfig.rssFeedsEnabled,
62
+ });
63
+ // A site that has just switched Discover off resolves to `none` and so has
64
+ // no feed of its own to name — but that is exactly the site with something
65
+ // to say, and the declaration a directory needs to read sits in every feed,
66
+ // not only the one it was polling. `/latest/feed` is the address that is
67
+ // always served, so the stop is sent there.
68
+ //
69
+ // Only for an owner's own `off`, never for the other ways a site resolves to
70
+ // `none`: a demo site or one with feeds switched off would be naming an
71
+ // address that answers 404.
72
+ const feedPath =
73
+ getDiscoverFeedPath(mode) ??
74
+ (parseDiscoverSetting(storedValue) === "off" &&
75
+ appConfig.rssFeedsEnabled &&
76
+ !appConfig.demoMode
77
+ ? getDiscoverFeedPath("latest")
78
+ : null);
79
+ if (!feedPath) return false;
80
+
81
+ // Deferred, so the caller answers without waiting on a directory. The helper
82
+ // is what keeps the orphaned promise from taking a Node process down with
83
+ // it: `announceToDiscover` already resolves rather than throws, and this is
84
+ // what holds if it ever stops being.
85
+ runDeferred(c, "Discover announcement", async () => {
86
+ await c.var.services.settings.announceToDiscover({
87
+ endpoint,
88
+ feedUrl: toAbsoluteSiteUrl(
89
+ feedPath,
90
+ appConfig.siteUrl,
91
+ appConfig.sitePathPrefix,
92
+ ),
93
+ });
94
+ });
95
+ return true;
96
+ }
@@ -6,6 +6,7 @@ import type { AppVariables } from "../../../types/app-context.js";
6
6
  import {
7
7
  createTestDatabase,
8
8
  DEFAULT_TEST_SITE_ID,
9
+ enforceD1BoundParameterLimit,
9
10
  } from "../../../__tests__/helpers/db.js";
10
11
  import { posts as postTable } from "../../../db/schema.js";
11
12
  import { createPostService } from "../../../services/post.js";
@@ -22,7 +23,7 @@ import type { Database } from "../../../db/index.js";
22
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
23
24
 
24
25
  function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
25
- const { db } = createTestDatabase();
26
+ const { db, sqlite } = createTestDatabase();
26
27
  const pathService = createPathService(db as never, DEFAULT_TEST_SITE_ID);
27
28
 
28
29
  const services = {
@@ -65,7 +66,12 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
65
66
  app.route("/latest", latestRoutes);
66
67
  app.route("/featured", featuredRoutes);
67
68
 
68
- return { app, services, db: db as unknown as Database };
69
+ return { app, services, db: db as unknown as Database, sqlite };
70
+ }
71
+
72
+ /** Count the `<entry>` elements in an Atom document. */
73
+ function countEntries(xml: string): number {
74
+ return xml.match(/<entry>/g)?.length ?? 0;
69
75
  }
70
76
 
71
77
  afterEach(() => {
@@ -597,6 +603,132 @@ describe("Atom Feed Routes", () => {
597
603
  });
598
604
  });
599
605
 
606
+ describe("Discover fields", () => {
607
+ it("gives every entry the post's own ID beside its permalink id", async () => {
608
+ const { app, services } = createFeedTestApp();
609
+ const post = await services.posts.create({
610
+ format: "note",
611
+ title: "Identified",
612
+ bodyMarkdown: "Body",
613
+ status: "published",
614
+ slug: "identified",
615
+ });
616
+
617
+ const xml = await (await app.request("/latest/feed")).text();
618
+
619
+ expect(xml).toContain(
620
+ `<id>http://localhost:${DEFAULT_APP_PORT}/identified</id>\n <jant:id>${post.id}</jant:id>`,
621
+ );
622
+ });
623
+
624
+ it("names the status endpoint in the declaration while the site is listed", async () => {
625
+ const { app, services } = createFeedTestApp();
626
+ await services.settings.set("DISCOVER", "latest");
627
+
628
+ const xml = await (await app.request("/latest/feed")).text();
629
+
630
+ expect(xml).toContain(
631
+ ` status="http://localhost:${DEFAULT_APP_PORT}/api/discover/posts">latest</jant:discover>`,
632
+ );
633
+ });
634
+
635
+ it("names no status endpoint under none", async () => {
636
+ const { app, services } = createFeedTestApp();
637
+ await services.settings.set("DISCOVER", "off");
638
+
639
+ const xml = await (await app.request("/latest/feed")).text();
640
+
641
+ expect(xml).toContain("<jant:discover>none</jant:discover>");
642
+ });
643
+ });
644
+
645
+ describe("?limit=", () => {
646
+ async function createNotes(
647
+ services: ReturnType<typeof createFeedTestApp>["services"],
648
+ count: number,
649
+ options: { featured?: boolean } = {},
650
+ ) {
651
+ for (let i = 0; i < count; i++) {
652
+ await services.posts.create({
653
+ format: "note",
654
+ title: `Post ${i}`,
655
+ bodyMarkdown: `Body ${i}`,
656
+ status: "published",
657
+ featured: options.featured,
658
+ });
659
+ }
660
+ }
661
+
662
+ it("carries more than RSS_FEED_LIMIT when asked", async () => {
663
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "2" });
664
+ await createNotes(services, 4);
665
+
666
+ const xml = await (await app.request("/latest/feed?limit=3")).text();
667
+
668
+ expect(countEntries(xml)).toBe(3);
669
+ expect(xml).toContain("Post 3");
670
+ expect(xml).toContain("Post 1");
671
+ expect(xml).not.toContain("Post 0");
672
+ });
673
+
674
+ it("carries fewer when asked", async () => {
675
+ const { app, services } = createFeedTestApp();
676
+ await createNotes(services, 3);
677
+
678
+ const xml = await (await app.request("/latest/feed?limit=1")).text();
679
+
680
+ expect(countEntries(xml)).toBe(1);
681
+ expect(xml).toContain("Post 2");
682
+ });
683
+
684
+ it("keeps the site's length for a value it cannot read", async () => {
685
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "2" });
686
+ await createNotes(services, 4);
687
+
688
+ for (const query of ["limit=abc", "limit=0", "limit=-1", "limit=1.5"]) {
689
+ const res = await app.request(`/latest/feed?${query}`);
690
+ expect(res.status).toBe(200);
691
+ expect(countEntries(await res.text())).toBe(2);
692
+ }
693
+ });
694
+
695
+ it("applies to the featured and main feeds", async () => {
696
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "1" });
697
+ await createNotes(services, 3, { featured: true });
698
+
699
+ const featured = await (
700
+ await app.request("/featured/feed?limit=2")
701
+ ).text();
702
+ const main = await (await app.request("/feed?limit=2")).text();
703
+
704
+ expect(countEntries(featured)).toBe(2);
705
+ expect(countEntries(main)).toBe(2);
706
+ });
707
+
708
+ it("leaves the feed's self link on the address without it", async () => {
709
+ const { app, services } = createFeedTestApp();
710
+ await createNotes(services, 1);
711
+
712
+ const xml = await (await app.request("/latest/feed?limit=10")).text();
713
+
714
+ expect(xml).toContain(
715
+ `<link href="http://localhost:${DEFAULT_APP_PORT}/latest/feed" rel="self"/>`,
716
+ );
717
+ });
718
+
719
+ it("renders past a hundred entries within D1's bound-parameter limit", async () => {
720
+ const { app, services, sqlite } = createFeedTestApp();
721
+ await createNotes(services, 120, { featured: true });
722
+ enforceD1BoundParameterLimit(sqlite);
723
+
724
+ for (const path of ["/latest/feed", "/featured/feed"]) {
725
+ const res = await app.request(`${path}?limit=120`);
726
+ expect(res.status).toBe(200);
727
+ expect(countEntries(await res.text())).toBe(120);
728
+ }
729
+ });
730
+ });
731
+
600
732
  describe("RSS_PUBLISH_DELAY_SECONDS env var", () => {
601
733
  it("includes the exact 300-second boundary and excludes newer Posts", async () => {
602
734
  const currentTime = 2_000_000;
@@ -702,6 +834,37 @@ describe("Atom Feed Routes", () => {
702
834
  expect(xml).toContain("<hr/>");
703
835
  });
704
836
 
837
+ it("names a reply by its alias, the way the site does", async () => {
838
+ const { app, services } = createFeedTestApp();
839
+
840
+ const root = await services.posts.create({
841
+ format: "note",
842
+ title: "Aliased Thread Root",
843
+ bodyMarkdown: "Root content",
844
+ status: "published",
845
+ featured: true,
846
+ });
847
+ const reply = await services.posts.create({
848
+ format: "note",
849
+ bodyMarkdown: "Reply content",
850
+ status: "published",
851
+ replyToId: root.id,
852
+ });
853
+ await services.paths.create({
854
+ path: "/notes/the-reply",
855
+ kind: "alias",
856
+ postId: reply.id,
857
+ });
858
+
859
+ for (const path of ["/latest/feed", "/featured/feed"]) {
860
+ const xml = await (await app.request(path)).text();
861
+ // The row, the fold's `latest`, and the inline reply link all name
862
+ // the reply by the address the site uses, not by its slug.
863
+ expect(xml).toContain('href="http://localhost:3000/notes/the-reply"');
864
+ expect(xml).not.toContain(`/${reply.slug}"`);
865
+ }
866
+ });
867
+
705
868
  it("delays new replies inside an already eligible Thread", async () => {
706
869
  const currentTime = 2_000_000;
707
870
  const dateNow = vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import type { Bindings } from "../../../types.js";
4
4
  import type { AppVariables } from "../../../types/app-context.js";
@@ -154,6 +154,73 @@ describe("Sitemap Routes", () => {
154
154
  expect(indices).toEqual([...indices].sort((a, b) => a - b));
155
155
  });
156
156
 
157
+ // A Thread root's page renders its replies, so anything that changes a
158
+ // reply changes the page. Unlike ordering, edits count for <lastmod> —
159
+ // the page really did change.
160
+ it("moves <lastmod> when a reply is added", async () => {
161
+ const { app, services } = createSitemapTestApp();
162
+ const readLastmod = async () => {
163
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
164
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
165
+ };
166
+
167
+ vi.useFakeTimers();
168
+ try {
169
+ // lastmod is a date, so the two writes need to land on different days.
170
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
171
+ const root = await services.posts.create({
172
+ format: "note",
173
+ bodyMarkdown: "root",
174
+ title: "Threaded",
175
+ status: "published",
176
+ });
177
+ expect(await readLastmod()).toBe("2026-03-01");
178
+
179
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
180
+ await services.posts.create({
181
+ format: "note",
182
+ bodyMarkdown: "reply",
183
+ replyToId: root.id,
184
+ status: "published",
185
+ });
186
+ expect(await readLastmod()).toBe("2026-03-05");
187
+ } finally {
188
+ vi.useRealTimers();
189
+ }
190
+ });
191
+
192
+ it("ignores a draft reply in <lastmod>", async () => {
193
+ const { app, services } = createSitemapTestApp();
194
+ const readLastmod = async () => {
195
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
196
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
197
+ };
198
+
199
+ vi.useFakeTimers();
200
+ try {
201
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
202
+ const root = await services.posts.create({
203
+ format: "note",
204
+ bodyMarkdown: "root",
205
+ title: "Threaded",
206
+ status: "published",
207
+ });
208
+
209
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
210
+ await services.posts.create({
211
+ format: "note",
212
+ bodyMarkdown: "unpublished reply",
213
+ replyToId: root.id,
214
+ status: "draft",
215
+ });
216
+
217
+ // Not on the page, so it must not claim the page changed.
218
+ expect(await readLastmod()).toBe("2026-03-01");
219
+ } finally {
220
+ vi.useRealTimers();
221
+ }
222
+ });
223
+
157
224
  it("excludes private posts, replies, and drafts", async () => {
158
225
  const { app, services } = createSitemapTestApp();
159
226
  const root = await services.posts.create({
@@ -28,9 +28,13 @@ import type {
28
28
  import type { AppVariables } from "../../types/app-context.js";
29
29
  import { defaultFeedRenderer } from "../../lib/feed.js";
30
30
  import {
31
+ buildFeedDiscoveryFields,
32
+ featuredFeedSelection,
31
33
  getFeedEntryUpdatedAt,
34
+ getFeedLimit,
32
35
  getRssPublishedBefore,
33
- RSS_FEED_CACHE_CONTROL,
36
+ latestFeedSelection,
37
+ renderFeed,
34
38
  } from "../../lib/feed-policy.js";
35
39
  import { buildMediaMap } from "../../lib/media-helpers.js";
36
40
  import { getI18n } from "../../i18n/index.js";
@@ -38,6 +42,7 @@ import { FORMATS } from "../../types/constants.js";
38
42
 
39
43
  import { createMediaContext, toPostViews } from "../../lib/view.js";
40
44
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
45
+ import { getViewLang, viewBasePath } from "../../lib/view-language.js";
41
46
  import { toPlainText } from "../../lib/markdown.js";
42
47
 
43
48
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -82,12 +87,18 @@ async function loadMediaMap(
82
87
 
83
88
  /**
84
89
  * Build thread replies as PostView[] for a given root post from a thread map.
90
+ *
91
+ * Replies take the alias map too. A reply's permalink is what `<jant:post>`
92
+ * rows, the fold's `gap` and `latest`, and every `media:content` name it by,
93
+ * and a consumer keys on that address — so it has to be the address the site
94
+ * itself uses, which is the reply's first alias when it has one.
85
95
  */
86
96
  function buildThreadReplies(
87
97
  rootId: string,
88
98
  threadMap: Map<string, Post[]>,
89
99
  mediaMap: ReturnType<typeof buildMediaMap>,
90
100
  mediaCtx: ReturnType<typeof createMediaContext>,
101
+ aliasMap: Map<string, string>,
91
102
  ) {
92
103
  const thread = threadMap.get(rootId);
93
104
  if (!thread || thread.length <= 1) return undefined;
@@ -100,9 +111,20 @@ function buildThreadReplies(
100
111
  mediaAttachments: mediaMap.get(r.id) ?? [],
101
112
  })),
102
113
  mediaCtx,
114
+ undefined,
115
+ aliasMap,
103
116
  );
104
117
  }
105
118
 
119
+ /** The first alias of each post that has one: the address the site uses. */
120
+ function toAliasMap(aliasesMap: Map<string, string[]>): Map<string, string> {
121
+ const aliasMap = new Map<string, string>();
122
+ for (const [id, aliases] of aliasesMap) {
123
+ if (aliases[0]) aliasMap.set(id, aliases[0]);
124
+ }
125
+ return aliasMap;
126
+ }
127
+
106
128
  /**
107
129
  * Collect all reply IDs from a thread map (excluding root posts).
108
130
  */
@@ -126,13 +148,12 @@ async function buildLatestFeedData(
126
148
  format?: Format,
127
149
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
128
150
  const posts = await c.var.services.posts.list({
129
- status: "published",
130
- excludeReplies: true,
131
- excludeLatestHidden: true,
132
- excludePrivate: true,
151
+ ...latestFeedSelection({
152
+ lang: getViewLang(c) ?? undefined,
153
+ publishedBefore,
154
+ }),
133
155
  format,
134
156
  ignorePinnedSort: true,
135
- publishedBefore,
136
157
  limit: feedLimit,
137
158
  });
138
159
 
@@ -146,17 +167,19 @@ async function buildLatestFeedData(
146
167
  c.var.services.paths.getPostAliases(postIds),
147
168
  ]);
148
169
 
149
- // Load media for replies
170
+ // Replies are only known once the threads are, so their media and aliases
171
+ // come in a second round.
150
172
  const replyIds = collectReplyIds(threadMap);
151
- const replyMediaMap =
152
- replyIds.length > 0 ? await loadMediaMap(c, replyIds, mediaCtx) : mediaMap;
173
+ const [replyMediaMap, replyAliasesMap] =
174
+ replyIds.length > 0
175
+ ? await Promise.all([
176
+ loadMediaMap(c, replyIds, mediaCtx),
177
+ c.var.services.paths.getPostAliases(replyIds),
178
+ ])
179
+ : [mediaMap, new Map<string, string[]>()];
153
180
  // Merge reply media into main map
154
181
  const mergedMediaMap = new Map([...mediaMap, ...replyMediaMap]);
155
-
156
- const aliasMap = new Map<string, string>();
157
- for (const [id, aliases] of aliasesMap) {
158
- if (aliases[0]) aliasMap.set(id, aliases[0]);
159
- }
182
+ const aliasMap = toAliasMap(new Map([...aliasesMap, ...replyAliasesMap]));
160
183
 
161
184
  const postViews = toPostViews(
162
185
  posts.map((p) => ({
@@ -177,6 +200,7 @@ async function buildLatestFeedData(
177
200
  threadMap,
178
201
  mergedMediaMap,
179
202
  mediaCtx,
203
+ aliasMap,
180
204
  ),
181
205
  };
182
206
  });
@@ -197,9 +221,10 @@ async function buildFeaturedFeedData(
197
221
  publishedBefore: number,
198
222
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
199
223
  const rootIds = await c.var.services.posts.listFeaturedThreadRootIds({
200
- status: "published",
201
- excludePrivate: true,
202
- publishedBefore,
224
+ ...featuredFeedSelection({
225
+ lang: getViewLang(c) ?? undefined,
226
+ publishedBefore,
227
+ }),
203
228
  limit: feedLimit,
204
229
  });
205
230
 
@@ -224,7 +249,7 @@ async function buildFeaturedFeedData(
224
249
  const postIds = posts.map((p) => p.id);
225
250
  const mediaCtx = createMediaContext(c.var.appConfig);
226
251
 
227
- // Collect all post IDs (roots + replies) for media loading
252
+ // Collect all post IDs (roots + replies) for media and alias loading
228
253
  const allPostIds = new Set(postIds);
229
254
  for (const thread of threadMap.values()) {
230
255
  for (const post of thread) {
@@ -234,13 +259,9 @@ async function buildFeaturedFeedData(
234
259
 
235
260
  const [mediaMap, aliasesMap] = await Promise.all([
236
261
  loadMediaMap(c, [...allPostIds], mediaCtx),
237
- c.var.services.paths.getPostAliases(postIds),
262
+ c.var.services.paths.getPostAliases([...allPostIds]),
238
263
  ]);
239
-
240
- const aliasMap = new Map<string, string>();
241
- for (const [id, aliases] of aliasesMap) {
242
- if (aliases[0]) aliasMap.set(id, aliases[0]);
243
- }
264
+ const aliasMap = toAliasMap(aliasesMap);
244
265
 
245
266
  const postViews = toPostViews(
246
267
  posts.map((p) => ({
@@ -257,7 +278,13 @@ async function buildFeaturedFeedData(
257
278
  return {
258
279
  ...postView,
259
280
  feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
260
- threadReplies: buildThreadReplies(post.id, threadMap, mediaMap, mediaCtx),
281
+ threadReplies: buildThreadReplies(
282
+ post.id,
283
+ threadMap,
284
+ mediaMap,
285
+ mediaCtx,
286
+ aliasMap,
287
+ ),
261
288
  };
262
289
  });
263
290
 
@@ -284,8 +311,9 @@ export async function buildFeedData(
284
311
  const siteName = appConfig.siteName;
285
312
  const siteDescription = toPlainText(appConfig.siteDescription);
286
313
  const siteUrl = appConfig.siteUrl;
287
- const siteLanguage = appConfig.siteLanguage;
288
- const feedLimit = appConfig.rssFeedLimit;
314
+ // A language view's feed is that language's feed, so it declares it.
315
+ const siteLanguage = getViewLang(c) ?? appConfig.siteLanguage;
316
+ const feedLimit = getFeedLimit(c);
289
317
  const publishedBefore = getRssPublishedBefore(
290
318
  appConfig.rssPublishDelaySeconds,
291
319
  );
@@ -297,6 +325,7 @@ export async function buildFeedData(
297
325
  : await buildLatestFeedData(c, feedLimit, publishedBefore, opts.format);
298
326
 
299
327
  return {
328
+ ...buildFeedDiscoveryFields(c),
300
329
  siteName,
301
330
  siteDescription,
302
331
  siteUrl,
@@ -318,10 +347,11 @@ export async function buildFeedData(
318
347
  }),
319
348
  )}`,
320
349
  selfUrl: toAbsoluteSiteUrl(
321
- opts.selfPath,
350
+ `${viewBasePath(c)}${opts.selfPath}`,
322
351
  siteUrl,
323
352
  appConfig.sitePathPrefix,
324
353
  ),
354
+ siteIconUrl: appConfig.siteAvatarUrl || undefined,
325
355
  posts: postViews,
326
356
  };
327
357
  }
@@ -338,21 +368,20 @@ export function parseFormatQuery(c: Context<Env>): Format | undefined {
338
368
  return undefined;
339
369
  }
340
370
 
341
- export function renderFeed(xml: string) {
342
- return new Response(xml, {
343
- headers: {
344
- "Content-Type": "application/atom+xml; charset=utf-8",
345
- "Cache-Control": RSS_FEED_CACHE_CONTROL,
346
- },
347
- });
348
- }
349
-
350
- // Atom — /feed
351
- feedRoutes.get("/", async (c) => {
371
+ /**
372
+ * Render the site's main Atom feed for the current view language.
373
+ *
374
+ * @param c - Hono context
375
+ * @returns Atom feed response
376
+ */
377
+ export async function renderMainFeed(c: Context<Env>): Promise<Response> {
352
378
  const kind = c.var.appConfig.mainRssFeed === "latest" ? "latest" : "featured";
353
379
  const feedData = await buildFeedData(c, { kind, selfPath: "/feed" });
354
380
  return renderFeed(defaultFeedRenderer(feedData));
355
- });
381
+ }
382
+
383
+ // Atom — /feed
384
+ feedRoutes.get("/", renderMainFeed);
356
385
 
357
386
  // Legacy — /feed/latest moved to the canonical /latest/feed. Kept
358
387
  // indefinitely as a 308 so old subscribers don't break; preserves the