@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
@@ -211,6 +211,27 @@
211
211
  "when": 1786008671529,
212
212
  "tag": "0029_rare_hairball",
213
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
214
235
  }
215
236
  ]
216
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,6 +190,26 @@ 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"),
182
213
  /**
183
214
  * The author added this reply without announcing it. Persisted so both
184
215
  * activity timestamps below stay recomputable from the rows alone.
@@ -259,6 +290,12 @@ export const posts = pgTable(
259
290
  .where(
260
291
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
261
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`),
262
299
  index("idx_post_search_document").using("gin", table.searchDocument),
263
300
  index("idx_post_search_text_trgm").using(
264
301
  "gin",
@@ -459,6 +496,82 @@ export const collections = pgTable(
459
496
  ],
460
497
  );
461
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
+
462
575
  // =============================================================================
463
576
  // Path Registry (slug + alias + redirect)
464
577
  // =============================================================================
@@ -480,6 +593,10 @@ export const pathRegistry = pgTable(
480
593
  collectionId: text("collection_id").references(() => collections.id, {
481
594
  onDelete: "cascade",
482
595
  }),
596
+ smartCollectionId: text("smart_collection_id").references(
597
+ () => smartCollections.id,
598
+ { onDelete: "cascade" },
599
+ ),
483
600
  redirectToPath: text("redirect_to_path"),
484
601
  redirectType: integer("redirect_type"),
485
602
  archiveQuery: text("archive_query"),
@@ -489,7 +606,7 @@ export const pathRegistry = pgTable(
489
606
  (table) => [
490
607
  check(
491
608
  "chk_path_registry_kind",
492
- sql`${table.kind} IN ('slug', 'alias', 'redirect', 'archive')`,
609
+ sql`${table.kind} IN (${sqlTextEnum(PATH_KINDS)})`,
493
610
  ),
494
611
  uniqueIndex("uq_path_registry_site_path").on(table.siteId, table.path),
495
612
  uniqueIndex("uq_path_registry_site_post_slug")
@@ -498,18 +615,28 @@ export const pathRegistry = pgTable(
498
615
  uniqueIndex("uq_path_registry_site_collection_slug")
499
616
  .on(table.siteId, table.collectionId)
500
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
+ ),
501
623
  index("idx_path_registry_site_post_id").on(table.siteId, table.postId),
502
624
  index("idx_path_registry_site_collection_id").on(
503
625
  table.siteId,
504
626
  table.collectionId,
505
627
  ),
628
+ index("idx_path_registry_site_smart_collection_id").on(
629
+ table.siteId,
630
+ table.smartCollectionId,
631
+ ),
506
632
  check(
507
633
  "chk_path_registry_shape",
508
634
  sql`(
509
635
  ${table.kind} IN ('slug', 'alias')
510
636
  AND (
511
- (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL)
512
- 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)
513
640
  )
514
641
  AND ${table.redirectToPath} IS NULL
515
642
  AND ${table.redirectType} IS NULL
@@ -518,6 +645,7 @@ export const pathRegistry = pgTable(
518
645
  ${table.kind} = 'redirect'
519
646
  AND ${table.postId} IS NULL
520
647
  AND ${table.collectionId} IS NULL
648
+ AND ${table.smartCollectionId} IS NULL
521
649
  AND ${table.redirectToPath} IS NOT NULL
522
650
  AND ${table.redirectType} IN (301, 302)
523
651
  AND ${table.archiveQuery} IS NULL
@@ -525,6 +653,7 @@ export const pathRegistry = pgTable(
525
653
  ${table.kind} = 'archive'
526
654
  AND ${table.postId} IS NULL
527
655
  AND ${table.collectionId} IS NULL
656
+ AND ${table.smartCollectionId} IS NULL
528
657
  AND ${table.redirectToPath} IS NULL
529
658
  AND ${table.redirectType} IS NULL
530
659
  AND ${table.archiveQuery} IS NOT NULL
@@ -544,10 +673,16 @@ export const collectionDirectoryItems = pgTable(
544
673
  siteId: text("site_id")
545
674
  .notNull()
546
675
  .references(() => sites.id, { onDelete: "cascade" }),
547
- type: text("type", { enum: ["collection", "divider", "link"] }).notNull(),
676
+ type: text("type", {
677
+ enum: COLLECTION_DIRECTORY_ENTRY_TYPES,
678
+ }).notNull(),
548
679
  collectionId: text("collection_id").references(() => collections.id, {
549
680
  onDelete: "cascade",
550
681
  }),
682
+ smartCollectionId: text("smart_collection_id").references(
683
+ () => smartCollections.id,
684
+ { onDelete: "cascade" },
685
+ ),
551
686
  label: text("label"),
552
687
  url: text("url"),
553
688
  description: text("description"),
@@ -558,12 +693,16 @@ export const collectionDirectoryItems = pgTable(
558
693
  (table) => [
559
694
  check(
560
695
  "chk_collection_directory_item_type",
561
- sql`${table.type} IN ('collection', 'divider', 'link')`,
696
+ sql`${table.type} IN (${sqlTextEnum(COLLECTION_DIRECTORY_ENTRY_TYPES)})`,
562
697
  ),
563
698
  index("idx_collection_directory_item_site_collection_id").on(
564
699
  table.siteId,
565
700
  table.collectionId,
566
701
  ),
702
+ index("idx_collection_directory_item_site_smart_collection_id").on(
703
+ table.siteId,
704
+ table.smartCollectionId,
705
+ ),
567
706
  uniqueIndex("uq_collection_directory_item_site_position").on(
568
707
  table.siteId,
569
708
  table.position,
@@ -573,27 +712,41 @@ export const collectionDirectoryItems = pgTable(
573
712
  .where(
574
713
  sql`${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL`,
575
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
+ ),
576
720
  check(
577
721
  "chk_collection_directory_item_shape",
578
722
  sql`(
579
723
  ${table.type} = 'collection'
580
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
581
732
  AND ${table.label} IS NULL
582
733
  AND ${table.url} IS NULL
583
734
  ) OR (
584
735
  ${table.type} = 'divider'
585
736
  AND ${table.collectionId} IS NULL
737
+ AND ${table.smartCollectionId} IS NULL
586
738
  AND ${table.url} IS NULL
587
739
  ) OR (
588
740
  ${table.type} = 'link'
589
741
  AND ${table.collectionId} IS NULL
742
+ AND ${table.smartCollectionId} IS NULL
590
743
  AND ${table.label} IS NOT NULL
591
744
  AND ${table.url} IS NOT NULL
592
745
  )`,
593
746
  ),
594
747
  check(
595
748
  "chk_collection_directory_item_label",
596
- sql`${table.type} <> 'collection' OR ${table.label} IS NULL`,
749
+ sql`${table.type} NOT IN ('collection', 'smart_collection') OR ${table.label} IS NULL`,
597
750
  ),
598
751
  check(
599
752
  "chk_collection_directory_item_description",
@@ -659,6 +812,10 @@ export const navItems = pgTable(
659
812
  collectionId: text("collection_id").references(() => collections.id, {
660
813
  onDelete: "cascade",
661
814
  }),
815
+ smartCollectionId: text("smart_collection_id").references(
816
+ () => smartCollections.id,
817
+ { onDelete: "cascade" },
818
+ ),
662
819
  postId: text("post_id").references(() => posts.id, {
663
820
  onDelete: "cascade",
664
821
  }),
@@ -688,21 +845,31 @@ export const navItems = pgTable(
688
845
  ${table.type} = 'link'
689
846
  AND ${table.systemKey} IS NULL
690
847
  AND ${table.collectionId} IS NULL
848
+ AND ${table.smartCollectionId} IS NULL
691
849
  AND ${table.postId} IS NULL
692
850
  ) OR (
693
851
  ${table.type} = 'system'
694
852
  AND ${table.systemKey} IS NOT NULL
695
853
  AND ${table.collectionId} IS NULL
854
+ AND ${table.smartCollectionId} IS NULL
696
855
  AND ${table.postId} IS NULL
697
856
  ) OR (
698
857
  ${table.type} = 'collection'
699
858
  AND ${table.systemKey} IS NULL
700
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
701
867
  AND ${table.postId} IS NULL
702
868
  ) OR (
703
869
  ${table.type} = 'page'
704
870
  AND ${table.systemKey} IS NULL
705
871
  AND ${table.collectionId} IS NULL
872
+ AND ${table.smartCollectionId} IS NULL
706
873
  AND ${table.postId} IS NOT NULL
707
874
  )`,
708
875
  ),
@@ -713,6 +880,9 @@ export const navItems = pgTable(
713
880
  uniqueIndex("uq_nav_item_site_collection_id")
714
881
  .on(table.siteId, table.collectionId)
715
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`),
716
886
  uniqueIndex("uq_nav_item_site_post_id")
717
887
  .on(table.siteId, table.postId)
718
888
  .where(sql`${table.postId} IS NOT NULL`),
@@ -860,8 +1030,6 @@ export const verification = pgTable("verification", {
860
1030
  // GitHub App Installations (junction table: installation ↔ site, many-to-many)
861
1031
  // ---------------------------------------------------------------------------
862
1032
 
863
- const GITHUB_APP_ACCOUNT_TYPES = ["User", "Organization"] as const;
864
-
865
1033
  export const githubAppInstallation = pgTable(
866
1034
  "github_app_installation",
867
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
+ }