@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
@@ -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",
@@ -1413,11 +1567,17 @@ describe("PostService", () => {
1413
1567
  expect(updated?.pinnedAt).toBeTypeOf("number");
1414
1568
  });
1415
1569
 
1416
- it("updates slug", async () => {
1570
+ it("updates slug and its page navigation URL", async () => {
1417
1571
  const post = await postService.create({
1418
1572
  format: "note",
1419
- bodyMarkdown: "test",
1573
+ title: "Test page",
1420
1574
  slug: "old-slug",
1575
+ visibility: "latest_hidden",
1576
+ });
1577
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1578
+ const navItem = await navItemService.create({
1579
+ type: "page",
1580
+ postId: post.id,
1421
1581
  });
1422
1582
 
1423
1583
  const updated = await postService.update(post.id, {
@@ -1425,6 +1585,25 @@ describe("PostService", () => {
1425
1585
  });
1426
1586
 
1427
1587
  expect(updated?.slug).toBe("new-slug");
1588
+ expect(await navItemService.getById(navItem.id)).toMatchObject({
1589
+ label: "Test page",
1590
+ url: "/new-slug",
1591
+ });
1592
+ });
1593
+
1594
+ it("removes page navigation when the page becomes a draft", async () => {
1595
+ const post = await postService.create({
1596
+ format: "note",
1597
+ title: "Temporary page",
1598
+ slug: "temporary-page",
1599
+ visibility: "latest_hidden",
1600
+ });
1601
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1602
+ await navItemService.create({ type: "page", postId: post.id });
1603
+
1604
+ await postService.update(post.id, { status: "draft" });
1605
+
1606
+ expect(await navItemService.list()).toEqual([]);
1428
1607
  });
1429
1608
 
1430
1609
  it("updates quoteText and rating", async () => {
@@ -1601,6 +1780,21 @@ describe("PostService", () => {
1601
1780
  expect(found).toBeNull();
1602
1781
  });
1603
1782
 
1783
+ it("cascade deletes the post's page navigation item", async () => {
1784
+ const post = await postService.create({
1785
+ format: "note",
1786
+ title: "Disposable page",
1787
+ slug: "disposable-page",
1788
+ visibility: "latest_hidden",
1789
+ });
1790
+ const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
1791
+ await navItemService.create({ type: "page", postId: post.id });
1792
+
1793
+ await postService.delete(post.id);
1794
+
1795
+ expect(await navItemService.list()).toEqual([]);
1796
+ });
1797
+
1604
1798
  it("returns false for non-existent post", async () => {
1605
1799
  const result = await postService.delete(9999);
1606
1800
  expect(result).toBe(false);
@@ -2286,6 +2480,374 @@ describe("PostService", () => {
2286
2480
  });
2287
2481
  });
2288
2482
 
2483
+ describe("rebuildBodyHtml", () => {
2484
+ async function createFootnotePost(label: string) {
2485
+ return postService.create({
2486
+ format: "note",
2487
+ bodyMarkdown: `Body ${label}[^1]\n\n[^1]: Definition ${label}`,
2488
+ });
2489
+ }
2490
+
2491
+ function legacyFootnoteBody(): string {
2492
+ return JSON.stringify({
2493
+ type: "doc",
2494
+ content: [
2495
+ {
2496
+ type: "paragraph",
2497
+ content: [
2498
+ { type: "text", text: "Body " },
2499
+ {
2500
+ type: "text",
2501
+ text: "[1]",
2502
+ marks: [{ type: "link", attrs: { href: "#fn-1" } }],
2503
+ },
2504
+ ],
2505
+ },
2506
+ { type: "horizontalRule" },
2507
+ {
2508
+ type: "orderedList",
2509
+ content: [
2510
+ {
2511
+ type: "listItem",
2512
+ content: [
2513
+ {
2514
+ type: "paragraph",
2515
+ content: [
2516
+ { type: "text", text: "Definition " },
2517
+ {
2518
+ type: "text",
2519
+ text: "↩︎",
2520
+ marks: [{ type: "link", attrs: { href: "#fnref-1" } }],
2521
+ },
2522
+ ],
2523
+ },
2524
+ ],
2525
+ },
2526
+ ],
2527
+ },
2528
+ ],
2529
+ });
2530
+ }
2531
+
2532
+ it("resolves stale stored HTML on reads before the rebuild writes", async () => {
2533
+ const post = await createFootnotePost("stale");
2534
+ await db
2535
+ .update(posts)
2536
+ .set({
2537
+ bodyHtml: '<span class="sidenote">legacy</span>',
2538
+ bodyHtmlVersion: 1,
2539
+ })
2540
+ .where(eq(posts.id, post.id));
2541
+
2542
+ const resolved = await postService.getById(post.id);
2543
+ expect(resolved?.bodyHtml).toContain('role="doc-noteref"');
2544
+ expect(resolved?.bodyHtml).toContain('role="doc-endnotes"');
2545
+ expect(resolved?.bodyHtml).not.toContain("legacy");
2546
+
2547
+ const stored = await db
2548
+ .select({ bodyHtml: posts.bodyHtml })
2549
+ .from(posts)
2550
+ .where(eq(posts.id, post.id))
2551
+ .limit(1);
2552
+ expect(stored[0]?.bodyHtml).toContain("legacy");
2553
+ });
2554
+
2555
+ it("supports dry-run, preserves timestamps, and is idempotent", async () => {
2556
+ const post = await createFootnotePost("rebuild");
2557
+ await db
2558
+ .update(posts)
2559
+ .set({
2560
+ bodyHtml: '<span class="sidenote">legacy</span>',
2561
+ bodyHtmlVersion: 1,
2562
+ })
2563
+ .where(eq(posts.id, post.id));
2564
+
2565
+ const before = await db
2566
+ .select({
2567
+ updatedAt: posts.updatedAt,
2568
+ publishedAt: posts.publishedAt,
2569
+ })
2570
+ .from(posts)
2571
+ .where(eq(posts.id, post.id))
2572
+ .limit(1);
2573
+
2574
+ const dryRun = await postService.rebuildBodyHtml({ dryRun: true });
2575
+ expect(dryRun).toMatchObject({
2576
+ processed: 1,
2577
+ wouldRebuild: 1,
2578
+ rebuilt: 0,
2579
+ skipped: 0,
2580
+ conflicted: 0,
2581
+ failed: 0,
2582
+ done: true,
2583
+ targetVersion: POST_BODY_HTML_VERSION,
2584
+ });
2585
+
2586
+ const firstPass = await postService.rebuildBodyHtml();
2587
+ expect(firstPass).toMatchObject({
2588
+ wouldRebuild: 1,
2589
+ rebuilt: 1,
2590
+ failed: 0,
2591
+ });
2592
+
2593
+ const stored = await db
2594
+ .select({
2595
+ bodyHtml: posts.bodyHtml,
2596
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2597
+ updatedAt: posts.updatedAt,
2598
+ publishedAt: posts.publishedAt,
2599
+ })
2600
+ .from(posts)
2601
+ .where(eq(posts.id, post.id))
2602
+ .limit(1);
2603
+ expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
2604
+ expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
2605
+ expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
2606
+ expect(stored[0]?.publishedAt).toBe(before[0]?.publishedAt);
2607
+
2608
+ const secondPass = await postService.rebuildBodyHtml();
2609
+ expect(secondPass).toMatchObject({
2610
+ wouldRebuild: 0,
2611
+ rebuilt: 0,
2612
+ skipped: 1,
2613
+ failed: 0,
2614
+ });
2615
+ });
2616
+
2617
+ it("atomically upgrades historical generic footnotes and their projections", async () => {
2618
+ const post = await postService.create({
2619
+ format: "note",
2620
+ title: "Legacy",
2621
+ bodyMarkdown: "Placeholder",
2622
+ });
2623
+ const legacyBody = legacyFootnoteBody();
2624
+ await db
2625
+ .update(posts)
2626
+ .set({
2627
+ body: legacyBody,
2628
+ bodyHtml:
2629
+ '<p>Body <a href="#fn-1">[1]</a></p><hr><ol><li><p>Definition <a href="#fnref-1">↩︎</a></p></li></ol>',
2630
+ bodyHtmlVersion: 3,
2631
+ bodyText: "Body [1] #fn-1 Definition ↩︎ #fnref-1",
2632
+ summary: "Body [1]\n\nDefinition ↩︎",
2633
+ })
2634
+ .where(eq(posts.id, post.id));
2635
+ const before = await db
2636
+ .select({ updatedAt: posts.updatedAt, body: posts.body })
2637
+ .from(posts)
2638
+ .where(eq(posts.id, post.id))
2639
+ .limit(1);
2640
+
2641
+ const dryRun = await postService.rebuildBodyHtml({
2642
+ dryRun: true,
2643
+ summaryConfig: { maxParagraphs: 5, maxChars: 500 },
2644
+ });
2645
+ expect(dryRun).toMatchObject({
2646
+ wouldRebuild: 1,
2647
+ rebuilt: 0,
2648
+ wouldUpgradeFootnotes: 1,
2649
+ upgradedFootnotes: 0,
2650
+ failed: 0,
2651
+ });
2652
+ expect(
2653
+ await db
2654
+ .select({ body: posts.body })
2655
+ .from(posts)
2656
+ .where(eq(posts.id, post.id))
2657
+ .limit(1),
2658
+ ).toEqual([{ body: legacyBody }]);
2659
+
2660
+ const result = await postService.rebuildBodyHtml({
2661
+ summaryConfig: { maxParagraphs: 5, maxChars: 500 },
2662
+ });
2663
+ expect(result).toMatchObject({
2664
+ rebuilt: 1,
2665
+ wouldUpgradeFootnotes: 1,
2666
+ upgradedFootnotes: 1,
2667
+ conflicted: 0,
2668
+ failed: 0,
2669
+ });
2670
+
2671
+ const stored = await db
2672
+ .select({
2673
+ body: posts.body,
2674
+ bodyHtml: posts.bodyHtml,
2675
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2676
+ bodyText: posts.bodyText,
2677
+ summary: posts.summary,
2678
+ updatedAt: posts.updatedAt,
2679
+ })
2680
+ .from(posts)
2681
+ .where(eq(posts.id, post.id))
2682
+ .limit(1);
2683
+ expect(stored[0]?.body).toContain('"type":"footnoteReference"');
2684
+ expect(stored[0]?.body).toContain('"type":"footnoteDefinition"');
2685
+ expect(stored[0]?.bodyHtml).toContain('role="doc-noteref"');
2686
+ expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
2687
+ expect(stored[0]?.bodyText).toBe("Body Definition");
2688
+ expect(stored[0]?.summary).toBe("Body");
2689
+ expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
2690
+ expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
2691
+
2692
+ await expect(postService.rebuildBodyHtml()).resolves.toMatchObject({
2693
+ wouldRebuild: 0,
2694
+ wouldUpgradeFootnotes: 0,
2695
+ upgradedFootnotes: 0,
2696
+ skipped: 1,
2697
+ });
2698
+ });
2699
+
2700
+ it("reports malformed canonical bodies without marking them current", async () => {
2701
+ const post = await createFootnotePost("invalid");
2702
+ await db
2703
+ .update(posts)
2704
+ .set({
2705
+ body: "not json",
2706
+ bodyHtml: "<p>legacy fallback</p>",
2707
+ bodyHtmlVersion: 1,
2708
+ })
2709
+ .where(eq(posts.id, post.id));
2710
+
2711
+ const result = await postService.rebuildBodyHtml();
2712
+ expect(result.failed).toBe(1);
2713
+ expect(result.failures[0]?.postId).toBe(post.id);
2714
+ expect(result.rebuilt).toBe(0);
2715
+
2716
+ const stored = await db
2717
+ .select({
2718
+ bodyHtml: posts.bodyHtml,
2719
+ bodyHtmlVersion: posts.bodyHtmlVersion,
2720
+ })
2721
+ .from(posts)
2722
+ .where(eq(posts.id, post.id))
2723
+ .limit(1);
2724
+ expect(stored[0]).toEqual({
2725
+ bodyHtml: "<p>legacy fallback</p>",
2726
+ bodyHtmlVersion: 1,
2727
+ });
2728
+ expect((await postService.getById(post.id))?.bodyHtml).toBe(
2729
+ "<p>legacy fallback</p>",
2730
+ );
2731
+ });
2732
+
2733
+ it("paginates deterministically", async () => {
2734
+ const created = [];
2735
+ for (const label of ["one", "two", "three"]) {
2736
+ created.push(await createFootnotePost(label));
2737
+ }
2738
+ await db
2739
+ .update(posts)
2740
+ .set({ bodyHtmlVersion: 1 })
2741
+ .where(eq(posts.siteId, DEFAULT_TEST_SITE_ID));
2742
+
2743
+ const first = await postService.rebuildBodyHtml({ limit: 2 });
2744
+ expect(first.processed).toBe(2);
2745
+ expect(first.done).toBe(false);
2746
+ expect(first.nextCursor).not.toBeNull();
2747
+
2748
+ const second = await postService.rebuildBodyHtml({
2749
+ limit: 2,
2750
+ cursor: first.nextCursor ?? undefined,
2751
+ });
2752
+ expect(second.processed).toBe(1);
2753
+ expect(second.done).toBe(true);
2754
+ expect(first.rebuilt + second.rebuilt).toBe(created.length);
2755
+ });
2756
+
2757
+ it("lets a concurrent canonical-body edit win the compare-and-swap", async () => {
2758
+ const post = await createFootnotePost("conflict");
2759
+ await db
2760
+ .update(posts)
2761
+ .set({ bodyHtmlVersion: 1 })
2762
+ .where(eq(posts.id, post.id));
2763
+
2764
+ const concurrentBody = JSON.stringify({
2765
+ type: "doc",
2766
+ content: [
2767
+ {
2768
+ type: "paragraph",
2769
+ content: [{ type: "text", text: "Concurrent edit" }],
2770
+ },
2771
+ ],
2772
+ });
2773
+ let injected = false;
2774
+ const conflictDb = new Proxy(db, {
2775
+ get(target, property, receiver) {
2776
+ if (property === "update") {
2777
+ return (...args: Parameters<Database["update"]>) => {
2778
+ if (!injected) {
2779
+ injected = true;
2780
+ sqlite
2781
+ .prepare("UPDATE post SET body = ? WHERE id = ?")
2782
+ .run(concurrentBody, post.id);
2783
+ }
2784
+ return target.update(...args);
2785
+ };
2786
+ }
2787
+
2788
+ const value = Reflect.get(target, property, receiver) as unknown;
2789
+ return typeof value === "function" ? value.bind(target) : value;
2790
+ },
2791
+ });
2792
+ const conflictService = createPostService(
2793
+ conflictDb,
2794
+ { slugIdLength: 5 },
2795
+ DEFAULT_TEST_SITE_ID,
2796
+ );
2797
+
2798
+ const result = await conflictService.rebuildBodyHtml();
2799
+
2800
+ expect(result).toMatchObject({ rebuilt: 0, conflicted: 1 });
2801
+ const stored = await db
2802
+ .select({ body: posts.body, version: posts.bodyHtmlVersion })
2803
+ .from(posts)
2804
+ .where(eq(posts.id, post.id))
2805
+ .limit(1);
2806
+ expect(stored[0]).toEqual({ body: concurrentBody, version: 1 });
2807
+ });
2808
+
2809
+ it("never widens a rebuild to another site", async () => {
2810
+ const defaultPost = await createFootnotePost("default");
2811
+ const secondSiteId = "sit_second000000000000000000000";
2812
+ const timestamp = Math.floor(Date.now() / 1000);
2813
+ await db.insert(sites).values({
2814
+ id: secondSiteId,
2815
+ key: "second",
2816
+ status: "active",
2817
+ createdAt: timestamp,
2818
+ updatedAt: timestamp,
2819
+ });
2820
+ const secondService = createPostService(
2821
+ db,
2822
+ { slugIdLength: 5 },
2823
+ secondSiteId,
2824
+ );
2825
+ const secondPost = await secondService.create({
2826
+ format: "note",
2827
+ bodyMarkdown: "Second[^1]\n\n[^1]: Other site",
2828
+ });
2829
+ await db
2830
+ .update(posts)
2831
+ .set({ bodyHtmlVersion: 1 })
2832
+ .where(eq(posts.id, defaultPost.id));
2833
+ await db
2834
+ .update(posts)
2835
+ .set({ bodyHtmlVersion: 1 })
2836
+ .where(eq(posts.id, secondPost.id));
2837
+
2838
+ const result = await postService.rebuildBodyHtml();
2839
+ expect(result.processed).toBe(1);
2840
+
2841
+ const rows = await db
2842
+ .select({ id: posts.id, version: posts.bodyHtmlVersion })
2843
+ .from(posts);
2844
+ expect(rows.find((row) => row.id === defaultPost.id)?.version).toBe(
2845
+ POST_BODY_HTML_VERSION,
2846
+ );
2847
+ expect(rows.find((row) => row.id === secondPost.id)?.version).toBe(1);
2848
+ });
2849
+ });
2850
+
2289
2851
  describe("listForSitemap", () => {
2290
2852
  it("returns published non-reply non-private non-deleted posts", async () => {
2291
2853
  const root = await postService.create({