@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
@@ -7,7 +7,7 @@
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";
@@ -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
+ }
214
+
215
+ return { groups, referencesByNode };
216
+ }
217
+
218
+ function renderFootnoteReference(
219
+ node: TiptapNode,
220
+ context: RenderContext,
221
+ ): string {
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
+
232
+ return (
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}"`;
104
299
 
105
- function renderSidenoteReference(
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(
106
308
  node: TiptapNode,
107
309
  context: RenderContext,
108
310
  ): 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.
311
+ const bodyNodes = (node.content ?? []).filter(
312
+ (child) => child.type !== "footnoteDefinition",
313
+ );
314
+
120
315
  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>`
316
+ bodyNodes.map((bodyNode) => context.renderNode(bodyNode)).join("") +
317
+ renderFootnoteEndnotes(context)
124
318
  );
125
319
  }
126
320
 
@@ -140,30 +334,7 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
140
334
  };
141
335
 
142
336
  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
- },
337
+ doc: (node, context) => renderDocumentContent(node, context),
167
338
  paragraph: (node, context) =>
168
339
  `<p>${context.renderChildren(node.content)}</p>`,
169
340
  heading: (node, context) => {
@@ -213,7 +384,7 @@ const NODE_RENDERERS: Record<string, NodeRenderer> = {
213
384
  return `<div class="tiptap-html-block">${html}</div>`;
214
385
  },
215
386
  moreBreak: () => "<!--more-->",
216
- footnoteReference: (node, context) => renderSidenoteReference(node, context),
387
+ footnoteReference: (node, context) => renderFootnoteReference(node, context),
217
388
  footnoteDefinition: () => "",
218
389
  };
219
390
 
@@ -230,40 +401,144 @@ function renderText(text: string, marks: TiptapMark[] = []): string {
230
401
  return html;
231
402
  }
232
403
 
233
- function renderChildren(content: TiptapNode[] = []): string {
234
- return content.map(renderNode).join("");
235
- }
236
-
237
404
  function renderUnknownNode(node: TiptapNode, context: RenderContext): string {
238
405
  return node.content ? context.renderChildren(node.content) : "";
239
406
  }
240
407
 
241
- function renderNode(node: TiptapNode): string {
242
- const renderNodeType = NODE_RENDERERS[node.type] ?? renderUnknownNode;
243
- return renderNodeType(node, RENDER_CONTEXT);
408
+ function createRenderContext(footnotes: FootnotePlan): RenderContext {
409
+ const context: RenderContext = {
410
+ footnotes,
411
+ renderChildren(content: TiptapNode[] = []) {
412
+ return content.map((node) => context.renderNode(node)).join("");
413
+ },
414
+ renderNode(node) {
415
+ const renderNodeType = NODE_RENDERERS[node.type] ?? renderUnknownNode;
416
+ return renderNodeType(node, context);
417
+ },
418
+ renderText,
419
+ };
420
+
421
+ return context;
244
422
  }
245
423
 
246
- const RENDER_CONTEXT: RenderContext = {
247
- renderChildren,
248
- renderNode,
249
- renderText,
250
- };
251
-
252
424
  /**
253
425
  * Renders a parsed TipTap document to HTML.
254
426
  *
255
427
  * @param doc - Parsed TipTap document
428
+ * @param options - Rendering options, including a stable footnote ID namespace
256
429
  * @returns HTML string
257
430
  */
258
- export function renderTiptapDocument(doc: JSONContent): string {
431
+ export function renderTiptapDocument(
432
+ doc: JSONContent,
433
+ options: TiptapRenderOptions = {},
434
+ ): string {
259
435
  if (doc.type !== "doc") return "";
260
- return renderNode(normalizeFootnoteArtifacts(doc) as TiptapNode);
436
+ const normalized = normalizeFootnoteArtifacts(doc) as TiptapNode;
437
+ const context = createRenderContext(createFootnotePlan(normalized, options));
438
+ return context.renderNode(normalized);
439
+ }
440
+
441
+ export interface TiptapBoundaryRenderResult {
442
+ beforeHtml: string;
443
+ afterHtml: string;
444
+ }
445
+
446
+ /**
447
+ * Render a document as two byte-compatible segments around a top-level source
448
+ * boundary.
449
+ *
450
+ * The full document is planned before either segment is rendered, so a
451
+ * footnote before the boundary still includes backlinks to repeated references
452
+ * after it. Joining the two strings is therefore identical to a normal render.
453
+ *
454
+ * @param doc - Parsed TipTap document
455
+ * @param boundaryIndex - Index in the original `doc.content` array
456
+ * @param options - Rendering options, including a stable footnote ID namespace
457
+ * @returns The rendered segments, or null for an invalid root or boundary
458
+ * @example
459
+ * ```ts
460
+ * renderTiptapDocumentAroundBoundary(
461
+ * { type: "doc", content: [{ type: "paragraph" }] },
462
+ * 1,
463
+ * );
464
+ * // { beforeHtml: "<p></p>", afterHtml: "" }
465
+ * ```
466
+ */
467
+ export function renderTiptapDocumentAroundBoundary(
468
+ doc: JSONContent,
469
+ boundaryIndex: number,
470
+ options: TiptapRenderOptions = {},
471
+ ): TiptapBoundaryRenderResult | null {
472
+ const originalContent = doc.content ?? [];
473
+ if (
474
+ doc.type !== "doc" ||
475
+ !Array.isArray(doc.content) ||
476
+ !Number.isInteger(boundaryIndex) ||
477
+ boundaryIndex < 0 ||
478
+ boundaryIndex > originalContent.length
479
+ ) {
480
+ return null;
481
+ }
482
+
483
+ const normalized = normalizeFootnoteArtifacts(doc) as TiptapNode;
484
+ const context = createRenderContext(createFootnotePlan(normalized, options));
485
+ const normalizedBoundaryIndex = Math.min(
486
+ boundaryIndex,
487
+ normalized.content?.length ?? 0,
488
+ );
489
+ let beforeBodyHtml = "";
490
+ let afterBodyHtml = "";
491
+
492
+ for (const [index, node] of (normalized.content ?? []).entries()) {
493
+ if (node.type === "footnoteDefinition") continue;
494
+ const html = context.renderNode(node);
495
+ if (index < normalizedBoundaryIndex) {
496
+ beforeBodyHtml += html;
497
+ } else {
498
+ afterBodyHtml += html;
499
+ }
500
+ }
501
+
502
+ return {
503
+ beforeHtml: beforeBodyHtml,
504
+ afterHtml: afterBodyHtml + renderFootnoteEndnotes(context),
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Strictly parses and renders a TipTap JSON document.
510
+ *
511
+ * Unlike `renderTiptapJson`, this distinguishes invalid canonical JSON from a
512
+ * valid empty document. Projection rebuilds use it so malformed historical
513
+ * content is reported and never marked as current.
514
+ *
515
+ * @param json - TipTap JSON string
516
+ * @param options - Rendering options
517
+ * @returns A discriminated render result
518
+ */
519
+ export function tryRenderTiptapJson(
520
+ json: string,
521
+ options: TiptapRenderOptions = {},
522
+ ): TiptapRenderResult {
523
+ try {
524
+ const doc = JSON.parse(json) as JSONContent;
525
+ if (doc.type !== "doc") {
526
+ return { ok: false, error: "TipTap body root must be a doc node." };
527
+ }
528
+ return { ok: true, html: renderTiptapDocument(doc, options) };
529
+ } catch (error) {
530
+ return {
531
+ ok: false,
532
+ error: error instanceof Error ? error.message : "Invalid TipTap JSON.",
533
+ };
534
+ }
261
535
  }
262
536
 
263
537
  /**
264
538
  * Renders a Tiptap JSON document to an HTML string.
265
539
  *
266
540
  * @param json - Tiptap JSON string or parsed document object
541
+ * @param options - Rendering options, including a stable footnote ID namespace
267
542
  * @returns HTML string
268
543
  *
269
544
  * @example
@@ -272,13 +547,12 @@ export function renderTiptapDocument(doc: JSONContent): string {
272
547
  * // "<p>Hello</p>"
273
548
  * ```
274
549
  */
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
- }
550
+ export function renderTiptapJson(
551
+ json: string,
552
+ options: TiptapRenderOptions = {},
553
+ ): string {
554
+ const result = tryRenderTiptapJson(json, options);
555
+ return result.ok ? result.html : "";
282
556
  }
283
557
 
284
558
  /**
package/src/lib/view.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Theme components receive only View types -- no lib/ imports needed.
6
6
  */
7
7
 
8
+ import type { JSONContent } from "@tiptap/core";
8
9
  import type {
9
10
  Post,
10
11
  PostWithMedia,
@@ -35,7 +36,7 @@ import {
35
36
  import { getCollectionPagePath } from "./collection-paths.js";
36
37
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
37
38
  import { extractSummaryHtml, extractBodyText } from "./summary.js";
38
- import { renderTiptapDocument } from "./tiptap-render.js";
39
+ import { renderTiptapDocumentAroundBoundary } from "./tiptap-render.js";
39
40
  import { highlightText } from "./search-snippet.js";
40
41
  import { isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
41
42
 
@@ -154,8 +155,9 @@ const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
154
155
  * The summary HTML is not a byte-prefix of `bodyHtml` — structural nodes
155
156
  * (horizontalRule, moreBreak, image) appear in `bodyHtml` but are excluded from
156
157
  * the summary, so slicing `bodyHtml` by summary length lands mid-tag and
157
- * corrupts the markup. Instead we render the pre-boundary doc slice and splice
158
- * the marker at that exact block boundary.
158
+ * corrupts the markup. Instead we split a full-document render at the exact
159
+ * source boundary. The full render plan keeps repeated-footnote backlinks
160
+ * byte-compatible across the split.
159
161
  *
160
162
  * @param bodyJson - Tiptap JSON string for the post body
161
163
  * @param bodyHtml - Rendered body HTML to splice the marker into
@@ -169,6 +171,7 @@ function spliceAtSummaryBoundary(
169
171
  bodyHtml: string,
170
172
  breakAtIndex: number,
171
173
  markerHtml: string,
174
+ namespace: string,
172
175
  ): string | null {
173
176
  try {
174
177
  const doc = JSON.parse(bodyJson) as { type?: string; content?: unknown[] };
@@ -180,12 +183,13 @@ function spliceAtSummaryBoundary(
180
183
  ) {
181
184
  return null;
182
185
  }
183
- const beforeHtml = renderTiptapDocument({
184
- type: "doc",
185
- content: doc.content.slice(0, breakAtIndex) as never[],
186
- });
187
- if (!bodyHtml.startsWith(beforeHtml)) return null;
188
- return beforeHtml + markerHtml + bodyHtml.slice(beforeHtml.length);
186
+ const split = renderTiptapDocumentAroundBoundary(
187
+ doc as JSONContent,
188
+ breakAtIndex,
189
+ { namespace },
190
+ );
191
+ if (!split || split.beforeHtml + split.afterHtml !== bodyHtml) return null;
192
+ return split.beforeHtml + markerHtml + split.afterHtml;
189
193
  } catch {
190
194
  // Better an untouched body than corrupted markup.
191
195
  return null;
@@ -243,13 +247,13 @@ function clipPreviewText(
243
247
  *
244
248
  * @param post - Post with media attachments from database
245
249
  * @param _ctx - Media context with URL configuration
246
- * @param postCollections - Optional collections this post belongs to
250
+ * @param threadCollections - Optional shared Thread collections projected here
247
251
  * @returns Render-ready PostView with pre-computed fields
248
252
  */
249
253
  export function toPostView(
250
254
  post: PostWithMedia,
251
255
  ctx: MediaContext,
252
- postCollections?: Collection[],
256
+ threadCollections?: Collection[],
253
257
  isLastInThread?: boolean,
254
258
  aliasPath?: string,
255
259
  pinnedInCollection?: boolean,
@@ -288,6 +292,7 @@ export function toPostView(
288
292
  isArticle ? ARTICLE_SUMMARY_MAX_BLOCKS : NOTE_SUMMARY_MAX_BLOCKS,
289
293
  isArticle ? ARTICLE_SUMMARY_MAX_CHARS : NOTE_SUMMARY_MAX_CHARS,
290
294
  isArticle ? 0 : NOTE_SUMMARY_MIN_HIDDEN_CHARS,
295
+ { namespace: id },
291
296
  );
292
297
  if (result && isArticle) {
293
298
  summaryHtml = result.html;
@@ -298,6 +303,7 @@ export function toPostView(
298
303
  post.bodyHtml,
299
304
  result.breakAtIndex,
300
305
  '<span id="continue"></span>',
306
+ id,
301
307
  );
302
308
  if (spliced) bodyHtmlWithAnchor = spliced;
303
309
  }
@@ -307,6 +313,7 @@ export function toPostView(
307
313
  post.bodyHtml,
308
314
  result.breakAtIndex,
309
315
  "<span data-note-break></span>",
316
+ id,
310
317
  );
311
318
  if (spliced) {
312
319
  bodyHtmlWithAnchor = spliced;
@@ -316,11 +323,13 @@ export function toPostView(
316
323
  }
317
324
 
318
325
  // Convert collection tags
319
- const collections: CollectionTagView[] = (postCollections ?? []).map((c) => ({
320
- slug: c.slug,
321
- title: c.title,
322
- url: toPublicPath(getCollectionPagePath(c.slug), ctx.sitePathPrefix),
323
- }));
326
+ const collections: CollectionTagView[] = (threadCollections ?? []).map(
327
+ (c) => ({
328
+ slug: c.slug,
329
+ title: c.title,
330
+ url: toPublicPath(getCollectionPagePath(c.slug), ctx.sitePathPrefix),
331
+ }),
332
+ );
324
333
 
325
334
  // Convert media attachments
326
335
  const media: MediaView[] = post.mediaAttachments.map((m) => ({
@@ -429,12 +438,12 @@ export function toPostViewFromPost(
429
438
  ctx: MediaContext,
430
439
  isLastInThread?: boolean,
431
440
  aliasPath?: string,
432
- postCollections?: Collection[],
441
+ threadCollections?: Collection[],
433
442
  ): PostView {
434
443
  return toPostView(
435
444
  { ...post, mediaAttachments: [] },
436
445
  ctx,
437
- postCollections,
446
+ threadCollections,
438
447
  isLastInThread,
439
448
  aliasPath,
440
449
  );
@@ -534,6 +543,7 @@ export function toNavItemView(
534
543
  type: item.type as NavItemType,
535
544
  systemKey: item.systemKey,
536
545
  collectionId: item.collectionId,
546
+ postId: item.postId,
537
547
  label,
538
548
  url: publicUrl,
539
549
  placement: item.placement as NavItemPlacement,
@@ -587,14 +597,14 @@ export function toSearchResultView(
587
597
  ctx: MediaContext,
588
598
  query?: string,
589
599
  aliasPath?: string,
590
- postCollections?: Collection[],
600
+ threadCollections?: Collection[],
591
601
  ): SearchResultView {
592
602
  const post = toPostViewFromPost(
593
603
  result.post,
594
604
  ctx,
595
605
  undefined,
596
606
  aliasPath,
597
- postCollections,
607
+ threadCollections,
598
608
  );
599
609
 
600
610
  let titleHighlighted: string | undefined;