blume 1.6.6 → 1.7.0

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 (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/{chunk-nyqzjdhj.js → chunk-0qhq7b8q.js} +5 -5
  3. package/dist/cli/{chunk-cnvm6k3e.js → chunk-18tjv4f7.js} +11 -11
  4. package/dist/cli/{chunk-62qsssnh.js → chunk-5d4q7121.js} +401 -145
  5. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  6. package/dist/cli/{chunk-ag1zyr5x.js → chunk-9qs6acpw.js} +11 -11
  7. package/dist/cli/{chunk-aerwpe14.js → chunk-agy5rzxy.js} +98 -15
  8. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  9. package/dist/cli/{chunk-x1vrdjyk.js → chunk-cfw6x4rm.js} +5 -5
  10. package/dist/cli/{chunk-bawgnt8x.js → chunk-ckh3a410.js} +3 -3
  11. package/dist/cli/{chunk-j00ezcg5.js → chunk-drke6t0h.js} +9 -9
  12. package/dist/cli/{chunk-3k0kzs6d.js → chunk-j6pxe0dt.js} +2 -2
  13. package/dist/cli/{chunk-n0y172hf.js → chunk-jk1zwka1.js} +4 -4
  14. package/dist/cli/{chunk-f75cqye8.js → chunk-jxkxjsc1.js} +10 -10
  15. package/dist/cli/{chunk-s4k1pnvf.js → chunk-kwx90v78.js} +11 -11
  16. package/dist/cli/{chunk-9sh49q0h.js → chunk-n0nyat6g.js} +2 -2
  17. package/dist/cli/{chunk-wkq5tbtq.js → chunk-qq9nm3qd.js} +3 -3
  18. package/dist/cli/{chunk-etsqspj6.js → chunk-s102bysw.js} +2 -2
  19. package/dist/cli/{chunk-wb067mv3.js → chunk-s5dsk8bj.js} +18 -7
  20. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  21. package/dist/cli/{chunk-m3p3wahd.js → chunk-tnskyrej.js} +4 -4
  22. package/dist/cli/{chunk-vv237fp3.js → chunk-v2ymm99c.js} +26 -12
  23. package/dist/cli/{chunk-vv237fp3.js.map → chunk-v2ymm99c.js.map} +3 -3
  24. package/dist/cli/{chunk-5yvt556e.js → chunk-v5mm027v.js} +2 -2
  25. package/dist/cli/{chunk-vv3f8mb6.js → chunk-xv91q4nm.js} +24 -24
  26. package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-xv91q4nm.js.map} +3 -3
  27. package/dist/cli/{chunk-0ewz4trd.js → chunk-y3g15rvv.js} +6 -6
  28. package/dist/cli/{chunk-tc89yh2r.js → chunk-ye9zdkgv.js} +2 -2
  29. package/dist/cli/{chunk-n4qjabmt.js → chunk-ynacq3ev.js} +4 -4
  30. package/dist/cli/{chunk-s4jn7f1q.js → chunk-zr3ygrq3.js} +2 -2
  31. package/dist/cli/index.js +13 -13
  32. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  33. package/dist/types/theme/fonts.d.ts +22 -22
  34. package/docs/02-deployment.mdx +21 -0
  35. package/docs/content/navigation.mdx +2 -0
  36. package/docs/content/syntax.mdx +1 -1
  37. package/docs/discoverability/open-graph.mdx +4 -0
  38. package/package.json +1 -1
  39. package/src/astro/generate.ts +141 -5
  40. package/src/astro/integration.ts +12 -1
  41. package/src/astro/module-types.ts +9 -0
  42. package/src/astro/templates.ts +171 -11
  43. package/src/cli/commands/build.ts +28 -0
  44. package/src/components/Icon.astro +24 -0
  45. package/src/components/icon-sprite-middleware.ts +41 -0
  46. package/src/components/icon-sprite.ts +93 -0
  47. package/src/components/layout/IconSprite.astro +11 -0
  48. package/src/components/layout/NavTree.astro +156 -188
  49. package/src/components/layout/NavTreeCache.astro +45 -0
  50. package/src/components/layout/NavTreeScript.astro +256 -0
  51. package/src/components/layout/PageActions.astro +11 -5
  52. package/src/components/layout/PageLayout.astro +7 -0
  53. package/src/components/layout/ReferenceLayout.astro +7 -0
  54. package/src/components/layout/RootLayout.astro +30 -2
  55. package/src/components/layout/nav-cache.ts +49 -0
  56. package/src/components/layout/nav-utils.ts +69 -1
  57. package/src/markdown/language-icon.ts +64 -20
  58. package/src/markdown/mermaid.ts +11 -0
  59. package/src/og/cache.ts +236 -0
  60. package/src/og/card.ts +12 -4
  61. package/src/og/index.ts +8 -1
  62. package/src/registry/eject.ts +23 -8
  63. package/src/theme/entry.ts +41 -7
  64. package/src/theme/fonts.ts +30 -23
  65. package/dist/cli/chunk-62qsssnh.js.map +0 -36
  66. package/dist/cli/chunk-aerwpe14.js.map +0 -15
  67. package/dist/cli/chunk-wb067mv3.js.map +0 -13
  68. /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-0qhq7b8q.js.map} +0 -0
  69. /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-18tjv4f7.js.map} +0 -0
  70. /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-9qs6acpw.js.map} +0 -0
  71. /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-cfw6x4rm.js.map} +0 -0
  72. /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-ckh3a410.js.map} +0 -0
  73. /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-drke6t0h.js.map} +0 -0
  74. /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-j6pxe0dt.js.map} +0 -0
  75. /package/dist/cli/{chunk-n0y172hf.js.map → chunk-jk1zwka1.js.map} +0 -0
  76. /package/dist/cli/{chunk-f75cqye8.js.map → chunk-jxkxjsc1.js.map} +0 -0
  77. /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-kwx90v78.js.map} +0 -0
  78. /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-n0nyat6g.js.map} +0 -0
  79. /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-qq9nm3qd.js.map} +0 -0
  80. /package/dist/cli/{chunk-etsqspj6.js.map → chunk-s102bysw.js.map} +0 -0
  81. /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-tnskyrej.js.map} +0 -0
  82. /package/dist/cli/{chunk-5yvt556e.js.map → chunk-v5mm027v.js.map} +0 -0
  83. /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-y3g15rvv.js.map} +0 -0
  84. /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-ye9zdkgv.js.map} +0 -0
  85. /package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ynacq3ev.js.map} +0 -0
  86. /package/dist/cli/{chunk-s4jn7f1q.js.map → chunk-zr3ygrq3.js.map} +0 -0
