@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. 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. */
@@ -233,7 +275,7 @@ export interface CollectionFeedEntry {
233
275
 
234
276
  export interface FeaturedThreadTimelinePost {
235
277
  post: Post;
236
- /** Zero-based position among the Thread's published Posts. */
278
+ /** Zero-based position among the Thread's published Posts; the root is 0. */
237
279
  position: number;
238
280
  }
239
281
 
@@ -267,10 +309,72 @@ 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;
316
+ }
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
+
347
+ /** A Post together with the custom URL that has taken over as its address. */
348
+ export interface PostWithCanonicalAlias {
349
+ post: Post;
350
+ /**
351
+ * The newest registered `alias`, which is the address a slug URL redirects
352
+ * to. Null when the Post still answers at its slug.
353
+ */
354
+ canonicalAlias: string | null;
270
355
  }
271
356
 
272
357
  export interface PostService {
273
358
  getById(id: string): Promise<Post | null>;
359
+ /**
360
+ * The Post a resolved path points at, plus its canonical address, in one
361
+ * read.
362
+ *
363
+ * Serving a Post URL needs three things the registry and the Post row hold
364
+ * between them: the row, the Post's slug, and whether a custom URL has taken
365
+ * over as its address. Asking for those separately put three round trips at
366
+ * the head of every Post page, so they come back together.
367
+ *
368
+ * @param postId - TypeID of the Post
369
+ * @returns The hydrated Post and its canonical alias, or null when no such
370
+ * Post exists or it has no slug
371
+ * @example
372
+ * ```ts
373
+ * const loaded = await posts.getWithCanonicalAlias(resolved.postId);
374
+ * if (loaded?.canonicalAlias) redirect(loaded.canonicalAlias);
375
+ * ```
376
+ */
377
+ getWithCanonicalAlias(postId: string): Promise<PostWithCanonicalAlias | null>;
274
378
  getBodyContent(id: string): Promise<PostBodyContent | null>;
275
379
  getBySlug(slug: string): Promise<Post | null>;
276
380
  suggestSlug(input: {
@@ -313,6 +417,38 @@ export interface PostService {
313
417
  count(filters?: PostFilters): Promise<number>;
314
418
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
315
419
  countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
420
+ /**
421
+ * Measure several filters at once, in a single round trip.
422
+ *
423
+ * One table scan with one `SUM(CASE …)` and one `MAX(CASE …)` per filter,
424
+ * rather than one query per filter. On Workers the round trip is the cost
425
+ * that matters, and a page that lists twenty smart collections would
426
+ * otherwise pay twenty of them — `Promise.all` only turns "slow in sequence"
427
+ * into "expensive in parallel".
428
+ *
429
+ * Every predicate is built by the same `buildFilterConditions` a single
430
+ * `count` uses, so the numbers here and the numbers on the pages they link to
431
+ * cannot drift.
432
+ *
433
+ * `recentActivityAt` is the newest activity among the matched rows, on the
434
+ * one definition {@link buildRootActivityExpr} holds for everything that
435
+ * orders or dates a Thread. It reads the matched row's own columns, so it
436
+ * describes a Thread only when `base` excludes replies — which is what every
437
+ * caller does, because a reply is not a member of anything.
438
+ *
439
+ * @param filters - One filter per measurement, in order
440
+ * @param base - Applied to every measurement: the site, the reader's
441
+ * visibility floor, and anything else common to all of them
442
+ * @returns Count and newest activity, positionally matching `filters`;
443
+ * `recentActivityAt` is `null` where nothing matched
444
+ * @example
445
+ * await posts.aggregateMany([{ format: "note" }, { format: "quote" }], base);
446
+ * // [{ count: 12, recentActivityAt: 1706100000 }, { count: 5, … }]
447
+ */
448
+ aggregateMany(
449
+ filters: readonly PostFilters[],
450
+ base: PostFilters,
451
+ ): Promise<PostFilterAggregate[]>;
316
452
  /** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
317
453
  countByYearMonth(
318
454
  filters?: PostFilters,
@@ -369,6 +505,10 @@ export interface PostService {
369
505
  * Used when replacing a saved thread draft with a new version.
370
506
  */
371
507
  deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
508
+ /**
509
+ * Every Post in a Thread, drafts included, in Thread order: the root first,
510
+ * then replies by creation time, then ID.
511
+ */
372
512
  getThread(rootId: string): Promise<Post[]>;
373
513
  /**
374
514
  * 1-based position of a Post in the reply chain running from its Thread root
@@ -390,6 +530,112 @@ export interface PostService {
390
530
  status: Status,
391
531
  visibility: Visibility,
392
532
  ): Promise<void>;
533
+ /**
534
+ * Set the content language of a whole Thread (root and every reply).
535
+ *
536
+ * Thread-wide by design: `post.language` is uniform inside a Thread, which is
537
+ * what lets every language filter stay a plain column predicate. Rejects a
538
+ * language another Post in the same translation group already holds.
539
+ *
540
+ * @param postId - Any Post in the Thread; the Thread is resolved from it
541
+ * @param language - Canonical BCP 47 tag
542
+ * @throws {NotFoundError} When no such Post exists on this site
543
+ * @throws {ConflictError} When the translation group already has that language
544
+ * @example
545
+ * await posts.setThreadLanguage(post.id, "zh-Hans");
546
+ */
547
+ setThreadLanguage(postId: string, language: string): Promise<void>;
548
+ /**
549
+ * Stamp every Post that has no language yet with `language`.
550
+ *
551
+ * Runs once when an author turns multilingual content on, and is idempotent
552
+ * so re-enabling later only touches Posts written while it was off. Scoped to
553
+ * this site — hosted deployments share one database.
554
+ *
555
+ * @param language - Canonical BCP 47 tag of the site's primary language
556
+ * @returns Number of Posts stamped
557
+ * @example
558
+ * await posts.materializeMissingLanguage("zh-Hans"); // => 347
559
+ */
560
+ materializeMissingLanguage(language: string): Promise<number>;
561
+ /** Count Posts on this site that still have no language. */
562
+ countMissingLanguage(): Promise<number>;
563
+ /** Count Posts on this site written in `language`, drafts included. */
564
+ countByLanguage(language: string): Promise<number>;
565
+ /**
566
+ * Every language stamped on this site's Posts, with how many carry it.
567
+ * Drafts included; the pre-multilingual NULL rows are not.
568
+ */
569
+ listLanguagesInUse(): Promise<Array<{ language: string; count: number }>>;
570
+ /**
571
+ * List the Thread roots that are translations of the given Post, excluding
572
+ * the Post itself. Empty when it belongs to no translation group.
573
+ */
574
+ listTranslations(postId: string): Promise<Post[]>;
575
+ /**
576
+ * List translations for many Thread roots in one round trip, keyed by the
577
+ * root ID that was asked about. Roots with no group are omitted.
578
+ */
579
+ getTranslationsMap(postIds: string[]): Promise<Map<string, Post[]>>;
580
+ /**
581
+ * Find published Thread roots this Post could be linked to as a translation.
582
+ *
583
+ * Filtered server-side rather than in the menu, because "can these two be
584
+ * linked" is a rule the service owns: a candidate has to be a Thread root in
585
+ * a language this Post's translation group does not already hold, and the two
586
+ * groups must not both exist — `linkTranslation` refuses to merge them.
587
+ * Offering a post that will be rejected teaches the author nothing.
588
+ *
589
+ * The match is a plain substring scan over title and body text. It is a
590
+ * deliberate, authenticated action over one author's own posts, so the cost
591
+ * is bounded and the shared search index (which cannot express these filters)
592
+ * is not worth involving.
593
+ *
594
+ * @param postId - Any Post in the Thread looking for a translation
595
+ * @param options - Search text and how many candidates to return
596
+ * @returns Matching Thread roots, newest first
597
+ * @example
598
+ * await posts.listTranslationCandidates(id, { query: "recipe" });
599
+ */
600
+ listTranslationCandidates(
601
+ postId: string,
602
+ options: { query: string; limit?: number },
603
+ ): Promise<Post[]>;
604
+ /**
605
+ * Resolve an address the author pasted into the Thread it names, and say
606
+ * whether this Post could be linked to it as a translation.
607
+ *
608
+ * The same rules as {@link PostService.listTranslationCandidates}, asked
609
+ * about one known Thread instead of searched for — so the answer can be a
610
+ * reason rather than an empty list. An address that lands on a reply names
611
+ * its Thread, because translations link whole Threads.
612
+ *
613
+ * @param postId - Any Post in the Thread looking for a translation
614
+ * @param path - Internal path, as produced by `toInternalPath()`
615
+ * @returns The Thread root to link, or why it cannot be
616
+ * @example
617
+ * await posts.resolveTranslationCandidate(id, "/hello-world");
618
+ * // { status: "unpublished" }
619
+ */
620
+ resolveTranslationCandidate(
621
+ postId: string,
622
+ path: string,
623
+ ): Promise<TranslationCandidateResolution>;
624
+ /**
625
+ * Link two already-published Thread roots as translations of each other.
626
+ *
627
+ * Joins the side without a group into the other's group, minting one when
628
+ * neither has any. Merging two existing groups is refused — that would
629
+ * silently restructure both sides.
630
+ *
631
+ * @throws {ConflictError} When the languages clash or both sides have groups
632
+ */
633
+ linkTranslation(postId: string, otherPostId: string): Promise<void>;
634
+ /**
635
+ * Remove a Thread root from its translation group. When that leaves a single
636
+ * Post behind, its group is cleared too so no one-member groups survive.
637
+ */
638
+ unlinkTranslation(postId: string): Promise<void>;
393
639
  /** Get reply counts for multiple posts */
394
640
  getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
395
641
  /** Get preview replies for multiple thread roots */
@@ -450,7 +696,10 @@ export interface PostService {
450
696
  collectionIds: string[],
451
697
  options?: CollectionFeedEntryOptions,
452
698
  ): Promise<CollectionFeedEntry[]>;
453
- /** Fetch published Posts for each requested Thread root. */
699
+ /**
700
+ * Fetch published Posts for each requested Thread root, in Thread order: the
701
+ * root first, then replies by creation time, then ID.
702
+ */
454
703
  getPublishedThreads(
455
704
  rootIds: string[],
456
705
  options?: Pick<PostFilters, "publishedBefore">,
@@ -458,7 +707,8 @@ export interface PostService {
458
707
  /** Get distinct years with posts, bucketed on the `sortBy` time axis */
459
708
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
460
709
  /**
461
- * For each Thread ID, resolve the Post that currently ends the chain.
710
+ * For each Thread ID, resolve the Post that currently ends the chain: the
711
+ * last in Thread order, so the root only while it has no replies.
462
712
  *
463
713
  * Two callers need two different answers. Readers ask what the audience can
464
714
  * see, so drafts must not count — the Reply affordance belongs on the last
@@ -544,6 +794,9 @@ export async function rebuildPostBodyHtmlWithRuntimeSettings(
544
794
 
545
795
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
546
796
 
797
+ const INVALID_POST_BODY_MESSAGE =
798
+ "The body isn't a TipTap JSON document. Send a doc node as a JSON string, or send Markdown as bodyMarkdown.";
799
+
547
800
  function isValidSlug(value: string): boolean {
548
801
  return SLUG_RE.test(value);
549
802
  }
@@ -619,6 +872,34 @@ function ensurePostRating(
619
872
  throw new ErrorCtor("Rating must be an integer between 1 and 5.");
620
873
  }
621
874
 
875
+ /**
876
+ * Normalize a caller-supplied content language to what the column stores.
877
+ *
878
+ * Empty, missing, and unparseable values all become `null` — the single-language
879
+ * state — so the column never holds a half-set value. Whether the tag is one
880
+ * the site actually offers is a settings-layer question, checked where the site
881
+ * language configuration is in scope.
882
+ *
883
+ * @param value - Raw BCP 47 tag from a request or import
884
+ * @returns Canonical tag (`zh-Hans`), or `null` when there is nothing to store
885
+ * @example
886
+ * normalizePostLanguage("ZH-hans"); // "zh-Hans"
887
+ * normalizePostLanguage(" "); // null
888
+ */
889
+ export function normalizePostLanguage(
890
+ value: string | null | undefined,
891
+ ): string | null {
892
+ if (typeof value !== "string") return null;
893
+ const trimmed = value.trim();
894
+ if (!trimmed) return null;
895
+ if (!isValidContentLanguage(trimmed)) {
896
+ throw new ValidationError(
897
+ "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
898
+ );
899
+ }
900
+ return normalizeContentLanguage(trimmed);
901
+ }
902
+
622
903
  function assertPostFormatShape(data: {
623
904
  format: Format;
624
905
  title?: string | null;
@@ -711,6 +992,33 @@ export function createPostService(
711
992
  return posts.publishedAt;
712
993
  }
713
994
 
995
+ /**
996
+ * Thread order: the root first, then replies by creation time, then ID.
997
+ *
998
+ * The root leads whatever its own `createdAt` says. A reply can be older
999
+ * than its root — a post moved into a Thread keeps its creation time, and an
1000
+ * export without `created` dates every post by publication — and ordering
1001
+ * on time alone would open such a Thread on a reply. Every surface that
1002
+ * reads the first post as the root would then lose it: the Thread page's
1003
+ * canonical URL, Featured, Collections, the feeds, and the reply guard's
1004
+ * idea of where the Thread ends. Every query that orders a Thread's posts
1005
+ * goes through here so they agree.
1006
+ *
1007
+ * @param direction - `desc` walks from the Thread's end back to its root
1008
+ * @returns ORDER BY terms, placed after any partitioning column
1009
+ * @example
1010
+ * db.select().from(posts).orderBy(posts.threadId, ...threadOrder());
1011
+ * sql`ROW_NUMBER() OVER (ORDER BY ${sql.join(threadOrder(), sql`, `)})`;
1012
+ */
1013
+ function threadOrder(direction: "asc" | "desc" = "asc"): SQL[] {
1014
+ const terms = [
1015
+ sql`CASE WHEN ${posts.replyToId} IS NULL THEN 0 ELSE 1 END`,
1016
+ sql`${posts.createdAt}`,
1017
+ sql`${posts.id}`,
1018
+ ];
1019
+ return direction === "asc" ? terms : terms.map((term) => desc(term));
1020
+ }
1021
+
714
1022
  function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
715
1023
  return databaseDialect === "pg"
716
1024
  ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
@@ -723,11 +1031,6 @@ export function createPostService(
723
1031
  : sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
724
1032
  }
725
1033
 
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
1034
  /** Check if a slug is available (not used by posts or path_registry) */
732
1035
  async function isSlugAvailable(slug: string): Promise<boolean> {
733
1036
  return resolvedPaths.isPathAvailable(slug);
@@ -934,22 +1237,71 @@ export function createPostService(
934
1237
  )`;
935
1238
  }
936
1239
 
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}`);
1240
+ /**
1241
+ * Load a Post and assert it can carry a translation group.
1242
+ *
1243
+ * Groups live on Thread roots only, and both sides must belong to this site.
1244
+ * These two rules replace the table CHECK the SQLite schema cannot add, so
1245
+ * every write path that touches `translation_group_id` goes through here.
1246
+ */
1247
+ async function requireTranslatableRoot(
1248
+ postId: string,
1249
+ ): Promise<typeof posts.$inferSelect> {
1250
+ const rows = await db
1251
+ .select()
1252
+ .from(posts)
1253
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
1254
+ .limit(1);
1255
+ const row = rows[0];
1256
+ if (!row) {
1257
+ throw new NotFoundError("Post");
946
1258
  }
947
- if (filters.excludeLatestHidden) {
948
- conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1259
+ if (row.threadId !== row.id) {
1260
+ throw new ValidationError(
1261
+ "Translations link whole threads. Use the thread's first post.",
1262
+ );
949
1263
  }
950
- if (filters.excludePrivate) {
951
- conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1264
+ return row;
1265
+ }
1266
+
1267
+ /** Reject a language another Post in the same translation group already holds. */
1268
+ async function assertTranslationLanguageFree(
1269
+ groupId: string,
1270
+ language: string,
1271
+ excludePostId?: string,
1272
+ ): Promise<void> {
1273
+ const rows = await db
1274
+ .select({ id: posts.id, title: posts.title })
1275
+ .from(posts)
1276
+ .where(
1277
+ and(
1278
+ eq(posts.siteId, siteId),
1279
+ eq(posts.translationGroupId, groupId),
1280
+ eq(posts.language, language),
1281
+ ),
1282
+ )
1283
+ .limit(2);
1284
+ const clash = rows.find((row) => row.id !== excludePostId);
1285
+ if (clash) {
1286
+ // The language's own name, not its tag: this message is shown to the
1287
+ // author, and "简体中文" is what they picked in the menu — "zh-Hans" is
1288
+ // an implementation detail they never chose.
1289
+ const languageName = getOrBuildEntry(language).native;
1290
+ throw new ConflictError(
1291
+ clash.title
1292
+ ? `"${clash.title}" is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`
1293
+ : `Another post is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`,
1294
+ );
952
1295
  }
1296
+ }
1297
+
1298
+ /** Build WHERE conditions from filters (shared by list and count) */
1299
+ function buildFilterConditions(filters: PostFilters) {
1300
+ const conditions = [
1301
+ eq(posts.siteId, siteId),
1302
+ ...buildReaderVisibilityConditions(posts, siteId, filters),
1303
+ ];
1304
+
953
1305
  if (filters.pinned !== undefined) {
954
1306
  conditions.push(
955
1307
  filters.pinned
@@ -979,6 +1331,9 @@ export function createPostService(
979
1331
  if (filters.threadId) {
980
1332
  conditions.push(eq(posts.threadId, filters.threadId));
981
1333
  }
1334
+ if (filters.ids !== undefined) {
1335
+ conditions.push(inArray(posts.id, filters.ids));
1336
+ }
982
1337
  if (filters.excludeReplies) {
983
1338
  conditions.push(isNull(posts.replyToId));
984
1339
  }
@@ -1150,80 +1505,61 @@ export function createPostService(
1150
1505
  ...keys,
1151
1506
  ];
1152
1507
 
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
-
1508
+ if (filters.featured) {
1167
1509
  return buildLexicographicCursorCondition(
1168
1510
  withPinnedSortKey([
1169
1511
  {
1170
1512
  direction: "desc",
1171
- expr: sortTimestampSortExpr,
1172
- value: cursorSortTimestamp,
1513
+ expr: featuredPublishedSortExpr,
1514
+ value: cursorFeaturedPublishedAt,
1173
1515
  },
1174
1516
  { direction: "desc", expr: posts.id, value: cursorPost.id },
1175
1517
  ]),
1176
1518
  );
1177
1519
  }
1178
1520
 
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
- );
1521
+ // One case per sort order, mirroring `list()` key for key.
1522
+ switch (filters.sortOrder ?? "newest") {
1523
+ case "oldest":
1524
+ return buildLexicographicCursorCondition(
1525
+ withPinnedSortKey([
1526
+ {
1527
+ direction: "asc",
1528
+ expr: sortTimestampSortExpr,
1529
+ value: cursorSortTimestamp,
1530
+ },
1531
+ { direction: "asc", expr: posts.id, value: cursorPost.id },
1532
+ ]),
1533
+ );
1534
+ case "rating_desc":
1535
+ return buildLexicographicCursorCondition(
1536
+ withPinnedSortKey([
1537
+ {
1538
+ direction: "desc",
1539
+ expr: ratingPresenceExpr,
1540
+ value: cursorRatingPresence,
1541
+ },
1542
+ { direction: "desc", expr: ratingSortExpr, value: cursorRating },
1543
+ {
1544
+ direction: "desc",
1545
+ expr: sortTimestampSortExpr,
1546
+ value: cursorSortTimestamp,
1547
+ },
1548
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1549
+ ]),
1550
+ );
1551
+ case "newest":
1552
+ return buildLexicographicCursorCondition(
1553
+ withPinnedSortKey([
1554
+ {
1555
+ direction: "desc",
1556
+ expr: sortTimestampSortExpr,
1557
+ value: cursorSortTimestamp,
1558
+ },
1559
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1560
+ ]),
1561
+ );
1209
1562
  }
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
1563
  }
1228
1564
 
1229
1565
  function toPost(
@@ -1258,6 +1594,8 @@ export function createPostService(
1258
1594
  previewProvider: row.previewProvider,
1259
1595
  replyToId: row.replyToId,
1260
1596
  threadId: row.threadId,
1597
+ language: row.language,
1598
+ translationGroupId: row.translationGroupId,
1261
1599
  quietReply: row.quietReply,
1262
1600
  publishedAt: row.publishedAt,
1263
1601
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
@@ -1271,14 +1609,80 @@ export function createPostService(
1271
1609
  };
1272
1610
  }
1273
1611
 
1612
+ /**
1613
+ * Thread visibility the loaded rows already answer, plus the thread ids they
1614
+ * do not.
1615
+ *
1616
+ * A reply's own `visibility` column is null — it inherits the root's — so a
1617
+ * row only speaks for its thread when it *is* the root. List surfaces query
1618
+ * thread roots exclusively (`excludeReplies`), so for them this covers every
1619
+ * thread and the follow-up read never runs.
1620
+ *
1621
+ * Entries are set only for a non-null visibility, matching
1622
+ * {@link getThreadVisibilityMap}: a root row with no visibility is treated as
1623
+ * unreadable by both paths.
1624
+ */
1625
+ function seedThreadVisibility(rows: (typeof posts.$inferSelect)[]): {
1626
+ known: Map<string, Visibility>;
1627
+ missingThreadIds: string[];
1628
+ } {
1629
+ const known = new Map<string, Visibility>();
1630
+ const covered = new Set<string>();
1631
+ for (const row of rows) {
1632
+ if (row.id !== row.threadId) continue;
1633
+ covered.add(row.threadId);
1634
+ if (row.visibility) {
1635
+ known.set(row.threadId, ensurePostVisibility(row.visibility, Error));
1636
+ }
1637
+ }
1638
+ const missingThreadIds = [
1639
+ ...new Set(
1640
+ rows
1641
+ .map((row) => row.threadId)
1642
+ .filter((threadId) => !covered.has(threadId)),
1643
+ ),
1644
+ ];
1645
+ return { known, missingThreadIds };
1646
+ }
1647
+
1648
+ /**
1649
+ * Slug lookup and root-visibility lookup are independent, so they go out
1650
+ * together rather than one after the other.
1651
+ */
1652
+ async function loadHydrationMaps(
1653
+ rows: (typeof posts.$inferSelect)[],
1654
+ ): Promise<{
1655
+ slugMap: Map<string, string>;
1656
+ visibilityMap: Map<string, Visibility>;
1657
+ }> {
1658
+ const { known, missingThreadIds } = seedThreadVisibility(rows);
1659
+ const [slugMap, fetched] = await Promise.all([
1660
+ resolvedPaths.getPostSlugMap(rows.map((row) => row.id)),
1661
+ missingThreadIds.length > 0
1662
+ ? getThreadVisibilityMap(missingThreadIds)
1663
+ : Promise.resolve(new Map<string, Visibility>()),
1664
+ ]);
1665
+ for (const [threadId, visibility] of fetched) {
1666
+ known.set(threadId, visibility);
1667
+ }
1668
+ return { slugMap, visibilityMap: known };
1669
+ }
1670
+
1274
1671
  async function hydratePost(
1275
1672
  row: typeof posts.$inferSelect | undefined,
1673
+ knownSlug?: string,
1276
1674
  ): Promise<Post | null> {
1277
1675
  if (!row) return null;
1278
- const slug = await resolvedPaths.getPostSlug(row.id);
1676
+ const { known, missingThreadIds } = seedThreadVisibility([row]);
1677
+ const [slug, fetched] = await Promise.all([
1678
+ knownSlug ?? resolvedPaths.getPostSlug(row.id),
1679
+ missingThreadIds.length > 0
1680
+ ? getThreadVisibilityMap(missingThreadIds)
1681
+ : Promise.resolve(new Map<string, Visibility>()),
1682
+ ]);
1279
1683
  if (!slug) return null;
1280
- const rootVisibilityMap = await getThreadVisibilityMap([row.threadId]);
1281
- const visibility = rootVisibilityMap.get(row.threadId) ?? row.visibility;
1684
+ const visibility =
1685
+ known.get(row.threadId) ?? fetched.get(row.threadId) ?? row.visibility;
1282
1686
  if (!visibility) return null;
1283
1687
  return toPost(row, slug, visibility);
1284
1688
  }
@@ -1287,17 +1691,11 @@ export function createPostService(
1287
1691
  rows: (typeof posts.$inferSelect)[],
1288
1692
  ): Promise<Post[]> {
1289
1693
  if (rows.length === 0) return [];
1290
- const slugMap = await resolvedPaths.getPostSlugMap(
1291
- rows.map((row) => row.id),
1292
- );
1293
- const rootVisibilityMap = await getThreadVisibilityMap(
1294
- rows.map((row) => row.threadId),
1295
- );
1694
+ const { slugMap, visibilityMap } = await loadHydrationMaps(rows);
1296
1695
  return rows
1297
1696
  .map((row) => {
1298
1697
  const slug = slugMap.get(row.id);
1299
- const visibility =
1300
- rootVisibilityMap.get(row.threadId) ?? row.visibility;
1698
+ const visibility = visibilityMap.get(row.threadId) ?? row.visibility;
1301
1699
  return slug && visibility ? toPost(row, slug, visibility) : null;
1302
1700
  })
1303
1701
  .filter((row): row is Post => row !== null);
@@ -1355,18 +1753,10 @@ export function createPostService(
1355
1753
  }
1356
1754
 
1357
1755
  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
- }
1756
+ const conditions: SQL[] = [
1757
+ eq(posts.siteId, siteId),
1758
+ ...buildReaderVisibilityConditions(posts, siteId, options ?? {}),
1759
+ ];
1370
1760
  if (options?.publishedBefore !== undefined) {
1371
1761
  conditions.push(
1372
1762
  sql`${posts.publishedAt} < ${options.publishedBefore}`,
@@ -1391,6 +1781,9 @@ export function createPostService(
1391
1781
  if (options?.hasRating) {
1392
1782
  conditions.push(isNotNull(posts.rating));
1393
1783
  }
1784
+ if (options?.threadIds !== undefined) {
1785
+ conditions.push(inArray(posts.threadId, options.threadIds));
1786
+ }
1394
1787
 
1395
1788
  return conditions;
1396
1789
  }
@@ -1617,6 +2010,45 @@ export function createPostService(
1617
2010
  return hydratePost(result[0]);
1618
2011
  },
1619
2012
 
2013
+ async getWithCanonicalAlias(postId) {
2014
+ const rows = await db
2015
+ .select({ post: posts, path: pathRegistry })
2016
+ .from(posts)
2017
+ .leftJoin(
2018
+ pathRegistry,
2019
+ and(
2020
+ eq(pathRegistry.siteId, siteId),
2021
+ eq(pathRegistry.postId, posts.id),
2022
+ ),
2023
+ )
2024
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
2025
+ .orderBy(asc(pathRegistry.createdAt), asc(pathRegistry.id));
2026
+
2027
+ const row = rows[0];
2028
+ if (!row) return null;
2029
+
2030
+ let slug: string | null = null;
2031
+ let canonicalAlias: string | null = null;
2032
+ for (const { path } of rows) {
2033
+ if (!path) continue;
2034
+ if (path.kind === "slug") {
2035
+ slug = path.path;
2036
+ continue;
2037
+ }
2038
+ // Rows arrive oldest first, so the last `alias` seen is the newest —
2039
+ // the one a slug URL redirects to. Ties within a second fall to the
2040
+ // higher id rather than to whatever the storage engine returned first.
2041
+ if (path.kind === "alias") canonicalAlias = `/${path.path}`;
2042
+ }
2043
+
2044
+ // A Post with no slug row has no address, which is what `hydratePost`
2045
+ // says by returning null for it.
2046
+ if (!slug) return null;
2047
+
2048
+ const post = await hydratePost(row.post, slug);
2049
+ return post ? { post, canonicalAlias } : null;
2050
+ },
2051
+
1620
2052
  async getBodyContent(id) {
1621
2053
  const post = await this.getById(id);
1622
2054
  if (!post) return null;
@@ -1636,7 +2068,14 @@ export function createPostService(
1636
2068
  if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
1637
2069
  return null;
1638
2070
  }
1639
- return this.getById(resolved.postId);
2071
+ const result = await db
2072
+ .select()
2073
+ .from(posts)
2074
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, resolved.postId)))
2075
+ .limit(1);
2076
+ // The registry row that answered is the slug, so hydration does not go
2077
+ // back for it.
2078
+ return hydratePost(result[0], resolved.path);
1640
2079
  },
1641
2080
 
1642
2081
  async suggestSlug(input) {
@@ -1698,36 +2137,39 @@ export function createPostService(
1698
2137
  .where(conditions.length > 0 ? and(...conditions) : undefined)
1699
2138
  .limit(filters.limit ?? 100);
1700
2139
 
1701
- let query =
1702
- filters.featured || filters.sortOrder === undefined
1703
- ? baseQuery.orderBy(
2140
+ // A case per sort order, and no shared fallthrough. This was an if/else
2141
+ // chain whose last branch was written for one order and silently caught
2142
+ // another, so `newest` ordered by rating instead of by time wherever it
2143
+ // was passed explicitly — a smart collection page, most visibly.
2144
+ const buildOrderBy = (): SQL<unknown>[] => {
2145
+ if (filters.featured) {
2146
+ return [
2147
+ ...pinnedOrder,
2148
+ desc(featuredPublishedSortExpr),
2149
+ desc(posts.id),
2150
+ ];
2151
+ }
2152
+ switch (filters.sortOrder ?? "newest") {
2153
+ case "oldest":
2154
+ return [...pinnedOrder, asc(sortTimestampSortExpr), asc(posts.id)];
2155
+ case "rating_desc":
2156
+ return [
1704
2157
  ...pinnedOrder,
1705
- filters.featured
1706
- ? desc(featuredPublishedSortExpr)
1707
- : desc(sortTimestampSortExpr),
2158
+ desc(ratingPresence),
2159
+ desc(posts.rating),
2160
+ desc(sortTimestampSortExpr),
1708
2161
  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
- );
2162
+ ];
2163
+ case "newest":
2164
+ return [
2165
+ ...pinnedOrder,
2166
+ desc(sortTimestampSortExpr),
2167
+ desc(posts.id),
2168
+ ];
2169
+ }
2170
+ };
2171
+
2172
+ let query = baseQuery.orderBy(...buildOrderBy());
1731
2173
 
1732
2174
  if (filters.offset !== undefined) {
1733
2175
  query = query.offset(filters.offset) as typeof query;
@@ -1774,6 +2216,8 @@ export function createPostService(
1774
2216
  id: posts.id,
1775
2217
  updatedAt: threadModifiedAt,
1776
2218
  featuredAt: posts.featuredAt,
2219
+ language: posts.language,
2220
+ translationGroupId: posts.translationGroupId,
1777
2221
  })
1778
2222
  .from(posts)
1779
2223
  .where(conditions.length > 0 ? and(...conditions) : undefined)
@@ -1799,6 +2243,8 @@ export function createPostService(
1799
2243
  alias,
1800
2244
  updatedAt: row.updatedAt,
1801
2245
  featuredAt: row.featuredAt,
2246
+ language: row.language,
2247
+ translationGroupId: row.translationGroupId,
1802
2248
  };
1803
2249
  })
1804
2250
  .filter((entry): entry is SitemapPostEntry => entry !== null);
@@ -1845,6 +2291,59 @@ export function createPostService(
1845
2291
  return result[0]?.count ?? 0;
1846
2292
  },
1847
2293
 
2294
+ async aggregateMany(filters, base) {
2295
+ if (filters.length === 0) return [];
2296
+
2297
+ const baseConditions = buildFilterConditions(base);
2298
+ // The row is its own Thread root here — callers exclude replies — so the
2299
+ // activity expression reads the columns in hand. No correlated subquery.
2300
+ const activityAt = buildRootActivityExpr({
2301
+ lastActivityAt: posts.lastActivityAt,
2302
+ publishedAt: posts.publishedAt,
2303
+ updatedAt: posts.updatedAt,
2304
+ });
2305
+ // A `SUM(CASE …)` and a `MAX(CASE …)` per filter, over one pass of the
2306
+ // rows the base predicate already narrows to. The aliases are positional
2307
+ // and generated here, so nothing a caller supplies reaches the SQL as an
2308
+ // identifier.
2309
+ const columns = Object.fromEntries(
2310
+ filters.flatMap((filter, index) => {
2311
+ const conditions = buildFilterConditions({ ...base, ...filter });
2312
+ const predicate =
2313
+ conditions.length > 0 ? and(...conditions) : sql`1 = 1`;
2314
+ return [
2315
+ [
2316
+ `n${index}`,
2317
+ sql<number>`CAST(SUM(CASE WHEN (${predicate}) THEN 1 ELSE 0 END) AS INTEGER)`,
2318
+ ],
2319
+ [
2320
+ `a${index}`,
2321
+ sql<
2322
+ number | null
2323
+ >`MAX(CASE WHEN (${predicate}) THEN ${activityAt} END)`,
2324
+ ],
2325
+ ];
2326
+ }),
2327
+ );
2328
+
2329
+ const rows = await db
2330
+ .select(columns)
2331
+ .from(posts)
2332
+ .where(baseConditions.length > 0 ? and(...baseConditions) : undefined);
2333
+
2334
+ const row = rows[0] as Record<string, number | null> | undefined;
2335
+ return filters.map((_, index) => {
2336
+ const activity = row?.[`a${index}`];
2337
+ return {
2338
+ count: Number(row?.[`n${index}`] ?? 0),
2339
+ recentActivityAt:
2340
+ activity === null || activity === undefined
2341
+ ? null
2342
+ : Number(activity),
2343
+ };
2344
+ });
2345
+ },
2346
+
1848
2347
  async countUpTo(filters = {}, limit) {
1849
2348
  const normalizedLimit = Math.max(0, Math.trunc(limit));
1850
2349
  if (normalizedLimit === 0) {
@@ -1898,6 +2397,9 @@ export function createPostService(
1898
2397
  const preparedBody = trimmedBody
1899
2398
  ? tryPreparePostBodyHtml(id, trimmedBody)
1900
2399
  : null;
2400
+ if (preparedBody && !preparedBody.ok) {
2401
+ throw new ValidationError(INVALID_POST_BODY_MESSAGE);
2402
+ }
1901
2403
  const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
1902
2404
  const title = data.title?.trim() || null;
1903
2405
  const quoteText = data.quoteText?.trim() || null;
@@ -1937,6 +2439,11 @@ export function createPostService(
1937
2439
  let threadId = id;
1938
2440
  let status: Status = requestedStatus ?? "published";
1939
2441
  let visibility: Visibility | null = requestedVisibility ?? "public";
2442
+ // Roots carry the requested language; replies overwrite this with the
2443
+ // root's value below so a Thread never mixes languages.
2444
+ let language = normalizePostLanguage(data.language);
2445
+ let translationGroupId: string | null = null;
2446
+ let sourceTranslationPostId: string | null = null;
1940
2447
 
1941
2448
  // Collapse the two-input (`flag` + `flagAt`) pair the DTO exposes
1942
2449
  // into the single timestamp form the DB column stores. Explicit
@@ -2001,6 +2508,10 @@ export function createPostService(
2001
2508
  status = root.status;
2002
2509
  }
2003
2510
  visibility = null;
2511
+ // A reply is part of the root's Thread, so it is written in the
2512
+ // Thread's language whatever the caller asked for. This is what keeps
2513
+ // language filters correct on member-grained queries.
2514
+ language = root?.language ?? parent.language ?? null;
2004
2515
 
2005
2516
  if (
2006
2517
  (data.collectionIds?.length ?? 0) > 0 ||
@@ -2012,6 +2523,54 @@ export function createPostService(
2012
2523
  }
2013
2524
  }
2014
2525
 
2526
+ const { primary, languages, reservedPrefixes } =
2527
+ await readLanguageSettings(db, siteId, databaseSchema);
2528
+
2529
+ // Nobody named a language, so read one out of the text. Every write path
2530
+ // that bypasses compose — the API, the Telegram bot, MCP — lands here, so
2531
+ // a multilingual site never accumulates unlabelled posts. An explicit
2532
+ // choice is the author's and is left alone; replies already carry the
2533
+ // Thread's language.
2534
+ if (!data.replyToId && !language) {
2535
+ language = suggestPostLanguage({
2536
+ text: bodyText ?? title ?? quoteText,
2537
+ languages,
2538
+ primary,
2539
+ });
2540
+ }
2541
+
2542
+ // "Add a translation" carries only the source ID until the author
2543
+ // submits; the group is minted here so abandoning the composer leaves
2544
+ // nothing behind (no draft row, no slug, no single-member group).
2545
+ if (data.translationOfId) {
2546
+ if (data.replyToId) {
2547
+ throw new ValidationError(
2548
+ "Translations link whole threads. Add the translation from the thread's first post.",
2549
+ );
2550
+ }
2551
+ const source = await requireTranslatableRoot(data.translationOfId);
2552
+ if (!language) {
2553
+ throw new ValidationError(
2554
+ "Choose a language for this translation before saving it.",
2555
+ );
2556
+ }
2557
+ if (source.language === language) {
2558
+ throw new ConflictError(
2559
+ "That post is already written in this language. Pick a different language for the translation.",
2560
+ );
2561
+ }
2562
+ if (source.translationGroupId) {
2563
+ await assertTranslationLanguageFree(
2564
+ source.translationGroupId,
2565
+ language,
2566
+ );
2567
+ translationGroupId = source.translationGroupId;
2568
+ } else {
2569
+ translationGroupId = createEntityId("translationGroup");
2570
+ sourceTranslationPostId = source.id;
2571
+ }
2572
+ }
2573
+
2015
2574
  // Featured surfaces only list published posts, so featuring a draft
2016
2575
  // writes a flag that does nothing and shows nowhere. Say so instead.
2017
2576
  if (status === "draft" && resolvedFeaturedAt !== null) {
@@ -2021,6 +2580,8 @@ export function createPostService(
2021
2580
  assertDraftPublishedAt(status, data.publishedAt);
2022
2581
  const publishedAt =
2023
2582
  status === "published" ? (data.publishedAt ?? timestamp) : null;
2583
+ const createdAt = data.createdAt ?? timestamp;
2584
+ const updatedAt = data.updatedAt ?? createdAt;
2024
2585
 
2025
2586
  // Resolve slug from slug, path, or title
2026
2587
  let slug: string;
@@ -2033,10 +2594,17 @@ export function createPostService(
2033
2594
  // readable slug can still set one explicitly.
2034
2595
  const titleForSlug =
2035
2596
  format === "quote" ? undefined : (title ?? undefined);
2597
+ // Only a non-primary language earns a readable language suffix: the bare
2598
+ // namespace belongs to the primary language, and a suffix shared by two
2599
+ // same-language posts distinguishes nothing.
2600
+ const languageSuffix =
2601
+ language && language !== primary
2602
+ ? toLanguagePrefix(language)
2603
+ : undefined;
2036
2604
 
2037
2605
  if (data.path) {
2038
2606
  const normalized = normalizePath(data.path);
2039
- if (isReservedPath(normalized)) {
2607
+ if (isReservedPath(normalized, reservedPrefixes)) {
2040
2608
  throw new ValidationError(
2041
2609
  `Path "${normalized}" is reserved and cannot be used`,
2042
2610
  );
@@ -2046,6 +2614,7 @@ export function createPostService(
2046
2614
  slug = await generatePostSlug({
2047
2615
  slug: normalized,
2048
2616
  idLength: config.slugIdLength,
2617
+ reservedPrefixes,
2049
2618
  isAvailable: isSlugAvailable,
2050
2619
  });
2051
2620
  } else {
@@ -2055,6 +2624,8 @@ export function createPostService(
2055
2624
  slug: slugified || undefined,
2056
2625
  title: titleForSlug,
2057
2626
  idLength: config.slugIdLength,
2627
+ languageSuffix,
2628
+ reservedPrefixes,
2058
2629
  isAvailable: isSlugAvailable,
2059
2630
  });
2060
2631
  // Verify the alias path is available before proceeding
@@ -2068,6 +2639,8 @@ export function createPostService(
2068
2639
  slug: data.slug,
2069
2640
  title: titleForSlug,
2070
2641
  idLength: config.slugIdLength,
2642
+ languageSuffix,
2643
+ reservedPrefixes,
2071
2644
  isAvailable: isSlugAvailable,
2072
2645
  });
2073
2646
  }
@@ -2127,6 +2700,23 @@ export function createPostService(
2127
2700
  if (usesBatchWrites) {
2128
2701
  const writeQueries = [];
2129
2702
 
2703
+ // Mint the group on the source Post in the same write as the
2704
+ // translation itself, so a failure never leaves a one-member group.
2705
+ if (sourceTranslationPostId && translationGroupId) {
2706
+ writeQueries.push(
2707
+ db
2708
+ .update(posts)
2709
+ .set({ translationGroupId, updatedAt: timestamp })
2710
+ .where(
2711
+ and(
2712
+ eq(posts.siteId, siteId),
2713
+ eq(posts.id, sourceTranslationPostId),
2714
+ isNull(posts.translationGroupId),
2715
+ ),
2716
+ ),
2717
+ );
2718
+ }
2719
+
2130
2720
  writeQueries.push(
2131
2721
  db.insert(posts).values({
2132
2722
  id,
@@ -2147,12 +2737,14 @@ export function createPostService(
2147
2737
  rating,
2148
2738
  replyToId: data.replyToId ?? null,
2149
2739
  threadId,
2740
+ language,
2741
+ translationGroupId,
2150
2742
  quietReply: isQuietReply,
2151
2743
  publishedAt,
2152
- lastActivityAt: publishedAt ?? timestamp,
2153
- threadUpdatedAt: publishedAt ?? timestamp,
2154
- createdAt: timestamp,
2155
- updatedAt: timestamp,
2744
+ lastActivityAt: publishedAt ?? updatedAt,
2745
+ threadUpdatedAt: publishedAt ?? updatedAt,
2746
+ createdAt,
2747
+ updatedAt,
2156
2748
  }),
2157
2749
  );
2158
2750
 
@@ -2207,6 +2799,19 @@ export function createPostService(
2207
2799
  );
2208
2800
  } else {
2209
2801
  await db.transaction(async (tx) => {
2802
+ if (sourceTranslationPostId && translationGroupId) {
2803
+ await tx
2804
+ .update(posts)
2805
+ .set({ translationGroupId, updatedAt: timestamp })
2806
+ .where(
2807
+ and(
2808
+ eq(posts.siteId, siteId),
2809
+ eq(posts.id, sourceTranslationPostId),
2810
+ isNull(posts.translationGroupId),
2811
+ ),
2812
+ );
2813
+ }
2814
+
2210
2815
  await tx.insert(posts).values({
2211
2816
  id,
2212
2817
  siteId,
@@ -2226,12 +2831,14 @@ export function createPostService(
2226
2831
  rating,
2227
2832
  replyToId: data.replyToId ?? null,
2228
2833
  threadId,
2834
+ language,
2835
+ translationGroupId,
2229
2836
  quietReply: isQuietReply,
2230
2837
  publishedAt,
2231
- lastActivityAt: publishedAt ?? timestamp,
2232
- threadUpdatedAt: publishedAt ?? timestamp,
2233
- createdAt: timestamp,
2234
- updatedAt: timestamp,
2838
+ lastActivityAt: publishedAt ?? updatedAt,
2839
+ threadUpdatedAt: publishedAt ?? updatedAt,
2840
+ createdAt,
2841
+ updatedAt,
2235
2842
  });
2236
2843
 
2237
2844
  await tx.insert(pathRegistry).values({
@@ -2358,13 +2965,16 @@ export function createPostService(
2358
2965
  // A reply with no date of its own belongs to the root's moment, not to
2359
2966
  // whenever the request happened to run. Without this, backdating the
2360
2967
  // root leaves its replies stamped today and the thread reads as if it
2361
- // spanned years. Thread order is by `createdAt`/`id` (see the thread
2362
- // queries), so sharing one `publishedAt` cannot reorder anything.
2968
+ // spanned years. Thread order is the root, then `createdAt`/`id` (see
2969
+ // `threadOrder`), so sharing one `publishedAt` cannot reorder anything.
2363
2970
  const rootPublishedAt = created[0]?.publishedAt ?? undefined;
2364
2971
  const postData: CreatePost = {
2365
2972
  ...data,
2366
2973
  // Chain each post as a reply to the previous one (server-side chaining)
2367
2974
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2975
+ // Translation groups and languages are Thread-level: the root carries
2976
+ // them and every reply inherits, so later items must not restate them.
2977
+ translationOfId: i === 0 ? data.translationOfId : undefined,
2368
2978
  quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
2369
2979
  publishedAt:
2370
2980
  i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
@@ -2397,6 +3007,17 @@ export function createPostService(
2397
3007
  const existing = await this.getById(id);
2398
3008
  if (!existing) return null;
2399
3009
 
3010
+ // Thread-wide, so it cannot ride along on the single-row write below.
3011
+ // Runs first: if the language is refused — the Thread's translation group
3012
+ // already holds it — nothing else should have been written either.
3013
+ if (
3014
+ data.language &&
3015
+ !existing.replyToId &&
3016
+ data.language !== existing.language
3017
+ ) {
3018
+ await this.setThreadLanguage(id, data.language);
3019
+ }
3020
+
2400
3021
  const timestamp = now();
2401
3022
  const nextFormat =
2402
3023
  data.format !== undefined
@@ -2505,6 +3126,9 @@ export function createPostService(
2505
3126
  const preparedBody = normalizedBody
2506
3127
  ? tryPreparePostBodyHtml(existing.id, normalizedBody)
2507
3128
  : null;
3129
+ if (preparedBody && !preparedBody.ok) {
3130
+ throw new ValidationError(INVALID_POST_BODY_MESSAGE);
3131
+ }
2508
3132
  updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
2509
3133
  updates.body = updatedBody;
2510
3134
  updates.bodyHtml = preparedBody?.ok
@@ -2761,8 +3385,7 @@ export function createPostService(
2761
3385
  ],
2762
3386
  );
2763
3387
  updateResult = results[updateIdx] as
2764
- | (typeof posts.$inferSelect)[]
2765
- | undefined;
3388
+ (typeof posts.$inferSelect)[] | undefined;
2766
3389
  } else {
2767
3390
  await db.transaction(async (tx) => {
2768
3391
  if (needsCascade) {
@@ -3044,7 +3667,7 @@ export function createPostService(
3044
3667
  .select()
3045
3668
  .from(posts)
3046
3669
  .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
3047
- .orderBy(posts.createdAt, posts.id);
3670
+ .orderBy(...threadOrder());
3048
3671
 
3049
3672
  return hydratePosts(rows);
3050
3673
  },
@@ -3154,6 +3777,397 @@ export function createPostService(
3154
3777
  await recalculateThreadActivity(rootId);
3155
3778
  },
3156
3779
 
3780
+ async setThreadLanguage(postId, language) {
3781
+ const normalized = normalizePostLanguage(language);
3782
+ if (!normalized) {
3783
+ throw new ValidationError("Choose a language for this post.");
3784
+ }
3785
+
3786
+ // A language the site does not publish has no view, no feed and no
3787
+ // switcher entry, so the post would simply vanish. Only checked once the
3788
+ // site actually has a language set to belong to.
3789
+ const { languages } = await readLanguageSettings(
3790
+ db,
3791
+ siteId,
3792
+ databaseSchema,
3793
+ );
3794
+ if (
3795
+ languages.length > 0 &&
3796
+ !languages.some(
3797
+ (tag) => toLanguagePrefix(tag) === toLanguagePrefix(normalized),
3798
+ )
3799
+ ) {
3800
+ throw new ValidationError(
3801
+ "This site does not publish that language. Add it in Settings → Language first.",
3802
+ );
3803
+ }
3804
+
3805
+ const rows = await db
3806
+ .select({
3807
+ threadId: posts.threadId,
3808
+ id: posts.id,
3809
+ translationGroupId: posts.translationGroupId,
3810
+ })
3811
+ .from(posts)
3812
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3813
+ .limit(1);
3814
+ const row = rows[0];
3815
+ if (!row) {
3816
+ throw new NotFoundError("Post");
3817
+ }
3818
+
3819
+ // The group lives on the root, so a reply's Thread has to be resolved
3820
+ // before the translation-group check can run.
3821
+ const rootRows =
3822
+ row.id === row.threadId
3823
+ ? [row]
3824
+ : await db
3825
+ .select({
3826
+ threadId: posts.threadId,
3827
+ id: posts.id,
3828
+ translationGroupId: posts.translationGroupId,
3829
+ })
3830
+ .from(posts)
3831
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, row.threadId)))
3832
+ .limit(1);
3833
+ const root = rootRows[0];
3834
+ if (root?.translationGroupId) {
3835
+ await assertTranslationLanguageFree(
3836
+ root.translationGroupId,
3837
+ normalized,
3838
+ root.id,
3839
+ );
3840
+ }
3841
+
3842
+ const timestamp = now();
3843
+ await db
3844
+ .update(posts)
3845
+ .set({ language: normalized, updatedAt: timestamp })
3846
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, row.threadId)));
3847
+ },
3848
+
3849
+ async materializeMissingLanguage(language) {
3850
+ const normalized = normalizePostLanguage(language);
3851
+ if (!normalized) {
3852
+ throw new ValidationError("Choose a primary language for this site.");
3853
+ }
3854
+
3855
+ // Counted before the write rather than read back from it: the three
3856
+ // supported drivers report affected rows differently, and the caller only
3857
+ // needs the number for its confirmation copy.
3858
+ const pending = await this.countMissingLanguage();
3859
+ if (pending === 0) return 0;
3860
+
3861
+ await db
3862
+ .update(posts)
3863
+ .set({ language: normalized })
3864
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3865
+ return pending;
3866
+ },
3867
+
3868
+ async countMissingLanguage() {
3869
+ const rows = await db
3870
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3871
+ .from(posts)
3872
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3873
+ return Number(rows[0]?.count ?? 0);
3874
+ },
3875
+
3876
+ async countByLanguage(language) {
3877
+ const normalized = normalizePostLanguage(language);
3878
+ if (!normalized) return 0;
3879
+
3880
+ const rows = await db
3881
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3882
+ .from(posts)
3883
+ .where(and(eq(posts.siteId, siteId), eq(posts.language, normalized)));
3884
+ return Number(rows[0]?.count ?? 0);
3885
+ },
3886
+
3887
+ async listLanguagesInUse() {
3888
+ const rows = await db
3889
+ .select({
3890
+ language: posts.language,
3891
+ count: sql<number>`CAST(count(*) AS INTEGER)`,
3892
+ })
3893
+ .from(posts)
3894
+ .where(and(eq(posts.siteId, siteId), isNotNull(posts.language)))
3895
+ .groupBy(posts.language);
3896
+ return rows
3897
+ .filter((row): row is { language: string; count: number } =>
3898
+ Boolean(row.language),
3899
+ )
3900
+ .map((row) => ({ language: row.language, count: Number(row.count) }));
3901
+ },
3902
+
3903
+ async listTranslations(postId) {
3904
+ const map = await this.getTranslationsMap([postId]);
3905
+ return map.get(postId) ?? [];
3906
+ },
3907
+
3908
+ async listTranslationCandidates(postId, options) {
3909
+ const term = options.query.trim();
3910
+ if (!term) return [];
3911
+ const limit = options.limit ?? 8;
3912
+
3913
+ const source = await this.getById(postId);
3914
+ if (!source) return [];
3915
+ const root =
3916
+ source.replyToId === null
3917
+ ? source
3918
+ : await this.getById(source.threadId);
3919
+ if (!root?.language) return [];
3920
+
3921
+ // Languages this Thread's group already speaks for, its own included.
3922
+ const taken = new Set([root.language]);
3923
+ if (root.translationGroupId) {
3924
+ for (const sibling of await this.listTranslations(root.id)) {
3925
+ if (sibling.language) taken.add(sibling.language);
3926
+ }
3927
+ }
3928
+
3929
+ // Escaped with an explicit ESCAPE clause: the two dialects disagree on
3930
+ // the default escape character (see `paths.findPathsUnderSegment`).
3931
+ const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
3932
+ const conditions: SQL[] = [
3933
+ eq(posts.siteId, siteId),
3934
+ eq(posts.status, "published"),
3935
+ // Thread roots only: a translation links whole Threads.
3936
+ sql`${posts.id} = ${posts.threadId}`,
3937
+ sql`${posts.id} != ${root.id}`,
3938
+ isNotNull(posts.language),
3939
+ notInArray(posts.language, [...taken]),
3940
+ sql`(${posts.title} LIKE ${like} ESCAPE '\\' OR ${posts.bodyText} LIKE ${like} ESCAPE '\\')`,
3941
+ ];
3942
+
3943
+ if (root.translationGroupId) {
3944
+ // Both sides having a group would ask for a merge, which is refused.
3945
+ conditions.push(isNull(posts.translationGroupId));
3946
+ } else {
3947
+ // The candidate may carry a group, as long as joining it would not put
3948
+ // two Posts of this language in it.
3949
+ conditions.push(
3950
+ sql`(${posts.translationGroupId} IS NULL OR NOT EXISTS (
3951
+ SELECT 1
3952
+ FROM post AS group_member
3953
+ WHERE group_member.site_id = ${siteId}
3954
+ AND group_member.translation_group_id = "post"."translation_group_id"
3955
+ AND group_member.language = ${root.language}
3956
+ ))`,
3957
+ );
3958
+ }
3959
+
3960
+ const rows = await db
3961
+ .select()
3962
+ .from(posts)
3963
+ .where(and(...conditions))
3964
+ .orderBy(desc(posts.publishedAt))
3965
+ .limit(limit);
3966
+
3967
+ return hydratePosts(rows);
3968
+ },
3969
+
3970
+ async resolveTranslationCandidate(postId, path) {
3971
+ const source = await this.getById(postId);
3972
+ if (!source) return { status: "not_found" };
3973
+ const root =
3974
+ source.replyToId === null
3975
+ ? source
3976
+ : await this.getById(source.threadId);
3977
+ if (!root?.language) return { status: "not_found" };
3978
+
3979
+ const target = await resolvedPaths.resolveTarget(path);
3980
+ if (!target) return { status: "not_found" };
3981
+ if (target.targetType !== "post" || !target.postId) {
3982
+ return { status: "not_a_post" };
3983
+ }
3984
+
3985
+ const found = await this.getById(target.postId);
3986
+ if (!found) return { status: "not_found" };
3987
+ // Translations link whole Threads, so an address that lands on a reply
3988
+ // is taken as naming the Thread it belongs to.
3989
+ const candidate =
3990
+ found.replyToId === null ? found : await this.getById(found.threadId);
3991
+ if (!candidate) return { status: "not_found" };
3992
+
3993
+ if (candidate.id === root.id) return { status: "same_thread" };
3994
+ if (candidate.status !== "published") return { status: "unpublished" };
3995
+ if (!candidate.language) return { status: "no_language" };
3996
+ if (candidate.language === root.language) {
3997
+ return { status: "same_language" };
3998
+ }
3999
+
4000
+ const siblings = root.translationGroupId
4001
+ ? await this.listTranslations(root.id)
4002
+ : [];
4003
+ if (siblings.some((post) => post.language === candidate.language)) {
4004
+ return { status: "language_taken", language: candidate.language };
4005
+ }
4006
+
4007
+ if (candidate.translationGroupId) {
4008
+ // `linkTranslation` refuses to merge two groups, and refuses to join
4009
+ // one that already speaks for this Thread's language.
4010
+ if (root.translationGroupId) return { status: "group_conflict" };
4011
+
4012
+ const theirGroup = await this.listTranslations(candidate.id);
4013
+ if (theirGroup.some((post) => post.language === root.language)) {
4014
+ return { status: "group_language_taken", language: root.language };
4015
+ }
4016
+ }
4017
+
4018
+ return { status: "ok", post: candidate };
4019
+ },
4020
+
4021
+ async getTranslationsMap(postIds) {
4022
+ const result = new Map<string, Post[]>();
4023
+ const uniqueIds = [...new Set(postIds)].filter(Boolean);
4024
+ if (uniqueIds.length === 0) return result;
4025
+
4026
+ const seedRows = await db
4027
+ .select({ id: posts.id, translationGroupId: posts.translationGroupId })
4028
+ .from(posts)
4029
+ .where(
4030
+ and(
4031
+ eq(posts.siteId, siteId),
4032
+ inArray(posts.id, uniqueIds),
4033
+ isNotNull(posts.translationGroupId),
4034
+ ),
4035
+ );
4036
+ if (seedRows.length === 0) return result;
4037
+
4038
+ const groupIds = [
4039
+ ...new Set(
4040
+ seedRows
4041
+ .map((row) => row.translationGroupId)
4042
+ .filter((groupId): groupId is string => groupId !== null),
4043
+ ),
4044
+ ];
4045
+ const memberRows = await db
4046
+ .select()
4047
+ .from(posts)
4048
+ .where(
4049
+ and(
4050
+ eq(posts.siteId, siteId),
4051
+ inArray(posts.translationGroupId, groupIds),
4052
+ ),
4053
+ );
4054
+ const members = await hydratePosts(memberRows);
4055
+ const byGroup = new Map<string, Post[]>();
4056
+ for (const member of members) {
4057
+ const groupId = member.translationGroupId;
4058
+ if (!groupId) continue;
4059
+ const bucket = byGroup.get(groupId);
4060
+ if (bucket) bucket.push(member);
4061
+ else byGroup.set(groupId, [member]);
4062
+ }
4063
+
4064
+ for (const seed of seedRows) {
4065
+ const groupId = seed.translationGroupId;
4066
+ if (!groupId) continue;
4067
+ const siblings = (byGroup.get(groupId) ?? []).filter(
4068
+ (member) => member.id !== seed.id,
4069
+ );
4070
+ if (siblings.length > 0) result.set(seed.id, siblings);
4071
+ }
4072
+ return result;
4073
+ },
4074
+
4075
+ async linkTranslation(postId, otherPostId) {
4076
+ if (postId === otherPostId) {
4077
+ throw new ValidationError("Pick a different post to link.");
4078
+ }
4079
+
4080
+ const [post, other] = await Promise.all([
4081
+ requireTranslatableRoot(postId),
4082
+ requireTranslatableRoot(otherPostId),
4083
+ ]);
4084
+
4085
+ if (!post.language || !other.language) {
4086
+ throw new ValidationError(
4087
+ "Both posts need a language before they can be linked as translations.",
4088
+ );
4089
+ }
4090
+ if (post.language === other.language) {
4091
+ throw new ConflictError(
4092
+ "These two posts are written in the same language. Translations need different languages.",
4093
+ );
4094
+ }
4095
+ if (post.translationGroupId && other.translationGroupId) {
4096
+ if (post.translationGroupId === other.translationGroupId) return;
4097
+ throw new ConflictError(
4098
+ "Both posts already belong to translation groups. Unlink one of them first.",
4099
+ );
4100
+ }
4101
+
4102
+ const timestamp = now();
4103
+ if (post.translationGroupId ?? other.translationGroupId) {
4104
+ const groupId = (post.translationGroupId ??
4105
+ other.translationGroupId) as string;
4106
+ const joiningId = post.translationGroupId ? other.id : post.id;
4107
+ const joiningLanguage = post.translationGroupId
4108
+ ? other.language
4109
+ : post.language;
4110
+ await assertTranslationLanguageFree(groupId, joiningLanguage);
4111
+ await db
4112
+ .update(posts)
4113
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
4114
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, joiningId)));
4115
+ return;
4116
+ }
4117
+
4118
+ const groupId = createEntityId("translationGroup");
4119
+ const setGroup = (targetId: string) =>
4120
+ db
4121
+ .update(posts)
4122
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
4123
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, targetId)));
4124
+
4125
+ if (usesBatchWrites) {
4126
+ await db.batch([setGroup(post.id), setGroup(other.id)]);
4127
+ } else {
4128
+ await db.transaction(async (tx) => {
4129
+ await tx
4130
+ .update(posts)
4131
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
4132
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
4133
+ await tx
4134
+ .update(posts)
4135
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
4136
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, other.id)));
4137
+ });
4138
+ }
4139
+ },
4140
+
4141
+ async unlinkTranslation(postId) {
4142
+ const post = await requireTranslatableRoot(postId);
4143
+ const groupId = post.translationGroupId;
4144
+ if (!groupId) return;
4145
+
4146
+ const timestamp = now();
4147
+ await db
4148
+ .update(posts)
4149
+ .set({ translationGroupId: null, updatedAt: timestamp })
4150
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
4151
+
4152
+ // A group of one is indistinguishable from no group and would linger as
4153
+ // a dangling key, so collapse it as soon as the second-to-last member
4154
+ // leaves.
4155
+ const remaining = await db
4156
+ .select({ id: posts.id })
4157
+ .from(posts)
4158
+ .where(
4159
+ and(eq(posts.siteId, siteId), eq(posts.translationGroupId, groupId)),
4160
+ )
4161
+ .limit(2);
4162
+ const lastMember = remaining.length === 1 ? remaining[0] : undefined;
4163
+ if (lastMember) {
4164
+ await db
4165
+ .update(posts)
4166
+ .set({ translationGroupId: null, updatedAt: timestamp })
4167
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, lastMember.id)));
4168
+ }
4169
+ },
4170
+
3157
4171
  async getReplyCounts(postIds) {
3158
4172
  if (postIds.length === 0) return new Map();
3159
4173
 
@@ -3187,10 +4201,9 @@ export function createPostService(
3187
4201
  .select({
3188
4202
  id: posts.id,
3189
4203
  threadId: posts.threadId,
3190
- createdAt: posts.createdAt,
3191
4204
  previewRank: sql<number>`CAST(ROW_NUMBER() OVER (
3192
4205
  PARTITION BY ${posts.threadId}
3193
- ORDER BY ${posts.createdAt}, ${posts.id}
4206
+ ORDER BY ${sql.join(threadOrder(), sql`, `)}
3194
4207
  ) AS INTEGER)`.as("preview_rank"),
3195
4208
  })
3196
4209
  .from(posts)
@@ -3208,15 +4221,10 @@ export function createPostService(
3208
4221
  .select({
3209
4222
  id: rankedReplies.id,
3210
4223
  threadId: rankedReplies.threadId,
3211
- createdAt: rankedReplies.createdAt,
3212
4224
  })
3213
4225
  .from(rankedReplies)
3214
4226
  .where(lte(rankedReplies.previewRank, previewCount))
3215
- .orderBy(
3216
- rankedReplies.threadId,
3217
- rankedReplies.createdAt,
3218
- rankedReplies.id,
3219
- );
4227
+ .orderBy(rankedReplies.threadId, sql`${rankedReplies.previewRank}`);
3220
4228
 
3221
4229
  const hydratedPosts = await hydratePostsById(
3222
4230
  rankedRows.map((row) => row.id),
@@ -3246,11 +4254,11 @@ export function createPostService(
3246
4254
  threadId: posts.threadId,
3247
4255
  firstReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
3248
4256
  PARTITION BY ${posts.threadId}
3249
- ORDER BY ${posts.createdAt}, ${posts.id}
4257
+ ORDER BY ${sql.join(threadOrder(), sql`, `)}
3250
4258
  ) AS INTEGER)`.as("first_reply_rank"),
3251
4259
  latestReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
3252
4260
  PARTITION BY ${posts.threadId}
3253
- ORDER BY ${posts.createdAt} DESC, ${posts.id} DESC
4261
+ ORDER BY ${sql.join(threadOrder("desc"), sql`, `)}
3254
4262
  ) AS INTEGER)`.as("latest_reply_rank"),
3255
4263
  totalReplyCount: sql<number>`CAST(COUNT(*) OVER (
3256
4264
  PARTITION BY ${posts.threadId}
@@ -3278,8 +4286,11 @@ export function createPostService(
3278
4286
  .from(rankedReplies)
3279
4287
  .where(
3280
4288
  or(
3281
- lte(rankedReplies.firstReplyRank, 2),
3282
- lte(rankedReplies.latestReplyRank, 3),
4289
+ // One past the leading window: that extra row is never rendered,
4290
+ // it is only where the gap link points. Without it the gap has to
4291
+ // aim at a reply already on screen.
4292
+ lte(rankedReplies.firstReplyRank, THREAD_LEADING_REPLIES + 1),
4293
+ lte(rankedReplies.latestReplyRank, THREAD_TRAILING_REPLIES),
3283
4294
  ),
3284
4295
  );
3285
4296
 
@@ -3293,6 +4304,7 @@ export function createPostService(
3293
4304
  leadingReplyIds: Map<number, string>;
3294
4305
  trailingReplyIds: Map<number, string>;
3295
4306
  latestReplyId: string | null;
4307
+ firstHiddenReplyId: string | null;
3296
4308
  totalReplyCount: number;
3297
4309
  }
3298
4310
  >();
@@ -3301,13 +4313,20 @@ export function createPostService(
3301
4313
  leadingReplyIds: new Map<number, string>(),
3302
4314
  trailingReplyIds: new Map<number, string>(),
3303
4315
  latestReplyId: null,
4316
+ firstHiddenReplyId: null,
3304
4317
  totalReplyCount: row.totalReplyCount,
3305
4318
  };
3306
4319
 
3307
- if (row.firstReplyRank <= 2) {
4320
+ if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
3308
4321
  existing.leadingReplyIds.set(row.firstReplyRank, row.id);
3309
4322
  }
3310
- if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
4323
+ if (row.firstReplyRank === THREAD_LEADING_REPLIES + 1) {
4324
+ existing.firstHiddenReplyId = row.id;
4325
+ }
4326
+ if (
4327
+ row.latestReplyRank > 1 &&
4328
+ row.latestReplyRank <= THREAD_TRAILING_REPLIES
4329
+ ) {
3311
4330
  existing.trailingReplyIds.set(row.latestReplyRank, row.id);
3312
4331
  }
3313
4332
  if (row.latestReplyRank === 1) {
@@ -3329,16 +4348,36 @@ export function createPostService(
3329
4348
  .map((id) => (id ? hydratedPosts.get(id) : undefined))
3330
4349
  .filter((post): post is Post => post !== undefined);
3331
4350
 
4351
+ const leadingReplies = hydrateReplyIds(
4352
+ Array.from({ length: THREAD_LEADING_REPLIES }, (_unused, index) =>
4353
+ context.leadingReplyIds.get(index + 1),
4354
+ ),
4355
+ );
4356
+ // Ranked newest-first, so walking down to rank 2 puts them back in
4357
+ // thread order. Rank 1 is the hero and is carried separately.
4358
+ const trailingReplies = hydrateReplyIds(
4359
+ Array.from(
4360
+ { length: THREAD_TRAILING_REPLIES - 1 },
4361
+ (_unused, index) =>
4362
+ context.trailingReplyIds.get(THREAD_TRAILING_REPLIES - index),
4363
+ ),
4364
+ );
4365
+ const firstHiddenReply =
4366
+ (context.firstHiddenReplyId
4367
+ ? hydratedPosts.get(context.firstHiddenReplyId)
4368
+ : undefined) ?? null;
4369
+
3332
4370
  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
- ]),
4371
+ leadingReplies,
4372
+ // Left overlapping on purpose. These are the ranked buckets, not the
4373
+ // rendered fold: on a short thread a reply lands in both windows, and
4374
+ // dropping the duplicate is a presentation rule that `ThreadPreview`
4375
+ // and `getThreadHiddenCount` each already apply.
4376
+ trailingReplies,
3341
4377
  latestReply,
4378
+ // Only a gap target: the query fetches it one past the leading
4379
+ // window, and nothing renders it.
4380
+ firstHiddenReply,
3342
4381
  totalReplyCount: context.totalReplyCount,
3343
4382
  });
3344
4383
  }
@@ -3404,7 +4443,7 @@ export function createPostService(
3404
4443
  const threadPosition = sql<number>`CAST(
3405
4444
  row_number() OVER (
3406
4445
  PARTITION BY ${posts.threadId}
3407
- ORDER BY ${posts.createdAt}, ${posts.id}
4446
+ ORDER BY ${sql.join(threadOrder(), sql`, `)}
3408
4447
  ) - 1 AS INTEGER
3409
4448
  )`.as("thread_position");
3410
4449
  const threadPostCount = sql<number>`CAST(
@@ -3728,20 +4767,25 @@ export function createPostService(
3728
4767
  const result = new Map<string, Post[]>();
3729
4768
  if (rootIds.length === 0) return result;
3730
4769
 
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);
4770
+ // A feed loads every Thread it renders at once, which can be hundreds.
4771
+ // Each Thread lands in one chunk, so its posts keep their order.
4772
+ const rows = await batchQueryRows([...new Set(rootIds)], (chunk) => {
4773
+ const conditions = [
4774
+ eq(posts.siteId, siteId),
4775
+ inArray(posts.threadId, chunk),
4776
+ eq(posts.status, "published"),
4777
+ ];
4778
+ if (options.publishedBefore !== undefined) {
4779
+ conditions.push(
4780
+ sql`${posts.publishedAt} < ${options.publishedBefore}`,
4781
+ );
4782
+ }
4783
+ return db
4784
+ .select()
4785
+ .from(posts)
4786
+ .where(and(...conditions))
4787
+ .orderBy(posts.threadId, ...threadOrder());
4788
+ });
3745
4789
 
3746
4790
  for (const post of await hydratePosts(rows)) {
3747
4791
  const thread = result.get(post.threadId);
@@ -3773,7 +4817,7 @@ export function createPostService(
3773
4817
  ...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
3774
4818
  ),
3775
4819
  )
3776
- .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
4820
+ .orderBy(posts.threadId, ...threadOrder("desc"));
3777
4821
 
3778
4822
  for (const row of rows) {
3779
4823
  if (!result.has(row.threadId)) {