@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  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__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -13,31 +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,
23
+ ArchiveLayout,
24
24
  ArchiveSort,
25
- ArchiveView,
26
- ArchiveVisibility,
27
25
  } from "../../types/props.js";
28
- import { FORMATS, MEDIA_KINDS } from "../../types.js";
26
+ import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../types.js";
29
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";
30
47
  import { defaultFeedRenderer } from "../../lib/feed.js";
31
48
  import {
49
+ buildFeedDiscoveryFields,
32
50
  getFeedEntryUpdatedAt,
51
+ getFeedLimit,
33
52
  getRssPublishedBefore,
34
- RSS_FEED_CACHE_CONTROL,
53
+ renderFeed,
35
54
  } from "../../lib/feed-policy.js";
36
55
  import { getNavigationData } from "../../lib/navigation.js";
37
56
  import { buildPageTitle } from "../../lib/page-title.js";
38
57
  import { renderPublicPage } from "../../lib/render.js";
39
58
  import { formatYearMonth } from "../../lib/time.js";
40
- 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";
41
66
  import {
42
67
  createMediaContext,
43
68
  toArchiveGroupsWithMedia,
@@ -55,122 +80,167 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
55
80
  // Shared filter parsing
56
81
  // =============================================================================
57
82
 
58
- /** 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
+ */
59
90
  interface ParsedArchiveParams {
60
- format?: Format;
61
- validYear?: number;
62
- collectionSlug?: string;
63
- mediaKinds?: MediaKind[];
64
- hasMedia?: boolean;
65
- hasTitle?: boolean;
66
- hasReplies?: boolean;
67
- visibility?: ArchiveVisibility;
68
- visibilityAll: boolean;
69
- 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;
70
99
  sort: ArchiveSort;
71
100
  currentPage: number;
72
101
  }
73
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
+
74
131
  /**
75
- * Parse archive filter query parameters.
132
+ * Read the archive's query string into a selection plus rendering options.
76
133
  *
77
134
  * @param c - Hono context
78
- * @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
79
137
  * @returns Parsed and validated query parameters
80
138
  */
81
- function parseArchiveParams(
139
+ function readArchiveParams(
82
140
  c: Context<Env>,
83
- queryOverrides?: Record<string, string>,
141
+ read: ParamReader,
142
+ collections: Collection[] | null,
84
143
  ): ParsedArchiveParams {
85
- const q = (key: string): string | undefined =>
86
- queryOverrides ? queryOverrides[key] : c.req.query(key);
87
-
88
- const formatParam = q("format") as Format | undefined;
89
- const format =
90
- formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
91
-
92
- const yearParam = q("year");
93
- const year = yearParam ? parseInt(yearParam, 10) : undefined;
94
- const validYear = year && !isNaN(year) && year > 1970 ? year : undefined;
95
-
96
- const collectionSlug = q("collection") || undefined;
97
-
98
- // Presence filters use single-word params with any/none values
99
- // (media=any|none|<kinds>, title=any|none, replies=any|none). The legacy
100
- // hasMedia/hasTitle/hasReplies=1/0 params are still accepted so old
101
- // bookmarks, feed subscriptions, and stored custom archive URLs keep
102
- // working; new URLs are always generated in the new style.
103
- const parsePresence = (
104
- param: string | undefined,
105
- legacy: string | undefined,
106
- ): boolean | undefined => {
107
- if (param === "any") return true;
108
- if (param === "none") return false;
109
- if (legacy === "1") return true;
110
- if (legacy === "0") return false;
111
- return undefined;
112
- };
113
-
114
- const mediaParam = q("media") || undefined;
115
- const mediaIsPresence = mediaParam === "any" || mediaParam === "none";
116
- const mediaKinds =
117
- mediaParam && !mediaIsPresence
118
- ? (mediaParam
119
- .split(",")
120
- .filter((m): m is MediaKind =>
121
- (MEDIA_KINDS as readonly string[]).includes(m),
122
- ) as MediaKind[])
123
- : undefined;
124
- const hasMedia = parsePresence(
125
- mediaIsPresence ? mediaParam : undefined,
126
- q("hasMedia"),
127
- );
128
-
129
- const hasTitle = parsePresence(q("title"), q("hasTitle"));
130
- const hasReplies = parsePresence(q("replies"), q("hasReplies"));
131
-
132
- const VALID_VISIBILITIES = ["public", "latest_hidden", "private", "featured"];
133
- const rawVisibilityParam = q("visibility");
134
- // "hidden" is the URL spelling of the internal latest_hidden value
135
- const visibilityParam =
136
- rawVisibilityParam === "hidden" ? "latest_hidden" : rawVisibilityParam;
137
- const visibilityAll = visibilityParam === "all";
138
- const visibility =
139
- visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
140
- ? (visibilityParam as ArchiveVisibility)
141
- : undefined;
142
-
143
- const viewParam = q("view") as ArchiveView | undefined;
144
- const view =
145
- viewParam && (viewParam === "grid" || viewParam === "list")
146
- ? viewParam
147
- : 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"));
148
152
 
149
153
  // sort selects the time axis for ordering, month grouping, and the year
150
154
  // filter alike — they must stay on the same column or the month headers
151
155
  // stop agreeing with the order inside them.
152
- const sort: ArchiveSort = q("sort") === "updated" ? "updated" : "published";
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
+ });
153
165
 
154
166
  // Page always comes from the actual request URL (pagination links use ?page=N)
155
167
  const pageParam = c.req.query("page");
156
168
  const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
157
169
 
158
170
  return {
159
- format,
160
- validYear,
161
- collectionSlug,
162
- mediaKinds: mediaKinds && mediaKinds.length > 0 ? mediaKinds : undefined,
163
- hasMedia,
164
- hasTitle,
165
- hasReplies,
166
- visibility,
167
- visibilityAll,
168
- 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,
169
181
  sort,
170
182
  currentPage,
171
183
  };
172
184
  }
