@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,651 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createTestDatabase,
4
+ DEFAULT_TEST_SITE_ID,
5
+ } from "../../__tests__/helpers/db.js";
6
+ import { createCollectionService } from "../collection.js";
7
+ import { createPathService } from "../path.js";
8
+ import { createPostService } from "../post.js";
9
+ import { createSmartCollectionService } from "../smart-collection.js";
10
+ import type { Database } from "../../db/index.js";
11
+ import type { SmartCollectionSortOrder } from "../../types.js";
12
+
13
+ describe("SmartCollectionService", () => {
14
+ let db: Database;
15
+ let sqlite: ReturnType<typeof createTestDatabase>["sqlite"];
16
+ let paths: ReturnType<typeof createPathService>;
17
+ let posts: ReturnType<typeof createPostService>;
18
+ let collections: ReturnType<typeof createCollectionService>;
19
+ let smartCollections: ReturnType<typeof createSmartCollectionService>;
20
+
21
+ /**
22
+ * Count the statements that scan `post`, while `run` executes.
23
+ *
24
+ * The directory's promise is one round trip however many smart collections a
25
+ * site has — on Workers that is the cost that actually matters, and it is the
26
+ * kind of property that regresses into a `Promise.all` without any test
27
+ * noticing.
28
+ */
29
+ async function countPostQueries<T>(
30
+ run: () => Promise<T>,
31
+ ): Promise<[T, number]> {
32
+ const original = sqlite.prepare.bind(sqlite);
33
+ let scans = 0;
34
+ const spy = vi.spyOn(sqlite, "prepare").mockImplementation(((
35
+ source: string,
36
+ ) => {
37
+ if (/from\s+"?post"?/i.test(source)) scans += 1;
38
+ return original(source);
39
+ }) as typeof sqlite.prepare);
40
+ try {
41
+ const value = await run();
42
+ return [value, scans];
43
+ } finally {
44
+ spy.mockRestore();
45
+ }
46
+ }
47
+
48
+ beforeEach(() => {
49
+ const testDb = createTestDatabase();
50
+ db = testDb.db as unknown as Database;
51
+ sqlite = testDb.sqlite;
52
+ paths = createPathService(db, DEFAULT_TEST_SITE_ID);
53
+ posts = createPostService(db, { slugIdLength: 5 }, DEFAULT_TEST_SITE_ID);
54
+ smartCollections = createSmartCollectionService(
55
+ db,
56
+ DEFAULT_TEST_SITE_ID,
57
+ paths,
58
+ posts,
59
+ );
60
+ collections = createCollectionService(
61
+ db,
62
+ DEFAULT_TEST_SITE_ID,
63
+ paths,
64
+ undefined,
65
+ undefined,
66
+ smartCollections,
67
+ );
68
+ });
69
+
70
+ const anonymous = { isAuthenticated: false };
71
+ const author = { isAuthenticated: true };
72
+
73
+ describe("create", () => {
74
+ it("stores conditions and reads them back in the shared vocabulary", async () => {
75
+ const created = await smartCollections.create({
76
+ slug: "quotes",
77
+ title: "Quotes",
78
+ description: "Things worth keeping.",
79
+ selection: { format: "quote", media: "any", title: false },
80
+ sort: "oldest",
81
+ layout: "grid",
82
+ });
83
+
84
+ expect(created.slug).toBe("quotes");
85
+ expect(created.selection).toEqual({
86
+ format: "quote",
87
+ media: "any",
88
+ title: false,
89
+ });
90
+ expect(created.sort).toBe("oldest");
91
+ expect(created.layout).toBe("grid");
92
+
93
+ const reread = await smartCollections.getBySlug("quotes");
94
+ expect(reread?.selection).toEqual(created.selection);
95
+ });
96
+
97
+ it("accepts no conditions at all, which means every post", async () => {
98
+ const created = await smartCollections.create({
99
+ slug: "everything",
100
+ title: "Everything",
101
+ });
102
+ expect(created.selection).toEqual({});
103
+ });
104
+
105
+ it("shares the root address space with posts and collections", async () => {
106
+ await collections.create({ slug: "books", title: "Books" });
107
+
108
+ await expect(
109
+ smartCollections.create({ slug: "books", title: "Quotes" }),
110
+ ).rejects.toThrow(/already in use/);
111
+ });
112
+
113
+ it("leaves no path behind when the row cannot be written", async () => {
114
+ // A condition naming a collection that does not exist is refused before
115
+ // anything is written, so the address stays free.
116
+ await expect(
117
+ smartCollections.create({
118
+ slug: "quotes",
119
+ title: "Quotes",
120
+ selection: { collection: ["col_01m0f291t3fzvte3vj2g8d611z"] },
121
+ }),
122
+ ).rejects.toThrow();
123
+
124
+ expect(await paths.isPathAvailable("quotes")).toBe(true);
125
+ });
126
+
127
+ it("refuses a private condition, which a published page can never name", async () => {
128
+ await expect(
129
+ smartCollections.create({
130
+ slug: "secrets",
131
+ title: "Secrets",
132
+ // @ts-expect-error — the type rules this out; the check is that the
133
+ // runtime does too, for anything reaching the service untyped.
134
+ selection: { visibility: "private" },
135
+ }),
136
+ ).rejects.toThrow();
137
+ });
138
+ });
139
+
140
+ describe("directory placement", () => {
141
+ it("takes a place in the directory, after what is already there", async () => {
142
+ const books = await collections.create({ slug: "books", title: "Books" });
143
+ const quotes = await smartCollections.create({
144
+ slug: "quotes",
145
+ title: "Quotes",
146
+ });
147
+
148
+ const items = await collections.listDirectoryItems();
149
+ expect(
150
+ items.map((item) => [
151
+ item.type,
152
+ item.collectionId ?? item.smartCollectionId,
153
+ ]),
154
+ ).toEqual([
155
+ ["collection", books.id],
156
+ ["smart_collection", quotes.id],
157
+ ]);
158
+ });
159
+
160
+ it("leaves no directory row behind when the address is taken", async () => {
161
+ await collections.create({ slug: "books", title: "Books" });
162
+
163
+ await expect(
164
+ smartCollections.create({ slug: "books", title: "Quotes" }),
165
+ ).rejects.toThrow(/already in use/);
166
+
167
+ const items = await collections.listDirectoryItems();
168
+ expect(items.filter((item) => item.type === "smart_collection")).toEqual(
169
+ [],
170
+ );
171
+ });
172
+
173
+ it("drags by its own id, which is how the directory names it", async () => {
174
+ const first = await collections.create({ slug: "books", title: "Books" });
175
+ const last = await collections.create({ slug: "notes", title: "Notes" });
176
+ const quotes = await smartCollections.create({
177
+ slug: "quotes",
178
+ title: "Quotes",
179
+ });
180
+
181
+ const placed = await collections.listDirectoryItems();
182
+ const [firstRow, lastRow] = placed;
183
+
184
+ // The drag surface sends the smart collection's own id, not its row's.
185
+ const moved = await collections.moveDirectoryItem(
186
+ quotes.id,
187
+ firstRow?.id ?? null,
188
+ lastRow?.id ?? null,
189
+ );
190
+ expect(moved?.smartCollectionId).toBe(quotes.id);
191
+
192
+ const reordered = await collections.listDirectoryItems();
193
+ expect(
194
+ reordered.map((item) => item.collectionId ?? item.smartCollectionId),
195
+ ).toEqual([first.id, quotes.id, last.id]);
196
+ });
197
+
198
+ it("places a smart collection that never had a row, then moves it", async () => {
199
+ const first = await collections.create({ slug: "books", title: "Books" });
200
+ const last = await collections.create({ slug: "notes", title: "Notes" });
201
+ const quotes = await smartCollections.create({
202
+ slug: "quotes",
203
+ title: "Quotes",
204
+ });
205
+
206
+ // A smart collection from before placement existed: the directory shows
207
+ // it appended at the end, with no row of its own.
208
+ sqlite
209
+ .prepare(
210
+ `DELETE FROM collection_directory_item WHERE smart_collection_id = ?`,
211
+ )
212
+ .run(quotes.id);
213
+ const unplaced = await collections.listDirectoryItems();
214
+ expect(unplaced).toHaveLength(2);
215
+
216
+ const [firstRow, lastRow] = unplaced;
217
+ await collections.moveDirectoryItem(
218
+ quotes.id,
219
+ firstRow?.id ?? null,
220
+ lastRow?.id ?? null,
221
+ );
222
+
223
+ const reordered = await collections.listDirectoryItems();
224
+ expect(
225
+ reordered.map((item) => item.collectionId ?? item.smartCollectionId),
226
+ ).toEqual([first.id, quotes.id, last.id]);
227
+ });
228
+
229
+ it("names an unplaced neighbour, without reshuffling what was on screen", async () => {
230
+ const books = await collections.create({ slug: "books", title: "Books" });
231
+ const quotes = await smartCollections.create({
232
+ slug: "quotes",
233
+ title: "Quotes",
234
+ });
235
+ sqlite
236
+ .prepare(
237
+ `DELETE FROM collection_directory_item WHERE smart_collection_id = ?`,
238
+ )
239
+ .run(quotes.id);
240
+
241
+ const divider = await collections.createDirectoryItem({
242
+ type: "divider",
243
+ label: "Reading",
244
+ });
245
+
246
+ // Dropping the divider between a placed collection and an unplaced smart
247
+ // collection: the smart collection has to become a row for the position
248
+ // to be computable, and it must land where the author already saw it.
249
+ await collections.moveDirectoryItem(divider.id, books.id, quotes.id);
250
+
251
+ const reordered = await collections.listDirectoryItems();
252
+ expect(
253
+ reordered.map(
254
+ (item) => item.collectionId ?? item.smartCollectionId ?? item.id,
255
+ ),
256
+ ).toEqual([books.id, divider.id, quotes.id]);
257
+ });
258
+ });
259
+
260
+ describe("update", () => {
261
+ it("clears a condition that was removed, rather than leaving it behind", async () => {
262
+ const created = await smartCollections.create({
263
+ slug: "quotes",
264
+ title: "Quotes",
265
+ selection: { format: "quote", year: 2024 },
266
+ });
267
+
268
+ const updated = await smartCollections.update(created.id, {
269
+ selection: { format: "quote" },
270
+ });
271
+
272
+ expect(updated?.selection).toEqual({ format: "quote" });
273
+ });
274
+
275
+ it("moves the address, and the navigation link with it", async () => {
276
+ const created = await smartCollections.create({
277
+ slug: "quotes",
278
+ title: "Quotes",
279
+ });
280
+
281
+ await smartCollections.update(created.id, { slug: "citations" });
282
+
283
+ expect(await smartCollections.getBySlug("quotes")).toBeNull();
284
+ const moved = await smartCollections.getBySlug("citations");
285
+ expect(moved?.id).toBe(created.id);
286
+ });
287
+ });
288
+
289
+ describe("directory entries", () => {
290
+ async function seed() {
291
+ await posts.create({
292
+ format: "quote",
293
+ quoteText: "A public quote",
294
+ bodyMarkdown: "public quote body",
295
+ status: "published",
296
+ });
297
+ await posts.create({
298
+ format: "quote",
299
+ quoteText: "A private quote",
300
+ bodyMarkdown: "private quote body",
301
+ status: "published",
302
+ visibility: "private",
303
+ });
304
+ await posts.create({
305
+ format: "note",
306
+ bodyMarkdown: "a note",
307
+ status: "published",
308
+ });
309
+ await posts.create({
310
+ format: "quote",
311
+ quoteText: "A draft quote",
312
+ bodyMarkdown: "draft quote body",
313
+ status: "draft",
314
+ });
315
+ }
316
+
317
+ it("counts what each reader can actually see", async () => {
318
+ await seed();
319
+ await smartCollections.create({
320
+ slug: "quotes",
321
+ title: "Quotes",
322
+ selection: { format: "quote" },
323
+ });
324
+
325
+ const [asReader] = await smartCollections.listDirectoryEntries(anonymous);
326
+ const [asAuthor] = await smartCollections.listDirectoryEntries(author);
327
+
328
+ // Drafts never count. The private quote counts only for the author —
329
+ // the same rule a manual collection's directory entry follows.
330
+ expect(asReader?.threadCount).toBe(1);
331
+ expect(asAuthor?.threadCount).toBe(2);
332
+ });
333
+
334
+ it("counts every smart collection in one query", async () => {
335
+ await seed();
336
+ await smartCollections.create({
337
+ slug: "quotes",
338
+ title: "Quotes",
339
+ selection: { format: "quote" },
340
+ });
341
+ await smartCollections.create({
342
+ slug: "notes",
343
+ title: "Notes",
344
+ selection: { format: "note" },
345
+ });
346
+ await smartCollections.create({
347
+ slug: "everything",
348
+ title: "Everything",
349
+ });
350
+
351
+ const [entries, postScans] = await countPostQueries(() =>
352
+ smartCollections.listDirectoryEntries(anonymous),
353
+ );
354
+
355
+ expect(entries.map((entry) => [entry.slug, entry.threadCount])).toEqual([
356
+ ["quotes", 1],
357
+ ["notes", 1],
358
+ ["everything", 2],
359
+ ]);
360
+ // Three smart collections, one scan of `post`. Not three.
361
+ expect(postScans).toBe(1);
362
+ });
363
+
364
+ it("returns nothing to count when there is nothing to count", async () => {
365
+ expect(await smartCollections.listDirectoryEntries(anonymous)).toEqual(
366
+ [],
367
+ );
368
+ expect(await posts.aggregateMany([], {})).toEqual([]);
369
+ });
370
+
371
+ // The directory prints a count and a date on every row, and the two kinds
372
+ // of row sit one above the other. A date that meant something different on
373
+ // a smart collection would be worse than no date at all.
374
+ it("dates each smart collection by the newest thread it matches", async () => {
375
+ vi.useFakeTimers();
376
+ try {
377
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
378
+ await posts.create({
379
+ format: "quote",
380
+ quoteText: "An old quote",
381
+ status: "published",
382
+ });
383
+
384
+ vi.setSystemTime(new Date("2024-01-02T00:00:00Z"));
385
+ await posts.create({
386
+ format: "note",
387
+ bodyMarkdown: "a note",
388
+ status: "published",
389
+ });
390
+
391
+ vi.setSystemTime(new Date("2024-01-03T00:00:00Z"));
392
+ await posts.create({
393
+ format: "quote",
394
+ quoteText: "A new quote",
395
+ status: "published",
396
+ });
397
+
398
+ await smartCollections.create({
399
+ slug: "quotes",
400
+ title: "Quotes",
401
+ selection: { format: "quote" },
402
+ });
403
+ await smartCollections.create({
404
+ slug: "notes",
405
+ title: "Notes",
406
+ selection: { format: "note" },
407
+ });
408
+
409
+ const entries = await smartCollections.listDirectoryEntries(anonymous);
410
+
411
+ // Per filter, not one date for the whole page.
412
+ expect(
413
+ entries.map((entry) => [entry.slug, entry.recentActivityAt]),
414
+ ).toEqual([
415
+ ["quotes", 1704240000],
416
+ ["notes", 1704153600],
417
+ ]);
418
+ } finally {
419
+ vi.useRealTimers();
420
+ }
421
+ });
422
+
423
+ it("dates it by what the viewer can see, as it counts by it", async () => {
424
+ vi.useFakeTimers();
425
+ try {
426
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
427
+ await posts.create({
428
+ format: "quote",
429
+ quoteText: "A public quote",
430
+ status: "published",
431
+ });
432
+
433
+ vi.setSystemTime(new Date("2024-01-02T00:00:00Z"));
434
+ await posts.create({
435
+ format: "quote",
436
+ quoteText: "A private quote",
437
+ status: "published",
438
+ visibility: "private",
439
+ });
440
+
441
+ await smartCollections.create({
442
+ slug: "quotes",
443
+ title: "Quotes",
444
+ selection: { format: "quote" },
445
+ });
446
+
447
+ const [asReader] =
448
+ await smartCollections.listDirectoryEntries(anonymous);
449
+ const [asAuthor] = await smartCollections.listDirectoryEntries(author);
450
+
451
+ // A reader is never told that something they cannot read just moved.
452
+ expect(asReader?.recentActivityAt).toBe(1704067200);
453
+ expect(asAuthor?.recentActivityAt).toBe(1704153600);
454
+ } finally {
455
+ vi.useRealTimers();
456
+ }
457
+ });
458
+
459
+ // Same definition as everywhere else: a Thread's activity is when it
460
+ // gained a post, so fixing a typo does not drag a collection to the top.
461
+ it("counts a reply as activity and a later edit as none", async () => {
462
+ vi.useFakeTimers();
463
+ try {
464
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
465
+ const root = await posts.create({
466
+ format: "note",
467
+ bodyMarkdown: "root",
468
+ status: "published",
469
+ });
470
+ await smartCollections.create({
471
+ slug: "notes",
472
+ title: "Notes",
473
+ selection: { format: "note" },
474
+ });
475
+
476
+ vi.setSystemTime(new Date("2024-01-01T00:01:00Z"));
477
+ await posts.update(root.id, { bodyMarkdown: "edited" });
478
+
479
+ const [afterEdit] =
480
+ await smartCollections.listDirectoryEntries(anonymous);
481
+ expect(afterEdit?.recentActivityAt).toBe(1704067200);
482
+
483
+ vi.setSystemTime(new Date("2024-01-01T00:02:00Z"));
484
+ await posts.create({
485
+ format: "note",
486
+ bodyMarkdown: "reply",
487
+ replyToId: root.id,
488
+ status: "published",
489
+ });
490
+
491
+ const [afterReply] =
492
+ await smartCollections.listDirectoryEntries(anonymous);
493
+ expect(afterReply?.recentActivityAt).toBe(1704067320);
494
+ } finally {
495
+ vi.useRealTimers();
496
+ }
497
+ });
498
+
499
+ it("dates an empty smart collection by itself", async () => {
500
+ const created = await smartCollections.create({
501
+ slug: "quotes",
502
+ title: "Quotes",
503
+ selection: { format: "quote" },
504
+ });
505
+
506
+ const [entry] = await smartCollections.listDirectoryEntries(anonymous);
507
+
508
+ // Nothing matched, so there is no thread to date it by — the same
509
+ // fallback an empty manual collection takes.
510
+ expect(entry?.threadCount).toBe(0);
511
+ expect(entry?.recentActivityAt).toBe(created.updatedAt);
512
+ });
513
+ });
514
+
515
+ describe("referential integrity", () => {
516
+ it("refuses to delete a collection a smart collection filters by, by name", async () => {
517
+ const books = await collections.create({ slug: "books", title: "Books" });
518
+ await smartCollections.create({
519
+ slug: "quotes",
520
+ title: "Quotes",
521
+ selection: { collection: [books.id] },
522
+ });
523
+
524
+ await expect(collections.delete(books.id)).rejects.toThrow(/Quotes/);
525
+ });
526
+
527
+ it("names every smart collection in the way, not just a count", async () => {
528
+ const books = await collections.create({ slug: "books", title: "Books" });
529
+ await smartCollections.create({
530
+ slug: "quotes",
531
+ title: "Quotes",
532
+ selection: { collection: [books.id] },
533
+ });
534
+ await smartCollections.create({
535
+ slug: "book-notes",
536
+ title: "Book Notes",
537
+ selection: { collection: [books.id] },
538
+ });
539
+
540
+ await expect(collections.delete(books.id)).rejects.toThrow(
541
+ /Quotes.*Book Notes/,
542
+ );
543
+ });
544
+
545
+ it("deletes a collection nothing depends on", async () => {
546
+ const books = await collections.create({ slug: "books", title: "Books" });
547
+ expect(await collections.delete(books.id)).toBe(true);
548
+ });
549
+
550
+ it("gathers nothing, not everything, when a condition is left dangling", async () => {
551
+ // `smart_collection.collection_id` carries no foreign key: no ON DELETE
552
+ // action was the right one, and RESTRICT would only turn every bulk
553
+ // delete into an ordering rule that fails anonymously. What makes that
554
+ // safe is this — the refusal above is the normal path, and the state it
555
+ // guards against degrades to an empty page rather than to the whole
556
+ // archive under someone's curated name.
557
+ const books = await collections.create({ slug: "books", title: "Books" });
558
+ const created = await smartCollections.create({
559
+ slug: "book-notes",
560
+ title: "Book Notes",
561
+ selection: { collection: [books.id] },
562
+ });
563
+ await posts.create({
564
+ format: "note",
565
+ bodyMarkdown: "a note in no collection",
566
+ status: "published",
567
+ });
568
+
569
+ // Straight past the service, the way an import or a hand-run statement
570
+ // would reach it.
571
+ sqlite.prepare('DELETE FROM "collection" WHERE "id" = ?').run(books.id);
572
+
573
+ const reread = await smartCollections.getById(created.id);
574
+ expect(reread?.selection).toEqual({ collection: [books.id] });
575
+ expect(
576
+ await posts.count(smartCollections.toPostFilters(reread!, author)),
577
+ ).toBe(0);
578
+ });
579
+
580
+ it("releases the address when the smart collection is deleted", async () => {
581
+ const created = await smartCollections.create({
582
+ slug: "quotes",
583
+ title: "Quotes",
584
+ });
585
+
586
+ expect(await smartCollections.delete(created.id)).toBe(true);
587
+ expect(await smartCollections.getBySlug("quotes")).toBeNull();
588
+ });
589
+ });
590
+
591
+ describe("toPostFilters", () => {
592
+ it("pins the year condition to publication, whatever the chosen order", async () => {
593
+ const created = await smartCollections.create({
594
+ slug: "twenty-four",
595
+ title: "2024",
596
+ selection: { year: 2024 },
597
+ sort: "newest",
598
+ });
599
+
600
+ const filters = smartCollections.toPostFilters(created, anonymous);
601
+
602
+ // Membership is not allowed to depend on presentation: ordering by
603
+ // activity must not move a post into or out of a year.
604
+ expect(filters.publishedAfter).toBe(Date.UTC(2024, 0, 1) / 1000);
605
+ expect(filters.publishedBefore).toBe(Date.UTC(2025, 0, 1) / 1000);
606
+ expect(filters.axisAfter).toBeUndefined();
607
+ expect(filters.sortBy).toBe("activity");
608
+ });
609
+
610
+ it("orders on the same axes a manual collection does", async () => {
611
+ const axisFor = async (sort: SmartCollectionSortOrder, slug: string) => {
612
+ const created = await smartCollections.create({
613
+ slug,
614
+ title: slug,
615
+ sort,
616
+ });
617
+ const filters = smartCollections.toPostFilters(created, anonymous);
618
+ return { sortBy: filters.sortBy, sortOrder: filters.sortOrder };
619
+ };
620
+
621
+ // `newest` and the rating tie-break follow the Thread; only `oldest`
622
+ // reads publication, because a reply must not make a Thread older.
623
+ expect(await axisFor("newest", "recent")).toEqual({
624
+ sortBy: "activity",
625
+ sortOrder: "newest",
626
+ });
627
+ expect(await axisFor("rating_desc", "best")).toEqual({
628
+ sortBy: "activity",
629
+ sortOrder: "rating_desc",
630
+ });
631
+ expect(await axisFor("oldest", "first")).toEqual({
632
+ sortBy: "published",
633
+ sortOrder: "oldest",
634
+ });
635
+ });
636
+
637
+ it("holds the private floor for a signed-out reader", async () => {
638
+ const created = await smartCollections.create({
639
+ slug: "quotes",
640
+ title: "Quotes",
641
+ });
642
+
643
+ expect(
644
+ smartCollections.toPostFilters(created, anonymous).excludePrivate,
645
+ ).toBe(true);
646
+ expect(
647
+ smartCollections.toPostFilters(created, author).excludePrivate,
648
+ ).toBe(false);
649
+ });
650
+ });
651
+ });
@@ -9,6 +9,7 @@
9
9
  import { ValidationError } from "../lib/errors.js";
