@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -20,6 +20,7 @@ import {
20
20
  asc,
21
21
  lte,
22
22
  gt,
23
+ getTableColumns,
23
24
  } from "drizzle-orm";
24
25
  import {
25
26
  type Database,
@@ -33,12 +34,19 @@ import {
33
34
  } from "../db/schema-bundle.js";
34
35
  import { createEntityId } from "../lib/ids.js";
35
36
  import { now } from "../lib/time.js";
36
- import { renderTiptapJson, trimTiptapBody } from "../lib/tiptap-render.js";
37
+ import { trimTiptapBody } from "../lib/tiptap-render.js";
38
+ import {
39
+ POST_BODY_HTML_VERSION,
40
+ renderPostBodyHtml,
41
+ resolvePostBodyHtml,
42
+ tryPreparePostBodyHtml,
43
+ } from "../lib/post-body-html.js";
37
44
  import { extractSummary, extractBodyText } from "../lib/summary.js";
38
45
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
39
46
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
40
47
  import { generatePostSlug } from "../lib/slug.js";
41
48
  import { getSlugValidationIssue } from "../lib/slug-format.js";
49
+ import { isReservedPath } from "../lib/constants.js";
42
50
  import { normalizePath, slugify } from "../lib/url.js";
43
51
  import type { StorageDriver } from "../lib/storage.js";
44
52
  import type { MediaService } from "./media.js";
@@ -73,6 +81,9 @@ import {
73
81
  } from "../lib/youtube.js";
74
82
  import { getPreviewStorageKey } from "../lib/upload.js";
75
83
  import { generateRandomId } from "../lib/nanoid.js";
84
+ import { resolveSummaryConfig } from "../lib/resolve-config.js";
85
+ import type { Bindings } from "../types/bindings.js";
86
+ import type { SettingsService } from "./settings.js";
76
87
 
77
88
  /** Dependencies for operations that coordinate with other services */
78
89
  export interface PostDeleteDeps {
@@ -80,6 +91,32 @@ export interface PostDeleteDeps {
80
91
  storage?: StorageDriver | null;
81
92
  }
82
93
 
94
+ export interface RebuildBodyHtmlOptions {
95
+ /** Batch size (1..100, default 50). */
96
+ limit?: number;
97
+ /** Exclusive post ID cursor. */
98
+ cursor?: string;
99
+ /** Compute and report changes without writing them. */
100
+ dryRun?: boolean;
101
+ /** Summary settings used only when a canonical legacy body is upgraded. */
102
+ summaryConfig?: SummaryConfig;
103
+ }
104
+
105
+ export interface RebuildBodyHtmlResult {
106
+ processed: number;
107
+ wouldRebuild: number;
108
+ rebuilt: number;
109
+ wouldUpgradeFootnotes: number;
110
+ upgradedFootnotes: number;
111
+ skipped: number;
112
+ conflicted: number;
113
+ failed: number;
114
+ failures: Array<{ postId: string; error: string }>;
115
+ nextCursor: string | null;
116
+ done: boolean;
117
+ targetVersion: number;
118
+ }
119
+
83
120
  export interface PostAttachmentDeps extends PostDeleteDeps {
84
121
  storageDriver: string;
85
122
  maxFileSizeMB: number;
@@ -119,7 +156,9 @@ export interface PostFilters {
119
156
  hasReplies?: boolean;
120
157
  /** Explicit result sort order */
121
158
  sortOrder?: SortOrder;
122
- /** Ignore global pinned ordering when sorting subscription/feed results. */
159
+ /** Timestamp used for chronological sorting (defaults to thread activity). */
160
+ sortBy?: "activity" | "published";
161
+ /** Ignore global pinned ordering when results must remain chronological. */
123
162
  ignorePinnedSort?: boolean;
124
163
  limit?: number;
125
164
  cursor?: string;
@@ -135,6 +174,13 @@ export interface SummaryConfig {
135
174
  interface ThreadRootPageOptions {
136
175
  status?: Status;
137
176
  excludePrivate?: boolean;
177
+ excludeLatestHidden?: boolean;
178
+ /** Exclude Posts published at or after this Unix timestamp. */
179
+ publishedBefore?: number;
180
+ /** Restrict by the Thread root's format without excluding Child Posts. */
181
+ rootFormat?: Format;
182
+ /** Restrict to Threads with at least one rated published post. */
183
+ hasRating?: true;
138
184
  limit?: number;
139
185
  offset?: number;
140
186
  }
@@ -146,6 +192,8 @@ interface CollectionFeedEntryOptions extends ThreadRootPageOptions {
146
192
 
147
193
  interface CollectionThreadRootPageOptions extends ThreadRootPageOptions {
148
194
  sortOrder?: CollectionSortOrder;
195
+ /** Root Post ID returned by the previous page. */
196
+ cursor?: string;
149
197
  }
150
198
 
151
199
  interface CursorSortKey {
@@ -159,6 +207,24 @@ export interface CollectionFeedEntry {
159
207
  collectedAt: number;
160
208
  }
161
209
 
210
+ export interface FeaturedThreadTimelinePost {
211
+ post: Post;
212
+ /** Zero-based position among the Thread's published Posts. */
213
+ position: number;
214
+ }
215
+
216
+ /**
217
+ * Bounded projection for one curated Featured Thread.
218
+ *
219
+ * `posts` contains only the Root, every Featured Post, and the final published
220
+ * Post, in Thread order. Positions preserve the hidden-gap counts without
221
+ * loading the omitted Posts or their related metadata.
222
+ */
223
+ export interface FeaturedThreadTimelineData {
224
+ posts: FeaturedThreadTimelinePost[];
225
+ featuredPostIds: string[];
226
+ }
227
+
162
228
  export interface PostBodyContent {
163
229
  id: string;
164
230
  type: "post";
@@ -237,6 +303,8 @@ export interface PostService {
237
303
  /**
238
304
  * Atomically create a thread of posts. The first item is the root; each
239
305
  * subsequent item is automatically chained as a reply to the previous one.
306
+ * When the first item quietly extends an existing thread, the quiet behavior
307
+ * applies to every new reply in the batch.
240
308
  * On failure, all already-created posts are rolled back.
241
309
  *
242
310
  * @param items - Ordered list of (data, attachments) pairs; at least 2 required
@@ -296,8 +364,12 @@ export interface PostService {
296
364
  ): Promise<Map<string, ThreadTimelineContext>>;
297
365
  /** Count distinct thread roots that contain featured published posts */
298
366
  countFeaturedThreadRoots(options?: ThreadRootPageOptions): Promise<number>;
299
- /** List featured thread root IDs ordered by the latest featured post in each thread */
367
+ /** List Featured Thread roots by the latest selected Post publication time. */
300
368
  listFeaturedThreadRootIds(options?: ThreadRootPageOptions): Promise<string[]>;
369
+ /** Load only the Posts rendered by the curated Featured Thread timeline. */
370
+ getFeaturedThreadTimelineData(
371
+ rootIds: string[],
372
+ ): Promise<Map<string, FeaturedThreadTimelineData>>;
301
373
  /** Count distinct thread roots that contain published posts in the given collection */
302
374
  countCollectionThreadRoots(
303
375
  collectionId: string,
@@ -308,6 +380,12 @@ export interface PostService {
308
380
  collectionIds: string[],
309
381
  options?: ThreadRootPageOptions,
310
382
  ): Promise<number>;
383
+ /** Count distinct matching collection Threads up to a fixed limit. */
384
+ countCollectionThreadRootsUpToForCollections(
385
+ collectionIds: string[],
386
+ options: ThreadRootPageOptions | undefined,
387
+ limit: number,
388
+ ): Promise<number>;
311
389
  /** List collection thread root IDs ordered by collected-at or rating semantics */
312
390
  listCollectionThreadRootIds(
313
391
  collectionId: string,
@@ -318,6 +396,11 @@ export interface PostService {
318
396
  collectionIds: string[],
319
397
  options?: CollectionThreadRootPageOptions,
320
398
  ): Promise<string[]>;
399
+ /** List and hydrate collection Thread roots using Thread-level ordering. */
400
+ listCollectionThreadRootsForCollections(
401
+ collectionIds: string[],
402
+ options?: CollectionThreadRootPageOptions,
403
+ ): Promise<Post[]>;
321
404
  /** List collection feed entries ordered by latest added-at timestamp */
322
405
  listCollectionFeedEntries(
323
406
  collectionId: string,
@@ -328,18 +411,11 @@ export interface PostService {
328
411
  collectionIds: string[],
329
412
  options?: CollectionFeedEntryOptions,
330
413
  ): Promise<CollectionFeedEntry[]>;
331
- /** Fetch all published, non-deleted posts for each requested thread root */
332
- getPublishedThreads(rootIds: string[]): Promise<Map<string, Post[]>>;
333
- /** For each thread, return post IDs that belong to the given collection */
334
- getCollectionPostIdsByThread(
335
- collectionId: string,
336
- threadIds: string[],
337
- ): Promise<Map<string, string[]>>;
338
- /** For each thread, return post IDs that belong to any of the given collections */
339
- getCollectionPostIdsByThreadForCollections(
340
- collectionIds: string[],
341
- threadIds: string[],
342
- ): Promise<Map<string, string[]>>;
414
+ /** Fetch published Posts for each requested Thread root. */
415
+ getPublishedThreads(
416
+ rootIds: string[],
417
+ options?: Pick<PostFilters, "publishedBefore">,
418
+ ): Promise<Map<string, Post[]>>;
343
419
  /** Get distinct years that have published posts */
344
420
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
345
421
  /** For each thread ID, return the ID of the last published, non-deleted post */
@@ -366,6 +442,44 @@ export interface PostService {
366
442
  nextCursor: string | null;
367
443
  done: boolean;
368
444
  }>;
445
+ /**
446
+ * Upgrade recognized legacy footnotes and rebuild stored post body HTML for
447
+ * this site.
448
+ *
449
+ * Updates use compare-and-swap guards so concurrent edits win. Editorial
450
+ * timestamps are never touched. The operation is cursor-paginated,
451
+ * idempotent, and supports a read-only dry run.
452
+ */
453
+ rebuildBodyHtml(
454
+ options?: RebuildBodyHtmlOptions,
455
+ ): Promise<RebuildBodyHtmlResult>;
456
+ }
457
+
458
+ /**
459
+ * Rebuild body HTML using the site's current runtime summary settings.
460
+ *
461
+ * Internal maintenance routes do not run behind the normal app-config
462
+ * middleware, so this service-level orchestration preserves the same
463
+ * DB → environment → built-in resolution used by regular requests.
464
+ *
465
+ * @param services - Site-scoped post and settings services.
466
+ * @param env - Runtime environment bindings.
467
+ * @param options - Rebuild options supplied by the maintenance caller.
468
+ * @returns Rebuild progress and outcome counts.
469
+ */
470
+ export async function rebuildPostBodyHtmlWithRuntimeSettings(
471
+ services: Pick<
472
+ { posts: PostService; settings: SettingsService },
473
+ "posts" | "settings"
474
+ >,
475
+ env: Bindings,
476
+ options: Omit<RebuildBodyHtmlOptions, "summaryConfig"> = {},
477
+ ): Promise<RebuildBodyHtmlResult> {
478
+ const allSettings = await services.settings.getAll();
479
+ return services.posts.rebuildBodyHtml({
480
+ ...options,
481
+ summaryConfig: resolveSummaryConfig(env, allSettings),
482
+ });
369
483
  }
370
484
 
371
485
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
@@ -521,7 +635,8 @@ export function createPostService(
521
635
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
522
636
  ): PostService {
523
637
  const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
524
- const { pathRegistry, posts, postCollections } = databaseSchema;
638
+ const { navItems, pathRegistry, posts, sites, threadCollections } =
639
+ databaseSchema;
525
640
  const databaseDialect = config.databaseDialect ?? "sqlite";
526
641
  const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
527
642
 
@@ -695,11 +810,11 @@ export function createPostService(
695
810
  }
696
811
 
697
812
  return uniqueCollectionIds.length === 1
698
- ? eq(postCollections.collectionId, firstCollectionId)
699
- : inArray(postCollections.collectionId, uniqueCollectionIds);
813
+ ? eq(threadCollections.collectionId, firstCollectionId)
814
+ : inArray(threadCollections.collectionId, uniqueCollectionIds);
700
815
  }
701
816
 
702
- function buildPostCollectionSubqueryCondition(
817
+ function buildThreadCollectionSubqueryCondition(
703
818
  collectionIds: readonly string[],
704
819
  ): SQL<unknown> {
705
820
  const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
@@ -710,9 +825,9 @@ export function createPostService(
710
825
  const placeholders = uniqueCollectionIds.map(
711
826
  (collectionId) => sql`${collectionId}`,
712
827
  );
713
- return sql`${posts.id} IN (
714
- SELECT post_id
715
- FROM post_collection
828
+ return sql`${posts.threadId} IN (
829
+ SELECT thread_id
830
+ FROM thread_collection
716
831
  WHERE site_id = ${siteId}
717
832
  AND collection_id IN (${sql.join(placeholders, sql`, `)})
718
833
  )`;
@@ -753,11 +868,11 @@ export function createPostService(
753
868
  }
754
869
  if (filters.collectionIds !== undefined) {
755
870
  conditions.push(
756
- buildPostCollectionSubqueryCondition(filters.collectionIds),
871
+ buildThreadCollectionSubqueryCondition(filters.collectionIds),
757
872
  );
758
873
  } else if (filters.collectionId !== undefined) {
759
874
  conditions.push(
760
- buildPostCollectionSubqueryCondition([filters.collectionId]),
875
+ buildThreadCollectionSubqueryCondition([filters.collectionId]),
761
876
  );
762
877
  }
763
878
  if (filters.threadId) {
@@ -844,7 +959,13 @@ export function createPostService(
844
959
  return rows[0]?.id ?? null;
845
960
  }
846
961
 
847
- function getCursorSortTimestamp(row: typeof posts.$inferSelect): number {
962
+ function getCursorSortTimestamp(
963
+ row: typeof posts.$inferSelect,
964
+ filters: PostFilters,
965
+ ): number {
966
+ if (filters.sortBy === "published") {
967
+ return row.publishedAt ?? row.createdAt;
968
+ }
848
969
  return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
849
970
  }
850
971
 
@@ -888,16 +1009,20 @@ export function createPostService(
888
1009
  }
889
1010
 
890
1011
  const sortTimestampExpr =
891
- filters.status === "draft"
892
- ? posts.updatedAt
893
- : filters.status === "published"
894
- ? posts.lastActivityAt
895
- : sql<number>`CASE
896
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
897
- ELSE ${posts.lastActivityAt}
898
- END`;
1012
+ filters.sortBy === "published"
1013
+ ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1014
+ : filters.status === "draft"
1015
+ ? posts.updatedAt
1016
+ : filters.status === "published"
1017
+ ? posts.lastActivityAt
1018
+ : sql<number>`CASE
1019
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1020
+ ELSE ${posts.lastActivityAt}
1021
+ END`;
899
1022
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
900
- const featuredSortExpr = sql<number>`coalesce(${posts.featuredAt}, -1)`;
1023
+ const featuredPublishedSortExpr = sql<number>`coalesce(
1024
+ ${posts.publishedAt}, ${posts.createdAt}, -1
1025
+ )`;
901
1026
  const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestampExpr}, -1)`;
902
1027
  const ratingPresenceExpr = sql<number>`CASE
903
1028
  WHEN ${posts.rating} IS NULL THEN 0
@@ -905,8 +1030,9 @@ export function createPostService(
905
1030
  END`;
906
1031
  const ratingSortExpr = sql<number>`coalesce(${posts.rating}, -1)`;
907
1032
  const cursorPinnedAt = cursorPost.pinnedAt ?? -1;
908
- const cursorFeaturedAt = cursorPost.featuredAt ?? -1;
909
- const cursorSortTimestamp = getCursorSortTimestamp(cursorPost);
1033
+ const cursorFeaturedPublishedAt =
1034
+ cursorPost.publishedAt ?? cursorPost.createdAt;
1035
+ const cursorSortTimestamp = getCursorSortTimestamp(cursorPost, filters);
910
1036
  const cursorRatingPresence = cursorPost.rating === null ? 0 : 1;
911
1037
  const cursorRating = cursorPost.rating ?? -1;
912
1038
  const withPinnedSortKey = (
@@ -925,8 +1051,8 @@ export function createPostService(
925
1051
  withPinnedSortKey([
926
1052
  {
927
1053
  direction: "desc",
928
- expr: featuredSortExpr,
929
- value: cursorFeaturedAt,
1054
+ expr: featuredPublishedSortExpr,
1055
+ value: cursorFeaturedPublishedAt,
930
1056
  },
931
1057
  { direction: "desc", expr: posts.id, value: cursorPost.id },
932
1058
  ]),
@@ -1012,7 +1138,12 @@ export function createPostService(
1012
1138
  title: row.title,
1013
1139
  url: row.url,
1014
1140
  body: row.body,
1015
- bodyHtml: row.bodyHtml,
1141
+ bodyHtml: resolvePostBodyHtml({
1142
+ id: row.id,
1143
+ body: row.body,
1144
+ bodyHtml: row.bodyHtml,
1145
+ bodyHtmlVersion: row.bodyHtmlVersion,
1146
+ }),
1016
1147
  bodyText: row.bodyText,
1017
1148
  quoteText: row.quoteText,
1018
1149
  summary: row.summary,
@@ -1122,6 +1253,33 @@ export function createPostService(
1122
1253
  if (options?.excludePrivate) {
1123
1254
  conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1124
1255
  }
1256
+ if (options?.excludeLatestHidden) {
1257
+ conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1258
+ }
1259
+ if (options?.publishedBefore !== undefined) {
1260
+ conditions.push(
1261
+ sql`${posts.publishedAt} < ${options.publishedBefore}`,
1262
+ sql`EXISTS (
1263
+ SELECT 1
1264
+ FROM post AS publication_root
1265
+ WHERE publication_root.site_id = ${siteId}
1266
+ AND publication_root.id = ${posts.threadId}
1267
+ AND publication_root.published_at < ${options.publishedBefore}
1268
+ )`,
1269
+ );
1270
+ }
1271
+ if (options?.rootFormat) {
1272
+ conditions.push(sql`EXISTS (
1273
+ SELECT 1
1274
+ FROM post AS collection_root
1275
+ WHERE collection_root.site_id = ${siteId}
1276
+ AND collection_root.id = ${posts.threadId}
1277
+ AND collection_root.format = ${options.rootFormat}
1278
+ )`);
1279
+ }
1280
+ if (options?.hasRating) {
1281
+ conditions.push(isNotNull(posts.rating));
1282
+ }
1125
1283
 
1126
1284
  return conditions;
1127
1285
  }
@@ -1130,7 +1288,12 @@ export function createPostService(
1130
1288
  return sql<number>`MAX(
1131
1289
  COALESCE(
1132
1290
  (
1133
- SELECT root.last_activity_at
1291
+ SELECT CASE
1292
+ WHEN root.updated_at > root.created_at
1293
+ AND root.updated_at > COALESCE(root.last_activity_at, -1)
1294
+ THEN root.updated_at
1295
+ ELSE COALESCE(root.last_activity_at, root.updated_at)
1296
+ END
1134
1297
  FROM post AS root
1135
1298
  WHERE root.site_id = ${siteId}
1136
1299
  AND root.id = ${posts.threadId}
@@ -1141,6 +1304,59 @@ export function createPostService(
1141
1304
  )`.as(alias);
1142
1305
  }
1143
1306
 
1307
+ function buildCollectionThreadSortQuery(
1308
+ collectionIds: string[],
1309
+ options: CollectionThreadRootPageOptions,
1310
+ ) {
1311
+ const conditions = [
1312
+ ...buildThreadRootPageConditions(options),
1313
+ buildCollectionMembershipCondition(collectionIds),
1314
+ ];
1315
+ const sortOrder = options.sortOrder ?? "newest";
1316
+ const publishedAt =
1317
+ sortOrder === "oldest"
1318
+ ? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
1319
+ : sql<number>`MAX(${posts.publishedAt})`.as("published_at");
1320
+ const threadActivityAt =
1321
+ buildCollectionThreadActivityExpr("thread_activity_at");
1322
+ const ratingPresence = sql<number>`MAX(
1323
+ CASE
1324
+ WHEN ${posts.rating} IS NULL THEN 0
1325
+ ELSE 1
1326
+ END
1327
+ )`.as("rating_presence");
1328
+ const ratingValue = sql<number>`MAX(coalesce(${posts.rating}, -1))`.as(
1329
+ "rating_value",
1330
+ );
1331
+ const collectionPinnedAt =
1332
+ sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
1333
+ "collection_pinned_at",
1334
+ );
1335
+
1336
+ const sortedThreads = db
1337
+ .select({
1338
+ threadId: posts.threadId,
1339
+ publishedAt,
1340
+ threadActivityAt,
1341
+ collectionPinnedAt,
1342
+ ratingPresence,
1343
+ ratingValue,
1344
+ })
1345
+ .from(posts)
1346
+ .innerJoin(
1347
+ threadCollections,
1348
+ and(
1349
+ eq(threadCollections.siteId, posts.siteId),
1350
+ eq(threadCollections.threadId, posts.threadId),
1351
+ ),
1352
+ )
1353
+ .where(and(...conditions))
1354
+ .groupBy(posts.threadId)
1355
+ .as("collection_thread_sort");
1356
+
1357
+ return { sortOrder, sortedThreads };
1358
+ }
1359
+
1144
1360
  function isMediaAttachmentInput(
1145
1361
  attachment: PostAttachmentInput,
1146
1362
  ): attachment is Extract<PostAttachmentInput, { type: "media" }> {
@@ -1210,36 +1426,50 @@ export function createPostService(
1210
1426
  await deps.media.deleteByIds(mediaIds, deps.storage).catch(() => undefined);
1211
1427
  }
1212
1428
 
1213
- async function getCollectionIdsForPost(postId: string): Promise<string[]> {
1429
+ async function getCollectionIdsForThread(
1430
+ threadId: string,
1431
+ ): Promise<string[]> {
1214
1432
  const rows = await db
1215
- .select({ collectionId: postCollections.collectionId })
1216
- .from(postCollections)
1433
+ .select({ collectionId: threadCollections.collectionId })
1434
+ .from(threadCollections)
1217
1435
  .where(
1218
1436
  and(
1219
- eq(postCollections.siteId, siteId),
1220
- eq(postCollections.postId, postId),
1437
+ eq(threadCollections.siteId, siteId),
1438
+ eq(threadCollections.threadId, threadId),
1221
1439
  ),
1222
1440
  );
1223
1441
 
1224
1442
  return rows.map((row) => row.collectionId);
1225
1443
  }
1226
1444
 
1227
- async function getPostCollectionTimestamps(
1228
- postId: string,
1229
- ): Promise<Map<string, number>> {
1445
+ async function getThreadCollectionEntries(threadId: string): Promise<
1446
+ Map<
1447
+ string,
1448
+ {
1449
+ createdAt: number;
1450
+ pinnedAt: number | null;
1451
+ }
1452
+ >
1453
+ > {
1230
1454
  const rows = await db
1231
1455
  .select({
1232
- collectionId: postCollections.collectionId,
1233
- createdAt: postCollections.createdAt,
1456
+ collectionId: threadCollections.collectionId,
1457
+ createdAt: threadCollections.createdAt,
1458
+ pinnedAt: threadCollections.pinnedAt,
1234
1459
  })
1235
- .from(postCollections)
1460
+ .from(threadCollections)
1236
1461
  .where(
1237
1462
  and(
1238
- eq(postCollections.siteId, siteId),
1239
- eq(postCollections.postId, postId),
1463
+ eq(threadCollections.siteId, siteId),
1464
+ eq(threadCollections.threadId, threadId),
1240
1465
  ),
1241
1466
  );
1242
- return new Map(rows.map((r) => [r.collectionId, r.createdAt]));
1467
+ return new Map(
1468
+ rows.map((row) => [
1469
+ row.collectionId,
1470
+ { createdAt: row.createdAt, pinnedAt: row.pinnedAt },
1471
+ ]),
1472
+ );
1243
1473
  }
1244
1474
 
1245
1475
  function buildRollbackUpdate(
@@ -1324,14 +1554,16 @@ export function createPostService(
1324
1554
  return [];
1325
1555
  }
1326
1556
  const sortTimestamp =
1327
- filters.status === "draft"
1328
- ? posts.updatedAt
1329
- : filters.status === "published"
1330
- ? posts.lastActivityAt
1331
- : sql<number>`CASE
1332
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1333
- ELSE ${posts.lastActivityAt}
1334
- END`;
1557
+ filters.sortBy === "published"
1558
+ ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1559
+ : filters.status === "draft"
1560
+ ? posts.updatedAt
1561
+ : filters.status === "published"
1562
+ ? posts.lastActivityAt
1563
+ : sql<number>`CASE
1564
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1565
+ ELSE ${posts.lastActivityAt}
1566
+ END`;
1335
1567
 
1336
1568
  if (cursorCondition) {
1337
1569
  conditions.push(cursorCondition);
@@ -1344,10 +1576,13 @@ export function createPostService(
1344
1576
 
1345
1577
  // NULL-sort order differs between dialects: SQLite puts NULLs last for
1346
1578
  // DESC, Postgres puts them first. Wrap nullable sort keys in COALESCE
1347
- // so pinned/featured posts land on top under both engines. Mirrors the
1579
+ // so pinned and nullable timeline values sort identically under both
1580
+ // engines. Mirrors the
1348
1581
  // expressions used by `buildListCursorCondition` above.
1349
1582
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
1350
- const featuredSortExpr = sql<number>`coalesce(${posts.featuredAt}, -1)`;
1583
+ const featuredPublishedSortExpr = sql<number>`coalesce(
1584
+ ${posts.publishedAt}, ${posts.createdAt}, -1
1585
+ )`;
1351
1586
  const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestamp}, -1)`;
1352
1587
  const pinnedOrder = filters.ignorePinnedSort
1353
1588
  ? []
@@ -1364,7 +1599,7 @@ export function createPostService(
1364
1599
  ? baseQuery.orderBy(
1365
1600
  ...pinnedOrder,
1366
1601
  filters.featured
1367
- ? desc(featuredSortExpr)
1602
+ ? desc(featuredPublishedSortExpr)
1368
1603
  : desc(sortTimestampSortExpr),
1369
1604
  desc(posts.id),
1370
1605
  )
@@ -1535,7 +1770,11 @@ export function createPostService(
1535
1770
  const rawBody = data.bodyMarkdown
1536
1771
  ? markdownToTiptapJson(data.bodyMarkdown)
1537
1772
  : (data.body ?? null);
1538
- const body = rawBody ? trimTiptapBody(rawBody) : null;
1773
+ const trimmedBody = rawBody ? trimTiptapBody(rawBody) : null;
1774
+ const preparedBody = trimmedBody
1775
+ ? tryPreparePostBodyHtml(id, trimmedBody)
1776
+ : null;
1777
+ const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
1539
1778
  const title = data.title?.trim() || null;
1540
1779
  const quoteText = data.quoteText?.trim() || null;
1541
1780
  const url = data.url?.trim() || null;
@@ -1551,7 +1790,11 @@ export function createPostService(
1551
1790
  : undefined,
1552
1791
  });
1553
1792
 
1554
- const bodyHtml = body ? renderTiptapJson(body) : null;
1793
+ const bodyHtml = preparedBody?.ok
1794
+ ? preparedBody.html
1795
+ : body
1796
+ ? renderPostBodyHtml(id, body)
1797
+ : null;
1555
1798
  const bodyText = body
1556
1799
  ? extractBodyText(body, { includeLinkHrefs: true })
1557
1800
  : null;
@@ -1623,6 +1866,15 @@ export function createPostService(
1623
1866
  }
1624
1867
  }
1625
1868
  visibility = null;
1869
+
1870
+ if (
1871
+ (data.collectionIds?.length ?? 0) > 0 ||
1872
+ (data.collectionEntries?.length ?? 0) > 0
1873
+ ) {
1874
+ throw new ConflictError(
1875
+ "Cannot set Collections while creating a Thread reply. Set them on the Thread root instead.",
1876
+ );
1877
+ }
1626
1878
  }
1627
1879
 
1628
1880
  assertDraftPublishedAt(status, data.publishedAt);
@@ -1643,6 +1895,11 @@ export function createPostService(
1643
1895
 
1644
1896
  if (data.path) {
1645
1897
  const normalized = normalizePath(data.path);
1898
+ if (isReservedPath(normalized)) {
1899
+ throw new ValidationError(
1900
+ `Path "${normalized}" is reserved and cannot be used`,
1901
+ );
1902
+ }
1646
1903
  if (isValidSlug(normalized)) {
1647
1904
  // Path is a valid slug — use it directly
1648
1905
  slug = await generatePostSlug({
@@ -1674,7 +1931,8 @@ export function createPostService(
1674
1931
  });
1675
1932
  }
1676
1933
 
1677
- // When structured `collectionEntries` are provided (Hugo import path),
1934
+ // Thread collection membership is stored against the root ID. When
1935
+ // structured `collectionEntries` are provided (Hugo import path),
1678
1936
  // they win over the bare `collectionIds` slug list and carry
1679
1937
  // `createdAt` / `position` / `pinnedAt` per row. Otherwise fall back to
1680
1938
  // the simple list and derive those fields from sensible defaults.
@@ -1683,7 +1941,7 @@ export function createPostService(
1683
1941
  data.collectionEntries.length > 0;
1684
1942
  const collectionInsertRows: {
1685
1943
  siteId: string;
1686
- postId: string;
1944
+ threadId: string;
1687
1945
  collectionId: string;
1688
1946
  createdAt: number;
1689
1947
  position: number;
@@ -1693,7 +1951,7 @@ export function createPostService(
1693
1951
  const seen = new Set<string>();
1694
1952
  const rows: {
1695
1953
  siteId: string;
1696
- postId: string;
1954
+ threadId: string;
1697
1955
  collectionId: string;
1698
1956
  createdAt: number;
1699
1957
  position: number;
@@ -1705,7 +1963,7 @@ export function createPostService(
1705
1963
  seen.add(entry.collectionId);
1706
1964
  rows.push({
1707
1965
  siteId,
1708
- postId: id,
1966
+ threadId,
1709
1967
  collectionId: entry.collectionId,
1710
1968
  createdAt: entry.createdAt ?? timestamp,
1711
1969
  position: entry.position ?? fallbackPosition,
@@ -1717,7 +1975,7 @@ export function createPostService(
1717
1975
  })()
1718
1976
  : [...new Set(data.collectionIds ?? [])].map((collectionId, index) => ({
1719
1977
  siteId,
1720
- postId: id,
1978
+ threadId,
1721
1979
  collectionId,
1722
1980
  createdAt: timestamp,
1723
1981
  position: index,
@@ -1741,6 +1999,7 @@ export function createPostService(
1741
1999
  url,
1742
2000
  body,
1743
2001
  bodyHtml,
2002
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
1744
2003
  bodyText,
1745
2004
  quoteText,
1746
2005
  summary,
@@ -1788,7 +2047,12 @@ export function createPostService(
1788
2047
 
1789
2048
  if (collectionInsertRows.length > 0) {
1790
2049
  writeQueries.push(
1791
- db.insert(postCollections).values(collectionInsertRows),
2050
+ data.replyToId
2051
+ ? db
2052
+ .insert(threadCollections)
2053
+ .values(collectionInsertRows)
2054
+ .onConflictDoNothing()
2055
+ : db.insert(threadCollections).values(collectionInsertRows),
1792
2056
  );
1793
2057
  }
1794
2058
 
@@ -1812,6 +2076,7 @@ export function createPostService(
1812
2076
  url,
1813
2077
  body,
1814
2078
  bodyHtml,
2079
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
1815
2080
  bodyText,
1816
2081
  quoteText,
1817
2082
  summary,
@@ -1853,7 +2118,14 @@ export function createPostService(
1853
2118
  }
1854
2119
 
1855
2120
  if (collectionInsertRows.length > 0) {
1856
- await tx.insert(postCollections).values(collectionInsertRows);
2121
+ const insertQuery = tx
2122
+ .insert(threadCollections)
2123
+ .values(collectionInsertRows);
2124
+ if (data.replyToId) {
2125
+ await insertQuery.onConflictDoNothing();
2126
+ } else {
2127
+ await insertQuery;
2128
+ }
1857
2129
  }
1858
2130
  });
1859
2131
  }
@@ -1926,6 +2198,9 @@ export function createPostService(
1926
2198
  throw new ValidationError("A thread requires at least 2 posts.");
1927
2199
  }
1928
2200
 
2201
+ const extendsExistingThreadQuietly =
2202
+ items[0]?.data.replyToId !== undefined &&
2203
+ items[0].data.quietReply === true;
1929
2204
  const created: Post[] = [];
1930
2205
 
1931
2206
  for (let i = 0; i < items.length; i++) {
@@ -1937,6 +2212,7 @@ export function createPostService(
1937
2212
  ...data,
1938
2213
  // Chain each post as a reply to the previous one (server-side chaining)
1939
2214
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2215
+ quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
1940
2216
  };
1941
2217
 
1942
2218
  try {
@@ -1990,6 +2266,19 @@ export function createPostService(
1990
2266
 
1991
2267
  const nextTitle =
1992
2268
  data.title !== undefined ? data.title?.trim() || null : existing.title;
2269
+ const effectiveNextVisibility = nextVisibility ?? existing.visibility;
2270
+ const wasNavigationPageEligible =
2271
+ existing.format === "note" &&
2272
+ existing.status === "published" &&
2273
+ existing.visibility !== "private" &&
2274
+ !isThreadReply(existing) &&
2275
+ hasNonEmptyText(existing.title);
2276
+ const remainsNavigationPageEligible =
2277
+ nextFormat === "note" &&
2278
+ nextStatus === "published" &&
2279
+ effectiveNextVisibility !== "private" &&
2280
+ !isThreadReply(existing) &&
2281
+ hasNonEmptyText(nextTitle);
1993
2282
 
1994
2283
  assertPostFormatShape({
1995
2284
  format: nextFormat,
@@ -2046,17 +2335,25 @@ export function createPostService(
2046
2335
  updates.featuredAt = data.featured ? timestamp : null;
2047
2336
  }
2048
2337
 
2338
+ let updatedBody: string | null | undefined;
2049
2339
  if (data.body !== undefined || data.bodyMarkdown !== undefined) {
2050
2340
  const rawBody = data.bodyMarkdown
2051
2341
  ? markdownToTiptapJson(data.bodyMarkdown)
2052
2342
  : (data.body ?? null);
2053
2343
  const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
2054
- updates.body = normalizedBody;
2055
- updates.bodyHtml = normalizedBody
2056
- ? renderTiptapJson(normalizedBody)
2344
+ const preparedBody = normalizedBody
2345
+ ? tryPreparePostBodyHtml(existing.id, normalizedBody)
2057
2346
  : null;
2058
- updates.bodyText = normalizedBody
2059
- ? extractBodyText(normalizedBody, { includeLinkHrefs: true })
2347
+ updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
2348
+ updates.body = updatedBody;
2349
+ updates.bodyHtml = preparedBody?.ok
2350
+ ? preparedBody.html
2351
+ : updatedBody
2352
+ ? renderPostBodyHtml(existing.id, updatedBody)
2353
+ : null;
2354
+ updates.bodyHtmlVersion = POST_BODY_HTML_VERSION;
2355
+ updates.bodyText = updatedBody
2356
+ ? extractBodyText(updatedBody, { includeLinkHrefs: true })
2060
2357
  : null;
2061
2358
  }
2062
2359
 
@@ -2064,14 +2361,7 @@ export function createPostService(
2064
2361
  if (summaryConfig) {
2065
2362
  const format = nextFormat;
2066
2363
  const title = nextTitle;
2067
- const body =
2068
- data.bodyMarkdown !== undefined
2069
- ? data.bodyMarkdown
2070
- ? markdownToTiptapJson(data.bodyMarkdown)
2071
- : null
2072
- : data.body !== undefined
2073
- ? data.body
2074
- : existing.body;
2364
+ const body = updatedBody !== undefined ? updatedBody : existing.body;
2075
2365
  if (format === "note" && title && body) {
2076
2366
  updates.summary = extractSummary(
2077
2367
  body,
@@ -2128,14 +2418,24 @@ export function createPostService(
2128
2418
  const needsCascade = statusChanged && !isThreadReply(existing);
2129
2419
  const needsReplyVisibilityCleanup =
2130
2420
  !isThreadReply(existing) && (statusChanged || visibilityChanged);
2131
- const needsCollectionSync = data.collectionIds !== undefined;
2132
- const nextCollectionIds = needsCollectionSync
2133
- ? [...new Set(data.collectionIds ?? [])]
2134
- : [];
2421
+ const needsCollectionSync =
2422
+ data.collectionIds !== undefined ||
2423
+ data.collectionEntries !== undefined;
2135
2424
  const needsThreadActivityRecalc =
2136
2425
  statusChanged || publishedAtChanged || existing.status === "draft";
2426
+ const needsPageNavDelete =
2427
+ wasNavigationPageEligible && !remainsNavigationPageEligible;
2428
+ const needsPageNavUrlUpdate =
2429
+ wasNavigationPageEligible &&
2430
+ remainsNavigationPageEligible &&
2431
+ slugChanged &&
2432
+ Boolean(data.slug);
2137
2433
  const hasExtraWrites =
2138
- needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
2434
+ needsCascade ||
2435
+ needsReplyVisibilityCleanup ||
2436
+ needsCollectionSync ||
2437
+ needsPageNavDelete ||
2438
+ needsPageNavUrlUpdate;
2139
2439
 
2140
2440
  if (!hasExtraWrites) {
2141
2441
  // Simple case: only the post update
@@ -2151,10 +2451,57 @@ export function createPostService(
2151
2451
  return hydratePost(result[0]);
2152
2452
  }
2153
2453
 
2154
- // Complex case: cascade + update + collection sync atomically
2155
- const existingCollectionTimestamps = needsCollectionSync
2156
- ? await getPostCollectionTimestamps(id)
2157
- : new Map<string, number>();
2454
+ // Complex case: cascade + update + Thread collection sync atomically.
2455
+ // Retained entries keep their collected/pinned timestamps when a normal
2456
+ // collectionIds update only changes selection or ordering.
2457
+ const existingThreadCollections = needsCollectionSync
2458
+ ? await getThreadCollectionEntries(existing.threadId)
2459
+ : new Map<string, { createdAt: number; pinnedAt: number | null }>();
2460
+ const collectionTimestamp = now();
2461
+ const nextThreadCollectionRows = (() => {
2462
+ if (!needsCollectionSync) return [];
2463
+
2464
+ if (data.collectionEntries !== undefined) {
2465
+ const seen = new Set<string>();
2466
+ return data.collectionEntries.flatMap((entry, index) => {
2467
+ if (seen.has(entry.collectionId)) return [];
2468
+ seen.add(entry.collectionId);
2469
+ const existingEntry = existingThreadCollections.get(
2470
+ entry.collectionId,
2471
+ );
2472
+ return [
2473
+ {
2474
+ siteId,
2475
+ threadId: existing.threadId,
2476
+ collectionId: entry.collectionId,
2477
+ createdAt:
2478
+ entry.createdAt ??
2479
+ existingEntry?.createdAt ??
2480
+ collectionTimestamp,
2481
+ position: entry.position ?? index,
2482
+ pinnedAt:
2483
+ entry.pinnedAt !== undefined
2484
+ ? entry.pinnedAt
2485
+ : (existingEntry?.pinnedAt ?? null),
2486
+ },
2487
+ ];
2488
+ });
2489
+ }
2490
+
2491
+ return [...new Set(data.collectionIds ?? [])].map(
2492
+ (collectionId, index) => {
2493
+ const existingEntry = existingThreadCollections.get(collectionId);
2494
+ return {
2495
+ siteId,
2496
+ threadId: existing.threadId,
2497
+ collectionId,
2498
+ createdAt: existingEntry?.createdAt ?? collectionTimestamp,
2499
+ position: index,
2500
+ pinnedAt: existingEntry?.pinnedAt ?? null,
2501
+ };
2502
+ },
2503
+ );
2504
+ })();
2158
2505
  let updateResult: (typeof posts.$inferSelect)[] | undefined;
2159
2506
 
2160
2507
  if (usesBatchWrites) {
@@ -2208,33 +2555,40 @@ export function createPostService(
2208
2555
  .returning(),
2209
2556
  );
2210
2557
 
2558
+ if (needsPageNavDelete) {
2559
+ writeQueries.push(
2560
+ db
2561
+ .delete(navItems)
2562
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
2563
+ );
2564
+ } else if (needsPageNavUrlUpdate && data.slug) {
2565
+ writeQueries.push(
2566
+ db
2567
+ .update(navItems)
2568
+ .set({
2569
+ url: `/${normalizePath(data.slug)}`,
2570
+ updatedAt: timestamp,
2571
+ })
2572
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
2573
+ );
2574
+ }
2575
+
2211
2576
  if (needsCollectionSync) {
2212
- // Delete all and re-insert to preserve user-specified ordering
2577
+ // Delete all and re-insert the one shared Thread membership set.
2213
2578
  writeQueries.push(
2214
2579
  db
2215
- .delete(postCollections)
2580
+ .delete(threadCollections)
2216
2581
  .where(
2217
2582
  and(
2218
- eq(postCollections.siteId, siteId),
2219
- eq(postCollections.postId, id),
2583
+ eq(threadCollections.siteId, siteId),
2584
+ eq(threadCollections.threadId, existing.threadId),
2220
2585
  ),
2221
2586
  ),
2222
2587
  );
2223
2588
 
2224
- if (nextCollectionIds.length > 0) {
2225
- const collectionTimestamp = now();
2589
+ if (nextThreadCollectionRows.length > 0) {
2226
2590
  writeQueries.push(
2227
- db.insert(postCollections).values(
2228
- nextCollectionIds.map((collectionId, index) => ({
2229
- siteId,
2230
- postId: id,
2231
- collectionId,
2232
- createdAt:
2233
- existingCollectionTimestamps.get(collectionId) ??
2234
- collectionTimestamp,
2235
- position: index,
2236
- })),
2237
- ),
2591
+ db.insert(threadCollections).values(nextThreadCollectionRows),
2238
2592
  );
2239
2593
  }
2240
2594
  }
@@ -2291,30 +2645,35 @@ export function createPostService(
2291
2645
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
2292
2646
  .returning();
2293
2647
 
2648
+ if (needsPageNavDelete) {
2649
+ await tx
2650
+ .delete(navItems)
2651
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
2652
+ } else if (needsPageNavUrlUpdate && data.slug) {
2653
+ await tx
2654
+ .update(navItems)
2655
+ .set({
2656
+ url: `/${normalizePath(data.slug)}`,
2657
+ updatedAt: timestamp,
2658
+ })
2659
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
2660
+ }
2661
+
2294
2662
  if (needsCollectionSync) {
2295
- // Delete all and re-insert to preserve user-specified ordering
2663
+ // Delete all and re-insert the one shared Thread membership set.
2296
2664
  await tx
2297
- .delete(postCollections)
2665
+ .delete(threadCollections)
2298
2666
  .where(
2299
2667
  and(
2300
- eq(postCollections.siteId, siteId),
2301
- eq(postCollections.postId, id),
2668
+ eq(threadCollections.siteId, siteId),
2669
+ eq(threadCollections.threadId, existing.threadId),
2302
2670
  ),
2303
2671
  );
2304
2672
 
2305
- if (nextCollectionIds.length > 0) {
2306
- const collectionTimestamp = now();
2307
- await tx.insert(postCollections).values(
2308
- nextCollectionIds.map((collectionId, index) => ({
2309
- siteId,
2310
- postId: id,
2311
- collectionId,
2312
- createdAt:
2313
- existingCollectionTimestamps.get(collectionId) ??
2314
- collectionTimestamp,
2315
- position: index,
2316
- })),
2317
- );
2673
+ if (nextThreadCollectionRows.length > 0) {
2674
+ await tx
2675
+ .insert(threadCollections)
2676
+ .values(nextThreadCollectionRows);
2318
2677
  }
2319
2678
  }
2320
2679
  });
@@ -2360,7 +2719,9 @@ export function createPostService(
2360
2719
  const existingPost = await this.getById(id);
2361
2720
  if (!existingPost) return null;
2362
2721
 
2363
- const existingCollectionIds = await getCollectionIdsForPost(id);
2722
+ const existingCollectionIds = await getCollectionIdsForThread(
2723
+ existingPost.threadId,
2724
+ );
2364
2725
  const rollbackData = buildRollbackUpdate(
2365
2726
  existingPost,
2366
2727
  existingCollectionIds,
@@ -2718,8 +3079,8 @@ export function createPostService(
2718
3079
  .from(rankedReplies)
2719
3080
  .where(
2720
3081
  or(
2721
- eq(rankedReplies.firstReplyRank, 1),
2722
- lte(rankedReplies.latestReplyRank, 2),
3082
+ lte(rankedReplies.firstReplyRank, 2),
3083
+ lte(rankedReplies.latestReplyRank, 3),
2723
3084
  ),
2724
3085
  );
2725
3086
 
@@ -2730,25 +3091,25 @@ export function createPostService(
2730
3091
  const contextByThreadId = new Map<
2731
3092
  string,
2732
3093
  {
2733
- secondReplyId: string | null;
2734
- penultimateReplyId: string | null;
3094
+ leadingReplyIds: Map<number, string>;
3095
+ trailingReplyIds: Map<number, string>;
2735
3096
  latestReplyId: string | null;
2736
3097
  totalReplyCount: number;
2737
3098
  }
2738
3099
  >();
2739
3100
  for (const row of contextRows) {
2740
3101
  const existing = contextByThreadId.get(row.threadId) ?? {
2741
- secondReplyId: null,
2742
- penultimateReplyId: null,
3102
+ leadingReplyIds: new Map<number, string>(),
3103
+ trailingReplyIds: new Map<number, string>(),
2743
3104
  latestReplyId: null,
2744
3105
  totalReplyCount: row.totalReplyCount,
2745
3106
  };
2746
3107
 
2747
- if (row.firstReplyRank === 1) {
2748
- existing.secondReplyId = row.id;
3108
+ if (row.firstReplyRank <= 2) {
3109
+ existing.leadingReplyIds.set(row.firstReplyRank, row.id);
2749
3110
  }
2750
- if (row.latestReplyRank === 2) {
2751
- existing.penultimateReplyId = row.id;
3111
+ if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
3112
+ existing.trailingReplyIds.set(row.latestReplyRank, row.id);
2752
3113
  }
2753
3114
  if (row.latestReplyRank === 1) {
2754
3115
  existing.latestReplyId = row.id;
@@ -2764,13 +3125,20 @@ export function createPostService(
2764
3125
  const latestReply = hydratedPosts.get(context.latestReplyId);
2765
3126
  if (!latestReply) continue;
2766
3127
 
3128
+ const hydrateReplyIds = (ids: Array<string | undefined>) =>
3129
+ ids
3130
+ .map((id) => (id ? hydratedPosts.get(id) : undefined))
3131
+ .filter((post): post is Post => post !== undefined);
3132
+
2767
3133
  result.set(threadId, {
2768
- secondReply: context.secondReplyId
2769
- ? (hydratedPosts.get(context.secondReplyId) ?? null)
2770
- : null,
2771
- penultimateReply: context.penultimateReplyId
2772
- ? (hydratedPosts.get(context.penultimateReplyId) ?? null)
2773
- : null,
3134
+ leadingReplies: hydrateReplyIds([
3135
+ context.leadingReplyIds.get(1),
3136
+ context.leadingReplyIds.get(2),
3137
+ ]),
3138
+ trailingReplies: hydrateReplyIds([
3139
+ context.trailingReplyIds.get(3),
3140
+ context.trailingReplyIds.get(2),
3141
+ ]),
2774
3142
  latestReply,
2775
3143
  totalReplyCount: context.totalReplyCount,
2776
3144
  });
@@ -2803,19 +3171,20 @@ export function createPostService(
2803
3171
  ...buildThreadRootPageConditions(options),
2804
3172
  isNotNull(posts.featuredAt),
2805
3173
  ];
2806
- const latestFeaturedAt = sql<number>`MAX(${posts.featuredAt})`.as(
2807
- "latest_featured_at",
2808
- );
3174
+ const latestFeaturedPublishedAt =
3175
+ sql<number>`MAX(${posts.publishedAt})`.as(
3176
+ "latest_featured_published_at",
3177
+ );
2809
3178
 
2810
3179
  let query = db
2811
3180
  .select({
2812
3181
  threadId: posts.threadId,
2813
- latestFeaturedAt,
3182
+ latestFeaturedPublishedAt,
2814
3183
  })
2815
3184
  .from(posts)
2816
3185
  .where(and(...conditions))
2817
3186
  .groupBy(posts.threadId)
2818
- .orderBy(desc(latestFeaturedAt), desc(posts.threadId));
3187
+ .orderBy(desc(latestFeaturedPublishedAt), desc(posts.threadId));
2819
3188
 
2820
3189
  if (options.limit !== undefined) {
2821
3190
  query = query.limit(options.limit) as typeof query;
@@ -2828,6 +3197,77 @@ export function createPostService(
2828
3197
  return rows.map((row) => row.threadId);
2829
3198
  },
2830
3199
 
3200
+ async getFeaturedThreadTimelineData(rootIds) {
3201
+ const result = new Map<string, FeaturedThreadTimelineData>();
3202
+ if (rootIds.length === 0) return result;
3203
+
3204
+ const uniqueRootIds = [...new Set(rootIds)];
3205
+ const threadPosition = sql<number>`CAST(
3206
+ row_number() OVER (
3207
+ PARTITION BY ${posts.threadId}
3208
+ ORDER BY ${posts.createdAt}, ${posts.id}
3209
+ ) - 1 AS INTEGER
3210
+ )`.as("thread_position");
3211
+ const threadPostCount = sql<number>`CAST(
3212
+ count(*) OVER (PARTITION BY ${posts.threadId}) AS INTEGER
3213
+ )`.as("thread_post_count");
3214
+
3215
+ const rankedPosts = db
3216
+ .select({
3217
+ ...getTableColumns(posts),
3218
+ threadPosition,
3219
+ threadPostCount,
3220
+ })
3221
+ .from(posts)
3222
+ .where(
3223
+ and(
3224
+ eq(posts.siteId, siteId),
3225
+ inArray(posts.threadId, uniqueRootIds),
3226
+ eq(posts.status, "published"),
3227
+ ),
3228
+ )
3229
+ .as("ranked_featured_thread_post");
3230
+
3231
+ const rows = await db
3232
+ .select()
3233
+ .from(rankedPosts)
3234
+ .where(
3235
+ or(
3236
+ eq(rankedPosts.id, rankedPosts.threadId),
3237
+ isNotNull(rankedPosts.featuredAt),
3238
+ sql`${rankedPosts.threadPosition} = ${rankedPosts.threadPostCount} - 1`,
3239
+ ),
3240
+ )
3241
+ .orderBy(rankedPosts.threadId, sql`${rankedPosts.threadPosition}`);
3242
+
3243
+ const hydratedPosts = await hydratePosts(
3244
+ rows.map(
3245
+ ({ threadPosition: _position, threadPostCount: _count, ...row }) =>
3246
+ row,
3247
+ ),
3248
+ );
3249
+ const hydratedById = new Map(
3250
+ hydratedPosts.map((post) => [post.id, post]),
3251
+ );
3252
+
3253
+ for (const row of rows) {
3254
+ const post = hydratedById.get(row.id);
3255
+ if (!post) continue;
3256
+
3257
+ const thread = result.get(row.threadId) ?? {
3258
+ posts: [],
3259
+ featuredPostIds: [],
3260
+ };
3261
+ thread.posts.push({ post, position: row.threadPosition });
3262
+ if (row.featuredAt !== null) {
3263
+ thread.featuredPostIds.push(row.id);
3264
+ }
3265
+ result.set(row.threadId, thread);
3266
+ }
3267
+
3268
+ return result;
3269
+ },
3270
+
2831
3271
  async countCollectionThreadRoots(collectionId, options = {}) {
2832
3272
  return this.countCollectionThreadRootsForCollections(
2833
3273
  [collectionId],
@@ -2853,10 +3293,10 @@ export function createPostService(
2853
3293
  })
2854
3294
  .from(posts)
2855
3295
  .innerJoin(
2856
- postCollections,
3296
+ threadCollections,
2857
3297
  and(
2858
- eq(postCollections.siteId, siteId),
2859
- eq(postCollections.postId, posts.id),
3298
+ eq(threadCollections.siteId, posts.siteId),
3299
+ eq(threadCollections.threadId, posts.threadId),
2860
3300
  ),
2861
3301
  )
2862
3302
  .where(and(...conditions));
@@ -2864,6 +3304,35 @@ export function createPostService(
2864
3304
  return rows[0]?.count ?? 0;
2865
3305
  },
2866
3306
 
3307
+ async countCollectionThreadRootsUpToForCollections(
3308
+ collectionIds,
3309
+ options = {},
3310
+ limit,
3311
+ ) {
3312
+ const normalizedLimit = Math.max(0, Math.trunc(limit));
3313
+ if (normalizedLimit === 0) return 0;
3314
+
3315
+ const conditions = [
3316
+ ...buildThreadRootPageConditions(options),
3317
+ buildCollectionMembershipCondition(collectionIds),
3318
+ ];
3319
+ const rows = await db
3320
+ .select({ threadId: posts.threadId })
3321
+ .from(posts)
3322
+ .innerJoin(
3323
+ threadCollections,
3324
+ and(
3325
+ eq(threadCollections.siteId, posts.siteId),
3326
+ eq(threadCollections.threadId, posts.threadId),
3327
+ ),
3328
+ )
3329
+ .where(and(...conditions))
3330
+ .groupBy(posts.threadId)
3331
+ .limit(normalizedLimit);
3332
+
3333
+ return rows.length;
3334
+ },
3335
+
2867
3336
  async listCollectionThreadRootIds(collectionId, options = {}) {
2868
3337
  return this.listCollectionThreadRootIdsForCollections(
2869
3338
  [collectionId],
@@ -2875,71 +3344,102 @@ export function createPostService(
2875
3344
  collectionIds,
2876
3345
  options = {},
2877
3346
  ) {
2878
- const conditions = [
2879
- ...buildThreadRootPageConditions(options),
2880
- buildCollectionMembershipCondition(collectionIds),
2881
- ];
2882
- const sortOrder = options.sortOrder ?? "newest";
2883
- const publishedAt =
2884
- sortOrder === "oldest"
2885
- ? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
2886
- : sql<number>`MAX(${posts.publishedAt})`.as("published_at");
2887
- const threadActivityAt =
2888
- buildCollectionThreadActivityExpr("thread_activity_at");
2889
- const ratingPresence = sql<number>`MAX(
2890
- CASE
2891
- WHEN ${posts.rating} IS NULL THEN 0
2892
- ELSE 1
2893
- END
2894
- )`.as("rating_presence");
2895
- const ratingValue = sql<number | null>`MAX(${posts.rating})`.as(
2896
- "rating_value",
3347
+ const { sortOrder, sortedThreads } = buildCollectionThreadSortQuery(
3348
+ collectionIds,
3349
+ options,
2897
3350
  );
3351
+ let cursorCondition: SQL<unknown> | undefined;
3352
+
3353
+ if (options.cursor) {
3354
+ const cursorRows = await db
3355
+ .select()
3356
+ .from(sortedThreads)
3357
+ .where(eq(sortedThreads.threadId, options.cursor))
3358
+ .limit(1);
3359
+ const cursorRow = cursorRows[0];
3360
+ if (!cursorRow) return [];
3361
+
3362
+ const pinnedKey: CursorSortKey = {
3363
+ direction: "desc",
3364
+ expr: sortedThreads.collectionPinnedAt,
3365
+ value: cursorRow.collectionPinnedAt,
3366
+ };
2898
3367
 
2899
- const collectionPinnedAt =
2900
- sql<number>`MAX(coalesce(${postCollections.pinnedAt}, -1))`.as(
2901
- "collection_pinned_at",
2902
- );
3368
+ cursorCondition =
3369
+ sortOrder === "oldest"
3370
+ ? buildLexicographicCursorCondition([
3371
+ pinnedKey,
3372
+ {
3373
+ direction: "asc",
3374
+ expr: sortedThreads.publishedAt,
3375
+ value: cursorRow.publishedAt,
3376
+ },
3377
+ {
3378
+ direction: "asc",
3379
+ expr: sortedThreads.threadId,
3380
+ value: cursorRow.threadId,
3381
+ },
3382
+ ])
3383
+ : sortOrder === "rating_desc"
3384
+ ? buildLexicographicCursorCondition([
3385
+ pinnedKey,
3386
+ {
3387
+ direction: "desc",
3388
+ expr: sortedThreads.ratingPresence,
3389
+ value: cursorRow.ratingPresence,
3390
+ },
3391
+ {
3392
+ direction: "desc",
3393
+ expr: sortedThreads.ratingValue,
3394
+ value: cursorRow.ratingValue,
3395
+ },
3396
+ {
3397
+ direction: "desc",
3398
+ expr: sortedThreads.threadActivityAt,
3399
+ value: cursorRow.threadActivityAt,
3400
+ },
3401
+ {
3402
+ direction: "desc",
3403
+ expr: sortedThreads.threadId,
3404
+ value: cursorRow.threadId,
3405
+ },
3406
+ ])
3407
+ : buildLexicographicCursorCondition([
3408
+ pinnedKey,
3409
+ {
3410
+ direction: "desc",
3411
+ expr: sortedThreads.threadActivityAt,
3412
+ value: cursorRow.threadActivityAt,
3413
+ },
3414
+ {
3415
+ direction: "desc",
3416
+ expr: sortedThreads.threadId,
3417
+ value: cursorRow.threadId,
3418
+ },
3419
+ ]);
3420
+ }
2903
3421
 
2904
- const baseQuery = db
2905
- .select({
2906
- threadId: posts.threadId,
2907
- publishedAt,
2908
- threadActivityAt,
2909
- collectionPinnedAt,
2910
- ratingPresence,
2911
- ratingValue,
2912
- })
2913
- .from(posts)
2914
- .innerJoin(
2915
- postCollections,
2916
- and(
2917
- eq(postCollections.siteId, siteId),
2918
- eq(postCollections.postId, posts.id),
2919
- ),
2920
- )
2921
- .where(and(...conditions))
2922
- .groupBy(posts.threadId);
3422
+ const baseQuery = db.select().from(sortedThreads).where(cursorCondition);
2923
3423
 
2924
3424
  let query =
2925
3425
  sortOrder === "oldest"
2926
3426
  ? baseQuery.orderBy(
2927
- desc(collectionPinnedAt),
2928
- asc(publishedAt),
2929
- asc(posts.threadId),
3427
+ desc(sortedThreads.collectionPinnedAt),
3428
+ asc(sortedThreads.publishedAt),
3429
+ asc(sortedThreads.threadId),
2930
3430
  )
2931
3431
  : sortOrder === "rating_desc"
2932
3432
  ? baseQuery.orderBy(
2933
- desc(collectionPinnedAt),
2934
- desc(ratingPresence),
2935
- desc(ratingValue),
2936
- desc(threadActivityAt),
2937
- desc(posts.threadId),
3433
+ desc(sortedThreads.collectionPinnedAt),
3434
+ desc(sortedThreads.ratingPresence),
3435
+ desc(sortedThreads.ratingValue),
3436
+ desc(sortedThreads.threadActivityAt),
3437
+ desc(sortedThreads.threadId),
2938
3438
  )
2939
3439
  : baseQuery.orderBy(
2940
- desc(collectionPinnedAt),
2941
- desc(threadActivityAt),
2942
- desc(posts.threadId),
3440
+ desc(sortedThreads.collectionPinnedAt),
3441
+ desc(sortedThreads.threadActivityAt),
3442
+ desc(sortedThreads.threadId),
2943
3443
  );
2944
3444
 
2945
3445
  if (options.limit !== undefined) {
@@ -2953,6 +3453,18 @@ export function createPostService(
2953
3453
  return rows.map((row) => row.threadId);
2954
3454
  },
2955
3455
 
3456
+ async listCollectionThreadRootsForCollections(collectionIds, options = {}) {
3457
+ const rootIds = await this.listCollectionThreadRootIdsForCollections(
3458
+ collectionIds,
3459
+ options,
3460
+ );
3461
+ const rootsById = await hydratePostsById(rootIds);
3462
+ return rootIds.flatMap((rootId) => {
3463
+ const root = rootsById.get(rootId);
3464
+ return root ? [root] : [];
3465
+ });
3466
+ },
3467
+
2956
3468
  async listCollectionFeedEntries(collectionId, options = {}) {
2957
3469
  return this.listCollectionFeedEntriesForCollections(
2958
3470
  [collectionId],
@@ -2967,11 +3479,11 @@ export function createPostService(
2967
3479
  ];
2968
3480
  const threadActivityAt =
2969
3481
  buildCollectionThreadActivityExpr("thread_activity_at");
2970
- const collectedAt = sql<number>`MAX(${postCollections.createdAt})`.as(
3482
+ const collectedAt = sql<number>`MAX(${threadCollections.createdAt})`.as(
2971
3483
  "collected_at",
2972
3484
  );
2973
3485
  const collectionPinnedAt =
2974
- sql<number>`MAX(coalesce(${postCollections.pinnedAt}, -1))`.as(
3486
+ sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
2975
3487
  "collection_pinned_at",
2976
3488
  );
2977
3489
  const pinnedOrder = options.ignoreCollectionPinnedSort
@@ -2987,10 +3499,10 @@ export function createPostService(
2987
3499
  })
2988
3500
  .from(posts)
2989
3501
  .innerJoin(
2990
- postCollections,
3502
+ threadCollections,
2991
3503
  and(
2992
- eq(postCollections.siteId, siteId),
2993
- eq(postCollections.postId, posts.id),
3504
+ eq(threadCollections.siteId, posts.siteId),
3505
+ eq(threadCollections.threadId, posts.threadId),
2994
3506
  ),
2995
3507
  )
2996
3508
  .where(and(...conditions))
@@ -3013,21 +3525,23 @@ export function createPostService(
3013
3525
  });
3014
3526
  },
3015
3527
 
3016
- async getPublishedThreads(rootIds) {
3528
+ async getPublishedThreads(rootIds, options = {}) {
3017
3529
  const result = new Map<string, Post[]>();
3018
3530
  if (rootIds.length === 0) return result;
3019
3531
 
3020
3532
  const unique = [...new Set(rootIds)];
3533
+ const conditions = [
3534
+ eq(posts.siteId, siteId),
3535
+ inArray(posts.threadId, unique),
3536
+ eq(posts.status, "published"),
3537
+ ];
3538
+ if (options.publishedBefore !== undefined) {
3539
+ conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
3540
+ }
3021
3541
  const rows = await db
3022
3542
  .select()
3023
3543
  .from(posts)
3024
- .where(
3025
- and(
3026
- eq(posts.siteId, siteId),
3027
- inArray(posts.threadId, unique),
3028
- eq(posts.status, "published"),
3029
- ),
3030
- )
3544
+ .where(and(...conditions))
3031
3545
  .orderBy(posts.threadId, posts.createdAt, posts.id);
3032
3546
 
3033
3547
  for (const post of await hydratePosts(rows)) {
@@ -3042,56 +3556,6 @@ export function createPostService(
3042
3556
  return result;
3043
3557
  },
3044
3558
 
3045
- async getCollectionPostIdsByThread(collectionId, threadIds) {
3046
- return this.getCollectionPostIdsByThreadForCollections(
3047
- [collectionId],
3048
- threadIds,
3049
- );
3050
- },
3051
-
3052
- async getCollectionPostIdsByThreadForCollections(collectionIds, threadIds) {
3053
- const result = new Map<string, string[]>();
3054
- if (threadIds.length === 0) return result;
3055
-
3056
- const unique = [...new Set(threadIds)];
3057
- const rows = await batchQueryRows(unique, (chunk) =>
3058
- db
3059
- .select({
3060
- threadId: posts.threadId,
3061
- postId: posts.id,
3062
- })
3063
- .from(posts)
3064
- .innerJoin(
3065
- postCollections,
3066
- and(
3067
- eq(postCollections.siteId, siteId),
3068
- eq(postCollections.postId, posts.id),
3069
- ),
3070
- )
3071
- .where(
3072
- and(
3073
- eq(posts.siteId, siteId),
3074
- buildCollectionMembershipCondition(collectionIds),
3075
- inArray(posts.threadId, chunk),
3076
- eq(posts.status, "published"),
3077
- ),
3078
- )
3079
- .groupBy(posts.threadId, posts.id)
3080
- .orderBy(posts.threadId, posts.createdAt, posts.id),
3081
- );
3082
-
3083
- for (const row of rows) {
3084
- const list = result.get(row.threadId);
3085
- if (list) {
3086
- list.push(row.postId);
3087
- } else {
3088
- result.set(row.threadId, [row.postId]);
3089
- }
3090
- }
3091
-
3092
- return result;
3093
- },
3094
-
3095
3559
  async getLastPostIdsByThread(threadIds) {
3096
3560
  const result = new Map<string, string>();
3097
3561
  if (threadIds.length === 0) return result;
@@ -3191,5 +3655,144 @@ export function createPostService(
3191
3655
  done: !hasMore,
3192
3656
  };
3193
3657
  },
3658
+
3659
+ async rebuildBodyHtml(options = {}) {
3660
+ const requested = options.limit ?? 50;
3661
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 100);
3662
+ const cursor = options.cursor;
3663
+ const dryRun = options.dryRun ?? false;
3664
+
3665
+ const siteRows = await db
3666
+ .select({ id: sites.id })
3667
+ .from(sites)
3668
+ .where(eq(sites.id, siteId))
3669
+ .limit(1);
3670
+ if (!siteRows[0]) {
3671
+ throw new NotFoundError("Site");
3672
+ }
3673
+
3674
+ const whereConditions = [eq(posts.siteId, siteId)];
3675
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
3676
+
3677
+ const rows = await db
3678
+ .select({
3679
+ id: posts.id,
3680
+ format: posts.format,
3681
+ title: posts.title,
3682
+ body: posts.body,
3683
+ bodyHtml: posts.bodyHtml,
3684
+ bodyHtmlVersion: posts.bodyHtmlVersion,
3685
+ })
3686
+ .from(posts)
3687
+ .where(and(...whereConditions))
3688
+ .orderBy(asc(posts.id))
3689
+ .limit(limit + 1);
3690
+
3691
+ const hasMore = rows.length > limit;
3692
+ const batch = hasMore ? rows.slice(0, limit) : rows;
3693
+ const failures: Array<{ postId: string; error: string }> = [];
3694
+ let wouldRebuild = 0;
3695
+ let rebuilt = 0;
3696
+ let wouldUpgradeFootnotes = 0;
3697
+ let upgradedFootnotes = 0;
3698
+ let skipped = 0;
3699
+ let conflicted = 0;
3700
+
3701
+ for (const row of batch) {
3702
+ if (row.bodyHtmlVersion > POST_BODY_HTML_VERSION) {
3703
+ skipped++;
3704
+ continue;
3705
+ }
3706
+
3707
+ let nextBody = row.body;
3708
+ let nextBodyHtml: string | null = null;
3709
+ let upgradesLegacyFootnotes = false;
3710
+ if (row.body !== null) {
3711
+ const prepared = tryPreparePostBodyHtml(row.id, row.body);
3712
+ if (!prepared.ok) {
3713
+ failures.push({ postId: row.id, error: prepared.error });
3714
+ continue;
3715
+ }
3716
+ nextBody = prepared.body;
3717
+ nextBodyHtml = prepared.html;
3718
+ upgradesLegacyFootnotes = prepared.upgradedLegacyFootnotes;
3719
+ }
3720
+
3721
+ if (
3722
+ row.bodyHtmlVersion === POST_BODY_HTML_VERSION &&
3723
+ row.body === nextBody &&
3724
+ row.bodyHtml === nextBodyHtml
3725
+ ) {
3726
+ skipped++;
3727
+ continue;
3728
+ }
3729
+
3730
+ wouldRebuild++;
3731
+ if (upgradesLegacyFootnotes) wouldUpgradeFootnotes++;
3732
+ if (dryRun) continue;
3733
+
3734
+ const bodyCondition =
3735
+ row.body === null ? isNull(posts.body) : eq(posts.body, row.body);
3736
+ const canonicalBodyChanged = row.body !== nextBody;
3737
+ const updates: Partial<typeof posts.$inferInsert> = {
3738
+ bodyHtml: nextBodyHtml,
3739
+ bodyHtmlVersion: POST_BODY_HTML_VERSION,
3740
+ };
3741
+ if (canonicalBodyChanged) {
3742
+ updates.body = nextBody;
3743
+ updates.bodyText = nextBody
3744
+ ? extractBodyText(nextBody, { includeLinkHrefs: true })
3745
+ : null;
3746
+
3747
+ if (options.summaryConfig) {
3748
+ updates.summary =
3749
+ row.format === "note" && row.title && nextBody
3750
+ ? extractSummary(
3751
+ nextBody,
3752
+ options.summaryConfig.maxParagraphs,
3753
+ options.summaryConfig.maxChars,
3754
+ )
3755
+ : null;
3756
+ }
3757
+ }
3758
+ const updatedRows = await db
3759
+ .update(posts)
3760
+ .set(updates)
3761
+ .where(
3762
+ and(
3763
+ eq(posts.siteId, siteId),
3764
+ eq(posts.id, row.id),
3765
+ bodyCondition,
3766
+ eq(posts.bodyHtmlVersion, row.bodyHtmlVersion),
3767
+ ),
3768
+ )
3769
+ .returning({ id: posts.id });
3770
+
3771
+ if (updatedRows.length === 0) {
3772
+ conflicted++;
3773
+ } else {
3774
+ rebuilt++;
3775
+ if (upgradesLegacyFootnotes) upgradedFootnotes++;
3776
+ }
3777
+ }
3778
+
3779
+ const lastRow = batch.at(-1);
3780
+ const lastId = lastRow ? lastRow.id : null;
3781
+
3782
+ return {
3783
+ processed: batch.length,
3784
+ wouldRebuild,
3785
+ rebuilt,
3786
+ wouldUpgradeFootnotes,
3787
+ upgradedFootnotes,
3788
+ skipped,
3789
+ conflicted,
3790
+ failed: failures.length,
3791
+ failures,
3792
+ nextCursor: hasMore ? lastId : null,
3793
+ done: !hasMore,
3794
+ targetVersion: POST_BODY_HTML_VERSION,
3795
+ };
3796
+ },
3194
3797
  };
3195
3798
  }