@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
@@ -15,6 +15,7 @@ import {
15
15
  isNull,
16
16
  desc,
17
17
  inArray,
18
+ notInArray,
18
19
  sql,
19
20
  isNotNull,
20
21
  asc,
@@ -36,6 +37,7 @@ import {
36
37
  buildRootActivityExpr,
37
38
  rootActivityColumns,
38
39
  } from "../db/thread-activity.js";
40
+ import { buildReaderVisibilityConditions } from "../db/post-visibility.js";
39
41
  import { createEntityId } from "../lib/ids.js";
40
42
  import { now } from "../lib/time.js";
41
43
  import { trimTiptapBody } from "../lib/tiptap-render.js";
@@ -46,12 +48,17 @@ import {
46
48
  tryPreparePostBodyHtml,
47
49
  } from "../lib/post-body-html.js";
48
50
  import { extractSummary, extractBodyText } from "../lib/summary.js";
51
+ import {
52
+ THREAD_LEADING_REPLIES,
53
+ THREAD_TRAILING_REPLIES,
54
+ } from "../lib/thread-fold.js";
49
55
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
50
56
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
51
57
  import { generatePostSlug } from "../lib/slug.js";
52
58
  import { getSlugValidationIssue } from "../lib/slug-format.js";
53
59
  import { isReservedPath } from "../lib/constants.js";
54
- import { normalizePath, slugify } from "../lib/url.js";
60
+ import { normalizePath } from "../lib/url.js";
61
+ import { slugify } from "../lib/slugify.js";
55
62
  import type { StorageDriver } from "../lib/storage.js";
56
63
  import type { MediaService } from "./media.js";
57
64
  import {
@@ -78,6 +85,14 @@ import {
78
85
  ValidationError,
79
86
  NotFoundError,
80
87
  } from "../lib/errors.js";
88
+ import {
89
+ isValidContentLanguage,
90
+ normalizeContentLanguage,
91
+ toLanguagePrefix,
92
+ } from "../i18n/locales.js";
93
+ import { readLanguageSettings } from "./language.js";
94
+ import { getOrBuildEntry } from "../i18n/supported-locales.js";
95
+ import { suggestPostLanguage } from "../lib/lang-detect.js";
81
96
  import { createPathService, type PathService } from "./path.js";
82
97
  import {
83
98
  extractYouTubeVideoId,
@@ -141,6 +156,12 @@ export interface PostFilters {
141
156
  /** Exclude private posts from results */
142
157
  excludePrivate?: boolean;
143
158
  threadId?: string;
159
+ /**
160
+ * Restrict to these Posts. The list becomes one `IN (…)`, so a caller keeps
161
+ * it within a single statement's parameter budget — 50 is what D1 leaves
162
+ * room for beside the other conditions.
163
+ */
164
+ ids?: string[];
144
165
  /** Unix timestamp (inclusive) — only posts published at or after this time */
145
166
  publishedAfter?: number;
146
167
  /** Unix timestamp (exclusive) — only posts published before this time */
@@ -155,6 +176,16 @@ export interface PostFilters {
155
176
  axisBefore?: number;
156
177
  /** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
157
178
  mediaKinds?: MediaKind[];
179
+ /**
180
+ * Restrict to one BCP 47 content language (canonical form, matched exactly).
181
+ *
182
+ * A plain column predicate is correct at every grain because `post.language`
183
+ * is uniform across a Thread — replies inherit the Root's value and a
184
+ * language change rewrites the whole Thread. It rides along as a residual
185
+ * predicate on the existing hot-path partial indexes rather than getting its
186
+ * own index; see the multilingual design notes for the upgrade path.
187
+ */
188
+ lang?: string;
158
189
  /** Filter by media presence */
159
190
  hasMedia?: boolean;
160
191
  /** Filter by title presence */
@@ -189,18 +220,29 @@ export interface PostFilters {
189
220
  offset?: number; // offset for page-based pagination
190
221
  }
191
222
 
223
+ /** What one filter measured over a set of Posts. See `aggregateMany`. */
224
+ export interface PostFilterAggregate {
225
+ count: number;
226
+ /** Newest activity among the matched rows; `null` when nothing matched. */
227
+ recentActivityAt: number | null;
228
+ }
229
+
192
230
  /** Config for automatic summary extraction */
193
231
  export interface SummaryConfig {
194
232
  maxParagraphs: number;
195
233
  maxChars: number;
196
234
  }
197
235
 
198
- interface ThreadRootPageOptions {
236
+ export interface ThreadRootPageOptions {
199
237
  status?: Status;
200
238
  excludePrivate?: boolean;
201
239
  excludeLatestHidden?: boolean;
240
+ /** See `PostFilters.lang`. */
241
+ lang?: string;
202
242
  /** Exclude Posts published at or after this Unix timestamp. */
203
243
  publishedBefore?: number;
244
+ /** Restrict to these Threads, by root ID. Same budget as `PostFilters.ids`. */
245
+ threadIds?: string[];
204
246
  /** Restrict by the Thread root's format without excluding Child Posts. */
205
247
  rootFormat?: Format;
206
248
  /** Restrict to Threads with at least one rated published post. */
@@ -267,8 +309,41 @@ export interface SitemapPostEntry {
267
309
  alias: string | null;
268
310
  updatedAt: number;
269
311
  featuredAt: number | null;
312
+ /** Content language, when the post has one. */
313
+ language: string | null;
314
+ /** Translation group, used to emit sitemap `hreflang` alternates. */
315
+ translationGroupId: string | null;
270
316
  }
271
317
 
318
+ /**
319
+ * What an address the author pasted turned out to be, for the one question the
320
+ * translation picker asks: can this Thread be linked to that one?
321
+ *
322
+ * Every "no" carries which one it is, because the author is looking at a page
323
+ * they know exists and needs to be told what is wrong with it, not that the
324
+ * search found nothing.
325
+ */
326
+ export type TranslationCandidateResolution =
327
+ /** Linkable: the Thread root the address names. */
328
+ | { status: "ok"; post: Post }
329
+ /** No page at that address — a typo, or something since deleted. */
330
+ | { status: "not_found" }
331
+ /** A real page, but a collection or an archive rather than a Post. */
332
+ | { status: "not_a_post" }
333
+ /** The Thread the author is already linking from. */
334
+ | { status: "same_thread" }
335
+ | { status: "unpublished" }
336
+ /** Nothing to translate between until it has a language. */
337
+ | { status: "no_language" }
338
+ /** Written in the same language as this Thread. */
339
+ | { status: "same_language" }
340
+ /** A Thread already linked here speaks for that language. */
341
+ | { status: "language_taken"; language: string }
342
+ /** Both sides carry a group; linking would silently merge them. */
343
+ | { status: "group_conflict" }
344
+ /** Its group already holds this Thread's language. */
345
+ | { status: "group_language_taken"; language: string };
346
+
272
347
  export interface PostService {
273
348
  getById(id: string): Promise<Post | null>;
274
349
  getBodyContent(id: string): Promise<PostBodyContent | null>;
@@ -313,6 +388,38 @@ export interface PostService {
313
388
  count(filters?: PostFilters): Promise<number>;
314
389
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
315
390
  countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
391
+ /**
392
+ * Measure several filters at once, in a single round trip.
393
+ *
394
+ * One table scan with one `SUM(CASE …)` and one `MAX(CASE …)` per filter,
395
+ * rather than one query per filter. On Workers the round trip is the cost
396
+ * that matters, and a page that lists twenty smart collections would
397
+ * otherwise pay twenty of them — `Promise.all` only turns "slow in sequence"
398
+ * into "expensive in parallel".
399
+ *
400
+ * Every predicate is built by the same `buildFilterConditions` a single
401
+ * `count` uses, so the numbers here and the numbers on the pages they link to
402
+ * cannot drift.
403
+ *
404
+ * `recentActivityAt` is the newest activity among the matched rows, on the
405
+ * one definition {@link buildRootActivityExpr} holds for everything that
406
+ * orders or dates a Thread. It reads the matched row's own columns, so it
407
+ * describes a Thread only when `base` excludes replies — which is what every
408
+ * caller does, because a reply is not a member of anything.
409
+ *
410
+ * @param filters - One filter per measurement, in order
411
+ * @param base - Applied to every measurement: the site, the reader's
412
+ * visibility floor, and anything else common to all of them
413
+ * @returns Count and newest activity, positionally matching `filters`;
414
+ * `recentActivityAt` is `null` where nothing matched
415
+ * @example
416
+ * await posts.aggregateMany([{ format: "note" }, { format: "quote" }], base);
417
+ * // [{ count: 12, recentActivityAt: 1706100000 }, { count: 5, … }]
418
+ */
419
+ aggregateMany(
420
+ filters: readonly PostFilters[],
421
+ base: PostFilters,
422
+ ): Promise<PostFilterAggregate[]>;
316
423
  /** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
317
424
  countByYearMonth(
318
425
  filters?: PostFilters,
@@ -390,6 +497,112 @@ export interface PostService {
390
497
  status: Status,
391
498
  visibility: Visibility,
392
499
  ): Promise<void>;
500
+ /**
501
+ * Set the content language of a whole Thread (root and every reply).
502
+ *
503
+ * Thread-wide by design: `post.language` is uniform inside a Thread, which is
504
+ * what lets every language filter stay a plain column predicate. Rejects a
505
+ * language another Post in the same translation group already holds.
506
+ *
507
+ * @param postId - Any Post in the Thread; the Thread is resolved from it
508
+ * @param language - Canonical BCP 47 tag
509
+ * @throws {NotFoundError} When no such Post exists on this site
510
+ * @throws {ConflictError} When the translation group already has that language
511
+ * @example
512
+ * await posts.setThreadLanguage(post.id, "zh-Hans");
513
+ */
514
+ setThreadLanguage(postId: string, language: string): Promise<void>;
515
+ /**
516
+ * Stamp every Post that has no language yet with `language`.
517
+ *
518
+ * Runs once when an author turns multilingual content on, and is idempotent
519
+ * so re-enabling later only touches Posts written while it was off. Scoped to
520
+ * this site — hosted deployments share one database.
521
+ *
522
+ * @param language - Canonical BCP 47 tag of the site's primary language
523
+ * @returns Number of Posts stamped
524
+ * @example
525
+ * await posts.materializeMissingLanguage("zh-Hans"); // => 347
526
+ */
527
+ materializeMissingLanguage(language: string): Promise<number>;
528
+ /** Count Posts on this site that still have no language. */
529
+ countMissingLanguage(): Promise<number>;
530
+ /** Count Posts on this site written in `language`, drafts included. */
531
+ countByLanguage(language: string): Promise<number>;
532
+ /**
533
+ * Every language stamped on this site's Posts, with how many carry it.
534
+ * Drafts included; the pre-multilingual NULL rows are not.
535
+ */
536
+ listLanguagesInUse(): Promise<Array<{ language: string; count: number }>>;
537
+ /**
538
+ * List the Thread roots that are translations of the given Post, excluding
539
+ * the Post itself. Empty when it belongs to no translation group.
540
+ */
541
+ listTranslations(postId: string): Promise<Post[]>;
542
+ /**
543
+ * List translations for many Thread roots in one round trip, keyed by the
544
+ * root ID that was asked about. Roots with no group are omitted.
545
+ */
546
+ getTranslationsMap(postIds: string[]): Promise<Map<string, Post[]>>;
547
+ /**
548
+ * Find published Thread roots this Post could be linked to as a translation.
549
+ *
550
+ * Filtered server-side rather than in the menu, because "can these two be
551
+ * linked" is a rule the service owns: a candidate has to be a Thread root in
552
+ * a language this Post's translation group does not already hold, and the two
553
+ * groups must not both exist — `linkTranslation` refuses to merge them.
554
+ * Offering a post that will be rejected teaches the author nothing.
555
+ *
556
+ * The match is a plain substring scan over title and body text. It is a
557
+ * deliberate, authenticated action over one author's own posts, so the cost
558
+ * is bounded and the shared search index (which cannot express these filters)
559
+ * is not worth involving.
560
+ *
561
+ * @param postId - Any Post in the Thread looking for a translation
562
+ * @param options - Search text and how many candidates to return
563
+ * @returns Matching Thread roots, newest first
564
+ * @example
565
+ * await posts.listTranslationCandidates(id, { query: "recipe" });
566
+ */
567
+ listTranslationCandidates(
568
+ postId: string,
569
+ options: { query: string; limit?: number },
570
+ ): Promise<Post[]>;
571
+ /**
572
+ * Resolve an address the author pasted into the Thread it names, and say
573
+ * whether this Post could be linked to it as a translation.
574
+ *
575
+ * The same rules as {@link PostService.listTranslationCandidates}, asked
576
+ * about one known Thread instead of searched for — so the answer can be a
577
+ * reason rather than an empty list. An address that lands on a reply names
578
+ * its Thread, because translations link whole Threads.
579
+ *
580
+ * @param postId - Any Post in the Thread looking for a translation
581
+ * @param path - Internal path, as produced by `toInternalPath()`
582
+ * @returns The Thread root to link, or why it cannot be
583
+ * @example
584
+ * await posts.resolveTranslationCandidate(id, "/hello-world");
585
+ * // { status: "unpublished" }
586
+ */
587
+ resolveTranslationCandidate(
588
+ postId: string,
589
+ path: string,
590
+ ): Promise<TranslationCandidateResolution>;
591
+ /**
592
+ * Link two already-published Thread roots as translations of each other.
593
+ *
594
+ * Joins the side without a group into the other's group, minting one when
595
+ * neither has any. Merging two existing groups is refused — that would
596
+ * silently restructure both sides.
597
+ *
598
+ * @throws {ConflictError} When the languages clash or both sides have groups
599
+ */
600
+ linkTranslation(postId: string, otherPostId: string): Promise<void>;
601
+ /**
602
+ * Remove a Thread root from its translation group. When that leaves a single
603
+ * Post behind, its group is cleared too so no one-member groups survive.
604
+ */
605
+ unlinkTranslation(postId: string): Promise<void>;
393
606
  /** Get reply counts for multiple posts */
394
607
  getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
395
608
  /** Get preview replies for multiple thread roots */
@@ -619,6 +832,34 @@ function ensurePostRating(
619
832
  throw new ErrorCtor("Rating must be an integer between 1 and 5.");
620
833
  }
621
834
 
835
+ /**
836
+ * Normalize a caller-supplied content language to what the column stores.
837
+ *
838
+ * Empty, missing, and unparseable values all become `null` — the single-language
839
+ * state — so the column never holds a half-set value. Whether the tag is one
840
+ * the site actually offers is a settings-layer question, checked where the site
841
+ * language configuration is in scope.
842
+ *
843
+ * @param value - Raw BCP 47 tag from a request or import
844
+ * @returns Canonical tag (`zh-Hans`), or `null` when there is nothing to store
845
+ * @example
846
+ * normalizePostLanguage("ZH-hans"); // "zh-Hans"
847
+ * normalizePostLanguage(" "); // null
848
+ */
849
+ export function normalizePostLanguage(
850
+ value: string | null | undefined,
851
+ ): string | null {
852
+ if (typeof value !== "string") return null;
853
+ const trimmed = value.trim();
854
+ if (!trimmed) return null;
855
+ if (!isValidContentLanguage(trimmed)) {
856
+ throw new ValidationError(
857
+ "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
858
+ );
859
+ }
860
+ return normalizeContentLanguage(trimmed);
861
+ }
862
+
622
863
  function assertPostFormatShape(data: {
623
864
  format: Format;
624
865
  title?: string | null;
@@ -723,11 +964,6 @@ export function createPostService(
723
964
  : sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
724
965
  }
725
966
 
726
- const effectiveVisibilityExpr = sql<string>`coalesce(
727
- ${posts.visibility},
728
- (SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId} AND root.site_id = ${siteId})
729
- )`;
730
-
731
967
  /** Check if a slug is available (not used by posts or path_registry) */
732
968
  async function isSlugAvailable(slug: string): Promise<boolean> {
733
969
  return resolvedPaths.isPathAvailable(slug);
@@ -934,22 +1170,71 @@ export function createPostService(
934
1170
  )`;
935
1171
  }
936
1172
 
937
- /** Build WHERE conditions from filters (shared by list and count) */
938
- function buildFilterConditions(filters: PostFilters) {
939
- const conditions = [eq(posts.siteId, siteId)];
940
-
941
- if (filters.status) {
942
- conditions.push(eq(posts.status, filters.status));
943
- }
944
- if (filters.visibility !== undefined) {
945
- conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
1173
+ /**
1174
+ * Load a Post and assert it can carry a translation group.
1175
+ *
1176
+ * Groups live on Thread roots only, and both sides must belong to this site.
1177
+ * These two rules replace the table CHECK the SQLite schema cannot add, so
1178
+ * every write path that touches `translation_group_id` goes through here.
1179
+ */
1180
+ async function requireTranslatableRoot(
1181
+ postId: string,
1182
+ ): Promise<typeof posts.$inferSelect> {
1183
+ const rows = await db
1184
+ .select()
1185
+ .from(posts)
1186
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
1187
+ .limit(1);
1188
+ const row = rows[0];
1189
+ if (!row) {
1190
+ throw new NotFoundError("Post");
946
1191
  }
947
- if (filters.excludeLatestHidden) {
948
- conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1192
+ if (row.threadId !== row.id) {
1193
+ throw new ValidationError(
1194
+ "Translations link whole threads. Use the thread's first post.",
1195
+ );
949
1196
  }
950
- if (filters.excludePrivate) {
951
- conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1197
+ return row;
1198
+ }
1199
+
1200
+ /** Reject a language another Post in the same translation group already holds. */
1201
+ async function assertTranslationLanguageFree(
1202
+ groupId: string,
1203
+ language: string,
1204
+ excludePostId?: string,
1205
+ ): Promise<void> {
1206
+ const rows = await db
1207
+ .select({ id: posts.id, title: posts.title })
1208
+ .from(posts)
1209
+ .where(
1210
+ and(
1211
+ eq(posts.siteId, siteId),
1212
+ eq(posts.translationGroupId, groupId),
1213
+ eq(posts.language, language),
1214
+ ),
1215
+ )
1216
+ .limit(2);
1217
+ const clash = rows.find((row) => row.id !== excludePostId);
1218
+ if (clash) {
1219
+ // The language's own name, not its tag: this message is shown to the
1220
+ // author, and "简体中文" is what they picked in the menu — "zh-Hans" is
1221
+ // an implementation detail they never chose.
1222
+ const languageName = getOrBuildEntry(language).native;
1223
+ throw new ConflictError(
1224
+ clash.title
1225
+ ? `"${clash.title}" is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`
1226
+ : `Another post is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`,
1227
+ );
952
1228
  }
1229
+ }
1230
+
1231
+ /** Build WHERE conditions from filters (shared by list and count) */
1232
+ function buildFilterConditions(filters: PostFilters) {
1233
+ const conditions = [
1234
+ eq(posts.siteId, siteId),
1235
+ ...buildReaderVisibilityConditions(posts, siteId, filters),
1236
+ ];
1237
+
953
1238
  if (filters.pinned !== undefined) {
954
1239
  conditions.push(
955
1240
  filters.pinned
@@ -979,6 +1264,9 @@ export function createPostService(
979
1264
  if (filters.threadId) {
980
1265
  conditions.push(eq(posts.threadId, filters.threadId));
981
1266
  }
1267
+ if (filters.ids !== undefined) {
1268
+ conditions.push(inArray(posts.id, filters.ids));
1269
+ }
982
1270
  if (filters.excludeReplies) {
983
1271
  conditions.push(isNull(posts.replyToId));
984
1272
  }
@@ -1150,80 +1438,61 @@ export function createPostService(
1150
1438
  ...keys,
1151
1439
  ];
1152
1440
 
1153
- if (filters.featured || filters.sortOrder === undefined) {
1154
- if (filters.featured) {
1155
- return buildLexicographicCursorCondition(
1156
- withPinnedSortKey([
1157
- {
1158
- direction: "desc",
1159
- expr: featuredPublishedSortExpr,
1160
- value: cursorFeaturedPublishedAt,
1161
- },
1162
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1163
- ]),
1164
- );
1165
- }
1166
-
1441
+ if (filters.featured) {
1167
1442
  return buildLexicographicCursorCondition(
1168
1443
  withPinnedSortKey([
1169
1444
  {
1170
1445
  direction: "desc",
1171
- expr: sortTimestampSortExpr,
1172
- value: cursorSortTimestamp,
1446
+ expr: featuredPublishedSortExpr,
1447
+ value: cursorFeaturedPublishedAt,
1173
1448
  },
1174
1449
  { direction: "desc", expr: posts.id, value: cursorPost.id },
1175
1450
  ]),
1176
1451
  );
1177
1452
  }
1178
1453
 
1179
- if (filters.sortOrder === "oldest") {
1180
- return buildLexicographicCursorCondition(
1181
- withPinnedSortKey([
1182
- {
1183
- direction: "asc",
1184
- expr: sortTimestampSortExpr,
1185
- value: cursorSortTimestamp,
1186
- },
1187
- { direction: "asc", expr: posts.id, value: cursorPost.id },
1188
- ]),
1189
- );
1190
- }
1191
-
1192
- if (filters.sortOrder === "rating_desc") {
1193
- return buildLexicographicCursorCondition(
1194
- withPinnedSortKey([
1195
- {
1196
- direction: "desc",
1197
- expr: ratingPresenceExpr,
1198
- value: cursorRatingPresence,
1199
- },
1200
- { direction: "desc", expr: ratingSortExpr, value: cursorRating },
1201
- {
1202
- direction: "desc",
1203
- expr: sortTimestampSortExpr,
1204
- value: cursorSortTimestamp,
1205
- },
1206
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1207
- ]),
1208
- );
1454
+ // One case per sort order, mirroring `list()` key for key.
1455
+ switch (filters.sortOrder ?? "newest") {
1456
+ case "oldest":
1457
+ return buildLexicographicCursorCondition(
1458
+ withPinnedSortKey([
1459
+ {
1460
+ direction: "asc",
1461
+ expr: sortTimestampSortExpr,
1462
+ value: cursorSortTimestamp,
1463
+ },
1464
+ { direction: "asc", expr: posts.id, value: cursorPost.id },
1465
+ ]),
1466
+ );
1467
+ case "rating_desc":
1468
+ return buildLexicographicCursorCondition(
1469
+ withPinnedSortKey([
1470
+ {
1471
+ direction: "desc",
1472
+ expr: ratingPresenceExpr,
1473
+ value: cursorRatingPresence,
1474
+ },
1475
+ { direction: "desc", expr: ratingSortExpr, value: cursorRating },
1476
+ {
1477
+ direction: "desc",
1478
+ expr: sortTimestampSortExpr,
1479
+ value: cursorSortTimestamp,
1480
+ },
1481
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1482
+ ]),
1483
+ );
1484
+ case "newest":
1485
+ return buildLexicographicCursorCondition(
1486
+ withPinnedSortKey([
1487
+ {
1488
+ direction: "desc",
1489
+ expr: sortTimestampSortExpr,
1490
+ value: cursorSortTimestamp,
1491
+ },
1492
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1493
+ ]),
1494
+ );
1209
1495
  }
1210
-
1211
- return buildLexicographicCursorCondition(
1212
- withPinnedSortKey([
1213
- {
1214
- direction: "desc",
1215
- expr: ratingPresenceExpr,
1216
- value: cursorRatingPresence,
1217
- },
1218
- { direction: "asc", expr: ratingSortExpr, value: cursorRating },
1219
- {
1220
- direction: "desc",
1221
- expr: sortTimestampSortExpr,
1222
- value: cursorSortTimestamp,
1223
- },
1224
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1225
- ]),
1226
- );
1227
1496
  }
1228
1497
 
1229
1498
  function toPost(
@@ -1258,6 +1527,8 @@ export function createPostService(
1258
1527
  previewProvider: row.previewProvider,
1259
1528
  replyToId: row.replyToId,
1260
1529
  threadId: row.threadId,
1530
+ language: row.language,
1531
+ translationGroupId: row.translationGroupId,
1261
1532
  quietReply: row.quietReply,
1262
1533
  publishedAt: row.publishedAt,
1263
1534
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
@@ -1355,18 +1626,10 @@ export function createPostService(
1355
1626
  }
1356
1627
 
1357
1628
  function buildThreadRootPageConditions(options?: ThreadRootPageOptions) {
1358
- const conditions: SQL[] = [eq(posts.siteId, siteId)];
1359
- const status = options?.status;
1360
-
1361
- if (status) {
1362
- conditions.push(eq(posts.status, status));
1363
- }
1364
- if (options?.excludePrivate) {
1365
- conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1366
- }
1367
- if (options?.excludeLatestHidden) {
1368
- conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1369
- }
1629
+ const conditions: SQL[] = [
1630
+ eq(posts.siteId, siteId),
1631
+ ...buildReaderVisibilityConditions(posts, siteId, options ?? {}),
1632
+ ];
1370
1633
  if (options?.publishedBefore !== undefined) {
1371
1634
  conditions.push(
1372
1635
  sql`${posts.publishedAt} < ${options.publishedBefore}`,
@@ -1391,6 +1654,9 @@ export function createPostService(
1391
1654
  if (options?.hasRating) {
1392
1655
  conditions.push(isNotNull(posts.rating));
1393
1656
  }
1657
+ if (options?.threadIds !== undefined) {
1658
+ conditions.push(inArray(posts.threadId, options.threadIds));
1659
+ }
1394
1660
 
1395
1661
  return conditions;
1396
1662
  }
@@ -1698,36 +1964,39 @@ export function createPostService(
1698
1964
  .where(conditions.length > 0 ? and(...conditions) : undefined)
1699
1965
  .limit(filters.limit ?? 100);
1700
1966
 
1701
- let query =
1702
- filters.featured || filters.sortOrder === undefined
1703
- ? baseQuery.orderBy(
1967
+ // A case per sort order, and no shared fallthrough. This was an if/else
1968
+ // chain whose last branch was written for one order and silently caught
1969
+ // another, so `newest` ordered by rating instead of by time wherever it
1970
+ // was passed explicitly — a smart collection page, most visibly.
1971
+ const buildOrderBy = (): SQL<unknown>[] => {
1972
+ if (filters.featured) {
1973
+ return [
1974
+ ...pinnedOrder,
1975
+ desc(featuredPublishedSortExpr),
1976
+ desc(posts.id),
1977
+ ];
1978
+ }
1979
+ switch (filters.sortOrder ?? "newest") {
1980
+ case "oldest":
1981
+ return [...pinnedOrder, asc(sortTimestampSortExpr), asc(posts.id)];
1982
+ case "rating_desc":
1983
+ return [
1704
1984
  ...pinnedOrder,
1705
- filters.featured
1706
- ? desc(featuredPublishedSortExpr)
1707
- : desc(sortTimestampSortExpr),
1985
+ desc(ratingPresence),
1986
+ desc(posts.rating),
1987
+ desc(sortTimestampSortExpr),
1708
1988
  desc(posts.id),
1709
- )
1710
- : filters.sortOrder === "oldest"
1711
- ? baseQuery.orderBy(
1712
- ...pinnedOrder,
1713
- asc(sortTimestampSortExpr),
1714
- asc(posts.id),
1715
- )
1716
- : filters.sortOrder === "rating_desc"
1717
- ? baseQuery.orderBy(
1718
- ...pinnedOrder,
1719
- desc(ratingPresence),
1720
- desc(posts.rating),
1721
- desc(sortTimestampSortExpr),
1722
- desc(posts.id),
1723
- )
1724
- : baseQuery.orderBy(
1725
- ...pinnedOrder,
1726
- desc(ratingPresence),
1727
- asc(posts.rating),
1728
- desc(sortTimestampSortExpr),
1729
- desc(posts.id),
1730
- );
1989
+ ];
1990
+ case "newest":
1991
+ return [
1992
+ ...pinnedOrder,
1993
+ desc(sortTimestampSortExpr),
1994
+ desc(posts.id),
1995
+ ];
1996
+ }
1997
+ };
1998
+
1999
+ let query = baseQuery.orderBy(...buildOrderBy());
1731
2000
 
1732
2001
  if (filters.offset !== undefined) {
1733
2002
  query = query.offset(filters.offset) as typeof query;
@@ -1774,6 +2043,8 @@ export function createPostService(
1774
2043
  id: posts.id,
1775
2044
  updatedAt: threadModifiedAt,
1776
2045
  featuredAt: posts.featuredAt,
2046
+ language: posts.language,
2047
+ translationGroupId: posts.translationGroupId,
1777
2048
  })
1778
2049
  .from(posts)
1779
2050
  .where(conditions.length > 0 ? and(...conditions) : undefined)
@@ -1799,6 +2070,8 @@ export function createPostService(
1799
2070
  alias,
1800
2071
  updatedAt: row.updatedAt,
1801
2072
  featuredAt: row.featuredAt,
2073
+ language: row.language,
2074
+ translationGroupId: row.translationGroupId,
1802
2075
  };
1803
2076
  })
1804
2077
  .filter((entry): entry is SitemapPostEntry => entry !== null);
@@ -1845,6 +2118,59 @@ export function createPostService(
1845
2118
  return result[0]?.count ?? 0;
1846
2119
  },
1847
2120
 
2121
+ async aggregateMany(filters, base) {
2122
+ if (filters.length === 0) return [];
2123
+
2124
+ const baseConditions = buildFilterConditions(base);
2125
+ // The row is its own Thread root here — callers exclude replies — so the
2126
+ // activity expression reads the columns in hand. No correlated subquery.
2127
+ const activityAt = buildRootActivityExpr({
2128
+ lastActivityAt: posts.lastActivityAt,
2129
+ publishedAt: posts.publishedAt,
2130
+ updatedAt: posts.updatedAt,
2131
+ });
2132
+ // A `SUM(CASE …)` and a `MAX(CASE …)` per filter, over one pass of the
2133
+ // rows the base predicate already narrows to. The aliases are positional
2134
+ // and generated here, so nothing a caller supplies reaches the SQL as an
2135
+ // identifier.
2136
+ const columns = Object.fromEntries(
2137
+ filters.flatMap((filter, index) => {
2138
+ const conditions = buildFilterConditions({ ...base, ...filter });
2139
+ const predicate =
2140
+ conditions.length > 0 ? and(...conditions) : sql`1 = 1`;
2141
+ return [
2142
+ [
2143
+ `n${index}`,
2144
+ sql<number>`CAST(SUM(CASE WHEN (${predicate}) THEN 1 ELSE 0 END) AS INTEGER)`,
2145
+ ],
2146
+ [
2147
+ `a${index}`,
2148
+ sql<
2149
+ number | null
2150
+ >`MAX(CASE WHEN (${predicate}) THEN ${activityAt} END)`,
2151
+ ],
2152
+ ];
2153
+ }),
2154
+ );
2155
+
2156
+ const rows = await db
2157
+ .select(columns)
2158
+ .from(posts)
2159
+ .where(baseConditions.length > 0 ? and(...baseConditions) : undefined);
2160
+
2161
+ const row = rows[0] as Record<string, number | null> | undefined;
2162
+ return filters.map((_, index) => {
2163
+ const activity = row?.[`a${index}`];
2164
+ return {
2165
+ count: Number(row?.[`n${index}`] ?? 0),
2166
+ recentActivityAt:
2167
+ activity === null || activity === undefined
2168
+ ? null
2169
+ : Number(activity),
2170
+ };
2171
+ });
2172
+ },
2173
+
1848
2174
  async countUpTo(filters = {}, limit) {
1849
2175
  const normalizedLimit = Math.max(0, Math.trunc(limit));
1850
2176
  if (normalizedLimit === 0) {
@@ -1937,6 +2263,11 @@ export function createPostService(
1937
2263
  let threadId = id;
1938
2264
  let status: Status = requestedStatus ?? "published";
1939
2265
  let visibility: Visibility | null = requestedVisibility ?? "public";
2266
+ // Roots carry the requested language; replies overwrite this with the
2267
+ // root's value below so a Thread never mixes languages.
2268
+ let language = normalizePostLanguage(data.language);
2269
+ let translationGroupId: string | null = null;
2270
+ let sourceTranslationPostId: string | null = null;
1940
2271
 
1941
2272
  // Collapse the two-input (`flag` + `flagAt`) pair the DTO exposes
1942
2273
  // into the single timestamp form the DB column stores. Explicit
@@ -2001,6 +2332,10 @@ export function createPostService(
2001
2332
  status = root.status;
2002
2333
  }
2003
2334
  visibility = null;
2335
+ // A reply is part of the root's Thread, so it is written in the
2336
+ // Thread's language whatever the caller asked for. This is what keeps
2337
+ // language filters correct on member-grained queries.
2338
+ language = root?.language ?? parent.language ?? null;
2004
2339
 
2005
2340
  if (
2006
2341
  (data.collectionIds?.length ?? 0) > 0 ||
@@ -2012,6 +2347,54 @@ export function createPostService(
2012
2347
  }
2013
2348
  }
2014
2349
 
2350
+ const { primary, languages, reservedPrefixes } =
2351
+ await readLanguageSettings(db, siteId, databaseSchema);
2352
+
2353
+ // Nobody named a language, so read one out of the text. Every write path
2354
+ // that bypasses compose — the API, the Telegram bot, MCP — lands here, so
2355
+ // a multilingual site never accumulates unlabelled posts. An explicit
2356
+ // choice is the author's and is left alone; replies already carry the
2357
+ // Thread's language.
2358
+ if (!data.replyToId && !language) {
2359
+ language = suggestPostLanguage({
2360
+ text: bodyText ?? title ?? quoteText,
2361
+ languages,
2362
+ primary,
2363
+ });
2364
+ }
2365
+
2366
+ // "Add a translation" carries only the source ID until the author
2367
+ // submits; the group is minted here so abandoning the composer leaves
2368
+ // nothing behind (no draft row, no slug, no single-member group).
2369
+ if (data.translationOfId) {
2370
+ if (data.replyToId) {
2371
+ throw new ValidationError(
2372
+ "Translations link whole threads. Add the translation from the thread's first post.",
2373
+ );
2374
+ }
2375
+ const source = await requireTranslatableRoot(data.translationOfId);
2376
+ if (!language) {
2377
+ throw new ValidationError(
2378
+ "Choose a language for this translation before saving it.",
2379
+ );
2380
+ }
2381
+ if (source.language === language) {
2382
+ throw new ConflictError(
2383
+ "That post is already written in this language. Pick a different language for the translation.",
2384
+ );
2385
+ }
2386
+ if (source.translationGroupId) {
2387
+ await assertTranslationLanguageFree(
2388
+ source.translationGroupId,
2389
+ language,
2390
+ );
2391
+ translationGroupId = source.translationGroupId;
2392
+ } else {
2393
+ translationGroupId = createEntityId("translationGroup");
2394
+ sourceTranslationPostId = source.id;
2395
+ }
2396
+ }
2397
+
2015
2398
  // Featured surfaces only list published posts, so featuring a draft
2016
2399
  // writes a flag that does nothing and shows nowhere. Say so instead.
2017
2400
  if (status === "draft" && resolvedFeaturedAt !== null) {
@@ -2033,10 +2416,17 @@ export function createPostService(
2033
2416
  // readable slug can still set one explicitly.
2034
2417
  const titleForSlug =
2035
2418
  format === "quote" ? undefined : (title ?? undefined);
2419
+ // Only a non-primary language earns a readable language suffix: the bare
2420
+ // namespace belongs to the primary language, and a suffix shared by two
2421
+ // same-language posts distinguishes nothing.
2422
+ const languageSuffix =
2423
+ language && language !== primary
2424
+ ? toLanguagePrefix(language)
2425
+ : undefined;
2036
2426
 
2037
2427
  if (data.path) {
2038
2428
  const normalized = normalizePath(data.path);
2039
- if (isReservedPath(normalized)) {
2429
+ if (isReservedPath(normalized, reservedPrefixes)) {
2040
2430
  throw new ValidationError(
2041
2431
  `Path "${normalized}" is reserved and cannot be used`,
2042
2432
  );
@@ -2046,6 +2436,7 @@ export function createPostService(
2046
2436
  slug = await generatePostSlug({
2047
2437
  slug: normalized,
2048
2438
  idLength: config.slugIdLength,
2439
+ reservedPrefixes,
2049
2440
  isAvailable: isSlugAvailable,
2050
2441
  });
2051
2442
  } else {
@@ -2055,6 +2446,8 @@ export function createPostService(
2055
2446
  slug: slugified || undefined,
2056
2447
  title: titleForSlug,
2057
2448
  idLength: config.slugIdLength,
2449
+ languageSuffix,
2450
+ reservedPrefixes,
2058
2451
  isAvailable: isSlugAvailable,
2059
2452
  });
2060
2453
  // Verify the alias path is available before proceeding
@@ -2068,6 +2461,8 @@ export function createPostService(
2068
2461
  slug: data.slug,
2069
2462
  title: titleForSlug,
2070
2463
  idLength: config.slugIdLength,
2464
+ languageSuffix,
2465
+ reservedPrefixes,
2071
2466
  isAvailable: isSlugAvailable,
2072
2467
  });
2073
2468
  }
@@ -2127,6 +2522,23 @@ export function createPostService(
2127
2522
  if (usesBatchWrites) {
2128
2523
  const writeQueries = [];
2129
2524
 
2525
+ // Mint the group on the source Post in the same write as the
2526
+ // translation itself, so a failure never leaves a one-member group.
2527
+ if (sourceTranslationPostId && translationGroupId) {
2528
+ writeQueries.push(
2529
+ db
2530
+ .update(posts)
2531
+ .set({ translationGroupId, updatedAt: timestamp })
2532
+ .where(
2533
+ and(
2534
+ eq(posts.siteId, siteId),
2535
+ eq(posts.id, sourceTranslationPostId),
2536
+ isNull(posts.translationGroupId),
2537
+ ),
2538
+ ),
2539
+ );
2540
+ }
2541
+
2130
2542
  writeQueries.push(
2131
2543
  db.insert(posts).values({
2132
2544
  id,
@@ -2147,6 +2559,8 @@ export function createPostService(
2147
2559
  rating,
2148
2560
  replyToId: data.replyToId ?? null,
2149
2561
  threadId,
2562
+ language,
2563
+ translationGroupId,
2150
2564
  quietReply: isQuietReply,
2151
2565
  publishedAt,
2152
2566
  lastActivityAt: publishedAt ?? timestamp,
@@ -2207,6 +2621,19 @@ export function createPostService(
2207
2621
  );
2208
2622
  } else {
2209
2623
  await db.transaction(async (tx) => {
2624
+ if (sourceTranslationPostId && translationGroupId) {
2625
+ await tx
2626
+ .update(posts)
2627
+ .set({ translationGroupId, updatedAt: timestamp })
2628
+ .where(
2629
+ and(
2630
+ eq(posts.siteId, siteId),
2631
+ eq(posts.id, sourceTranslationPostId),
2632
+ isNull(posts.translationGroupId),
2633
+ ),
2634
+ );
2635
+ }
2636
+
2210
2637
  await tx.insert(posts).values({
2211
2638
  id,
2212
2639
  siteId,
@@ -2226,6 +2653,8 @@ export function createPostService(
2226
2653
  rating,
2227
2654
  replyToId: data.replyToId ?? null,
2228
2655
  threadId,
2656
+ language,
2657
+ translationGroupId,
2229
2658
  quietReply: isQuietReply,
2230
2659
  publishedAt,
2231
2660
  lastActivityAt: publishedAt ?? timestamp,
@@ -2365,6 +2794,9 @@ export function createPostService(
2365
2794
  ...data,
2366
2795
  // Chain each post as a reply to the previous one (server-side chaining)
2367
2796
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2797
+ // Translation groups and languages are Thread-level: the root carries
2798
+ // them and every reply inherits, so later items must not restate them.
2799
+ translationOfId: i === 0 ? data.translationOfId : undefined,
2368
2800
  quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
2369
2801
  publishedAt:
2370
2802
  i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
@@ -2397,6 +2829,17 @@ export function createPostService(
2397
2829
  const existing = await this.getById(id);
2398
2830
  if (!existing) return null;
2399
2831
 
2832
+ // Thread-wide, so it cannot ride along on the single-row write below.
2833
+ // Runs first: if the language is refused — the Thread's translation group
2834
+ // already holds it — nothing else should have been written either.
2835
+ if (
2836
+ data.language &&
2837
+ !existing.replyToId &&
2838
+ data.language !== existing.language
2839
+ ) {
2840
+ await this.setThreadLanguage(id, data.language);
2841
+ }
2842
+
2400
2843
  const timestamp = now();
2401
2844
  const nextFormat =
2402
2845
  data.format !== undefined
@@ -2761,8 +3204,7 @@ export function createPostService(
2761
3204
  ],
2762
3205
  );
2763
3206
  updateResult = results[updateIdx] as
2764
- | (typeof posts.$inferSelect)[]
2765
- | undefined;
3207
+ (typeof posts.$inferSelect)[] | undefined;
2766
3208
  } else {
2767
3209
  await db.transaction(async (tx) => {
2768
3210
  if (needsCascade) {
@@ -3154,6 +3596,397 @@ export function createPostService(
3154
3596
  await recalculateThreadActivity(rootId);
3155
3597
  },
3156
3598
 
3599
+ async setThreadLanguage(postId, language) {
3600
+ const normalized = normalizePostLanguage(language);
3601
+ if (!normalized) {
3602
+ throw new ValidationError("Choose a language for this post.");
3603
+ }
3604
+
3605
+ // A language the site does not publish has no view, no feed and no
3606
+ // switcher entry, so the post would simply vanish. Only checked once the
3607
+ // site actually has a language set to belong to.
3608
+ const { languages } = await readLanguageSettings(
3609
+ db,
3610
+ siteId,
3611
+ databaseSchema,
3612
+ );
3613
+ if (
3614
+ languages.length > 0 &&
3615
+ !languages.some(
3616
+ (tag) => toLanguagePrefix(tag) === toLanguagePrefix(normalized),
3617
+ )
3618
+ ) {
3619
+ throw new ValidationError(
3620
+ "This site does not publish that language. Add it in Settings → Language first.",
3621
+ );
3622
+ }
3623
+
3624
+ const rows = await db
3625
+ .select({
3626
+ threadId: posts.threadId,
3627
+ id: posts.id,
3628
+ translationGroupId: posts.translationGroupId,
3629
+ })
3630
+ .from(posts)
3631
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3632
+ .limit(1);
3633
+ const row = rows[0];
3634
+ if (!row) {
3635
+ throw new NotFoundError("Post");
3636
+ }
3637
+
3638
+ // The group lives on the root, so a reply's Thread has to be resolved
3639
+ // before the translation-group check can run.
3640
+ const rootRows =
3641
+ row.id === row.threadId
3642
+ ? [row]
3643
+ : await db
3644
+ .select({
3645
+ threadId: posts.threadId,
3646
+ id: posts.id,
3647
+ translationGroupId: posts.translationGroupId,
3648
+ })
3649
+ .from(posts)
3650
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, row.threadId)))
3651
+ .limit(1);
3652
+ const root = rootRows[0];
3653
+ if (root?.translationGroupId) {
3654
+ await assertTranslationLanguageFree(
3655
+ root.translationGroupId,
3656
+ normalized,
3657
+ root.id,
3658
+ );
3659
+ }
3660
+
3661
+ const timestamp = now();
3662
+ await db
3663
+ .update(posts)
3664
+ .set({ language: normalized, updatedAt: timestamp })
3665
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, row.threadId)));
3666
+ },
3667
+
3668
+ async materializeMissingLanguage(language) {
3669
+ const normalized = normalizePostLanguage(language);
3670
+ if (!normalized) {
3671
+ throw new ValidationError("Choose a primary language for this site.");
3672
+ }
3673
+
3674
+ // Counted before the write rather than read back from it: the three
3675
+ // supported drivers report affected rows differently, and the caller only
3676
+ // needs the number for its confirmation copy.
3677
+ const pending = await this.countMissingLanguage();
3678
+ if (pending === 0) return 0;
3679
+
3680
+ await db
3681
+ .update(posts)
3682
+ .set({ language: normalized })
3683
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3684
+ return pending;
3685
+ },
3686
+
3687
+ async countMissingLanguage() {
3688
+ const rows = await db
3689
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3690
+ .from(posts)
3691
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3692
+ return Number(rows[0]?.count ?? 0);
3693
+ },
3694
+
3695
+ async countByLanguage(language) {
3696
+ const normalized = normalizePostLanguage(language);
3697
+ if (!normalized) return 0;
3698
+
3699
+ const rows = await db
3700
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3701
+ .from(posts)
3702
+ .where(and(eq(posts.siteId, siteId), eq(posts.language, normalized)));
3703
+ return Number(rows[0]?.count ?? 0);
3704
+ },
3705
+
3706
+ async listLanguagesInUse() {
3707
+ const rows = await db
3708
+ .select({
3709
+ language: posts.language,
3710
+ count: sql<number>`CAST(count(*) AS INTEGER)`,
3711
+ })
3712
+ .from(posts)
3713
+ .where(and(eq(posts.siteId, siteId), isNotNull(posts.language)))
3714
+ .groupBy(posts.language);
3715
+ return rows
3716
+ .filter((row): row is { language: string; count: number } =>
3717
+ Boolean(row.language),
3718
+ )
3719
+ .map((row) => ({ language: row.language, count: Number(row.count) }));
3720
+ },
3721
+
3722
+ async listTranslations(postId) {
3723
+ const map = await this.getTranslationsMap([postId]);
3724
+ return map.get(postId) ?? [];
3725
+ },
3726
+
3727
+ async listTranslationCandidates(postId, options) {
3728
+ const term = options.query.trim();
3729
+ if (!term) return [];
3730
+ const limit = options.limit ?? 8;
3731
+
3732
+ const source = await this.getById(postId);
3733
+ if (!source) return [];
3734
+ const root =
3735
+ source.replyToId === null
3736
+ ? source
3737
+ : await this.getById(source.threadId);
3738
+ if (!root?.language) return [];
3739
+
3740
+ // Languages this Thread's group already speaks for, its own included.
3741
+ const taken = new Set([root.language]);
3742
+ if (root.translationGroupId) {
3743
+ for (const sibling of await this.listTranslations(root.id)) {
3744
+ if (sibling.language) taken.add(sibling.language);
3745
+ }
3746
+ }
3747
+
3748
+ // Escaped with an explicit ESCAPE clause: the two dialects disagree on
3749
+ // the default escape character (see `paths.findPathsUnderSegment`).
3750
+ const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
3751
+ const conditions: SQL[] = [
3752
+ eq(posts.siteId, siteId),
3753
+ eq(posts.status, "published"),
3754
+ // Thread roots only: a translation links whole Threads.
3755
+ sql`${posts.id} = ${posts.threadId}`,
3756
+ sql`${posts.id} != ${root.id}`,
3757
+ isNotNull(posts.language),
3758
+ notInArray(posts.language, [...taken]),
3759
+ sql`(${posts.title} LIKE ${like} ESCAPE '\\' OR ${posts.bodyText} LIKE ${like} ESCAPE '\\')`,
3760
+ ];
3761
+
3762
+ if (root.translationGroupId) {
3763
+ // Both sides having a group would ask for a merge, which is refused.
3764
+ conditions.push(isNull(posts.translationGroupId));
3765
+ } else {
3766
+ // The candidate may carry a group, as long as joining it would not put
3767
+ // two Posts of this language in it.
3768
+ conditions.push(
3769
+ sql`(${posts.translationGroupId} IS NULL OR NOT EXISTS (
3770
+ SELECT 1
3771
+ FROM post AS group_member
3772
+ WHERE group_member.site_id = ${siteId}
3773
+ AND group_member.translation_group_id = "post"."translation_group_id"
3774
+ AND group_member.language = ${root.language}
3775
+ ))`,
3776
+ );
3777
+ }
3778
+
3779
+ const rows = await db
3780
+ .select()
3781
+ .from(posts)
3782
+ .where(and(...conditions))
3783
+ .orderBy(desc(posts.publishedAt))
3784
+ .limit(limit);
3785
+
3786
+ return hydratePosts(rows);
3787
+ },
3788
+
3789
+ async resolveTranslationCandidate(postId, path) {
3790
+ const source = await this.getById(postId);
3791
+ if (!source) return { status: "not_found" };
3792
+ const root =
3793
+ source.replyToId === null
3794
+ ? source
3795
+ : await this.getById(source.threadId);
3796
+ if (!root?.language) return { status: "not_found" };
3797
+
3798
+ const target = await resolvedPaths.resolveTarget(path);
3799
+ if (!target) return { status: "not_found" };
3800
+ if (target.targetType !== "post" || !target.postId) {
3801
+ return { status: "not_a_post" };
3802
+ }
3803
+
3804
+ const found = await this.getById(target.postId);
3805
+ if (!found) return { status: "not_found" };
3806
+ // Translations link whole Threads, so an address that lands on a reply
3807
+ // is taken as naming the Thread it belongs to.
3808
+ const candidate =
3809
+ found.replyToId === null ? found : await this.getById(found.threadId);
3810
+ if (!candidate) return { status: "not_found" };
3811
+
3812
+ if (candidate.id === root.id) return { status: "same_thread" };
3813
+ if (candidate.status !== "published") return { status: "unpublished" };
3814
+ if (!candidate.language) return { status: "no_language" };
3815
+ if (candidate.language === root.language) {
3816
+ return { status: "same_language" };
3817
+ }
3818
+
3819
+ const siblings = root.translationGroupId
3820
+ ? await this.listTranslations(root.id)
3821
+ : [];
3822
+ if (siblings.some((post) => post.language === candidate.language)) {
3823
+ return { status: "language_taken", language: candidate.language };
3824
+ }
3825
+
3826
+ if (candidate.translationGroupId) {
3827
+ // `linkTranslation` refuses to merge two groups, and refuses to join
3828
+ // one that already speaks for this Thread's language.
3829
+ if (root.translationGroupId) return { status: "group_conflict" };
3830
+
3831
+ const theirGroup = await this.listTranslations(candidate.id);
3832
+ if (theirGroup.some((post) => post.language === root.language)) {
3833
+ return { status: "group_language_taken", language: root.language };
3834
+ }
3835
+ }
3836
+
3837
+ return { status: "ok", post: candidate };
3838
+ },
3839
+
3840
+ async getTranslationsMap(postIds) {
3841
+ const result = new Map<string, Post[]>();
3842
+ const uniqueIds = [...new Set(postIds)].filter(Boolean);
3843
+ if (uniqueIds.length === 0) return result;
3844
+
3845
+ const seedRows = await db
3846
+ .select({ id: posts.id, translationGroupId: posts.translationGroupId })
3847
+ .from(posts)
3848
+ .where(
3849
+ and(
3850
+ eq(posts.siteId, siteId),
3851
+ inArray(posts.id, uniqueIds),
3852
+ isNotNull(posts.translationGroupId),
3853
+ ),
3854
+ );
3855
+ if (seedRows.length === 0) return result;
3856
+
3857
+ const groupIds = [
3858
+ ...new Set(
3859
+ seedRows
3860
+ .map((row) => row.translationGroupId)
3861
+ .filter((groupId): groupId is string => groupId !== null),
3862
+ ),
3863
+ ];
3864
+ const memberRows = await db
3865
+ .select()
3866
+ .from(posts)
3867
+ .where(
3868
+ and(
3869
+ eq(posts.siteId, siteId),
3870
+ inArray(posts.translationGroupId, groupIds),
3871
+ ),
3872
+ );
3873
+ const members = await hydratePosts(memberRows);
3874
+ const byGroup = new Map<string, Post[]>();
3875
+ for (const member of members) {
3876
+ const groupId = member.translationGroupId;
3877
+ if (!groupId) continue;
3878
+ const bucket = byGroup.get(groupId);
3879
+ if (bucket) bucket.push(member);
3880
+ else byGroup.set(groupId, [member]);
3881
+ }
3882
+
3883
+ for (const seed of seedRows) {
3884
+ const groupId = seed.translationGroupId;
3885
+ if (!groupId) continue;
3886
+ const siblings = (byGroup.get(groupId) ?? []).filter(
3887
+ (member) => member.id !== seed.id,
3888
+ );
3889
+ if (siblings.length > 0) result.set(seed.id, siblings);
3890
+ }
3891
+ return result;
3892
+ },
3893
+
3894
+ async linkTranslation(postId, otherPostId) {
3895
+ if (postId === otherPostId) {
3896
+ throw new ValidationError("Pick a different post to link.");
3897
+ }
3898
+
3899
+ const [post, other] = await Promise.all([
3900
+ requireTranslatableRoot(postId),
3901
+ requireTranslatableRoot(otherPostId),
3902
+ ]);
3903
+
3904
+ if (!post.language || !other.language) {
3905
+ throw new ValidationError(
3906
+ "Both posts need a language before they can be linked as translations.",
3907
+ );
3908
+ }
3909
+ if (post.language === other.language) {
3910
+ throw new ConflictError(
3911
+ "These two posts are written in the same language. Translations need different languages.",
3912
+ );
3913
+ }
3914
+ if (post.translationGroupId && other.translationGroupId) {
3915
+ if (post.translationGroupId === other.translationGroupId) return;
3916
+ throw new ConflictError(
3917
+ "Both posts already belong to translation groups. Unlink one of them first.",
3918
+ );
3919
+ }
3920
+
3921
+ const timestamp = now();
3922
+ if (post.translationGroupId ?? other.translationGroupId) {
3923
+ const groupId = (post.translationGroupId ??
3924
+ other.translationGroupId) as string;
3925
+ const joiningId = post.translationGroupId ? other.id : post.id;
3926
+ const joiningLanguage = post.translationGroupId
3927
+ ? other.language
3928
+ : post.language;
3929
+ await assertTranslationLanguageFree(groupId, joiningLanguage);
3930
+ await db
3931
+ .update(posts)
3932
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3933
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, joiningId)));
3934
+ return;
3935
+ }
3936
+
3937
+ const groupId = createEntityId("translationGroup");
3938
+ const setGroup = (targetId: string) =>
3939
+ db
3940
+ .update(posts)
3941
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3942
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, targetId)));
3943
+
3944
+ if (usesBatchWrites) {
3945
+ await db.batch([setGroup(post.id), setGroup(other.id)]);
3946
+ } else {
3947
+ await db.transaction(async (tx) => {
3948
+ await tx
3949
+ .update(posts)
3950
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3951
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
3952
+ await tx
3953
+ .update(posts)
3954
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3955
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, other.id)));
3956
+ });
3957
+ }
3958
+ },
3959
+
3960
+ async unlinkTranslation(postId) {
3961
+ const post = await requireTranslatableRoot(postId);
3962
+ const groupId = post.translationGroupId;
3963
+ if (!groupId) return;
3964
+
3965
+ const timestamp = now();
3966
+ await db
3967
+ .update(posts)
3968
+ .set({ translationGroupId: null, updatedAt: timestamp })
3969
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
3970
+
3971
+ // A group of one is indistinguishable from no group and would linger as
3972
+ // a dangling key, so collapse it as soon as the second-to-last member
3973
+ // leaves.
3974
+ const remaining = await db
3975
+ .select({ id: posts.id })
3976
+ .from(posts)
3977
+ .where(
3978
+ and(eq(posts.siteId, siteId), eq(posts.translationGroupId, groupId)),
3979
+ )
3980
+ .limit(2);
3981
+ const lastMember = remaining.length === 1 ? remaining[0] : undefined;
3982
+ if (lastMember) {
3983
+ await db
3984
+ .update(posts)
3985
+ .set({ translationGroupId: null, updatedAt: timestamp })
3986
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, lastMember.id)));
3987
+ }
3988
+ },
3989
+
3157
3990
  async getReplyCounts(postIds) {
3158
3991
  if (postIds.length === 0) return new Map();
3159
3992
 
@@ -3278,8 +4111,11 @@ export function createPostService(
3278
4111
  .from(rankedReplies)
3279
4112
  .where(
3280
4113
  or(
3281
- lte(rankedReplies.firstReplyRank, 2),
3282
- lte(rankedReplies.latestReplyRank, 3),
4114
+ // One past the leading window: that extra row is never rendered,
4115
+ // it is only where the gap link points. Without it the gap has to
4116
+ // aim at a reply already on screen.
4117
+ lte(rankedReplies.firstReplyRank, THREAD_LEADING_REPLIES + 1),
4118
+ lte(rankedReplies.latestReplyRank, THREAD_TRAILING_REPLIES),
3283
4119
  ),
3284
4120
  );
3285
4121
 
@@ -3293,6 +4129,7 @@ export function createPostService(
3293
4129
  leadingReplyIds: Map<number, string>;
3294
4130
  trailingReplyIds: Map<number, string>;
3295
4131
  latestReplyId: string | null;
4132
+ firstHiddenReplyId: string | null;
3296
4133
  totalReplyCount: number;
3297
4134
  }
3298
4135
  >();
@@ -3301,13 +4138,20 @@ export function createPostService(
3301
4138
  leadingReplyIds: new Map<number, string>(),
3302
4139
  trailingReplyIds: new Map<number, string>(),
3303
4140
  latestReplyId: null,
4141
+ firstHiddenReplyId: null,
3304
4142
  totalReplyCount: row.totalReplyCount,
3305
4143
  };
3306
4144
 
3307
- if (row.firstReplyRank <= 2) {
4145
+ if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
3308
4146
  existing.leadingReplyIds.set(row.firstReplyRank, row.id);
3309
4147
  }
3310
- if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
4148
+ if (row.firstReplyRank === THREAD_LEADING_REPLIES + 1) {
4149
+ existing.firstHiddenReplyId = row.id;
4150
+ }
4151
+ if (
4152
+ row.latestReplyRank > 1 &&
4153
+ row.latestReplyRank <= THREAD_TRAILING_REPLIES
4154
+ ) {
3311
4155
  existing.trailingReplyIds.set(row.latestReplyRank, row.id);
3312
4156
  }
3313
4157
  if (row.latestReplyRank === 1) {
@@ -3329,16 +4173,36 @@ export function createPostService(
3329
4173
  .map((id) => (id ? hydratedPosts.get(id) : undefined))
3330
4174
  .filter((post): post is Post => post !== undefined);
3331
4175
 
4176
+ const leadingReplies = hydrateReplyIds(
4177
+ Array.from({ length: THREAD_LEADING_REPLIES }, (_unused, index) =>
4178
+ context.leadingReplyIds.get(index + 1),
4179
+ ),
4180
+ );
4181
+ // Ranked newest-first, so walking down to rank 2 puts them back in
4182
+ // thread order. Rank 1 is the hero and is carried separately.
4183
+ const trailingReplies = hydrateReplyIds(
4184
+ Array.from(
4185
+ { length: THREAD_TRAILING_REPLIES - 1 },
4186
+ (_unused, index) =>
4187
+ context.trailingReplyIds.get(THREAD_TRAILING_REPLIES - index),
4188
+ ),
4189
+ );
4190
+ const firstHiddenReply =
4191
+ (context.firstHiddenReplyId
4192
+ ? hydratedPosts.get(context.firstHiddenReplyId)
4193
+ : undefined) ?? null;
4194
+
3332
4195
  result.set(threadId, {
3333
- leadingReplies: hydrateReplyIds([
3334
- context.leadingReplyIds.get(1),
3335
- context.leadingReplyIds.get(2),
3336
- ]),
3337
- trailingReplies: hydrateReplyIds([
3338
- context.trailingReplyIds.get(3),
3339
- context.trailingReplyIds.get(2),
3340
- ]),
4196
+ leadingReplies,
4197
+ // Left overlapping on purpose. These are the ranked buckets, not the
4198
+ // rendered fold: on a short thread a reply lands in both windows, and
4199
+ // dropping the duplicate is a presentation rule that `ThreadPreview`
4200
+ // and `getThreadHiddenCount` each already apply.
4201
+ trailingReplies,
3341
4202
  latestReply,
4203
+ // Only a gap target: the query fetches it one past the leading
4204
+ // window, and nothing renders it.
4205
+ firstHiddenReply,
3342
4206
  totalReplyCount: context.totalReplyCount,
3343
4207
  });
3344
4208
  }
@@ -3728,20 +4592,25 @@ export function createPostService(
3728
4592
  const result = new Map<string, Post[]>();
3729
4593
  if (rootIds.length === 0) return result;
3730
4594
 
3731
- const unique = [...new Set(rootIds)];
3732
- const conditions = [
3733
- eq(posts.siteId, siteId),
3734
- inArray(posts.threadId, unique),
3735
- eq(posts.status, "published"),
3736
- ];
3737
- if (options.publishedBefore !== undefined) {
3738
- conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
3739
- }
3740
- const rows = await db
3741
- .select()
3742
- .from(posts)
3743
- .where(and(...conditions))
3744
- .orderBy(posts.threadId, posts.createdAt, posts.id);
4595
+ // A feed loads every Thread it renders at once, which can be hundreds.
4596
+ // Each Thread lands in one chunk, so its posts keep their order.
4597
+ const rows = await batchQueryRows([...new Set(rootIds)], (chunk) => {
4598
+ const conditions = [
4599
+ eq(posts.siteId, siteId),
4600
+ inArray(posts.threadId, chunk),
4601
+ eq(posts.status, "published"),
4602
+ ];
4603
+ if (options.publishedBefore !== undefined) {
4604
+ conditions.push(
4605
+ sql`${posts.publishedAt} < ${options.publishedBefore}`,
4606
+ );
4607
+ }
4608
+ return db
4609
+ .select()
4610
+ .from(posts)
4611
+ .where(and(...conditions))
4612
+ .orderBy(posts.threadId, posts.createdAt, posts.id);
4613
+ });
3745
4614
 
3746
4615
  for (const post of await hydratePosts(rows)) {
3747
4616
  const thread = result.get(post.threadId);