@@ -0,0 +1,256 @@
1
+ ---
2
+ // The sidebar's client behavior, split out of NavTree so a subtree rendered
3
+ // into the sidebar cache (see nav-cache.ts) never carries a copy of it: Astro
4
+ // emits a component's script where the component renders, and a cached
5
+ // subtree would replay the copy captured on its first render on every later
6
+ // page, next to the live root's own. Rendered by the root NavTree only, when
7
+ // a `page`-mode group gives the panel stack something to do or sections are
8
+ // deferred.
9
+ //
10
+ // Two jobs. The `<blume-nav>` drill-in panel stack; and deferred sections —
11
+ // a collapsed group's children or an inactive panel's contents left out of
12
+ // the page as an empty element with `data-nav-src`, fetched from the
13
+ // prerendered fragment at that URL on first open (and prefetched on hover or
14
+ // focus, so the open is usually instant). Fetched fragments are kept for the
15
+ // session, so a section opened once is free on every later page.
16
+ ---
17
+
18
+ <script>
19
+ const DURATION = 260;
20
+ const EASING = "cubic-bezier(0.33, 1, 0.68, 1)";
21
+
22
+ /** Fragment URL → its HTML, once fetched (shared across router swaps). */
23
+ const fragments = new Map<string, Promise<string>>();
24
+
25
+ const loadFragment = (src: string): Promise<string> => {
26
+ let pending = fragments.get(src);
27
+ if (!pending) {
28
+ pending = fetch(src, { headers: { Accept: "text/html" } }).then(
29
+ (response) => {
30
+ if (!response.ok) {
31
+ throw new Error(`Sidebar fragment ${src} responded ${response.status}`);
32
+ }
33
+ return response.text();
34
+ }
35
+ );
36
+ fragments.set(src, pending);
37
+ // A failed fetch is retried on the next open rather than cached.
38
+ pending.catch(() => fragments.delete(src));
39
+ }
40
+ return pending;
41
+ };
42
+
43
+ /** Fill a deferred element from its fragment; a no-op once filled. */
44
+ const fillDeferred = async (slot: HTMLElement): Promise<void> => {
45
+ const src = slot.dataset.navSrc;
46
+ if (!src) {
47
+ return;
48
+ }
49
+ slot.setAttribute("aria-busy", "true");
50
+ try {
51
+ slot.innerHTML = await loadFragment(src);
52
+ delete slot.dataset.navSrc;
53
+ } finally {
54
+ slot.removeAttribute("aria-busy");
55
+ }
56
+ };
57
+
58
+ const deferredIn = (element: Element | null): HTMLElement | null =>
59
+ element instanceof HTMLElement && element.dataset.navSrc ? element : null;
60
+
61
+ // Delegated at the document so the handlers survive client-router swaps
62
+ // (the document persists; only its tree is replaced). Module scripts run
63
+ // once per real load, and the flag keeps a second copy of this module —
64
+ // a page that renders two trees — from doubling the listeners.
65
+ let installed = false;
66
+ const install = (): void => {
67
+ if (installed) {
68
+ return;
69
+ }
70
+ installed = true;
71
+ document.addEventListener(
72
+ "toggle",
73
+ (event) => {
74
+ const details = event.target;
75
+ if (!(details instanceof HTMLDetailsElement) || !details.open) {
76
+ return;
77
+ }
78
+ const slot = deferredIn(details.querySelector(":scope > [data-nav-src]"));
79
+ if (slot) {
80
+ void fillDeferred(slot);
81
+ }
82
+ },
83
+ true
84
+ );
85
+ // Prefetch on intent: a hovered or focused summary, or a drill-in row.
86
+ const prefetch = (event: Event): void => {
87
+ const target = event.target;
88
+ if (!(target instanceof Element)) {
89
+ return;
90
+ }
91
+ const summary = target.closest("summary");
92
+ const slot = summary
93
+ ? deferredIn(summary.parentElement?.querySelector(":scope > [data-nav-src]") ?? null)
94
+ : null;
95
+ if (slot?.dataset.navSrc) {
96
+ void loadFragment(slot.dataset.navSrc).catch(() => undefined);
97
+ return;
98
+ }
99
+ const drill = target.closest("[data-nav-to]");
100
+ const nav = drill?.closest("blume-nav");
101
+ if (drill && nav instanceof BlumeNav) {
102
+ const panel = deferredIn(nav.panel(drill.getAttribute("data-nav-to") ?? ""));
103
+ if (panel?.dataset.navSrc) {
104
+ void loadFragment(panel.dataset.navSrc).catch(() => undefined);
105
+ }
106
+ }
107
+ };
108
+ document.addEventListener("pointerover", prefetch);
109
+ document.addEventListener("focusin", prefetch);
110
+ };
111
+
112
+ class BlumeNav extends HTMLElement {
113
+ active: HTMLElement | null = null;
114
+ finishSlide: (() => void) | null = null;
115
+
116
+ connectedCallback() {
117
+ if (this.dataset.ready) {
118
+ return;
119
+ }
120
+ this.dataset.ready = "1";
121
+ this.addEventListener("click", (event) => {
122
+ const target = event.target;
123
+ if (!(target instanceof Element)) {
124
+ return;
125
+ }
126
+ const drill = target.closest("[data-nav-to]");
127
+ if (drill && this.contains(drill)) {
128
+ const id = drill.getAttribute("data-nav-to");
129
+ if (id && this.panel(id)) {
130
+ event.preventDefault();
131
+ void this.show(id, true);
132
+ }
133
+ return;
134
+ }
135
+ const back = target.closest("[data-nav-back]");
136
+ if (back && this.contains(back)) {
137
+ event.preventDefault();
138
+ void this.show(back.getAttribute("data-nav-back") || "root", true);
139
+ }
140
+ });
141
+ // Initial panel is route-driven, so it snaps into place without a slide.
142
+ void this.show(this.dataset.initial || "root", false);
143
+ }
144
+
145
+ panel(id: string): HTMLElement | null {
146
+ return this.querySelector(`[data-nav-panel="${id}"]`);
147
+ }
148
+
149
+ depth(panel: HTMLElement): number {
150
+ return Number(panel.dataset.navDepth ?? "0");
151
+ }
152
+
153
+ reduced(): boolean {
154
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
155
+ }
156
+
157
+ async show(id: string, animate: boolean): Promise<void> {
158
+ const next = this.panel(id) || this.panel("root");
159
+ if (!next) {
160
+ return;
161
+ }
162
+ // A deferred panel loads its contents before it slides in.
163
+ await fillDeferred(next);
164
+ const prev = this.active;
165
+ if (prev === next) {
166
+ return;
167
+ }
168
+ // Settle any in-flight slide before starting the next one.
169
+ if (this.finishSlide) {
170
+ this.finishSlide();
171
+ }
172
+ this.active = next;
173
+ this.dataset.active = next.dataset.navPanel;
174
+
175
+ if (!(animate && prev) || this.reduced()) {
176
+ for (const panel of this.querySelectorAll<HTMLElement>("[data-nav-panel]")) {
177
+ panel.hidden = panel !== next;
178
+ }
179
+ return;
180
+ }
181
+
182
+ this.slide(prev, next, this.depth(next) > this.depth(prev));
183
+ }
184
+
185
+ slide(out: HTMLElement, into: HTMLElement, forward: boolean): void {
186
+ const rtl = getComputedStyle(this).direction === "rtl" ? -1 : 1;
187
+ const sign = (forward ? -1 : 1) * rtl;
188
+
189
+ const fromHeight = out.offsetHeight;
190
+ this.style.position = "relative";
191
+ this.style.overflow = "hidden";
192
+ this.style.height = `${fromHeight}px`;
193
+ into.hidden = false;
194
+ for (const panel of [out, into]) {
195
+ panel.style.position = "absolute";
196
+ panel.style.top = "0";
197
+ panel.style.insetInlineStart = "0";
198
+ panel.style.width = "100%";
199
+ }
200
+ const toHeight = into.offsetHeight;
201
+ this.style.height = `${toHeight}px`;
202
+
203
+ const options = { duration: DURATION, easing: EASING };
204
+ const animations = [
205
+ out.animate(
206
+ [
207
+ { transform: "translateX(0)" },
208
+ { transform: `translateX(${sign * 100}%)` },
209
+ ],
210
+ options
211
+ ),
212
+ into.animate(
213
+ [
214
+ { transform: `translateX(${-sign * 100}%)` },
215
+ { transform: "translateX(0)" },
216
+ ],
217
+ options
218
+ ),
219
+ this.animate(
220
+ [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }],
221
+ options
222
+ ),
223
+ ];
224
+
225
+ let done = false;
226
+ const finish = () => {
227
+ if (done) {
228
+ return;
229
+ }
230
+ done = true;
231
+ for (const animation of animations) {
232
+ animation.cancel();
233
+ }
234
+ out.hidden = true;
235
+ for (const panel of [out, into]) {
236
+ panel.style.position = "";
237
+ panel.style.top = "";
238
+ panel.style.insetInlineStart = "";
239
+ panel.style.width = "";
240
+ panel.style.transform = "";
241
+ }
242
+ this.style.position = "";
243
+ this.style.overflow = "";
244
+ this.style.height = "";
245
+ this.finishSlide = null;
246
+ };
247
+ this.finishSlide = finish;
248
+ animations[0]?.finished.then(finish, finish);
249
+ }
250
+ }
251
+
252
+ install();
253
+ if (!customElements.get("blume-nav")) {
254
+ customElements.define("blume-nav", BlumeNav);
255
+ }
256
+ </script>
@@ -523,11 +523,17 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
523
523
  epubLabel.textContent = generatingLabel;
