@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -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-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -4,13 +4,16 @@ import {
4
4
  createTestDatabase,
5
5
  DEFAULT_TEST_SITE_ID,
6
6
  } from "../../__tests__/helpers/db.js";
7
- import { postCollections, posts } from "../../db/schema.js";
7
+ import { threadCollections, posts, sites } from "../../db/schema.js";
8
8
  import { createPostService } from "../post.js";
9
9
  import { createMediaService } from "../media.js";
10
10
  import { createCollectionService } from "../collection.js";
11
+ import { createNavItemService } from "../navigation.js";
11
12
  import type { Database } from "../../db/index.js";
12
13
  import { createPathService } from "../path.js";
13
14
  import type { MediaService } from "../media.js";
15
+ import { POST_BODY_HTML_VERSION } from "../../lib/post-body-html.js";
16
+ import type BetterSqlite3 from "better-sqlite3";
14
17
 
15
18
  function createMockStorage() {
16
19
  const files = new Map<string, { body: Uint8Array; contentType?: string }>();
@@ -44,12 +47,14 @@ function createMockStorage() {
44
47
 
45
48
  describe("PostService", () => {
46
49
  let db: Database;
50
+ let sqlite: BetterSqlite3.Database;
47
51
  let postService: ReturnType<typeof createPostService>;
48
52
  let collectionService: ReturnType<typeof createCollectionService>;
49
53
 
50
54
  beforeEach(() => {
51
55
  const testDb = createTestDatabase();
52
56
  db = testDb.db as unknown as Database;
57
+ sqlite = testDb.sqlite;
53
58
  postService = createPostService(
54
59
  db,
55
60
  { slugIdLength: 5 },
@@ -152,6 +157,26 @@ describe("PostService", () => {
152
157
  expect(post.bodyHtml).toContain("<strong>bold</strong>");
153
158
  });
154
159
 
160
+ it("writes current namespaced body HTML projection metadata", async () => {
161
+ const post = await postService.create({
162
+ format: "note",
163
+ bodyMarkdown: "Body[^1]\n\n[^1]: Definition",
164
+ });
165
+ const row = await db
166
+ .select({
167
+ bodyHtml: posts.bodyHtml,
168
+ bodyHtmlVersion: posts.bodyHtmlVersion,
169
+ })
170
+ .from(posts)
171
+ .where(eq(posts.id, post.id))
172
+ .limit(1);
173
+
174
+ expect(row[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
175
+ expect(row[0]?.bodyHtml).toMatch(/id="fn-[a-z0-9]{13}-1"/);
176
+ expect(row[0]?.bodyHtml).not.toContain(post.id);
177
+ expect(post.bodyHtml).toContain('role="doc-endnotes"');
178
+ });
179
+
155
180
  it("sets publishedAt and timestamps", async () => {
156
181
  const post = await postService.create({
157
182
  format: "note",
@@ -344,6 +369,16 @@ describe("PostService", () => {
344
369
  ).rejects.toThrow("Parent post not found");
345
370
  });
346
371
 
372
+ it("rejects reserved paths that would otherwise become aliases", async () => {
373
+ await expect(
374
+ postService.create({
375
+ format: "note",
376
+ bodyMarkdown: "reserved path",
377
+ path: "skill.md",
378
+ }),
379
+ ).rejects.toThrow('Path "skill.md" is reserved and cannot be used');
380
+ });
381
+
347
382
  it("rolls back the post insert when slug persistence fails inside the batch", async () => {
348
383
  await postService.create({
349
384
  format: "note",
@@ -683,6 +718,55 @@ describe("PostService", () => {
683
718
  expect(notFeatured[0]?.bodyText).toBe("normal post");
684
719
  });
685
720
 
721
+ it("orders Featured Post lists and cursors by publishedAt", async () => {
722
+ const oldest = await postService.create({
723
+ format: "note",
724
+ bodyMarkdown: "oldest",
725
+ featured: true,
726
+ publishedAt: 1000,
727
+ });
728
+ const middle = await postService.create({
729
+ format: "note",
730
+ bodyMarkdown: "middle",
731
+ featured: true,
732
+ publishedAt: 2000,
733
+ });
734
+ const newest = await postService.create({
735
+ format: "note",
736
+ bodyMarkdown: "newest",
737
+ featured: true,
738
+ publishedAt: 3000,
739
+ });
740
+
741
+ await db
742
+ .update(posts)
743
+ .set({ featuredAt: 9000 })
744
+ .where(eq(posts.id, oldest.id));
745
+ await db
746
+ .update(posts)
747
+ .set({ featuredAt: 8000 })
748
+ .where(eq(posts.id, middle.id));
749
+ await db
750
+ .update(posts)
751
+ .set({ featuredAt: 7000 })
752
+ .where(eq(posts.id, newest.id));
753
+
754
+ const firstPage = await postService.list({
755
+ featured: true,
756
+ ignorePinnedSort: true,
757
+ limit: 2,
758
+ });
759
+ const secondPage = await postService.list({
760
+ featured: true,
761
+ ignorePinnedSort: true,
762
+ cursor: middle.id,
763
+ limit: 2,
764
+ });
765
+
766
+ expect(firstPage.map((post) => post.id)).toEqual([newest.id, middle.id]);
767
+ expect(secondPage.map((post) => post.id)).toEqual([oldest.id]);
768
+ });
769
+
686
770
  it("excludes posts hidden from Latest when requested", async () => {
687
771
  await postService.create({
688
772
  format: "note",
@@ -1072,9 +1156,9 @@ describe("PostService", () => {
1072
1156
  rating: i + 1,
1073
1157
  });
1074
1158
 
1075
- await db.insert(postCollections).values({
1159
+ await db.insert(threadCollections).values({
1076
1160
  siteId: DEFAULT_TEST_SITE_ID,
1077
- postId: post.id,
1161
+ threadId: post.id,
1078
1162
  collectionId: collection.id,
1079
1163
  createdAt: 100 + i,
1080
1164
  });
@@ -1298,6 +1382,76 @@ describe("PostService", () => {
1298
1382
  }
1299
1383
  });
1300
1384
 
1385
+ it("updates the shared Thread collections when editing a child", async () => {
1386
+ const firstCollection = await collectionService.create({
1387
+ slug: "first-thread-collection",
1388
+ title: "First",
1389
+ });
1390
+ const secondCollection = await collectionService.create({
1391
+ slug: "second-thread-collection",
1392
+ title: "Second",
1393
+ });
1394
+ const root = await postService.create({
1395
+ format: "note",
1396
+ bodyMarkdown: "root",
1397
+ collectionIds: [firstCollection.id],
1398
+ });
1399
+ const child = await postService.create({
1400
+ format: "note",
1401
+ bodyMarkdown: "child",
1402
+ replyToId: root.id,
1403
+ });
1404
+
1405
+ expect(
1406
+ (await collectionService.getCollectionsByPostId(child.id)).map(
1407
+ (collection) => collection.id,
1408
+ ),
1409
+ ).toEqual([firstCollection.id]);
1410
+
1411
+ await postService.update(child.id, {
1412
+ collectionIds: [secondCollection.id],
1413
+ });
1414
+
1415
+ const [rootCollections, childCollections, rows] = await Promise.all([
1416
+ collectionService.getCollectionsByPostId(root.id),
1417
+ collectionService.getCollectionsByPostId(child.id),
1418
+ db.select().from(threadCollections),
1419
+ ]);
1420
+ expect(rootCollections.map((collection) => collection.id)).toEqual([
1421
+ secondCollection.id,
1422
+ ]);
1423
+ expect(childCollections.map((collection) => collection.id)).toEqual([
1424
+ secondCollection.id,
1425
+ ]);
1426
+ expect(rows).toEqual([
1427
+ expect.objectContaining({
1428
+ threadId: root.id,
1429
+ collectionId: secondCollection.id,
1430
+ }),
1431
+ ]);
1432
+ });
1433
+
1434
+ it("rejects Collection membership while creating a child", async () => {
1435
+ const collection = await collectionService.create({
1436
+ slug: "reply-create-collection",
1437
+ title: "Reply Create Collection",
1438
+ });
1439
+ const root = await postService.create({
1440
+ format: "note",
1441
+ bodyMarkdown: "root",
1442
+ });
1443
+
1444
+ await expect(
1445
+ postService.create({
1446
+ format: "note",
1447
+ bodyMarkdown: "child",
1448
+ replyToId: root.id,
1449
+ collectionIds: [collection.id],
1450
+ }),
1451
+ ).rejects.toThrow("Cannot set Collections while creating a Thread reply");
1452
+ expect(await collectionService.getThreadIds(collection.id)).toEqual([]);
1453
+ });
1454
+
1301
1455
  it("sets publishedAt when publishing a draft", async () => {
1302
1456
  const post = await postService.create({
1303
1457
  format: "note",
@@ -1316,6 +1470,9 @@ describe("PostService", () => {
1316
1470
  expect(published?.status).toBe("published");
1317
1471
  expect(published?.publishedAt).toBeTypeOf("number");
1318
1472
  expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
1473
+ expect(
1474
+ (await postService.list({ status: "draft" })).map((draft) => draft.id),
1475
+ ).not.toContain(post.id);
1319
1476
  });
1320
1477
 
1321
1478
  it("clears publishedAt when converting a published post back to draft", async () => {
@@ -1413,11 +1570,17 @@ describe("PostService", () => {
1413
1570
  expect(updated?.pinnedAt).toBeTypeOf("number");
1414
1571
  });
1415
1572
 
1416
- it("updates slug", async () => {
1573
+ it("updates slug and its page navigation URL", async () => {
1417
1574
  const post = await postService.create({
1418
1575
  format: "note",
1419
- bodyMarkdown: "test",
1576
+ title: "Test page",
1420
1577
  slug: "old-slug",
1578
+ visibility: "latest_hidden",
1579
+ });
1580
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1581
+ const navItem = await navItemService.create({
1582
+ type: "page",
1583
+ postId: post.id,
1421
1584
  });
1422
1585
 
1423
1586
  const updated = await postService.update(post.id, {
@@ -1425,6 +1588,25 @@ describe("PostService", () => {
1425
1588
  });
1426
1589
 
1427
1590
  expect(updated?.slug).toBe("new-slug");
1591
+ expect(await navItemService.getById(navItem.id)).toMatchObject({
1592
+ label: "Test page",
1593
+ url: "/new-slug",
1594
+ });
1595
+ });
1596
+
1597
+ it("removes page navigation when the page becomes a draft", async () => {
1598
+ const post = await postService.create({
1599
+ format: "note",
1600
+ title: "Temporary page",
1601
+ slug: "temporary-page",
1602
+ visibility: "latest_hidden",
1603
+ });
1604
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1605
+ await navItemService.create({ type: "page", postId: post.id });
1606
+
1607
+ await postService.update(post.id, { status: "draft" });
1608
+
1609
+ expect(await navItemService.list()).toEqual([]);
1428
1610
  });
1429
1611
 
1430
1612
  it("updates quoteText and rating", async () => {
@@ -1601,6 +1783,21 @@ describe("PostService", () => {
1601
1783
  expect(found).toBeNull();
1602
1784
  });
1603
1785
 
1786
+ it("cascade deletes the post's page navigation item", async () => {
1787
+ const post = await postService.create({
1788
+ format: "note",
1789
+ title: "Disposable page",
1790
+ slug: "disposable-page",
1791
+ visibility: "latest_hidden",
1792
+ });
1793
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1794
+ await navItemService.create({ type: "page", postId: post.id });
1795
+
1796
+ await postService.delete(post.id);
1797
+
1798
+ expect(await navItemService.list()).toEqual([]);
1799
+ });
1800
+
1604
1801
  it("returns false for non-existent post", async () => {
1605
1802
  const result = await postService.delete(9999);
1606
1803
  expect(result).toBe(false);
@@ -1662,6 +1859,92 @@ describe("PostService", () => {
1662
1859
  });
1663
1860
  });
1664
1861
 
1862
+ describe("createThreadWithAttachments", () => {
1863
+ // No attachments in these cases, so only the up-front id check is reached.
1864
+ const deps = {
1865
+ media: { validateIds: async () => [] },
1866
+ } as unknown as Parameters<
1867
+ typeof postService.createThreadWithAttachments
1868
+ >[1];
1869
+
1870
+ it("dates every reply from the root when the root is backdated", async () => {
1871
+ const backdated = 1710000000; // 2024-03-09
1872
+
1873
+ const created = await postService.createThreadWithAttachments(
1874
+ [
1875
+ {
1876
+ data: {
1877
+ format: "note",
1878
+ bodyMarkdown: "root",
1879
+ publishedAt: backdated,
1880
+ },
1881
+ attachments: [],
1882
+ },
1883
+ { data: { format: "note", bodyMarkdown: "second" }, attachments: [] },
1884
+ { data: { format: "note", bodyMarkdown: "third" }, attachments: [] },
1885
+ ],
1886
+ deps,
1887
+ undefined,
1888
+ );
1889
+
1890
+ // Without inheritance the replies would land on today and the thread
1891
+ // would read as if it spanned two years.
1892
+ expect(created.map((p) => p.publishedAt)).toEqual([
1893
+ backdated,
1894
+ backdated,
1895
+ backdated,
1896
+ ]);
1897
+ // Order still comes from createdAt/id, so the chain is intact.
1898
+ expect(created[1].replyToId).toBe(created[0].id);
1899
+ expect(created[2].replyToId).toBe(created[1].id);
1900
+ });
1901
+
1902
+ it("keeps a reply's own date when one is given", async () => {
1903
+ const rootAt = 1710000000;
1904
+ const replyAt = 1720000000;
1905
+
1906
+ const created = await postService.createThreadWithAttachments(
1907
+ [
1908
+ {
1909
+ data: { format: "note", bodyMarkdown: "root", publishedAt: rootAt },
1910
+ attachments: [],
1911
+ },
1912
+ {
1913
+ data: {
1914
+ format: "note",
1915
+ bodyMarkdown: "second",
1916
+ publishedAt: replyAt,
1917
+ },
1918
+ attachments: [],
1919
+ },
1920
+ ],
1921
+ deps,
1922
+ undefined,
1923
+ );
1924
+
1925
+ expect(created.map((p) => p.publishedAt)).toEqual([rootAt, replyAt]);
1926
+ });
1927
+
1928
+ it("leaves draft threads undated", async () => {
1929
+ const created = await postService.createThreadWithAttachments(
1930
+ [
1931
+ {
1932
+ data: { format: "note", bodyMarkdown: "root", status: "draft" },
1933
+ attachments: [],
1934
+ },
1935
+ {
1936
+ data: { format: "note", bodyMarkdown: "second", status: "draft" },
1937
+ attachments: [],
1938
+ },
1939
+ ],
1940
+ deps,
1941
+ undefined,
1942
+ );
1943
+
1944
+ expect(created.map((p) => p.publishedAt)).toEqual([null, null]);
1945
+ });
1946
+ });
1947
+
1665
1948
  describe("threads", () => {
1666
1949
  it("sets threadId on reply to a root post", async () => {
1667
1950
  const root = await postService.create({
@@ -1721,6 +2004,143 @@ describe("PostService", () => {
1721
2004
  );
1722
2005
  });
1723
2006
 
2007
+ it("refuses to feature an unpublished post", async () => {
2008
+ await expect(
2009
+ postService.create({
2010
+ format: "note",
2011
+ bodyMarkdown: "not ready",
2012
+ status: "draft",
2013
+ featured: true,
2014
+ }),
2015
+ ).rejects.toThrow("Publish this post before featuring it.");
2016
+
2017
+ const draft = await postService.create({
2018
+ format: "note",
2019
+ bodyMarkdown: "not ready",
2020
+ status: "draft",
2021
+ });
2022
+ await expect(
2023
+ postService.update(draft.id, { featured: true }),
2024
+ ).rejects.toThrow("Publish this post before featuring it.");
2025
+ });
2026
+
2027
+ it("keeps a featured flag through a trip back to draft", async () => {
2028
+ const post = await postService.create({
2029
+ format: "note",
2030
+ bodyMarkdown: "published",
2031
+ featured: true,
2032
+ });
2033
+
2034
+ const unpublished = await postService.update(post.id, {
2035
+ status: "draft",
2036
+ });
2037
+
2038
+ expect(unpublished?.status).toBe("draft");
2039
+ expect(unpublished?.featuredAt).not.toBeNull();
2040
+ });
2041
+
2042
+ it("publishes a reply whose parent is still a draft", async () => {
2043
+ const root = await postService.create({
2044
+ format: "note",
2045
+ bodyMarkdown: "root",
2046
+ });
2047
+ const draft = await postService.create({
2048
+ format: "note",
2049
+ bodyMarkdown: "unfinished",
2050
+ replyToId: root.id,
2051
+ status: "draft",
2052
+ });
2053
+
2054
+ const next = await postService.create({
2055
+ format: "note",
2056
+ bodyMarkdown: "continues past the draft",
2057
+ replyToId: draft.id,
2058
+ });
2059
+
2060
+ // Readers never saw the draft, so publishing past it reads as
2061
+ // continuous. The draft stays parked until it is dealt with on its own.
2062
+ expect(next.status).toBe("published");
2063
+ expect(draft.status).toBe("draft");
2064
+ });
2065
+
2066
+ it("publishes only the post asked for, leaving other drafts alone", async () => {
2067
+ const root = await postService.create({
2068
+ format: "note",
2069
+ bodyMarkdown: "root",
2070
+ });
2071
+ const first = await postService.create({
2072
+ format: "note",
2073
+ bodyMarkdown: "first draft",
2074
+ replyToId: root.id,
2075
+ status: "draft",
2076
+ });
2077
+ const second = await postService.create({
2078
+ format: "note",
2079
+ bodyMarkdown: "second draft",
2080
+ replyToId: first.id,
2081
+ status: "draft",
2082
+ });
2083
+
2084
+ await postService.update(second.id, { status: "published" });
2085
+
2086
+ const statusById = new Map(
2087
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2088
+ );
2089
+ expect(statusById.get(first.id)).toBe("draft");
2090
+ expect(statusById.get(second.id)).toBe("published");
2091
+ });
2092
+
2093
+ it("keeps a draft between two published replies", async () => {
2094
+ const root = await postService.create({
2095
+ format: "note",
2096
+ bodyMarkdown: "root",
2097
+ });
2098
+ const middle = await postService.create({
2099
+ format: "note",
2100
+ bodyMarkdown: "middle",
2101
+ replyToId: root.id,
2102
+ });
2103
+ const last = await postService.create({
2104
+ format: "note",
2105
+ bodyMarkdown: "last",
2106
+ replyToId: middle.id,
2107
+ });
2108
+
2109
+ await postService.update(middle.id, { status: "draft" });
2110
+
2111
+ const statusById = new Map(
2112
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2113
+ );
2114
+ // Unpublishing one post touches only that post. The public thread simply
2115
+ // skips it; nothing else in the chain changes.
2116
+ expect(statusById.get(root.id)).toBe("published");
2117
+ expect(statusById.get(middle.id)).toBe("draft");
2118
+ expect(statusById.get(last.id)).toBe("published");
2119
+ });
2120
+
2121
+ it("names the draft when an unpublished reply holds the thread tail", async () => {
2122
+ const root = await postService.create({
2123
+ format: "note",
2124
+ bodyMarkdown: "root",
2125
+ });
2126
+ await postService.create({
2127
+ format: "note",
2128
+ bodyMarkdown: "unfinished",
2129
+ replyToId: root.id,
2130
+ status: "draft",
2131
+ });
2132
+
2133
+ await expect(
2134
+ postService.create({
2135
+ format: "note",
2136
+ bodyMarkdown: "reply",
2137
+ replyToId: root.id,
2138
+ }),
2139
+ ).rejects.toThrow(
2140
+ "This thread ends with an unpublished draft. Finish that draft or discard it, then reply.",
2141
+ );
2142
+ });
2143
+
1724
2144
  it("inherits status from root post", async () => {
1725
2145
  const root = await postService.create({
1726
2146
  format: "note",
@@ -1831,6 +2251,57 @@ describe("PostService", () => {
1831
2251
  expect(thread[0]?.bodyText).toBe("root");
1832
2252
  });
1833
2253
 
2254
+ it("getThreadPosition counts the chain from the root down to a post", async () => {
2255
+ const root = await postService.create({
2256
+ format: "note",
2257
+ bodyMarkdown: "root",
2258
+ });
2259
+ const reply1 = await postService.create({
2260
+ format: "note",
2261
+ bodyMarkdown: "reply1",
2262
+ replyToId: root.id,
2263
+ });
2264
+ const reply2 = await postService.create({
2265
+ format: "note",
2266
+ bodyMarkdown: "reply2",
2267
+ replyToId: reply1.id,
2268
+ });
2269
+
2270
+ expect(await postService.getThreadPosition(root.id)).toBe(1);
2271
+ expect(await postService.getThreadPosition(reply1.id)).toBe(2);
2272
+ expect(await postService.getThreadPosition(reply2.id)).toBe(3);
2273
+ });
2274
+
2275
+ it("getThreadPosition walks the reply chain rather than counting members", async () => {
2276
+ // `create` only allows replying to the tail, so today a thread is a
2277
+ // straight chain and depth happens to equal its size. The walk is what
2278
+ // makes the two agree — counting `threadId` members would give the same
2279
+ // answer here and the wrong one the moment a draft chain is incomplete.
2280
+ const root = await postService.create({
2281
+ format: "note",
2282
+ bodyMarkdown: "root",
2283
+ });
2284
+ const tail = await postService.create({
2285
+ format: "note",
2286
+ bodyMarkdown: "tail",
2287
+ replyToId: root.id,
2288
+ });
2289
+
2290
+ // A second post filed under the same thread without joining the chain:
2291
+ // it is not above the tail, so it must not push the tail's position.
2292
+ await postService.create({
2293
+ format: "note",
2294
+ bodyMarkdown: "unrelated",
2295
+ });
2296
+
2297
+ expect(await postService.getThreadPosition(tail.id)).toBe(2);
2298
+ expect(await postService.getThread(root.id)).toHaveLength(2);
2299
+ });
2300
+
2301
+ it("getThreadPosition returns 0 for a post that does not exist", async () => {
2302
+ expect(await postService.getThreadPosition("pst_missing")).toBe(0);
2303
+ });
2304
+
1834
2305
  it("getThread excludes deleted posts", async () => {
1835
2306
  const root = await postService.create({
1836
2307
  format: "note",
@@ -2286,6 +2757,374 @@ describe("PostService", () => {
2286
2757
  });
2287
2758
  });
2288
2759
 
2760
+ describe("rebuildBodyHtml", () => {
2761
+ async function createFootnotePost(label: string) {
2762
+ return postService.create({
2763
+ format: "note",
2764
+ bodyMarkdown: `Body ${label}[^1]\n\n[^1]: Definition ${label}`,
2765
+ });
2766
+ }
2767
+
2768
+ function legacyFootnoteBody(): string {
2769
+ return JSON.stringify({
2770
+ type: "doc",
2771
+ content: [
2772
+ {
2773
+ type: "paragraph",
2774
+ content: [
2775
+ { type: "text", text: "Body " },
2776
+ {
2777
+ type: "text",
2778
+ text: "[1]",
2779
+ marks: [{ type: "link", attrs: { href: "#fn-1" } }],
2780
+ },
2781
+ ],
2782
+ },
2783
+ { type: "horizontalRule" },
2784
+ {
2785
+ type: "orderedList",
2786
+ content: [
2787
+ {
2788
+ type: "listItem",
2789
+ content: [
2790
+ {
2791
+ type: "paragraph",
2792
+ content: [
2793
+ { type: "text", text: "Definition " },
2794
+ {
2795
+ type: "text",
2796
+ text: "↩︎",
2797
+ marks: [{ type: "link", attrs: { href: "#fnref-1" } }],
2798
+ },
2799
+ ],
2800
+ },
2801
+ ],
2802
+ },
2803
+ ],
2804
+ },
2805
+ ],
2806
+ });
2807
+ }
2808
+
2809
+ it("resolves stale stored HTML on reads before the rebuild writes", async () => {
2810
+ const post = await createFootnotePost("stale");
2811
+ await db
2812
+ .update(posts)
2813
+ .set({
2814
+ bodyHtml: '<span class="sidenote">legacy</span>',
2815
+ bodyHtmlVersion: 1,
2816
+ })
2817
+ .where(eq(posts.id, post.id));
2818
+
2819
+ const resolved = await postService.getById(post.id);
2820
+ expect(resolved?.bodyHtml).toContain('role="doc-noteref"');
2821
+ expect(resolved?.bodyHtml).toContain('role="doc-endnotes"');
2822
+ expect(resolved?.bodyHtml).not.toContain("legacy");
2823
+
2824
+ const stored = await db
2825
+ .select({ bodyHtml: posts.bodyHtml })
2826
+ .from(posts)
2827
+ .where(eq(posts.id, post.id))
2828
+ .limit(1);
2829
+ expect(stored[0]?.bodyHtml).toContain("legacy");
2830
+ });
2831
+
2832
+ it("supports dry-run, preserves timestamps, and is idempotent", async () => {
2833
+ const post = await createFootnotePost("rebuild");
2834
+ await db
2835
+ .update(posts)
2836
+ .set({
2837
+ bodyHtml: '<span class="sidenote">legacy</span>',
2838
+ bodyHtmlVersion: 1,
2839
+ })
2840
+ .where(eq(posts.id, post.id));
2841
+
2842
+ const before = await db
2843
+ .select({
2844
+ updatedAt: posts.updatedAt,
2845
+ publishedAt: posts.publishedAt,
2846
+ })
2847
+ .from(posts)
2848
+ .where(eq(posts.id, post.id))
2849
+ .limit(1);
2850
+
2851
+ const dryRun = await postService.rebuildBodyHtml({ dryRun: true });
2852
+ expect(dryRun).toMatchObject({
2853
+ processed: 1,
2854
+ wouldRebuild: 1,
2855
+ rebuilt: 0,
2856
+ skipped: 0,
2857
+ conflicted: 0,
2858
+ failed: 0,
2859
+ done: true,
2860
+ targetVersion: POST_BODY_HTML_VERSION,
2861
+ });
2862
+
2863
+ const firstPass = await postService.rebuildBodyHtml();
2864
+ expect(firstPass).toMatchObject({
2865
+ wouldRebuild: 1,
2866
+ rebuilt: 1,
2867
+ failed: 0,
2868
+ });
2869
+
2870
+ const stored = await db
2871
+ .select({
2872
+ bodyHtml: posts.bodyHtml,
2873
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2874
+ updatedAt: posts.updatedAt,
2875
+ publishedAt: posts.publishedAt,
2876
+ })
2877
+ .from(posts)
2878
+ .where(eq(posts.id, post.id))
2879
+ .limit(1);
2880
+ expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
2881
+ expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
2882
+ expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
2883
+ expect(stored[0]?.publishedAt).toBe(before[0]?.publishedAt);
2884
+
2885
+ const secondPass = await postService.rebuildBodyHtml();
2886
+ expect(secondPass).toMatchObject({
2887
+ wouldRebuild: 0,
2888
+ rebuilt: 0,
2889
+ skipped: 1,
2890
+ failed: 0,
2891
+ });
2892
+ });
2893
+
2894
+ it("atomically upgrades historical generic footnotes and their projections", async () => {
2895
+ const post = await postService.create({
2896
+ format: "note",
2897
+ title: "Legacy",
2898
+ bodyMarkdown: "Placeholder",
2899
+ });
2900
+ const legacyBody = legacyFootnoteBody();
2901
+ await db
2902
+ .update(posts)
2903
+ .set({
2904
+ body: legacyBody,
2905
+ bodyHtml:
2906
+ '<p>Body <a href="#fn-1">[1]</a></p><hr><ol><li><p>Definition <a href="#fnref-1">↩︎</a></p></li></ol>',
2907
+ bodyHtmlVersion: 3,
2908
+ bodyText: "Body [1] #fn-1 Definition ↩︎ #fnref-1",
2909
+ summary: "Body [1]\n\nDefinition ↩︎",
2910
+ })
2911
+ .where(eq(posts.id, post.id));
2912
+ const before = await db
2913
+ .select({ updatedAt: posts.updatedAt, body: posts.body })
2914
+ .from(posts)
2915
+ .where(eq(posts.id, post.id))
2916
+ .limit(1);
2917
+
2918
+ const dryRun = await postService.rebuildBodyHtml({
2919
+ dryRun: true,
2920
+ summaryConfig: { maxParagraphs: 5, maxChars: 500 },
2921
+ });
2922
+ expect(dryRun).toMatchObject({
2923
+ wouldRebuild: 1,
2924
+ rebuilt: 0,
2925
+ wouldUpgradeFootnotes: 1,
2926
+ upgradedFootnotes: 0,
2927
+ failed: 0,
2928
+ });
2929
+ expect(
2930
+ await db
2931
+ .select({ body: posts.body })
2932
+ .from(posts)
2933
+ .where(eq(posts.id, post.id))
2934
+ .limit(1),
2935
+ ).toEqual([{ body: legacyBody }]);
2936
+
2937
+ const result = await postService.rebuildBodyHtml({
2938
+ summaryConfig: { maxParagraphs: 5, maxChars: 500 },
2939
+ });
2940
+ expect(result).toMatchObject({
2941
+ rebuilt: 1,
2942
+ wouldUpgradeFootnotes: 1,
2943
+ upgradedFootnotes: 1,
2944
+ conflicted: 0,
2945
+ failed: 0,
2946
+ });
2947
+
2948
+ const stored = await db
2949
+ .select({
2950
+ body: posts.body,
2951
+ bodyHtml: posts.bodyHtml,
2952
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2953
+ bodyText: posts.bodyText,
2954
+ summary: posts.summary,
2955
+ updatedAt: posts.updatedAt,
2956
+ })
2957
+ .from(posts)
2958
+ .where(eq(posts.id, post.id))
2959
+ .limit(1);
2960
+ expect(stored[0]?.body).toContain('"type":"footnoteReference"');
2961
+ expect(stored[0]?.body).toContain('"type":"footnoteDefinition"');
2962
+ expect(stored[0]?.bodyHtml).toContain('role="doc-noteref"');
2963
+ expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
2964
+ expect(stored[0]?.bodyText).toBe("Body Definition");
2965
+ expect(stored[0]?.summary).toBe("Body");
2966
+ expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
2967
+ expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
2968
+
2969
+ await expect(postService.rebuildBodyHtml()).resolves.toMatchObject({
2970
+ wouldRebuild: 0,
2971
+ wouldUpgradeFootnotes: 0,
2972
+ upgradedFootnotes: 0,
2973
+ skipped: 1,
2974
+ });
2975
+ });
2976
+
2977
+ it("reports malformed canonical bodies without marking them current", async () => {
2978
+ const post = await createFootnotePost("invalid");
2979
+ await db
2980
+ .update(posts)
2981
+ .set({
2982
+ body: "not json",
2983
+ bodyHtml: "<p>legacy fallback</p>",
2984
+ bodyHtmlVersion: 1,
2985
+ })
2986
+ .where(eq(posts.id, post.id));
2987
+
2988
+ const result = await postService.rebuildBodyHtml();
2989
+ expect(result.failed).toBe(1);
2990
+ expect(result.failures[0]?.postId).toBe(post.id);
2991
+ expect(result.rebuilt).toBe(0);
2992
+
2993
+ const stored = await db
2994
+ .select({
2995
+ bodyHtml: posts.bodyHtml,
2996
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2997
+ })
2998
+ .from(posts)
2999
+ .where(eq(posts.id, post.id))
3000
+ .limit(1);
3001
+ expect(stored[0]).toEqual({
3002
+ bodyHtml: "<p>legacy fallback</p>",
3003
+ bodyHtmlVersion: 1,
3004
+ });
3005
+ expect((await postService.getById(post.id))?.bodyHtml).toBe(
3006
+ "<p>legacy fallback</p>",
3007
+ );
3008
+ });
3009
+
3010
+ it("paginates deterministically", async () => {
3011
+ const created = [];
3012
+ for (const label of ["one", "two", "three"]) {
3013
+ created.push(await createFootnotePost(label));
3014
+ }
3015
+ await db
3016
+ .update(posts)
3017
+ .set({ bodyHtmlVersion: 1 })
3018
+ .where(eq(posts.siteId, DEFAULT_TEST_SITE_ID));
3019
+
3020
+ const first = await postService.rebuildBodyHtml({ limit: 2 });
3021
+ expect(first.processed).toBe(2);
3022
+ expect(first.done).toBe(false);
3023
+ expect(first.nextCursor).not.toBeNull();
3024
+
3025
+ const second = await postService.rebuildBodyHtml({
3026
+ limit: 2,
3027
+ cursor: first.nextCursor ?? undefined,
3028
+ });
3029
+ expect(second.processed).toBe(1);
3030
+ expect(second.done).toBe(true);
3031
+ expect(first.rebuilt + second.rebuilt).toBe(created.length);
3032
+ });
3033
+
3034
+ it("lets a concurrent canonical-body edit win the compare-and-swap", async () => {
3035
+ const post = await createFootnotePost("conflict");
3036
+ await db
3037
+ .update(posts)
3038
+ .set({ bodyHtmlVersion: 1 })
3039
+ .where(eq(posts.id, post.id));
3040
+
3041
+ const concurrentBody = JSON.stringify({
3042
+ type: "doc",
3043
+ content: [
3044
+ {
3045
+ type: "paragraph",
3046
+ content: [{ type: "text", text: "Concurrent edit" }],
3047
+ },
3048
+ ],
3049
+ });
3050
+ let injected = false;
3051
+ const conflictDb = new Proxy(db, {
3052
+ get(target, property, receiver) {
3053
+ if (property === "update") {
3054
+ return (...args: Parameters<Database["update"]>) => {
3055
+ if (!injected) {
3056
+ injected = true;
3057
+ sqlite
3058
+ .prepare("UPDATE post SET body = ? WHERE id = ?")
3059
+ .run(concurrentBody, post.id);
3060
+ }
3061
+ return target.update(...args);
3062
+ };
3063
+ }
3064
+
3065
+ const value = Reflect.get(target, property, receiver) as unknown;
3066
+ return typeof value === "function" ? value.bind(target) : value;
3067
+ },
3068
+ });
3069
+ const conflictService = createPostService(
3070
+ conflictDb,
3071
+ { slugIdLength: 5 },
3072
+ DEFAULT_TEST_SITE_ID,
3073
+ );
3074
+
3075
+ const result = await conflictService.rebuildBodyHtml();
3076
+
3077
+ expect(result).toMatchObject({ rebuilt: 0, conflicted: 1 });
3078
+ const stored = await db
3079
+ .select({ body: posts.body, version: posts.bodyHtmlVersion })
3080
+ .from(posts)
3081
+ .where(eq(posts.id, post.id))
3082
+ .limit(1);
3083
+ expect(stored[0]).toEqual({ body: concurrentBody, version: 1 });
3084
+ });
3085
+
3086
+ it("never widens a rebuild to another site", async () => {
3087
+ const defaultPost = await createFootnotePost("default");
3088
+ const secondSiteId = "sit_second000000000000000000000";
3089
+ const timestamp = Math.floor(Date.now() / 1000);
3090
+ await db.insert(sites).values({
3091
+ id: secondSiteId,
3092
+ key: "second",
3093
+ status: "active",
3094
+ createdAt: timestamp,
3095
+ updatedAt: timestamp,
3096
+ });
3097
+ const secondService = createPostService(
3098
+ db,
3099
+ { slugIdLength: 5 },
3100
+ secondSiteId,
3101
+ );
3102
+ const secondPost = await secondService.create({
3103
+ format: "note",
3104
+ bodyMarkdown: "Second[^1]\n\n[^1]: Other site",
3105
+ });
3106
+ await db
3107
+ .update(posts)
3108
+ .set({ bodyHtmlVersion: 1 })
3109
+ .where(eq(posts.id, defaultPost.id));
3110
+ await db
3111
+ .update(posts)
3112
+ .set({ bodyHtmlVersion: 1 })
3113
+ .where(eq(posts.id, secondPost.id));
3114
+
3115
+ const result = await postService.rebuildBodyHtml();
3116
+ expect(result.processed).toBe(1);
3117
+
3118
+ const rows = await db
3119
+ .select({ id: posts.id, version: posts.bodyHtmlVersion })
3120
+ .from(posts);
3121
+ expect(rows.find((row) => row.id === defaultPost.id)?.version).toBe(
3122
+ POST_BODY_HTML_VERSION,
3123
+ );
3124
+ expect(rows.find((row) => row.id === secondPost.id)?.version).toBe(1);
3125
+ });
3126
+ });
3127
+
2289
3128
  describe("listForSitemap", () => {
2290
3129
  it("returns published non-reply non-private non-deleted posts", async () => {
2291
3130
  const root = await postService.create({