@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
@@ -204,6 +204,34 @@
204
204
  "when": 1784531392184,
205
205
  "tag": "0028_perfect_sharon_carter",
206
206
  "breakpoints": true
207
+ },
208
+ {
209
+ "idx": 29,
210
+ "version": "7",
211
+ "when": 1786008671529,
212
+ "tag": "0029_rare_hairball",
213
+ "breakpoints": true
214
+ },
215
+ {
216
+ "idx": 30,
217
+ "version": "7",
218
+ "when": 1786069149825,
219
+ "tag": "0030_abandoned_nighthawk",
220
+ "breakpoints": true
221
+ },
222
+ {
223
+ "idx": 31,
224
+ "version": "7",
225
+ "when": 1787282384249,
226
+ "tag": "0031_strong_ultimatum",
227
+ "breakpoints": true
228
+ },
229
+ {
230
+ "idx": 32,
231
+ "version": "7",
232
+ "when": 1787425868269,
233
+ "tag": "0032_furry_multiple_man",
234
+ "breakpoints": true
207
235
  }
208
236
  ]
209
237
  }
@@ -19,26 +19,37 @@ import {
19
19
  customType,
20
20
  } from "drizzle-orm/pg-core";
21
21
  import { sql } from "drizzle-orm";
22
- import { SYSTEM_NAV_KEY_VALUES } from "../../types/constants.js";
23
-
24
- const SITE_STATUSES = ["active", "suspended"] as const;
25
- const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
26
- const SITE_MEMBER_ROLES = ["owner", "admin", "editor"] as const;
27
- const FORMATS = ["note", "link", "quote"] as const;
28
- const STATUSES = ["draft", "published"] as const;
29
- const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
30
- const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
31
- const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
32
- const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
33
- const UPLOAD_SESSION_STATES = [
34
- "pending",
35
- "uploaded",
36
- "completed",
37
- "aborted",
38
- "failed",
39
- ] as const;
40
- const CONTENT_DISPOSITIONS = ["inline", "attachment"] as const;
22
+ import {
23
+ ARCHIVE_LAYOUTS,
24
+ COLLECTION_DIRECTORY_ENTRY_TYPES,
25
+ COLLECTION_SORT_ORDERS,
26
+ CONTENT_DISPOSITIONS,
27
+ EARLIEST_FILTERABLE_YEAR,
28
+ FORMATS,
29
+ GITHUB_APP_ACCOUNT_TYPES,
30
+ LATEST_FILTERABLE_YEAR,
31
+ NAV_ITEM_PLACEMENTS,
32
+ NAV_ITEM_TYPES,
33
+ PATH_KINDS,
34
+ PUBLIC_ARCHIVE_VISIBILITIES,
35
+ SITE_DOMAIN_KINDS,
36
+ SITE_MEMBER_ROLES,
37
+ SITE_STATUSES,
38
+ SMART_COLLECTION_SORT_ORDERS,
39
+ STATUSES,
40
+ SYSTEM_NAV_KEY_VALUES,
41
+ UPLOAD_SESSION_STATES,
42
+ VISIBILITIES,
43
+ } from "../../types/constants.js";
41
44
 
45
+ /**
46
+ * Render a value list as the `IN (...)` body of a CHECK constraint.
47
+ *
48
+ * Every list it is handed comes from `types/constants.ts`. Declaring one
49
+ * locally instead is how a CHECK once shipped missing an enum value: the
50
+ * shared list grew, the schema's private copy did not, and the generated
51
+ * constraint rejected rows the application treats as valid.
52
+ */
42
53
  function sqlTextEnum(values: readonly string[]) {
43
54
  return sql.raw(values.map((value) => `'${value}'`).join(", "));
44
55
  }
