@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. 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:
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.
269
318
  *
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.
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,56 +413,101 @@ 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
- const navData = await getNavigationData(c);
435
+ // The chrome is independent of every query below, so it goes out with them
436
+ // rather than ahead of them. Its `isAuthenticated` is `c.var.isAuthenticated`
437
+ // verbatim, which is what the filters below read.
438
+ const navDataPromise = getNavigationData(c);
439
+ const isAuthenticated = c.var.isAuthenticated;
336
440
 
337
441
  const filters = buildArchivePostFilters(params, {
338
- isAuthenticated: navData.isAuthenticated,
339
- collectionId,
442
+ isAuthenticated,
443
+ lang: getViewLang(c) ?? undefined,
340
444
  });
341
445
 
342
446
  // --- Parallel data fetches ------------------------------------------------
343
447
  // List view doesn't need month-based grouping, so skip countByYearMonth.
344
448
 
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
- ]);
449
+ // No layout in the URL means the site's configured default, not grid: the
450
+ // archive is the widest of Featured / Latest / All and the other two are
451
+ // timelines, so `list` is the default default.
452
+ const layout: ArchiveLayout = params.layout ?? appConfig.archiveDefaultLayout;
453
+ const isListView = layout === "list";
454
+
455
+ // The same view with every selection cleared, so the count can say how much
456
+ // the filter removed. Skipped entirely when nothing is filtered — the two
457
+ // numbers would be equal — which keeps the plain /archive page at its
458
+ // current query count. When it does run it is the cheapest of the three
459
+ // aggregates, since clearing the selections is what drops the collection and
460
+ // media EXISTS subqueries.
461
+ const baselineFilters = hasActiveArchiveFilter(params.selection)
462
+ ? buildArchivePostFilters(params, {
463
+ isAuthenticated,
464
+ lang: filters.lang,
465
+ selection: {},
466
+ })
467
+ : undefined;
468
+
469
+ const [
470
+ navData,
471
+ totalCount,
472
+ baselineCount,
473
+ monthlyCounts,
474
+ posts,
475
+ availableYears,
476
+ listedCollections,
477
+ ] = await Promise.all([
478
+ navDataPromise,
479
+ services.posts.count(filters),
480
+ baselineFilters
481
+ ? services.posts.count(baselineFilters)
482
+ : Promise.resolve(undefined),
483
+ isListView
484
+ ? Promise.resolve([] as { yearMonth: string; count: number }[])
485
+ : services.posts.countByYearMonth(filters),
486
+ services.posts.list({
487
+ ...filters,
488
+ limit: pageSize,
489
+ offset: (params.currentPage - 1) * pageSize,
490
+ }),
491
+ services.posts.getDistinctYears({
492
+ status: "published",
493
+ excludeReplies: true,
494
+ lang: filters.lang,
495
+ sortBy: filters.sortBy,
496
+ }),
497
+ // Already loaded when the query named a collection, because parsing needed
498
+ // it. Fetched here otherwise, so an unfiltered archive keeps paying for one
499
+ // round of queries rather than two.
500
+ preloadedCollections ?? services.collections.list(),
501
+ ]);
502
+
503
+ const allCollections = preloadedCollections ?? listedCollections;
504
+ const dimensionCtx: DimensionContext = {
505
+ collections: buildCollectionVocabulary(allCollections),
506
+ };
365
507
 
366
508
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
367
509
 
368
510
  const mediaCtx = createMediaContext(appConfig);
369
- const allPostIds = posts.map((p) => p.id);
370
- const archiveAliasesMap =
371
- await c.var.services.paths.getPostAliases(allPostIds);
372
- const archiveAliasMap = new Map<string, string>();
373
- for (const [id, aliases] of archiveAliasesMap) {
374
- if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
375
- }
376
511
 
377
512
  // --- List view: flat timeline items (no month grouping) ------------------
378
513
 
@@ -407,10 +542,17 @@ export async function renderArchivePage(
407
542
  );
408
543
 
409
544
  const postIds = posts.map((p) => p.id);
