@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
@@ -13,30 +13,56 @@ import { Hono } from "hono";
13
13
  import type { Context } from "hono";
14
14
  import type {
15
15
  Bindings,
16
+ Collection,
16
17
  FeedData,
17
- Format,
18
- MediaKind,
19
18
  PostWithMedia,
20
19
  } from "../../types.js";
21
20
  import type { AppVariables } from "../../types/app-context.js";
22
21
  import type {
23
22
  ArchiveFilters,
24
- ArchiveView,
25
- ArchiveVisibility,
23
+ ArchiveLayout,
24
+ ArchiveSort,
26
25
  } from "../../types/props.js";
27
- import { FORMATS, MEDIA_KINDS } from "../../types.js";
26
+ import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../types.js";
28
27
  import { ArchivePage } from "../../ui/pages/ArchivePage.js";
28
+ import {
29
+ describeArchiveFilters,
30
+ getArchiveViewTitle,
31
+ hasActiveArchiveFilter,
32
+ } from "../../ui/shared/archive-labels.js";
33
+ import type {
34
+ DimensionContext,
35
+ ParamReader,
36
+ PostFilterSelection,
37
+ } from "../../lib/filter-dimensions.js";
38
+ import {
39
+ buildCollectionVocabulary,
40
+ EMPTY_COLLECTION_VOCABULARY,
41
+ hasFilterSelection,
42
+ parsePostFilterSelection,
43
+ readCollectionSlugs,
44
+ serializePostFilterSelection,
45
+ toPostFilters,
46
+ } from "../../lib/filter-dimensions.js";
29
47
  import { defaultFeedRenderer } from "../../lib/feed.js";
30
48
  import {
49
+ buildFeedDiscoveryFields,
31
50
  getFeedEntryUpdatedAt,
51
+ getFeedLimit,
32
52
  getRssPublishedBefore,
33
- RSS_FEED_CACHE_CONTROL,
53
+ renderFeed,
34
54
  } from "../../lib/feed-policy.js";
35
55
  import { getNavigationData } from "../../lib/navigation.js";
36
56
  import { buildPageTitle } from "../../lib/page-title.js";
37
57
  import { renderPublicPage } from "../../lib/render.js";
38
58
  import { formatYearMonth } from "../../lib/time.js";
