@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
package/src/db/schema.ts CHANGED
@@ -15,26 +15,37 @@ import {
15
15
  check,
16
16
  } from "drizzle-orm/sqlite-core";
17
17
  import { sql } from "drizzle-orm";
18
- import { SYSTEM_NAV_KEY_VALUES } from "../types/constants.js";
19
-
20
- const SITE_STATUSES = ["active", "suspended"] as const;
21
- const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
22
- const SITE_MEMBER_ROLES = ["owner", "admin", "editor"] as const;
23
- const FORMATS = ["note", "link", "quote"] as const;
24
- const STATUSES = ["draft", "published"] as const;
25
- const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
26
- const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
27
- const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
28
- const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
29
- const UPLOAD_SESSION_STATES = [
30
- "pending",
31
- "uploaded",
32
- "completed",
33
- "aborted",
34
- "failed",
35
- ] as const;
36
- const CONTENT_DISPOSITIONS = ["inline", "attachment"] as const;
18
+ import {
19
+ ARCHIVE_LAYOUTS,
20
+ COLLECTION_DIRECTORY_ENTRY_TYPES,
21
+ COLLECTION_SORT_ORDERS,
22
+ CONTENT_DISPOSITIONS,
23
+ EARLIEST_FILTERABLE_YEAR,
24
+ FORMATS,
25
+ GITHUB_APP_ACCOUNT_TYPES,
26
+ LATEST_FILTERABLE_YEAR,
27
+ NAV_ITEM_PLACEMENTS,
28
+ NAV_ITEM_TYPES,
29
+ PATH_KINDS,
30
+ PUBLIC_ARCHIVE_VISIBILITIES,
31
+ SITE_DOMAIN_KINDS,
32
+ SITE_MEMBER_ROLES,
33
+ SITE_STATUSES,
34
+ SMART_COLLECTION_SORT_ORDERS,
35
+ STATUSES,
36
+ SYSTEM_NAV_KEY_VALUES,
37
+ UPLOAD_SESSION_STATES,
38
+ VISIBILITIES,
39
+ } from "../types/constants.js";
37
40
 
41
+ /**
42
+ * Render a value list as the `IN (...)` body of a CHECK constraint.
43
+ *
44
+ * Every list it is handed comes from `types/constants.ts`. Declaring one
45
+ * locally instead is how a CHECK once shipped missing an enum value: the
46
+ * shared list grew, the schema's private copy did not, and the generated
47
+ * constraint rejected rows the application treats as valid.
48
+ */
38
49
  function sqlTextEnum(values: readonly string[]) {
39
50
  return sql.raw(values.map((value) => `'${value}'`).join(", "));
40
51
  }
@@ -164,8 +175,46 @@ export const posts = sqliteTable(
164
175
  previewProvider: text("preview_provider"),
165
176
  replyToId: text("reply_to_id"),
166
177
  threadId: text("thread_id").notNull(),
178
+ /**
179
+ * BCP 47 content language in canonical form (`en`, `zh-Hans`).
180
+ *
181
+ * Uniform across a Thread: replies inherit the Root's value on creation and
182
+ * a language change rewrites every row in the Thread. Language filters can
183
+ * therefore be plain column predicates even on member-grained queries.
184
+ *
185
+ * NULL only exists before multilingual content is first enabled; enabling
186
+ * stamps every NULL row with the primary language.
187
+ */
188
+ language: text("language"),
189
+ /**
190
+ * Shared translation-group key (TypeID, `tgr_` prefix). Rows sharing a value
191
+ * are translations of one another — one row per language, no direction.
192
+ *
193
+ * Only ever set on Thread Roots (`thread_id = id`); enforced in the post
194
+ * service rather than by a table CHECK, because adding a CHECK to `post`
195
+ * would make drizzle-kit rebuild the table and silently drop the FTS
196
+ * triggers (`post_ai` / `post_ad` / `post_au`) that keep search current.
197
+ */
198
+ translationGroupId: text("translation_group_id"),
199
+ /**
200
+ * The author added this reply without announcing it. Persisted so both
201
+ * activity timestamps below stay recomputable from the rows alone.
202
+ * Always false on Thread roots.
203
+ */
204
+ quietReply: integer("quiet_reply", { mode: "boolean" })
205
+ .notNull()
206
+ .default(false),
167
207
  publishedAt: integer("published_at"),
208
+ /**
209
+ * Root only: newest published_at in the Thread, excluding quiet replies —
210
+ * "last announced". Drives Latest and the feeds.
211
+ */
168
212
  lastActivityAt: integer("last_activity_at"),
213
+ /**
214
+ * Root only: newest published_at in the Thread, quiet replies included —
215
+ * "last changed". Drives the archive's updated sort.
216
+ */
217
+ threadUpdatedAt: integer("thread_updated_at"),
169
218
  createdAt: integer("created_at").notNull(),
170
219
  updatedAt: integer("updated_at").notNull(),
171
220
  },