410
- const [rawMediaMap, replyCounts] = await Promise.all([
545
+ // Only the grid renders permalinks from this map. The list view goes
546
+ // through `assembleTimelineItems`, which loads the same aliases itself.
547
+ const [rawMediaMap, replyCounts, archiveAliasesMap] = await Promise.all([
411
548
  services.media.getByPostIds(postIds),
412
549
  services.posts.getReplyCounts(postIds),
550
+ services.paths.getPostAliases(postIds),
413
551
  ]);
552
+ const archiveAliasMap = new Map<string, string>();
553
+ for (const [id, aliases] of archiveAliasesMap) {
554
+ if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
555
+ }
414
556
  const mediaMap = buildMediaMap(
415
557
  rawMediaMap,
416
558
  mediaCtx.r2PublicUrl,
@@ -446,57 +588,89 @@ export async function renderArchivePage(
446
588
 
447
589
  // --- Build active filter state for UI -------------------------------------
448
590
 
449
- const effectiveVisibility = navData.isAuthenticated
450
- ? params.visibilityAll
451
- ? undefined
452
- : (params.visibility ?? undefined)
453
- : undefined;
454
-
455
591
  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,
592
+ selection: params.selection,
593
+ layout: params.layout,
466
594
  sort: params.sort === "updated" ? "updated" : undefined,
467
595
  };
468
596
 
469
- const feedQuery = buildArchiveFeedQuery(params);
597
+ const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
598
+ // A feed is unauthenticated by construction, so it cannot serve a selection
599
+ // only the author can see. Offering the link anyway would promise a feed
600
+ // that answers 404.
601
+ const feedHref =
602
+ appConfig.rssFeedsEnabled && !archiveQueryRequiresAuth(params)
603
+ ? `/archive/feed${feedQuery}`
604
+ : undefined;
470
605
 
471
606
  const availableCollectionsList = allCollections.map((col) => ({
607
+ id: col.id,
472
608
  slug: col.slug,
473
609
  title: col.title,
474
610
  }));
475
611
 
612
+ // A facet is one URL out of a family with no ceiling: `media` alone is a
613
+ // comma-joined subset of kinds, multiplied by year x collection x format x
614
+ // title x replies x visibility x sort. None of them is a page the site
615
+ // publishes, and the archive sits in no cache, so every one a crawler walks
616
+ // is a Worker invocation and seven D1 queries. `follow`, not `nofollow`:
617
+ // the posts these URLs link to are the real pages.
618
+ const readerAssembled = isReaderAssembledArchive(params, queryOverrides);
619
+ if (readerAssembled) c.header("X-Robots-Tag", "noindex, follow");
620
+
621
+ // Everything left over renders the same posts in the same order as the bare
622
+ // path — `layout` is markup only, `visibility=all` selects nothing, and
623
+ // tracking params select nothing either — so they all consolidate onto it.
624
+ // `page` is the exception and stays: page 2 is different content, not a
625
+ // different rendering of page 1.
626
+ const canonicalQuery =
627
+ params.currentPage > 1 ? `?page=${params.currentPage}` : "";
628
+ const canonicalHref = readerAssembled
629
+ ? undefined
630
+ : `${toAbsoluteSiteUrl(c.req.path, appConfig.siteUrl, appConfig.sitePathPrefix)}${canonicalQuery}`;
631
+
476
632
  return renderPublicPage(c, {
477
- // Distinguishes a bookmarked or shared ?sort=updated view in the tab bar.
633
+ // A tab has no filter bar beside it, so unlike the page heading this has to
634
+ // name the selection itself — otherwise every bookmarked filtered view
635
+ // reads identically in the tab bar.
478
636
  title: buildPageTitle(
479
- "Archive",
637
+ getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
480
638
  params.sort === "updated" ? "Recently updated" : undefined,
481
639
  navData.siteName,
482
640
  ),
641
+ // An hreflang set is made of canonical URLs, so it mirrors the canonical
642
+ // exactly: the same trimmed query where there is one, and nothing at all
643
+ // on a facet, which has no canonical for the set to be built from.
644
+ alternateLanguages: readerAssembled
645
+ ? undefined
646
+ : buildSurfaceAlternates(c, { query: canonicalQuery }),
647
+ noindex: readerAssembled ? "follow" : undefined,
648
+ canonicalHref,
649
+ // The filters are part of the feed: a reader subscribing from a filtered
650
+ // archive gets that filtered feed, not the whole archive.
651
+ pageFeed: feedHref
652
+ ? {
653
+ href: toViewPath(c, feedHref),
654
+ title: buildArchiveFeedLabel(c, params, dimensionCtx),
655
+ }
656
+ : undefined,
483
657
  navData,
484
658
  content: (
485
659
  <ArchivePage
486
660
  groups={groups}
487
661
  items={flatItems}
662
+ defaultLayout={appConfig.archiveDefaultLayout}
488
663
  totalCount={totalCount}
664
+ baselineCount={baselineCount}
489
665
  currentPage={params.currentPage}
490
666
  totalPages={totalPages}
491
667
  filters={archiveFilters}
492
668
  availableYears={availableYears}
493
669
  availableCollections={availableCollectionsList}
494
670
  isAuthenticated={navData.isAuthenticated}
495
- sitePathPrefix={navData.sitePathPrefix}
671
+ basePath={navData.basePath}
496
672
  timeZone={appConfig.timeZone}
497
- feedHref={
498
- appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
499
- }
673
+ feedHref={feedHref}
500
674
  />
501
675
  ),
502
676
  });
@@ -506,130 +680,51 @@ export async function renderArchivePage(
506
680
  // Archive page route
507
681
  // =============================================================================
508
682
 
509
- archiveRoutes.get("/", (c) => {
510
- const canonical = legacyArchiveParamsRedirect(c);
511
- if (canonical) return c.redirect(canonical, 308);
683
+ /**
684
+ * Serve the archive page, first normalizing any legacy query parameters.
685
+ *
686
+ * @param c - Hono context
687
+ * @returns Archive page response, or a 308 to the canonical parameter spelling
688
+ */
689
+ export function renderArchiveRoute(
690
+ c: Context<Env>,
691
+ ): Promise<Response> | Response {
692
+ const redirect = archiveParamsRedirect(c);
693
+ if (redirect) return c.redirect(redirect.location, redirect.status);
512
694
  return renderArchivePage(c);
513
- });
695
+ }
696
+
697
+ archiveRoutes.get("/", renderArchiveRoute);
514
698
 
515
699
  // =============================================================================
516
700
  // Archive feed
517
701
  // =============================================================================
518
702
 
519
703
  /**
520
- * Build a descriptive feed title from active filters.
704
+ * Build a descriptive label for the archive feed from the active filters.
705
+ *
706
+ * Shared by the feed document's own title and the autodiscovery link on the
707
+ * archive page, so a subscriber sees the same name in both places.
708
+ *
709
+ * Unlike the page title this lists every active dimension. A feed name sits in
710
+ * someone's reader for years with no UI to inspect it, so two subscriptions
711
+ * that differ only in a dropped dimension must not arrive under one name.
521
712
  *
522
713
  * @param c - Hono context
523
714
  * @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"
715
+ * @param ctx - Collection vocabulary, for naming a selected collection
716
+ * @returns Feed label, e.g. "Archive: Untitled, 2024"
717
+ * @example
718
+ * buildArchiveFeedLabel(c, params); // "Archive"
526
719
  */
527
- function buildArchiveFeedTitle(
720
+ function buildArchiveFeedLabel(
528
721
  c: Context<Env>,
529
722
  params: ParsedArchiveParams,
530
- collectionTitle?: string,
723
+ ctx: DimensionContext,
531
724
  ): string {
532
725
  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
-
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
726
 
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
- }
727
+ const parts = describeArchiveFilters(params.selection, i18n, ctx);
633
728
 
634
729
  const archiveLabel = i18n._(
635
730
  msg({
@@ -639,71 +734,67 @@ function buildArchiveFeedTitle(
639
734
  );
640
735
 
641
736
  if (parts.length === 0) {
642
- return `${siteName} - ${archiveLabel}`;
737
+ return archiveLabel;
643
738
  }
644
739
 
645
- return `${siteName} - ${archiveLabel}: ${parts.join(", ")}`;
740
+ return `${archiveLabel}: ${parts.join(", ")}`;
646
741
  }
647
742
 
648
743
  async function buildArchiveFeedData(
649
744
  c: Context<Env>,
650
745
  selfPath: string,
651
- ): Promise<FeedData> {
746
+ ): Promise<FeedData | null> {
652
747
  const { appConfig, services } = c.var;
653
- const params = parseArchiveParams(c);
748
+ const { params, collections } = await parseArchiveParams(c);
654
749
 
655
- const collection = params.collectionSlug
656
- ? await services.collections.getBySlug(params.collectionSlug)
657
- : undefined;
750
+ // A collection slug that names nothing: the caller turns this into the same
751
+ // 404 the page gives, rather than handing a subscriber the entire archive
752
+ // under the collection's name.
753
+ if (params.collectionMissing) return null;
754
+
755
+ const dimensionCtx: DimensionContext = {
756
+ collections: collections
757
+ ? buildCollectionVocabulary(collections)
758
+ : EMPTY_COLLECTION_VOCABULARY,
759
+ };
658
760
  const rssPublishedBefore = getRssPublishedBefore(
659
761
  appConfig.rssPublishDelaySeconds,
660
762
  );
661
- const yearPublishedBefore = params.validYear
662
- ? Date.UTC(params.validYear + 1, 0, 1) / 1000
663
- : undefined;
664
763
 
665
764
  // Feed mirrors the unauthenticated archive page: published + non-private,
666
765
  // including Hidden-from-Latest. /archive is the canonical "all posts" view,
667
766
  // so its feed must match.
668
767
  //
669
768
  // 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
769
+ // by activity would make the feed's contents shift under a fixed length — a
770
+ // new reply pulls an old Thread back into the window and pushes something
771
+ // else out — and it buys nothing in return, because readers
673
772
  // key entries by id and will not re-surface one that merely moved.
674
773
  // /latest/feed is the activity feed; this one is the chronological record.
675
774
  //
676
775
  // `?sort=updated` opts into the same axis the page uses, for a subscriber
677
776
  // who wants that trade-off deliberately.
678
777
  const sortsByActivity = params.sort === "updated";
778
+ // The year filter follows the active axis; the RSS delay always stays on
779
+ // publication, so a just-published post is never announced early. When both
780
+ // bound the publication column, the tighter one wins.
781
+ const selectionFilters = toPostFilters(params.selection, {
782
+ yearAxis: sortsByActivity ? "sort" : "published",
783
+ });
679
784
  const filters: PostFilters = {
680
- format: params.format,
785
+ lang: getViewLang(c) ?? undefined,
681
786
  status: "published",
682
787
  excludeReplies: true,
683
788
  excludePrivate: true,
684
789
  excludeLatestHidden: false,
685
- collectionId: collection?.id,
686
- mediaKinds: params.mediaKinds,
687
- hasMedia: params.hasMedia,
688
- hasTitle: params.hasTitle,
689
- hasReplies: params.hasReplies,
790
+ ...selectionFilters,
690
791
  sortBy: sortsByActivity ? "thread_updated" : "published",
691
792
  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
793
  publishedBefore:
703
- yearPublishedBefore === undefined || sortsByActivity
794
+ selectionFilters.publishedBefore === undefined
704
795
  ? rssPublishedBefore
705
- : Math.min(yearPublishedBefore, rssPublishedBefore),
706
- limit: appConfig.rssFeedLimit,
796
+ : Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
797
+ limit: getFeedLimit(c),
707
798
  };
708
799
 
709
800
  const posts = await services.posts.list(filters);
@@ -793,16 +884,20 @@ async function buildArchiveFeedData(
793
884
  };
794
885
  });
795
886
 
796
- const feedQuery = buildArchiveFeedQuery(params);
887
+ const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
797
888
 
798
889
  return {
890
+ ...buildFeedDiscoveryFields(c, { query: feedQuery }),
799
891
  siteName: appConfig.siteName,
800
892
  siteDescription: toPlainText(appConfig.siteDescription),
801
893
  siteUrl: appConfig.siteUrl,
802
- siteLanguage: appConfig.siteLanguage,
803
- title: buildArchiveFeedTitle(c, params, collection?.title),
894
+ siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
895
+ title: buildPageTitle(
896
+ appConfig.siteName,
897
+ buildArchiveFeedLabel(c, params, dimensionCtx),
898
+ ),
804
899
  selfUrl: toAbsoluteSiteUrl(
805
- `${selfPath}${feedQuery}`,
900
+ `${viewBasePath(c)}${selfPath}${feedQuery}`,
806
901
  appConfig.siteUrl,
807
902
  appConfig.sitePathPrefix,
808
903
  ),
@@ -810,25 +905,38 @@ async function buildArchiveFeedData(
810
905
  };
811
906
  }
812
907
 
813
- // Atom — /archive/feed
814
- archiveRoutes.get("/feed", async (c) => {
908
+ /**
909
+ * Render the archive Atom feed for the current view language.
910
+ *
911
+ * @param c - Hono context
912
+ * @returns Atom feed response
913
+ */
914
+ export async function renderArchiveFeed(c: Context<Env>): Promise<Response> {
915
+ // No session reaches a feed reader, so a selection only the author can see
916
+ // has no honest rendering here — not an empty feed, and certainly not the
917
+ // unfiltered one. The page already withholds the link; this is the backstop.
918
+ // Read without resolving anything: the guard reads the visibility param and
919
+ // the session, so it must not depend on a database round trip.
920
+ const guardParams = readArchiveParams(c, archiveParamReader(c), null);
921
+ if (archiveQueryRequiresAuth(guardParams)) return c.notFound();
922
+
815
923
  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
- });
924
+ // A collection slug that names nothing: the same 404 the page gives, rather
925
+ // than handing a subscriber the entire archive under the collection's name.
926
+ if (!feedData) return c.notFound();
927
+
928
+ return renderFeed(defaultFeedRenderer(feedData));
929
+ }
930
+
931
+ // Atom — /archive/feed
932
+ archiveRoutes.get("/feed", renderArchiveFeed);
823
933
 
824
934
  // Legacy atom.xml redirect
825
- archiveRoutes.get("/feed/atom.xml", (c) => {
826
- const sitePathPrefix = c.var.appConfig.sitePathPrefix;
935
+ export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
827
936
  const qs = c.req.url.includes("?")
828
937
  ? c.req.url.slice(c.req.url.indexOf("?"))
829
938
  : "";
830
- return c.redirect(
831
- `${toPublicPath("/archive/feed", sitePathPrefix)}${qs}`,
832
- 308,
833
- );
834
- });
939
+ return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
940
+ }
941
+
942
+ archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);