@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
@@ -0,0 +1,1349 @@
1
+ /**
2
+ * Post filter dimensions — one declaration per dimension, read by every surface.
3
+ *
4
+ * A "dimension" is one axis a reader can narrow posts along: format, year,
5
+ * collection, media, title presence, reply presence, visibility. Each one needs
6
+ * a query-string spelling, a parser, a serializer, a translation into
7
+ * {@link PostFilters}, and a reader-facing name. Written separately, those live
8
+ * in as many files as there are surfaces, and the same vocabulary ends up stored
9
+ * several times over.
10
+ *
11
+ * That is not a theoretical risk here. Before this module the archive kept
12
+ * **four** implementations of one vocabulary: the page's parser, the public
13
+ * API's Zod schema, the chip bar's URL writer, and the feed's self-URL writer.
14
+ * They had already drifted — `?collection=` accepted a list on the API and a
15
+ * single slug on the page.
16
+ *
17
+ * The rule this module encodes: **a dimension is declared once.** Adding an
18
+ * eighth one touches this file and the storage schema, never a surface.
19
+ *
20
+ * Naming is deliberately neutral. The vocabulary belongs to "filtering posts",
21
+ * not to any one page, so both the archive and anything built on top of it can
22
+ * depend on it without either looking like it depends on the other.
23
+ */
24
+
25
+ import { msg } from "@lingui/core/macro";
26
+ import type { MessageDescriptor } from "@lingui/core";
27
+ import type { I18n } from "../i18n/i18n.js";
28
+ // Imported from `types/constants.js` rather than `types/props.js`: the props
29
+ // module reads this one for `PostFilterSelection`, and the vocabulary should not
30
+ // depend on the viewmodel that displays it.
31
+ import type {
32
+ ArchiveVisibility,
33
+ Format,
34
+ MediaKind,
35
+ } from "../types/constants.js";
36
+ import {
37
+ EARLIEST_FILTERABLE_YEAR,
38
+ FORMATS,
39
+ LATEST_FILTERABLE_YEAR,
40
+ MEDIA_KINDS,
41
+ PUBLIC_ARCHIVE_VISIBILITIES,
42
+ } from "../types/constants.js";
43
+ import { ID_PREFIX, isTypeId } from "./ids.js";
44
+ import type { PostFilters } from "../services/post.js";
45
+
46
+ type Translator = Pick<I18n, "_">;
47
+
48
+ // =============================================================================
49
+ // Values
50
+ // =============================================================================
51
+
52
+ /** Every dimension, in the order a reader-facing summary lists them. */
53
+ export const FILTER_DIMENSION_KEYS = [
54
+ "collection",
55
+ "format",
56
+ "title",
57
+ "year",
58
+ "media",
59
+ "replies",
60
+ "visibility",
61
+ ] as const;
62
+
63
+ export type FilterDimensionKey = (typeof FILTER_DIMENSION_KEYS)[number];
64
+
65
+ /**
66
+ * What `media` selects, in the one folded vocabulary the URL, the storage
67
+ * column, and the editing control all share.
68
+ *
69
+ * `any` and `none` ask about presence; a list of kinds asks for those kinds.
70
+ * Splitting this into a presence flag plus a kind list would create states that
71
+ * cannot mean anything — "no media, of kinds image and video" — so it stays one
72
+ * value. Only {@link toPostFilters} fans it out, because `PostFilters` happens
73
+ * to carry two fields.
74
+ */
75
+ export type MediaSelection = "any" | "none" | readonly MediaKind[];
76
+
77
+ /**
78
+ * Is this a {@link MediaSelection}, in either of its two shapes?
79
+ *
80
+ * A predicate rather than a schema, like every other storage check here: the
81
+ * editing dialog compiles this registry into the browser bundle, and a
82
+ * validation library is not something a page should download in order to read a
83
+ * query string. The one schema an API boundary needs is assembled from these
84
+ * predicates in `lib/schemas.ts`.
85
+ */
86
+ export function isMediaSelection(value: unknown): value is MediaSelection {
87
+ if (value === "any" || value === "none") return true;
88
+ return (
89
+ Array.isArray(value) &&
90
+ value.length > 0 &&
91
+ value.every((kind) =>
92
+ (MEDIA_KINDS as readonly string[]).includes(kind as string),
93
+ )
94
+ );
95
+ }
96
+
97
+ /** The value type each dimension carries once parsed. */
98
+ export interface FilterDimensionValues {
99
+ /** Collection ids, OR'd together: a post in any one of them matches. */
100
+ collection: readonly string[];
101
+ format: Format;
102
+ /** `true` = has a title, `false` = has none. */
103
+ title: boolean;
104
+ /** Publication year (Gregorian, UTC). */
105
+ year: number;
106
+ media: MediaSelection;
107
+ /** `true` = thread roots with replies, `false` = posts without any. */
108
+ replies: boolean;
109
+ visibility: ArchiveVisibility;
110
+ }
111
+
112
+ /**
113
+ * One reader's selection: every dimension optional, at most one value each.
114
+ *
115
+ * There is no "match any" mode and no repeated dimension. Both would be lies —
116
+ * the conditions are always AND'd, and every dimension but `media` and
117
+ * `collection` is a single column.
118
+ */
119
+ export type PostFilterSelection = {
120
+ readonly [K in FilterDimensionKey]?: FilterDimensionValues[K];
121
+ };
122
+
123
+ // =============================================================================
124
+ // Context
125
+ // =============================================================================
126
+
127
+ /** Collections, in the three lookups the `collection` dimension needs. */
128
+ export interface CollectionVocabulary {
129
+ idBySlug: ReadonlyMap<string, string>;
130
+ slugById: ReadonlyMap<string, string>;
131
+ titleById: ReadonlyMap<string, string>;
132
+ }
133
+
134
+ /**
135
+ * Build the collection lookups from whatever the caller already has loaded.
136
+ *
137
+ * @param collections - Every collection on the site
138
+ * @returns Slug/id/title lookups for {@link DimensionContext}
139
+ * @example
140
+ * buildCollectionVocabulary(await services.collections.list());
141
+ */
142
+ export function buildCollectionVocabulary(
143
+ collections: readonly { id: string; slug: string; title: string }[],
144
+ ): CollectionVocabulary {
145
+ const idBySlug = new Map<string, string>();
146
+ const slugById = new Map<string, string>();
147
+ const titleById = new Map<string, string>();
148
+ for (const collection of collections) {
149
+ idBySlug.set(collection.slug, collection.id);
150
+ slugById.set(collection.id, collection.slug);
151
+ titleById.set(collection.id, collection.title);
152
+ }
153
+ return { idBySlug, slugById, titleById };
154
+ }
155
+
156
+ /** An empty vocabulary, for callers with no collection selection to resolve. */
157
+ export const EMPTY_COLLECTION_VOCABULARY: CollectionVocabulary = {
158
+ idBySlug: new Map(),
159
+ slugById: new Map(),
160
+ titleById: new Map(),
161
+ };
162
+
163
+ /**
164
+ * Which time column `year` narrows.
165
+ *
166
+ * - `published` — when the Thread root was published. The historical record,
167
+ * and the only honest axis for a stored selection: membership must not depend
168
+ * on how the page happens to be sorted today.
169
+ * - `sort` — whichever column the query sorts by. The archive uses this so
170
+ * every month bucket shown under `year=N` really belongs to that year.
171
+ */
172
+ export type YearAxis = "published" | "sort";
173
+
174
+ /** Everything a dimension needs that it cannot hold itself. */
175
+ export interface DimensionContext {
176
+ /** Required whenever a selection names collections. */
177
+ collections?: CollectionVocabulary;
178
+ /** Defaults to `published`. */
179
+ yearAxis?: YearAxis;
180
+ }
181
+
182
+ // =============================================================================
183
+ // URL parsing
184
+ // =============================================================================
185
+
186
+ /** Reads one query parameter, or `undefined` when the URL omits it. */
187
+ export type ParamReader = (key: string) => string | undefined;
188
+
189
+ /**
190
+ * What a query string said about one dimension.
191
+ *
192
+ * `cleared` and `absent` are separate outcomes because a URL can say "I looked
193
+ * at this dimension and chose nothing" (`visibility=all`); they filter
194
+ * identically but a strict caller must not treat the word as unknown.
195
+ */
196
+ export type DimensionParse<V> =
197
+ | { state: "absent" }
198
+ | { state: "cleared" }
199
+ | { state: "value"; value: V }
200
+ | { state: "invalid"; message: string };
201
+
202
+ /** One rejected parameter, in the shape an API error or a refusal can use. */
203
+ export interface FilterParseIssue {
204
+ param: string;
205
+ message: string;
206
+ }
207
+
208
+ // =============================================================================
209
+ // Reading stored columns
210
+ // =============================================================================
211
+
212
+ /**
213
+ * Read a stored boolean, whichever way the dialect spells one.
214
+ *
215
+ * SQLite keeps booleans as `0`/`1` integers and Postgres as real booleans, and
216
+ * a value that arrives as neither is not a third state — it is a column this
217
+ * dimension cannot read, which reads as "not selected".
218
+ */
219
+ function readStoredBoolean(raw: unknown): boolean | null {
220
+ if (typeof raw === "boolean") return raw;
221
+ if (raw === 1 || raw === 0) return raw === 1;
222
+ return null;
223
+ }
224
+
225
+ /** Read a stored {@link MediaSelection} back out of its folded column. */
226
+ function readStoredMediaSelection(raw: unknown): MediaSelection | null {
227
+ if (typeof raw !== "string" || raw === "") return null;
228
+ if (raw === "any" || raw === "none") return raw;
229
+ const kinds = raw
230
+ .split(",")
231
+ .map((part) => part.trim())
232
+ .filter((part): part is MediaKind =>
233
+ (MEDIA_KINDS as readonly string[]).includes(part),
234
+ );
235
+ return kinds.length > 0 ? kinds : null;
236
+ }
237
+
238
+ // =============================================================================
239
+ // Dimension declarations
240
+ // =============================================================================
241
+
242
+ interface DimensionUrl<V> {
243
+ /** The canonical query-string name — the only one ever written. */
244
+ param: string;
245
+ /**
246
+ * Older spellings still read but never written.
247
+ *
248
+ * Real query strings hold these: bookmarks, feed subscriptions, and stored
249
+ * custom archive paths. A strict parser must recognise them too, or it will
250
+ * call a URL it perfectly understands unparseable.
251
+ */
252
+ legacy?: readonly string[];
253
+ parse(read: ParamReader, ctx: DimensionContext): DimensionParse<V>;
254
+ /** The canonical value string, or `null` when the value cannot be spelled. */
255
+ serialize(value: V, ctx: DimensionContext): string | null;
256
+ }
257
+
258
+ /**
259
+ * How a stored selection keeps this dimension.
260
+ *
261
+ * A smart collection is a selection written down, so every dimension needs one
262
+ * column, one validator for what may go in it, and the two conversions between
263
+ * the column and the value. Declaring them here is what keeps a schema change
264
+ * from needing an edit in the service, the API, and the editor as well.
265
+ */
266
+ interface DimensionStorage<V> {
267
+ /**
268
+ * The Drizzle property this dimension occupies on `smart_collection`.
269
+ *
270
+ * Both dialect schemas name it identically; the SQL column is its snake_case
271
+ * form. `null` in the column always means "not selected".
272
+ */
273
+ column: string;
274
+ /**
275
+ * May the column hold this value? Narrower than the URL vocabulary can parse:
276
+ * `visibility=private` is a URL a reader may write and a value a smart
277
+ * collection may never store.
278
+ */
279
+ isStorable(value: unknown): value is V;
280
+ /** The column value for this dimension's value. */
281
+ toColumn(value: V): string | number | boolean;
282
+ /** Read a column back, or `null` when it is unset or unreadable. */
283
+ fromColumn(raw: unknown): V | null;
284
+ }
285
+
286
+ /**
287
+ * The control an editor renders for this dimension.
288
+ *
289
+ * Two rules make this small enough to stay small.
290
+ *
291
+ * First: there is no "unset" option in any of them. A condition row exists or it
292
+ * does not, and that is already how "unset" is said. Offering it twice would be
293
+ * two switches for one state.
294
+ *
295
+ * Second: **a control produces the same string the URL does.** Whatever the
296
+ * reader picks is handed back through `url.parse` and written with
297
+ * `url.serialize`, so a control never carries a parser of its own. That is what
298
+ * keeps the union to rendering shapes — a hypothetical `rating` dimension is an
299
+ * `enum` over `"1".."5"`, not a new kind — and it is why `media` gets one folded
300
+ * vocabulary rather than a presence flag beside a kind list.
301
+ */
302
+ export type DimensionControl =
303
+ | {
304
+ kind: "enum";
305
+ options: readonly string[];
306
+ labelOf(value: string): MessageDescriptor;
307
+ }
308
+ | { kind: "year" }
309
+ | { kind: "collection" }
310
+ /** any / none / a multi-select of kinds, over the one folded vocabulary. */
311
+ | { kind: "media" }
312
+ /** Two states. "Unset" is the row not being there. */
313
+ | { kind: "presence"; yes: MessageDescriptor; no: MessageDescriptor };
314
+
315
+ interface Dimension<K extends FilterDimensionKey> {
316
+ /** Stable identity, and the row key in an editing UI. */
317
+ key: K;
318
+ /** The dimension's own name, for a menu of dimensions. */
319
+ label: MessageDescriptor;
320
+ url: DimensionUrl<FilterDimensionValues[K]>;
321
+ storage: DimensionStorage<FilterDimensionValues[K]>;
322
+ control: DimensionControl;
323
+ /** This value's slice of a `PostFilters`. */
324
+ toPostFilter(
325
+ value: FilterDimensionValues[K],
326
+ ctx: DimensionContext,
327
+ ): Partial<PostFilters>;
328
+ /**
329
+ * How this value reads to a reader, on its own.
330
+ *
331
+ * Plain and non-absorbing: a surface that wants "Untitled" to stand in for
332
+ * "Notes, without a title" composes that itself. Returns `null` when the
333
+ * value cannot be named — an unresolvable collection id.
334
+ */
335
+ describe(
336
+ value: FilterDimensionValues[K],
337
+ i18n: Translator,
338
+ ctx: DimensionContext,
339
+ ): string | null;
340
+ }
341
+
342
+ // --- collection --------------------------------------------------------------
343
+
344
+ /**
345
+ * Separators accepted between collection slugs.
346
+ *
347
+ * A comma is what gets written. `+` is accepted because `/collections/{a+b}`
348
+ * spells a selection that way in a path — but in a *query string* `+` is
349
+ * form-encoding for a space, so by the time a value reaches here it has already
350
+ * become one. Both are listed: the space is what actually arrives, and the `+`
351
+ * covers a caller that percent-escaped it.
352
+ */
353
+ const COLLECTION_SLUG_SEPARATORS = /[,+\s]+/;
354
+
355
+ function parseCollectionSlugs(raw: string): string[] {
356
+ const seen = new Set<string>();
357
+ for (const part of raw.split(COLLECTION_SLUG_SEPARATORS)) {
358
+ const slug = part.trim();
359
+ if (slug) seen.add(slug);
360
+ }
361
+ return [...seen];
362
+ }
363
+
364
+ /**
365
+ * The collection slugs a query string names, before any of them is resolved.
366
+ *
367
+ * Split out so a caller can decide whether it needs to load the collection
368
+ * vocabulary at all. The archive renders without one on every unfiltered
369
+ * request, and paying for a lookup to discover that would cost a round trip on
370
+ * the site's widest reader page.
371
+ *
372
+ * @param read - Query parameter reader
373
+ * @returns Slugs in the order written, deduplicated; empty when none is named
374
+ * @example
375
+ * readCollectionSlugs((k) => (k === "collection" ? "tech,art" : undefined));
376
+ * // ["tech", "art"]
377
+ */
378
+ export function readCollectionSlugs(read: ParamReader): string[] {
379
+ const raw = read(COLLECTION_DIMENSION.url.param);
380
+ return raw ? parseCollectionSlugs(raw) : [];
381
+ }
382
+
383
+ const COLLECTION_DIMENSION: Dimension<"collection"> = {
384
+ key: "collection",
385
+ label: msg({
386
+ message: "Collection",
387
+ comment: "@context: Post filter dimension name - collection membership",
388
+ }),
389
+ url: {
390
+ param: "collection",
391
+ parse(read, ctx) {
392
+ const raw = read("collection");
393
+ if (!raw) return { state: "absent" };
394
+ const slugs = parseCollectionSlugs(raw);
395
+ if (slugs.length === 0) return { state: "absent" };
396
+
397
+ const vocabulary = ctx.collections;
398
+ const ids: string[] = [];
399
+ for (const slug of slugs) {
400
+ const id = vocabulary?.idBySlug.get(slug);
401
+ // A slug that resolves to nothing is never dropped. Dropping it renders
402
+ // the whole archive under a name the reader typed, with the heading and
403
+ // the feed title both pretending the word was never there.
404
+ if (!id) {
405
+ return {
406
+ state: "invalid",
407
+ message: `No collection named "${slug}".`,
408
+ };
409
+ }
410
+ ids.push(id);
411
+ }
412
+ return { state: "value", value: ids };
413
+ },
414
+ serialize(value, ctx) {
415
+ const slugs = value
416
+ .map((id) => ctx.collections?.slugById.get(id))
417
+ .filter((slug): slug is string => Boolean(slug));
418
+ return slugs.length > 0 ? slugs.join(",") : null;
419
+ },
420
+ },
421
+ storage: {
422
+ column: "collectionId",
423
+ // The archive can OR several collections together; a smart collection
424
+ // deliberately cannot (see the feature notes on OR within a dimension), so
425
+ // the column is a single foreign key and the stored value is a list of one.
426
+ // Keeping the value shape identical either way is what lets one registry
427
+ // serve both.
428
+ isStorable(value): value is readonly string[] {
429
+ return (
430
+ Array.isArray(value) &&
431
+ value.length === 1 &&
432
+ typeof value[0] === "string" &&
433
+ isTypeId(value[0], ID_PREFIX.collection)
434
+ );
435
+ },
436
+ toColumn(value) {
437
+ return value[0] as string;
438
+ },
439
+ fromColumn(raw) {
440
+ return typeof raw === "string" && raw ? [raw] : null;
441
+ },
442
+ },
443
+ control: { kind: "collection" },
444
+ toPostFilter(value) {
445
+ return { collectionIds: [...value] };
446
+ },
447
+ describe(value, _i18n, ctx) {
448
+ const titles = value
449
+ .map((id) => ctx.collections?.titleById.get(id))
450
+ .filter((title): title is string => Boolean(title));
451
+ return titles.length > 0 ? titles.join(", ") : null;
452
+ },
453
+ };
454
+
455
+ // --- format ------------------------------------------------------------------
456
+
457
+ const FORMAT_LABELS_PLURAL: Record<Format, MessageDescriptor> = {
458
+ note: msg({
459
+ message: "Notes",
460
+ comment: "@context: Post format label plural - notes",
461
+ }),
462
+ link: msg({
463
+ message: "Links",
464
+ comment: "@context: Post format label plural - links",
465
+ }),
466
+ quote: msg({
467
+ message: "Quotes",
468
+ comment: "@context: Post format label plural - quotes",
469
+ }),
470
+ };
471
+
472
+ const FORMAT_DIMENSION: Dimension<"format"> = {
473
+ key: "format",
474
+ label: msg({
475
+ message: "Format",
476
+ comment: "@context: Post filter dimension name - note, link, or quote",
477
+ }),
478
+ url: {
479
+ param: "format",
480
+ parse(read) {
481
+ const raw = read("format");
482
+ if (!raw) return { state: "absent" };
483
+ if (!(FORMATS as readonly string[]).includes(raw)) {
484
+ return {
485
+ state: "invalid",
486
+ message: `Invalid format value. Allowed: ${FORMATS.join(", ")}`,
487
+ };
488
+ }
489
+ return { state: "value", value: raw as Format };
490
+ },
491
+ serialize(value) {
492
+ return value;
493
+ },
494
+ },
495
+ storage: {
496
+ column: "format",
497
+ isStorable(value): value is Format {
498
+ return (
499
+ typeof value === "string" &&
500
+ (FORMATS as readonly string[]).includes(value)
501
+ );
502
+ },
503
+ toColumn(value) {
504
+ return value;
505
+ },
506
+ fromColumn(raw) {
507
+ return (FORMATS as readonly string[]).includes(raw as string)
508
+ ? (raw as Format)
509
+ : null;
510
+ },
511
+ },
512
+ control: {
513
+ kind: "enum",
514
+ options: FORMATS,
515
+ labelOf: (value) => FORMAT_LABELS_PLURAL[value as Format],
516
+ },
517
+ toPostFilter(value) {
518
+ return { format: value };
519
+ },
520
+ describe(value, i18n) {
521
+ return i18n._(FORMAT_LABELS_PLURAL[value]);
522
+ },
523
+ };
524
+
525
+ /**
526
+ * Plural name of a post format.
527
+ *
528
+ * @param format - Post format key
529
+ * @param i18n - Translator
530
+ * @returns Reader-facing plural, e.g. `Notes`
531
+ * @example
532
+ * getFormatLabelPlural("note", i18n); // "Notes"
533
+ */
534
+ export function getFormatLabelPlural(format: Format, i18n: Translator): string {
535
+ return i18n._(FORMAT_LABELS_PLURAL[format]);
536
+ }
537
+
538
+ // --- title -------------------------------------------------------------------
539
+
540
+ const TITLE_PRESENT_LABEL = msg({
541
+ message: "Titled",
542
+ comment: "@context: Archive filter - notes that have a title",
543
+ });
544
+ const TITLE_ABSENT_LABEL = msg({
545
+ message: "Untitled",
546
+ comment: "@context: Archive filter - notes without a title",
547
+ });
548
+
549
+ /**
550
+ * Read a pre-rename `hasX=1|0` presence flag.
551
+ *
552
+ * Stored custom archive paths, bookmarks, and feed subscriptions still carry
553
+ * these, so every parser has to recognise them; nothing writes them any more.
554
+ */
555
+ function parseLegacyPresence(
556
+ read: ParamReader,
557
+ legacyParam: string,
558
+ ): DimensionParse<boolean> {
559
+ const legacy = read(legacyParam);
560
+ if (legacy === undefined) return { state: "absent" };
561
+ if (legacy === "1") return { state: "value", value: true };
562
+ if (legacy === "0") return { state: "value", value: false };
563
+ return {
564
+ state: "invalid",
565
+ message: `Invalid ${legacyParam} value. Allowed: 0, 1`,
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Read a two-state presence parameter, with its pre-rename spelling.
571
+ *
572
+ * `any`/`none` is the current vocabulary; `1`/`0` under a `hasX` name is the
573
+ * legacy one.
574
+ */
575
+ function parsePresence(
576
+ read: ParamReader,
577
+ param: string,
578
+ legacyParam: string,
579
+ ): DimensionParse<boolean> {
580
+ const raw = read(param);
581
+ if (raw !== undefined && raw !== "") {
582
+ if (raw === "any") return { state: "value", value: true };
583
+ if (raw === "none") return { state: "value", value: false };
584
+ return {
585
+ state: "invalid",
586
+ message: `Invalid ${param} value. Allowed: any, none`,
587
+ };
588
+ }
589
+ return parseLegacyPresence(read, legacyParam);
590
+ }
591
+
592
+ const TITLE_DIMENSION: Dimension<"title"> = {
593
+ key: "title",
594
+ label: msg({
595
+ message: "Title",
596
+ comment: "@context: Post filter dimension name - whether a post is titled",
597
+ }),
598
+ url: {
599
+ param: "title",
600
+ legacy: ["hasTitle"],
601
+ parse(read) {
602
+ return parsePresence(read, "title", "hasTitle");
603
+ },
604
+ serialize(value) {
605
+ return value ? "any" : "none";
606
+ },
607
+ },
608
+ storage: {
609
+ column: "hasTitle",
610
+ isStorable(value): value is boolean {
611
+ return typeof value === "boolean";
612
+ },
613
+ toColumn(value) {
614
+ return value;
615
+ },
616
+ fromColumn: readStoredBoolean,
617
+ },
618
+ control: {
619
+ kind: "presence",
620
+ yes: TITLE_PRESENT_LABEL,
621
+ no: TITLE_ABSENT_LABEL,
622
+ },
623
+ toPostFilter(value) {
624
+ return { hasTitle: value };
625
+ },
626
+ describe(value, i18n) {
627
+ return i18n._(value ? TITLE_PRESENT_LABEL : TITLE_ABSENT_LABEL);
628
+ },
629
+ };
630
+
631
+ // --- year --------------------------------------------------------------------
632
+
633
+ const YEAR_DIMENSION: Dimension<"year"> = {
634
+ key: "year",
635
+ label: msg({
636
+ message: "Year",
637
+ comment: "@context: Post filter dimension name - year of publication",
638
+ }),
639
+ url: {
640
+ param: "year",
641
+ parse(read) {
642
+ const raw = read("year");
643
+ if (!raw) return { state: "absent" };
644
+ const year = Number.parseInt(raw, 10);
645
+ // Bounded at both ends, and the ceiling is the load-bearing one:
646
+ // `toPostFilter` turns a year into `Date.UTC` bounds, which go `NaN` past
647
+ // year 275760. A NaN bound is a comparison every row fails, so the page
648
+ // would render empty with nothing to say why.
649
+ if (
650
+ !Number.isFinite(year) ||
651
+ year < EARLIEST_FILTERABLE_YEAR ||
652
+ year > LATEST_FILTERABLE_YEAR
653
+ ) {
654
+ return {
655
+ state: "invalid",
656
+ message: `Invalid year value. Allowed: ${EARLIEST_FILTERABLE_YEAR} to ${LATEST_FILTERABLE_YEAR}`,
657
+ };
658
+ }
659
+ return { state: "value", value: year };
660
+ },
661
+ serialize(value) {
662
+ return String(value);
663
+ },
664
+ },
665
+ storage: {
666
+ column: "year",
667
+ isStorable(value): value is number {
668
+ return (
669
+ typeof value === "number" &&
670
+ Number.isInteger(value) &&
671
+ value >= EARLIEST_FILTERABLE_YEAR &&
672
+ value <= LATEST_FILTERABLE_YEAR
673
+ );
674
+ },
675
+ toColumn(value) {
676
+ return value;
677
+ },
678
+ fromColumn(raw) {
679
+ return typeof raw === "number" && Number.isInteger(raw) ? raw : null;
680
+ },
681
+ },
682
+ control: { kind: "year" },
683
+ toPostFilter(value, ctx) {
684
+ const after = Date.UTC(value, 0, 1) / 1000;
685
+ const before = Date.UTC(value + 1, 0, 1) / 1000;
686
+ // `PostFilters` has no year field: a year is a pair of timestamp bounds on
687
+ // whichever column the caller is treating as the timeline.
688
+ return ctx.yearAxis === "sort"
689
+ ? { axisAfter: after, axisBefore: before }
690
+ : { publishedAfter: after, publishedBefore: before };
691
+ },
692
+ describe(value) {
693
+ return String(value);
694
+ },
695
+ };
696
+
697
+ // --- media -------------------------------------------------------------------
698
+
699
+ const MEDIA_KIND_LABELS: Record<MediaKind, MessageDescriptor> = {
700
+ image: msg({
701
+ message: "Images",
702
+ comment: "@context: Archive media filter - images",
703
+ }),
704
+ video: msg({
705
+ message: "Video",
706
+ comment: "@context: Archive media filter - video",
707
+ }),
708
+ audio: msg({
709
+ message: "Audio",
710
+ comment: "@context: Archive media filter - audio",
711
+ }),
712
+ text: msg({
713
+ message: "Text attachment",
714
+ comment: "@context: Archive media filter - text file attachments",
715
+ }),
716
+ document: msg({
717
+ message: "Files",
718
+ comment: "@context: Archive media filter - files/documents",
719
+ }),
720
+ };
721
+
722
+ const MEDIA_ANY_LABEL = msg({
723
+ message: "With media",
724
+ comment: "@context: Archive filter - posts carrying any media attachment",
725
+ });
726
+ const MEDIA_NONE_LABEL = msg({
727
+ message: "Without media",
728
+ comment: "@context: Archive filter - posts with no media attachment",
729
+ });
730
+
731
+ const MEDIA_DIMENSION: Dimension<"media"> = {
732
+ key: "media",
733
+ label: msg({
734
+ message: "Media",
735
+ comment: "@context: Post filter dimension name - attached media",
736
+ }),
737
+ url: {
738
+ param: "media",
739
+ legacy: ["hasMedia"],
740
+ parse(read) {
741
+ const raw = read("media");
742
+ if (raw === undefined || raw === "") {
743
+ const legacy = parseLegacyPresence(read, "hasMedia");
744
+ return legacy.state === "value"
745
+ ? { state: "value", value: legacy.value ? "any" : "none" }
746
+ : legacy;
747
+ }
748
+ if (raw === "any" || raw === "none") {
749
+ return { state: "value", value: raw };
750
+ }
751
+ const parts = raw
752
+ .split(",")
753
+ .map((part) => part.trim())
754
+ .filter((part) => part.length > 0);
755
+ const kinds = parts.filter((part): part is MediaKind =>
756
+ (MEDIA_KINDS as readonly string[]).includes(part),
757
+ );
758
+ if (parts.length === 0 || kinds.length !== parts.length) {
759
+ return {
760
+ state: "invalid",
761
+ message: `Invalid media value. Allowed: any, none, or kinds: ${MEDIA_KINDS.join(", ")}`,
762
+ };
763
+ }
764
+ return { state: "value", value: kinds };
765
+ },
766
+ serialize(value) {
767
+ if (typeof value === "string") return value;
768
+ // A kind list that names no kind selects nothing. Parsing never produces
769
+ // one, but a hand-built selection can, and `media=` is not a spelling any
770
+ // parser should have to make sense of.
771
+ return value.length > 0 ? value.join(",") : null;
772
+ },
773
+ },
774
+ storage: {
775
+ column: "media",
776
+ isStorable: isMediaSelection,
777
+ // The column holds the same folded string the URL does, so what is stored
778
+ // and what is shared are one vocabulary rather than two.
779
+ toColumn(value) {
780
+ return typeof value === "string" ? value : value.join(",");
781
+ },
782
+ fromColumn: readStoredMediaSelection,
783
+ },
784
+ control: { kind: "media" },
785
+ toPostFilter(value) {
786
+ // The one dimension whose single value spans two `PostFilters` fields.
787
+ if (value === "any") return { hasMedia: true };
788
+ if (value === "none") return { hasMedia: false };
789
+ return value.length > 0 ? { mediaKinds: [...value] } : {};
790
+ },
791
+ describe(value, i18n) {
792
+ if (value === "any") return i18n._(MEDIA_ANY_LABEL);
793
+ if (value === "none") return i18n._(MEDIA_NONE_LABEL);
794
+ // A single kind names itself; several are better summarised than listed.
795
+ if (value.length === 1) {
796
+ return i18n._(MEDIA_KIND_LABELS[value[0] as MediaKind]);
797
+ }
798
+ return value.length > 0 ? i18n._(MEDIA_ANY_LABEL) : null;
799
+ },
800
+ };
801
+
802
+ /**
803
+ * Name of a media kind.
804
+ *
805
+ * @param kind - Media kind key
806
+ * @param i18n - Translator
807
+ * @returns Reader-facing label, e.g. `Images`
808
+ * @example
809
+ * getMediaKindLabel("image", i18n); // "Images"
810
+ */
811
+ export function getMediaKindLabel(kind: MediaKind, i18n: Translator): string {
812
+ return i18n._(MEDIA_KIND_LABELS[kind]);
813
+ }
814
+
815
+ // --- replies -----------------------------------------------------------------
816
+
817
+ const REPLIES_PRESENT_LABEL = msg({
818
+ message: "Threads",
819
+ comment: "@context: Archive thread filter - thread roots with replies",
820
+ });
821
+ const REPLIES_ABSENT_LABEL = msg({
822
+ message: "Single posts",
823
+ comment: "@context: Archive thread filter - posts without replies",
824
+ });
825
+
826
+ const REPLIES_DIMENSION: Dimension<"replies"> = {
827
+ key: "replies",
828
+ label: msg({
829
+ message: "Replies",
830
+ comment:
831
+ "@context: Post filter dimension name - whether a thread has replies",
832
+ }),
833
+ url: {
834
+ param: "replies",
835
+ legacy: ["hasReplies"],
836
+ parse(read) {
837
+ return parsePresence(read, "replies", "hasReplies");
838
+ },
839
+ serialize(value) {
840
+ return value ? "any" : "none";
841
+ },
842
+ },
843
+ storage: {
844
+ column: "hasReplies",
845
+ isStorable(value): value is boolean {
846
+ return typeof value === "boolean";
847
+ },
848
+ toColumn(value) {
849
+ return value;
850
+ },
851
+ fromColumn: readStoredBoolean,
852
+ },
853
+ control: {
854
+ kind: "presence",
855
+ yes: REPLIES_PRESENT_LABEL,
856
+ no: REPLIES_ABSENT_LABEL,
857
+ },
858
+ toPostFilter(value) {
859
+ return { hasReplies: value };
860
+ },
861
+ describe(value, i18n) {
862
+ return i18n._(value ? REPLIES_PRESENT_LABEL : REPLIES_ABSENT_LABEL);
863
+ },
864
+ };
865
+
866
+ // --- visibility --------------------------------------------------------------
867
+
868
+ /**
869
+ * How each visibility is spelled in a URL.
870
+ *
871
+ * `latest_hidden` is the stored value; `hidden` is the word every surface puts
872
+ * in a query string. Keyed by the shared list rather than restating it, so a
873
+ * fifth visibility fails to compile here until it is given a spelling.
874
+ */
875
+ const VISIBILITY_URL_SPELLING = {
876
+ public: "public",
877
+ featured: "featured",
878
+ latest_hidden: "hidden",
879
+ private: "private",
880
+ } as const satisfies Record<ArchiveVisibility, string>;
881
+
882
+ /** Every visibility a dimension value may hold, for reading a column back. */
883
+ const ARCHIVE_VISIBILITY_VALUES = Object.keys(
884
+ VISIBILITY_URL_SPELLING,
885
+ ) as ArchiveVisibility[];
886
+
887
+ const VISIBILITY_BY_URL_VALUE = new Map<string, ArchiveVisibility>(
888
+ Object.entries(VISIBILITY_URL_SPELLING).map(([stored, url]) => [
889
+ url,
890
+ stored as ArchiveVisibility,
891
+ ]),
892
+ );
893
+
894
+ /**
895
+ * Pre-rename spellings of a visibility value.
896
+ *
897
+ * `latest_hidden` is the stored spelling, which stored custom archive paths and
898
+ * old bookmarks still carry. `all` predates the discovery that it means exactly
899
+ * what an absent parameter means — it is read as "chose nothing" and never
900
+ * written again.
901
+ */
902
+ const LEGACY_VISIBILITY_VALUES = new Map<
903
+ string,
904
+ { state: "value"; value: ArchiveVisibility } | { state: "cleared" }
905
+ >([
906
+ ["latest_hidden", { state: "value", value: "latest_hidden" }],
907
+ ["all", { state: "cleared" }],
908
+ ]);
909
+
910
+ const VISIBILITY_LABELS: Record<ArchiveVisibility, MessageDescriptor> = {
911
+ public: msg({
912
+ message: "Public",
913
+ comment: "@context: Archive visibility filter - public posts",
914
+ }),
915
+ latest_hidden: msg({
916
+ message: "Hidden from Latest",
917
+ comment: "@context: Archive visibility filter for posts hidden from Latest",
918
+ }),
919
+ private: msg({
920
+ message: "Private",
921
+ comment: "@context: Archive visibility filter - private posts",
922
+ }),
923
+ featured: msg({
924
+ message: "Featured",
925
+ comment: "@context: Archive visibility filter - featured posts",
926
+ }),
927
+ };
928
+
929
+ const VISIBILITY_DIMENSION: Dimension<"visibility"> = {
930
+ key: "visibility",
931
+ label: msg({
932
+ message: "Visibility",
933
+ comment: "@context: Post filter dimension name - who can see a post",
934
+ }),
935
+ url: {
936
+ param: "visibility",
937
+ parse(read) {
938
+ const raw = read("visibility");
939
+ if (!raw) return { state: "absent" };
940
+ const legacy = LEGACY_VISIBILITY_VALUES.get(raw);
941
+ if (legacy) return legacy;
942
+ const stored = VISIBILITY_BY_URL_VALUE.get(raw);
943
+ if (!stored) {
944
+ return {
945
+ state: "invalid",
946
+ message: `Invalid visibility value. Allowed: ${[
947
+ ...VISIBILITY_BY_URL_VALUE.keys(),
948
+ ].join(", ")}`,
949
+ };
950
+ }
951
+ return { state: "value", value: stored };
952
+ },
953
+ serialize(value) {
954
+ return VISIBILITY_URL_SPELLING[value];
955
+ },
956
+ },
957
+ storage: {
958
+ column: "visibility",
959
+ // Narrower than the URL vocabulary on purpose: a smart collection is a
960
+ // published page, so it can never name the one set only its author sees.
961
+ // `private` parses here and fails validation, which is exactly what lets
962
+ // the "turn this link into a smart collection" flow refuse it by name.
963
+ isStorable(value): value is ArchiveVisibility {
964
+ return (
965
+ typeof value === "string" &&
966
+ (PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(value)
967
+ );
968
+ },
969
+ toColumn(value) {
970
+ return value;
971
+ },
972
+ fromColumn(raw) {
973
+ return (ARCHIVE_VISIBILITY_VALUES as readonly string[]).includes(
974
+ raw as string,
975
+ )
976
+ ? (raw as ArchiveVisibility)
977
+ : null;
978
+ },
979
+ },
980
+ control: {
981
+ kind: "enum",
982
+ options: PUBLIC_ARCHIVE_VISIBILITIES,
983
+ labelOf: (value) => VISIBILITY_LABELS[value as ArchiveVisibility],
984
+ },
985
+ toPostFilter(value) {
986
+ // `featured` is a virtual visibility — a separate flag rather than a stored
987
+ // value — so it lands in a different field than the other three.
988
+ return value === "featured" ? { featured: true } : { visibility: value };
989
+ },
990
+ describe(value, i18n) {
991
+ return i18n._(VISIBILITY_LABELS[value]);
992
+ },
993
+ };
994
+
995
+ /**
996
+ * Name of a visibility filter value.
997
+ *
998
+ * @param visibility - Visibility filter value
999
+ * @param i18n - Translator
1000
+ * @returns Reader-facing label, e.g. `Hidden from Latest`
1001
+ * @example
1002
+ * getVisibilityLabel("private", i18n); // "Private"
1003
+ */
1004
+ export function getVisibilityLabel(
1005
+ visibility: ArchiveVisibility,
1006
+ i18n: Translator,
1007
+ ): string {
1008
+ return i18n._(VISIBILITY_LABELS[visibility]);
1009
+ }
1010
+
1011
+ // =============================================================================
1012
+ // The registry
1013
+ // =============================================================================
1014
+
1015
+ /** Every dimension, keyed. The one place each is declared. */
1016
+ export const FILTER_DIMENSIONS = {
1017
+ collection: COLLECTION_DIMENSION,
1018
+ format: FORMAT_DIMENSION,
1019
+ title: TITLE_DIMENSION,
1020
+ year: YEAR_DIMENSION,
1021
+ media: MEDIA_DIMENSION,
1022
+ replies: REPLIES_DIMENSION,
1023
+ visibility: VISIBILITY_DIMENSION,
1024
+ } as const satisfies { [K in FilterDimensionKey]: Dimension<K> };
1025
+
1026
+ /**
1027
+ * Every query parameter any dimension reads, current and legacy spellings both.
1028
+ *
1029
+ * A strict parser uses this to tell "a parameter I do not know" from "a
1030
+ * parameter I know and chose not to act on".
1031
+ */
1032
+ export const FILTER_DIMENSION_PARAMS: readonly string[] =
1033
+ FILTER_DIMENSION_KEYS.flatMap((key) => [
1034
+ FILTER_DIMENSIONS[key].url.param,
1035
+ ...(FILTER_DIMENSIONS[key].url.legacy ?? []),
1036
+ ]);
1037
+
1038
+ // =============================================================================
1039
+ // Storing a whole selection
1040
+ // =============================================================================
1041
+
1042
+ /**
1043
+ * The column values a selection writes, every dimension named.
1044
+ *
1045
+ * Dimensions with no value are written as `null`, not omitted: removing a
1046
+ * condition has to clear its column, and an object that simply left it out
1047
+ * would leave the old condition in place.
1048
+ *
1049
+ * @param selection - The selection to store
1050
+ * @returns One entry per dimension, keyed by its storage column
1051
+ * @example
1052
+ * selectionToColumns({ format: "note" });
1053
+ * // { collectionId: null, format: "note", hasTitle: null, ... }
1054
+ */
1055
+ export function selectionToColumns(
1056
+ selection: PostFilterSelection,
1057
+ ): Record<string, string | number | boolean | null> {
1058
+ const columns: Record<string, string | number | boolean | null> = {};
1059
+ for (const key of FILTER_DIMENSION_KEYS) {
1060
+ const { storage } = FILTER_DIMENSIONS[key];
1061
+ const value = selection[key];
1062
+ columns[storage.column] =
1063
+ value === undefined ? null : storeDimension(key, value);
1064
+ }
1065
+ return columns;
1066
+ }
1067
+
1068
+ function storeDimension<K extends FilterDimensionKey>(
1069
+ key: K,
1070
+ value: FilterDimensionValues[K],
1071
+ ): string | number | boolean {
1072
+ const toColumn = FILTER_DIMENSIONS[key].storage.toColumn as unknown as (
1073
+ value: FilterDimensionValues[K],
1074
+ ) => string | number | boolean;
1075
+ return toColumn(value);
1076
+ }
1077
+
1078
+ /**
1079
+ * Read a stored row back into a selection.
1080
+ *
1081
+ * A column this dimension cannot read is treated as unset. That is the honest
1082
+ * reading: the alternative is a smart collection that refuses to render because
1083
+ * one condition is malformed, which turns a bad column into a broken page.
1084
+ *
1085
+ * @param row - A `smart_collection` row, in either dialect's shape
1086
+ * @returns The selection the row stores
1087
+ * @example
1088
+ * selectionFromRow({ format: "note", hasTitle: 0 });
1089
+ * // { format: "note", title: false }
1090
+ */
1091
+ export function selectionFromRow(
1092
+ row: Record<string, unknown>,
1093
+ ): PostFilterSelection {
1094
+ const selection: Record<string, unknown> = {};
1095
+ for (const key of FILTER_DIMENSION_KEYS) {
1096
+ const { storage } = FILTER_DIMENSIONS[key];
1097
+ const raw = row[storage.column];
1098
+ if (raw === null || raw === undefined) continue;
1099
+ const value = storage.fromColumn(raw);
1100
+ if (value !== null) selection[key] = value;
1101
+ }
1102
+ return selection as PostFilterSelection;
1103
+ }
1104
+
1105
+ // =============================================================================
1106
+ // Parsing a whole selection
1107
+ // =============================================================================
1108
+
1109
+ /** A lenient parse: what was selected, and what could not be read. */
1110
+ export interface FilterSelectionParse {
1111
+ selection: PostFilterSelection;
1112
+ /**
1113
+ * Dimensions whose value was unreadable, keyed by canonical parameter name.
1114
+ * They are absent from `selection`.
1115
+ */
1116
+ issues: FilterParseIssue[];
1117
+ }
1118
+
1119
+ /**
1120
+ * Read every dimension out of a query string, dropping what cannot be read.
1121
+ *
1122
+ * The lenient half of the pair. A page still has to render when a URL carries a
1123
+ * value it does not recognise, so an unreadable dimension is simply not
1124
+ * selected — but it is *reported*, because some surfaces owe the reader an
1125
+ * answer rather than a quietly wider one. Callers that must refuse outright use
1126
+ * {@link parsePostFilterSelectionStrict}: one vocabulary, two policies, never
1127
+ * two vocabularies.
1128
+ *
1129
+ * @param read - Query parameter reader
1130
+ * @param ctx - Collection vocabulary and year axis
1131
+ * @returns The selection, plus any dimension that could not be read
1132
+ * @example
1133
+ * parsePostFilterSelection((k) => (k === "format" ? "quote" : undefined), {});
1134
+ * // { selection: { format: "quote" }, issues: [] }
1135
+ */
1136
+ export function parsePostFilterSelection(
1137
+ read: ParamReader,
1138
+ ctx: DimensionContext,
1139
+ ): FilterSelectionParse {
1140
+ const selection: Record<string, unknown> = {};
1141
+ const issues: FilterParseIssue[] = [];
1142
+ for (const key of FILTER_DIMENSION_KEYS) {
1143
+ const dimension = FILTER_DIMENSIONS[key];
1144
+ const parsed = dimension.url.parse(read, ctx);
1145
+ if (parsed.state === "value") selection[key] = parsed.value;
1146
+ else if (parsed.state === "invalid") {
1147
+ issues.push({ param: dimension.url.param, message: parsed.message });
1148
+ }
1149
+ }
1150
+ return { selection: selection as PostFilterSelection, issues };
1151
+ }
1152
+
1153
+ /** What a strict parse produced, or why it refused. */
1154
+ export type StrictFilterParse =
1155
+ | { ok: true; selection: PostFilterSelection }
1156
+ | { ok: false; issues: FilterParseIssue[] };
1157
+
1158
+ /**
1159
+ * Read every dimension out of a query string, refusing anything unreadable.
1160
+ *
1161
+ * For decisions, not rendering: whether a URL can become a persistent object,
1162
+ * or whether an API caller's words can be answered. A renderer that cannot read
1163
+ * a value drops it and still shows a page; a decision that drops it silently
1164
+ * answers a different question than the one asked.
1165
+ *
1166
+ * @param read - Query parameter reader
1167
+ * @param present - Every parameter name actually in the query string
1168
+ * @param ctx - Collection vocabulary and year axis
1169
+ * @param opts.allow - Parameter names that belong to the caller, not to a
1170
+ * dimension (`limit`, `cursor`, `sort`, …). Anything else present is unknown.
1171
+ * @returns The selection, or the issues that stopped it
1172
+ * @example
1173
+ * parsePostFilterSelectionStrict(read, ["format"], ctx, { allow: [] });
1174
+ * // { ok: true, selection: { format: "quote" } }
1175
+ */
1176
+ export function parsePostFilterSelectionStrict(
1177
+ read: ParamReader,
1178
+ present: readonly string[],
1179
+ ctx: DimensionContext,
1180
+ opts: { allow?: readonly string[] } = {},
1181
+ ): StrictFilterParse {
1182
+ const known = new Set([...FILTER_DIMENSION_PARAMS, ...(opts.allow ?? [])]);
1183
+ const unknown: FilterParseIssue[] = [];
1184
+
1185
+ for (const param of present) {
1186
+ if (!known.has(param)) {
1187
+ unknown.push({ param, message: `Unknown parameter "${param}".` });
1188
+ }
1189
+ }
1190
+
1191
+ const { selection, issues } = parsePostFilterSelection(read, ctx);
1192
+ const all = [...unknown, ...issues];
1193
+ if (all.length > 0) return { ok: false, issues: all };
1194
+ return { ok: true, selection };
1195
+ }
1196
+
1197
+ // =============================================================================
1198
+ // Serializing a whole selection
1199
+ // =============================================================================
1200
+
1201
+ /**
1202
+ * Write a selection back into query parameters, in the canonical spelling.
1203
+ *
1204
+ * Legacy spellings are read but never written, so a URL this produces round
1205
+ * trips through {@link parsePostFilterSelectionStrict} unchanged.
1206
+ *
1207
+ * @param selection - The selection to spell
1208
+ * @param ctx - Collection vocabulary
1209
+ * @param into - Params to write into; a fresh set when omitted
1210
+ * @returns The params, for chaining
1211
+ * @example
1212
+ * serializePostFilterSelection({ format: "quote" }, {}).toString();
1213
+ * // "format=quote"
1214
+ */
1215
+ export function serializePostFilterSelection(
1216
+ selection: PostFilterSelection,
1217
+ ctx: DimensionContext,
1218
+ into: URLSearchParams = new URLSearchParams(),
1219
+ ): URLSearchParams {
1220
+ for (const key of FILTER_DIMENSION_KEYS) {
1221
+ const value = selection[key];
1222
+ if (value === undefined) continue;
1223
+ const dimension = FILTER_DIMENSIONS[key];
1224
+ // Each branch narrows `value` to its own dimension's type; the registry's
1225
+ // per-key typing cannot survive the loop, so the call is made per key.
1226
+ const spelled = spellDimension(key, value, ctx);
1227
+ if (spelled !== null) into.set(dimension.url.param, spelled);
1228
+ }
1229
+ return into;
1230
+ }
1231
+
1232
+ /**
1233
+ * Why the three helpers below each carry one cast.
1234
+ *
1235
+ * The registry is precisely typed per key — `satisfies { [K in Key]: Dimension<K> }`
1236
+ * checks every declaration against its own value type. What a `for` loop over
1237
+ * the keys cannot carry is the *pairing*: at the call site TypeScript sees a
1238
+ * union of dimensions and a union of values, and has no way to know that the
1239
+ * one drawn from `FILTER_DIMENSIONS[key]` belongs with the one drawn from
1240
+ * `selection[key]`. They do, by construction — both are indexed by the same
1241
+ * `key`. One cast per operation is the cost of iterating; the alternative is a
1242
+ * seven-arm switch in each of three functions, which is exactly the repetition
1243
+ * this module exists to remove.
1244
+ */
1245
+ function spellDimension<K extends FilterDimensionKey>(
1246
+ key: K,
1247
+ value: FilterDimensionValues[K],
1248
+ ctx: DimensionContext,
1249
+ ): string | null {
1250
+ const serialize = FILTER_DIMENSIONS[key].url.serialize as unknown as (
1251
+ value: FilterDimensionValues[K],
1252
+ ctx: DimensionContext,
1253
+ ) => string | null;
1254
+ return serialize(value, ctx);
1255
+ }
1256
+
1257
+ // =============================================================================
1258
+ // Translating a selection
1259
+ // =============================================================================
1260
+
1261
+ /**
1262
+ * Turn a selection into the `PostFilters` fields it implies.
1263
+ *
1264
+ * Nothing else: the caller still owns status, reply exclusion, the reader's
1265
+ * visibility floor, sorting, and paging. This is the selection's contribution
1266
+ * and no more, so the same selection means the same thing on a page, in a feed,
1267
+ * and inside an aggregate count.
1268
+ *
1269
+ * @param selection - Dimensions the reader chose
1270
+ * @param ctx - Collection vocabulary and year axis
1271
+ * @returns The matching `PostFilters` slice
1272
+ * @example
1273
+ * toPostFilters({ format: "quote", media: "any" }, {});
1274
+ * // { format: "quote", hasMedia: true }
1275
+ */
1276
+ export function toPostFilters(
1277
+ selection: PostFilterSelection,
1278
+ ctx: DimensionContext,
1279
+ ): Partial<PostFilters> {
1280
+ let filters: Partial<PostFilters> = {};
1281
+ for (const key of FILTER_DIMENSION_KEYS) {
1282
+ const value = selection[key];
1283
+ if (value === undefined) continue;
1284
+ filters = { ...filters, ...filterFor(key, value, ctx) };
1285
+ }
1286
+ return filters;
1287
+ }
1288
+
1289
+ function filterFor<K extends FilterDimensionKey>(
1290
+ key: K,
1291
+ value: FilterDimensionValues[K],
1292
+ ctx: DimensionContext,
1293
+ ): Partial<PostFilters> {
1294
+ const toFilter = FILTER_DIMENSIONS[key].toPostFilter as unknown as (
1295
+ value: FilterDimensionValues[K],
1296
+ ctx: DimensionContext,
1297
+ ) => Partial<PostFilters>;
1298
+ return toFilter(value, ctx);
1299
+ }
1300
+
1301
+ /** Whether a selection narrows anything at all. */
1302
+ export function hasFilterSelection(selection: PostFilterSelection): boolean {
1303
+ return FILTER_DIMENSION_KEYS.some((key) => selection[key] !== undefined);
1304
+ }
1305
+
1306
+ /**
1307
+ * How each selected dimension reads, one string per dimension.
1308
+ *
1309
+ * Plain readings in registry order, with no cross-dimension absorption — a
1310
+ * surface that wants "Untitled" to stand in for "Notes, without a title"
1311
+ * composes that itself, because whether it can afford to depends on how much
1312
+ * room it has.
1313
+ *
1314
+ * @param selection - Dimensions the reader chose
1315
+ * @param i18n - Translator
1316
+ * @param ctx - Collection vocabulary
1317
+ * @returns One label per selected dimension, unnameable ones omitted
1318
+ * @example
1319
+ * describeFilterSelection({ format: "quote", year: 2024 }, i18n, {});
1320
+ * // ["Quotes", "2024"]
1321
+ */
1322
+ export function describeFilterSelection(
1323
+ selection: PostFilterSelection,
1324
+ i18n: Translator,
1325
+ ctx: DimensionContext,
1326
+ ): string[] {
1327
+ const parts: string[] = [];
1328
+ for (const key of FILTER_DIMENSION_KEYS) {
1329
+ const value = selection[key];
1330
+ if (value === undefined) continue;
1331
+ const part = describeDimension(key, value, i18n, ctx);
1332
+ if (part) parts.push(part);
1333
+ }
1334
+ return parts;
1335
+ }
1336
+
1337
+ function describeDimension<K extends FilterDimensionKey>(
1338
+ key: K,
1339
+ value: FilterDimensionValues[K],
1340
+ i18n: Translator,
1341
+ ctx: DimensionContext,
1342
+ ): string | null {
1343
+ const describe = FILTER_DIMENSIONS[key].describe as unknown as (
1344
+ value: FilterDimensionValues[K],
1345
+ i18n: Translator,
1346
+ ctx: DimensionContext,
1347
+ ) => string | null;
1348
+ return describe(value, i18n, ctx);
1349
+ }