@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
@@ -0,0 +1,472 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { initFootnoteRails, __testOnly } from "../footnote-rail.js";
5
+
6
+ let rectangles = new WeakMap<globalThis.Element, Partial<globalThis.DOMRect>>();
7
+ let animationFrameId = 0;
8
+ let animationFrames = new Map<number, (time: number) => void>();
9
+
10
+ class TestResizeObserver {
11
+ static instances: TestResizeObserver[] = [];
12
+
13
+ readonly observed = new Set<globalThis.Element>();
14
+ disconnected = false;
15
+
16
+ constructor(
17
+ readonly callback: (
18
+ entries: globalThis.ResizeObserverEntry[],
19
+ observer: globalThis.ResizeObserver,
20
+ ) => void,
21
+ ) {
22
+ TestResizeObserver.instances.push(this);
23
+ }
24
+
25
+ observe(target: globalThis.Element): void {
26
+ this.observed.add(target);
27
+ }
28
+
29
+ unobserve(target: globalThis.Element): void {
30
+ this.observed.delete(target);
31
+ }
32
+
33
+ disconnect(): void {
34
+ this.disconnected = true;
35
+ this.observed.clear();
36
+ }
37
+ }
38
+
39
+ function rect(partial: Partial<globalThis.DOMRect>): globalThis.DOMRect {
40
+ const top = partial.top ?? 0;
41
+ const left = partial.left ?? 0;
42
+ const width = partial.width ?? 0;
43
+ const height = partial.height ?? 0;
44
+ return {
45
+ x: left,
46
+ y: top,
47
+ top,
48
+ right: partial.right ?? left + width,
49
+ bottom: partial.bottom ?? top + height,
50
+ left,
51
+ width,
52
+ height,
53
+ toJSON: () => ({}),
54
+ };
55
+ }
56
+
57
+ function setRect(
58
+ element: globalThis.Element,
59
+ partial: Partial<globalThis.DOMRect>,
60
+ ): void {
61
+ rectangles.set(element, partial);
62
+ }
63
+
64
+ function flushAnimationFrames(): void {
65
+ while (animationFrames.size > 0) {
66
+ const pending = animationFrames;
67
+ animationFrames = new Map();
68
+ for (const callback of pending.values()) callback(0);
69
+ }
70
+ }
71
+
72
+ function renderCanonicalFootnotes(): {
73
+ body: HTMLElement;
74
+ firstReference: HTMLAnchorElement;
75
+ repeatedReference: HTMLAnchorElement;
76
+ secondReference: HTMLAnchorElement;
77
+ firstNote: globalThis.HTMLLIElement;
78
+ secondNote: globalThis.HTMLLIElement;
79
+ list: globalThis.HTMLOListElement;
80
+ } {
81
+ document.body.innerHTML = `
82
+ <div data-post-view>
83
+ <article data-page="post">
84
+ <div class="e-content prose post-detail-body" data-post-body>
85
+ <p>First <sup class="footnote-ref"><a id="fnref-s-1-1" href="#fn-s-1" role="doc-noteref">1</a></sup></p>
86
+ <p>Repeated <sup class="footnote-ref"><a id="fnref-s-1-2" href="#fn-s-1" role="doc-noteref">1</a></sup></p>
87
+ <p>Second <sup class="footnote-ref"><a id="fnref-s-2-1" href="#fn-s-2" role="doc-noteref">2</a></sup></p>
88
+ <section class="footnote-endnotes" role="doc-endnotes">
89
+ <ol class="footnote-list">
90
+ <li id="fn-s-1" class="footnote"><p>One <a href="#fnref-s-1-1" role="doc-backlink">↩︎</a></p></li>
91
+ <li id="fn-s-2" class="footnote"><p>Two <a href="#fnref-s-2-1" role="doc-backlink">↩︎</a></p></li>
92
+ </ol>
93
+ </section>
94
+ </div>
95
+ </article>
96
+ </div>
97
+ `;
98
+
99
+ const requireElement = <T extends globalThis.Element>(
100
+ selector: string,
101
+ ): T => {
102
+ const element = document.querySelector<T>(selector);
103
+ if (!element) throw new Error(`Missing test element: ${selector}`);
104
+ return element;
105
+ };
106
+
107
+ return {
108
+ body: requireElement<HTMLElement>("[data-post-body]"),
109
+ firstReference: requireElement<HTMLAnchorElement>("#fnref-s-1-1"),
110
+ repeatedReference: requireElement<HTMLAnchorElement>("#fnref-s-1-2"),
111
+ secondReference: requireElement<HTMLAnchorElement>("#fnref-s-2-1"),
112
+ firstNote: requireElement<globalThis.HTMLLIElement>("#fn-s-1"),
113
+ secondNote: requireElement<globalThis.HTMLLIElement>("#fn-s-2"),
114
+ list: requireElement<globalThis.HTMLOListElement>(".footnote-list"),
115
+ };
116
+ }
117
+
118
+ function appendTimelineFootnotes(scope: string): {
119
+ container: HTMLElement;
120
+ body: HTMLElement;
121
+ firstReference: HTMLAnchorElement;
122
+ secondReference: HTMLAnchorElement;
123
+ firstNote: globalThis.HTMLLIElement;
124
+ secondNote: globalThis.HTMLLIElement;
125
+ } {
126
+ const container = document.createElement("div");
127
+ container.setAttribute("data-timeline-item", "");
128
+ container.setAttribute("data-timeline-item-id", scope);
129
+ container.innerHTML = `
130
+ <div data-timeline-item-content>
131
+ <article data-post="pst_${scope}">
132
+ <div class="e-content prose post-body-summary" data-post-body>
133
+ <p>First <sup class="footnote-ref"><a id="fnref-${scope}-1-1" href="#fn-${scope}-1" role="doc-noteref">1</a></sup></p>
134
+ <p>Second <sup class="footnote-ref"><a id="fnref-${scope}-2-1" href="#fn-${scope}-2" role="doc-noteref">2</a></sup></p>
135
+ <section class="footnote-endnotes" role="doc-endnotes">
136
+ <ol class="footnote-list">
137
+ <li id="fn-${scope}-1" class="footnote"><p>One <span class="footnote-backlinks"><a href="#fnref-${scope}-1-1" role="doc-backlink">↩︎</a></span></p></li>
138
+ <li id="fn-${scope}-2" class="footnote"><p>Two <span class="footnote-backlinks"><a href="#fnref-${scope}-2-1" role="doc-backlink">↩︎</a></span></p></li>
139
+ </ol>
140
+ </section>
141
+ </div>
142
+ </article>
143
+ </div>
144
+ `;
145
+ document.body.appendChild(container);
146
+
147
+ const requireElement = <T extends globalThis.Element>(
148
+ selector: string,
149
+ ): T => {
150
+ const element = container.querySelector<T>(selector);
151
+ if (!element) throw new Error(`Missing timeline test element: ${selector}`);
152
+ return element;
153
+ };
154
+
155
+ return {
156
+ container,
157
+ body: requireElement<HTMLElement>("[data-post-body]"),
158
+ firstReference: requireElement<HTMLAnchorElement>(`#fnref-${scope}-1-1`),
159
+ secondReference: requireElement<HTMLAnchorElement>(`#fnref-${scope}-2-1`),
160
+ firstNote: requireElement<globalThis.HTMLLIElement>(`#fn-${scope}-1`),
161
+ secondNote: requireElement<globalThis.HTMLLIElement>(`#fn-${scope}-2`),
162
+ };
163
+ }
164
+
165
+ describe("footnote rail layout", () => {
166
+ beforeEach(() => {
167
+ document.body.innerHTML = "";
168
+ globalThis.history.replaceState(null, "", "/");
169
+ rectangles = new WeakMap<globalThis.Element, Partial<globalThis.DOMRect>>();
170
+ animationFrameId = 0;
171
+ animationFrames = new Map();
172
+ TestResizeObserver.instances = [];
173
+
174
+ vi.stubGlobal("ResizeObserver", TestResizeObserver);
175
+ vi.stubGlobal(
176
+ "requestAnimationFrame",
177
+ (callback: (time: number) => void): number => {
178
+ animationFrameId += 1;
179
+ animationFrames.set(animationFrameId, callback);
180
+ return animationFrameId;
181
+ },
182
+ );
183
+ vi.stubGlobal("cancelAnimationFrame", (id: number): void => {
184
+ animationFrames.delete(id);
185
+ });
186
+ vi.spyOn(
187
+ globalThis.Element.prototype,
188
+ "getBoundingClientRect",
189
+ ).mockImplementation(function getBoundingClientRect(
190
+ this: globalThis.Element,
191
+ ): globalThis.DOMRect {
192
+ return rect(rectangles.get(this) ?? {});
193
+ });
194
+ vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
195
+ () =>
196
+ ({
197
+ getPropertyValue: (name: string) =>
198
+ name === "--jant-footnote-rail-enabled" ? "1" : "",
199
+ rowGap: "12px",
200
+ }) as globalThis.CSSStyleDeclaration,
201
+ );
202
+ });
203
+
204
+ afterEach(() => {
205
+ __testOnly.resetControllers();
206
+ vi.restoreAllMocks();
207
+ vi.unstubAllGlobals();
208
+ document.body.innerHTML = "";
209
+ globalThis.history.replaceState(null, "", "/");
210
+ });
211
+
212
+ it("keeps sparse notes at their natural anchors", () => {
213
+ expect(
214
+ __testOnly.computeFootnoteRailLayout(
215
+ [
216
+ { anchorTop: 10, blockSize: 20 },
217
+ { anchorTop: 50, blockSize: 10 },
218
+ ],
219
+ 12,
220
+ ),
221
+ ).toEqual({ offsets: [10, 50], blockSize: 60 });
222
+ });
223
+
224
+ it("pushes colliding notes down without moving the first note", () => {
225
+ expect(
226
+ __testOnly.computeFootnoteRailLayout(
227
+ [
228
+ { anchorTop: 10, blockSize: 20 },
229
+ { anchorTop: 15, blockSize: 30 },
230
+ ],
231
+ 12,
232
+ ),
233
+ ).toEqual({ offsets: [10, 42], blockSize: 72 });
234
+ });
235
+
236
+ it("clamps invalid geometry instead of producing invalid CSS", () => {
237
+ expect(
238
+ __testOnly.computeFootnoteRailLayout(
239
+ [
240
+ { anchorTop: -10, blockSize: Number.NaN },
241
+ { anchorTop: Number.POSITIVE_INFINITY, blockSize: 10 },
242
+ ],
243
+ -2,
244
+ ),
245
+ ).toEqual({ offsets: [0, 0], blockSize: 10 });
246
+ });
247
+
248
+ it("maps repeated references to the first occurrence", () => {
249
+ const { body, firstReference, firstNote, secondNote } =
250
+ renderCanonicalFootnotes();
251
+
252
+ const structure = __testOnly.findFootnoteStructure(body);
253
+
254
+ expect(structure?.pairs).toHaveLength(2);
255
+ expect(structure?.pairs[0]).toMatchObject({
256
+ reference: firstReference,
257
+ note: firstNote,
258
+ });
259
+ expect(structure?.pairs[1]?.note).toBe(secondNote);
260
+ });
261
+
262
+ it("fails closed when an endnote has no matching reference", () => {
263
+ const { body, secondReference } = renderCanonicalFootnotes();
264
+ secondReference.remove();
265
+
266
+ expect(__testOnly.findFootnoteStructure(body)).toBeNull();
267
+ });
268
+
269
+ it("positions the existing list and resolves a collision", () => {
270
+ const {
271
+ body,
272
+ firstReference,
273
+ repeatedReference,
274
+ secondReference,
275
+ firstNote,
276
+ secondNote,
277
+ list,
278
+ } = renderCanonicalFootnotes();
279
+ setRect(body, { top: 100, height: 200 });
280
+ setRect(firstReference, { top: 110 });
281
+ setRect(repeatedReference, { top: 118 });
282
+ setRect(secondReference, { top: 130 });
283
+ setRect(firstNote, { height: 30 });
284
+ setRect(secondNote, { height: 40 });
285
+
286
+ initFootnoteRails(document);
287
+ flushAnimationFrames();
288
+
289
+ expect(__testOnly.getControllerCount()).toBe(1);
290
+ expect(body.classList.contains("footnote-rail-ready")).toBe(true);
291
+ expect(firstNote.style.getPropertyValue("--footnote-rail-y")).toBe("10px");
292
+ expect(secondNote.style.getPropertyValue("--footnote-rail-y")).toBe("52px");
293
+ expect(firstNote.style.getPropertyValue("--footnote-rail-number")).toBe(
294
+ '"1"',
295
+ );
296
+ expect(list.style.getPropertyValue("--footnote-rail-block-size")).toBe(
297
+ "92px",
298
+ );
299
+ expect(body.style.minBlockSize).toBe("200px");
300
+ });
301
+
302
+ it("enhances each timeline item independently", () => {
303
+ const first = appendTimelineFootnotes("timeline-a");
304
+ const second = appendTimelineFootnotes("timeline-b");
305
+
306
+ setRect(first.body, { top: 100, height: 180 });
307
+ setRect(first.firstReference, { top: 110 });
308
+ setRect(first.secondReference, { top: 150 });
309
+ setRect(first.firstNote, { height: 20 });
310
+ setRect(first.secondNote, { height: 20 });
311
+
312
+ setRect(second.body, { top: 500, height: 180 });
313
+ setRect(second.firstReference, { top: 515 });
314
+ setRect(second.secondReference, { top: 560 });
315
+ setRect(second.firstNote, { height: 24 });
316
+ setRect(second.secondNote, { height: 24 });
317
+
318
+ initFootnoteRails(document);
319
+ flushAnimationFrames();
320
+
321
+ expect(__testOnly.getControllerCount()).toBe(2);
322
+ expect(first.body.classList.contains("footnote-rail-ready")).toBe(true);
323
+ expect(second.body.classList.contains("footnote-rail-ready")).toBe(true);
324
+ expect(first.firstNote.style.getPropertyValue("--footnote-rail-y")).toBe(
325
+ "10px",
326
+ );
327
+ expect(second.firstNote.style.getPropertyValue("--footnote-rail-y")).toBe(
328
+ "15px",
329
+ );
330
+ expect(TestResizeObserver.instances).toHaveLength(2);
331
+ expect(TestResizeObserver.instances[0]?.observed.has(first.container)).toBe(
332
+ true,
333
+ );
334
+ expect(
335
+ TestResizeObserver.instances[1]?.observed.has(second.container),
336
+ ).toBe(true);
337
+ });
338
+
339
+ it("hydrates a swapped timeline item and cleans up detached controllers", () => {
340
+ const detail = renderCanonicalFootnotes();
341
+ const timeline = appendTimelineFootnotes("timeline-hydrated");
342
+
343
+ setRect(detail.body, { top: 100, height: 180 });
344
+ setRect(detail.firstReference, { top: 110 });
345
+ setRect(detail.secondReference, { top: 150 });
346
+ setRect(detail.firstNote, { height: 20 });
347
+ setRect(detail.secondNote, { height: 20 });
348
+ setRect(timeline.body, { top: 500, height: 180 });
349
+ setRect(timeline.firstReference, { top: 510 });
350
+ setRect(timeline.secondReference, { top: 550 });
351
+ setRect(timeline.firstNote, { height: 20 });
352
+ setRect(timeline.secondNote, { height: 20 });
353
+
354
+ initFootnoteRails(timeline.container);
355
+ flushAnimationFrames();
356
+
357
+ expect(__testOnly.getControllerCount()).toBe(1);
358
+ expect(timeline.body.classList.contains("footnote-rail-ready")).toBe(true);
359
+ expect(detail.body.classList.contains("footnote-rail-ready")).toBe(false);
360
+
361
+ initFootnoteRails(document);
362
+ flushAnimationFrames();
363
+ expect(__testOnly.getControllerCount()).toBe(2);
364
+
365
+ timeline.container.remove();
366
+ initFootnoteRails(document);
367
+ flushAnimationFrames();
368
+ expect(__testOnly.getControllerCount()).toBe(1);
369
+ });
370
+
371
+ it("leaves excessive rails as bottom endnotes", () => {
372
+ const { body, firstReference, secondReference, firstNote, secondNote } =
373
+ renderCanonicalFootnotes();
374
+ setRect(body, { top: 100, height: 40 });
375
+ setRect(firstReference, { top: 105 });
376
+ setRect(secondReference, { top: 110 });
377
+ setRect(firstNote, { height: 240 });
378
+ setRect(secondNote, { height: 240 });
379
+
380
+ initFootnoteRails(document);
381
+ flushAnimationFrames();
382
+
383
+ expect(body.classList.contains("footnote-rail-ready")).toBe(false);
384
+ expect(firstNote.style.getPropertyValue("--footnote-rail-y")).toBe("");
385
+ expect(body.style.minBlockSize).toBe("");
386
+ });
387
+
388
+ it("keeps canonical bottom endnotes when the container is not wide enough", () => {
389
+ const { body, firstReference, secondReference, firstNote, secondNote } =
390
+ renderCanonicalFootnotes();
391
+ vi.mocked(globalThis.getComputedStyle).mockImplementation(
392
+ () =>
393
+ ({
394
+ getPropertyValue: () => "0",
395
+ rowGap: "12px",
396
+ }) as unknown as globalThis.CSSStyleDeclaration,
397
+ );
398
+ setRect(body, { top: 100, height: 200 });
399
+ setRect(firstReference, { top: 110 });
400
+ setRect(secondReference, { top: 160 });
401
+ setRect(firstNote, { height: 20 });
402
+ setRect(secondNote, { height: 20 });
403
+
404
+ initFootnoteRails(document);
405
+ flushAnimationFrames();
406
+
407
+ expect(body.classList.contains("footnote-rail-ready")).toBe(false);
408
+ expect(firstNote.style.getPropertyValue("--footnote-rail-y")).toBe("");
409
+ });
410
+
411
+ it("restores bottom endnotes for print and remeasures afterward", () => {
412
+ const { body, firstReference, secondReference, firstNote, secondNote } =
413
+ renderCanonicalFootnotes();
414
+ setRect(body, { top: 100, height: 200 });
415
+ setRect(firstReference, { top: 110 });
416
+ setRect(secondReference, { top: 160 });
417
+ setRect(firstNote, { height: 20 });
418
+ setRect(secondNote, { height: 20 });
419
+ initFootnoteRails(document);
420
+ flushAnimationFrames();
421
+ expect(body.classList.contains("footnote-rail-ready")).toBe(true);
422
+
423
+ globalThis.dispatchEvent(new Event("beforeprint"));
424
+
425
+ expect(body.classList.contains("footnote-rail-ready")).toBe(false);
426
+ expect(body.style.minBlockSize).toBe("");
427
+
428
+ globalThis.dispatchEvent(new Event("afterprint"));
429
+ flushAnimationFrames();
430
+
431
+ expect(body.classList.contains("footnote-rail-ready")).toBe(true);
432
+ });
433
+
434
+ it("repairs initial footnote hash position after enhancement", () => {
435
+ const { body, firstReference, secondReference, firstNote, secondNote } =
436
+ renderCanonicalFootnotes();
437
+ globalThis.history.replaceState(null, "", "/post#fn-s-2");
438
+ const scrollIntoView = vi.fn();
439
+ secondNote.scrollIntoView = scrollIntoView;
440
+ setRect(body, { top: 100, height: 200 });
441
+ setRect(firstReference, { top: 110 });
442
+ setRect(secondReference, { top: 160 });
443
+ setRect(firstNote, { height: 20 });
444
+ setRect(secondNote, { height: 20 });
445
+
446
+ initFootnoteRails(document);
447
+ flushAnimationFrames();
448
+
449
+ expect(scrollIntoView).toHaveBeenCalledOnce();
450
+ expect(scrollIntoView).toHaveBeenCalledWith({
451
+ behavior: "auto",
452
+ block: "nearest",
453
+ });
454
+ });
455
+
456
+ it("does not initialize rich text outside public post surfaces", () => {
457
+ document.body.innerHTML = `
458
+ <div class="compose-reply-context-body">
459
+ <article data-post="pst_compose">
460
+ <div class="prose post-body-summary" data-post-body>
461
+ <p>Compose preview</p>
462
+ </div>
463
+ </article>
464
+ </div>
465
+ `;
466
+
467
+ initFootnoteRails(document);
468
+ flushAnimationFrames();
469
+
470
+ expect(__testOnly.getControllerCount()).toBe(0);
471
+ });
472
+ });
@@ -8,11 +8,13 @@ import { describe, expect, it, vi } from "vitest";
8
8
  vi.mock("../thread-context.js", () => ({ setupThreadContexts: vi.fn() }));
