@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -30,6 +30,12 @@ function setupApp(rssPublishDelaySeconds = 0) {
30
30
  return { app, services };
31
31
  }
32
32
 
33
+ function setupAuthedApp() {
34
+ const { app, services } = createTestApp({ authenticated: true });
35
+ app.route("/archive", archiveRoutes);
36
+ return { app, services };
37
+ }
38
+
33
39
  afterEach(() => {
34
40
  vi.restoreAllMocks();
35
41
  });
@@ -60,6 +66,31 @@ describe("archive feed filter params", () => {
60
66
  expect(xml).not.toContain("Recent Archive Post");
61
67
  });
62
68
 
69
+ it("carries the number of entries ?limit= asks for", async () => {
70
+ const { app, services } = setupApp();
71
+ await services.posts.create({
72
+ format: "note",
73
+ title: "Older Archive Post",
74
+ bodyMarkdown: "Older",
75
+ status: "published",
76
+ publishedAt: 1_000,
77
+ });
78
+ await services.posts.create({
79
+ format: "note",
80
+ title: "Newer Archive Post",
81
+ bodyMarkdown: "Newer",
82
+ status: "published",
83
+ publishedAt: 2_000,
84
+ });
85
+
86
+ const xml = await fetchFeed(app, "?limit=1");
87
+
88
+ expect(xml).toContain("Newer Archive Post");
89
+ expect(xml).not.toContain("Older Archive Post");
90
+ // Not a filter, so the feed's own address does not carry it.
91
+ expect(xml).not.toContain("limit=1");
92
+ });
93
+
63
94
  it("filters by title with the new param and the legacy fallback", async () => {
64
95
  const { app, services } = setupApp();
65
96
  await services.posts.create({
@@ -116,10 +147,23 @@ describe("archive feed filter params", () => {
116
147
  format: "note",
117
148
  bodyMarkdown: "text only body",
118
149
  });
150
+ await services.media.create({
151
+ filename: "unattached.jpg",
152
+ originalName: "unattached.jpg",
153
+ mimeType: "image/jpeg",
154
+ size: 1024,
155
+ storageKey: "media/unattached.jpg",
156
+ });
119
157
 
120
158
  const fresh = await fetchFeed(app, "?media=none");
121
159
  expect(fresh).toContain("text only body");
122
160
 
161
+ const page = await app.request("/archive?media=none");
162
+ expect(page.status).toBe(200);
163
+ const pageHtml = await page.text();
164
+ expect(pageHtml).toContain('aria-label="1 thread"');
165
+ expect(pageHtml).toContain("text only body");
166
+
123
167
  const legacy = await fetchFeed(app, "?hasMedia=0");
124
168
  expect(legacy).toContain("text only body");
125
169
 
@@ -128,6 +172,134 @@ describe("archive feed filter params", () => {
128
172
  });
129
173
  });
130
174
 
175
+ describe("archive feed ordering", () => {
176
+ // The feed belongs to the archive page, so it uses the page's default axis.
177
+ // Ordering by activity would let a reply to an old Thread reshuffle the
178
+ // feed window under a fixed rssFeedLimit, and readers key entries by id
179
+ // anyway — a moved entry is not a re-surfaced one. /latest/feed is where
180
+ // activity ordering lives.
181
+ it("orders by publication, not by recent replies", async () => {
182
+ const { app, services } = setupApp();
183
+ const oldThread = await services.posts.create({
184
+ format: "note",
185
+ title: "Old thread with a new reply",
186
+ bodyMarkdown: "root from 2019",
187
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
188
+ });
189
+ await services.posts.create({
190
+ format: "note",
191
+ bodyMarkdown: "reply from 2026",
192
+ replyToId: oldThread.id,
193
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
194
+ });
195
+ await services.posts.create({
196
+ format: "note",
197
+ title: "Plain newer post",
198
+ bodyMarkdown: "root from 2025",
199
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
200
+ });
201
+
202
+ const xml = await fetchFeed(app, "");
203
+
204
+ expect(xml.indexOf("Plain newer post")).toBeLessThan(
205
+ xml.indexOf("Old thread with a new reply"),
206
+ );
207
+ });
208
+
209
+ it("honors an explicit sort=updated, quiet replies included", async () => {
210
+ const { app, services } = setupApp();
211
+ const oldThread = await services.posts.create({
212
+ format: "note",
213
+ title: "Old thread with a new reply",
214
+ bodyMarkdown: "root from 2019",
215
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
216
+ });
217
+ await services.posts.create({
218
+ format: "note",
219
+ bodyMarkdown: "quiet addition",
220
+ replyToId: oldThread.id,
221
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
222
+ quietReply: true,
223
+ });
224
+ await services.posts.create({
225
+ format: "note",
226
+ title: "Plain newer post",
227
+ bodyMarkdown: "root from 2025",
228
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
229
+ });
230
+
231
+ const updated = await fetchFeed(app, "?sort=updated");
232
+ expect(updated.indexOf("Old thread with a new reply")).toBeLessThan(
233
+ updated.indexOf("Plain newer post"),
234
+ );
235
+
236
+ // Same URL param, same axis as the page — the default stays chronological.
237
+ const chronological = await fetchFeed(app, "");
238
+ expect(chronological.indexOf("Plain newer post")).toBeLessThan(
239
+ chronological.indexOf("Old thread with a new reply"),
240
+ );
241
+ });
242
+
243
+ it("scopes a year filter to the axis the feed is sorted on", async () => {
244
+ const { app, services } = setupApp();
245
+ const oldThread = await services.posts.create({
246
+ format: "note",
247
+ title: "Extended in 2026",
248
+ bodyMarkdown: "root from 2019",
249
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
250
+ });
251
+ await services.posts.create({
252
+ format: "note",
253
+ bodyMarkdown: "addition",
254
+ replyToId: oldThread.id,
255
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
256
+ });
257
+ await services.posts.create({
258
+ format: "note",
259
+ title: "Published in 2025",
260
+ bodyMarkdown: "root from 2025",
261
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
262
+ });
263
+
264
+ const byActivity = await fetchFeed(app, "?sort=updated&year=2026");
265
+ expect(byActivity).toContain("Extended in 2026");
266
+ expect(byActivity).not.toContain("Published in 2025");
267
+
268
+ const byPublication = await fetchFeed(app, "?year=2025");
269
+ expect(byPublication).toContain("Published in 2025");
270
+ expect(byPublication).not.toContain("Extended in 2026");
271
+ });
272
+
273
+ it("keeps the same order after an old thread gains a reply", async () => {
274
+ const { app, services } = setupApp();
275
+ const first = await services.posts.create({
276
+ format: "note",
277
+ title: "Alpha",
278
+ bodyMarkdown: "alpha root",
279
+ publishedAt: Date.UTC(2020, 0, 1) / 1000,
280
+ });
281
+ await services.posts.create({
282
+ format: "note",
283
+ title: "Beta",
284
+ bodyMarkdown: "beta root",
285
+ publishedAt: Date.UTC(2021, 0, 1) / 1000,
286
+ });
287
+
288
+ const before = await fetchFeed(app, "");
289
+ const orderBefore = before.indexOf("Beta") < before.indexOf("Alpha");
290
+
291
+ await services.posts.create({
292
+ format: "note",
293
+ bodyMarkdown: "late addition",
294
+ replyToId: first.id,
295
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
296
+ });
297
+
298
+ const after = await fetchFeed(app, "");
299
+ expect(after.indexOf("Beta") < after.indexOf("Alpha")).toBe(orderBefore);
300
+ });
301
+ });
302
+
131
303
  describe("archive page legacy param redirect", () => {
132
304
  it("redirects legacy boolean params to their single-word spelling", async () => {
133
305
  const { app } = setupApp();
@@ -157,6 +329,20 @@ describe("archive page legacy param redirect", () => {
157
329
  expect(res.headers.get("location")).toBe("/archive?visibility=hidden");
158
330
  });
159
331
 
332
+ // `visibility=all` filtered nothing, exactly like an absent parameter — a
333
+ // second spelling of "nothing selected" that only the parser knew about.
334
+ it("drops the visibility=all chip state, which selected nothing", async () => {
335
+ const { app } = setupApp();
336
+
337
+ const res = await app.request("/archive?visibility=all");
338
+ expect(res.status).toBe(308);
339
+ expect(res.headers.get("location")).toBe("/archive");
340
+
341
+ const withOthers = await app.request("/archive?format=note&visibility=all");
342
+ expect(withOthers.status).toBe(308);
343
+ expect(withOthers.headers.get("location")).toBe("/archive?format=note");
344
+ });
345
+
160
346
  it("drops a legacy param without overriding an explicit new one", async () => {
161
347
  const { app } = setupApp();
162
348
 
@@ -164,6 +350,39 @@ describe("archive page legacy param redirect", () => {
164
350
  expect(res.status).toBe(308);
165
351
  expect(res.headers.get("location")).toBe("/archive?title=any");
166
352
  });
353
+
354
+ // `view` was renamed to `layout` so `view` can name the saved-selection
355
+ // concept. Shared links and bookmarks predate the rename.
356
+ it("redirects the legacy view param to layout", async () => {
357
+ const { app } = setupApp();
358
+
359
+ const res = await app.request("/archive?view=grid");
360
+ expect(res.status).toBe(308);
361
+ expect(res.headers.get("location")).toBe("/archive?layout=grid");
362
+ });
363
+
364
+ it("keeps layout when a URL carries both spellings", async () => {
365
+ const { app } = setupApp();
366
+
367
+ const res = await app.request("/archive?layout=list&view=grid");
368
+ expect(res.status).toBe(308);
369
+ expect(res.headers.get("location")).toBe("/archive?layout=list");
370
+ });
371
+
372
+ it("drops a view value it cannot render", async () => {
373
+ const { app } = setupApp();
374
+
375
+ const res = await app.request("/archive?view=carousel");
376
+ expect(res.status).toBe(308);
377
+ expect(res.headers.get("location")).toBe("/archive");
378
+ });
379
+
380
+ it("leaves a canonical layout param alone", async () => {
381
+ const { app } = setupApp();
382
+
383
+ const res = await app.request("/archive?layout=grid");
384
+ expect(res.status).toBe(200);
385
+ });
167
386
  });
168
387
 
169
388
  describe("archive page ordering", () => {
@@ -213,7 +432,7 @@ describe("archive page ordering", () => {
213
432
  publishedAt: Date.UTC(2026, 0, 1) / 1000,
214
433
  });
215
434
 
216
- const res = await app.request("/archive?view=list");
435
+ const res = await app.request("/archive?layout=list");
217
436
  expect(res.status).toBe(200);
218
437
  const html = await res.text();
219
438
 
@@ -222,3 +441,440 @@ describe("archive page ordering", () => {
222
441
  );
223
442
  });
224
443
  });
444
+
445
+ describe("archive page sort=updated", () => {
446
+ /**
447
+ * An old thread that recently gained a reply, plus a younger standalone
448
+ * thread. The two axes disagree about their order, which is the whole point
449
+ * of the toggle.
450
+ */
451
+ async function seedDivergingThreads(services: {
452
+ posts: {
453
+ create: (data: Record<string, unknown>) => Promise<{ id: string }>;
454
+ };
455
+ }) {
456
+ const olderThread = await services.posts.create({
457
+ format: "note",
458
+ title: "Growing thread",
459
+ bodyMarkdown: "root from 2019",
460
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
461
+ });
462
+ await services.posts.create({
463
+ format: "note",
464
+ bodyMarkdown: "reply from 2026",
465
+ replyToId: olderThread.id,
466
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
467
+ });
468
+ await services.posts.create({
469
+ format: "note",
470
+ title: "Untouched newer thread",
471
+ bodyMarkdown: "root from 2025",
472
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
473
+ });
474
+ }
475
+
476
+ it("puts recently extended threads first", async () => {
477
+ const { app, services } = setupApp();
478
+ await seedDivergingThreads(services);
479
+
480
+ const res = await app.request("/archive?sort=updated");
481
+ expect(res.status).toBe(200);
482
+ const html = await res.text();
483
+
484
+ expect(html.indexOf("Growing thread")).toBeLessThan(
485
+ html.indexOf("Untouched newer thread"),
486
+ );
487
+ });
488
+
489
+ // Month headers are a grid affordance, so the bucketing axis is asserted
490
+ // through the grid layout rather than the site default.
491
+ it("buckets months on the same axis it sorts by", async () => {
492
+ const { app, services } = setupApp();
493
+ await seedDivergingThreads(services);
494
+
495
+ const byPublished = await (
496
+ await app.request("/archive?layout=grid")
497
+ ).text();
498
+ expect(byPublished).toContain("March 2019");
499
+ expect(byPublished).not.toContain("August 2026");
500
+
501
+ const byActivity = await (
502
+ await app.request("/archive?layout=grid&sort=updated")
503
+ ).text();
504
+ expect(byActivity).toContain("August 2026");
505
+ expect(byActivity).not.toContain("March 2019");
506
+ });
507
+
508
+ it("scopes the year filter to the active axis", async () => {
509
+ const { app, services } = setupApp();
510
+ await seedDivergingThreads(services);
511
+
512
+ const activityYear = await (
513
+ await app.request("/archive?sort=updated&year=2026")
514
+ ).text();
515
+ expect(activityYear).toContain("Growing thread");
516
+ expect(activityYear).not.toContain("Untouched newer thread");
517
+
518
+ const publishedYear = await (
519
+ await app.request("/archive?year=2026")
520
+ ).text();
521
+ expect(publishedYear).not.toContain("Growing thread");
522
+ });
523
+
524
+ it("keeps the default axis unchanged without the param", async () => {
525
+ const { app, services } = setupApp();
526
+ await seedDivergingThreads(services);
527
+
528
+ const html = await (await app.request("/archive")).text();
529
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
530
+ html.indexOf("Growing thread"),
531
+ );
532
+ });
533
+
534
+ it("distinguishes the updated view in the document title", async () => {
535
+ const { app, services } = setupApp();
536
+ await seedDivergingThreads(services);
537
+
538
+ const byPublished = await (await app.request("/archive")).text();
539
+ expect(byPublished).toContain("<title>All posts - ");
540
+ expect(byPublished).not.toContain("Recently updated");
541
+
542
+ const byActivity = await (
543
+ await app.request("/archive?sort=updated")
544
+ ).text();
545
+ expect(byActivity).toContain("<title>All posts - Recently updated - ");
546
+ });
547
+
548
+ it("says how much a filter removed, and stays quiet when none did", async () => {
549
+ const { app, services } = setupApp();
550
+ await seedDivergingThreads(services);
551
+
552
+ // Two thread roots; asking for threads with replies leaves one of them.
553
+ const filtered = await (await app.request("/archive?replies=any")).text();
554
+ expect(filtered).toContain("of 2");
555
+
556
+ // Nothing filtered, so there is no baseline worth comparing against.
557
+ const unfiltered = await (await app.request("/archive")).text();
558
+ expect(unfiltered).not.toContain("of 2");
559
+ });
560
+
561
+ it("names the active filter in the document title", async () => {
562
+ const { app, services } = setupApp();
563
+ await seedDivergingThreads(services);
564
+
565
+ const byFormat = await (await app.request("/archive?format=note")).text();
566
+ expect(byFormat).toContain("<title>Notes - ");
567
+
568
+ // Title presence absorbs the format it refines, as the filter chip does.
569
+ const untitled = await (
570
+ await app.request("/archive?format=note&title=none")
571
+ ).text();
572
+ expect(untitled).toContain("<title>Untitled - ");
573
+
574
+ // A tab truncates from the right, so the description stops at two parts.
575
+ const capped = await (
576
+ await app.request("/archive?format=note&year=2019&replies=any")
577
+ ).text();
578
+ expect(capped).toContain("<title>Notes, 2019 - ");
579
+ });
580
+
581
+ it("ignores an unknown sort value", async () => {
582
+ const { app, services } = setupApp();
583
+ await seedDivergingThreads(services);
584
+
585
+ const html = await (await app.request("/archive?sort=nonsense")).text();
586
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
587
+ html.indexOf("Growing thread"),
588
+ );
589
+ });
590
+
591
+ // The quiet-reply switch promises "won't move the thread to the top of
592
+ // latest". The archive is not Latest — it is the canonical all-posts view,
593
+ // and it already shows Hidden-from-Latest content. So its updated sort
594
+ // reports when the Thread actually changed.
595
+ it("surfaces quietly extended threads even though Latest does not", async () => {
596
+ const { app, services } = setupApp();
597
+ const quietThread = await services.posts.create({
598
+ format: "note",
599
+ title: "Quietly extended thread",
600
+ bodyMarkdown: "root from 2019",
601
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
602
+ });
603
+ await services.posts.create({
604
+ format: "note",
605
+ bodyMarkdown: "quiet addendum",
606
+ replyToId: quietThread.id,
607
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
608
+ quietReply: true,
609
+ });
610
+ await services.posts.create({
611
+ format: "note",
612
+ title: "Untouched newer thread",
613
+ bodyMarkdown: "root from 2025",
614
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
615
+ });
616
+
617
+ const html = await (
618
+ await app.request("/archive?layout=grid&sort=updated")
619
+ ).text();
620
+ expect(html.indexOf("Quietly extended thread")).toBeLessThan(
621
+ html.indexOf("Untouched newer thread"),
622
+ );
623
+ expect(html).toContain("August 2026");
624
+
625
+ // Latest still honors the quiet flag.
626
+ const roots = await services.posts.list({
627
+ status: "published",
628
+ excludeReplies: true,
629
+ excludeLatestHidden: true,
630
+ });
631
+ expect(roots[0]?.title).toBe("Untouched newer thread");
632
+ });
633
+ });
634
+
635
+ /**
636
+ * `visibility` is the one archive dimension whose values are not all reachable
637
+ * by every reader. The rule: `private` is never silently dropped. On the page
638
+ * the reader's own URL is rewritten so the address bar keeps describing what
639
+ * rendered; a stored archive path 404s instead (see custom-archive-url.test.ts,
640
+ * which has no URL to rewrite).
641
+ */
642
+ describe("archive visibility without a session", () => {
643
+ async function seedVisibilities(services: {
644
+ posts: {
645
+ create: (input: Record<string, unknown>) => Promise<unknown>;
646
+ };
647
+ }) {
648
+ await services.posts.create({
649
+ format: "note",
650
+ bodyMarkdown: "body on the stream",
651
+ status: "published",
652
+ });
653
+ await services.posts.create({
654
+ format: "note",
655
+ bodyMarkdown: "body off the stream",
656
+ status: "published",
657
+ visibility: "latest_hidden",
658
+ });
659
+ await services.posts.create({
660
+ format: "note",
661
+ bodyMarkdown: "body kept back",
662
+ status: "published",
663
+ visibility: "private",
664
+ });
665
+ }
666
+
667
+ it("filters by Hidden-from-Latest for a signed-out reader", async () => {
668
+ const { app, services } = setupApp();
669
+ await seedVisibilities(services);
670
+
671
+ const res = await app.request("/archive?visibility=hidden");
672
+ expect(res.status).toBe(200);
673
+
674
+ const html = await res.text();
675
+ expect(html).toContain("body off the stream");
676
+ expect(html).not.toContain("body on the stream");
677
+ expect(html).not.toContain("body kept back");
678
+ });
679
+
680
+ it("strips private from a signed-out reader's URL, keeping the rest", async () => {
681
+ const { app } = setupApp();
682
+
683
+ const res = await app.request("/archive?visibility=private&format=note");
684
+ expect(res.status).toBe(302);
685
+ expect(res.headers.get("location")).toBe("/archive?format=note");
686
+ });
687
+
688
+ // 302, not 308: the URL moved for this reader at this moment, and has to
689
+ // keep working once the author signs in.
690
+ it("uses a temporary redirect, and a permanent one for legacy spellings", async () => {
691
+ const { app } = setupApp();
692
+
693
+ const both = await app.request("/archive?visibility=private&hasTitle=1");
694
+ expect(both.status).toBe(302);
695
+ expect(both.headers.get("location")).toBe("/archive?title=any");
696
+
697
+ const legacyOnly = await app.request("/archive?hasTitle=1");
698
+ expect(legacyOnly.status).toBe(308);
699
+ });
700
+
701
+ // The redirect must answer from the param and the session alone. Firing only
702
+ // when private posts exist would tell any visitor whether this site keeps any.
703
+ it("redirects identically when the site holds no private posts", async () => {
704
+ const { app, services } = setupApp();
705
+ await services.posts.create({
706
+ format: "note",
707
+ bodyMarkdown: "the only post here",
708
+ status: "published",
709
+ });
710
+
711
+ const res = await app.request("/archive?visibility=private");
712
+ expect(res.status).toBe(302);
713
+ expect(res.headers.get("location")).toBe("/archive");
714
+ });
715
+
716
+ it("leaves the author's own private filter alone", async () => {
717
+ const { app, services } = setupAuthedApp();
718
+ await seedVisibilities(services);
719
+
720
+ const res = await app.request("/archive?visibility=private");
721
+ expect(res.status).toBe(200);
722
+
723
+ const html = await res.text();
724
+ expect(html).toContain("body kept back");
725
+ expect(html).not.toContain("body on the stream");
726
+ });
727
+
728
+ // A feed carries no session, so it cannot serve this selection. The page
729
+ // withholds the link rather than promising a feed that answers 404.
730
+ it("offers no feed link while the private filter is active", async () => {
731
+ const { app, services } = setupAuthedApp();
732
+ await seedVisibilities(services);
733
+
734
+ const filtered = await app.request("/archive?visibility=private");
735
+ expect(await filtered.text()).not.toContain("/archive/feed");
736
+
737
+ const plain = await app.request("/archive");
738
+ expect(await plain.text()).toContain("/archive/feed");
739
+ });
740
+
741
+ it("carries the visibility selection into the feed", async () => {
742
+ const { app, services } = setupApp();
743
+ await seedVisibilities(services);
744
+
745
+ const xml = await fetchFeed(app, "?visibility=hidden");
746
+ expect(xml).toContain("body off the stream");
747
+ expect(xml).not.toContain("body on the stream");
748
+ });
749
+
750
+ it("404s the feed for a selection it cannot serve", async () => {
751
+ const { app, services } = setupApp();
752
+ await seedVisibilities(services);
753
+
754
+ const res = await app.request("/archive/feed?visibility=private");
755
+ expect(res.status).toBe(404);
756
+ });
757
+ });
758
+
759
+ describe("archive collection selection", () => {
760
+ async function seedOneCollectedPost(services: {
761
+ posts: {
762
+ create: (input: Record<string, unknown>) => Promise<{ id: string }>;
763
+ };
764
+ collections: {
765
+ create: (input: Record<string, unknown>) => Promise<{ id: string }>;
766
+ addThread: (collectionId: string, threadId: string) => Promise<unknown>;
767
+ };
768
+ }) {
769
+ const collection = await services.collections.create({
770
+ slug: "recipes",
771
+ title: "Recipes",
772
+ });
773
+ const collected = await services.posts.create({
774
+ format: "note",
775
+ title: "Collected Post",
776
+ bodyMarkdown: "in the collection",
777
+ status: "published",
778
+ });
779
+ await services.collections.addThread(collection.id, collected.id);
780
+ await services.posts.create({
781
+ format: "note",
782
+ title: "Loose Post",
783
+ bodyMarkdown: "in no collection",
784
+ status: "published",
785
+ });
786
+ }
787
+
788
+ it("filters the page by a collection that exists", async () => {
789
+ const { app, services } = setupApp();
790
+ await seedOneCollectedPost(services);
791
+
792
+ const res = await app.request("/archive?collection=recipes");
793
+ expect(res.status).toBe(200);
794
+ const html = await res.text();
795
+ expect(html).toContain("Collected Post");
796
+ expect(html).not.toContain("Loose Post");
797
+ });
798
+
799
+ // Rendering the whole archive here would answer a question nobody asked,
800
+ // under the name the reader typed — the failure `visibility=private` was
801
+ // fixed for. `/collections/{slug}` answers the same question with a 404.
802
+ it("404s the page for a collection that does not exist", async () => {
803
+ const { app, services } = setupApp();
804
+ await seedOneCollectedPost(services);
805
+
806
+ const res = await app.request("/archive?collection=nope");
807
+ expect(res.status).toBe(404);
808
+ });
809
+
810
+ it("404s the feed for a collection that does not exist", async () => {
811
+ const { app, services } = setupApp();
812
+ await seedOneCollectedPost(services);
813
+
814
+ expect((await app.request("/archive/feed?collection=recipes")).status).toBe(
815
+ 200,
816
+ );
817
+ expect((await app.request("/archive/feed?collection=nope")).status).toBe(
818
+ 404,
819
+ );
820
+ });
821
+
822
+ // The page read a single slug while `/api/public/archive` read a list, so one
823
+ // spelling meant two things depending on which surface you asked. Both now
824
+ // read the same vocabulary.
825
+ it("selects the union of several collections", async () => {
826
+ const { app, services } = setupApp();
827
+ const recipes = await services.collections.create({
828
+ slug: "recipes",
829
+ title: "Recipes",
830
+ });
831
+ const travel = await services.collections.create({
832
+ slug: "travel",
833
+ title: "Travel",
834
+ });
835
+ const inRecipes = await services.posts.create({
836
+ format: "note",
837
+ title: "Recipe Post",
838
+ bodyMarkdown: "in recipes",
839
+ status: "published",
840
+ });
841
+ const inTravel = await services.posts.create({
842
+ format: "note",
843
+ title: "Travel Post",
844
+ bodyMarkdown: "in travel",
845
+ status: "published",
846
+ });
847
+ await services.posts.create({
848
+ format: "note",
849
+ title: "Loose Post",
850
+ bodyMarkdown: "in no collection",
851
+ status: "published",
852
+ });
853
+ await services.collections.addThread(recipes.id, inRecipes.id);
854
+ await services.collections.addThread(travel.id, inTravel.id);
855
+
856
+ const res = await app.request("/archive?collection=recipes,travel");
857
+ expect(res.status).toBe(200);
858
+ const html = await res.text();
859
+ expect(html).toContain("Recipe Post");
860
+ expect(html).toContain("Travel Post");
861
+ expect(html).not.toContain("Loose Post");
862
+
863
+ // Hono decodes a query value as form-urlencoded, so the `+` that separates
864
+ // slugs in `/collections/{a+b}` arrives here as a space. It is read anyway;
865
+ // a comma is what gets written back.
866
+ const plusSpelling = await app.request(
867
+ "/archive?collection=recipes+travel",
868
+ );
869
+ expect(plusSpelling.status).toBe(200);
870
+ expect(await plusSpelling.text()).toContain("Travel Post");
871
+ });
872
+
873
+ it("404s when only one of several collections is missing", async () => {
874
+ const { app, services } = setupApp();
875
+ await seedOneCollectedPost(services);
876
+
877
+ const res = await app.request("/archive?collection=recipes,nope");
878
+ expect(res.status).toBe(404);
879
+ });
880
+ });