@@ -213,6 +262,9 @@ export const posts = sqliteTable(
213
262
  index("idx_post_site_root_published_activity")
214
263
  .on(table.siteId, table.lastActivityAt, table.id)
215
264
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
265
+ index("idx_post_site_root_thread_updated")
266
+ .on(table.siteId, table.threadUpdatedAt, table.id)
267
+ .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'published'`),
216
268
  index("idx_post_site_root_draft_updated")
217
269
  .on(table.siteId, table.updatedAt, table.id)
218
270
  .where(sql`${table.replyToId} IS NULL AND ${table.status} = 'draft'`),
@@ -226,6 +278,12 @@ export const posts = sqliteTable(
226
278
  .where(
227
279
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
228
280
  ),
281
+ // One Post per language inside a translation group. The `site_id` prefix
282
+ // matches the indexing convention here and lets the same index serve
283
+ // "list the members of this group" without a second index.
284
+ uniqueIndex("uq_post_site_translation_group_language")
285
+ .on(table.siteId, table.translationGroupId, table.language)
286
+ .where(sql`${table.translationGroupId} IS NOT NULL`),
229
287
  ],
230
288
  );
231
289
 
@@ -421,6 +479,108 @@ export const collections = sqliteTable(
421
479
  ],
422
480
  );
423
481
 
482
+ // =============================================================================
483
+ // Smart Collections
484
+ // =============================================================================
485
+
486
+ /**
487
+ * A collection whose members are decided by conditions rather than by tagging.
488
+ *
489
+ * Kept apart from `collection` rather than folded into it. The two share an
490
+ * identity shape — a path registry row, a title, a description — but half the
491
+ * columns of a merged table would be meaningless for half its rows, every query
492
+ * would branch, and a smart collection can filter *by* a collection, which in
493
+ * one table becomes a self-reference and a cycle to defend against. The
494
+ * resemblance belongs in a read model, not in the storage.
495
+ *
496
+ * The condition columns are declared once in `lib/filter-dimensions.ts`; this
497
+ * table is where they land. `NULL` always means "this dimension is not part of
498
+ * the conditions".
499
+ *
500
+ * Never private: `visibility` is constrained to the public archive values, so
501
+ * there is no such thing as a smart collection page that answers 404 to a
502
+ * reader. Per-post visibility still applies exactly as it does on a manual
503
+ * collection page.
504
+ *
505
+ * `collection_id` carries no foreign key, which makes it the one id column here
506
+ * that does not. None of the three actions is the right one: CASCADE would
507
+ * delete a smart collection — a page, with an address — because a collection it
508
+ * filters by went away, SET NULL would silently widen it from "Rust notes" to
509
+ * every post, and RESTRICT can only say `FOREIGN KEY constraint failed`, which
510
+ * no one should ever be shown. The refusal that belongs here is
511
+ * `smartCollections.assertCollectionUnused()`, which names the smart
512
+ * collections standing in the way; a constraint underneath it would add no
513
+ * answer, only turn every bulk delete into an ordering rule nothing enforces.
514
+ *
515
+ * What a dangling id costs is bounded: the condition compiles to an `IN` over a
516
+ * collection no `thread_collection` row carries, so the smart collection matches
517
+ * nothing. Visible and repairable by editing the conditions — not a page that
518
+ * quietly grew to the whole archive.
519
+ */
520
+ export const smartCollections = sqliteTable(
521
+ "smart_collection",
522
+ {
523
+ id: text("id").primaryKey(),
524
+ siteId: text("site_id")
525
+ .notNull()
526
+ .references(() => sites.id, { onDelete: "cascade" }),
527
+ title: text("title").notNull(),
528
+ description: text("description"),
529
+
530
+ // --- Conditions (see lib/filter-dimensions.ts) --------------------------
531
+ format: text("format", { enum: FORMATS }),
532
+ year: integer("year"),
533
+ // No foreign key, alone among this schema's id columns — see the note under
534
+ // the table on why this one is held in the service instead.
535
+ collectionId: text("collection_id"),
536
+ /** `any` | `none` | comma-joined MEDIA_KINDS. One folded vocabulary. */
537
+ media: text("media"),
538
+ hasTitle: integer("has_title", { mode: "boolean" }),
539
+ hasReplies: integer("has_replies", { mode: "boolean" }),
540
+ visibility: text("visibility", { enum: PUBLIC_ARCHIVE_VISIBILITIES }),
541
+
542
+ // --- Presentation (not conditions) --------------------------------------
543
+ sort: text("sort", { enum: SMART_COLLECTION_SORT_ORDERS })
544
+ .notNull()
545
+ .default("newest"),
546
+ /** NULL follows the site's `ARCHIVE_DEFAULT_LAYOUT`. */
547
+ layout: text("layout", { enum: ARCHIVE_LAYOUTS }),
548
+
549
+ createdAt: integer("created_at").notNull(),
550
+ updatedAt: integer("updated_at").notNull(),
551
+ },
552
+ (table) => [
553
+ check(
554
+ "chk_smart_collection_format",
555
+ sql`${table.format} IS NULL OR ${table.format} IN (${sqlTextEnum(FORMATS)})`,
556
+ ),
557
+ check(
558
+ "chk_smart_collection_visibility",
559
+ sql`${table.visibility} IS NULL OR ${table.visibility} IN (${sqlTextEnum(PUBLIC_ARCHIVE_VISIBILITIES)})`,
560
+ ),
561
+ check(
562
+ "chk_smart_collection_sort",
563
+ sql`${table.sort} IN (${sqlTextEnum(SMART_COLLECTION_SORT_ORDERS)})`,
564
+ ),
565
+ check(
566
+ "chk_smart_collection_layout",
567
+ sql`${table.layout} IS NULL OR ${table.layout} IN (${sqlTextEnum(ARCHIVE_LAYOUTS)})`,
568
+ ),
569
+ check(
570
+ "chk_smart_collection_year",
571
+ sql`${table.year} IS NULL OR ${table.year} BETWEEN ${sql.raw(String(EARLIEST_FILTERABLE_YEAR))} AND ${sql.raw(String(LATEST_FILTERABLE_YEAR))}`,
572
+ ),
573
+ index("idx_smart_collection_site_created_at").on(
574
+ table.siteId,
575
+ table.createdAt,
576
+ ),
577
+ index("idx_smart_collection_site_collection_id").on(
578
+ table.siteId,
579
+ table.collectionId,
580
+ ),
581
+ ],
582
+ );
583
+
424
584
  // =============================================================================
425
585
  // Path Registry (slug + alias + redirect)
426
586
  // =============================================================================
@@ -442,6 +602,10 @@ export const pathRegistry = sqliteTable(
442
602
  collectionId: text("collection_id").references(() => collections.id, {
443
603
  onDelete: "cascade",
444
604
  }),
605
+ smartCollectionId: text("smart_collection_id").references(
606
+ () => smartCollections.id,
607
+ { onDelete: "cascade" },
608
+ ),
445
609
  redirectToPath: text("redirect_to_path"),
446
610
  redirectType: integer("redirect_type"),
447
611
  archiveQuery: text("archive_query"),
@@ -451,7 +615,7 @@ export const pathRegistry = sqliteTable(
451
615
  (table) => [
452
616
  check(
453
617
  "chk_path_registry_kind",
454
- sql`${table.kind} IN ('slug', 'alias', 'redirect', 'archive')`,
618
+ sql`${table.kind} IN (${sqlTextEnum(PATH_KINDS)})`,
455
619
  ),
456
620
  uniqueIndex("uq_path_registry_site_path").on(table.siteId, table.path),
457
621
  uniqueIndex("uq_path_registry_site_post_slug")
@@ -460,18 +624,28 @@ export const pathRegistry = sqliteTable(
460
624
  uniqueIndex("uq_path_registry_site_collection_slug")
461
625
  .on(table.siteId, table.collectionId)
462
626
  .where(sql`${table.kind} = 'slug' AND ${table.collectionId} IS NOT NULL`),
627
+ uniqueIndex("uq_path_registry_site_smart_collection_slug")
628
+ .on(table.siteId, table.smartCollectionId)
629
+ .where(
630
+ sql`${table.kind} = 'slug' AND ${table.smartCollectionId} IS NOT NULL`,
631
+ ),
463
632
  index("idx_path_registry_site_post_id").on(table.siteId, table.postId),
464
633
  index("idx_path_registry_site_collection_id").on(
465
634
  table.siteId,
466
635
  table.collectionId,
467
636
  ),
637
+ index("idx_path_registry_site_smart_collection_id").on(
638
+ table.siteId,
639
+ table.smartCollectionId,
640
+ ),
468
641
  check(
469
642
  "chk_path_registry_shape",
470
643
  sql`(
471
644
  ${table.kind} IN ('slug', 'alias')
472
645
  AND (
473
- (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL)
474
- OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL)
646
+ (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NULL)
647
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL AND ${table.smartCollectionId} IS NULL)
648
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NOT NULL)
475
649
  )