9
9
  vi.mock("../feed-video-player.js", () => ({ initFeedVideoPlayer: vi.fn() }));
10
10
  vi.mock("../audio-player.js", () => ({ initPrecomputedWaveforms: vi.fn() }));
11
+ vi.mock("../footnote-rail.js", () => ({ initFootnoteRails: vi.fn() }));
11
12
 
12
13
  import { hydratePartial } from "../hydrate-partial.js";
13
14
  import { setupThreadContexts } from "../thread-context.js";
14
15
  import { initFeedVideoPlayer } from "../feed-video-player.js";
15
16
  import { initPrecomputedWaveforms } from "../audio-player.js";
17
+ import { initFootnoteRails } from "../footnote-rail.js";
16
18
 
17
19
  describe("hydratePartial", () => {
18
20
  it("re-initializes per-element behaviors scoped to the swapped root", () => {
@@ -23,5 +25,6 @@ describe("hydratePartial", () => {
23
25
  expect(setupThreadContexts).toHaveBeenCalledWith(root);
24
26
  expect(initFeedVideoPlayer).toHaveBeenCalledWith(root);
25
27
  expect(initPrecomputedWaveforms).toHaveBeenCalledWith(root);
28
+ expect(initFootnoteRails).toHaveBeenCalledWith(root);
26
29
  });
27
30
  });
@@ -1,16 +1,18 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { beforeEach, describe, expect, it } from "vitest";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  QUEUED_TOAST_STORAGE_KEY,
6
6
  consumeQueuedToast,
7
7
  queueToastForNextPage,
8
+ showToastWithAction,
8
9
  } from "../toast.js";
9
10
 
10
11
  describe("toast", () => {
11
12
  beforeEach(() => {
12
13
  document.body.innerHTML = '<div id="toast-container"></div>';
13
14
  globalThis.sessionStorage.clear();
15
+ vi.useRealTimers();
14
16
  });
15
17
 
16
18
  it("queues and consumes a toast on the next page load", () => {
@@ -43,4 +45,19 @@ describe("toast", () => {
43
45
  expect(action?.textContent).toBe("View");
44
46
  expect(action?.getAttribute("href")).toBe("/published-post");
45
47
  });
48
+
49
+ it("keeps action toasts available longer than passive notifications", () => {
50
+ vi.useFakeTimers();
51
+ showToastWithAction("Collection created.", {
52
+ label: "Edit Navigation",
53
+ href: "/settings/navigation",
54
+ });
55
+ const toast = document.querySelector<HTMLElement>(".toast");
56
+
57
+ vi.advanceTimersByTime(3000);
58
+ expect(toast?.classList.contains("toast-out")).toBe(false);
59
+
60
+ vi.advanceTimersByTime(5000);
61
+ expect(toast?.classList.contains("toast-out")).toBe(true);
62
+ });
46
63
  });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One-time state passed from the Collection editor to the Collections page.
3
+ */
4
+
5
+ export const PENDING_COLLECTION_CREATED_STORAGE_KEY =
6
+ "jant.pendingCollectionCreated";
7
+
8
+ interface PendingCollectionCreated {
9
+ collectionId: string;
10
+ }
11
+
12
+ function canUseSessionStorage(): boolean {
13
+ try {
14
+ return typeof globalThis.sessionStorage !== "undefined";
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Queue a Collection ID so the destination page can show an inline next step.
22
+ *
23
+ * @param collectionId - TypeID of the newly created Collection
24
+ * @returns Nothing
25
+ * @example
26
+ * queueCollectionCreatedNotice("col_01abc");
27
+ */
28
+ export function queueCollectionCreatedNotice(collectionId: string): void {
29
+ const normalizedId = collectionId.trim();
30
+ if (!normalizedId || !canUseSessionStorage()) return;
31
+
32
+ globalThis.sessionStorage.setItem(
33
+ PENDING_COLLECTION_CREATED_STORAGE_KEY,
34
+ JSON.stringify({
35
+ collectionId: normalizedId,
36
+ } satisfies PendingCollectionCreated),
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Consume the queued Collection ID, clearing it whether or not it is valid.
42
+ *
43
+ * @returns The newly created Collection ID, or null when none is available
44
+ * @example
45
+ * const collectionId = consumeCollectionCreatedNotice();
46
+ */
47
+ export function consumeCollectionCreatedNotice(): string | null {
48
+ if (!canUseSessionStorage()) return null;
49
+
50
+ const raw = globalThis.sessionStorage.getItem(
51
+ PENDING_COLLECTION_CREATED_STORAGE_KEY,
52
+ );
53
+ if (!raw) return null;
54
+
55
+ globalThis.sessionStorage.removeItem(PENDING_COLLECTION_CREATED_STORAGE_KEY);
56
+
57
+ try {
58
+ const parsed = JSON.parse(raw) as Partial<PendingCollectionCreated>;
59
+ return typeof parsed.collectionId === "string" &&
60
+ parsed.collectionId.trim().length > 0
61
+ ? parsed.collectionId.trim()
62
+ : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
@@ -14,6 +14,7 @@ import {
14
14
  getCollectionsDirectoryPath,
15
15
  } from "../lib/collection-paths.js";
16
16
  import { publicPath } from "./runtime-paths.js";
17
+ import { queueCollectionCreatedNotice } from "./collection-created-notice.js";
17
18
  import { showToast } from "./toast.js";
18
19
 
19
20
  function normalizeLocalHref(href: string): URL | null {
@@ -138,6 +139,7 @@ document.addEventListener("jant:collection-submit", async (event: Event) => {
138
139
  body: JSON.stringify(detail.data),
139
140
  });
140
141
  const json = (await res.json().catch(() => null)) as {
142
+ id?: string;
141
143
  slug?: string;
142
144
  error?: string;
143
145
  } | null;
@@ -158,6 +160,21 @@ document.addEventListener("jant:collection-submit", async (event: Event) => {
158
160
  : undefined,
159
161
  );
160
162
 
163
+ if (!detail.isEdit) {
164
+ const collectionId =
165
+ typeof json?.id === "string" && json.id.length > 0
166
+ ? json.id
167
+ : undefined;
168
+ const destination = normalizeLocalHref(redirectUrl);
169
+ const destinationPath = destination
170
+ ? toInternalPath(destination.pathname)
171
+ : null;
172
+
173
+ if (collectionId && destinationPath === getCollectionsDirectoryPath()) {
174
+ queueCollectionCreatedNotice(collectionId);
175
+ }
176
+ }
177
+
161
178
  window.location.href = redirectUrl;
162
179
  return;
163
180
  } catch (error) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared client-side helpers for adding Collections to site navigation.
3
+ */
4
+
5
+ import { getJsonString, readJsonObject } from "./json.js";
6
+ import { applySiteHeaderHtml } from "./site-header-fragment.js";
7
+
8
+ const SITE_HEADER_REQUEST_HEADER = "X-Jant-Site-Header";
9
+ const INCLUDE_SITE_HEADER_RESPONSE = "include";
10
+
11
+ /**
12
+ * Add a Collection to the end of the primary navigation and refresh the
13
+ * rendered site header when the server returns a fragment.
14
+ *
15
+ * @param collectionId - TypeID of the Collection to add
16
+ * @returns The created navigation item ID when present
17
+ * @example
18
+ * const navItemId = await addCollectionToNavigation("col_01abc");
19
+ */
20
+ export async function addCollectionToNavigation(
21
+ collectionId: string,
22
+ ): Promise<string | undefined> {
23
+ const response = await fetch("/api/nav-items", {
24
+ method: "POST",
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ Accept: "application/json",
28
+ [SITE_HEADER_REQUEST_HEADER]: INCLUDE_SITE_HEADER_RESPONSE,
29
+ },
30
+ body: JSON.stringify({
31
+ type: "collection",
32
+ collectionId,
33
+ placement: "header",
34
+ }),
35
+ });
36
+ const body = await readJsonObject(response);
37
+
38
+ if (!response.ok) {
39
+ throw new Error(getJsonString(body, "error") ?? `HTTP ${response.status}`);
40
+ }
41
+
42
+ applySiteHeaderHtml(getJsonString(body, "headerHtml"));
43
+ return getJsonString(body, "id");
44
+ }