173
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
+
174
244
  /**
175
245
  * Build PostFilters from parsed archive params.
176
246
  *
@@ -182,50 +252,31 @@ function buildArchivePostFilters(
182
252
  params: ParsedArchiveParams,
183
253
  opts: {
184
254
  isAuthenticated: boolean;
185
- collectionId?: string;
255
+ lang?: string;
256
+ /** Overrides the request's own selection, for the unfiltered baseline. */
257
+ selection?: PostFilterSelection;
186
258
  },
187
259
  ): PostFilters {
188
- const { isAuthenticated, collectionId } = opts;
189
-
190
- // Map visibility: feed routes force public; page respects auth
191
- // Authenticated users default to showing all visibilities
192
- const effectiveVisibility = isAuthenticated
193
- ? params.visibilityAll
194
- ? undefined
195
- : (params.visibility ?? undefined)
196
- : undefined;
197
-
198
- // The year filter follows the active axis, so every month bucket shown
199
- // under `year=N` really belongs to that year.
200
- const yearRange = params.validYear
201
- ? {
202
- after: Date.UTC(params.validYear, 0, 1) / 1000,
203
- before: Date.UTC(params.validYear + 1, 0, 1) / 1000,
204
- }
205
- : undefined;
260
+ const { isAuthenticated, lang } = opts;
261
+ const selection = opts.selection ?? params.selection;
206
262
  const sortsByActivity = params.sort === "updated";
207
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.
208
269
  return {
209
- format: params.format,
270
+ lang,
210
271
  status: "published",
211
272
  excludeReplies: true,
212
273
  excludePrivate: !isAuthenticated,
213
274
  excludeLatestHidden: false,
214
- ...(effectiveVisibility === "featured"
215
- ? { featured: true }
216
- : effectiveVisibility
217
- ? { visibility: effectiveVisibility }
218
- : {}),
219
- collectionId,
220
- ...(yearRange
221
- ? sortsByActivity
222
- ? { axisAfter: yearRange.after, axisBefore: yearRange.before }
223
- : { publishedAfter: yearRange.after, publishedBefore: yearRange.before }
224
- : {}),
225
- mediaKinds: params.mediaKinds,
226
- hasMedia: params.hasMedia,
227
- hasTitle: params.hasTitle,
228
- hasReplies: params.hasReplies,
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
+ }),
229
280
  // "thread_updated", not "activity": the archive is the canonical all-posts
230
281
  // view, and the quiet-reply switch only promises not to move a Thread on
231
282
  // Latest. Here the honest answer is when the Thread last changed.
