@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
@@ -18,7 +18,7 @@ import { createPostService } from "../../services/post.js";
18
18
  import { createMediaService } from "../../services/media.js";
19
19
  import { createPathService } from "../../services/path.js";
20
20
  import { createCollectionService } from "../../services/collection.js";
21
- import { postCollections, posts as postTable, sites } from "../../db/schema.js";
21
+ import { posts as postTable, sites } from "../../db/schema.js";
22
22
  import { buildMediaMap } from "../media-helpers.js";
23
23
  import {
24
24
  assembleCollectionTimeline,
@@ -157,18 +157,14 @@ describe("Timeline data assembly", () => {
157
157
  return {
158
158
  post: postWithMedia,
159
159
  threadPreview: {
160
- secondReply: threadCtx.secondReply
161
- ? {
162
- ...threadCtx.secondReply,
163
- mediaAttachments: [],
164
- }
165
- : undefined,
166
- penultimateReply: threadCtx.penultimateReply
167
- ? {
168
- ...threadCtx.penultimateReply,
169
- mediaAttachments: [],
170
- }
171
- : undefined,
160
+ leadingReplies: threadCtx.leadingReplies.map((reply) => ({
161
+ ...reply,
162
+ mediaAttachments: [],
163
+ })),
164
+ trailingReplies: threadCtx.trailingReplies.map((reply) => ({
165
+ ...reply,
166
+ mediaAttachments: [],
167
+ })),
172
168
  latestReply: {
173
169
  ...threadCtx.latestReply,
174
170
  mediaAttachments: [],
@@ -183,7 +179,9 @@ describe("Timeline data assembly", () => {
183
179
 
184
180
  expect(items).toHaveLength(1);
185
181
  expect(items[0]?.threadPreview).toBeDefined();
186
- expect(items[0]?.threadPreview?.secondReply?.bodyText).toBe("Reply 1");
182
+ expect(items[0]?.threadPreview?.leadingReplies[0]?.bodyText).toBe(
183
+ "Reply 1",
184
+ );
187
185
  expect(items[0]?.threadPreview?.latestReply.bodyText).toBe("Reply 2");
188
186
  expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
189
187
  });
@@ -347,6 +345,7 @@ describe("Timeline data assembly", () => {
347
345
 
348
346
  expect(result.items).toHaveLength(1);
349
347
  expect(result.items[0]?.post.id).toBe(root.id);
348
+ expect(result.items[0]?.curatedThread?.showContextRatings).toBe(false);
350
349
  expect(result.items[0]?.curatedThread?.segments).toEqual([
351
350
  expect.objectContaining({
352
351
  post: expect.objectContaining({ id: root.id }),
@@ -526,22 +525,26 @@ describe("Timeline data assembly", () => {
526
525
  ]);
527
526
  });
528
527
 
529
- it("orders featured threads by latest featured time instead of later non-featured activity", async () => {
528
+ it("orders Featured Threads by the latest selected Post publication time", async () => {
530
529
  const olderFeaturedRoot = await postService.create({
531
530
  format: "note",
532
531
  bodyMarkdown: "Older featured root",
532
+ publishedAt: 1000,
533
533
  });
534
534
  const olderFeaturedReply = await postService.create({
535
535
  format: "note",
536
536
  bodyMarkdown: "Older featured reply",
537
537
  replyToId: olderFeaturedRoot.id,
538
+ publishedAt: 3000,
538
539
  });
539
540
  const newerFeaturedRoot = await postService.create({
540
541
  format: "note",
541
542
  bodyMarkdown: "Newer featured root",
542
543
  featured: true,
544
+ publishedAt: 2000,
543
545
  });
544
546
 
547
+ // Featured timestamps deliberately disagree with publication order.
545
548
  await db
546
549
  .update(postTable)
547
550
  .set({ featuredAt: 100 })
@@ -555,6 +558,7 @@ describe("Timeline data assembly", () => {
555
558
  format: "note",
556
559
  bodyMarkdown: "Later non-featured reply",
557
560
  replyToId: olderFeaturedReply.id,
561
+ publishedAt: 4000,
558
562
  });
559
563
 
560
564
  const result = await assembleFeaturedTimeline(createTimelineContext(), {
@@ -562,11 +566,63 @@ describe("Timeline data assembly", () => {
562
566
  });
563
567
 
564
568
  expect(result.items).toHaveLength(2);
565
- expect(result.items[0]?.post.id).toBe(newerFeaturedRoot.id);
566
- expect(result.items[1]?.post.id).toBe(olderFeaturedRoot.id);
569
+ expect(result.items[0]?.post.id).toBe(olderFeaturedRoot.id);
570
+ expect(result.items[1]?.post.id).toBe(newerFeaturedRoot.id);
571
+ });
572
+
573
+ it("loads only the Root, Featured Posts, and final Post for curated display", async () => {
574
+ const root = await postService.create({
575
+ format: "note",
576
+ bodyMarkdown: "Root",
577
+ publishedAt: 1000,
578
+ });
579
+ const hiddenReplyA = await postService.create({
580
+ format: "note",
581
+ bodyMarkdown: "Hidden reply A",
582
+ replyToId: root.id,
583
+ publishedAt: 2000,
584
+ });
585
+ const featuredReply = await postService.create({
586
+ format: "note",
587
+ bodyMarkdown: "Featured reply",
588
+ replyToId: hiddenReplyA.id,
589
+ featured: true,
590
+ publishedAt: 3000,
591
+ });
592
+ const hiddenReplyB = await postService.create({
593
+ format: "note",
594
+ bodyMarkdown: "Hidden reply B",
595
+ replyToId: featuredReply.id,
596
+ publishedAt: 4000,
597
+ });
598
+ const finalReply = await postService.create({
599
+ format: "note",
600
+ bodyMarkdown: "Final reply",
601
+ replyToId: hiddenReplyB.id,
602
+ publishedAt: 5000,
603
+ });
604
+
605
+ const result = await postService.getFeaturedThreadTimelineData([root.id]);
606
+ const thread = result.get(root.id);
607
+
608
+ expect(thread?.posts).toEqual([
609
+ expect.objectContaining({
610
+ post: expect.objectContaining({ id: root.id }),
611
+ position: 0,
612
+ }),
613
+ expect.objectContaining({
614
+ post: expect.objectContaining({ id: featuredReply.id }),
615
+ position: 2,
616
+ }),
617
+ expect.objectContaining({
618
+ post: expect.objectContaining({ id: finalReply.id }),
619
+ position: 4,
620
+ }),
621
+ ]);
622
+ expect(thread?.featuredPostIds).toEqual([featuredReply.id]);
567
623
  });
568
624
 
569
- it("groups collection posts by root thread and sorts threads by collected-at", async () => {
625
+ it("renders every post in each collected Thread and sorts by Thread activity", async () => {
570
626
  const collection = await collectionService.create({
571
627
  slug: "reading",
572
628
  title: "Reading",
@@ -595,26 +651,12 @@ describe("Timeline data assembly", () => {
595
651
  bodyMarkdown: "Second thread root",
596
652
  });
597
653
 
598
- await db.insert(postCollections).values([
599
- {
600
- siteId: DEFAULT_TEST_SITE_ID,
601
- postId: collectedReplyA.id,
602
- collectionId: collection.id,
603
- createdAt: 100,
604
- },
605
- {
606
- siteId: DEFAULT_TEST_SITE_ID,
607
- postId: collectedReplyB.id,
608
- collectionId: collection.id,
609
- createdAt: 200,
610
- },
611
- {
612
- siteId: DEFAULT_TEST_SITE_ID,
613
- postId: secondRoot.id,
614
- collectionId: collection.id,
615
- createdAt: 300,
616
- },
617
- ]);
654
+ await collectionService.addThread(collection.id, firstRoot.id, {
655
+ createdAt: 200,
656
+ });
657
+ await collectionService.addThread(collection.id, secondRoot.id, {
658
+ createdAt: 300,
659
+ });
618
660
 
619
661
  const result = await assembleCollectionTimeline(createTimelineContext(), {
620
662
  collectionIds: [collection.id],
@@ -625,6 +667,7 @@ describe("Timeline data assembly", () => {
625
667
  expect(result.items).toHaveLength(2);
626
668
  expect(result.items[0]?.post.id).toBe(secondRoot.id);
627
669
  expect(result.items[1]?.post.id).toBe(firstRoot.id);
670
+ expect(result.items[1]?.curatedThread?.showContextRatings).toBe(true);
628
671
  expect(result.items[1]?.curatedThread?.segments).toEqual([
629
672
  expect.objectContaining({
630
673
  post: expect.objectContaining({ id: firstRoot.id }),
@@ -634,17 +677,22 @@ describe("Timeline data assembly", () => {
634
677
  expect.objectContaining({
635
678
  post: expect.objectContaining({ id: collectedReplyA.id }),
636
679
  hiddenBeforeCount: 0,
637
- highlighted: true,
680
+ highlighted: false,
681
+ }),
682
+ expect.objectContaining({
683
+ post: expect.objectContaining({ id: hiddenMiddleReply.id }),
684
+ hiddenBeforeCount: 0,
685
+ highlighted: false,
638
686
  }),
639
687
  expect.objectContaining({
640
688
  post: expect.objectContaining({ id: collectedReplyB.id }),
641
- hiddenBeforeCount: 1,
689
+ hiddenBeforeCount: 0,
642
690
  highlighted: true,
643
691
  }),
644
692
  ]);
645
693
  });
646
694
 
647
- it("keeps the last post visible when a collected root is the only selected post", async () => {
695
+ it("renders the complete Thread and highlights its latest Post", async () => {
648
696
  const collection = await collectionService.create({
649
697
  slug: "root-only",
650
698
  title: "Root only",
@@ -659,10 +707,7 @@ describe("Timeline data assembly", () => {
659
707
  replyToId: root.id,
660
708
  });
661
709
 
662
- await db.insert(postCollections).values({
663
- siteId: DEFAULT_TEST_SITE_ID,
664
- postId: root.id,
665
- collectionId: collection.id,
710
+ await collectionService.addThread(collection.id, root.id, {
666
711
  createdAt: 100,
667
712
  });
668
713
 
@@ -678,17 +723,17 @@ describe("Timeline data assembly", () => {
678
723
  expect.objectContaining({
679
724
  post: expect.objectContaining({ id: root.id }),
680
725
  hiddenBeforeCount: 0,
681
- highlighted: true,
726
+ highlighted: false,
682
727
  }),
683
728
  expect.objectContaining({
684
729
  post: expect.objectContaining({ id: uncollectedReply.id }),
685
730
  hiddenBeforeCount: 0,
686
- highlighted: false,
731
+ highlighted: true,
687
732
  }),
688
733
  ]);
689
734
  });
690
735
 
691
- it("highlights the union of collected posts across multiple collections", async () => {
736
+ it("deduplicates Threads selected through multiple collections", async () => {
692
737
  const smart = await collectionService.create({
693
738
  slug: "smart",
694
739
  title: "Smart",
@@ -721,26 +766,15 @@ describe("Timeline data assembly", () => {
721
766
  bodyMarkdown: "Second thread root",
722
767
  });
723
768
 
724
- await db.insert(postCollections).values([
725
- {
726
- siteId: DEFAULT_TEST_SITE_ID,
727
- postId: smartReply.id,
728
- collectionId: smart.id,
729
- createdAt: 100,
730
- },
731
- {
732
- siteId: DEFAULT_TEST_SITE_ID,
733
- postId: movieReply.id,
734
- collectionId: movies.id,
735
- createdAt: 200,
736
- },
737
- {
738
- siteId: DEFAULT_TEST_SITE_ID,
739
- postId: secondRoot.id,
740
- collectionId: movies.id,
741
- createdAt: 300,
742
- },
743
- ]);
769
+ await collectionService.addThread(smart.id, firstRoot.id, {
770
+ createdAt: 100,
771
+ });
772
+ await collectionService.addThread(movies.id, firstRoot.id, {
773
+ createdAt: 200,
774
+ });
775
+ await collectionService.addThread(movies.id, secondRoot.id, {
776
+ createdAt: 300,
777
+ });
744
778
 
745
779
  const result = await assembleCollectionTimeline(createTimelineContext(), {
746
780
  collectionIds: [smart.id, movies.id],
@@ -760,11 +794,16 @@ describe("Timeline data assembly", () => {
760
794
  expect.objectContaining({
761
795
  post: expect.objectContaining({ id: smartReply.id }),
762
796
  hiddenBeforeCount: 0,
763
- highlighted: true,
797
+ highlighted: false,
798
+ }),
799
+ expect.objectContaining({
800
+ post: expect.objectContaining({ id: hiddenMiddleReply.id }),
801
+ hiddenBeforeCount: 0,
802
+ highlighted: false,
764
803
  }),
765
804
  expect.objectContaining({
766
805
  post: expect.objectContaining({ id: movieReply.id }),
767
- hiddenBeforeCount: 1,
806
+ hiddenBeforeCount: 0,
768
807
  highlighted: true,
769
808
  }),
770
809
  ]);
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  TIMEZONES,
4
+ getTimeZoneOptions,
4
5
  isSupportedTimeZone,
5
6
  mapIanaToTimezone,
6
7
  normalizeTimeZone,
@@ -69,8 +70,15 @@ describe("normalizeTimeZone", () => {
69
70
  expect(normalizeTimeZone("London")).toBe("Europe/London");
70
71
  });
71
72
 
72
- it("normalizes accepted IANA aliases to the curated canonical value", () => {
73
- expect(normalizeTimeZone("Asia/Calcutta")).toBe("Asia/Kolkata");
73
+ it("preserves runtime-supported zones instead of collapsing their rules", () => {
74
+ expect(normalizeTimeZone("America/Phoenix")).toBe("America/Phoenix");
75
+ expect(normalizeTimeZone("Australia/Darwin")).toBe("Australia/Darwin");
76
+ expect(normalizeTimeZone("Etc/GMT-8")).toBe("Etc/GMT-8");
77
+ });
78
+
79
+ it("normalizes fixed offsets and UTC aliases through Intl", () => {
80
+ expect(normalizeTimeZone("+08")).toBe("+08:00");
81
+ expect(normalizeTimeZone("+08:00")).toBe("+08:00");
74
82
  expect(normalizeTimeZone("Etc/UTC")).toBe("UTC");
75
83
  });
76
84
 
@@ -81,9 +89,13 @@ describe("normalizeTimeZone", () => {
81
89
  });
82
90
 
83
91
  describe("isSupportedTimeZone", () => {
84
- it("accepts canonical, aliased, and legacy values", () => {
92
+ it("accepts runtime zones, fixed offsets, and legacy values", () => {
85
93
  expect(isSupportedTimeZone("Asia/Shanghai")).toBe(true);
86
94
  expect(isSupportedTimeZone("Asia/Calcutta")).toBe(true);
95
+ expect(isSupportedTimeZone("America/Phoenix")).toBe(true);
96
+ expect(isSupportedTimeZone("Australia/Darwin")).toBe(true);
97
+ expect(isSupportedTimeZone("Etc/GMT-8")).toBe(true);
98
+ expect(isSupportedTimeZone("+08:00")).toBe(true);
87
99
  expect(isSupportedTimeZone("Beijing")).toBe(true);
88
100
  });
89
101
 
@@ -92,3 +104,19 @@ describe("isSupportedTimeZone", () => {
92
104
  expect(isSupportedTimeZone("Unknown/Zone")).toBe(false);
93
105
  });
94
106
  });
107
+
108
+ describe("getTimeZoneOptions", () => {
109
+ it("adds an unlisted current timezone without expanding the curated list", () => {
110
+ const options = getTimeZoneOptions("America/Phoenix");
111
+
112
+ expect(options).toHaveLength(TIMEZONES.length + 1);
113
+ expect(options.at(-1)).toMatchObject({
114
+ label: "America/Phoenix",
115
+ value: "America/Phoenix",
116
+ });
117
+ });
118
+
119
+ it("does not duplicate a curated timezone", () => {
120
+ expect(getTimeZoneOptions("Asia/Shanghai")).toBe(TIMEZONES);
121
+ });
122
+ });
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  renderTiptapDocument,
4
+ renderTiptapDocumentAroundBoundary,
4
5
  renderTiptapJson,
6
+ tryRenderTiptapJson,
5
7
  trimTiptapBody,
6
8
  } from "../tiptap-render.js";
7
9
 
@@ -68,7 +70,7 @@ describe("renderTiptapDocument", () => {
68
70
  );
69
71
  });
70
72
 
71
- it("renders footnote references as inline sidenotes", () => {
73
+ it("renders canonical semantic endnotes without layout wrappers", () => {
72
74
  const html = renderTiptapDocument(
73
75
  doc(
74
76
  p(text("Body copy"), {
@@ -81,16 +83,21 @@ describe("renderTiptapDocument", () => {
81
83
  content: [p(text("Footnote body"))],
82
84
  },
83
85
  ),
86
+ { namespace: "pst_test" },
84
87
  );
85
88
 
86
89
  expect(html).toBe(
87
- '<p>Body copy<label for="sn-1" class="margin-toggle sidenote-number footref"></label>' +
88
- '<input type="checkbox" id="sn-1" class="margin-toggle footref-toggle"/>' +
89
- '<span class="sidenote">Footnote body</span></p>',
90
+ '<p>Body copy<sup class="footnote-ref">' +
91
+ '<a id="fnref-0sk9zhgh1dhm5-1-1" href="#fn-0sk9zhgh1dhm5-1" role="doc-noteref">1</a></sup></p>' +
92
+ '<section class="footnote-endnotes" role="doc-endnotes"><ol class="footnote-list">' +
93
+ '<li id="fn-0sk9zhgh1dhm5-1" class="footnote"><p>Footnote body <span class="footnote-backlinks">' +
94
+ '<a href="#fnref-0sk9zhgh1dhm5-1-1" class="footnote-backref" role="doc-backlink">↩︎</a>' +
95
+ "</span></p></li></ol></section>",
90
96
  );
97
+ expect(html).not.toMatch(/<div|\sstyle=|\sdata-footnote-/);
91
98
  });
92
99
 
93
- it("renders footnote reference without definition gracefully", () => {
100
+ it("renders a missing footnote definition without a dead link", () => {
94
101
  const html = renderTiptapDocument(
95
102
  doc(
96
103
  p(text("Body copy"), {
@@ -100,11 +107,255 @@ describe("renderTiptapDocument", () => {
100
107
  ),
101
108
  );
102
109
 
103
- expect(html).toBe(
104
- '<p>Body copy<label for="sn-1" class="margin-toggle sidenote-number footref"></label>' +
105
- '<input type="checkbox" id="sn-1" class="margin-toggle footref-toggle"/>' +
106
- '<span class="sidenote"></span></p>',
110
+ expect(html).toBe('<p>Body copy<sup class="footnote-ref">1</sup></p>');
111
+ });
112
+
113
+ it("uses a native list start when a missing definition creates a leading numbering gap", () => {
114
+ const html = renderTiptapDocument(
115
+ doc(
116
+ p(text("Missing"), {
117
+ type: "footnoteReference",
118
+ attrs: { label: "missing" },
119
+ }),
120
+ p(text("Defined"), {
121
+ type: "footnoteReference",
122
+ attrs: { label: "defined" },
123
+ }),
124
+ {
125
+ type: "footnoteDefinition",
126
+ attrs: { label: "defined" },
127
+ content: [p(text("Second definition"))],
128
+ },
129
+ ),
107
130
  );
131
+
132
+ expect(html).toContain('<sup class="footnote-ref">1</sup>');
133
+ expect(html).toContain('href="#fn-2" role="doc-noteref">2</a>');
134
+ expect(html).toContain('<ol class="footnote-list" start="2">');
135
+ expect(html).toContain('<li id="fn-2" class="footnote">');
136
+ expect(html).not.toContain('value="2"');
137
+ });
138
+
139
+ it("uses a native list-item value only for an internal numbering gap", () => {
140
+ const html = renderTiptapDocument(
141
+ doc(
142
+ p({ type: "footnoteReference", attrs: { label: "one" } }),
143
+ p({ type: "footnoteReference", attrs: { label: "missing" } }),
144
+ p({ type: "footnoteReference", attrs: { label: "three" } }),
145
+ {
146
+ type: "footnoteDefinition",
147
+ attrs: { label: "one" },
148
+ content: [p(text("First definition"))],
149
+ },
150
+ {
151
+ type: "footnoteDefinition",
152
+ attrs: { label: "three" },
153
+ content: [p(text("Third definition"))],
154
+ },
155
+ ),
156
+ );
157
+
158
+ expect(html).toContain('<ol class="footnote-list">');
159
+ expect(html).toContain('<li id="fn-1" class="footnote">');
160
+ expect(html).toContain('<li id="fn-3" class="footnote" value="3">');
161
+ });
162
+
163
+ it("renders repeated references once with unique reciprocal backlinks", () => {
164
+ const html = renderTiptapDocument(
165
+ doc(
166
+ p(text("First"), {
167
+ type: "footnoteReference",
168
+ attrs: { label: "Note" },
169
+ }),
170
+ p(text("Second"), {
171
+ type: "footnoteReference",
172
+ attrs: { label: "note" },
173
+ }),
174
+ {
175
+ type: "footnoteDefinition",
176
+ attrs: { label: "NOTE" },
177
+ content: [p(text("Shared definition"))],
178
+ },
179
+ ),
180
+ { namespace: "pst_repeat" },
181
+ );
182
+
183
+ expect(html.match(/role="doc-endnotes"/g)).toHaveLength(1);
184
+ expect(html.match(/<ol class="footnote-list">/g)).toHaveLength(1);
185
+ expect(html.match(/<li id="fn-/g)).toHaveLength(1);
186
+ expect(html).toContain('id="fnref-0r3u815ai35bm-1-1"');
187
+ expect(html).toContain('id="fnref-0r3u815ai35bm-1-2"');
188
+ expect(html.match(/href="#fn-0r3u815ai35bm-1"/g)).toHaveLength(2);
189
+ expect(html).toContain(
190
+ 'href="#fnref-0r3u815ai35bm-1-1" class="footnote-backref" role="doc-backlink">↩︎1</a>',
191
+ );
192
+ expect(html).toContain(
193
+ 'href="#fnref-0r3u815ai35bm-1-2" class="footnote-backref" role="doc-backlink">↩︎2</a>',
194
+ );
195
+ expect(html.indexOf("Shared definition")).toBeGreaterThan(
196
+ html.indexOf("Second"),
197
+ );
198
+ });
199
+
200
+ it("keeps all definitions in one native ordered list", () => {
201
+ const html = renderTiptapDocument(
202
+ doc(
203
+ p(text("First"), {
204
+ type: "footnoteReference",
205
+ attrs: { label: "first" },
206
+ }),
207
+ p(text("Second"), {
208
+ type: "footnoteReference",
209
+ attrs: { label: "second" },
210
+ }),
211
+ {
212
+ type: "footnoteDefinition",
213
+ attrs: { label: "first" },
214
+ content: [p(text("First definition"))],
215
+ },
216
+ {
217
+ type: "footnoteDefinition",
218
+ attrs: { label: "second" },
219
+ content: [p(text("Second definition"))],
220
+ },
221
+ ),
222
+ );
223
+
224
+ expect(html.match(/<ol class="footnote-list">/g)).toHaveLength(1);
225
+ expect(html).toContain('<li id="fn-1" class="footnote">');
226
+ expect(html).toContain('<li id="fn-2" class="footnote">');
227
+ expect(html).not.toMatch(/\s(?:start|value|style)=/);
228
+ expect(html.indexOf("First definition")).toBeGreaterThan(
229
+ html.indexOf("Second"),
230
+ );
231
+ });
232
+
233
+ it("keeps rich footnote blocks inside a native endnote list item", () => {
234
+ const html = renderTiptapDocument(
235
+ doc(
236
+ p(text("Body"), {
237
+ type: "footnoteReference",
238
+ attrs: { label: "rich" },
239
+ }),
240
+ {
241
+ type: "footnoteDefinition",
242
+ attrs: { label: "rich" },
243
+ content: [
244
+ p(text("First paragraph")),
245
+ {
246
+ type: "bulletList",
247
+ content: [
248
+ {
249
+ type: "listItem",
250
+ content: [p(text("List item"))],
251
+ },
252
+ ],
253
+ },
254
+ ],
255
+ },
256
+ ),
257
+ );
258
+
259
+ expect(html).toContain(
260
+ '<section class="footnote-endnotes" role="doc-endnotes"><ol class="footnote-list"',
261
+ );
262
+ expect(html).toContain('<li id="fn-1" class="footnote">');
263
+ expect(html).toContain(
264
+ '<p>First paragraph</p><ul><li><p>List item</p></li></ul><p class="footnote-backlinks"><a href="#fnref-1-1" class="footnote-backref" role="doc-backlink">↩︎</a></p>',
265
+ );
266
+ expect(html).not.toContain('<div class="footnote-body"');
267
+ expect(html).not.toContain('role="doc-footnote"');
268
+ });
269
+
270
+ it("appends backlinks to the authored final paragraph of rich definitions", () => {
271
+ const html = renderTiptapDocument(
272
+ doc(p({ type: "footnoteReference", attrs: { label: "rich" } }), {
273
+ type: "footnoteDefinition",
274
+ attrs: { label: "rich" },
275
+ content: [
276
+ {
277
+ type: "bulletList",
278
+ content: [
279
+ {
280
+ type: "listItem",
281
+ content: [p(text("List item"))],
282
+ },
283
+ ],
284
+ },
285
+ p(text("Final paragraph")),
286
+ ],
287
+ }),
288
+ );
289
+
290
+ expect(html).toContain(
291
+ '<ul><li><p>List item</p></li></ul><p>Final paragraph <span class="footnote-backlinks">',
292
+ );
293
+ expect(html).not.toContain('<p class="footnote-backlinks">');
294
+ });
295
+
296
+ it("uses the render namespace to isolate documents sharing a page", () => {
297
+ const input = doc(p({ type: "footnoteReference", attrs: { label: "1" } }), {
298
+ type: "footnoteDefinition",
299
+ attrs: { label: "1" },
300
+ content: [p(text("Definition"))],
301
+ });
302
+
303
+ const first = renderTiptapDocument(input, { namespace: "pst_first" });
304
+ const second = renderTiptapDocument(input, { namespace: "pst_second" });
305
+
306
+ expect(first).toContain('id="fn-3t66v0e9xc2qx-1"');
307
+ expect(second).toContain('id="fn-2b338ib1g2med-1"');
308
+ expect(first).not.toContain("2b338ib1g2med");
309
+ expect(second).not.toContain("3t66v0e9xc2qx");
310
+ });
311
+
312
+ it("keeps immutable entity namespaces compact in public fragment IDs", () => {
313
+ const input = doc(p({ type: "footnoteReference", attrs: { label: "1" } }), {
314
+ type: "footnoteDefinition",
315
+ attrs: { label: "1" },
316
+ content: [p(text("Definition"))],
317
+ });
318
+ const namespace = "pst_01kxwe4tkwfqfsrwcgqgeg1b7k";
319
+ const html = renderTiptapDocument(input, { namespace });
320
+ const referenceId = html.match(/<a id="([^"]+)"/)?.[1];
321
+ const definitionId = html.match(/<li id="([^"]+)"/)?.[1];
322
+
323
+ expect(referenceId).toBe("fnref-0nj7nw33xdf9h-1-1");
324
+ expect(definitionId).toBe("fn-0nj7nw33xdf9h-1");
325
+ expect(referenceId?.length).toBeLessThan(30);
326
+ expect(html).not.toContain(namespace);
327
+ expect(html).toContain(`href="#${definitionId}"`);
328
+ expect(html).toContain(`href="#${referenceId}"`);
329
+ });
330
+
331
+ it("splits at a source boundary using the full repeated-footnote plan", () => {
332
+ const input = doc(
333
+ p(text("First"), {
334
+ type: "footnoteReference",
335
+ attrs: { label: "shared" },
336
+ }),
337
+ p(text("Second"), {
338
+ type: "footnoteReference",
339
+ attrs: { label: "shared" },
340
+ }),
341
+ {
342
+ type: "footnoteDefinition",
343
+ attrs: { label: "shared" },
344
+ content: [p(text("Definition"))],
345
+ },
346
+ );
347
+ const options = { namespace: "pst_split" };
348
+ const full = renderTiptapDocument(input, options);
349
+ const split = renderTiptapDocumentAroundBoundary(input, 1, options);
350
+
351
+ expect(split).not.toBeNull();
352
+ expect(split!.beforeHtml + split!.afterHtml).toBe(full);
353
+ expect(split!.beforeHtml).toContain("fnref-2q7yplng9am9n-1-1");
354
+ expect(split!.beforeHtml).not.toContain("footnote-document");
355
+ expect(split!.beforeHtml).not.toContain('role="doc-endnotes"');
356
+ expect(split!.afterHtml).toContain('id="fnref-2q7yplng9am9n-1-2"');
357
+ expect(split!.afterHtml).toContain('role="doc-endnotes"');
358
+ expect(split!.afterHtml.endsWith("</section>")).toBe(true);
108
359
  });
109
360
 
110
361
  it("renders code blocks as escaped text, not nested inline markup", () => {
@@ -141,6 +392,14 @@ describe("renderTiptapJson", () => {
141
392
  it("returns an empty string for non-doc JSON", () => {
142
393
  expect(renderTiptapDocument({ type: "paragraph" })).toBe("");
143
394
  });
395
+
396
+ it("distinguishes invalid JSON from a valid empty document", () => {
397
+ expect(tryRenderTiptapJson("not json")).toMatchObject({ ok: false });
398
+ expect(tryRenderTiptapJson(JSON.stringify(doc()))).toEqual({
399
+ ok: true,
400
+ html: "",
401
+ });
402
+ });
144
403
  });
145
404
 
146
405
  describe("trimTiptapBody", () => {