@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
@@ -52,6 +52,13 @@ export interface CloudflareRequestRuntime {
52
52
  export async function createCloudflareRequestRuntime(
53
53
  env: Bindings,
54
54
  publicRequestUrl: string,
55
+ options?: {
56
+ /**
57
+ * Anchor this request's first read to the primary instead of any replica.
58
+ * Set for requests that carry a session cookie — see `createRequestRuntime`.
59
+ */
60
+ anchorReadsToPrimary?: boolean;
61
+ },
55
62
  ): Promise<CloudflareRequestRuntime> {
56
63
  if (!env.DB) {
57
64
  throw new Error("Cloudflare runtime requires a DB binding.");
@@ -62,8 +69,19 @@ export async function createCloudflareRequestRuntime(
62
69
  throw new Error("AUTH_SECRET should be set after startup validation.");
63
70
  }
64
71
 
65
- // Use withSession() to enable D1 Read Replication.
66
- const session = env.DB.withSession();
72
+ // Use withSession() to enable D1 Read Replication. The constraint applies to
73
+ // the session's *first* query; every later query is anchored to the bookmark
74
+ // that one returns, so the whole request is sequentially consistent.
75
+ //
76
+ // Bookmarks are not carried across requests, so an unconstrained first read
77
+ // may land on a replica that has not caught up yet. For anonymous traffic
78
+ // that is fine and it is the point of replication. For a request holding a
79
+ // session cookie it is not: the session row is written on the primary, and a
80
+ // lagging replica answers "no such session" — the visitor is bounced to
81
+ // sign-in even though they never signed out.
82
+ const session = env.DB.withSession(
83
+ options?.anchorReadsToPrimary ? "first-primary" : "first-unconstrained",
84
+ );
67
85
 
68
86
  // Note: Drizzle ORM doesn't officially support D1DatabaseSession yet
69
87
  // (issue #2226), but it works at runtime.
@@ -89,6 +107,7 @@ export async function createCloudflareRequestRuntime(
89
107
 
90
108
  const servicesConfig = {
91
109
  databaseDialect: "sqlite" as const,
110
+ authSecret,
92
111
  bootstrapSite: getSingleSiteBootstrapOptions(env),
93
112
  enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
94
113
  hostedControlPlane: createHostedControlPlaneClient(env),
@@ -2,13 +2,39 @@ import { createCloudflareRequestRuntime } from "./cloudflare.js";
2
2
  import { createNodeRequestRuntime } from "./node.js";
3
3
  import type { Bindings } from "../types/bindings.js";
4
4
 
5
+ /**
6
+ * Whether a `Cookie` header carries a better-auth session token.
7
+ *
8
+ * Matched on the `.session_token` suffix so it holds for both the plain and
9
+ * the `__Secure-`-prefixed cookie name. This is a cheap "probably signed in"
10
+ * hint used to pick a database consistency mode — it does not verify the
11
+ * cookie's signature, and it must not be used to make an auth decision.
12
+ *
13
+ * @param cookieHeader - Raw `Cookie` request header, if any
14
+ * @returns `true` when a session token cookie appears to be present
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * hasSessionCookie("__Secure-better-auth.session_token=abc"); // true
19
+ * hasSessionCookie("theme=dark"); // false
20
+ * ```
21
+ */
22
+ export function hasSessionCookie(
23
+ cookieHeader: string | null | undefined,
24
+ ): boolean {
25
+ return !!cookieHeader?.includes(".session_token=");
26
+ }
27
+
5
28
  export async function createRequestRuntime(
6
29
  env: Bindings,
7
30
  publicRequestUrl: string,
31
+ request?: Request,
8
32
  ) {
9
33
  if (env.NODE_DATABASE || env.NODE_SQLITE) {
10
34
  return createNodeRequestRuntime(env, publicRequestUrl);
11
35
  }
12
36
 
13
- return createCloudflareRequestRuntime(env, publicRequestUrl);
37
+ return createCloudflareRequestRuntime(env, publicRequestUrl, {
38
+ anchorReadsToPrimary: hasSessionCookie(request?.headers.get("cookie")),
39
+ });
14
40
  }
@@ -27,6 +27,7 @@ import {
27
27
  getResolvedSiteBaseUrl,
28
28
  resolveCliSite,
29
29
  resolveRequestSite,
30
+ type CliSiteSelector,
30
31
  } from "./site.js";
31
32
 
32
33
  export interface NodeRequestRuntime {
@@ -144,6 +145,7 @@ export async function createNodeRequestRuntime(
144
145
 
145
146
  const servicesConfig = {
146
147
  databaseDialect,
148
+ authSecret,
147
149
  bootstrapSite: getSingleSiteBootstrapOptions(env),
148
150
  enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
149
151
  hostedControlPlane: createHostedControlPlaneClient(env),
@@ -175,9 +177,24 @@ export async function createNodeRequestRuntime(
175
177
  * Builds the runtime objects needed by local CLI commands.
176
178
  *
177
179
  * Unlike the request runtime, this path does not require auth configuration.
180
+ *
181
+ * @param env - Bindings with a resolved `NODE_DATABASE` or `NODE_SQLITE`
182
+ * @param selector - The site the command acts on. Required in host-based
183
+ * mode; single-site mode falls back to the instance's one site.
184
+ * @returns Services, storage and database scoped to that site
185
+ * @throws {Error} When host-based mode has no selector, or the selector
186
+ * matches no site
187
+ * @example
188
+ * ```ts
189
+ * const runtime = await createNodeCliRuntime(bindings, {
190
+ * kind: "site",
191
+ * idOrKey: "demo",
192
+ * });
193
+ * ```
178
194
  */
179
195
  export async function createNodeCliRuntime(
180
196
  env: Bindings,
197
+ selector: CliSiteSelector | null = null,
181
198
  ): Promise<NodeCliRuntime> {
182
199
  const nodeDatabase = env.NODE_DATABASE;
183
200
  const sqlite = env.NODE_SQLITE;
@@ -194,7 +211,7 @@ export async function createNodeCliRuntime(
194
211
 
195
212
  const slugIdLength =
196
213
  parseInt(getEnvString(env, "SLUG_ID_LENGTH") ?? "5", 10) || 5;
197
- const siteLookup = await resolveCliSite(db, env, databaseSchema);
214
+ const siteLookup = await resolveCliSite(db, env, selector, databaseSchema);
198
215
 
199
216
  return {
200
217
  currentSite: siteLookup.site,
@@ -17,7 +17,7 @@ import {
17
17
  type SiteLookupResult,
18
18
  TRANSIENT_SINGLE_SITE_ID,
19
19
  } from "../services/site.js";
20
- import { NotFoundError } from "../lib/errors.js";
20
+ import { NotFoundError, SiteUnavailableError } from "../lib/errors.js";
21
21
  import type { Bindings } from "../types/bindings.js";
22
22
 
23
23
  function logHostedSiteResolutionFailure(input: {
@@ -115,45 +115,116 @@ export async function resolveRequestSite(
115
115
  siteKey: resolved.site.key,
116
116
  siteStatus: resolved.site.status,
117
117
  });
118
- throw new NotFoundError("Site");
118
+ // The host resolved, so the site is real — it is just suspended (hosted
119
+ // plan ended, or an operator took it down). Serving a 404 here told the
120
+ // visitor the address was wrong and told crawlers to drop the site, both
121
+ // of which are false while it can still be restored.
122
+ throw new SiteUnavailableError();
119
123
  }
120
124
  return resolved;
121
125
  }
122
126
 
123
- export async function resolveCliSite(
127
+ /**
128
+ * The site a CLI command was told to act on. The `jant` CLI builds it from
129
+ * `--site <key|id>`, `--host <host>` with `--path-prefix`, or `--url <url>`
130
+ * (see `parseCliSiteSelector` in `bin/lib/site-selection.js`).
131
+ */
132
+ export type CliSiteSelector =
133
+ | { kind: "site"; idOrKey: string }
134
+ | {
135
+ kind: "host";
136
+ host: string;
137
+ /** The domain's stored prefix (`/blog`), or null for a root domain. */
138
+ pathPrefix: string | null;
139
+ };
140
+
141
+ /**
142
+ * Printed when a host-based CLI command names no site. Kept word for word with
143
+ * the raw-SQL resolver the D1 path uses in `bin/lib/site-selection.js`.
144
+ */
145
+ const HOST_BASED_CLI_SITE_REQUIRED_MESSAGE =
146
+ "host-based mode needs a target site. Pass --site <key|id>, --host <host>, or --url <url>.";
147
+
148
+ async function resolveSelectedCliSite(
124
149
  db: Database,
125
- env: Bindings,
126
- databaseSchema: DatabaseSchema = sqliteSchemaBundle,
150
+ selector: CliSiteSelector,
151
+ databaseSchema: DatabaseSchema,
127
152
  ): Promise<SiteLookupResult> {
128
153
  const siteService = createSiteService(db, databaseSchema);
129
- const resolutionMode = getSiteResolutionMode(env);
130
154
 
131
- if (resolutionMode === "single-site") {
132
- const resolved = await siteService.resolveSingleSite({
133
- ...getSingleSiteBootstrapOptions(env),
134
- createIfMissing: false,
135
- });
136
-
137
- if (resolved.site.id === TRANSIENT_SINGLE_SITE_ID) {
138
- throw new Error(
139
- "No site is configured for this instance yet. Finish /setup before running this command.",
140
- );
155
+ if (selector.kind === "site") {
156
+ const site = await siteService.getByIdOrKey(selector.idOrKey);
157
+ if (!site) {
158
+ throw new Error(`No site found for --site ${selector.idOrKey}.`);
141
159
  }
160
+ return {
161
+ site,
162
+ domain: await siteService.getPrimaryDomainForSite(site.id),
163
+ };
164
+ }
142
165
 
143
- return resolved;
166
+ const resolved = await siteService.resolveByHost(
167
+ selector.host,
168
+ selector.pathPrefix,
169
+ );
170
+ if (!resolved) {
171
+ throw new Error(
172
+ `No site found for host "${selector.host}"${selector.pathPrefix ? ` and path prefix "${selector.pathPrefix}"` : ""}.`,
173
+ );
144
174
  }
175
+ return resolved;
176
+ }
177
+
178
+ /**
179
+ * Resolve the site a CLI command acts on.
180
+ *
181
+ * A selector wins in either mode. Without one, single-site mode uses the
182
+ * instance's one site, and host-based mode fails before touching the
183
+ * database: a hosted database holds every tenant, so there is no default to
184
+ * fall back to — not even when it happens to hold one site today.
185
+ *
186
+ * @param db - The database
187
+ * @param env - Bindings that carry `SITE_RESOLUTION_MODE` and the single-site
188
+ * origin
189
+ * @param selector - The site the command was told to act on, if any
190
+ * @param databaseSchema - The schema bundle for the database's dialect
191
+ * @returns The site and its domain
192
+ * @throws {Error} When the selector matches no site, when host-based mode has
193
+ * no selector, or when single-site mode has not finished setup
194
+ * @example
195
+ * ```ts
196
+ * await resolveCliSite(db, env, { kind: "site", idOrKey: "demo" });
197
+ * ```
198
+ */
199
+ export async function resolveCliSite(
200
+ db: Database,
201
+ env: Bindings,
202
+ selector: CliSiteSelector | null = null,
203
+ databaseSchema: DatabaseSchema = sqliteSchemaBundle,
204
+ ): Promise<SiteLookupResult> {
205
+ if (selector) {
206
+ return resolveSelectedCliSite(db, selector, databaseSchema);
207
+ }
208
+
209
+ if (getSiteResolutionMode(env) === "host-based") {
210
+ throw new Error(HOST_BASED_CLI_SITE_REQUIRED_MESSAGE);
211
+ }
212
+
213
+ const resolved = await createSiteService(
214
+ db,
215
+ databaseSchema,
216
+ ).resolveSingleSite({
217
+ ...getSingleSiteBootstrapOptions(env),
218
+ createIfMissing: false,
219
+ });
145
220
 
146
- const onlySite = await siteService.getOnlySite();
147
- if (!onlySite) {
221
+ if (resolved.site.id === TRANSIENT_SINGLE_SITE_ID) {
148
222
  throw new Error(
149
- "CLI site selection for host-based mode is not implemented yet.",
223
+ "No site is configured for this instance yet. Finish /setup before running this command.",
150
224
  );
151
225
  }
152
226
 
153
- return {
154
- site: onlySite,
155
- domain: null,
156
- };
227
+ return resolved;
157
228
  }
158
229
 
159
230
  export function getResolvedSiteBaseUrl(
@@ -18,6 +18,7 @@ import {
18
18
  siteDomains,
19
19
  siteMembers,
20
20
  sites,
21
+ smartCollections,
21
22
  user,
22
23
  verification,
23
24
  } from "../../db/schema.js";
@@ -172,6 +173,56 @@ describe("AuthService", () => {
172
173
  createdAt: timestamp,
173
174
  });
174
175
 
176
+ // A smart collection that filters *by* the collection above, with every row
177
+ // that hangs off it. This is the shape a factory reset used to die on: the
178
+ // condition column once carried `ON DELETE restrict`, so clearing
179
+ // `collection` threw halfway through a reset that runs without a
180
+ // transaction on SQLite and D1, leaving a half-erased site behind.
181
+ await db.insert(smartCollections).values({
182
+ id: "smc_01km9authdelete00000000000",
183
+ siteId: DEFAULT_TEST_SITE_ID,
184
+ title: "Untitled notes",
185
+ collectionId: "col_01km9authdelete00000000000",
186
+ format: "note",
187
+ hasTitle: false,
188
+ sort: "newest",
189
+ createdAt: timestamp,
190
+ updatedAt: timestamp,
191
+ });
192
+
193
+ await db.insert(pathRegistry).values({
194
+ id: "pth_01km9authsmart0000000000000",
195
+ siteId: DEFAULT_TEST_SITE_ID,
196
+ path: "untitled-notes",
197
+ kind: "slug",
198
+ smartCollectionId: "smc_01km9authdelete00000000000",
199
+ createdAt: timestamp,
200
+ updatedAt: timestamp,
201
+ });
202
+
203
+ await db.insert(collectionDirectoryItems).values({
204
+ id: "cdi_01km9authsmart0000000000000",
205
+ siteId: DEFAULT_TEST_SITE_ID,
206
+ type: "smart_collection",
207
+ smartCollectionId: "smc_01km9authdelete00000000000",
208
+ label: null,
209
+ position: "a1",
210
+ createdAt: timestamp,
211
+ updatedAt: timestamp,
212
+ });
213
+
214
+ await db.insert(navItems).values({
215
+ id: "nav_01km9authsmart0000000000000",
216
+ siteId: DEFAULT_TEST_SITE_ID,
217
+ type: "smart_collection",
218
+ smartCollectionId: "smc_01km9authdelete00000000000",
219
+ label: "",
220
+ url: "/untitled-notes",
221
+ position: "a1",
222
+ createdAt: timestamp,
223
+ updatedAt: timestamp,
224
+ });
225
+
175
226
  await db.insert(navItems).values({
176
227
  id: "nav_01km9authdelete00000000000",
177
228
  siteId: DEFAULT_TEST_SITE_ID,
@@ -241,6 +292,9 @@ describe("AuthService", () => {
241
292
  const remainingPosts = await db.select().from(posts);
242
293
  const remainingMedia = await db.select().from(media);
243
294
  const remainingCollections = await db.select().from(collections);
295
+ const remainingSmartCollections = await db
296
+ .select()
297
+ .from(smartCollections);
244
298
  const remainingPaths = await db.select().from(pathRegistry);
245
299
  const remainingDirectoryItems = await db
246
300
  .select()
@@ -262,6 +316,7 @@ describe("AuthService", () => {
262
316
  expect(remainingPosts).toHaveLength(0);
263
317
  expect(remainingMedia).toHaveLength(0);
264
318
  expect(remainingCollections).toHaveLength(0);
319
+ expect(remainingSmartCollections).toHaveLength(0);
265
320
  expect(remainingPaths).toHaveLength(0);
266
321
  expect(remainingDirectoryItems).toHaveLength(0);
267
322
  expect(remainingThreadCollections).toHaveLength(0);
@@ -287,12 +342,16 @@ describe("AuthService", () => {
287
342
  const remainingSiteMembers = await db.select().from(siteMembers);
288
343
  const remainingUsers = await db.select().from(user);
289
344
  const remainingPosts = await db.select().from(posts);
345
+ const remainingSmartCollections = await db
346
+ .select()
347
+ .from(smartCollections);
290
348
  const remainingSettings = await db.select().from(settingsTable);
291
349
  const existingSites = await db.select().from(sites);
292
350
  const existingSiteDomains = await db.select().from(siteDomains);
293
351
  expect(remainingSiteMembers).toHaveLength(0);
294
352
  expect(remainingUsers).toHaveLength(0);
295
353
  expect(remainingPosts).toHaveLength(0);
354
+ expect(remainingSmartCollections).toHaveLength(0);
296
355
  expect(remainingSettings).toHaveLength(0);
297
356
  expect(existingSites).toHaveLength(0);
298
357
  expect(existingSiteDomains).toHaveLength(0);
@@ -305,22 +364,72 @@ describe("AuthService", () => {
305
364
  db = testDb.db as unknown as Database;
306
365
  });
307
366
 
308
- it("validates delete CSRF tokens in Node runtimes", async () => {
367
+ function createTokenService(authSecret = "test-auth-secret") {
309
368
  const settingsService = createSettingsService(db, DEFAULT_TEST_SITE_ID);
310
- const authService = createAuthService(db, settingsService, {
369
+ return createAuthService(db, settingsService, {
311
370
  databaseDialect: "pg",
371
+ authSecret,
312
372
  });
373
+ }
374
+
375
+ it("validates delete CSRF tokens in Node runtimes", async () => {
376
+ const authService = createTokenService();
313
377
 
314
- const token = await authService.generateDeleteCsrfToken();
378
+ const token = await authService.generateDeleteCsrfToken("ses_1");
315
379
 
316
- await expect(authService.validateDeleteCsrfToken(token)).resolves.toBe(
317
- true,
318
- );
319
380
  await expect(
320
- authService.validateDeleteCsrfToken(`${token}nope`),
381
+ authService.validateDeleteCsrfToken(token, "ses_1"),
382
+ ).resolves.toBe(true);
383
+ await expect(
384
+ authService.validateDeleteCsrfToken(`${token}nope`, "ses_1"),
321
385
  ).resolves.toBe(false);
322
386
  });
323
387
 
388
+ it("issues the same delete token however often a session asks", async () => {
389
+ // Rendering the delete page is a GET. Issuing twice must not invalidate
390
+ // the copy already sitting in an open tab — that turned any prefetch or
391
+ // browser extension re-fetching the page into a dead delete button.
392
+ const authService = createTokenService();
393
+
394
+ const first = await authService.generateDeleteCsrfToken("ses_1");
395
+ const second = await authService.generateDeleteCsrfToken("ses_1");
396
+
397
+ expect(second).toBe(first);
398
+ await expect(
399
+ authService.validateDeleteCsrfToken(first, "ses_1"),
400
+ ).resolves.toBe(true);
401
+ });
402
+
403
+ it("refuses a delete token minted for another session", async () => {
404
+ const authService = createTokenService();
405
+
406
+ const token = await authService.generateDeleteCsrfToken("ses_1");
407
+
408
+ await expect(
409
+ authService.validateDeleteCsrfToken(token, "ses_2"),
410
+ ).resolves.toBe(false);
411
+ });
412
+
413
+ it("refuses a delete token minted under another signing key", async () => {
414
+ const token =
415
+ await createTokenService("secret-a").generateDeleteCsrfToken("ses_1");
416
+
417
+ await expect(
418
+ createTokenService("secret-b").validateDeleteCsrfToken(token, "ses_1"),
419
+ ).resolves.toBe(false);
420
+ });
421
+
422
+ it("refuses to mint a delete token without a signing key", async () => {
423
+ const settingsService = createSettingsService(db, DEFAULT_TEST_SITE_ID);
424
+ const authService = createAuthService(db, settingsService, {
425
+ databaseDialect: "pg",
426
+ });
427
+
428
+ await expect(
429
+ authService.generateDeleteCsrfToken("ses_1"),
430
+ ).rejects.toThrow(/AUTH_SECRET/);
431
+ });
432
+
324
433
  it("validates password reset tokens in Node runtimes", async () => {
325
434
  const settingsService = createSettingsService(db, DEFAULT_TEST_SITE_ID);
326
435
  const authService = createAuthService(db, settingsService, {
@@ -267,6 +267,102 @@ describe("CollectionService", () => {
267
267
  });
268
268
 
269
269
  describe("listDirectoryData", () => {
270
+ it("counts only what this reader can see", async () => {
271
+ const reading = await collectionService.create({
272
+ slug: "reading",
273
+ title: "Reading",
274
+ });
275
+
276
+ const published = await postService.create({
277
+ format: "note",
278
+ bodyMarkdown: "Published",
279
+ });
280
+ const draft = await postService.create({
281
+ format: "note",
282
+ bodyMarkdown: "Draft",
283
+ status: "draft",
284
+ });
285
+ const secret = await postService.create({
286
+ format: "note",
287
+ bodyMarkdown: "Private",
288
+ visibility: "private",
289
+ });
290
+ for (const post of [published, draft, secret]) {
291
+ await collectionService.addThread(reading.id, post.id);
292
+ }
293
+
294
+ // A stranger who could subtract the directory's number from the
295
+ // collection page's would learn how much unpublished work is in here.
296
+ const anonymous = await collectionService.listDirectoryData({
297
+ isAuthenticated: false,
298
+ });
299
+ expect(anonymous.collections[0]?.threadCount).toBe(1);
300
+ expect(anonymous.collections[0]?.recentActivityAt).toBe(
301
+ published.lastActivityAt,
302
+ );
303
+
304
+ const author = await collectionService.listDirectoryData({
305
+ isAuthenticated: true,
306
+ });
307
+ expect(author.collections[0]?.threadCount).toBe(2);
308
+ });
309
+
310
+ it("counts a Thread once, whichever of its posts matched", async () => {
311
+ const reading = await collectionService.create({
312
+ slug: "reading",
313
+ title: "Reading",
314
+ });
315
+ const root = await postService.create({
316
+ format: "note",
317
+ bodyMarkdown: "Root",
318
+ });
319
+ await postService.create({
320
+ format: "note",
321
+ bodyMarkdown: "Reply",
322
+ replyToId: root.id,
323
+ });
324
+ await collectionService.addThread(reading.id, root.id);
325
+
326
+ const directory = await collectionService.listDirectoryData({
327
+ isAuthenticated: false,
328
+ });
329
+ expect(directory.collections[0]?.threadCount).toBe(1);
330
+ });
331
+
332
+ it("narrows counts to the view's language", async () => {
333
+ const reading = await collectionService.create({
334
+ slug: "reading",
335
+ title: "Reading",
336
+ });
337
+ const english = await postService.create({
338
+ format: "note",
339
+ bodyMarkdown: "English",
340
+ language: "en",
341
+ });
342
+ const japanese = await postService.create({
343
+ format: "note",
344
+ bodyMarkdown: "Japanese",
345
+ language: "ja",
346
+ });
347
+ for (const post of [english, japanese]) {
348
+ await collectionService.addThread(reading.id, post.id);
349
+ }
350
+
351
+ const englishView = await collectionService.listDirectoryData({
352
+ isAuthenticated: false,
353
+ lang: "en",
354
+ });
355
+ expect(englishView.collections[0]?.threadCount).toBe(1);
356
+
357
+ // The row itself stays listed in every view — only its number narrows.
358
+ const japaneseView = await collectionService.listDirectoryData({
359
+ isAuthenticated: false,
360
+ lang: "ja",
361
+ });
362
+ expect(japaneseView.collections).toHaveLength(1);
363
+ expect(japaneseView.collections[0]?.threadCount).toBe(1);
364
+ });
365
+
270
366
  it("returns collections with recent activity and labeled dividers", async () => {
271
367
  const reading = await collectionService.create({
272
368
  slug: "reading",
@@ -283,7 +379,9 @@ describe("CollectionService", () => {
283
379
  });
284
380
  await collectionService.addThread(reading.id, post.id);
285
381
 
286
- const directory = await collectionService.listDirectoryData();
382
+ const directory = await collectionService.listDirectoryData({
383
+ isAuthenticated: false,
384
+ });
287
385
 
288
386
  expect(directory.collections).toHaveLength(1);
289
387
  expect(directory.collections[0]?.recentActivityAt).toBe(
@@ -329,7 +427,9 @@ describe("CollectionService", () => {
329
427
  vi.setSystemTime(new Date("2024-01-01T00:01:00Z"));
330
428
  await postService.update(root.id, { bodyMarkdown: "after" });
331
429
 
332
- const directory = await collectionService.listDirectoryData();
430
+ const directory = await collectionService.listDirectoryData({
431
+ isAuthenticated: false,
432
+ });
333
433
  expect(directory.collections[0]?.recentActivityAt).toBe(1704067200);
334
434
  } finally {
335
435
  vi.useRealTimers();
@@ -357,7 +457,9 @@ describe("CollectionService", () => {
357
457
  replyToId: root.id,
358
458
  });
359
459
 
360
- const directory = await collectionService.listDirectoryData();
460
+ const directory = await collectionService.listDirectoryData({
461
+ isAuthenticated: false,
462
+ });
361
463
  expect(directory.collections[0]?.recentActivityAt).toBe(1704067260);
362
464
  } finally {
363
465
  vi.useRealTimers();
@@ -371,7 +473,9 @@ describe("CollectionService", () => {
371
473
  url: "/archive?format=quote",
372
474
  });
373
475
 
374
- const directory = await collectionService.listDirectoryData();
476
+ const directory = await collectionService.listDirectoryData({
477
+ isAuthenticated: false,
478
+ });
375
479
 
376
480
  expect(directory.directoryItems).toContainEqual(
377
481
  expect.objectContaining({