@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
@@ -7,14 +7,14 @@
7
7
 
8
8
  import type { JSONContent } from "@tiptap/core";
9
9
  import {
10
- getFootnoteDomId,
10
+ getFootnoteLabelKey,
11
11
  normalizeFootnoteArtifacts,
12
12
  normalizeFootnoteLabel,
13
13
  } from "./footnotes.js";
14
14
  import { renderEmbedFromAttrs } from "./embed-render.js";
15
15
  import { escapeHtml } from "./html.js";
16
16
  import { renderPublishedImageFigure } from "./rich-image.js";
17
- import { sanitizeUrl } from "./url.js";
17
+ import { sanitizeRichTextHref } from "./url.js";
18
18
 
19
19
  interface TiptapMark {
20
20
  type: string;
@@ -30,6 +30,7 @@ interface TiptapNode {
30
30
  }
31
31
 
32
32
  interface RenderContext {
33
+ footnotes: FootnotePlan;
33
34
  renderChildren(content?: TiptapNode[]): string;
34
35
  renderNode(node: TiptapNode): string;
35
36
  renderText(text: string, marks?: TiptapMark[]): string;
@@ -38,6 +39,40 @@ interface RenderContext {
38
39
  type MarkRenderer = (html: string, mark: TiptapMark) => string;
39
40
  type NodeRenderer = (node: TiptapNode, context: RenderContext) => string;
40
41
 
42
+ export interface TiptapRenderOptions {
43
+ /**
44
+ * Stable namespace for fragment IDs when multiple rendered documents can
45
+ * share a page. Persisted post HTML passes the immutable post TypeID.
46
+ */
47
+ namespace?: string;
48
+ }
49
+
50
+ export type TiptapRenderResult =
51
+ | { ok: true; html: string }
52
+ | { ok: false; error: string };
53
+
54
+ interface FootnoteReferencePlan {
55
+ definitionId: string;
56
+ referenceId: string;
57
+ number: number;
58
+ occurrence: number;
59
+ label: string;
60
+ hasDefinition: boolean;
61
+ }
62
+
63
+ interface FootnoteGroup {
64
+ definitionId: string;
65
+ number: number;
66
+ label: string;
67
+ definition: TiptapNode | null;
68
+ references: FootnoteReferencePlan[];
69
+ }
70
+
71
+ interface FootnotePlan {
72
+ groups: FootnoteGroup[];
73
+ referencesByNode: WeakMap<TiptapNode, FootnoteReferencePlan>;
74
+ }
75
+
41
76
  function getStringAttr(
42
77
  attrs: Record<string, unknown> | undefined,
43
78
  name: string,
@@ -80,47 +115,206 @@ function renderTableCell(
80
115
  return `<${tagName}${colspanAttr}${rowspanAttr}>${context.renderChildren(node.content)}</${tagName}>`;
81
116
  }
82
117
 
83
- /**
84
- * Strips wrapping `<p>...</p>` from single-paragraph sidenote bodies
85
- * so they render cleanly as inline spans.
86
- */
87
- function stripSingleParagraph(html: string): string {
88
- const trimmed = html.trim();
89
- if (
90
- trimmed.startsWith("<p>") &&
91
- trimmed.endsWith("</p>") &&
92
- trimmed.indexOf("<p>", 1) === -1
93
- ) {
94
- return trimmed.slice(3, -4);
118
+ const FOOTNOTE_NAMESPACE_HASH_OFFSET = 0xcbf29ce484222325n;
119
+ const FOOTNOTE_NAMESPACE_HASH_PRIME = 0x100000001b3n;
120
+ const FOOTNOTE_NAMESPACE_HASH_MASK = 0xffffffffffffffffn;
121
+
122
+ function createFootnoteIdScope(namespace: string | undefined): string {
123
+ const normalized = (namespace ?? "")
124
+ .trim()
125
+ .toLowerCase()
126
+ .replace(/[^a-z0-9_-]+/g, "-")
127
+ .replace(/^-+|-+$/g, "");
128
+ if (!normalized) return "";
129
+
130
+ // Fragment IDs are public implementation details, not content IDs. Hash the
131
+ // immutable entity namespace so HTML stays readable without embedding a full
132
+ // 30-character TypeID. A 64-bit scope keeps collision risk negligible even
133
+ // across a page containing thousands of independently rendered documents.
134
+ let hash = FOOTNOTE_NAMESPACE_HASH_OFFSET;
135
+ for (let index = 0; index < normalized.length; index += 1) {
136
+ hash ^= BigInt(normalized.charCodeAt(index));
137
+ hash =
138
+ (hash * FOOTNOTE_NAMESPACE_HASH_PRIME) & FOOTNOTE_NAMESPACE_HASH_MASK;
95
139
  }
96
- return trimmed;
140
+
141
+ return hash.toString(36).padStart(13, "0");
97
142
  }
98
143
 
99
- /**
100
- * Module-level definition map populated by the `doc` renderer so that
101
- * `footnoteReference` nodes can inline sidenote content.
102
- */
103
- let activeDefinitionMap: Map<string, TiptapNode> | null = null;
144
+ function collectFootnoteReferences(
145
+ node: TiptapNode,
146
+ visit: (reference: TiptapNode) => void,
147
+ ): void {
148
+ if (node.type === "footnoteReference") {
149
+ visit(node);
150
+ return;
151
+ }
152
+
153
+ for (const child of node.content ?? []) {
154
+ collectFootnoteReferences(child, visit);
155
+ }
156
+ }
157
+
158
+ function createFootnotePlan(
159
+ doc: TiptapNode,
160
+ options: TiptapRenderOptions,
161
+ ): FootnotePlan {
162
+ const definitions = new Map<string, TiptapNode>();
163
+ const bodyNodes = (doc.content ?? []).filter(
164
+ (node) => node.type !== "footnoteDefinition",
165
+ );
166
+
167
+ for (const node of doc.content ?? []) {
168
+ if (node.type !== "footnoteDefinition") continue;
169
+ const key = getFootnoteLabelKey(getStringAttr(node.attrs, "label"));
170
+ if (key && !definitions.has(key)) definitions.set(key, node);
171
+ }
172
+
173
+ const namespace = createFootnoteIdScope(options.namespace);
174
+ const idScope = namespace ? `${namespace}-` : "";
175
+ const groupsByKey = new Map<string, FootnoteGroup>();
176
+ const groups: FootnoteGroup[] = [];
177
+ const referencesByNode = new WeakMap<TiptapNode, FootnoteReferencePlan>();
178
+
179
+ for (const owner of bodyNodes) {
180
+ collectFootnoteReferences(owner, (reference) => {
181
+ const normalizedLabel = normalizeFootnoteLabel(
182
+ getStringAttr(reference.attrs, "label"),
183
+ );
184
+ const label = normalizedLabel || "footnote";
185
+ const key = getFootnoteLabelKey(label);
186
+ let group = groupsByKey.get(key);
187
+
188
+ if (!group) {
189
+ const number = groupsByKey.size + 1;
190
+ group = {
191
+ definitionId: `fn-${idScope}${number}`,
192
+ number,
193
+ label,
194
+ definition: definitions.get(key) ?? null,
195
+ references: [],
196
+ };
197
+ groupsByKey.set(key, group);
198
+ groups.push(group);
199
+ }
200
+
201
+ const occurrence = group.references.length + 1;
202
+ const referencePlan: FootnoteReferencePlan = {
203
+ definitionId: group.definitionId,
204
+ referenceId: `fnref-${idScope}${group.number}-${occurrence}`,
205
+ number: group.number,
206
+ occurrence,
207
+ label: group.label,
208
+ hasDefinition: group.definition !== null,
209
+ };
210
+ group.references.push(referencePlan);
211
+ referencesByNode.set(reference, referencePlan);
212
+ });
213
+ }
104
214
 
105
- function renderSidenoteReference(
215
+ return { groups, referencesByNode };
216
+ }
217
+
218
+ function renderFootnoteReference(
106
219
  node: TiptapNode,
107
220
  context: RenderContext,
108
221
  ): string {
109
- const label = normalizeFootnoteLabel(getStringAttr(node.attrs, "label"));
110
- const footnoteId = getFootnoteDomId(label);
111
- const definitionNode = activeDefinitionMap?.get(label);
112
- const bodyHtml = definitionNode
113
- ? stripSingleParagraph(context.renderChildren(definitionNode.content))
114
- : "";
115
-
116
- // The `footref` / `footref-toggle` classes are not styled by us; they mark the
117
- // Tufte sidenote trio as an Org-mode-style footnote so HTML-to-Markdown readers
118
- // (Defuddle, used by Obsidian Web Clipper) recover `[^n]` references instead of
119
- // silently dropping `span.sidenote`. See docs/internal/markdown-contract.md.
222
+ const plan = context.footnotes.referencesByNode.get(node);
223
+ const fallbackLabel =
224
+ normalizeFootnoteLabel(getStringAttr(node.attrs, "label")) || "footnote";
225
+ const label = plan?.label ?? fallbackLabel;
226
+ const number = plan?.number ?? label;
227
+
228
+ if (!plan?.hasDefinition) {
229
+ return `<sup class="footnote-ref">${escapeHtml(String(number))}</sup>`;
230
+ }
231
+
120
232
  return (
121
- `<label for="sn-${escapeHtml(footnoteId)}" class="margin-toggle sidenote-number footref"></label>` +
122
- `<input type="checkbox" id="sn-${escapeHtml(footnoteId)}" class="margin-toggle footref-toggle"/>` +
123
- `<span class="sidenote">${bodyHtml}</span>`
233
+ `<sup class="footnote-ref">` +
234
+ `<a id="${escapeHtml(plan.referenceId)}" href="#${escapeHtml(plan.definitionId)}" role="doc-noteref">` +
235
+ `${plan.number}</a></sup>`
236
+ );
237
+ }
238
+
239
+ function renderFootnoteGroup(
240
+ group: FootnoteGroup,
241
+ context: RenderContext,
242
+ includeValue: boolean,
243
+ ): string {
244
+ if (!group.definition) return "";
245
+
246
+ const definitionContent = group.definition.content ?? [];
247
+ const backlinks = group.references
248
+ .map((reference) => {
249
+ const occurrence =
250
+ group.references.length > 1 ? String(reference.occurrence) : "";
251
+ return (
252
+ `<a href="#${escapeHtml(reference.referenceId)}" class="footnote-backref" ` +
253
+ `role="doc-backlink">↩︎${occurrence}</a>`
254
+ );
255
+ })
256
+ .join(" ");
257
+ const backlinksHtml = `<span class="footnote-backlinks">${backlinks}</span>`;
258
+ const lastBlock = definitionContent.at(-1);
259
+ let bodyHtml: string;
260
+
261
+ if (lastBlock?.type === "paragraph") {
262
+ const precedingBlocks = definitionContent.slice(0, -1);
263
+ const lastParagraphHtml = context.renderChildren(lastBlock.content);
264
+ const separator = lastParagraphHtml ? " " : "";
265
+ bodyHtml =
266
+ context.renderChildren(precedingBlocks) +
267
+ `<p>${lastParagraphHtml}${separator}${backlinksHtml}</p>`;
268
+ } else {
269
+ bodyHtml =
270
+ context.renderChildren(definitionContent) +
271
+ `<p class="footnote-backlinks">${backlinks}</p>`;
272
+ }
273
+
274
+ const valueAttr = includeValue ? ` value="${group.number}"` : "";
275
+
276
+ return (
277
+ `<li id="${escapeHtml(group.definitionId)}" class="footnote"${valueAttr}>` +
278
+ bodyHtml +
279
+ `</li>`
280
+ );
281
+ }
282
+
283
+ function renderFootnoteEndnotes(context: RenderContext): string {
284
+ const groups = context.footnotes.groups.filter(
285
+ (group) => group.definition !== null,
286
+ );
287
+ if (groups.length === 0) return "";
288
+
289
+ const firstNumber = groups[0]?.number ?? 1;
290
+ let expectedNumber = firstNumber;
291
+ const items = groups
292
+ .map((group) => {
293
+ const includeValue = group.number !== expectedNumber;
294
+ expectedNumber = group.number + 1;
295
+ return renderFootnoteGroup(group, context, includeValue);
296
+ })
297
+ .join("");
298
+ const startAttr = firstNumber === 1 ? "" : ` start="${firstNumber}"`;
299
+
300
+ return (
301
+ `<section class="footnote-endnotes" role="doc-endnotes">` +
302
+ `<ol class="footnote-list"${startAttr}>${items}</ol>` +
303
+ `</section>`
304
+ );
305
+ }
306
+
307
+ function renderDocumentContent(
308
+ node: TiptapNode,
309
+ context: RenderContext,
310
+ ): string {
311
+ const bodyNodes = (node.content ?? []).filter(
312
+ (child) => child.type !== "footnoteDefinition",
313
+ );
314
+
315
+ return (
316
+ bodyNodes.map((bodyNode) => context.renderNode(bodyNode)).join("") +
317
+ renderFootnoteEndnotes(context)
124
318
  );
125
319
  }
126
320
 
@@ -130,7 +324,9 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
130
324
  strike: (html) => `<s>${html}</s>`,
131
325
  code: (html) => `<code>${html}</code>`,
132
326
  link: (html, mark) => {
133
- const href = escapeHtml(sanitizeUrl(getStringAttr(mark.attrs, "href")));
327
+ const href = escapeHtml(
328
+ sanitizeRichTextHref(getStringAttr(mark.attrs, "href")),
329
+ );
134
330
  const target = getStringAttr(mark.attrs, "target");
135
331
  const targetAttr = target ? ` target="${escapeHtml(target)}"` : "";
136
332
  const relAttr = target ? ' rel="noopener noreferrer"' : "";
@@ -140,30 +336,7 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
140
336
  };
141
337
 
142
338
  const NODE_RENDERERS: Record<string, NodeRenderer> = {
143
- doc: (node, context) => {
144
- const content = node.content ?? [];
145
-
146
- // Build definition lookup so footnoteReference can inline sidenotes
147
- const definitionMap = new Map<string, TiptapNode>();
148
- for (const child of content) {
149
- if (child.type === "footnoteDefinition") {
150
- const label = normalizeFootnoteLabel(
151
- getStringAttr(child.attrs, "label"),
152
- );
153
- if (label) definitionMap.set(label, child);
154
- }
155
- }
156
-
157
- activeDefinitionMap = definitionMap.size > 0 ? definitionMap : null;
158
- try {
159
- const bodyNodes = content.filter(
160
- (child) => child.type !== "footnoteDefinition",
161
- );
162
- return context.renderChildren(bodyNodes);
163
- } finally {
164
- activeDefinitionMap = null;
165
- }
166
- },
339
+ doc: (node, context) => renderDocumentContent(node, context),
167
340
  paragraph: (node, context) =>
168
341
  `<p>${context.renderChildren(node.content)}</p>`,
169
342
  heading: (node, context) => {
@@ -213,7 +386,7 @@ const NODE_RENDERERS: Record<string, NodeRenderer> = {
213
386
  return `<div class="tiptap-html-block">${html}</div>`;
214
387
  },
215
388
  moreBreak: () => "<!--more-->",
216
- footnoteReference: (node, context) => renderSidenoteReference(node, context),
389
+ footnoteReference: (node, context) => renderFootnoteReference(node, context),
217
390
  footnoteDefinition: () => "",
218
391
  };
219
392
 
@@ -230,40 +403,144 @@ function renderText(text: string, marks: TiptapMark[] = []): string {
230
403
  return html;
231
404
  }
232
405
 
233
- function renderChildren(content: TiptapNode[] = []): string {
234
- return content.map(renderNode).join("");
235
- }
236
-
237
406
  function renderUnknownNode(node: TiptapNode, context: RenderContext): string {
238
407
  return node.content ? context.renderChildren(node.content) : "";
239
408
  }
240
409
 
241
- function renderNode(node: TiptapNode): string {
242
- const renderNodeType = NODE_RENDERERS[node.type] ?? renderUnknownNode;
243
- return renderNodeType(node, RENDER_CONTEXT);
410
+ function createRenderContext(footnotes: FootnotePlan): RenderContext {
411
+ const context: RenderContext = {
412
+ footnotes,
413
+ renderChildren(content: TiptapNode[] = []) {
414
+ return content.map((node) => context.renderNode(node)).join("");
415
+ },
416
+ renderNode(node) {
417
+ const renderNodeType = NODE_RENDERERS[node.type] ?? renderUnknownNode;
418
+ return renderNodeType(node, context);
419
+ },
420
+ renderText,
421
+ };
422
+
423
+ return context;
244
424
  }
245
425
 
246
- const RENDER_CONTEXT: RenderContext = {
247
- renderChildren,
248
- renderNode,
249
- renderText,
250
- };
251
-
252
426
  /**
253
427
  * Renders a parsed TipTap document to HTML.
254
428
  *
255
429
  * @param doc - Parsed TipTap document
430
+ * @param options - Rendering options, including a stable footnote ID namespace
256
431
  * @returns HTML string
257
432
  */
258
- export function renderTiptapDocument(doc: JSONContent): string {
433
+ export function renderTiptapDocument(
434
+ doc: JSONContent,
435
+ options: TiptapRenderOptions = {},
436
+ ): string {
259
437
  if (doc.type !== "doc") return "";
260
- return renderNode(normalizeFootnoteArtifacts(doc) as TiptapNode);
438
+ const normalized = normalizeFootnoteArtifacts(doc) as TiptapNode;
439
+ const context = createRenderContext(createFootnotePlan(normalized, options));
440
+ return context.renderNode(normalized);
441
+ }
442
+
443
+ export interface TiptapBoundaryRenderResult {
444
+ beforeHtml: string;
445
+ afterHtml: string;
446
+ }
447
+
448
+ /**
449
+ * Render a document as two byte-compatible segments around a top-level source
450
+ * boundary.
451
+ *
452
+ * The full document is planned before either segment is rendered, so a
453
+ * footnote before the boundary still includes backlinks to repeated references
454
+ * after it. Joining the two strings is therefore identical to a normal render.
455
+ *
456
+ * @param doc - Parsed TipTap document
457
+ * @param boundaryIndex - Index in the original `doc.content` array
458
+ * @param options - Rendering options, including a stable footnote ID namespace
459
+ * @returns The rendered segments, or null for an invalid root or boundary
460
+ * @example
461
+ * ```ts
462
+ * renderTiptapDocumentAroundBoundary(
463
+ * { type: "doc", content: [{ type: "paragraph" }] },
464
+ * 1,
465
+ * );
466
+ * // { beforeHtml: "<p></p>", afterHtml: "" }
467
+ * ```
468
+ */
469
+ export function renderTiptapDocumentAroundBoundary(
470
+ doc: JSONContent,
471
+ boundaryIndex: number,
472
+ options: TiptapRenderOptions = {},
473
+ ): TiptapBoundaryRenderResult | null {
474
+ const originalContent = doc.content ?? [];
475
+ if (
476
+ doc.type !== "doc" ||
477
+ !Array.isArray(doc.content) ||
478
+ !Number.isInteger(boundaryIndex) ||
479
+ boundaryIndex < 0 ||
480
+ boundaryIndex > originalContent.length
481
+ ) {
482
+ return null;
483
+ }
484
+
485
+ const normalized = normalizeFootnoteArtifacts(doc) as TiptapNode;
486
+ const context = createRenderContext(createFootnotePlan(normalized, options));
487
+ const normalizedBoundaryIndex = Math.min(
488
+ boundaryIndex,
489
+ normalized.content?.length ?? 0,
490
+ );
491
+ let beforeBodyHtml = "";
492
+ let afterBodyHtml = "";
493
+
494
+ for (const [index, node] of (normalized.content ?? []).entries()) {
495
+ if (node.type === "footnoteDefinition") continue;
496
+ const html = context.renderNode(node);
497
+ if (index < normalizedBoundaryIndex) {
498
+ beforeBodyHtml += html;
499
+ } else {
500
+ afterBodyHtml += html;
501
+ }
502
+ }
503
+
504
+ return {
505
+ beforeHtml: beforeBodyHtml,
506
+ afterHtml: afterBodyHtml + renderFootnoteEndnotes(context),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Strictly parses and renders a TipTap JSON document.
512
+ *
513
+ * Unlike `renderTiptapJson`, this distinguishes invalid canonical JSON from a
514
+ * valid empty document. Projection rebuilds use it so malformed historical
515
+ * content is reported and never marked as current.
516
+ *
517
+ * @param json - TipTap JSON string
518
+ * @param options - Rendering options
519
+ * @returns A discriminated render result
520
+ */
521
+ export function tryRenderTiptapJson(
522
+ json: string,
523
+ options: TiptapRenderOptions = {},
524
+ ): TiptapRenderResult {
525
+ try {
526
+ const doc = JSON.parse(json) as JSONContent;
527
+ if (doc.type !== "doc") {
528
+ return { ok: false, error: "TipTap body root must be a doc node." };
529
+ }
530
+ return { ok: true, html: renderTiptapDocument(doc, options) };
531
+ } catch (error) {
532
+ return {
533
+ ok: false,
534
+ error: error instanceof Error ? error.message : "Invalid TipTap JSON.",
535
+ };
536
+ }
261
537
  }
262
538
 
263
539
  /**
264
540
  * Renders a Tiptap JSON document to an HTML string.
265
541
  *
266
542
  * @param json - Tiptap JSON string or parsed document object
543
+ * @param options - Rendering options, including a stable footnote ID namespace
267
544
  * @returns HTML string
268
545
  *
269
546
  * @example
@@ -272,13 +549,12 @@ export function renderTiptapDocument(doc: JSONContent): string {
272
549
  * // "<p>Hello</p>"
273
550
  * ```
274
551
  */
275
- export function renderTiptapJson(json: string): string {
276
- try {
277
- const doc = JSON.parse(json) as JSONContent;
278
- return renderTiptapDocument(doc);
279
- } catch {
280
- return "";
281
- }
552
+ export function renderTiptapJson(
553
+ json: string,
554
+ options: TiptapRenderOptions = {},
555
+ ): string {
556
+ const result = tryRenderTiptapJson(json, options);
557
+ return result.ok ? result.html : "";
282
558
  }
283
559
 
284
560
  /**