476
650
  AND ${table.redirectToPath} IS NULL
477
651
  AND ${table.redirectType} IS NULL
@@ -480,6 +654,7 @@ export const pathRegistry = sqliteTable(
480
654
  ${table.kind} = 'redirect'
481
655
  AND ${table.postId} IS NULL
482
656
  AND ${table.collectionId} IS NULL
657
+ AND ${table.smartCollectionId} IS NULL
483
658
  AND ${table.redirectToPath} IS NOT NULL
484
659
  AND ${table.redirectType} IN (301, 302)
485
660
  AND ${table.archiveQuery} IS NULL
@@ -487,6 +662,7 @@ export const pathRegistry = sqliteTable(
487
662
  ${table.kind} = 'archive'
488
663
  AND ${table.postId} IS NULL
489
664
  AND ${table.collectionId} IS NULL
665
+ AND ${table.smartCollectionId} IS NULL
490
666
  AND ${table.redirectToPath} IS NULL
491
667
  AND ${table.redirectType} IS NULL
492
668
  AND ${table.archiveQuery} IS NOT NULL
@@ -506,10 +682,16 @@ export const collectionDirectoryItems = sqliteTable(
506
682
  siteId: text("site_id")
507
683
  .notNull()
508
684
  .references(() => sites.id, { onDelete: "cascade" }),
509
- type: text("type", { enum: ["collection", "divider", "link"] }).notNull(),
685
+ type: text("type", {
686
+ enum: COLLECTION_DIRECTORY_ENTRY_TYPES,
687
+ }).notNull(),
510
688
  collectionId: text("collection_id").references(() => collections.id, {
511
689
  onDelete: "cascade",
512
690
  }),
691
+ smartCollectionId: text("smart_collection_id").references(
692
+ () => smartCollections.id,
693
+ { onDelete: "cascade" },
694
+ ),
513
695
  label: text("label"),
514
696
  url: text("url"),
515
697
  description: text("description"),
@@ -520,12 +702,16 @@ export const collectionDirectoryItems = sqliteTable(
520
702
  (table) => [
521
703
  check(
522
704
  "chk_collection_directory_item_type",
523
- sql`${table.type} IN ('collection', 'divider', 'link')`,
705
+ sql`${table.type} IN (${sqlTextEnum(COLLECTION_DIRECTORY_ENTRY_TYPES)})`,
524
706
  ),
525
707
  index("idx_collection_directory_item_site_collection_id").on(
526
708
  table.siteId,
527
709
  table.collectionId,
528
710
  ),
711
+ index("idx_collection_directory_item_site_smart_collection_id").on(
712
+ table.siteId,
713
+ table.smartCollectionId,
714
+ ),
529
715
  uniqueIndex("uq_collection_directory_item_site_position").on(
530
716
  table.siteId,
531
717
  table.position,
@@ -535,27 +721,41 @@ export const collectionDirectoryItems = sqliteTable(
535
721
  .where(
536
722
  sql`${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL`,
537
723
  ),
724
+ uniqueIndex("uq_collection_directory_item_site_smart_collection_once")
725
+ .on(table.siteId, table.smartCollectionId)
726
+ .where(
727
+ sql`${table.type} = 'smart_collection' AND ${table.smartCollectionId} IS NOT NULL`,
728
+ ),
538
729
  check(
539
730
  "chk_collection_directory_item_shape",
540
731
  sql`(
541
732
  ${table.type} = 'collection'
542
733
  AND ${table.collectionId} IS NOT NULL
734
+ AND ${table.smartCollectionId} IS NULL
735
+ AND ${table.label} IS NULL
736
+ AND ${table.url} IS NULL
737
+ ) OR (
738
+ ${table.type} = 'smart_collection'
739
+ AND ${table.collectionId} IS NULL
740
+ AND ${table.smartCollectionId} IS NOT NULL
543
741
  AND ${table.label} IS NULL
544
742
  AND ${table.url} IS NULL
545
743
  ) OR (
546
744
  ${table.type} = 'divider'
547
745
  AND ${table.collectionId} IS NULL
746
+ AND ${table.smartCollectionId} IS NULL
548
747
  AND ${table.url} IS NULL
549
748
  ) OR (
550
749
  ${table.type} = 'link'
551
750
  AND ${table.collectionId} IS NULL
751
+ AND ${table.smartCollectionId} IS NULL
552
752
  AND ${table.label} IS NOT NULL
553
753
  AND ${table.url} IS NOT NULL
554
754
  )`,
555
755
  ),
556
756
  check(
557
757
  "chk_collection_directory_item_label",
558
- sql`${table.type} <> 'collection' OR ${table.label} IS NULL`,
758
+ sql`${table.type} NOT IN ('collection', 'smart_collection') OR ${table.label} IS NULL`,
559
759
  ),
560
760
  check(
561
761
  "chk_collection_directory_item_description",
@@ -621,6 +821,10 @@ export const navItems = sqliteTable(
621
821
  collectionId: text("collection_id").references(() => collections.id, {
622
822
  onDelete: "cascade",
623
823
  }),
824
+ smartCollectionId: text("smart_collection_id").references(
825
+ () => smartCollections.id,
826
+ { onDelete: "cascade" },
827
+ ),
624
828
  postId: text("post_id").references(() => posts.id, {
625
829
  onDelete: "cascade",
626
830
  }),
@@ -650,21 +854,31 @@ export const navItems = sqliteTable(
650
854
  ${table.type} = 'link'
651
855
  AND ${table.systemKey} IS NULL
652
856
  AND ${table.collectionId} IS NULL
857
+ AND ${table.smartCollectionId} IS NULL
653
858
  AND ${table.postId} IS NULL
654
859
  ) OR (
655
860
  ${table.type} = 'system'
656
861
  AND ${table.systemKey} IS NOT NULL
657
862
  AND ${table.collectionId} IS NULL
863
+ AND ${table.smartCollectionId} IS NULL
658
864
  AND ${table.postId} IS NULL
659
865
  ) OR (
660
866
  ${table.type} = 'collection'
661
867
  AND ${table.systemKey} IS NULL
662
868
  AND ${table.collectionId} IS NOT NULL
869
+ AND ${table.smartCollectionId} IS NULL
870
+ AND ${table.postId} IS NULL
871
+ ) OR (
872
+ ${table.type} = 'smart_collection'
873
+ AND ${table.systemKey} IS NULL
874
+ AND ${table.collectionId} IS NULL
875
+ AND ${table.smartCollectionId} IS NOT NULL
663
876
  AND ${table.postId} IS NULL
664
877
  ) OR (
665
878
  ${table.type} = 'page'
666
879
  AND ${table.systemKey} IS NULL
667
880
  AND ${table.collectionId} IS NULL
881
+ AND ${table.smartCollectionId} IS NULL
668
882
  AND ${table.postId} IS NOT NULL
669
883
  )`,
670
884
  ),
@@ -675,6 +889,9 @@ export const navItems = sqliteTable(
675
889
  uniqueIndex("uq_nav_item_site_collection_id")
676
890
  .on(table.siteId, table.collectionId)
677
891
  .where(sql`${table.collectionId} IS NOT NULL`),
892
+ uniqueIndex("uq_nav_item_site_smart_collection_id")
893
+ .on(table.siteId, table.smartCollectionId)
894
+ .where(sql`${table.smartCollectionId} IS NOT NULL`),
678
895
  uniqueIndex("uq_nav_item_site_post_id")
679
896
  .on(table.siteId, table.postId)
680
897
  .where(sql`${table.postId} IS NOT NULL`),
@@ -792,8 +1009,6 @@ export const verification = sqliteTable("verification", {
792
1009
  // GitHub App Installations (junction table: installation ↔ site, many-to-many)
793
1010
  // ---------------------------------------------------------------------------
794
1011
 
795
- const GITHUB_APP_ACCOUNT_TYPES = ["User", "Organization"] as const;
796
-
797
1012
  export const githubAppInstallation = sqliteTable(
798
1013
  "github_app_installation",
799
1014
  {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Thread activity — one definition, shared by every surface that orders by it.
3
+ *
4
+ * A Thread's activity is when it last **gained a post**. Editing an existing
5
+ * post is not activity: otherwise fixing a typo would drag a years-old thread
6
+ * back to the top of Latest and of every collection that holds it.
7
+ *
8
+ * This lived as three separate copies (the collection thread sort, the
9
+ * collection directory, and the Hugo export). One of them was changed in #144
10
+ * to treat a newer `updated_at` as activity and the others were not, so the
11
+ * same thread ranked differently depending on which page you were looking at,
12
+ * and the drift went unnoticed for months. Adding a fourth copy is the failure
13
+ * mode this module exists to prevent — call it instead.
14
+ *
15
+ * Two flavors of "last activity" exist, and they differ only in how they treat
16
+ * quiet replies:
17
+ *
18
+ * - `last_activity_at` — quiet replies excluded. "Last announced." Latest, the
19
+ * feeds, and collection ordering read this.
20
+ * - `thread_updated_at` — quiet replies included. "Last changed." The
21
+ * archive's updated sort reads this.
22
+ *
23
+ * Both are maintained on the Thread root by the post service; everything here
24
+ * only reads them, with fallbacks for rows that predate the columns.
25
+ */
26
+
27
+ import { sql, type SQL, type SQLWrapper } from "drizzle-orm";
28
+
29
+ /**
30
+ * Activity timestamp for a Thread root row, in SQL.
31
+ *
32
+ * Pass column references for a query whose rows are already Thread roots, or
33
+ * raw fragments (`sql.raw("root.last_activity_at")`) when reaching a root
34
+ * through a correlated subquery.
35
+ *
36
+ * **Correlated-subquery warning:** Drizzle only qualifies interpolated columns
37
+ * with their table name when the query has a join. In a single-table
38
+ * `select()`, `${posts.id}` renders as a bare `"id"`, which inside a subquery
39
+ * binds to the subquery's own alias instead of correlating to the outer row —
40
+ * silently, with no error. Write the outer table out (`"post"."id"`) in that
41
+ * case rather than interpolating.
42
+ *
43
+ * @param root - The root's three timestamp columns, most authoritative first
44
+ * @returns COALESCE expression usable in ORDER BY, GROUP BY, or a projection
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * // Rows are already Thread roots (joined on thread_collection.thread_id):
49
+ * const activityAt = buildRootActivityExpr({
50
+ * lastActivityAt: posts.lastActivityAt,
51
+ * publishedAt: posts.publishedAt,
52
+ * updatedAt: posts.updatedAt,
53
+ * });
54
+ * ```
55
+ */
56
+ export function buildRootActivityExpr(root: {
57
+ lastActivityAt: SQLWrapper;
58
+ publishedAt: SQLWrapper;
59
+ updatedAt: SQLWrapper;
60
+ }): SQL<number> {
61
+ return sql<number>`COALESCE(
62
+ ${root.lastActivityAt},
63
+ ${root.publishedAt},
64
+ ${root.updatedAt}
65
+ )`;
66
+ }
67
+
68
+ /** The Thread root's timestamp columns, named for a correlated subquery alias. */
69
+ export function rootActivityColumns(alias: string): {
70
+ lastActivityAt: SQLWrapper;
71
+ publishedAt: SQLWrapper;
72
+ updatedAt: SQLWrapper;
73
+ } {
74
+ return {
75
+ lastActivityAt: sql.raw(`${alias}.last_activity_at`),
76
+ publishedAt: sql.raw(`${alias}.published_at`),
77
+ updatedAt: sql.raw(`${alias}.updated_at`),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Activity timestamp for an already-loaded Thread root, in TypeScript.
83
+ *
84
+ * The same COALESCE order as {@link buildRootActivityExpr}, for callers that
85
+ * hold hydrated posts rather than a query builder.
86
+ *
87
+ * @param root - A Thread root post
88
+ * @returns Unix timestamp (seconds) of the root's last activity
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const activityAt = getRootActivityAt(threadRoot);
93
+ * ```
94
+ */
95
+ export function getRootActivityAt(root: {
96
+ lastActivityAt?: number | null;
97
+ publishedAt?: number | null;
98
+ updatedAt: number;
99
+ }): number {
100
+ return root.lastActivityAt ?? root.publishedAt ?? root.updatedAt;
101
+ }
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { detectLocaleFromHeader, detectCjkFontFromHeader } from "../detect.js";
2
+ import {
3
+ detectLocaleFromHeader,
4
+ getCjkFontFromLanguageTag,
5
+ } from "../detect.js";
3
6
 
4
7
  describe("detectLocaleFromHeader", () => {
5
8
  it("returns en for en", () => {
@@ -45,94 +48,36 @@ describe("detectLocaleFromHeader", () => {
45
48
  });
46
49
  });
47
50
 
48
- describe("detectCjkFontFromHeader", () => {
49
- // Simplified Chinese
50
- it("maps zh-CN to zh-Hans", () => {
51
- expect(detectCjkFontFromHeader("zh-CN")).toBe("zh-Hans");
51
+ describe("getCjkFontFromLanguageTag", () => {
52
+ it("maps Simplified Chinese tags to the zh-Hans profile", () => {
53
+ expect(getCjkFontFromLanguageTag("zh-CN")).toBe("zh-Hans");
54
+ expect(getCjkFontFromLanguageTag("zh-SG")).toBe("zh-Hans");
55
+ expect(getCjkFontFromLanguageTag("zh-Hans")).toBe("zh-Hans");
56
+ // Bare `zh` is ambiguous in principle; Simplified is the majority reading.
57
+ expect(getCjkFontFromLanguageTag("zh")).toBe("zh-Hans");
52
58
  });
53
59
 
54
- it("maps zh-SG to zh-Hans", () => {
55
- expect(detectCjkFontFromHeader("zh-SG")).toBe("zh-Hans");
60
+ it("maps Traditional Chinese tags to the zh-Hant profile", () => {
61
+ expect(getCjkFontFromLanguageTag("zh-TW")).toBe("zh-Hant");
62
+ expect(getCjkFontFromLanguageTag("zh-HK")).toBe("zh-Hant");
63
+ expect(getCjkFontFromLanguageTag("zh-MO")).toBe("zh-Hant");
64
+ expect(getCjkFontFromLanguageTag("zh-Hant")).toBe("zh-Hant");
56
65
  });
57
66
 
58
- it("maps zh-Hans to zh-Hans", () => {
59
- expect(detectCjkFontFromHeader("zh-Hans")).toBe("zh-Hans");
60
- });
61
-
62
- it("maps bare zh to zh-Hans", () => {
63
- expect(detectCjkFontFromHeader("zh")).toBe("zh-Hans");
64
- });
65
-
66
- // Traditional Chinese
67
- it("maps zh-TW to zh-Hant", () => {
68
- expect(detectCjkFontFromHeader("zh-TW")).toBe("zh-Hant");
69
- });
70
-
71
- it("maps zh-HK to zh-Hant", () => {
72
- expect(detectCjkFontFromHeader("zh-HK")).toBe("zh-Hant");
73
- });
74
-
75
- it("maps zh-MO to zh-Hant", () => {
76
- expect(detectCjkFontFromHeader("zh-MO")).toBe("zh-Hant");
77
- });
78
-
79
- it("maps zh-Hant to zh-Hant", () => {
80
- expect(detectCjkFontFromHeader("zh-Hant")).toBe("zh-Hant");
81
- });
82
-
83
- // Japanese
84
- it("maps ja to ja", () => {
85
- expect(detectCjkFontFromHeader("ja")).toBe("ja");
86
- });
87
-
88
- it("maps ja-JP to ja", () => {
89
- expect(detectCjkFontFromHeader("ja-JP")).toBe("ja");
90
- });
91
-
92
- // Korean
93
- it("maps ko to ko", () => {
94
- expect(detectCjkFontFromHeader("ko")).toBe("ko");
95
- });
96
-
97
- it("maps ko-KR to ko", () => {
98
- expect(detectCjkFontFromHeader("ko-KR")).toBe("ko");
99
- });
100
-
101
- // q-value priority
102
- it("picks highest q-value CJK language", () => {
103
- expect(detectCjkFontFromHeader("fr;q=0.5,zh-CN;q=0.9,en;q=0.8")).toBe(
104
- "zh-Hans",
105
- );
106
- });
107
-
108
- it("picks first CJK when q-values are equal", () => {
109
- expect(detectCjkFontFromHeader("zh-TW,en")).toBe("zh-Hant");
110
- });
111
-
112
- // Non-CJK fallback
113
- it("returns off for non-CJK language", () => {
114
- expect(detectCjkFontFromHeader("en-US")).toBe("off");
115
- });
116
-
117
- it("returns off for undefined", () => {
118
- expect(detectCjkFontFromHeader(undefined)).toBe("off");
119
- });
120
-
121
- it("returns off for empty string", () => {
122
- expect(detectCjkFontFromHeader("")).toBe("off");
123
- });
124
-
125
- it("skips entries with q=0", () => {
126
- expect(detectCjkFontFromHeader("zh-CN;q=0,en")).toBe("off");
67
+ it("maps Japanese and Korean tags", () => {
68
+ expect(getCjkFontFromLanguageTag("ja")).toBe("ja");
69
+ expect(getCjkFontFromLanguageTag("ja-JP")).toBe("ja");
70
+ expect(getCjkFontFromLanguageTag("ko")).toBe("ko");
71
+ expect(getCjkFontFromLanguageTag("ko-KR")).toBe("ko");
127
72
  });
128
73
 
129
74
  it("is case-insensitive", () => {
130
- expect(detectCjkFontFromHeader("ZH-CN")).toBe("zh-Hans");
75
+ expect(getCjkFontFromLanguageTag("ZH-CN")).toBe("zh-Hans");
131
76
  });
132
77
 
133
- it("handles realistic browser header", () => {
134
- expect(detectCjkFontFromHeader("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7")).toBe(
135
- "zh-Hant",
136
- );
78
+ it("returns nothing for languages with no CJK profile", () => {
79
+ expect(getCjkFontFromLanguageTag("en-US")).toBeUndefined();
80
+ expect(getCjkFontFromLanguageTag("")).toBeUndefined();
81
+ expect(getCjkFontFromLanguageTag(" ")).toBeUndefined();
137
82
  });
138
83
  });