@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -15,11 +15,16 @@ import {
15
15
  supportsDrizzleTransaction,
16
16
  } from "../db/index.js";
17
17
  import type { DatabaseDialect } from "../db/dialect.js";
18
+ import {
19
+ buildRootActivityExpr,
20
+ rootActivityColumns,
21
+ } from "../db/thread-activity.js";
22
+ import { buildReaderVisibilityConditions } from "../db/post-visibility.js";
18
23
  import {
19
24
  sqliteSchemaBundle,
20
25
  type DatabaseSchema,
21
26
  } from "../db/schema-bundle.js";
22
- import { createEntityId } from "../lib/ids.js";
27
+ import { createEntityId, isTypeId, ID_PREFIX } from "../lib/ids.js";
23
28
  import { now } from "../lib/time.js";
24
29
  import type {
25
30
  Collection,
@@ -30,6 +35,7 @@ import type {
30
35
  CollectionsDirectoryData,
31
36
  CreateCollection,
32
37
  CreateCollectionDirectoryEntry,
38
+ SmartCollectionDirectoryEntry,
33
39
  UpdateCollection,
34
40
  UpdateCollectionDirectoryEntry,
35
41
  CollectionSortOrder,
@@ -45,6 +51,13 @@ import {
45
51
  type PathService,
46
52
  } from "./path.js";
47
53
  import { getCollectionPagePath } from "../lib/collection-paths.js";
54
+ import { getSlugValidationIssue } from "../lib/slug-format.js";
55
+ import type { SmartCollectionService } from "./smart-collection.js";
56
+ import {
57
+ DIRECTORY_POSITION_RETRY_ATTEMPTS as POSITION_RETRY_ATTEMPTS,
58
+ getAppendDirectoryPosition as appendDirectoryPosition,
59
+ getLastDirectoryPosition as lastDirectoryPosition,
60
+ } from "./collection-directory-position.js";
48
61
  import {
49
62
  CreateCollectionDirectoryItemSchema,
50
63
  CollectionDirectoryLabelSchema,
@@ -56,8 +69,6 @@ import {
56
69
  parseValidated,
57
70
  } from "../lib/schemas.js";
58
71
 
59
- const POSITION_RETRY_ATTEMPTS = 5;
60
-
61
72
  // Re-export shared constraint detection — see db/dialect.ts
62
73
  import { isUniqueConstraintError } from "../db/dialect.js";
63
74
 
@@ -87,6 +98,21 @@ function parseCollectionSelectionSlugs(
87
98
  return slugs.length > 0 ? slugs : null;
88
99
  }
89
100
 
101
+ /**
102
+ * Who is looking at the collections directory.
103
+ *
104
+ * The directory lists every collection in every language view — that is site
105
+ * skeleton, and it does not depend on the reader. Its *numbers* do: they answer
106
+ * "how many threads will I see if I click this", so they carry the same
107
+ * visibility and language narrowing the collection page applies.
108
+ */
109
+ export interface CollectionDirectoryViewer {
110
+ /** Signed-in authors see their private threads counted; readers do not. */
111
+ isAuthenticated: boolean;
112
+ /** Content language of the current view, when the site has more than one. */
113
+ lang?: string;
114
+ }
115
+
90
116
  export interface CollectionService {
91
117
  getById(id: string): Promise<Collection | null>;
92
118
  getBySlug(slug: string): Promise<Collection | null>;
@@ -95,12 +121,25 @@ export interface CollectionService {
95
121
  slugExpression: string,
96
122
  ): Promise<ResolvedCollectionSelection | null>;
97
123
  list(): Promise<Collection[]>;
98
- listDirectoryData(): Promise<CollectionsDirectoryData>;
124
+ listDirectoryData(
125
+ viewer: CollectionDirectoryViewer,
126
+ ): Promise<CollectionsDirectoryData>;
99
127
  /** List collections sorted by most recent Thread addition (for compose dialog) */
100
128
  listByRecentActivity(): Promise<Collection[]>;
101
129
  create(data: CreateCollection): Promise<Collection>;
102
130
  update(id: string, data: UpdateCollection): Promise<Collection | null>;
103
131
  delete(id: string): Promise<boolean>;
132
+ /**
133
+ * Whether a collection can take this address.
134
+ *
135
+ * A collection shares the root namespace with posts and smart collections,
136
+ * so the whole path registry answers this, not the collection table.
137
+ *
138
+ * @param slug - Address to test, already lowercased
139
+ * @param excludeId - Ignore the address this collection already holds
140
+ * @returns True when nothing else holds the address
141
+ */
142
+ checkSlugAvailability(slug: string, excludeId?: string): Promise<boolean>;
104
143
  /** List all collection directory items ordered by position */
105
144
  listDirectoryItems(): Promise<CollectionDirectoryEntry[]>;
106
145
  /** Create a collection directory item (collection, divider, or link) */
@@ -114,7 +153,14 @@ export interface CollectionService {
114
153
  id: string,
115
154
  data: UpdateCollectionDirectoryEntry,
116
155
  ): Promise<CollectionDirectoryEntry | null>;
117
- /** Move a collection directory item between two neighbors */
156
+ /**
157
+ * Move a collection directory row between two neighbors.
158
+ *
159
+ * Each id is a directory row id, or — for a collection or smart collection
160
+ * the directory renders without one — that entry's own id. Naming one of
161
+ * those writes rows for everything unplaced, in the order the directory
162
+ * already shows, and then moves within them.
163
+ */
118
164
  moveDirectoryItem(
119
165
  id: string,
120
166
  after: string | null,
@@ -209,12 +255,22 @@ export function createCollectionService(
209
255
  paths: PathService | undefined,
210
256
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
211
257
  databaseDialect: DatabaseDialect = "sqlite",
258
+ /**
259
+ * Consulted before a collection is deleted, so a smart collection that
260
+ * filters by it is named rather than silently widened.
261
+ *
262
+ * Passed as a dependency rather than reached for, following the same shape
263
+ * `storage?: StorageDriver` uses for media cleanup — the route stays out of
264
+ * cross-service orchestration.
265
+ */
266
+ smartCollections?: SmartCollectionService,
212
267
  ): CollectionService {
213
268
  const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
214
269
  const {
215
270
  collections,
216
271
  pathRegistry,
217
272
  collectionDirectoryItems: directoryItemsTable,
273
+ smartCollections: smartCollectionsTable,
218
274
  threadCollections,
219
275
  posts,
220
276
  navItems,
@@ -283,6 +339,7 @@ export function createCollectionService(
283
339
  siteId: row.siteId,
284
340
  type: row.type as CollectionDirectoryEntryType,
285
341
  collectionId: row.collectionId,
342
+ smartCollectionId: row.smartCollectionId,
286
343
  label: row.label,
287
344
  url: row.url,
288
345
  description: row.description,
@@ -346,13 +403,7 @@ export function createCollectionService(
346
403
  }
347
404
 
348
405
  async function getLastDirectoryPosition(): Promise<string | null> {
349
- const rows = await db
350
- .select({ position: directoryItemsTable.position })
351
- .from(directoryItemsTable)
352
- .where(eq(directoryItemsTable.siteId, siteId))
353
- .orderBy(sql`${directoryItemsTable.position} DESC`)
354
- .limit(1);
355
- return rows[0]?.position ?? null;
406
+ return lastDirectoryPosition(db, directoryItemsTable, siteId);
356
407
  }
357
408
 
358
409
  async function listOrderedDirectoryPositions(excludeId?: string) {
@@ -368,8 +419,7 @@ export function createCollectionService(
368
419
  }
369
420
 
370
421
  async function getAppendDirectoryPosition(): Promise<string> {
371
- const lastPos = await getLastDirectoryPosition();
372
- return generateKeyBetween(lastPos, null);
422
+ return appendDirectoryPosition(db, directoryItemsTable, siteId);
373
423
  }
374
424
 
375
425
  async function pathExists(path: string): Promise<boolean> {
@@ -421,6 +471,140 @@ export function createCollectionService(
421
471
  return generateKeyBetween(rows.at(-1)?.position ?? null, null);
422
472
  }
423
473
 
474
+ /**
475
+ * Write rows for the entries the directory only shows, in the order it shows
476
+ * them.
477
+ *
478
+ * A directory row is a position, not a membership: `buildDirectoryItems`
479
+ * appends every collection and smart collection that has none, so an unplaced
480
+ * entry is visible but has nothing to move and cannot be named as a
481
+ * neighbour. Materializing in exactly the render order is invisible — nothing
482
+ * on screen changes — and afterwards every row the author can see is a row
483
+ * the author can drag.
484
+ *
485
+ * @returns Directory row id, keyed by the collection or smart collection it places
486
+ */
487
+ async function materializeDirectoryRows(): Promise<Map<string, string>> {
488
+ const [directoryRows, collectionRows, smartCollectionRows] =
489
+ await Promise.all([
490
+ db
491
+ .select({
492
+ id: directoryItemsTable.id,
493
+ collectionId: directoryItemsTable.collectionId,
494
+ smartCollectionId: directoryItemsTable.smartCollectionId,
495
+ position: directoryItemsTable.position,
496
+ })
497
+ .from(directoryItemsTable)
498
+ .where(eq(directoryItemsTable.siteId, siteId))
499
+ .orderBy(asc(directoryItemsTable.position)),
500
+ db
501
+ .select({ id: collections.id })
502
+ .from(collections)
503
+ .where(eq(collections.siteId, siteId))
504
+ .orderBy(asc(collections.createdAt)),
505
+ db
506
+ .select({ id: smartCollectionsTable.id })
507
+ .from(smartCollectionsTable)
508
+ .where(eq(smartCollectionsTable.siteId, siteId))
509
+ .orderBy(asc(smartCollectionsTable.createdAt)),
510
+ ]);
511
+
512
+ const placed = new Map<string, string>();
513
+ let lastPosition: string | null = null;
514
+ for (const row of directoryRows) {
515
+ const entityId = row.collectionId ?? row.smartCollectionId;
516
+ if (entityId) placed.set(entityId, row.id);
517
+ lastPosition = row.position;
518
+ }
519
+
520
+ // Manual collections before smart ones, matching the two fallback loops in
521
+ // `buildDirectoryItems`. Read them in the other order and the first drag
522
+ // would reshuffle rows the author never touched.
523
+ const unplaced: { type: CollectionDirectoryEntryType; entityId: string }[] =
524
+ [
525
+ ...collectionRows
526
+ .filter((row) => !placed.has(row.id))
527
+ .map((row) => ({ type: "collection" as const, entityId: row.id })),
528
+ ...smartCollectionRows
529
+ .filter((row) => !placed.has(row.id))
530
+ .map((row) => ({
531
+ type: "smart_collection" as const,
532
+ entityId: row.id,
533
+ })),
534
+ ];
535
+
536
+ const timestamp = now();
537
+ for (const entry of unplaced) {
538
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
539
+ const id = createEntityId("collectionDirectoryItem");
540
+ const position = generateKeyBetween(lastPosition, null);
541
+ try {
542
+ await db.insert(directoryItemsTable).values({
543
+ id,
544
+ siteId,
545
+ type: entry.type,
546
+ collectionId: entry.type === "collection" ? entry.entityId : null,
547
+ smartCollectionId:
548
+ entry.type === "smart_collection" ? entry.entityId : null,
549
+ label: null,
550
+ url: null,
551
+ position,
552
+ createdAt: timestamp,
553
+ updatedAt: timestamp,
554
+ });
555
+ placed.set(entry.entityId, id);
556
+ lastPosition = position;
557
+ break;
558
+ } catch (err) {
559
+ if (
560
+ !isUniqueConstraintError(err) ||
561
+ attempt === POSITION_RETRY_ATTEMPTS - 1
562
+ ) {
563
+ throw err;
564
+ }
565
+ lastPosition = await getLastDirectoryPosition();
566
+ }
567
+ }
568
+ }
569
+
570
+ return placed;
571
+ }
572
+
573
+ /**
574
+ * Name the three rows a move talks about by their directory row id.
575
+ *
576
+ * The drag surface names an unplaced collection or smart collection by its
577
+ * own id, because that is all it has to render. Any such id here means the
578
+ * directory has to become explicit before a position can be computed.
579
+ *
580
+ * @returns Row ids, or `null` when the moved row no longer exists
581
+ */
582
+ async function resolveDirectoryMoveRows(
583
+ id: string,
584
+ afterId: string | null,
585
+ beforeId: string | null,
586
+ ): Promise<{
587
+ id: string;
588
+ after: string | null;
589
+ before: string | null;
590
+ } | null> {
591
+ const isRowId = (value: string | null) =>
592
+ value === null || isTypeId(value, ID_PREFIX.collectionDirectoryItem);
593
+
594
+ if (isRowId(id) && isRowId(afterId) && isRowId(beforeId)) {
595
+ return { id, after: afterId, before: beforeId };
596
+ }
597
+
598
+ const placed = await materializeDirectoryRows();
599
+ const toRowId = (value: string | null) =>
600
+ value === null || isRowId(value) ? value : (placed.get(value) ?? null);
601
+
602
+ const rowId = toRowId(id);
603
+ if (!rowId) return null;
604
+
605
+ return { id: rowId, after: toRowId(afterId), before: toRowId(beforeId) };
606
+ }
607
+
424
608
  async function hydrateCollection(
425
609
  row: typeof collections.$inferSelect | undefined,
426
610
  ): Promise<Collection | null> {
@@ -445,34 +629,38 @@ export function createCollectionService(
445
629
  .filter((row): row is Collection => row !== null);
446
630
  }
447
631
 
448
- async function listDirectoryCollections(): Promise<
449
- CollectionDirectoryCollection[]
450
- > {
451
- const threadActivityAt = sql<number>`CASE
452
- WHEN ${posts.updatedAt} > ${posts.createdAt}
453
- AND ${posts.updatedAt} > COALESCE(${posts.lastActivityAt}, -1)
454
- THEN ${posts.updatedAt}
455
- ELSE COALESCE(
456
- ${posts.lastActivityAt},
632
+ async function listDirectoryCollections(
633
+ viewer: CollectionDirectoryViewer,
634
+ ): Promise<CollectionDirectoryCollection[]> {
635
+ // Joined on thread_id rather than id, so a Thread counts once no matter
636
+ // which of its posts matched — the same shape
637
+ // `posts.countCollectionThreadRootsForCollections` uses for the collection
638
+ // page. The two numbers are shown one click apart and have to agree.
639
+ const readerConditions = buildReaderVisibilityConditions(posts, siteId, {
640
+ status: "published",
641
+ excludePrivate: !viewer.isAuthenticated,
642
+ lang: viewer.lang,
643
+ });
644
+
645
+ const threadCount =
646
+ sql<number>`CAST(COUNT(DISTINCT ${posts.threadId}) AS INTEGER)`.as(
647
+ "thread_count",
648
+ );
649
+ // Rows here are Thread *members*, so the root's activity is reached
650
+ // through a correlated subquery; the outer MAX only collapses the group.
651
+ // A member whose root row is missing falls back to its own timestamps.
652
+ const recentActivityAt = sql<number | null>`MAX(
653
+ COALESCE(
654
+ (
655
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
656
+ FROM post AS root
657
+ WHERE root.site_id = ${siteId}
658
+ AND root.id = ${posts.threadId}
659
+ ),
457
660
  ${posts.publishedAt},
458
661
  ${posts.updatedAt}
459
662
  )
460
- END`;
461
- const threadCount = sql<number>`
462
- CAST(COUNT(
463
- CASE
464
- WHEN ${posts.id} IS NOT NULL THEN 1
465
- END
466
- ) AS INTEGER)
467
- `.as("thread_count");
468
- const recentActivityAt = sql<number | null>`
469
- MAX(
470
- CASE
471
- WHEN ${posts.id} IS NOT NULL
472
- THEN ${threadActivityAt}
473
- END
474
- )
475
- `.as("recent_activity_at");
663
+ )`.as("recent_activity_at");
476
664
 
477
665
  const rows = await db
478
666
  .select({
@@ -488,11 +676,15 @@ export function createCollectionService(
488
676
  eq(collections.id, threadCollections.collectionId),
489
677
  ),
490
678
  )
679
+ // The reader's conditions belong in ON, not WHERE: a collection this
680
+ // reader can see nothing in stays listed and reads `0 threads`, which is
681
+ // what the directory promises — every collection in every view.
491
682
  .leftJoin(
492
683
  posts,
493
684
  and(
494
685
  eq(posts.siteId, threadCollections.siteId),
495
- eq(threadCollections.threadId, posts.id),
686
+ eq(posts.threadId, threadCollections.threadId),
687
+ ...readerConditions,
496
688
  ),
497
689
  )
498
690
  .where(eq(collections.siteId, siteId))
@@ -519,14 +711,33 @@ export function createCollectionService(
519
711
  .filter((row): row is CollectionDirectoryCollection => row !== null);
520
712
  }
521
713
 
714
+ /**
715
+ * Order the directory: explicitly placed rows first, then everything else.
716
+ *
717
+ * A directory row is a *position*, not a membership. Both kinds of collection
718
+ * appear whether or not one exists — anything unplaced is appended here — so
719
+ * there is no such thing as a collection missing from `/collections`, and no
720
+ * "add to directory" picker to build. The rows exist only so a collection can
721
+ * be interleaved with dividers and links and dragged around.
722
+ *
723
+ * Creating either kind writes its row, so the appended tail is for rows from
724
+ * before that was true and for imports. An entry in it is rendered under its
725
+ * own id, because there is no row id to render; moving one places it first —
726
+ * see `materializeDirectoryRows`.
727
+ */
522
728
  function buildDirectoryItems(
523
729
  directoryCollections: CollectionDirectoryCollection[],
730
+ directorySmartCollections: SmartCollectionDirectoryEntry[],
524
731
  orderedDirectoryItems: CollectionDirectoryEntry[],
525
732
  ): CollectionDirectoryItem[] {
526
733
  const collectionMap = new Map(
527
734
  directoryCollections.map((collection) => [collection.id, collection]),
528
735
  );
736
+ const smartCollectionMap = new Map(
737
+ directorySmartCollections.map((entry) => [entry.id, entry]),
738
+ );
529
739
  const seenCollections = new Set<string>();
740
+ const seenSmartCollections = new Set<string>();
530
741
  const items: CollectionDirectoryItem[] = [];
531
742
 
532
743
  for (const item of orderedDirectoryItems) {
@@ -551,6 +762,21 @@ export function createCollectionService(
551
762
  continue;
552
763
  }
553
764
 
765
+ if (item.type === "smart_collection") {
766
+ const smartCollection = item.smartCollectionId
767
+ ? smartCollectionMap.get(item.smartCollectionId)
768
+ : undefined;
769
+ if (!smartCollection) continue;
770
+
771
+ seenSmartCollections.add(smartCollection.id);
772
+ items.push({
773
+ id: item.id,
774
+ type: "smart_collection",
775
+ smartCollection,
776
+ });
777
+ continue;
778
+ }
779
+
554
780
  const collection = item.collectionId
555
781
  ? collectionMap.get(item.collectionId)
556
782
  : undefined;
@@ -573,6 +799,15 @@ export function createCollectionService(
573
799
  });
574
800
  }
575
801
 
802
+ for (const smartCollection of directorySmartCollections) {
803
+ if (seenSmartCollections.has(smartCollection.id)) continue;
804
+ items.push({
805
+ id: smartCollection.id,
806
+ type: "smart_collection",
807
+ smartCollection,
808
+ });
809
+ }
810
+
576
811
  return items;
577
812
  }
578
813
 
@@ -628,15 +863,31 @@ export function createCollectionService(
628
863
  return hydrateCollections(rows);
629
864
  },
630
865
 
631
- async listDirectoryData() {
632
- const [directoryCollections, orderedDirectoryItems] = await Promise.all([
633
- listDirectoryCollections(),
866
+ async listDirectoryData(viewer) {
867
+ const [
868
+ directoryCollections,
869
+ directorySmartCollections,
870
+ orderedDirectoryItems,
871
+ ] = await Promise.all([
872
+ listDirectoryCollections(viewer),
873
+ // One pass for every smart collection, not one query each — see
874
+ // `posts.aggregateMany`. Both kinds of row are measured through the
875
+ // same reader predicate, so a signed-out visitor sees a directory
876
+ // whose counts and dates agree with the pages behind them.
877
+ smartCollections
878
+ ? smartCollections.listDirectoryEntries(viewer)
879
+ : Promise.resolve([] as SmartCollectionDirectoryEntry[]),
634
880
  this.listDirectoryItems(),
635
881
  ]);
636
882
 
637
883
  return {
638
884
  collections: directoryCollections,
639
- items: buildDirectoryItems(directoryCollections, orderedDirectoryItems),
885
+ smartCollections: directorySmartCollections,
886
+ items: buildDirectoryItems(
887
+ directoryCollections,
888
+ directorySmartCollections,
889
+ orderedDirectoryItems,
890
+ ),
640
891
  directoryItems: orderedDirectoryItems,
641
892
  };
642
893
  },
@@ -841,6 +1092,13 @@ export function createCollectionService(
841
1092
  },
842
1093
 
843
1094
  async delete(id) {
1095
+ // Refused, not repaired: clearing the condition would quietly widen a
1096
+ // smart collection the author never edited. This is the only place that
1097
+ // refusal lives — `smart_collection.collection_id` carries no foreign key,
1098
+ // precisely so the answer can name the smart collections in the way
1099
+ // instead of failing a constraint.
1100
+ await smartCollections?.assertCollectionUnused(id);
1101
+
844
1102
  await db
845
1103
  .delete(threadCollections)
846
1104
  .where(
@@ -867,6 +1125,24 @@ export function createCollectionService(
867
1125
  return result.length > 0;
868
1126
  },
869
1127
 
1128
+ async checkSlugAvailability(slug, excludeId) {
1129
+ const issue = getSlugValidationIssue(slug);
1130
+ if (issue === "invalid") {
1131
+ throw new ValidationError("Slug contains invalid characters");
1132
+ }
1133
+ if (issue === "reserved") {
1134
+ throw new ValidationError("Slug is reserved");
1135
+ }
1136
+
1137
+ const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
1138
+ if (!resolved) return true;
1139
+ return Boolean(
1140
+ excludeId &&
1141
+ resolved.kind === "slug" &&
1142
+ resolved.collectionId === excludeId,
1143
+ );
1144
+ },
1145
+
870
1146
  async listDirectoryItems() {
871
1147
  const rows = await db
872
1148
  .select()
@@ -1026,13 +1302,16 @@ export function createCollectionService(
1026
1302
  },
1027
1303
 
1028
1304
  async moveDirectoryItem(id, afterId, beforeId) {
1305
+ const rows = await resolveDirectoryMoveRows(id, afterId, beforeId);
1306
+ if (!rows) return null;
1307
+
1029
1308
  const items = await db
1030
1309
  .select()
1031
1310
  .from(directoryItemsTable)
1032
1311
  .where(
1033
1312
  and(
1034
1313
  eq(directoryItemsTable.siteId, siteId),
1035
- eq(directoryItemsTable.id, id),
1314
+ eq(directoryItemsTable.id, rows.id),
1036
1315
  ),
1037
1316
  )
1038
1317
  .limit(1);
@@ -1044,13 +1323,17 @@ export function createCollectionService(
1044
1323
  const result = await db
1045
1324
  .update(directoryItemsTable)
1046
1325
  .set({
1047
- position: await getDirectoryMovePosition(id, afterId, beforeId),
1326
+ position: await getDirectoryMovePosition(
1327
+ rows.id,
1328
+ rows.after,
1329
+ rows.before,
1330
+ ),
1048
1331
  updatedAt: timestamp,
1049
1332
  })
1050
1333
  .where(
1051
1334
  and(
1052
1335
  eq(directoryItemsTable.siteId, siteId),
1053
- eq(directoryItemsTable.id, id),
1336
+ eq(directoryItemsTable.id, rows.id),
1054
1337
  ),
1055
1338
  )
1056
1339
  .returning();
@@ -15,10 +15,15 @@ import { isReservedPath } from "../lib/constants.js";
15
15
  import { ConflictError, ValidationError } from "../lib/errors.js";
16
16
  import { normalizePath } from "../lib/url.js";
17
17
  import type { CustomUrl } from "../types.js";
18
+ import { readLanguageSettings } from "./language.js";
18
19
  import { createPathService, type PathService } from "./path.js";
19
20
 
20
21
  export interface CreateCustomUrl {
21
22
  path: string;
23
+ /**
24
+ * `archive` is readable but no longer creatable — {@link CustomUrlService.create}
25
+ * refuses it. Stored ones predate smart collections and keep working.
26
+ */
22
27
  targetType: "post" | "collection" | "redirect" | "archive";
23
28
  targetId?: string;
24
29
  toPath?: string;
@@ -111,7 +116,12 @@ export function createCustomUrlService(
111
116
  async create(data) {
112
117
  const normalized = normalizeInputPath(data.path);
113
118
 
114
- if (isReservedPath(normalized)) {
119
+ const { reservedPrefixes } = await readLanguageSettings(
120
+ db,
121
+ siteId,
122
+ databaseSchema,
123
+ );
124
+ if (isReservedPath(normalized, reservedPrefixes)) {
115
125
  throw new ValidationError(
116
126
  `Path "${normalized}" is reserved and cannot be used`,
117
127
  );
@@ -128,26 +138,16 @@ export function createCustomUrlService(
128
138
  }
129
139
 
130
140
  if (data.targetType === "archive") {
131
- if (!data.archiveQuery) {
132
- throw new ValidationError("Archive query parameters are required");
133
- }
134
- const record = await resolvedPaths.create({
135
- path: normalized,
136
- kind: "archive",
137
- archiveQuery: data.archiveQuery,
138
- });
139
- const row = await db
140
- .select()
141
- .from(pathRegistry)
142
- .where(
143
- and(
144
- eq(pathRegistry.siteId, siteId),
145
- eq(pathRegistry.id, record.id),
146
- ),
147
- )
148
- .limit(1);
149
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- freshly inserted row exists
150
- return toCustomUrl(row[0]!);
141
+ // Refused here, not only hidden in the settings form. Typing
142
+ // `format=note&title=none` into a text field was the problem smart
143
+ // collections were built to replace, and a UI that stops offering it
144
+ // while the API still accepts it has not stopped offering it.
145
+ //
146
+ // Reading, listing, and deleting these paths all stay: existing ones
147
+ // keep working indefinitely, and each carries an upgrade button.
148
+ throw new ValidationError(
149
+ "Archive addresses are no longer created here. Make a smart collection instead.",
150
+ );
151
151
  }
152
152
 
153
153
  if (data.targetType === "redirect") {
@@ -238,7 +238,12 @@ export function createCustomUrlService(
238
238
 
239
239
  async isPathAvailable(path) {
240
240
  const normalized = normalizeInputPath(path);
241
- if (isReservedPath(normalized)) return false;
241
+ const { reservedPrefixes } = await readLanguageSettings(
242
+ db,
243
+ siteId,
244
+ databaseSchema,
245
+ );
246
+ if (isReservedPath(normalized, reservedPrefixes)) return false;
242
247
  return resolvedPaths.isPathAvailable(normalized);
243
248
  },
244
249
  };