@@ -236,26 +287,15 @@ function buildArchivePostFilters(
236
287
 
237
288
  /**
238
289
  * Build a query string from parsed archive params (for feed self-URL and
239
- * archive page feed link). Omits view and page — those shape the rendered
290
+ * archive page feed link). Omits layout and page — those shape the rendered
240
291
  * page, not the result set — but carries `sort`, so the feed button on an
241
292
  * updated-sorted page hands back the matching feed.
242
293
  */
243
- function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
244
- const qs = new URLSearchParams();
245
- if (params.format) qs.set("format", params.format);
246
- if (params.validYear) qs.set("year", String(params.validYear));
247
- if (params.collectionSlug) qs.set("collection", params.collectionSlug);
248
- if (params.mediaKinds && params.mediaKinds.length > 0) {
249
- qs.set("media", params.mediaKinds.join(","));
250
- } else if (params.hasMedia !== undefined) {
251
- qs.set("media", params.hasMedia ? "any" : "none");
252
- }
253
- if (params.hasTitle !== undefined) {
254
- qs.set("title", params.hasTitle ? "any" : "none");
255
- }
256
- if (params.hasReplies !== undefined) {
257
- qs.set("replies", params.hasReplies ? "any" : "none");
258
- }
294
+ function buildArchiveFeedQuery(
295
+ params: ParsedArchiveParams,
296
+ ctx: DimensionContext,
297
+ ): string {
298
+ const qs = serializePostFilterSelection(params.selection, ctx);
259
299
  if (params.sort === "updated") qs.set("sort", "updated");
260
300
  const str = qs.toString();
261
301
  return str ? `?${str}` : "";
@@ -264,19 +304,30 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
264
304
  export const archiveRoutes = new Hono<Env>();
265
305
 
266
306
  /**
267
- * Build a canonical redirect target when a request uses legacy archive
268
- * 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:
269
310
  *
270
- * Only legacy params are rewritten; everything else (including unknown
271
- * params) is preserved. Returns null when the URL is already canonical.
272
- * Applies to the /archive page only — feeds and the public API accept
273
- * legacy spellings silently, and custom archive URLs (path_registry
274
- * query overrides) never reach this path.
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.
318
+ *
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.
275
324
  *
276
325
  * @param c - Hono context
277
- * @returns Canonical path + query to redirect to, or null
326
+ * @returns Redirect target and status code, or null
278
327
  */
279
- function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
328
+ function archiveParamsRedirect(
329
+ c: Context<Env>,
330
+ ): { location: string; status: 302 | 308 } | null {
280
331
  const url = new URL(c.req.url);
281
332
  const params = url.searchParams;
282
333
  let changed = false;
@@ -301,9 +352,48 @@ function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
301
352
  changed = true;
302
353
  }
303
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
+
304
391
  if (!changed) return null;
305
392
  const qs = params.toString();
306
- return `${url.pathname}${qs ? `?${qs}` : ""}`;
393
+ return {
394
+ location: `${url.pathname}${qs ? `?${qs}` : ""}`,
395
+ status: authStripped ? 302 : 308,
396
+ };
307
397
  }
308
398
 
309
399
  // =============================================================================
@@ -323,45 +413,91 @@ export async function renderArchivePage(
323
413
  ): Promise<Response> {
324
414
  const { services, appConfig } = c.var;
325
415
  const pageSize = appConfig.archivePageSize;
326
- const params = parseArchiveParams(c, queryOverrides);
327
-
328
- // --- 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
+ }
329
432
 
330
- const collection = params.collectionSlug
331
- ? await services.collections.getBySlug(params.collectionSlug)
332
- : undefined;
333
- const collectionId = collection?.id;
433
+ if (params.collectionMissing) return c.notFound();
334
434
 
335
435
  const navData = await getNavigationData(c);
336
436
 
337
437
  const filters = buildArchivePostFilters(params, {
338
438
  isAuthenticated: navData.isAuthenticated,
339
- collectionId,
439
+ lang: getViewLang(c) ?? undefined,
340
440
  });
341
441
 
342
442
  // --- Parallel data fetches ------------------------------------------------
343
443
  // List view doesn't need month-based grouping, so skip countByYearMonth.
344
444
 
345
- const isListView = params.view === "list";
346
-
347
- const [totalCount, monthlyCounts, posts, availableYears, allCollections] =
348
- await Promise.all([
349
- services.posts.count(filters),
350
- isListView
351
- ? Promise.resolve([] as { yearMonth: string; count: number }[])
352
- : services.posts.countByYearMonth(filters),
353
- services.posts.list({
354
- ...filters,
355
- limit: pageSize,
356
- offset: (params.currentPage - 1) * pageSize,
357
- }),
358
- services.posts.getDistinctYears({
359
- status: "published",
360
- excludeReplies: true,
361
- sortBy: filters.sortBy,
362
- }),
363
- services.collections.list(),
364
- ]);
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
+ };
365
501
 
