@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,200 @@
1
+ /**
2
+ * What the archive tells crawlers about each of its URLs.
3
+ *
4
+ * The filter space has no ceiling — `media` alone is a comma-joined subset of
5
+ * kinds, multiplied by every other dimension — and the page sits in no cache,
6
+ * so each facet a crawler walks costs a Worker invocation and seven D1
7
+ * queries. None of those URLs is a page the site publishes. The ones that are
8
+ * — the bare path, a stored archive path, and the same page rendered
9
+ * differently — consolidate onto one canonical instead.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
14
+ import { archiveRoutes } from "../archive.js";
15
+ import { pageRoutes } from "../page.js";
16
+
17
+ const SITE_URL = "http://localhost:3000";
18
+
19
+ function setupApp() {
20
+ const { app, services } = createTestApp({ authenticated: false });
21
+ app.route("/archive", archiveRoutes);
22
+ return { app, services };
23
+ }
24
+
25
+ function setupPageApp() {
26
+ const testApp = createTestApp({ authenticated: false });
27
+ const { app } = testApp;
28
+ app.use("*", async (c, next) => {
29
+ c.set("publicPath", c.req.path);
30
+ c.set("publicRequestUrl", c.req.url);
31
+ await next();
32
+ });
33
+ app.route("/", pageRoutes);
34
+ return testApp;
35
+ }
36
+
37
+ function readCanonical(html: string): string | null {
38
+ return html.match(/<link rel="canonical" href="([^"]+)"/)?.[1] ?? null;
39
+ }
40
+
41
+ function readRobotsMeta(html: string): string | null {
42
+ return html.match(/<meta name="robots" content="([^"]+)"/)?.[1] ?? null;
43
+ }
44
+
45
+ /**
46
+ * The `<link rel="alternate" hreflang>` set only. The language switcher in the
47
+ * nav also carries `hreflang`, on ordinary `<a>` links — that is navigation
48
+ * chrome, not the annotation search engines read.
49
+ */
50
+ function readAlternates(html: string): string[] {
51
+ return [
52
+ ...html.matchAll(
53
+ /<link rel="alternate" hreflang="([^"]+)" href="([^"]+)"/g,
54
+ ),
55
+ ].map((m) => `${m[1]} ${m[2]}`);
56
+ }
57
+
58
+ async function seed(services: {
59
+ posts: { create: (input: Record<string, unknown>) => Promise<unknown> };
60
+ }) {
61
+ await services.posts.create({
62
+ format: "note",
63
+ bodyMarkdown: "a note in the archive",
64
+ status: "published",
65
+ });
66
+ }
67
+
68
+ describe("archive indexing", () => {
69
+ it("publishes the bare archive with a self-canonical", async () => {
70
+ const { app, services } = setupApp();
71
+ await seed(services);
72
+
73
+ const res = await app.request("/archive");
74
+ const html = await res.text();
75
+
76
+ expect(readRobotsMeta(html)).toBeNull();
77
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
78
+ expect(readCanonical(html)).toBe(`${SITE_URL}/archive`);
79
+ });
80
+
81
+ // `layout` changes the markup, not the posts or their order, so the two
82
+ // renderings are one page with one canonical.
83
+ it("consolidates layout and tracking params onto the bare path", async () => {
84
+ const { app, services } = setupApp();
85
+ await seed(services);
86
+
87
+ for (const query of ["?layout=grid", "?utm_source=nl"]) {
88
+ const html = await (await app.request(`/archive${query}`)).text();
89
+ expect(readRobotsMeta(html)).toBeNull();
90
+ expect(readCanonical(html)).toBe(`${SITE_URL}/archive`);
91
+ }
92
+ });
93
+
94
+ // Pagination is genuinely different content, not a different rendering of
95
+ // the same content, so the canonical keeps it.
96
+ it("keeps the page number in the canonical", async () => {
97
+ const { app, services } = setupApp();
98
+ await seed(services);
99
+
100
+ const html = await (await app.request("/archive?page=2")).text();
101
+ expect(readCanonical(html)).toBe(`${SITE_URL}/archive?page=2`);
102
+ });
103
+
104
+ it("keeps a reader's facet out of the index while leaving its links crawlable", async () => {
105
+ const { app, services } = setupApp();
106
+ await seed(services);
107
+
108
+ const res = await app.request("/archive?format=note");
109
+ const html = await res.text();
110
+
111
+ expect(readRobotsMeta(html)).toBe("noindex, follow");
112
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, follow");
113
+ expect(readCanonical(html)).toBeNull();
114
+ });
115
+
116
+ // `sort` is a filter, not a presentation toggle: the year filter follows the
117
+ // sort axis, and under pagination a different order is a different page one.
118
+ it("treats sort as a facet rather than a rendering", async () => {
119
+ const { app, services } = setupApp();
120
+ await seed(services);
121
+
122
+ const html = await (await app.request("/archive?sort=updated")).text();
123
+ expect(readRobotsMeta(html)).toBe("noindex, follow");
124
+ expect(readCanonical(html)).toBeNull();
125
+ });
126
+
127
+ // A site that asked not to be indexed at all has made the stricter
128
+ // statement, and a page policy may only narrow further. `follow` would
129
+ // otherwise reopen the site's links on exactly the URLs it never wanted
130
+ // crawled.
131
+ it("lets a site-wide noindex override the facet policy", async () => {
132
+ const { app, services } = createTestApp({
133
+ authenticated: false,
134
+ demoMode: true,
135
+ });
136
+ app.route("/archive", archiveRoutes);
137
+ await seed(services);
138
+
139
+ const facet = await (await app.request("/archive?format=note")).text();
140
+ expect(readRobotsMeta(facet)).toBe("noindex, nofollow");
141
+
142
+ const bare = await (await app.request("/archive")).text();
143
+ expect(readRobotsMeta(bare)).toBe("noindex, nofollow");
144
+ });
145
+
146
+ // Every member of an hreflang set has to be a canonical URL. Left alone, the
147
+ // self-referential alternate would carry the very params the canonical
148
+ // strips, giving one page two identities.
149
+ it("builds the hreflang set from the canonical, not the raw URL", async () => {
150
+ const { app, services } = setupApp();
151
+ await services.settings.set("SITE_LANGUAGE", "zh-Hans");
152
+ await services.settings.set("ADDITIONAL_LANGUAGES", "en");
153
+ await services.settings.set("MULTILINGUAL_ENABLED", "true");
154
+ await seed(services);
155
+
156
+ const html = await (await app.request("/archive?utm_source=nl")).text();
157
+
158
+ expect(readCanonical(html)).toBe(`${SITE_URL}/archive`);
159
+ expect(readAlternates(html)).toEqual([
160
+ `zh-Hans ${SITE_URL}/archive`,
161
+ `en ${SITE_URL}/en/archive`,
162
+ `x-default ${SITE_URL}/archive`,
163
+ ]);
164
+ });
165
+
166
+ // A facet has no canonical, so there is no set to build.
167
+ it("emits no hreflang set on a facet", async () => {
168
+ const { app, services } = setupApp();
169
+ await services.settings.set("SITE_LANGUAGE", "zh-Hans");
170
+ await services.settings.set("ADDITIONAL_LANGUAGES", "en");
171
+ await services.settings.set("MULTILINGUAL_ENABLED", "true");
172
+ await seed(services);
173
+
174
+ const facet = await (await app.request("/archive?format=note")).text();
175
+ expect(readAlternates(facet)).toEqual([]);
176
+
177
+ const bare = await (await app.request("/archive")).text();
178
+ expect(readAlternates(bare)).toContain(`en ${SITE_URL}/en/archive`);
179
+ });
180
+
181
+ // The author declared this path, so it is a page even though its stored
182
+ // query is a filter. Same seam the auth guard uses.
183
+ it("publishes a stored archive path despite its filter", async () => {
184
+ const { app, services } = setupPageApp();
185
+ await seed(services);
186
+ await services.paths.create({
187
+ path: "/every-note",
188
+ kind: "archive",
189
+ archiveQuery: "format=note&sort=updated",
190
+ });
191
+
192
+ const res = await app.request("/every-note");
193
+ expect(res.status).toBe(200);
194
+
195
+ const html = await res.text();
196
+ expect(readRobotsMeta(html)).toBeNull();
197
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
198
+ expect(readCanonical(html)).toBe(`${SITE_URL}/every-note`);
199
+ });
200
+ });
@@ -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({
@@ -298,6 +329,20 @@ describe("archive page legacy param redirect", () => {
298
329
  expect(res.headers.get("location")).toBe("/archive?visibility=hidden");
299
330
  });
300
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
+
301
346
  it("drops a legacy param without overriding an explicit new one", async () => {
302
347
  const { app } = setupApp();
303
348
 
@@ -305,6 +350,39 @@ describe("archive page legacy param redirect", () => {
305
350
  expect(res.status).toBe(308);
306
351
  expect(res.headers.get("location")).toBe("/archive?title=any");
307
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
+ });
308
386
  });
309
387
 
310
388
  describe("archive page ordering", () => {
@@ -354,7 +432,7 @@ describe("archive page ordering", () => {
354
432
  publishedAt: Date.UTC(2026, 0, 1) / 1000,
355
433
  });
356
434
 
357
- const res = await app.request("/archive?view=list");
435
+ const res = await app.request("/archive?layout=list");
358
436
  expect(res.status).toBe(200);
359
437
  const html = await res.text();
360
438
 
@@ -408,16 +486,20 @@ describe("archive page sort=updated", () => {
408
486
  );
409
487
  });
410
488
 
489
+ // Month headers are a grid affordance, so the bucketing axis is asserted
490
+ // through the grid layout rather than the site default.
411
491
  it("buckets months on the same axis it sorts by", async () => {
412
492
  const { app, services } = setupApp();
413
493
  await seedDivergingThreads(services);
414
494
 
415
- const byPublished = await (await app.request("/archive")).text();
495
+ const byPublished = await (
496
+ await app.request("/archive?layout=grid")
497
+ ).text();
416
498
  expect(byPublished).toContain("March 2019");
417
499
  expect(byPublished).not.toContain("August 2026");
418
500
 
419
501
  const byActivity = await (
420
- await app.request("/archive?sort=updated")
502
+ await app.request("/archive?layout=grid&sort=updated")
421
503
  ).text();
422
504
  expect(byActivity).toContain("August 2026");
423
505
  expect(byActivity).not.toContain("March 2019");
@@ -454,13 +536,46 @@ describe("archive page sort=updated", () => {
454
536
  await seedDivergingThreads(services);
455
537
 
456
538
  const byPublished = await (await app.request("/archive")).text();
457
- expect(byPublished).toContain("<title>Archive - ");
539
+ expect(byPublished).toContain("<title>All posts - ");
458
540
  expect(byPublished).not.toContain("Recently updated");
459
541
 
460
542
  const byActivity = await (
461
543
  await app.request("/archive?sort=updated")
462
544
  ).text();
463
- expect(byActivity).toContain("<title>Archive - Recently updated - ");
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 - ");
464
579
  });
465
580
 
466
581
  it("ignores an unknown sort value", async () => {
@@ -499,7 +614,9 @@ describe("archive page sort=updated", () => {
499
614
  publishedAt: Date.UTC(2025, 0, 1) / 1000,
500
615
  });
501
616
 
502
- const html = await (await app.request("/archive?sort=updated")).text();
617
+ const html = await (
618
+ await app.request("/archive?layout=grid&sort=updated")
619
+ ).text();
503
620
  expect(html.indexOf("Quietly extended thread")).toBeLessThan(
504
621
  html.indexOf("Untouched newer thread"),
505
622
  );
@@ -514,3 +631,250 @@ describe("archive page sort=updated", () => {
514
631
  expect(roots[0]?.title).toBe("Untouched newer thread");
515
632
  });
516
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
+ });
@@ -166,6 +166,22 @@ describe("Collection Routing", () => {
166
166
  expect(xml).toContain("Film log");
167
167
  });
168
168
 
169
+ it("leads the collection feed title with the site name", async () => {
170
+ // A reader's sidebar sorts by feed title, so every Jant feed leads with
171
+ // the site name — otherwise one site's feeds scatter across the alphabet.
172
+ const { app, services } = createCollectionRoutingTestApp();
173
+ await services.settings.set("SITE_NAME", "Jant");
174
+ await createCollectionWithPost(services, {
175
+ slug: "reading",
176
+ title: "Reading",
177
+ postTitle: "Book log",
178
+ });
179
+
180
+ const xml = await (await app.request("/reading/feed")).text();
181
+
182
+ expect(xml).toContain("<title>Jant - Reading</title>");
183
+ });
184
+
169
185
  it("applies the RSS publication delay to Collection feeds", async () => {
170
186
  const currentTime = 2_000_000;
171
187
  vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);