@@ -179,8 +190,43 @@ export const posts = pgTable(
179
190
  previewProvider: text("preview_provider"),
180
191
  replyToId: text("reply_to_id"),
181
192
  threadId: text("thread_id").notNull(),
193
+ /**
194
+ * BCP 47 content language in canonical form (`en`, `zh-Hans`).
195
+ *
196
+ * Uniform across a Thread: replies inherit the Root's value on creation and
197
+ * a language change rewrites every row in the Thread. Language filters can
198
+ * therefore be plain column predicates even on member-grained queries.
199
+ *
200
+ * NULL only exists before multilingual content is first enabled; enabling
201
+ * stamps every NULL row with the primary language.
202
+ */
203
+ language: text("language"),
204
+ /**
205
+ * Shared translation-group key (TypeID, `tgr_` prefix). Rows sharing a value
206
+ * are translations of one another — one row per language, no direction.
207
+ *
208
+ * Only ever set on Thread Roots (`thread_id = id`); enforced in the post
209
+ * service so both dialects share one rule (see the SQLite schema for why a
210
+ * table CHECK is not an option there).
211
+ */
212
+ translationGroupId: text("translation_group_id"),
213
+ /**
214
+ * The author added this reply without announcing it. Persisted so both
215
+ * activity timestamps below stay recomputable from the rows alone.
216
+ * Always false on Thread roots.
217
+ */
218
+ quietReply: boolean("quiet_reply").notNull().default(false),
182
219
  publishedAt: integer("published_at"),
220
+ /**
221
+ * Root only: newest published_at in the Thread, excluding quiet replies —
222
+ * "last announced". Drives Latest and the feeds.
223
+ */
183
224
  lastActivityAt: integer("last_activity_at"),
225
+ /**
226
+ * Root only: newest published_at in the Thread, quiet replies included —
227
+ * "last changed". Drives the archive's updated sort.
228
+ */
229
+ threadUpdatedAt: integer("thread_updated_at"),
184
230
  createdAt: integer("created_at").notNull(),
185
231
  updatedAt: integer("updated_at").notNull(),
186
232
  },