39
- import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
59
+ import { toAbsoluteSiteUrl } from "../../lib/url.js";
60
+ import {
61
+ buildSurfaceAlternates,
62
+ getViewLang,
63
+ toViewPath,
64
+ viewBasePath,
65
+ } from "../../lib/view-language.js";
40
66
  import {
41
67
  createMediaContext,
42
68
  toArchiveGroupsWithMedia,
@@ -54,115 +80,167 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
54
80
  // Shared filter parsing
55
81
  // =============================================================================
56
82
 
57
- /** Parsed archive query parameters (before service-level filter conversion). */
83
+ /**
84
+ * One archive request, after the query string has been read.
85
+ *
86
+ * The filter dimensions live in `selection`, spoken in the shared vocabulary
87
+ * (`lib/filter-dimensions.ts`); everything beside it shapes the rendering
88
+ * rather than the result set.
89
+ */
58
90
  interface ParsedArchiveParams {
59
- format?: Format;
60
- validYear?: number;
61
- collectionSlug?: string;
62
- mediaKinds?: MediaKind[];
63
- hasMedia?: boolean;
64
- hasTitle?: boolean;
65
- hasReplies?: boolean;
66
- visibility?: ArchiveVisibility;
67
- visibilityAll: boolean;
68
- view?: ArchiveView;
91
+ selection: PostFilterSelection;
92
+ /**
93
+ * Set when `?collection=` named something this site does not have. The whole
94
+ * request is answered with a 404 rather than rendered — see
95
+ * {@link readArchiveParams}.
96
+ */
97
+ collectionMissing: boolean;
98
+ layout?: ArchiveLayout;
99
+ sort: ArchiveSort;
69
100
  currentPage: number;
70
101
  }
71
102
 
103
+ /** Reads this request's query string, or the stored one a custom path carries. */
104
+ function archiveParamReader(
105
+ c: Context<Env>,
106
+ queryOverrides?: Record<string, string>,
107
+ ): ParamReader {
108
+ return (key) => (queryOverrides ? queryOverrides[key] : c.req.query(key));
109
+ }
110
+
111
+ /**
112
+ * Load the collection vocabulary, but only when this request needs one.
113
+ *
114
+ * Resolving `?collection=` costs a round trip, and the archive renders without
115
+ * one on every request that names no collection — which is most of them, on the
116
+ * site's widest reader page. So the decision is made from the query string
117
+ * alone, before anything is parsed.
118
+ *
119
+ * @param c - Hono context
120
+ * @param read - Query parameter reader
121
+ * @returns Every collection on the site, or null when none was named
122
+ */
123
+ async function loadArchiveCollections(
124
+ c: Context<Env>,
125
+ read: ParamReader,
126
+ ): Promise<Collection[] | null> {
127
+ if (readCollectionSlugs(read).length === 0) return null;
128
+ return c.var.services.collections.list();
129
+ }
130
+
72
131
  /**
73
- * Parse archive filter query parameters.
132
+ * Read the archive's query string into a selection plus rendering options.
74
133
  *
75
134
  * @param c - Hono context
76
- * @param queryOverrides - Optional map of query param overrides (used by custom archive URLs)
135
+ * @param read - Query parameter reader
136
+ * @param collections - Collections to resolve slugs against, when any is named
77
137
  * @returns Parsed and validated query parameters
78
138
  */
79
- function parseArchiveParams(
139
+ function readArchiveParams(
80
140
  c: Context<Env>,
81
- queryOverrides?: Record<string, string>,
141
+ read: ParamReader,
142
+ collections: Collection[] | null,
82
143
  ): ParsedArchiveParams {
83
- const q = (key: string): string | undefined =>
84
- queryOverrides ? queryOverrides[key] : c.req.query(key);
85
-
86
- const formatParam = q("format") as Format | undefined;
87
- const format =
88
- formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
89
-
90
- const yearParam = q("year");
91
- const year = yearParam ? parseInt(yearParam, 10) : undefined;
92
- const validYear = year && !isNaN(year) && year > 1970 ? year : undefined;
93
-
94
- const collectionSlug = q("collection") || undefined;
95
-
96
- // Presence filters use single-word params with any/none values
97
- // (media=any|none|<kinds>, title=any|none, replies=any|none). The legacy
98
- // hasMedia/hasTitle/hasReplies=1/0 params are still accepted so old
99
- // bookmarks, feed subscriptions, and stored custom archive URLs keep
100
- // working; new URLs are always generated in the new style.
101
- const parsePresence = (
102
- param: string | undefined,
103
- legacy: string | undefined,
104
- ): boolean | undefined => {
105
- if (param === "any") return true;
106
- if (param === "none") return false;
107
- if (legacy === "1") return true;
108
- if (legacy === "0") return false;
109
- return undefined;
110
- };
111
-
112
- const mediaParam = q("media") || undefined;
113
- const mediaIsPresence = mediaParam === "any" || mediaParam === "none";
114
- const mediaKinds =
115
- mediaParam && !mediaIsPresence
116
- ? (mediaParam
117
- .split(",")
118
- .filter((m): m is MediaKind =>
119
- (MEDIA_KINDS as readonly string[]).includes(m),
120
- ) as MediaKind[])
121
- : undefined;
122
- const hasMedia = parsePresence(
123
- mediaIsPresence ? mediaParam : undefined,
124
- q("hasMedia"),
125
- );
126
-
127
- const hasTitle = parsePresence(q("title"), q("hasTitle"));
128
- const hasReplies = parsePresence(q("replies"), q("hasReplies"));
129
-
130
- const VALID_VISIBILITIES = ["public", "latest_hidden", "private", "featured"];
131
- const rawVisibilityParam = q("visibility");
132
- // "hidden" is the URL spelling of the internal latest_hidden value
133
- const visibilityParam =
134
- rawVisibilityParam === "hidden" ? "latest_hidden" : rawVisibilityParam;
135
- const visibilityAll = visibilityParam === "all";
136
- const visibility =
137
- visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
138
- ? (visibilityParam as ArchiveVisibility)
139
- : undefined;
140
-
141
- const viewParam = q("view") as ArchiveView | undefined;
142
- const view =
143
- viewParam && (viewParam === "grid" || viewParam === "list")
144
- ? viewParam
145
- : undefined;
144
+ // `layout` is the current spelling; `view` is the pre-rename one, kept
145
+ // readable indefinitely so old bookmarks and stored custom archive URLs keep
146
+ // working. Custom archive URLs bypass the canonical redirect below, so this
147
+ // parser — not the redirect — is what actually keeps them alive. `layout`
148
+ // wins when a URL somehow carries both.
149
+ const readLayout = (value: string | undefined): ArchiveLayout | undefined =>
150
+ value === "grid" || value === "list" ? value : undefined;
151
+ const layout = readLayout(read("layout")) ?? readLayout(read("view"));
152
+
153
+ // sort selects the time axis for ordering, month grouping, and the year
154
+ // filter alike — they must stay on the same column or the month headers
155
+ // stop agreeing with the order inside them.
156
+ const sort: ArchiveSort =
157
+ read("sort") === "updated" ? "updated" : "published";
158
+
159
+ const { selection, issues } = parsePostFilterSelection(read, {
160
+ collections: collections
161
+ ? buildCollectionVocabulary(collections)
162
+ : EMPTY_COLLECTION_VOCABULARY,
163
+ yearAxis: sort === "updated" ? "sort" : "published",
164
+ });
146
165
 
147
166
  // Page always comes from the actual request URL (pagination links use ?page=N)
148
167
  const pageParam = c.req.query("page");
149
168
  const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
150
169
 
151
170
  return {
152
- format,
153
- validYear,
154
- collectionSlug,
155
- mediaKinds: mediaKinds && mediaKinds.length > 0 ? mediaKinds : undefined,
156
- hasMedia,
157
- hasTitle,
158
- hasReplies,
159
- visibility,
160
- visibilityAll,
161
- view,
171
+ selection,
172
+ // A slug that resolves to nothing must never fall through as "no collection
173
+ // filter": that renders the whole archive under a name the reader chose,
174
+ // with the heading, the chip bar, and the feed title all pretending the word
175
+ // was never typed. The same rule `visibility=private` follows — a selection
176
+ // that cannot be honored is answered, not erased. `/collections/{slug}`
177
+ // already answers this exact question with a 404, and so does every caller
178
+ // here.
179
+ collectionMissing: issues.some((issue) => issue.param === "collection"),
180
+ layout,
181
+ sort,
162
182
  currentPage,
163
183
  };
164
184
  }
165
185
 
186
+ /** Read the query string and resolve anything in it that needs the database. */
187
+ async function parseArchiveParams(
188
+ c: Context<Env>,
189
+ queryOverrides?: Record<string, string>,
190
+ ): Promise<{ params: ParsedArchiveParams; collections: Collection[] | null }> {
191
+ const read = archiveParamReader(c, queryOverrides);
192
+ const collections = await loadArchiveCollections(c, read);
193
+ return { params: readArchiveParams(c, read, collections), collections };
194
+ }
195
+
196
+ /**
197
+ * Whether an archive query names a result set only a signed-in reader can see.
198
+ *
199
+ * Answered from the query alone, never from what the database happens to hold.
200
+ * A check that fired only when private posts existed would answer "does this
201
+ * site keep anything private?" to whoever asked.
202
+ *
203
+ * @param params - Parsed query params
204
+ * @returns `true` when rendering this query requires an authenticated reader
205
+ * @example
206
+ * archiveQueryRequiresAuth({ selection: { visibility: "private" }, ... }); // true
207
+ */
208
+ function archiveQueryRequiresAuth(params: ParsedArchiveParams): boolean {
209
+ const visibility = params.selection.visibility;
210
+ if (visibility === undefined) return false;
211
+ return !(PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(
212
+ visibility,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Whether this URL is a selection a reader assembled rather than a page the
218
+ * site publishes.
219
+ *
220
+ * Deliberately not `hasActiveArchiveFilter`, which answers a different
221
+ * question: whether to run the baseline count and render `42 of 1,240`. `sort`
222
+ * does not change that count, so folding it in there would buy a wasted query
223
+ * and the label `1,240 of 1,240`. But `sort` does change which posts land on
224
+ * the first page, and together with `year` it changes the set outright — the
225
+ * year filter follows the sort axis. Two questions, two predicates.
226
+ *
227
+ * A stored query is never reader-assembled: the author declared that path, so
228
+ * it is a page like any other. Same `queryOverrides` seam the auth guard uses.
229
+ *
230
+ * @param params - Parsed query params
231
+ * @param queryOverrides - Stored query from the path registry, when there is one
232
+ * @returns `true` when the URL is one facet of a combinatorial family
233
+ * @example
234
+ * isReaderAssembledArchive({ selection: { format: "note" }, ... }, undefined); // true
235
+ */
236
+ function isReaderAssembledArchive(
237
+ params: ParsedArchiveParams,
238
+ queryOverrides: Record<string, string> | undefined,
239
+ ): boolean {
240
+ if (queryOverrides) return false;
241
+ return hasFilterSelection(params.selection) || params.sort === "updated";
242
+ }
243
+
166
244
  /**
167
245
  * Build PostFilters from parsed archive params.
168
246
  *
@@ -174,69 +252,51 @@ function buildArchivePostFilters(
174
252
  params: ParsedArchiveParams,
175
253
  opts: {
176
254
  isAuthenticated: boolean;
177
- collectionId?: string;
255
+ lang?: string;
256
+ /** Overrides the request's own selection, for the unfiltered baseline. */
257
+ selection?: PostFilterSelection;
178
258
  },
179
259
  ): PostFilters {
180
- const { isAuthenticated, collectionId } = opts;
181
-
182
- // Map visibility: feed routes force public; page respects auth
183
- // Authenticated users default to showing all visibilities
184
- const effectiveVisibility = isAuthenticated
185
- ? params.visibilityAll
186
- ? undefined
187
- : (params.visibility ?? undefined)
188
- : undefined;
189
-
190
- let publishedAfter: number | undefined;
191
- let publishedBefore: number | undefined;
192
- if (params.validYear) {
193
- publishedAfter = Date.UTC(params.validYear, 0, 1) / 1000;
194
- publishedBefore = Date.UTC(params.validYear + 1, 0, 1) / 1000;
195
- }
196
-
260
+ const { isAuthenticated, lang } = opts;
261
+ const selection = opts.selection ?? params.selection;
262
+ const sortsByActivity = params.sort === "updated";
263
+
264
+ // The visibility clause is applied as selected, with no auth-dependent
265
+ // degrade: a query naming `private` has already been redirected or 404'd by
266
+ // the time it reaches here (see `archiveQueryRequiresAuth`). Silently
267
+ // dropping the clause instead would render a different set under the same
268
+ // name. `excludePrivate` below stays the unconditional floor regardless.
197
269
  return {
198
- format: params.format,
270
+ lang,
199
271
  status: "published",
200
272
  excludeReplies: true,
201
273
  excludePrivate: !isAuthenticated,
202
274
  excludeLatestHidden: false,
203
- ...(effectiveVisibility === "featured"
204
- ? { featured: true }
205
- : effectiveVisibility
206
- ? { visibility: effectiveVisibility }
207
- : {}),
208
- collectionId,
209
- publishedAfter,
210
- publishedBefore,
211
- mediaKinds: params.mediaKinds,
212
- hasMedia: params.hasMedia,
213
- hasTitle: params.hasTitle,
214
- hasReplies: params.hasReplies,
215
- sortBy: "published",
275
+ // The year filter follows the active axis, so every month bucket shown
276
+ // under `year=N` really belongs to that year.
277
+ ...toPostFilters(selection, {
278
+ yearAxis: sortsByActivity ? "sort" : "published",
279
+ }),
280
+ // "thread_updated", not "activity": the archive is the canonical all-posts
281
+ // view, and the quiet-reply switch only promises not to move a Thread on
282
+ // Latest. Here the honest answer is when the Thread last changed.
283
+ sortBy: sortsByActivity ? "thread_updated" : "published",
216
284
  ignorePinnedSort: true,
217
285
  };
218
286
  }
219
287
 
220
288
  /**
221
289
  * Build a query string from parsed archive params (for feed self-URL and
222
- * archive page feed link). Omits page-only params (view, page).
290
+ * archive page feed link). Omits layout and page — those shape the rendered
291
+ * page, not the result set — but carries `sort`, so the feed button on an
292
+ * updated-sorted page hands back the matching feed.
223
293
  */
224
- function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
225
- const qs = new URLSearchParams();
226
- if (params.format) qs.set("format", params.format);
227
- if (params.validYear) qs.set("year", String(params.validYear));
228
- if (params.collectionSlug) qs.set("collection", params.collectionSlug);
229
- if (params.mediaKinds && params.mediaKinds.length > 0) {
230
- qs.set("media", params.mediaKinds.join(","));
231
- } else if (params.hasMedia !== undefined) {
232
- qs.set("media", params.hasMedia ? "any" : "none");
233
- }
234
- if (params.hasTitle !== undefined) {
235
- qs.set("title", params.hasTitle ? "any" : "none");
236
- }
237
- if (params.hasReplies !== undefined) {
238
- qs.set("replies", params.hasReplies ? "any" : "none");
239
- }
294
+ function buildArchiveFeedQuery(
295
+ params: ParsedArchiveParams,
296
+ ctx: DimensionContext,
297
+ ): string {
298
+ const qs = serializePostFilterSelection(params.selection, ctx);
299
+ if (params.sort === "updated") qs.set("sort", "updated");
240
300
  const str = qs.toString();
241
301
  return str ? `?${str}` : "";
242
302
  }
@@ -244,19 +304,30 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
244
304
  export const archiveRoutes = new Hono<Env>();
245
305
 
246
306
  /**
247
- * Build a canonical redirect target when a request uses legacy archive
248
- * param spellings (hasMedia/hasTitle/hasReplies=1/0, visibility=latest_hidden).
307
+ * Build a canonical redirect for the archive page when the URL needs one.
308
+ *
309
+ * Two kinds of rewrite happen here, and they do not deserve the same status:
310
+ *
311
+ * - **Legacy spellings** (`hasMedia`/`hasTitle`/`hasReplies=1/0`,
312
+ * `visibility=latest_hidden`, `visibility=all`, `view=`) are unconditional —
313
+ * the old spelling is gone for everyone, so 308.
314
+ * - **`visibility=private` for a signed-out reader** is not. That URL moved
315
+ * for *this* reader, *now*, and has to keep working once the author signs
316
+ * in, so it is a 302. When both apply, the weaker claim wins and everything
317
+ * is still resolved in a single hop.
249
318
  *
250
- * Only legacy params are rewritten; everything else (including unknown
251
- * params) is preserved. Returns null when the URL is already canonical.
252
- * Applies to the /archive page only — feeds and the public API accept
253
- * legacy spellings silently, and custom archive URLs (path_registry
254
- * query overrides) never reach this path.
319
+ * Only these params are touched; everything else, including unknown params, is
320
+ * preserved. Returns null when the URL is already canonical for this reader.
321
+ * Applies to the /archive page only — feeds and the public API accept legacy
322
+ * spellings silently, and custom archive URLs (path_registry query overrides)
323
+ * never reach this path.
255
324
  *
256
325
  * @param c - Hono context
257
- * @returns Canonical path + query to redirect to, or null
326
+ * @returns Redirect target and status code, or null
258
327
  */
259
- function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
328
+ function archiveParamsRedirect(
329
+ c: Context<Env>,
330
+ ): { location: string; status: 302 | 308 } | null {
260
331
  const url = new URL(c.req.url);
261
332
  const params = url.searchParams;
262
333
  let changed = false;
@@ -281,9 +352,48 @@ function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
281
352
  changed = true;
282
353
  }
283
354
 
355
+ // `visibility=all` was a second spelling of "no visibility selected" — it
356
+ // filtered nothing, exactly like an absent parameter, and only the parser
357
+ // knew. Dropped rather than kept alive, on the same reasoning as `view=`.
358
+ if (params.get("visibility") === "all") {
359
+ params.delete("visibility");
360
+ changed = true;
361
+ }
362
+
363
+ // A signed-out reader cannot select `private`, and the one answer that is
364
+ // never right is to drop the clause and render a different set under the
365
+ // same URL. Strip it here so the address bar and the page agree on what the
366
+ // reader is looking at. The decision reads the param and the session only,
367
+ // so it is identical whether or not this site holds a single private post.
368
+ let authStripped = false;
369
+ if (params.get("visibility") === "private" && !c.var.isAuthenticated) {
370
+ params.delete("visibility");
371
+ authStripped = true;
372
+ changed = true;
373
+ }
374
+
375
+ // `view` became `layout` so `view` can name the saved-selection concept.
376
+ // Only a value this page actually renders is carried across; anything else
377
+ // is dropped, which lands the reader on the site default rather than on a
378
+ // URL that keeps a meaningless param alive.
379
+ const legacyLayout = params.get("view");
380
+ if (legacyLayout !== null) {
381
+ if (
382
+ !params.has("layout") &&
383
+ (legacyLayout === "grid" || legacyLayout === "list")
384
+ ) {
385
+ params.set("layout", legacyLayout);
386
+ }
387
+ params.delete("view");
388
+ changed = true;
389
+ }
390
+
284
391
  if (!changed) return null;
285
392
  const qs = params.toString();
286
- return `${url.pathname}${qs ? `?${qs}` : ""}`;
393
+ return {
394
+ location: `${url.pathname}${qs ? `?${qs}` : ""}`,
395
+ status: authStripped ? 302 : 308,
396
+ };
287
397
  }
288
398
 
289
399
  // =============================================================================
@@ -303,44 +413,91 @@ export async function renderArchivePage(
303
413
  ): Promise<Response> {
304
414
  const { services, appConfig } = c.var;
305
415
  const pageSize = appConfig.archivePageSize;
306
- const params = parseArchiveParams(c, queryOverrides);
307
-
308
- // --- Resolve collection slug to ID ----------------------------------------
416
+ const { params, collections: preloadedCollections } =
417
+ await parseArchiveParams(c, queryOverrides);
418
+
419
+ // An owner-stored query is the path's own definition, so there is nothing to
420
+ // redirect to the way the /archive route redirects a reader's own params:
421
+ // the path names a set this reader cannot see, and the honest answer is that
422
+ // it does not exist for them. The presence of `queryOverrides` is exactly
423
+ // what separates the two cases, so every future caller passing a stored
424
+ // query inherits this guard.
425
+ if (
426
+ queryOverrides &&
427
+ archiveQueryRequiresAuth(params) &&
428
+ !c.var.isAuthenticated
429
+ ) {
430
+ return c.notFound();
431
+ }
309
432
 
310
- const collection = params.collectionSlug
311
- ? await services.collections.getBySlug(params.collectionSlug)
312
- : undefined;
313
- const collectionId = collection?.id;
433
+ if (params.collectionMissing) return c.notFound();
314
434
 
315
435
  const navData = await getNavigationData(c);
316
436
 
317
437
  const filters = buildArchivePostFilters(params, {
318
438
  isAuthenticated: navData.isAuthenticated,
319
- collectionId,
439
+ lang: getViewLang(c) ?? undefined,
320
440
  });
321
441
 
322
442
  // --- Parallel data fetches ------------------------------------------------
323
443
  // List view doesn't need month-based grouping, so skip countByYearMonth.
324
444
 
325
- const isListView = params.view === "list";
326
-
327
- const [totalCount, monthlyCounts, posts, availableYears, allCollections] =
328
- await Promise.all([
329
- services.posts.count(filters),
330
- isListView
331
- ? Promise.resolve([] as { yearMonth: string; count: number }[])
332
- : services.posts.countByYearMonth(filters),
333
- services.posts.list({
334
- ...filters,
335
- limit: pageSize,
336
- offset: (params.currentPage - 1) * pageSize,
337
- }),
338
- services.posts.getDistinctYears({
339
- status: "published",
340
- excludeReplies: true,
341
- }),
342
- services.collections.list(),
343
- ]);
445
+ // No layout in the URL means the site's configured default, not grid: the
446
+ // archive is the widest of Featured / Latest / All and the other two are
447
+ // timelines, so `list` is the default default.
448
+ const layout: ArchiveLayout = params.layout ?? appConfig.archiveDefaultLayout;
449
+ const isListView = layout === "list";
450
+
451
+ // The same view with every selection cleared, so the count can say how much
452
+ // the filter removed. Skipped entirely when nothing is filtered — the two
453
+ // numbers would be equal — which keeps the plain /archive page at its
454
+ // current query count. When it does run it is the cheapest of the three
455
+ // aggregates, since clearing the selections is what drops the collection and
456
+ // media EXISTS subqueries.
457
+ const baselineFilters = hasActiveArchiveFilter(params.selection)
458
+ ? buildArchivePostFilters(params, {
459
+ isAuthenticated: navData.isAuthenticated,
460
+ lang: filters.lang,
461
+ selection: {},
462
+ })
463
+ : undefined;
464
+
465
+ const [
466
+ totalCount,
467
+ baselineCount,
468
+ monthlyCounts,
469
+ posts,
470
+ availableYears,
471
+ listedCollections,
472
+ ] = await Promise.all([
473
+ services.posts.count(filters),
474
+ baselineFilters
475
+ ? services.posts.count(baselineFilters)
476
+ : Promise.resolve(undefined),
477
+ isListView
478
+ ? Promise.resolve([] as { yearMonth: string; count: number }[])
479
+ : services.posts.countByYearMonth(filters),
480
+ services.posts.list({
481
+ ...filters,
482
+ limit: pageSize,
483
+ offset: (params.currentPage - 1) * pageSize,
484
+ }),
485
+ services.posts.getDistinctYears({
486
+ status: "published",
487
+ excludeReplies: true,
488
+ lang: filters.lang,
489
+ sortBy: filters.sortBy,
490
+ }),
491
+ // Already loaded when the query named a collection, because parsing needed
492
+ // it. Fetched here otherwise, so an unfiltered archive keeps paying for one
493
+ // round of queries rather than two.
494
+ preloadedCollections ?? services.collections.list(),
495
+ ]);
496
+
497
+ const allCollections = preloadedCollections ?? listedCollections;
498
+ const dimensionCtx: DimensionContext = {
499
+ collections: buildCollectionVocabulary(allCollections),
500
+ };
344
501
 
345
502
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
346
503
 
@@ -365,8 +522,12 @@ export async function renderArchivePage(
365
522
 
366
523
  const grouped = new Map<string, PostWithMedia[]>();
367
524
  for (const post of posts) {
368
- const publishedAt = post.publishedAt ?? post.updatedAt;
369
- const key = formatYearMonth(publishedAt, appConfig.timeZone);
525
+ // Bucket on the same axis the query sorted by, so groups stay ordered.
526
+ const groupedAt =
527
+ params.sort === "updated"
528
+ ? post.threadUpdatedAt
529
+ : (post.publishedAt ?? post.updatedAt);
530
+ const key = formatYearMonth(groupedAt, appConfig.timeZone);
370
531
  if (!grouped.has(key)) {
371
532
  grouped.set(key, []);
372
533
  }
@@ -421,51 +582,89 @@ export async function renderArchivePage(
421
582
 
422
583
  // --- Build active filter state for UI -------------------------------------
423
584
 
424
- const effectiveVisibility = navData.isAuthenticated
425
- ? params.visibilityAll
426
- ? undefined
427
- : (params.visibility ?? undefined)
428
- : undefined;
429
-
430
585
  const archiveFilters: ArchiveFilters = {
431
- year: params.validYear,
432
- collectionSlug: params.collectionSlug,
433
- collectionTitle: collection?.title,
434
- format: params.format,
435
- mediaKinds: params.mediaKinds,
436
- hasMedia: params.hasMedia,
437
- hasTitle: params.hasTitle,
438
- hasReplies: params.hasReplies,
439
- visibility: effectiveVisibility,
440
- view: params.view,
586
+ selection: params.selection,
587
+ layout: params.layout,
588
+ sort: params.sort === "updated" ? "updated" : undefined,
441
589
  };
442
590
 
443
- const feedQuery = buildArchiveFeedQuery(params);
591
+ const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
592
+ // A feed is unauthenticated by construction, so it cannot serve a selection
593
+ // only the author can see. Offering the link anyway would promise a feed
594
+ // that answers 404.
595
+ const feedHref =
596
+ appConfig.rssFeedsEnabled && !archiveQueryRequiresAuth(params)
597
+ ? `/archive/feed${feedQuery}`
598
+ : undefined;
444
599
 
445
600
  const availableCollectionsList = allCollections.map((col) => ({
601
+ id: col.id,
446
602
  slug: col.slug,
447
603
  title: col.title,
448
604
  }));
449
605
 
606
+ // A facet is one URL out of a family with no ceiling: `media` alone is a
607
+ // comma-joined subset of kinds, multiplied by year x collection x format x
608
+ // title x replies x visibility x sort. None of them is a page the site
609
+ // publishes, and the archive sits in no cache, so every one a crawler walks
610
+ // is a Worker invocation and seven D1 queries. `follow`, not `nofollow`:
611
+ // the posts these URLs link to are the real pages.
612
+ const readerAssembled = isReaderAssembledArchive(params, queryOverrides);
613
+ if (readerAssembled) c.header("X-Robots-Tag", "noindex, follow");
614
+
615
+ // Everything left over renders the same posts in the same order as the bare
616
+ // path — `layout` is markup only, `visibility=all` selects nothing, and
617
+ // tracking params select nothing either — so they all consolidate onto it.
618
+ // `page` is the exception and stays: page 2 is different content, not a
619
+ // different rendering of page 1.
620
+ const canonicalQuery =
621
+ params.currentPage > 1 ? `?page=${params.currentPage}` : "";
622
+ const canonicalHref = readerAssembled
623
+ ? undefined
624
+ : `${toAbsoluteSiteUrl(c.req.path, appConfig.siteUrl, appConfig.sitePathPrefix)}${canonicalQuery}`;
625
+
450
626
  return renderPublicPage(c, {
451
- title: buildPageTitle("Archive", navData.siteName),
627
+ // A tab has no filter bar beside it, so unlike the page heading this has to
628
+ // name the selection itself — otherwise every bookmarked filtered view
629
+ // reads identically in the tab bar.
630
+ title: buildPageTitle(
631
+ getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
632
+ params.sort === "updated" ? "Recently updated" : undefined,
633
+ navData.siteName,
634
+ ),
635
+ // An hreflang set is made of canonical URLs, so it mirrors the canonical
636
+ // exactly: the same trimmed query where there is one, and nothing at all
637
+ // on a facet, which has no canonical for the set to be built from.
638
+ alternateLanguages: readerAssembled
639
+ ? undefined
640
+ : buildSurfaceAlternates(c, { query: canonicalQuery }),
641
+ noindex: readerAssembled ? "follow" : undefined,
642
+ canonicalHref,
643
+ // The filters are part of the feed: a reader subscribing from a filtered
644
+ // archive gets that filtered feed, not the whole archive.
645
+ pageFeed: feedHref
646
+ ? {
647
+ href: toViewPath(c, feedHref),
648
+ title: buildArchiveFeedLabel(c, params, dimensionCtx),
649
+ }
650
+ : undefined,
452
651
  navData,
453
652
  content: (
454
653
  <ArchivePage
455
654
  groups={groups}
456
655
  items={flatItems}
656
+ defaultLayout={appConfig.archiveDefaultLayout}
457
657
  totalCount={totalCount}
658
+ baselineCount={baselineCount}
458
659
  currentPage={params.currentPage}
459
660
  totalPages={totalPages}
460
661
  filters={archiveFilters}
461
662
  availableYears={availableYears}
462
663
  availableCollections={availableCollectionsList}
463
664
  isAuthenticated={navData.isAuthenticated}
464
- sitePathPrefix={navData.sitePathPrefix}
665
+ basePath={navData.basePath}
465
666
  timeZone={appConfig.timeZone}
466
- feedHref={
467
- appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
468
- }
667
+ feedHref={feedHref}
469
668
  />
470
669
  ),
471
670
  });
@@ -475,130 +674,51 @@ export async function renderArchivePage(
475
674
  // Archive page route
476
675
  // =============================================================================
477
676
 
478
- archiveRoutes.get("/", (c) => {
479
- const canonical = legacyArchiveParamsRedirect(c);
480
- if (canonical) return c.redirect(canonical, 308);
677
+ /**
678
+ * Serve the archive page, first normalizing any legacy query parameters.
679
+ *
680
+ * @param c - Hono context
681
+ * @returns Archive page response, or a 308 to the canonical parameter spelling
682
+ */
683
+ export function renderArchiveRoute(
684
+ c: Context<Env>,
685
+ ): Promise<Response> | Response {
686
+ const redirect = archiveParamsRedirect(c);
687
+ if (redirect) return c.redirect(redirect.location, redirect.status);
481
688
  return renderArchivePage(c);
482
- });
689
+ }
690
+
691
+ archiveRoutes.get("/", renderArchiveRoute);
483
692
 
484
693
  // =============================================================================
485
694
  // Archive feed
486
695
  // =============================================================================
487
696
 
488
697
  /**
489
- * Build a descriptive feed title from active filters.
698
+ * Build a descriptive label for the archive feed from the active filters.
699
+ *
700
+ * Shared by the feed document's own title and the autodiscovery link on the
701
+ * archive page, so a subscriber sees the same name in both places.
702
+ *
703
+ * Unlike the page title this lists every active dimension. A feed name sits in
704
+ * someone's reader for years with no UI to inspect it, so two subscriptions
705
+ * that differ only in a dropped dimension must not arrive under one name.
490
706
  *
491
707
  * @param c - Hono context
492
708
  * @param params - Parsed archive filter params
493
- * @param collectionTitle - Resolved collection title (if any)
494
- * @returns Feed title string, e.g. "Site - Archive: Notes without title"
709
+ * @param ctx - Collection vocabulary, for naming a selected collection
710
+ * @returns Feed label, e.g. "Archive: Untitled, 2024"
711
+ * @example
712
+ * buildArchiveFeedLabel(c, params); // "Archive"
495
713
  */
496
- function buildArchiveFeedTitle(
714
+ function buildArchiveFeedLabel(
497
715
  c: Context<Env>,
498
716
  params: ParsedArchiveParams,
499
- collectionTitle?: string,
717
+ ctx: DimensionContext,
500
718
  ): string {
501
719
  const i18n = getI18n(c);
502
- const siteName = c.var.appConfig.siteName;
503
-
504
- const parts: string[] = [];
505
-
506
- if (params.format) {
507
- const formatLabels: Record<string, string> = {
508
- note: i18n._(
509
- msg({
510
- message: "Notes",
511
- comment:
512
- "@context: Archive feed title segment for note format filter",
513
- }),
514
- ),
515
- link: i18n._(
516
- msg({
517
- message: "Links",
518
- comment:
519
- "@context: Archive feed title segment for link format filter",
520
- }),
521
- ),
522
- quote: i18n._(
523
- msg({
524
- message: "Quotes",
525
- comment:
526
- "@context: Archive feed title segment for quote format filter",
527
- }),
528
- ),
529
- };
530
- parts.push(formatLabels[params.format] ?? params.format);
531
- }
532
720
 
533
- if (collectionTitle) {
534
- parts.push(collectionTitle);
535
- }
536
-
537
- if (params.hasTitle === false) {
538
- parts.push(
539
- i18n._(
540
- msg({
541
- message: "without title",
542
- comment: "@context: Archive feed title segment for hasTitle=0 filter",
543
- }),
544
- ),
545
- );
546
- } else if (params.hasTitle === true) {
547
- parts.push(
548
- i18n._(
549
- msg({
550
- message: "with title",
551
- comment: "@context: Archive feed title segment for hasTitle=1 filter",
552
- }),
553
- ),
554
- );
555
- }
556
-
557
- if (params.hasMedia === true) {
558
- parts.push(
559
- i18n._(
560
- msg({
561
- message: "with media",
562
- comment: "@context: Archive feed title segment for hasMedia=1 filter",
563
- }),
564
- ),
565
- );
566
- } else if (params.hasMedia === false) {
567
- parts.push(
568
- i18n._(
569
- msg({
570
- message: "without media",
571
- comment: "@context: Archive feed title segment for hasMedia=0 filter",
572
- }),
573
- ),
574
- );
575
- }
576
-
577
- if (params.hasReplies === true) {
578
- parts.push(
579
- i18n._(
580
- msg({
581
- message: "threads",
582
- comment:
583
- "@context: Archive feed title segment for hasReplies=1 filter",
584
- }),
585
- ),
586
- );
587
- } else if (params.hasReplies === false) {
588
- parts.push(
589
- i18n._(
590
- msg({
591
- message: "single posts",
592
- comment:
593
- "@context: Archive feed title segment for hasReplies=0 filter",
594
- }),
595
- ),
596
- );
597
- }
598
-
599
- if (params.validYear) {
600
- parts.push(String(params.validYear));
601
- }
721
+ const parts = describeArchiveFilters(params.selection, i18n, ctx);
602
722
 
603
723
  const archiveLabel = i18n._(
604
724
  msg({
@@ -608,54 +728,67 @@ function buildArchiveFeedTitle(
608
728
  );
609
729
 
610
730
  if (parts.length === 0) {
611
- return `${siteName} - ${archiveLabel}`;
731
+ return archiveLabel;
612
732
  }
613
733
 
614
- return `${siteName} - ${archiveLabel}: ${parts.join(", ")}`;
734
+ return `${archiveLabel}: ${parts.join(", ")}`;
615
735
  }
616
736
 
617
737
  async function buildArchiveFeedData(
618
738
  c: Context<Env>,
619
739
  selfPath: string,
620
- ): Promise<FeedData> {
740
+ ): Promise<FeedData | null> {
621
741
  const { appConfig, services } = c.var;
622
- const params = parseArchiveParams(c);
742
+ const { params, collections } = await parseArchiveParams(c);
623
743
 
624
- const collection = params.collectionSlug
625
- ? await services.collections.getBySlug(params.collectionSlug)
626
- : undefined;
744
+ // A collection slug that names nothing: the caller turns this into the same
745
+ // 404 the page gives, rather than handing a subscriber the entire archive
746
+ // under the collection's name.
747
+ if (params.collectionMissing) return null;
748
+
749
+ const dimensionCtx: DimensionContext = {
750
+ collections: collections
751
+ ? buildCollectionVocabulary(collections)
752
+ : EMPTY_COLLECTION_VOCABULARY,
753
+ };
627
754
  const rssPublishedBefore = getRssPublishedBefore(
628
755
  appConfig.rssPublishDelaySeconds,
629
756
  );
630
- const yearPublishedBefore = params.validYear
631
- ? Date.UTC(params.validYear + 1, 0, 1) / 1000
632
- : undefined;
633
757
 
634
758
  // Feed mirrors the unauthenticated archive page: published + non-private,
635
759
  // including Hidden-from-Latest. /archive is the canonical "all posts" view,
636
760
  // so its feed must match.
761
+ //
762
+ // Ordered by publication by default, like the page it belongs to. Ordering
763
+ // by activity would make the feed's contents shift under a fixed length — a
764
+ // new reply pulls an old Thread back into the window and pushes something
765
+ // else out — and it buys nothing in return, because readers
766
+ // key entries by id and will not re-surface one that merely moved.
767
+ // /latest/feed is the activity feed; this one is the chronological record.
768
+ //
769
+ // `?sort=updated` opts into the same axis the page uses, for a subscriber
770
+ // who wants that trade-off deliberately.
771
+ const sortsByActivity = params.sort === "updated";
772
+ // The year filter follows the active axis; the RSS delay always stays on
773
+ // publication, so a just-published post is never announced early. When both
774
+ // bound the publication column, the tighter one wins.
775
+ const selectionFilters = toPostFilters(params.selection, {
776
+ yearAxis: sortsByActivity ? "sort" : "published",
777
+ });
637
778
  const filters: PostFilters = {
638
- format: params.format,
779
+ lang: getViewLang(c) ?? undefined,
639
780
  status: "published",
640
781
  excludeReplies: true,
641
782
  excludePrivate: true,
642
783
  excludeLatestHidden: false,
643
- collectionId: collection?.id,
644
- mediaKinds: params.mediaKinds,
645
- hasMedia: params.hasMedia,
646
- hasTitle: params.hasTitle,
647
- hasReplies: params.hasReplies,
784
+ ...selectionFilters,
785
+ sortBy: sortsByActivity ? "thread_updated" : "published",
648
786
  ignorePinnedSort: true,
649
- ...(params.validYear
650
- ? {
651
- publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
652
- }
653
- : {}),
654
787
  publishedBefore:
655
- yearPublishedBefore === undefined
788
+ selectionFilters.publishedBefore === undefined
656
789
  ? rssPublishedBefore
657
- : Math.min(yearPublishedBefore, rssPublishedBefore),
658
- limit: appConfig.rssFeedLimit,
790
+ : Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
791
+ limit: getFeedLimit(c),
659
792
  };
660
793
 
661
794
  const posts = await services.posts.list(filters);
@@ -745,16 +878,20 @@ async function buildArchiveFeedData(
745
878
  };
746
879
  });
747
880
 
748
- const feedQuery = buildArchiveFeedQuery(params);
881
+ const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
749
882
 
750
883
  return {
884
+ ...buildFeedDiscoveryFields(c, { query: feedQuery }),
751
885
  siteName: appConfig.siteName,
752
886
  siteDescription: toPlainText(appConfig.siteDescription),
753
887
  siteUrl: appConfig.siteUrl,
754
- siteLanguage: appConfig.siteLanguage,
755
- title: buildArchiveFeedTitle(c, params, collection?.title),
888
+ siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
889
+ title: buildPageTitle(
890
+ appConfig.siteName,
891
+ buildArchiveFeedLabel(c, params, dimensionCtx),
892
+ ),
756
893
  selfUrl: toAbsoluteSiteUrl(
757
- `${selfPath}${feedQuery}`,
894
+ `${viewBasePath(c)}${selfPath}${feedQuery}`,
758
895
  appConfig.siteUrl,
759
896
  appConfig.sitePathPrefix,
760
897
  ),
@@ -762,25 +899,38 @@ async function buildArchiveFeedData(
762
899
  };
763
900
  }
764
901
 
765
- // Atom — /archive/feed
766
- archiveRoutes.get("/feed", async (c) => {
902
+ /**
903
+ * Render the archive Atom feed for the current view language.
904
+ *
905
+ * @param c - Hono context
906
+ * @returns Atom feed response
907
+ */
908
+ export async function renderArchiveFeed(c: Context<Env>): Promise<Response> {
909
+ // No session reaches a feed reader, so a selection only the author can see
910
+ // has no honest rendering here — not an empty feed, and certainly not the
911
+ // unfiltered one. The page already withholds the link; this is the backstop.
912
+ // Read without resolving anything: the guard reads the visibility param and
913
+ // the session, so it must not depend on a database round trip.
914
+ const guardParams = readArchiveParams(c, archiveParamReader(c), null);
915
+ if (archiveQueryRequiresAuth(guardParams)) return c.notFound();
916
+
767
917
  const feedData = await buildArchiveFeedData(c, "/archive/feed");
768
- return new Response(defaultFeedRenderer(feedData), {
769
- headers: {
770
- "Content-Type": "application/atom+xml; charset=utf-8",
771
- "Cache-Control": RSS_FEED_CACHE_CONTROL,
772
- },
773
- });
774
- });
918
+ // A collection slug that names nothing: the same 404 the page gives, rather
919
+ // than handing a subscriber the entire archive under the collection's name.
920
+ if (!feedData) return c.notFound();
921
+
922
+ return renderFeed(defaultFeedRenderer(feedData));
923
+ }
924
+
925
+ // Atom — /archive/feed
926
+ archiveRoutes.get("/feed", renderArchiveFeed);
775
927
 
776
928
  // Legacy atom.xml redirect
777
- archiveRoutes.get("/feed/atom.xml", (c) => {
778
- const sitePathPrefix = c.var.appConfig.sitePathPrefix;
929
+ export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
779
930
  const qs = c.req.url.includes("?")
780
931
  ? c.req.url.slice(c.req.url.indexOf("?"))
781
932
  : "";
782
- return c.redirect(
783
- `${toPublicPath("/archive/feed", sitePathPrefix)}${qs}`,
784
- 308,
785
- );
786
- });
933
+ return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
934
+ }
935
+
936
+ archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);