10
10
  import type { Visibility } from "../types.js";
11
11
  import type { CollectionService } from "./collection.js";
12
+ import type { SmartCollectionService } from "./smart-collection.js";
12
13
  import type { PathService } from "./path.js";
13
14
  import type { PostService } from "./post.js";
14
15
 
@@ -34,7 +35,8 @@ export type AboutPageStatus =
34
35
  state: "conflict";
35
36
  path: typeof ABOUT_PAGE_PATH;
36
37
  conflict: {
37
- targetType: "collection" | "redirect" | "archive" | "post";
38
+ targetType:
39
+ "collection" | "smart_collection" | "redirect" | "archive" | "post";
38
40
  id: string | null;
39
41
  title: string | null;
40
42
  };
@@ -49,6 +51,7 @@ export function createAboutPageService(deps: {
49
51
  paths: PathService;
50
52
  posts: PostService;
51
53
  collections: CollectionService;
54
+ smartCollections: SmartCollectionService;
52
55
  }): AboutPageService {
53
56
  async function readStatus(): Promise<AboutPageStatus> {
54
57
  const resolved = await deps.paths.resolve(ABOUT_PAGE_SLUG);
@@ -99,6 +102,24 @@ export function createAboutPageService(deps: {
99
102
  };
100
103
  }
101
104
 
105
+ if (
106
+ resolved.targetType === "smart_collection" &&
107
+ resolved.smartCollectionId
108
+ ) {
109
+ const smartCollection = await deps.smartCollections.getById(
110
+ resolved.smartCollectionId,
111
+ );
112
+ return {
113
+ ...base,
114
+ state: "conflict",
115
+ conflict: {
116
+ targetType: "smart_collection",
117
+ id: resolved.smartCollectionId,
118
+ title: smartCollection?.title ?? null,
119
+ },
120
+ };
121
+ }
122
+
102
123
  return {
103
124
  ...base,
104
125
  state: "conflict",