@@ -228,6 +274,9 @@ export const posts = pgTable(
228
274
  index("idx_post_site_root_published_activity")
229
275
  .on(table.siteId, table.lastActivityAt, table.id)
230
276
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
277
+ index("idx_post_site_root_thread_updated")
278
+ .on(table.siteId, table.threadUpdatedAt, table.id)
279
+ .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
231
280
  index("idx_post_site_root_draft_updated")
232
281
  .on(table.siteId, table.updatedAt, table.id)
233
282
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
@@ -241,6 +290,12 @@ export const posts = pgTable(
241
290
  .where(
242
291
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
243
292
  ),
293
+ // One Post per language inside a translation group. The `site_id` prefix
294
+ // matches the indexing convention here and lets the same index serve
295
+ // "list the members of this group" without a second index.
296
+ uniqueIndex("uq_post_site_translation_group_language")
297
+ .on(table.siteId, table.translationGroupId, table.language)
298
+ .where(sql`${table.translationGroupId} IS NOT NULL`),
244
299
  index("idx_post_search_document").using("gin", table.searchDocument),
245
300
  index("idx_post_search_text_trgm").using(
246
301
  "gin",
@@ -441,6 +496,82 @@ export const collections = pgTable(
441
496
  ],
442
497
  );
443
498
 
499
+ // =============================================================================
500
+ // Smart Collections
501
+ // =============================================================================
502
+
503
+ /**
504
+ * A collection whose members are decided by conditions rather than by tagging.
505
+ *
506
+ * See the SQLite schema for why this is its own table and what `NULL` means in
507
+ * a condition column. The two dialects must stay identical: Drizzle ignores a
508
+ * column it does not know about, so a field added on one side and forgotten on
509
+ * the other loses data silently.
510
+ */
511
+ export const smartCollections = pgTable(
512
+ "smart_collection",
513
+ {
514
+ id: text("id").primaryKey(),
515
+ siteId: text("site_id")
516
+ .notNull()
517
+ .references(() => sites.id, { onDelete: "cascade" }),
518
+ title: text("title").notNull(),
519
+ description: text("description"),
520
+
521
+ // --- Conditions (see lib/filter-dimensions.ts) --------------------------
522
+ format: text("format", { enum: FORMATS }),
523
+ year: integer("year"),
524
+ // No foreign key, alone among this schema's id columns. See the SQLite
525
+ // schema for why the refusal is held in the service instead.
526
+ collectionId: text("collection_id"),
527
+ /** `any` | `none` | comma-joined MEDIA_KINDS. One folded vocabulary. */
528
+ media: text("media"),
529
+ hasTitle: boolean("has_title"),
530
+ hasReplies: boolean("has_replies"),
531
+ visibility: text("visibility", { enum: PUBLIC_ARCHIVE_VISIBILITIES }),
532
+
533
+ // --- Presentation (not conditions) --------------------------------------
534
+ sort: text("sort", { enum: SMART_COLLECTION_SORT_ORDERS })
535
+ .notNull()
536
+ .default("newest"),
537
+ /** NULL follows the site's `ARCHIVE_DEFAULT_LAYOUT`. */
538
+ layout: text("layout", { enum: ARCHIVE_LAYOUTS }),
539
+
540
+ createdAt: integer("created_at").notNull(),
541
+ updatedAt: integer("updated_at").notNull(),
542
+ },
543
+ (table) => [
544
+ check(
545
+ "chk_smart_collection_format",
546
+ sql`${table.format} IS NULL OR ${table.format} IN (${sqlTextEnum(FORMATS)})`,
547
+ ),
548
+ check(
549
+ "chk_smart_collection_visibility",
550
+ sql`${table.visibility} IS NULL OR ${table.visibility} IN (${sqlTextEnum(PUBLIC_ARCHIVE_VISIBILITIES)})`,
551
+ ),
552
+ check(
553
+ "chk_smart_collection_sort",
554
+ sql`${table.sort} IN (${sqlTextEnum(SMART_COLLECTION_SORT_ORDERS)})`,
555
+ ),
556
+ check(
557
+ "chk_smart_collection_layout",
558
+ sql`${table.layout} IS NULL OR ${table.layout} IN (${sqlTextEnum(ARCHIVE_LAYOUTS)})`,
559
+ ),
560
+ check(
561
+ "chk_smart_collection_year",
562
+ sql`${table.year} IS NULL OR ${table.year} BETWEEN ${sql.raw(String(EARLIEST_FILTERABLE_YEAR))} AND ${sql.raw(String(LATEST_FILTERABLE_YEAR))}`,
563
+ ),
564
+ index("idx_smart_collection_site_created_at").on(
565
+ table.siteId,
566
+ table.createdAt,
567
+ ),
568
+ index("idx_smart_collection_site_collection_id").on(
569
+ table.siteId,
570
+ table.collectionId,
571
+ ),
572
+ ],
573
+ );
574
+
444
575
  // =============================================================================
445
576
  // Path Registry (slug + alias + redirect)
446
577
  // =============================================================================
@@ -462,6 +593,10 @@ export const pathRegistry = pgTable(
462
593
  collectionId: text("collection_id").references(() => collections.id, {
463
594
  onDelete: "cascade",
464
595
  }),
596
+ smartCollectionId: text("smart_collection_id").references(
597
+ () => smartCollections.id,
598
+ { onDelete: "cascade" },
599
+ ),
465
600
  redirectToPath: text("redirect_to_path"),
466
601
  redirectType: integer("redirect_type"),
467
602
  archiveQuery: text("archive_query"),
@@ -471,7 +606,7 @@ export const pathRegistry = pgTable(
471
606
  (table) => [
472
607
  check(
473
608
  "chk_path_registry_kind",
474
- sql`${table.kind} IN ('slug', 'alias', 'redirect', 'archive')`,
609
+ sql`${table.kind} IN (${sqlTextEnum(PATH_KINDS)})`,
475
610
  ),
476
611
  uniqueIndex("uq_path_registry_site_path").on(table.siteId, table.path),
477
612
  uniqueIndex("uq_path_registry_site_post_slug")
@@ -480,18 +615,28 @@ export const pathRegistry = pgTable(
480
615
  uniqueIndex("uq_path_registry_site_collection_slug")
481
616
  .on(table.siteId, table.collectionId)
482
617
  .where(sql`${table.kind} = 'slug' AND ${table.collectionId} IS NOT NULL`),
618
+ uniqueIndex("uq_path_registry_site_smart_collection_slug")
619
+ .on(table.siteId, table.smartCollectionId)
620
+ .where(
621
+ sql`${table.kind} = 'slug' AND ${table.smartCollectionId} IS NOT NULL`,
622
+ ),
483
623
  index("idx_path_registry_site_post_id").on(table.siteId, table.postId),
484
624
  index("idx_path_registry_site_collection_id").on(
485
625
  table.siteId,
486
626
  table.collectionId,
487
627
  ),
628
+ index("idx_path_registry_site_smart_collection_id").on(
629
+ table.siteId,
630
+ table.smartCollectionId,
631
+ ),
488
632
  check(
489
633
  "chk_path_registry_shape",
490
634
  sql`(
491
635
  ${table.kind} IN ('slug', 'alias')
492
636
  AND (
493
- (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL)
494
- OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL)
637
+ (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NULL)
638
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL AND ${table.smartCollectionId} IS NULL)
639
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NOT NULL)
495
640
  )
496
641
  AND ${table.redirectToPath} IS NULL
497
642
  AND ${table.redirectType} IS NULL
@@ -500,6 +645,7 @@ export const pathRegistry = pgTable(
500
645
  ${table.kind} = 'redirect'
501
646
  AND ${table.postId} IS NULL
502
647
  AND ${table.collectionId} IS NULL
648
+ AND ${table.smartCollectionId} IS NULL
503
649
  AND ${table.redirectToPath} IS NOT NULL
504
650
  AND ${table.redirectType} IN (301, 302)
505
651
  AND ${table.archiveQuery} IS NULL
@@ -507,6 +653,7 @@ export const pathRegistry = pgTable(
507
653
  ${table.kind} = 'archive'
508
654
  AND ${table.postId} IS NULL
509
655
  AND ${table.collectionId} IS NULL
656
+ AND ${table.smartCollectionId} IS NULL
510
657
  AND ${table.redirectToPath} IS NULL
511
658
  AND ${table.redirectType} IS NULL
512
659
  AND ${table.archiveQuery} IS NOT NULL
@@ -526,10 +673,16 @@ export const collectionDirectoryItems = pgTable(
526
673
  siteId: text("site_id")
527
674
  .notNull()
528
675
  .references(() => sites.id, { onDelete: "cascade" }),
529
- type: text("type", { enum: ["collection", "divider", "link"] }).notNull(),
676
+ type: text("type", {
677
+ enum: COLLECTION_DIRECTORY_ENTRY_TYPES,
678
+ }).notNull(),
530
679
  collectionId: text("collection_id").references(() => collections.id, {
531
680
  onDelete: "cascade",
532
681
  }),
682
+ smartCollectionId: text("smart_collection_id").references(
683
+ () => smartCollections.id,
684
+ { onDelete: "cascade" },
685
+ ),
533
686
  label: text("label"),
534
687
  url: text("url"),
535
688
  description: text("description"),
@@ -540,12 +693,16 @@ export const collectionDirectoryItems = pgTable(
540
693
  (table) => [
541
694
  check(
542
695
  "chk_collection_directory_item_type",
543
- sql`${table.type} IN ('collection', 'divider', 'link')`,
696
+ sql`${table.type} IN (${sqlTextEnum(COLLECTION_DIRECTORY_ENTRY_TYPES)})`,
544
697
  ),
545
698
  index("idx_collection_directory_item_site_collection_id").on(
546
699
  table.siteId,
547
700
  table.collectionId,
548
701
  ),
702
+ index("idx_collection_directory_item_site_smart_collection_id").on(
703
+ table.siteId,
704
+ table.smartCollectionId,
705
+ ),
549
706
  uniqueIndex("uq_collection_directory_item_site_position").on(
550
707
  table.siteId,
551
708
  table.position,
@@ -555,27 +712,41 @@ export const collectionDirectoryItems = pgTable(
555
712
  .where(
556
713
  sql`${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL`,
557
714
  ),
715
+ uniqueIndex("uq_collection_directory_item_site_smart_collection_once")
716
+ .on(table.siteId, table.smartCollectionId)
717
+ .where(
718
+ sql`${table.type} = 'smart_collection' AND ${table.smartCollectionId} IS NOT NULL`,
719
+ ),
558
720
  check(
559
721
  "chk_collection_directory_item_shape",
560
722
  sql`(
561
723
  ${table.type} = 'collection'
562
724
  AND ${table.collectionId} IS NOT NULL
725
+ AND ${table.smartCollectionId} IS NULL
726
+ AND ${table.label} IS NULL
727
+ AND ${table.url} IS NULL
728
+ ) OR (
729
+ ${table.type} = 'smart_collection'
730
+ AND ${table.collectionId} IS NULL
731
+ AND ${table.smartCollectionId} IS NOT NULL
563
732
  AND ${table.label} IS NULL
564
733
  AND ${table.url} IS NULL
565
734
  ) OR (
566
735
  ${table.type} = 'divider'
567
736
  AND ${table.collectionId} IS NULL
737
+ AND ${table.smartCollectionId} IS NULL
568
738
  AND ${table.url} IS NULL
569
739
  ) OR (
570
740
  ${table.type} = 'link'
571
741
  AND ${table.collectionId} IS NULL
742
+ AND ${table.smartCollectionId} IS NULL
572
743
  AND ${table.label} IS NOT NULL
573
744
  AND ${table.url} IS NOT NULL
574
745
  )`,
575
746
  ),
576
747
  check(
577
748
  "chk_collection_directory_item_label",
578
- sql`${table.type} <> 'collection' OR ${table.label} IS NULL`,
749
+ sql`${table.type} NOT IN ('collection', 'smart_collection') OR ${table.label} IS NULL`,
579
750
  ),
580
751
  check(
581
752
  "chk_collection_directory_item_description",
@@ -641,6 +812,10 @@ export const navItems = pgTable(
641
812
  collectionId: text("collection_id").references(() => collections.id, {
642
813
  onDelete: "cascade",
643
814
  }),
815
+ smartCollectionId: text("smart_collection_id").references(
816
+ () => smartCollections.id,
817
+ { onDelete: "cascade" },
818
+ ),
644
819
  postId: text("post_id").references(() => posts.id, {
645
820
  onDelete: "cascade",
646
821
  }),
@@ -670,21 +845,31 @@ export const navItems = pgTable(
670
845
  ${table.type} = 'link'
671
846
  AND ${table.systemKey} IS NULL
672
847
  AND ${table.collectionId} IS NULL
848
+ AND ${table.smartCollectionId} IS NULL
673
849
  AND ${table.postId} IS NULL
674
850
  ) OR (
675
851
  ${table.type} = 'system'
676
852
  AND ${table.systemKey} IS NOT NULL
677
853
  AND ${table.collectionId} IS NULL
854
+ AND ${table.smartCollectionId} IS NULL
678
855
  AND ${table.postId} IS NULL
679
856
  ) OR (
680
857
  ${table.type} = 'collection'
681
858
  AND ${table.systemKey} IS NULL
682
859
  AND ${table.collectionId} IS NOT NULL
860
+ AND ${table.smartCollectionId} IS NULL
861
+ AND ${table.postId} IS NULL
862
+ ) OR (
863
+ ${table.type} = 'smart_collection'
864
+ AND ${table.systemKey} IS NULL
865
+ AND ${table.collectionId} IS NULL
866
+ AND ${table.smartCollectionId} IS NOT NULL
683
867
  AND ${table.postId} IS NULL
684
868
  ) OR (
685
869
  ${table.type} = 'page'
686
870
  AND ${table.systemKey} IS NULL
687
871
  AND ${table.collectionId} IS NULL
872
+ AND ${table.smartCollectionId} IS NULL
688
873
  AND ${table.postId} IS NOT NULL
689
874
  )`,
690
875
  ),
@@ -695,6 +880,9 @@ export const navItems = pgTable(
695
880
  uniqueIndex("uq_nav_item_site_collection_id")
696
881
  .on(table.siteId, table.collectionId)
697
882
  .where(sql`${table.collectionId} IS NOT NULL`),
883
+ uniqueIndex("uq_nav_item_site_smart_collection_id")
884
+ .on(table.siteId, table.smartCollectionId)
885
+ .where(sql`${table.smartCollectionId} IS NOT NULL`),
698
886
  uniqueIndex("uq_nav_item_site_post_id")
699
887
  .on(table.siteId, table.postId)
700
888
  .where(sql`${table.postId} IS NOT NULL`),
@@ -842,8 +1030,6 @@ export const verification = pgTable("verification", {
842
1030
  // GitHub App Installations (junction table: installation ↔ site, many-to-many)
843
1031
  // ---------------------------------------------------------------------------
844
1032
 
845
- const GITHUB_APP_ACCOUNT_TYPES = ["User", "Organization"] as const;
846
-
847
1033
  export const githubAppInstallation = pgTable(
848
1034
  "github_app_installation",
849
1035
  {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Who may see a post — one definition, shared by every surface that counts or
3
+ * lists them.
4
+ *
5
+ * A reply carries no visibility of its own: it inherits the Thread root's, so
6
+ * hiding a root hides the whole thread. Every query that filters by visibility
7
+ * has to resolve that inheritance the same way, and a query that forgets is not
8
+ * loud about it — it returns *more* rows, which looks like working code.
9
+ *
10
+ * That is how the collections directory came to count drafts and private
11
+ * threads into the number it showed a signed-out reader while the collection
12
+ * page, one click away, counted them out. Subtracting one number from the other
13
+ * told a stranger how much unpublished material a collection held.
14
+ *
15
+ * The rule these helpers encode: **the same reader gets the same set on every
16
+ * surface.** A page and the directory entry that links to it must apply this
17
+ * from one place, not two.
18
+ */
19
+
20
+ import { sql, type SQL, type SQLWrapper } from "drizzle-orm";
21
+ import type { Status, Visibility } from "../types/constants.js";
22
+
23
+ /** The `post` columns the visibility rules read, in either dialect's bundle. */
24
+ export interface PostVisibilityColumns {
25
+ status: SQLWrapper;
26
+ visibility: SQLWrapper;
27
+ threadId: SQLWrapper;
28
+ language: SQLWrapper;
29
+ }
30
+
31
+ /**
32
+ * A post's visibility after Thread-root inheritance.
33
+ *
34
+ * Replies store `NULL` and answer with their root's value, so this is the
35
+ * expression to compare against — never the raw column.
36
+ *
37
+ * @param post - The post table's `visibility` and `threadId` columns
38
+ * @param siteId - Site the query is scoped to
39
+ * @returns COALESCE expression yielding a {@link Visibility} value
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const visibility = buildEffectiveVisibilityExpr(posts, siteId);
44
+ * conditions.push(sql`${visibility} != 'private'`);
45
+ * ```
46
+ */
47
+ export function buildEffectiveVisibilityExpr(
48
+ post: Pick<PostVisibilityColumns, "visibility" | "threadId">,
49
+ siteId: string,
50
+ ): SQL<string> {
51
+ return sql<string>`coalesce(
52
+ ${post.visibility},
53
+ (SELECT root.visibility FROM post AS root WHERE root.id = ${post.threadId} AND root.site_id = ${siteId})
54
+ )`;
55
+ }
56
+
57
+ /** What a given reader is allowed to see, in the shape every caller passes. */
58
+ export interface ReaderVisibilityOptions {
59
+ /** Usually `"published"`. Omitted only where drafts are the subject. */
60
+ status?: Status;
61
+ /** Narrow to exactly one visibility, as the archive's filter bar does. */
62
+ visibility?: Visibility;
63
+ /** True for a signed-out reader. */
64
+ excludePrivate?: boolean;
65
+ /** True where `latest_hidden` posts are held back, as on Latest. */
66
+ excludeLatestHidden?: boolean;
67
+ /** Restrict to one content language, for a per-language view. */
68
+ lang?: string;
69
+ }
70
+
71
+ /**
72
+ * The WHERE clauses that narrow posts to what one reader may see.
73
+ *
74
+ * Returns clauses only for the options that are set, so it composes with
75
+ * whatever else a query filters on. Safe in a LEFT JOIN's `ON` clause, which is
76
+ * how a directory keeps listing a collection whose posts this reader cannot
77
+ * see, showing it as empty rather than dropping the row.
78
+ *
79
+ * @param post - The post table's columns
80
+ * @param siteId - Site the query is scoped to
81
+ * @param options - What this reader may see
82
+ * @returns Conditions to spread into `and(...)`
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * const conditions = buildReaderVisibilityConditions(posts, siteId, {
87
+ * status: "published",
88
+ * excludePrivate: !isAuthenticated,
89
+ * });
90
+ * ```
91
+ */
92
+ export function buildReaderVisibilityConditions(
93
+ post: PostVisibilityColumns,
94
+ siteId: string,
95
+ options: ReaderVisibilityOptions = {},
96
+ ): SQL[] {
97
+ const effectiveVisibility = buildEffectiveVisibilityExpr(post, siteId);
98
+ const conditions: SQL[] = [];
99
+
100
+ if (options.status) {
101
+ conditions.push(sql`${post.status} = ${options.status}`);
102
+ }
103
+ if (options.visibility !== undefined) {
104
+ conditions.push(sql`${effectiveVisibility} = ${options.visibility}`);
105
+ }
106
+ if (options.excludePrivate) {
107
+ conditions.push(sql`${effectiveVisibility} != 'private'`);
108
+ }
109
+ if (options.excludeLatestHidden) {
110
+ conditions.push(sql`${effectiveVisibility} != 'latest_hidden'`);
111
+ }
112
+ if (options.lang) {
113
+ conditions.push(sql`${post.language} = ${options.lang}`);
114
+ }
115
+
116
+ return conditions;
117
+ }