366
502
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
367
503
 
@@ -446,57 +582,89 @@ export async function renderArchivePage(
446
582
 
447
583
  // --- Build active filter state for UI -------------------------------------
448
584
 
449
- const effectiveVisibility = navData.isAuthenticated
450
- ? params.visibilityAll
451
- ? undefined
452
- : (params.visibility ?? undefined)
453
- : undefined;
454
-
455
585
  const archiveFilters: ArchiveFilters = {
456
- year: params.validYear,
457
- collectionSlug: params.collectionSlug,
458
- collectionTitle: collection?.title,
459
- format: params.format,
460
- mediaKinds: params.mediaKinds,
461
- hasMedia: params.hasMedia,
462
- hasTitle: params.hasTitle,
463
- hasReplies: params.hasReplies,
464
- visibility: effectiveVisibility,
465
- view: params.view,
586
+ selection: params.selection,
587
+ layout: params.layout,
466
588
  sort: params.sort === "updated" ? "updated" : undefined,
467
589
  };
468
590
 
469
- 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;
470
599
 
471
600
  const availableCollectionsList = allCollections.map((col) => ({
601
+ id: col.id,
472
602
  slug: col.slug,
473
603
  title: col.title,
474
604
  }));
475
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
+
476
626
  return renderPublicPage(c, {
477
- // Distinguishes a bookmarked or shared ?sort=updated view in the tab bar.
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.
478
630
  title: buildPageTitle(
479
- "Archive",
631
+ getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
480
632
  params.sort === "updated" ? "Recently updated" : undefined,
481
633
  navData.siteName,
482
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,
483
651
  navData,
484
652
  content: (
485
653
  <ArchivePage
486
654
  groups={groups}
487
655
  items={flatItems}
656
+ defaultLayout={appConfig.archiveDefaultLayout}
488
657
  totalCount={totalCount}
658
+ baselineCount={baselineCount}
489
659
  currentPage={params.currentPage}
490
660
  totalPages={totalPages}
491
661
  filters={archiveFilters}
492
662
  availableYears={availableYears}
493
663
  availableCollections={availableCollectionsList}
494
664
  isAuthenticated={navData.isAuthenticated}
495
- sitePathPrefix={navData.sitePathPrefix}
665
+ basePath={navData.basePath}
496
666
  timeZone={appConfig.timeZone}
497
- feedHref={
498
- appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
499
- }
667
+ feedHref={feedHref}
500
668
  />
501
669
  ),
502
670
  });
@@ -506,130 +674,51 @@ export async function renderArchivePage(
506
674
  // Archive page route
507
675
  // =============================================================================
508
676
 
509
- archiveRoutes.get("/", (c) => {
510
- const canonical = legacyArchiveParamsRedirect(c);
511
- 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);
512
688
  return renderArchivePage(c);
513
- });
689
+ }
690
+
691
+ archiveRoutes.get("/", renderArchiveRoute);
514
692
 
515
693
  // =============================================================================
516
694
  // Archive feed
517
695
  // =============================================================================
518
696
 
519
697
  /**
520
- * 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.
521
706
  *
522
707
  * @param c - Hono context
523
708
  * @param params - Parsed archive filter params
524
- * @param collectionTitle - Resolved collection title (if any)
525
- * @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"
526
713
  */
527
- function buildArchiveFeedTitle(
714
+ function buildArchiveFeedLabel(
528
715
  c: Context<Env>,
529
716
  params: ParsedArchiveParams,
530
- collectionTitle?: string,
717
+ ctx: DimensionContext,
531
718
  ): string {
532
719
  const i18n = getI18n(c);
533
- const siteName = c.var.appConfig.siteName;
534
-
535
- const parts: string[] = [];
536
-
537
- if (params.format) {
538
- const formatLabels: Record<string, string> = {
539
- note: i18n._(
540
- msg({
541
- message: "Notes",
542
- comment:
543
- "@context: Archive feed title segment for note format filter",
544
- }),
545
- ),
546
- link: i18n._(
547
- msg({
548
- message: "Links",
549
- comment:
550
- "@context: Archive feed title segment for link format filter",
551
- }),
552
- ),
553
- quote: i18n._(
554
- msg({
555
- message: "Quotes",
556
- comment:
557
- "@context: Archive feed title segment for quote format filter",
558
- }),
559
- ),
560
- };
561
- parts.push(formatLabels[params.format] ?? params.format);
562
- }
563
720
 
564
- if (collectionTitle) {
565
- parts.push(collectionTitle);
566
- }
567
-
568
- if (params.hasTitle === false) {
569
- parts.push(
570
- i18n._(
571
- msg({
572
- message: "without title",
573
- comment: "@context: Archive feed title segment for hasTitle=0 filter",
574
- }),
575
- ),
576
- );
577
- } else if (params.hasTitle === true) {
578
- parts.push(
579
- i18n._(
580
- msg({
581
- message: "with title",
582
- comment: "@context: Archive feed title segment for hasTitle=1 filter",
583
- }),
584
- ),
585
- );
586
- }
587
-
588
- if (params.hasMedia === true) {
589
- parts.push(
590
- i18n._(
591
- msg({
592
- message: "with media",
593
- comment: "@context: Archive feed title segment for hasMedia=1 filter",
594
- }),
595
- ),
596
- );
597
- } else if (params.hasMedia === false) {
598
- parts.push(
599
- i18n._(
600
- msg({
601
- message: "without media",
602
- comment: "@context: Archive feed title segment for hasMedia=0 filter",
603
- }),
604
- ),
605
- );
606
- }
607
-
608
- if (params.hasReplies === true) {
609
- parts.push(
610
- i18n._(
611
- msg({
612
- message: "threads",
613
- comment:
614
- "@context: Archive feed title segment for hasReplies=1 filter",
615
- }),
616
- ),
617
- );
618
- } else if (params.hasReplies === false) {
619
- parts.push(
620
- i18n._(
621
- msg({
622
- message: "single posts",
623
- comment:
624
- "@context: Archive feed title segment for hasReplies=0 filter",
625
- }),
626
- ),
627
- );
628
- }
629
-
630
- if (params.validYear) {
631
- parts.push(String(params.validYear));
632
- }
721
+ const parts = describeArchiveFilters(params.selection, i18n, ctx);
633
722
 
634
723
  const archiveLabel = i18n._(
635
724
  msg({
@@ -639,71 +728,67 @@ function buildArchiveFeedTitle(
639
728
  );
640
729
 
641
730
  if (parts.length === 0) {
642
- return `${siteName} - ${archiveLabel}`;
731
+ return archiveLabel;
643
732
  }
644
733
 
645
- return `${siteName} - ${archiveLabel}: ${parts.join(", ")}`;
734
+ return `${archiveLabel}: ${parts.join(", ")}`;
646
735
  }
647
736
 
648
737
  async function buildArchiveFeedData(
649
738
  c: Context<Env>,
650
739
  selfPath: string,
651
- ): Promise<FeedData> {
740
+ ): Promise<FeedData | null> {
652
741
  const { appConfig, services } = c.var;
653
- const params = parseArchiveParams(c);
742
+ const { params, collections } = await parseArchiveParams(c);
654
743
 
655
- const collection = params.collectionSlug
656
- ? await services.collections.getBySlug(params.collectionSlug)
657
- : 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
+ };
658
754
  const rssPublishedBefore = getRssPublishedBefore(
659
755
  appConfig.rssPublishDelaySeconds,
660
756
  );
661
- const yearPublishedBefore = params.validYear
662
- ? Date.UTC(params.validYear + 1, 0, 1) / 1000
663
- : undefined;
664
757
 
665
758
  // Feed mirrors the unauthenticated archive page: published + non-private,
666
759
  // including Hidden-from-Latest. /archive is the canonical "all posts" view,
667
760
  // so its feed must match.
668
761
  //
669
762
  // Ordered by publication by default, like the page it belongs to. Ordering
670
- // by activity would make the feed's contents shift under a fixed
671
- // `rssFeedLimit` — a new reply pulls an old Thread back into the window and
672
- // pushes something else out — and it buys nothing in return, because readers
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
673
766
  // key entries by id and will not re-surface one that merely moved.
674
767
  // /latest/feed is the activity feed; this one is the chronological record.
675
768
  //
676
769
  // `?sort=updated` opts into the same axis the page uses, for a subscriber
677
770
  // who wants that trade-off deliberately.
678
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
+ });
679
778
  const filters: PostFilters = {
680
- format: params.format,
779
+ lang: getViewLang(c) ?? undefined,
681
780
  status: "published",
682
781
  excludeReplies: true,
683
782
  excludePrivate: true,
684
783
  excludeLatestHidden: false,
685
- collectionId: collection?.id,
686
- mediaKinds: params.mediaKinds,
687
- hasMedia: params.hasMedia,
688
- hasTitle: params.hasTitle,
689
- hasReplies: params.hasReplies,
784
+ ...selectionFilters,
690
785
  sortBy: sortsByActivity ? "thread_updated" : "published",
691
786
  ignorePinnedSort: true,
692
- // The year filter follows the active axis; the RSS delay always stays on
693
- // publication, so a just-published post is never announced early.
694
- ...(params.validYear
695
- ? sortsByActivity
696
- ? {
697
- axisAfter: Date.UTC(params.validYear, 0, 1) / 1000,
698
- axisBefore: yearPublishedBefore,
699
- }
700
- : { publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000 }
701
- : {}),
702
787
  publishedBefore:
703
- yearPublishedBefore === undefined || sortsByActivity
788
+ selectionFilters.publishedBefore === undefined
704
789
  ? rssPublishedBefore
705
- : Math.min(yearPublishedBefore, rssPublishedBefore),
706
- limit: appConfig.rssFeedLimit,
790
+ : Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
791
+ limit: getFeedLimit(c),
707
792
  };
708
793
 
709
794
  const posts = await services.posts.list(filters);
@@ -793,16 +878,20 @@ async function buildArchiveFeedData(
793
878
  };
794
879
  });
795
880
 
796
- const feedQuery = buildArchiveFeedQuery(params);
881
+ const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
797
882
 
798
883
  return {
884
+ ...buildFeedDiscoveryFields(c, { query: feedQuery }),
799
885
  siteName: appConfig.siteName,
800
886
  siteDescription: toPlainText(appConfig.siteDescription),
801
887
  siteUrl: appConfig.siteUrl,
802
- siteLanguage: appConfig.siteLanguage,
803
- title: buildArchiveFeedTitle(c, params, collection?.title),
888
+ siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
889
+ title: buildPageTitle(
890
+ appConfig.siteName,
891
+ buildArchiveFeedLabel(c, params, dimensionCtx),
892
+ ),
804
893
  selfUrl: toAbsoluteSiteUrl(
805
- `${selfPath}${feedQuery}`,
894
+ `${viewBasePath(c)}${selfPath}${feedQuery}`,
806
895
  appConfig.siteUrl,
807
896
  appConfig.sitePathPrefix,
808
897
  ),
@@ -810,25 +899,38 @@ async function buildArchiveFeedData(
810
899
  };
811
900
  }
812
901
 
813
- // Atom — /archive/feed
814
- 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
+
815
917
  const feedData = await buildArchiveFeedData(c, "/archive/feed");
816
- return new Response(defaultFeedRenderer(feedData), {
817
- headers: {
818
- "Content-Type": "application/atom+xml; charset=utf-8",
819
- "Cache-Control": RSS_FEED_CACHE_CONTROL,
820
- },
821
- });
822
- });
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);
823
927
 
824
928
  // Legacy atom.xml redirect
825
- archiveRoutes.get("/feed/atom.xml", (c) => {
826
- const sitePathPrefix = c.var.appConfig.sitePathPrefix;
929
+ export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
827
930
  const qs = c.req.url.includes("?")
828
931
  ? c.req.url.slice(c.req.url.indexOf("?"))
829
932
  : "";
830
- return c.redirect(
831
- `${toPublicPath("/archive/feed", sitePathPrefix)}${qs}`,
832
- 308,
833
- );
834
- });
933
+ return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
934
+ }
935
+
936
+ archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);