524
524
  }
525
525
  try {
526
- // Browser bundle: avoids Node built-ins and returns a Blob. Lazy-loaded
527
- // so it never ships in the main bundle. The browserified UMD nests its
528
- // callable under `.default`, which Vite's dev (esbuild) and build
529
- // (rollup) interops expose at slightly different depths — unwrap both.
530
- const epubModule = await import("epub-gen-memory/bundle");
526
+ // Browser bundle: avoids Node built-ins and returns a Blob. Loaded
527
+ // through the generated feature loaders, so it's absent from the
528
+ // bundle altogether when export.epub is off (this button isn't
529
+ // rendered then either). The browserified UMD nests its callable
530
+ // under `.default`, which Vite's dev (esbuild) and build (rollup)
531
+ // interops expose at slightly different depths — unwrap both.
532
+ const { loadEpub } = await import("blume:features");
533
+ if (!loadEpub) {
534
+ throw new Error("EPUB export is not enabled for this site.");
535
+ }
536
+ const epubModule = await loadEpub();
531
537
  const epub = epubModule.default?.default ?? epubModule.default;
532
538
  // The article HTML already opens with the page's <h1>, so don't let the
533
539
  // generator prepend a second chapter-title heading.
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import IconSprite from "./IconSprite.astro";
3
+ import { createIconSprite } from "../icon-sprite.ts";
2
4
  // A full-width page layout for landing pages, marketing pages, dashboards — any
3
5
  // page that wants Blume's chrome (document shell, header, theme, fonts) without
4
6
  // the docs sidebar + prose + TOC grid that RootLayout hard-codes. It renders the
@@ -243,6 +245,10 @@ const structuredDataJson = structuredData
243
245
  : null;
244
246
 
245
247
  const bannerKey = banner?.dismissible ? banner.key : null;
248
+
249
+ // Start the page's icon sprite before anything renders an icon (see
250
+ // icon-sprite.ts); <IconSprite /> at the end of the body emits it.
251
+ createIconSprite(Astro.locals);
246
252
  ---
247
253
 
248
254
  <!doctype html>
@@ -412,5 +418,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
412
418
 
413
419
  syncDrawerInert();
414
420
  </script>
421
+ <IconSprite />
415
422
  </body>
416
423
  </html>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import IconSprite from "./IconSprite.astro";
3
+ import { createIconSprite } from "../icon-sprite.ts";
2
4
  import "blume:theme";
3
5
  import data from "blume:data";
4
6
  import type { BlumeFavicon } from "../../core/data.ts";
@@ -108,6 +110,10 @@ const strings = ui ?? EN_UI;
108
110
  const searchLocale = searchLocaleFor(data.config.i18n, locale);
109
111
 
110
112
  const bannerKey = banner?.dismissible ? banner.key : null;
113
+
114
+ // Start the page's icon sprite before anything renders an icon (see
115
+ // icon-sprite.ts); <IconSprite /> at the end of the body emits it.
116
+ createIconSprite(Astro.locals);
111
117
  ---
112
118
 
113
119
  <!doctype html>
@@ -152,5 +158,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
152
158
  <script is:inline set:html={SCALAR_THEME_INIT_SCRIPT} />
153
159
  </div>
154
160
  <WebMcp />
161
+ <IconSprite />
155
162
  </body>
156
163
  </html>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import IconSprite from "./IconSprite.astro";
3
+ import { createIconSprite } from "../icon-sprite.ts";
2
4
  import data from "blume:data";
3
5
  import { EN_UI } from "../../core/i18n-ui.ts";
4
6
  import type { UIStrings } from "../../core/i18n-ui.ts";
@@ -54,6 +56,7 @@ import {
54
56
  findBreadcrumbs,
55
57
  flattenPages,
56
58
  getPagination,
59
+ navGroupIds,
57
60
  sidebarForRoute,
58
61
  } from "./nav-utils.ts";
59
62
  import NavTree from "./NavTree.astro";
@@ -181,6 +184,12 @@ interface Props {
181
184
  } | null;
182
185
  /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
183
186
  searchVersion?: string | null;
187
+ /**
188
+ * URL prefix of this page's deferred sidebar fragments (`/blume-nav/
189
+ * <version>/<locale>`), when the sidebar defers collapsed sections; absent,
190
+ * the sidebar renders every section in full. See NavTree.
191
+ */
192
+ navFragmentBase?: string;
184
193
  /**
185
194
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
186
195
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
@@ -256,6 +265,7 @@ const {
256
265
  versionSelector,
257
266
  versionNotice,
258
267
  searchVersion = null,
268
+ navFragmentBase,
259
269
  layout = {},
260
270
  clientData,
261
271
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
@@ -396,6 +406,9 @@ const sidebar = sidebarForRoute(
396
406
  page.route,
397
407
  navigation.root
398
408
  );
409
+ // Stable group ids over the full tree, so the scoped view above names its
410
+ // panels and deferred fragments the same way every other page does.
411
+ const navIds = navGroupIds(navigation.sidebar);
399
412
  const activeTab = currentTabForRoute(
400
413
  navigation.tabs,
401
414
  page.route,
@@ -429,6 +442,10 @@ const structuredDataJson = structuredData
429
442
  : null;
430
443
 
431
444
  const bannerKey = banner?.dismissible ? banner.key : null;
445
+
446
+ // Start the page's icon sprite before anything renders an icon (see
447
+ // icon-sprite.ts); <IconSprite /> at the end of the body emits it.
448
+ createIconSprite(Astro.locals);
432
449
  ---
433
450
 
434
451
  <!doctype html>
@@ -680,6 +697,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
680
697
  <div class="lg:hidden">
681
698
  <MobileNavSlot
682
699
  currentRoute={page.route}
700
+ fragmentBase={navFragmentBase}
701
+ ids={navIds}
683
702
  items={sidebar}
684
703
  strings={navStrings}
685
704
  />
@@ -687,6 +706,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
687
706
  <div class="hidden lg:block">
688
707
  <SidebarSlot
689
708
  currentRoute={page.route}
709
+ fragmentBase={navFragmentBase}
710
+ ids={navIds}
690
711
  items={sidebar}
691
712
  strings={navStrings}
692
713
  />
@@ -695,6 +716,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
695
716
  ) : (
696
717
  <SidebarSlot
697
718
  currentRoute={page.route}
719
+ fragmentBase={navFragmentBase}
720
+ ids={navIds}
698
721
  items={sidebar}
699
722
  strings={navStrings}
700
723
  />
@@ -792,8 +815,10 @@ const bannerKey = banner?.dismissible ? banner.key : null;
792
815
  import { syncDrawerInert } from "./drawer-inert.ts";
793
816
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
794
817
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
795
- // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
796
- import "../content/mermaid-element.ts";
818
+ // fences) when some page has one; Mermaid itself is lazy-loaded only on
819
+ // pages that use a diagram. The loader is null — and the element and
820
+ // library absent from the bundle — for a site with no diagrams.
821
+ import { loadMermaid } from "blume:features";
797
822
  // Registers the <blume-toc> custom element: scrollspy for the table of
798
823
  // contents, highlighting the section currently in view as you scroll.
799
824
  import "./toc-element.ts";
@@ -801,6 +826,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
801
826
  // Tree-shaken out of production builds.
802
827
  import "./hydration-hint.ts";
803
828
 
829
+ loadMermaid?.();
830
+
804
831
  // Runs once per real page load; re-syncs itself after client-router
805
832
  // swaps (see drawer-inert.ts).
806
833
  syncDrawerInert();
@@ -1054,5 +1081,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
1054
1081
  }
1055
1082
  </style>
1056
1083
  <WebMcp />
1084
+ <IconSprite />
1057
1085
  </body>
1058
1086
  </html>
@@ -0,0 +1,49 @@
1
+ import type { NavNode } from "../../core/types.ts";
2
+ import type { IconSymbol } from "../icon-sprite.ts";
3
+
4
+ /**
5
+ * Build-time cache of rendered sidebar subtrees. A group that does not contain
6
+ * the current page renders identically on every page (no `aria-current`, no
7
+ * forced-open ancestor), so its HTML is rendered once and reused across the
8
+ * build — keyed on the group node's identity (a regenerated navigation is a
9
+ * new object, so a stale tree is never served) and the render variant (the
10
+ * panel id prefix and localized labels). The in-flight promise is what's
11
+ * stored, so concurrent page renders share one render of the same subtree.
12
+ */
13
+ /** A cached subtree: its HTML and the sprite symbols that HTML references. */
14
+ export interface CachedNavSubtree {
15
+ html: string;
16
+ /** Symbol id → symbol, so a later page can register them into its sprite. */
17
+ icons: [string, IconSymbol][];
18
+ }
19
+
20
+ const cache = new WeakMap<NavNode, Map<string, Promise<CachedNavSubtree>>>();
21
+
22
+ /**
23
+ * The cached subtree of `node` for `variant` — its HTML plus the icon sprite
24
+ * symbols it references, since the HTML is reused on pages that never
25
+ * rendered those icons themselves — rendering it with `render` on the first
26
+ * request. `enabled: false` always renders (the dev
27
+ * server, where an edited component must show its change on the next request).
28
+ */
29
+ export const cachedNavSubtree = (
30
+ node: NavNode,
31
+ variant: string,
32
+ render: () => Promise<CachedNavSubtree>,
33
+ enabled = true
34
+ ): Promise<CachedNavSubtree> => {
35
+ if (!enabled) {
36
+ return render();
37
+ }
38
+ let byVariant = cache.get(node);
39
+ if (!byVariant) {
40
+ byVariant = new Map();
41
+ cache.set(node, byVariant);
42
+ }
43
+ let html = byVariant.get(variant);
44
+ if (!html) {
45
+ html = render();
46
+ byVariant.set(variant, html);
47
+ }
48
+ return html;
49
+ };
@@ -1,5 +1,5 @@
1
1
  import { isRootTab, isUnderPath } from "../../core/navigation.ts";
2
- import type { NavNode, NavTab } from "../../core/types.ts";
2
+ import type { NavNode, NavTab, Navigation } from "../../core/types.ts";
3
3
 
4
4
  /** A flat, ordered page reference used for previous/next pagination. */
5
5
  export interface FlatPage {
@@ -238,3 +238,71 @@ export const getPagination = (flat: FlatPage[], route: string) => {
238
238
  prev: index > 0 ? (flat[index - 1] ?? null) : null,
239
239
  };
240
240
  };
241
+
242
+ /**
243
+ * A stable id for every group in a sidebar — `g<n>` by pre-order position in
244
+ * the full tree. The layout hands `NavTree` a scoped view of that tree (a
245
+ * tab's section, or the sidebar minus the tab sections), so positions within
246
+ * the rendered slice differ from page to page; these ids name the same group
247
+ * everywhere, which the drill-in panels and the deferred-section fragments
248
+ * (`/blume-nav/…`) rely on. Keyed by node identity: the scoped views reuse
249
+ * the full tree's node objects.
250
+ */
251
+ export const navGroupIds = (sidebar: NavNode[]): Map<NavNode, string> => {
252
+ const ids = new Map<NavNode, string>();
253
+ const walk = (nodes: NavNode[]): void => {
254
+ for (const node of nodes) {
255
+ if (node.kind === "group") {
256
+ ids.set(node, `g${ids.size}`);
257
+ walk(node.children);
258
+ }
259
+ }
260
+ };
261
+ walk(sidebar);
262
+ return ids;
263
+ };
264
+
265
+ /** Whether any group in a sidebar renders as a disclosure or a drill-in panel. */
266
+ export const hasDeferrableGroups = (sidebar: NavNode[]): boolean =>
267
+ sidebar.some(
268
+ (node) =>
269
+ node.kind === "group" &&
270
+ ((node.display ?? "flat") !== "flat" ||
271
+ hasDeferrableGroups(node.children))
272
+ );
273
+
274
+ /** One of the navigation trees a site renders, by URL segment. */
275
+ export interface NavVariant {
276
+ /** `current`, or an archived version id. */
277
+ version: string;
278
+ /** `default`, or a locale code. */
279
+ locale: string;
280
+ navigation: Navigation;
281
+ }
282
+
283
+ /**
284
+ * Every navigation tree the runtime data holds — the default, each locale's,
285
+ * and each archived version's per locale — keyed the way the deferred
286
+ * sidebar fragments' URLs are (`/blume-nav/<version>/<locale>/…`). An
287
+ * unlocalized version tree is keyed by `""` in the data; it maps to
288
+ * `default` here.
289
+ */
290
+ export const navVariants = (data: {
291
+ navigation: Navigation;
292
+ navigationByLocale: Record<string, Navigation>;
293
+ navigationByVersion: Record<string, Record<string, Navigation>>;
294
+ }): NavVariant[] => [
295
+ { locale: "default", navigation: data.navigation, version: "current" },
296
+ ...Object.entries(data.navigationByLocale).map(([locale, navigation]) => ({
297
+ locale,
298
+ navigation,
299
+ version: "current",
300
+ })),
301
+ ...Object.entries(data.navigationByVersion).flatMap(([version, byLocale]) =>
302
+ Object.entries(byLocale).map(([locale, navigation]) => ({
303
+ locale: locale || "default",
304
+ navigation,
305
+ version,
306
+ }))
307
+ ),
308
+ ];
@@ -49,9 +49,10 @@ import {
49
49
  siYaml,
50
50
  } from "simple-icons";
51
51
 
52
- /** The slice of a `simple-icons` icon Blume reads (the SVG path data). */
52
+ /** The slice of a `simple-icons` icon Blume reads: its slug and path data. */
53
53
  interface SimpleIcon {
54
54
  path: string;
55
+ slug: string;
55
56
  }
56
57
 
57
58
  /** Fence language (and common aliases) → icon. Unmapped languages get none. */
@@ -146,23 +147,6 @@ export interface LanguageIconTransformer {
146
147
  pre: (this: IconContext, node: IconPreNode) => void;
147
148
  }
148
149
 
149
- /** Build an inline SVG hast node from a simple-icons path. */
150
- const iconNode = (path: string): HastNode => ({
151
- children: [
152
- { children: [], properties: { d: path }, tagName: "path", type: "element" },
153
- ],
154
- properties: {
155
- ariaHidden: "true",
156
- className: ["blume-lang-icon"],
157
- fill: "currentColor",
158
- height: 14,
159
- viewBox: "0 0 24 24",
160
- width: 14,
161
- },
162
- tagName: "svg",
163
- type: "element",
164
- });
165
-
166
150
  /** Build the transformer. Runs after Shiki's built-in `data-language` hook. */
167
151
  export const languageIconTransformer = (): LanguageIconTransformer => ({
168
152
  name: "blume:language-icon",
@@ -171,7 +155,67 @@ export const languageIconTransformer = (): LanguageIconTransformer => ({
171
155
  if (!icon) {
172
156
  return;
173
157
  }
174
- node.children.unshift(iconNode(icon.path));
175
- node.properties.dataIcon = "";
158
+ // The icon itself is CSS: the theme paints `pre[data-icon="<slug>"]::after`
159
+ // with the brand path as a mask (see `languageIconCss`), so a block
160
+ // carries a short attribute instead of ~1 kB of SVG — on a reference page
161
+ // with twenty TypeScript blocks, the difference is most of the page.
162
+ node.properties.dataIcon = icon.slug;
176
163
  },
177
164
  });
165
+
166
+ /** The icon slug for a fence language, or null for an unmapped language. */
167
+ export const languageIconSlug = (language: string): string | null =>
168
+ LANGUAGE_ICONS[language.toLowerCase()]?.slug ?? null;
169
+
170
+ // Fence openers (```ts, ~~~tsx) and the `lang`/`language` props of code
171
+ // components (<CodeBlock lang="ts">), which highlight through the same
172
+ // transformer. Word characters plus the few punctuation marks languages use.
173
+ const FENCE_LANGUAGE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*(?<lang>[\w+#.-]+)/gmu;
174
+ const PROP_LANGUAGE = /\blang(?:uage)?=["'](?<lang>[\w+#.-]+)["']/gu;
175
+
176
+ /**
177
+ * The icon slugs a site's Markdown uses, sorted and deduped, so the theme
178
+ * carries a mask rule for each of them and none for the other thirty.
179
+ */
180
+ export const languageIconSlugsIn = (markdown: string): string[] => {
181
+ const slugs = new Set<string>();
182
+ for (const pattern of [FENCE_LANGUAGE, PROP_LANGUAGE]) {
183
+ for (const match of markdown.matchAll(pattern)) {
184
+ const slug = languageIconSlug(match.groups?.lang ?? "");
185
+ if (slug) {
186
+ slugs.add(slug);
187
+ }
188
+ }
189
+ }
190
+ return [...slugs].toSorted();
191
+ };
192
+
193
+ const iconBySlug = (slug: string): SimpleIcon | undefined =>
194
+ Object.values(LANGUAGE_ICONS).find((icon) => icon.slug === slug);
195
+
196
+ /** A simple-icons path as a `mask-image` data URI (24×24 viewBox). */
197
+ const maskUri = (path: string): string =>
198
+ `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${path}"/></svg>`)}")`;
199
+
200
+ /**
201
+ * The per-language rules that paint a code block's icon: each gives the
202
+ * block's `::after` (positioned by the theme) the brand path as a mask over
203
+ * the muted foreground. Only the listed slugs get a rule, so an unmapped or
204
+ * unused language paints nothing rather than a blank square.
205
+ */
206
+ export const languageIconCss = (slugs: string[]): string =>
207
+ slugs
208
+ .map((slug) => {
209
+ const icon = iconBySlug(slug);
210
+ if (!icon) {
211
+ return "";
212
+ }
213
+ const mask = maskUri(icon.path);
214
+ return `.prose > :where(pre[data-language][data-icon="${slug}"])::after {
215
+ background-color: var(--blume-muted-foreground);
216
+ -webkit-mask-image: ${mask};
217
+ mask-image: ${mask};
218
+ }`;
219
+ })
220
+ .filter((rule) => rule !== "")
221
+ .join("\n");