@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,636 @@
1
+ /**
2
+ * Smart Collection Service
3
+ *
4
+ * A smart collection is a collection whose members are decided by conditions
5
+ * rather than by tagging. To a reader it is a collection: root-level address,
6
+ * title, description, post list, feed. To the author it is a different gesture
7
+ * — writing conditions instead of tagging posts — and the asymmetry is
8
+ * permanent: nothing is added by hand, pinned, or reordered.
9
+ *
10
+ * Two rules this service exists to hold:
11
+ *
12
+ * - **A smart collection is always public.** `visibility` can only be one of
13
+ * the public archive values, so there is no page that answers 404 to a
14
+ * reader, no feed guard, and no placement guard. Per-post visibility is
15
+ * unchanged: the same base predicate a manual collection page applies, so a
16
+ * signed-in author may see a larger count than an anonymous reader — exactly
17
+ * as on a manual collection.
18
+ * - **A collection another object depends on is not deleted silently.**
19
+ * `collection_id` carries no foreign key — no ON DELETE action was the right
20
+ * one, and a constraint could only fail anonymously — so `assertCollectionUnused`
21
+ * is the whole of that rule, and it refuses by name.
22
+ */
23
+
24
+ import { and, asc, eq, inArray } from "drizzle-orm";
25
+ import { type Database } from "../db/index.js";
26
+ import {
27
+ sqliteSchemaBundle,
28
+ type DatabaseSchema,
29
+ } from "../db/schema-bundle.js";
30
+ import { createEntityId } from "../lib/ids.js";
31
+ import { now } from "../lib/time.js";
32
+ import { isUniqueConstraintError } from "../db/dialect.js";
33
+ import {
34
+ DIRECTORY_POSITION_RETRY_ATTEMPTS,
35
+ getAppendDirectoryPosition,
36
+ } from "./collection-directory-position.js";
37
+ import {
38
+ ConflictError,
39
+ NotFoundError,
40
+ ValidationError,
41
+ } from "../lib/errors.js";
42
+ import { getCollectionPagePath } from "../lib/collection-paths.js";
43
+ import { generatePostSlug } from "../lib/slug.js";
44
+ import { getSlugValidationIssue } from "../lib/slug-format.js";
45
+ import {
46
+ selectionFromRow,
47
+ selectionToColumns,
48
+ toPostFilters,
49
+ type PostFilterSelection,
50
+ } from "../lib/filter-dimensions.js";
51
+ import {
52
+ CollectionDescriptionValueSchema,
53
+ CollectionSlugSchema,
54
+ CollectionTitleSchema,
55
+ parseValidated,
56
+ PostFilterSelectionSchema,
57
+ } from "../lib/schemas.js";
58
+ import type {
59
+ CreateSmartCollection,
60
+ SmartCollection,
61
+ SmartCollectionDirectoryEntry,
62
+ UpdateSmartCollection,
63
+ } from "../types.js";
64
+ import { toCollectionPath, type PathService } from "./path.js";
65
+ import type { PostFilters, PostService } from "./post.js";
66
+
67
+ /** Who is looking, for the per-post visibility rules a count has to respect. */
68
+ export interface SmartCollectionViewer {
69
+ /** Signed-in authors see their private threads counted; readers do not. */
70
+ isAuthenticated: boolean;
71
+ /** Content language of the current view, when the site has more than one. */
72
+ lang?: string;
73
+ }
74
+
75
+ export interface SmartCollectionService {
76
+ getById(id: string): Promise<SmartCollection | null>;
77
+ getBySlug(slug: string): Promise<SmartCollection | null>;
78
+ list(): Promise<SmartCollection[]>;
79
+ /**
80
+ * Every smart collection with the count and freshness this viewer would see.
81
+ *
82
+ * Both measures are the viewer's own: a thread only the author can see is
83
+ * counted for the author alone, and it dates the collection for the author
84
+ * alone. A reader is never told that something they cannot read just moved.
85
+ */
86
+ listDirectoryEntries(
87
+ viewer: SmartCollectionViewer,
88
+ ): Promise<SmartCollectionDirectoryEntry[]>;
89
+ create(data: CreateSmartCollection): Promise<SmartCollection>;
90
+ update(
91
+ id: string,
92
+ data: UpdateSmartCollection,
93
+ ): Promise<SmartCollection | null>;
94
+ delete(id: string): Promise<boolean>;
95
+ /**
96
+ * A slug this smart collection could take, derived from a title.
97
+ *
98
+ * Same shape posts use, because the namespace is the same one: an address is
99
+ * free or it is not, whoever holds it.
100
+ */
101
+ suggestSlug(input: { title?: string; excludeId?: string }): Promise<string>;
102
+ /** Whether an address is free, ignoring the one this smart collection holds. */
103
+ checkSlugAvailability(slug: string, excludeId?: string): Promise<boolean>;
104
+ /**
105
+ * How many threads a set of conditions matches, next to the unfiltered total.
106
+ *
107
+ * What makes the editing dialog possible: without it an author is filling in
108
+ * a blind form and can save something that matches nothing.
109
+ */
110
+ preview(
111
+ selection: PostFilterSelection,
112
+ viewer: SmartCollectionViewer,
113
+ ): Promise<{ count: number; baseline: number }>;
114
+ /**
115
+ * The `PostFilters` this smart collection's conditions imply, for one viewer.
116
+ *
117
+ * The only place conditions become a query. Page, feed, and count all read
118
+ * it, so none of them can answer with a different set than the others.
119
+ */
120
+ toPostFilters(
121
+ smartCollection: SmartCollection,
122
+ viewer: SmartCollectionViewer,
123
+ ): PostFilters;
124
+ /**
125
+ * Refuse to delete a collection that a smart collection filters by.
126
+ *
127
+ * Called before deleting a collection. Nothing is repaired automatically:
128
+ * clearing the condition would quietly widen a smart collection the author
129
+ * never edited, which this project does not do.
130
+ *
131
+ * @param collectionId - The collection about to be deleted
132
+ * @throws ValidationError naming the smart collections that depend on it
133
+ */
134
+ assertCollectionUnused(collectionId: string): Promise<void>;
135
+ }
136
+
137
+ export function createSmartCollectionService(
138
+ db: Database,
139
+ siteId: string,
140
+ resolvedPaths: PathService,
141
+ posts: PostService,
142
+ databaseSchema: DatabaseSchema = sqliteSchemaBundle,
143
+ slugIdLength = 5,
144
+ ): SmartCollectionService {
145
+ const {
146
+ smartCollections,
147
+ navItems,
148
+ collectionDirectoryItems: directoryItemsTable,
149
+ } = databaseSchema;
150
+
151
+ type SmartCollectionRow = typeof smartCollections.$inferSelect;
152
+
153
+ function toSmartCollection(
154
+ row: SmartCollectionRow,
155
+ slug: string,
156
+ ): SmartCollection {
157
+ return {
158
+ id: row.id,
159
+ siteId: row.siteId,
160
+ slug,
161
+ title: row.title,
162
+ description: row.description,
163
+ selection: selectionFromRow(row as unknown as Record<string, unknown>),
164
+ sort: row.sort,
165
+ layout: row.layout ?? null,
166
+ createdAt: row.createdAt,
167
+ updatedAt: row.updatedAt,
168
+ };
169
+ }
170
+
171
+ async function hydrate(
172
+ row: SmartCollectionRow | undefined,
173
+ ): Promise<SmartCollection | null> {
174
+ if (!row) return null;
175
+ const slug = await resolvedPaths.getSmartCollectionSlug(row.id);
176
+ return slug ? toSmartCollection(row, slug) : null;
177
+ }
178
+
179
+ async function hydrateMany(
180
+ rows: SmartCollectionRow[],
181
+ ): Promise<SmartCollection[]> {
182
+ if (rows.length === 0) return [];
183
+ const slugMap = await resolvedPaths.getSmartCollectionSlugMap(
184
+ rows.map((row) => row.id),
185
+ );
186
+ return rows
187
+ .map((row) => {
188
+ const slug = slugMap.get(row.id);
189
+ return slug ? toSmartCollection(row, slug) : null;
190
+ })
191
+ .filter((row): row is SmartCollection => row !== null);
192
+ }
193
+
194
+ /**
195
+ * Append this smart collection's row to the collections directory.
196
+ *
197
+ * The directory is one ordered list shared with manual collections, dividers,
198
+ * and links; the row is what gives a smart collection a position in it. The
199
+ * position rule itself lives in `collection-directory-position`, so both
200
+ * writers append the same way.
201
+ *
202
+ * @param id - The smart collection to place
203
+ * @param timestamp - Creation time, shared with the smart collection row
204
+ */
205
+ async function placeInDirectory(
206
+ id: string,
207
+ timestamp: number,
208
+ ): Promise<void> {
209
+ for (
210
+ let attempt = 0;
211
+ attempt < DIRECTORY_POSITION_RETRY_ATTEMPTS;
212
+ attempt += 1
213
+ ) {
214
+ try {
215
+ await db.insert(directoryItemsTable).values({
216
+ id: createEntityId("collectionDirectoryItem"),
217
+ siteId,
218
+ type: "smart_collection",
219
+ collectionId: null,
220
+ smartCollectionId: id,
221
+ label: null,
222
+ url: null,
223
+ position: await getAppendDirectoryPosition(
224
+ db,
225
+ directoryItemsTable,
226
+ siteId,
227
+ ),
228
+ createdAt: timestamp,
229
+ updatedAt: timestamp,
230
+ });
231
+ return;
232
+ } catch (err) {
233
+ if (
234
+ !isUniqueConstraintError(err) ||
235
+ attempt === DIRECTORY_POSITION_RETRY_ATTEMPTS - 1
236
+ ) {
237
+ throw err;
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ /** Validate and normalize the fields a create or update may set. */
244
+ function normalizeInput(
245
+ data: CreateSmartCollection | UpdateSmartCollection,
246
+ ): {
247
+ slug?: string;
248
+ title?: string;
249
+ description?: string | null;
250
+ selection?: PostFilterSelection;
251
+ sort?: SmartCollection["sort"];
252
+ layout?: SmartCollection["layout"];
253
+ } {
254
+ return {
255
+ ...(data.slug !== undefined
256
+ ? { slug: parseValidated(CollectionSlugSchema, data.slug) }
257
+ : {}),
258
+ ...(data.title !== undefined
259
+ ? { title: parseValidated(CollectionTitleSchema, data.title) }
260
+ : {}),
261
+ ...(data.description !== undefined
262
+ ? {
263
+ description:
264
+ data.description === null || data.description === ""
265
+ ? null
266
+ : parseValidated(
267
+ CollectionDescriptionValueSchema,
268
+ data.description,
269
+ ),
270
+ }
271
+ : {}),
272
+ ...(data.selection !== undefined
273
+ ? {
274
+ selection: parseValidated(
275
+ PostFilterSelectionSchema,
276
+ data.selection,
277
+ ),
278
+ }
279
+ : {}),
280
+ ...(data.sort !== undefined ? { sort: data.sort } : {}),
281
+ ...(data.layout !== undefined ? { layout: data.layout } : {}),
282
+ };
283
+ }
284
+
285
+ /** Is this address free, ignoring the one this smart collection already holds? */
286
+ async function isSlugFree(
287
+ slug: string,
288
+ excludeId?: string,
289
+ ): Promise<boolean> {
290
+ const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
291
+ if (!resolved) return true;
292
+ return Boolean(
293
+ excludeId &&
294
+ resolved.kind === "slug" &&
295
+ resolved.smartCollectionId === excludeId,
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Every collection the selection names must exist.
301
+ *
302
+ * The registry validates the *shape* of a collection id; only the database
303
+ * can say whether one is real. A condition pointing at nothing would render
304
+ * as a smart collection that silently matches no post.
305
+ */
306
+ async function assertSelectionResolvable(
307
+ selection: PostFilterSelection,
308
+ ): Promise<void> {
309
+ const ids = selection.collection ?? [];
310
+ if (ids.length === 0) return;
311
+
312
+ const { collections } = databaseSchema;
313
+ const rows = await db
314
+ .select({ id: collections.id })
315
+ .from(collections)
316
+ .where(
317
+ and(eq(collections.siteId, siteId), inArray(collections.id, [...ids])),
318
+ );
319
+ if (rows.length !== ids.length) {
320
+ throw new NotFoundError("Collection");
321
+ }
322
+ }
323
+
324
+ function buildPostFilters(
325
+ smartCollection: SmartCollection,
326
+ viewer: SmartCollectionViewer,
327
+ ): PostFilters {
328
+ // The same axes a manual collection sorts on, so the two kinds of
329
+ // collection cannot rank the same Thread differently: `newest` and the
330
+ // rating order's tie-break read Thread activity — when the Thread last
331
+ // gained an announced post — while `oldest` reads publication, which is
332
+ // the only reading of "oldest" that does not move when a reply lands.
333
+ const axis =
334
+ smartCollection.sort === "oldest"
335
+ ? ("published" as const)
336
+ : ("activity" as const);
337
+ return {
338
+ lang: viewer.lang,
339
+ status: "published",
340
+ excludeReplies: true,
341
+ // The floor, always. A smart collection is public, but the posts inside
342
+ // it are only as visible as they are anywhere else.
343
+ excludePrivate: !viewer.isAuthenticated,
344
+ excludeLatestHidden: false,
345
+ // Membership never depends on presentation, so the year condition is
346
+ // pinned to the publication axis whatever the chosen order is.
347
+ ...toPostFilters(smartCollection.selection, { yearAxis: "published" }),
348
+ sortBy: axis,
349
+ sortOrder: smartCollection.sort,
350
+ ignorePinnedSort: true,
351
+ };
352
+ }
353
+
354
+ return {
355
+ async getById(id) {
356
+ const rows = await db
357
+ .select()
358
+ .from(smartCollections)
359
+ .where(
360
+ and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
361
+ )
362
+ .limit(1);
363
+ return hydrate(rows[0]);
364
+ },
365
+
366
+ async getBySlug(slug) {
367
+ const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
368
+ if (
369
+ !resolved ||
370
+ resolved.kind !== "slug" ||
371
+ !resolved.smartCollectionId
372
+ ) {
373
+ return null;
374
+ }
375
+ return this.getById(resolved.smartCollectionId);
376
+ },
377
+
378
+ async list() {
379
+ const rows = await db
380
+ .select()
381
+ .from(smartCollections)
382
+ .where(eq(smartCollections.siteId, siteId))
383
+ .orderBy(asc(smartCollections.createdAt));
384
+ return hydrateMany(rows);
385
+ },
386
+
387
+ async listDirectoryEntries(viewer) {
388
+ const entries = await this.list();
389
+ if (entries.length === 0) return [];
390
+
391
+ // One pass for every smart collection on the page, not one query each.
392
+ // Same predicate builder the pages use, so the number in the directory
393
+ // and the number on the page it links to agree.
394
+ const measured = await posts.aggregateMany(
395
+ entries.map((entry) => toPostFilters(entry.selection, {})),
396
+ {
397
+ status: "published",
398
+ excludeReplies: true,
399
+ excludePrivate: !viewer.isAuthenticated,
400
+ excludeLatestHidden: false,
401
+ lang: viewer.lang,
402
+ },
403
+ );
404
+
405
+ return entries.map((entry, index) => ({
406
+ ...entry,
407
+ threadCount: measured[index]?.count ?? 0,
408
+ // Nothing matched — no thread to date it by — so the collection dates
409
+ // itself, exactly as an empty manual collection does.
410
+ recentActivityAt: measured[index]?.recentActivityAt ?? entry.updatedAt,
411
+ }));
412
+ },
413
+
414
+ async create(data) {
415
+ const normalized = normalizeInput(data);
416
+ if (!normalized.slug || !normalized.title) {
417
+ throw new ValidationError(
418
+ "A smart collection needs a title and an address.",
419
+ );
420
+ }
421
+ const selection = normalized.selection ?? {};
422
+ await assertSelectionResolvable(selection);
423
+
424
+ const id = createEntityId("smartCollection");
425
+ const timestamp = now();
426
+
427
+ // The row goes in before its path, because the path row's foreign key
428
+ // points at it. The address is what can still fail — it shares one
429
+ // namespace with posts and collections — so a clash rolls the row back
430
+ // rather than leaving a smart collection nothing can reach.
431
+ await db.insert(smartCollections).values({
432
+ id,
433
+ siteId,
434
+ title: normalized.title,
435
+ description: normalized.description ?? null,
436
+ ...selectionToColumns(selection),
437
+ sort: normalized.sort ?? "newest",
438
+ layout: normalized.layout ?? null,
439
+ createdAt: timestamp,
440
+ updatedAt: timestamp,
441
+ } as typeof smartCollections.$inferInsert);
442
+
443
+ try {
444
+ // A smart collection takes its place in the directory the way a manual
445
+ // one does — appended, with a row of its own. The directory would list
446
+ // it either way, but only a row can hold a position, so without this it
447
+ // sits below every manual collection and cannot be dragged out of there.
448
+ await placeInDirectory(id, timestamp);
449
+ await resolvedPaths.createSmartCollectionSlug(id, normalized.slug);
450
+ } catch (err) {
451
+ await db
452
+ .delete(directoryItemsTable)
453
+ .where(
454
+ and(
455
+ eq(directoryItemsTable.siteId, siteId),
456
+ eq(directoryItemsTable.smartCollectionId, id),
457
+ ),
458
+ );
459
+ await db
460
+ .delete(smartCollections)
461
+ .where(
462
+ and(
463
+ eq(smartCollections.siteId, siteId),
464
+ eq(smartCollections.id, id),
465
+ ),
466
+ );
467
+ if (err instanceof ConflictError) {
468
+ throw new ConflictError(
469
+ `Slug "${normalized.slug}" is already in use`,
470
+ );
471
+ }
472
+ throw err;
473
+ }
474
+
475
+ const created = await this.getById(id);
476
+ if (!created) throw new NotFoundError("Smart collection");
477
+ return created;
478
+ },
479
+
480
+ async update(id, data) {
481
+ const existing = await this.getById(id);
482
+ if (!existing) return null;
483
+
484
+ const normalized = normalizeInput(data);
485
+ if (normalized.selection) {
486
+ await assertSelectionResolvable(normalized.selection);
487
+ }
488
+
489
+ const timestamp = now();
490
+ const slugChanged =
491
+ normalized.slug !== undefined && normalized.slug !== existing.slug;
492
+
493
+ if (slugChanged && normalized.slug) {
494
+ try {
495
+ await resolvedPaths.updateSmartCollectionSlug(id, normalized.slug);
496
+ } catch (err) {
497
+ if (err instanceof ConflictError) {
498
+ throw new ConflictError(
499
+ `Slug "${normalized.slug}" is already in use`,
500
+ );
501
+ }
502
+ throw err;
503
+ }
504
+
505
+ // A nav item's URL is stored, unlike its label, so moving the address
506
+ // has to move the link with it — the same rewrite a renamed collection
507
+ // performs.
508
+ await db
509
+ .update(navItems)
510
+ .set({
511
+ url: getCollectionPagePath(normalized.slug),
512
+ updatedAt: timestamp,
513
+ })
514
+ .where(
515
+ and(
516
+ eq(navItems.siteId, siteId),
517
+ eq(navItems.smartCollectionId, id),
518
+ ),
519
+ );
520
+ }
521
+
522
+ const updates: Record<string, unknown> = { updatedAt: timestamp };
523
+ if (normalized.title !== undefined) updates.title = normalized.title;
524
+ if (normalized.description !== undefined) {
525
+ updates.description = normalized.description;
526
+ }
527
+ if (normalized.sort !== undefined) updates.sort = normalized.sort;
528
+ if (normalized.layout !== undefined) updates.layout = normalized.layout;
529
+ if (normalized.selection !== undefined) {
530
+ // Every dimension is written, including the ones with no value —
531
+ // removing a condition has to clear its column, not leave it behind.
532
+ Object.assign(updates, selectionToColumns(normalized.selection));
533
+ }
534
+
535
+ await db
536
+ .update(smartCollections)
537
+ .set(updates as Partial<typeof smartCollections.$inferInsert>)
538
+ .where(
539
+ and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
540
+ );
541
+
542
+ return this.getById(id);
543
+ },
544
+
545
+ async delete(id) {
546
+ await db
547
+ .delete(directoryItemsTable)
548
+ .where(
549
+ and(
550
+ eq(directoryItemsTable.siteId, siteId),
551
+ eq(directoryItemsTable.smartCollectionId, id),
552
+ ),
553
+ );
554
+ await db
555
+ .delete(navItems)
556
+ .where(
557
+ and(eq(navItems.siteId, siteId), eq(navItems.smartCollectionId, id)),
558
+ );
559
+ const result = await db
560
+ .delete(smartCollections)
561
+ .where(
562
+ and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
563
+ )
564
+ .returning();
565
+ return result.length > 0;
566
+ },
567
+
568
+ async suggestSlug(input) {
569
+ return generatePostSlug({
570
+ title: input.title,
571
+ idLength: slugIdLength,
572
+ isAvailable: (candidate) => isSlugFree(candidate, input.excludeId),
573
+ });
574
+ },
575
+
576
+ async checkSlugAvailability(slug, excludeId) {
577
+ const issue = getSlugValidationIssue(slug);
578
+ if (issue === "invalid") {
579
+ throw new ValidationError("Slug contains invalid characters");
580
+ }
581
+ if (issue === "reserved") {
582
+ throw new ValidationError("Slug is reserved");
583
+ }
584
+ return isSlugFree(slug, excludeId);
585
+ },
586
+
587
+ async preview(selection, viewer) {
588
+ const parsed = parseValidated(PostFilterSelectionSchema, selection);
589
+ await assertSelectionResolvable(parsed);
590
+
591
+ const base: PostFilters = {
592
+ status: "published",
593
+ excludeReplies: true,
594
+ excludePrivate: !viewer.isAuthenticated,
595
+ excludeLatestHidden: false,
596
+ lang: viewer.lang,
597
+ };
598
+ // Both numbers in one round trip, and both through the same predicate
599
+ // builder the saved page will use — a preview that counted differently
600
+ // than the page would be worse than no preview.
601
+ const [matched, all] = await posts.aggregateMany(
602
+ [toPostFilters(parsed, {}), {}],
603
+ base,
604
+ );
605
+ return { count: matched?.count ?? 0, baseline: all?.count ?? 0 };
606
+ },
607
+
608
+ toPostFilters(smartCollection, viewer) {
609
+ return buildPostFilters(smartCollection, viewer);
610
+ },
611
+
612
+ async assertCollectionUnused(collectionId) {
613
+ const rows = await db
614
+ .select({ title: smartCollections.title })
615
+ .from(smartCollections)
616
+ .where(
617
+ and(
618
+ eq(smartCollections.siteId, siteId),
619
+ eq(smartCollections.collectionId, collectionId),
620
+ ),
621
+ )
622
+ .orderBy(asc(smartCollections.createdAt));
623
+
624
+ if (rows.length === 0) return;
625
+
626
+ // Named, not counted: "used by 2 smart collections" leaves the author
627
+ // hunting for which ones.
628
+ const names = rows.map((row) => row.title).join(", ");
629
+ throw new ValidationError(
630
+ rows.length === 1
631
+ ? `This collection is used by the smart collection ${names}. Change or delete that first.`
632
+ : `This collection is used by these smart collections: ${names}. Change or delete them first.`,
633
+ );
634
+ },
635
+ };
636
+ }
@@ -483,28 +483,26 @@ export function createTelegramService(
483
483
  )
484
484
  .returning();
485
485
  return rows
486
- .map(
487
- (row): TelegramMediaGroupItem => ({
488
- id: row.id,
489
- siteId: row.siteId,
490
- botId: row.botId,
491
- telegramUserId: row.telegramUserId,
492
- mediaGroupId: row.mediaGroupId,
493
- chatId: row.chatId,
494
- messageId: row.messageId,
495
- updateId: row.updateId,
496
- fileId: row.fileId,
497
- mediaKind: row.mediaKind as TelegramMediaGroupKind,
498
- mimeType: row.mimeType,
499
- originalName: row.originalName,
500
- captionMarkdown: row.captionMarkdown,
501
- width: row.width,
502
- height: row.height,
503
- durationSeconds: row.durationSeconds,
504
- posterFileId: row.posterFileId,
505
- createdAt: row.createdAt,
506
- }),
507
- )
486
+ .map((row): TelegramMediaGroupItem => ({
487
+ id: row.id,
488
+ siteId: row.siteId,
489
+ botId: row.botId,
490
+ telegramUserId: row.telegramUserId,
491
+ mediaGroupId: row.mediaGroupId,
492
+ chatId: row.chatId,
493
+ messageId: row.messageId,
494
+ updateId: row.updateId,
495
+ fileId: row.fileId,
496
+ mediaKind: row.mediaKind as TelegramMediaGroupKind,
497
+ mimeType: row.mimeType,
498
+ originalName: row.originalName,
499
+ captionMarkdown: row.captionMarkdown,
500
+ width: row.width,
501
+ height: row.height,
502
+ durationSeconds: row.durationSeconds,
503
+ posterFileId: row.posterFileId,
504
+ createdAt: row.createdAt,
505
+ }))
508
506
  .sort((a, b) => a.messageId - b.messageId);
509
507
  },
510
508
 
@@ -91,9 +91,7 @@ export interface PutTransport extends PresignedPutTarget {
91
91
  }
92
92
 
93
93
  export type UploadTransport =
94
- | RelayTransport
95
- | MultipartRelayTransport
96
- | PutTransport;
94
+ RelayTransport | MultipartRelayTransport | PutTransport;
97
95
 
98
96
  export interface InitiateUploadResult {
99
97
  id: string;