blume 1.6.5 → 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 (179) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0qhq7b8q.js +111 -0
  4. package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
  5. package/dist/cli/chunk-18tjv4f7.js +96 -0
  6. package/dist/cli/chunk-18tjv4f7.js.map +10 -0
  7. package/dist/cli/chunk-27gtm2ym.js +69 -0
  8. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  9. package/dist/cli/chunk-2aj8ddew.js +72 -0
  10. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5d4q7121.js +4064 -0
  18. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  19. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  20. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  21. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  22. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  23. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  24. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  25. package/dist/cli/chunk-9qs6acpw.js +176 -0
  26. package/dist/cli/chunk-9qs6acpw.js.map +10 -0
  27. package/dist/cli/chunk-agy5rzxy.js +2453 -0
  28. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  29. package/dist/cli/chunk-bcy492zc.js +16 -0
  30. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  31. package/dist/cli/chunk-btfr9yvw.js +41 -0
  32. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  33. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  34. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  35. package/dist/cli/chunk-cfw6x4rm.js +1967 -0
  36. package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
  37. package/dist/cli/chunk-ckh3a410.js +277 -0
  38. package/dist/cli/chunk-ckh3a410.js.map +11 -0
  39. package/dist/cli/chunk-drke6t0h.js +259 -0
  40. package/dist/cli/chunk-drke6t0h.js.map +11 -0
  41. package/dist/cli/chunk-ev67ycx0.js +15 -0
  42. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  43. package/dist/cli/chunk-ey89bjj1.js +209 -0
  44. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  45. package/dist/cli/chunk-j6pxe0dt.js +69 -0
  46. package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
  47. package/dist/cli/chunk-jk1zwka1.js +387 -0
  48. package/dist/cli/chunk-jk1zwka1.js.map +12 -0
  49. package/dist/cli/chunk-jtb45atp.js +467 -0
  50. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  51. package/dist/cli/chunk-jxkxjsc1.js +76 -0
  52. package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
  53. package/dist/cli/chunk-kwx90v78.js +81 -0
  54. package/dist/cli/chunk-kwx90v78.js.map +10 -0
  55. package/dist/cli/chunk-n0nyat6g.js +30 -0
  56. package/dist/cli/chunk-n0nyat6g.js.map +10 -0
  57. package/dist/cli/chunk-pxj10x8y.js +35 -0
  58. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  59. package/dist/cli/chunk-qq9nm3qd.js +1141 -0
  60. package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
  61. package/dist/cli/chunk-s102bysw.js +5170 -0
  62. package/dist/cli/chunk-s102bysw.js.map +47 -0
  63. package/dist/cli/chunk-s5dsk8bj.js +769 -0
  64. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  65. package/dist/cli/chunk-s5e5jt53.js +227 -0
  66. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  67. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  68. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  69. package/dist/cli/chunk-tnskyrej.js +117 -0
  70. package/dist/cli/chunk-tnskyrej.js.map +10 -0
  71. package/dist/cli/chunk-v2ymm99c.js +1016 -0
  72. package/dist/cli/chunk-v2ymm99c.js.map +13 -0
  73. package/dist/cli/chunk-v5mm027v.js +185 -0
  74. package/dist/cli/chunk-v5mm027v.js.map +11 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  78. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  79. package/dist/cli/chunk-wd27zjcz.js +60 -0
  80. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  81. package/dist/cli/chunk-x66c5yjn.js +23 -0
  82. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  83. package/dist/cli/chunk-xv91q4nm.js +5314 -0
  84. package/dist/cli/chunk-xv91q4nm.js.map +58 -0
  85. package/dist/cli/chunk-y3g15rvv.js +679 -0
  86. package/dist/cli/chunk-y3g15rvv.js.map +15 -0
  87. package/dist/cli/chunk-ye9zdkgv.js +136 -0
  88. package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
  89. package/dist/cli/chunk-ynacq3ev.js +1062 -0
  90. package/dist/cli/chunk-ynacq3ev.js.map +25 -0
  91. package/dist/cli/chunk-zr3ygrq3.js +54 -0
  92. package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
  93. package/dist/cli/index.js +55 -27597
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  97. package/dist/types/core/code-fences.d.ts +11 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +70 -0
  100. package/dist/types/theme/fonts.d.ts +22 -22
  101. package/docs/02-deployment.mdx +22 -1
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/navigation.mdx +2 -0
  105. package/docs/content/syntax.mdx +1 -1
  106. package/docs/discoverability/open-graph.mdx +4 -0
  107. package/docs/reference/cli.mdx +1 -1
  108. package/package.json +4 -2
  109. package/src/ai/api/handlers.ts +4 -7
  110. package/src/ai/api/paths.ts +8 -0
  111. package/src/ai/api/spec.ts +2 -1
  112. package/src/ai/ask-context.ts +378 -22
  113. package/src/astro/generate.ts +161 -28
  114. package/src/astro/include-hmr.ts +10 -13
  115. package/src/astro/include-refresh.ts +0 -0
  116. package/src/astro/index.ts +6 -1
  117. package/src/astro/integration.ts +280 -53
  118. package/src/astro/module-types.ts +83 -0
  119. package/src/astro/templates.ts +256 -108
  120. package/src/audit/image-size.ts +10 -8
  121. package/src/cli/command-meta.ts +77 -0
  122. package/src/cli/commands/add.ts +2 -4
  123. package/src/cli/commands/audit.ts +2 -4
  124. package/src/cli/commands/build.ts +70 -346
  125. package/src/cli/commands/check.ts +2 -4
  126. package/src/cli/commands/dev.ts +31 -42
  127. package/src/cli/commands/doctor.ts +2 -4
  128. package/src/cli/commands/eject.ts +3 -41
  129. package/src/cli/commands/eval.ts +2 -5
  130. package/src/cli/commands/init.ts +2 -4
  131. package/src/cli/commands/mcp-stdio.ts +2 -5
  132. package/src/cli/commands/preview.ts +3 -5
  133. package/src/cli/commands/sync.ts +2 -4
  134. package/src/cli/commands/translate.ts +2 -5
  135. package/src/cli/commands/validate.ts +2 -4
  136. package/src/cli/commands/version.ts +2 -4
  137. package/src/cli/eject-scripts.ts +0 -45
  138. package/src/cli/host-args.ts +16 -0
  139. package/src/cli/index.ts +84 -35
  140. package/src/cli/lazy-command.ts +47 -0
  141. package/src/components/Icon.astro +24 -0
  142. package/src/components/content/GithubInfo.astro +4 -1
  143. package/src/components/icon-sprite-middleware.ts +41 -0
  144. package/src/components/icon-sprite.ts +93 -0
  145. package/src/components/layout/IconSprite.astro +11 -0
  146. package/src/components/layout/NavTree.astro +156 -188
  147. package/src/components/layout/NavTreeCache.astro +45 -0
  148. package/src/components/layout/NavTreeScript.astro +256 -0
  149. package/src/components/layout/PageActions.astro +11 -5
  150. package/src/components/layout/PageLayout.astro +21 -3
  151. package/src/components/layout/ReferenceLayout.astro +21 -4
  152. package/src/components/layout/RootLayout.astro +44 -6
  153. package/src/components/layout/nav-cache.ts +49 -0
  154. package/src/components/layout/nav-utils.ts +69 -1
  155. package/src/components/layout/page-locale.ts +29 -0
  156. package/src/core/api-name.ts +18 -0
  157. package/src/core/code-fences.ts +48 -0
  158. package/src/core/content-assets.ts +3 -7
  159. package/src/core/includes.ts +3 -7
  160. package/src/core/package-root.ts +1 -1
  161. package/src/core/schema.ts +19 -0
  162. package/src/core/sources/normalize.ts +2 -37
  163. package/src/core/sources/obsidian.ts +3 -2
  164. package/src/core/svg-dimensions.ts +97 -0
  165. package/src/core/version-cut.ts +2 -2
  166. package/src/deploy/artifacts.ts +370 -0
  167. package/src/deploy/cloudflare-negotiation.ts +97 -32
  168. package/src/deploy/function-bundle.ts +66 -20
  169. package/src/deploy/sitemap.ts +6 -0
  170. package/src/deploy/vercel-negotiation.ts +8 -30
  171. package/src/markdown/language-icon.ts +64 -20
  172. package/src/markdown/mermaid.ts +11 -0
  173. package/src/og/cache.ts +236 -0
  174. package/src/og/card.ts +18 -16
  175. package/src/og/index.ts +8 -1
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +23 -10
  178. package/src/theme/entry.ts +41 -7
  179. package/src/theme/fonts.ts +30 -23
@@ -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
@@ -35,6 +37,7 @@ import {
35
37
  import { buildStructuredData } from "../../seo/jsonld.ts";
36
38
  import { normalizeXHandle } from "../../seo/x-handle.ts";
37
39
  import { withBase } from "../islands/base-path.ts";
40
+ import { pageDirection, pageLocale } from "./page-locale.ts";
38
41
  import { searchLocaleFor } from "./search-locale.ts";
39
42
  import "blume:theme";
40
43
  import { ClientRouter } from "astro:transitions";
@@ -108,7 +111,11 @@ interface Props {
108
111
  */
109
112
  x?: { creator?: string; handle?: string };
110
113
  noindex?: boolean;
111
- /** Active locale + direction for `<html lang>`/`<html dir>`. */
114
+ /**
115
+ * Active locale + direction for `<html lang>`/`<html dir>`. Default to the
116
+ * locale Astro resolved from the URL (`Astro.currentLocale`), then the site
117
+ * default, and to that locale's configured direction.
118
+ */
112
119
  locale?: string;
113
120
  dir?: "ltr" | "rtl";
114
121
  /** Resolved UI dictionary; English baseline when omitted. */
@@ -145,13 +152,19 @@ const {
145
152
  structuredDataEnabled,
146
153
  x,
147
154
  noindex,
148
- locale = "en",
149
- dir = "ltr",
155
+ locale: localeProp,
156
+ dir: dirProp,
150
157
  ui,
151
158
  localeSwitch,
152
159
  clientData,
153
160
  } = Astro.props;
154
161
 
162
+ // The locale and direction: the page's own values when it passes them (the
163
+ // content catch-all always does), else what Astro's i18n routing resolved for
164
+ // this URL, else the site default (see `page-locale.ts`).
165
+ const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
166
+ const dir = dirProp ?? pageDirection(data.config.i18n, locale);
167
+
155
168
  const clientDataJson = clientData
156
169
  ? JSON.stringify(clientData).replaceAll("<", "\\u003c")
157
170
  : null;
@@ -232,6 +245,10 @@ const structuredDataJson = structuredData
232
245
  : null;
233
246
 
234
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);
235
252
  ---
236
253
 
237
254
  <!doctype html>
@@ -401,5 +418,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
401
418
 
402
419
  syncDrawerInert();
403
420
  </script>
421
+ <IconSprite />
404
422
  </body>
405
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";
@@ -17,6 +19,7 @@ import {
17
19
  THEME_INIT_SCRIPT,
18
20
  } from "./head-scripts.ts";
19
21
  import Header from "./Header.astro";
22
+ import { pageDirection, pageLocale } from "./page-locale.ts";
20
23
  import { searchLocaleFor } from "./search-locale.ts";
21
24
 
22
25
  // A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
@@ -66,9 +69,12 @@ interface Props {
66
69
  pageTitle: string;
67
70
  /** Keep the reference route out of crawler indexes. */
68
71
  noindex?: boolean;
69
- /** Active locale code for `<html lang>` (defaults to `en`). */
72
+ /**
73
+ * Active locale code for `<html lang>`. Defaults to the locale Astro
74
+ * resolved from the URL (`Astro.currentLocale`), then the site default.
75
+ */
70
76
  locale?: string;
71
- /** Text direction for `<html dir>` (defaults to `ltr`). */
77
+ /** Text direction for `<html dir>`; defaults to the locale's configured direction. */
72
78
  dir?: "ltr" | "rtl";
73
79
  /** Resolved UI dictionary; English baseline when omitted. */
74
80
  ui?: UIStrings;
@@ -88,16 +94,26 @@ const {
88
94
  searchEnabled,
89
95
  pageTitle,
90
96
  noindex = false,
91
- locale = "en",
92
- dir = "ltr",
97
+ locale: localeProp,
98
+ dir: dirProp,
93
99
  ui,
94
100
  } = Astro.props;
95
101
 
102
+ // The locale and direction: the page's own values when it passes them (the
103
+ // content catch-all always does), else what Astro's i18n routing resolved for
104
+ // this URL, else the site default (see `page-locale.ts`).
105
+ const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
106
+ const dir = dirProp ?? pageDirection(data.config.i18n, locale);
107
+
96
108
  const strings = ui ?? EN_UI;
97
109
  // Scope search to the reference page's language on a multi-locale site.
98
110
  const searchLocale = searchLocaleFor(data.config.i18n, locale);
99
111
 
100
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);
101
117
  ---
102
118
 
103
119
  <!doctype html>
@@ -142,5 +158,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
142
158
  <script is:inline set:html={SCALAR_THEME_INIT_SCRIPT} />
143
159
  </div>
144
160
  <WebMcp />
161
+ <IconSprite />
145
162
  </body>
146
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,10 +56,12 @@ 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";
60
63
  import { resolveSlot } from "./overrides.ts";
64
+ import { pageDirection, pageLocale } from "./page-locale.ts";
61
65
  import { searchLocaleFor } from "./search-locale.ts";
62
66
  import PageActions from "./PageActions.astro";
63
67
  import PageFeedback from "./PageFeedback.astro";
@@ -149,9 +153,12 @@ interface Props {
149
153
  lastModified?: string | null;
150
154
  noindex?: boolean;
151
155
  structuredDataEnabled?: boolean;
152
- /** Active locale code for `<html lang>` (defaults to `en`). */
156
+ /**
157
+ * Active locale code for `<html lang>`. Defaults to the locale Astro
158
+ * resolved from the URL (`Astro.currentLocale`), then the site default.
159
+ */
153
160
  locale?: string;
154
- /** Text direction for `<html dir>` (defaults to `ltr`). */
161
+ /** Text direction for `<html dir>`; defaults to the locale's configured direction. */
155
162
  dir?: "ltr" | "rtl";
156
163
  /**
157
164
  * Direction of the page content's own language — differs from `dir` on a
@@ -177,6 +184,12 @@ interface Props {
177
184
  } | null;
178
185
  /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
179
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;
180
193
  /**
181
194
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
182
195
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
@@ -242,8 +255,8 @@ const {
242
255
  lastModified,
243
256
  noindex,
244
257
  structuredDataEnabled,
245
- locale = "en",
246
- dir = "ltr",
258
+ locale: localeProp,
259
+ dir: dirProp,
247
260
  contentDir,
248
261
  ui,
249
262
  localeAlternates,
@@ -252,6 +265,7 @@ const {
252
265
  versionSelector,
253
266
  versionNotice,
254
267
  searchVersion = null,
268
+ navFragmentBase,
255
269
  layout = {},
256
270
  clientData,
257
271
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
@@ -259,6 +273,12 @@ const {
259
273
  contentLayout = "default",
260
274
  } = Astro.props;
261
275
 
276
+ // The locale and direction: the page's own values when it passes them (the
277
+ // content catch-all always does), else what Astro's i18n routing resolved for
278
+ // this URL, else the site default (see `page-locale.ts`).
279
+ const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
280
+ const dir = dirProp ?? pageDirection(data.config.i18n, locale);
281
+
262
282
  // Serialized once for island hooks; `<` escaped so content can't break the tag.
263
283
  const clientDataJson = clientData
264
284
  ? JSON.stringify(clientData).replaceAll("<", "\\u003c")
@@ -386,6 +406,9 @@ const sidebar = sidebarForRoute(
386
406
  page.route,
387
407
  navigation.root
388
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);
389
412
  const activeTab = currentTabForRoute(
390
413
  navigation.tabs,
391
414
  page.route,
@@ -419,6 +442,10 @@ const structuredDataJson = structuredData
419
442
  : null;
420
443
 
421
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);
422
449
  ---
423
450
 
424
451
  <!doctype html>
@@ -670,6 +697,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
670
697
  <div class="lg:hidden">
671
698
  <MobileNavSlot
672
699
  currentRoute={page.route}
700
+ fragmentBase={navFragmentBase}
701
+ ids={navIds}
673
702
  items={sidebar}
674
703
  strings={navStrings}
675
704
  />
@@ -677,6 +706,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
677
706
  <div class="hidden lg:block">
678
707
  <SidebarSlot
679
708
  currentRoute={page.route}
709
+ fragmentBase={navFragmentBase}
710
+ ids={navIds}
680
711
  items={sidebar}
681
712
  strings={navStrings}
682
713
  />
@@ -685,6 +716,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
685
716
  ) : (
686
717
  <SidebarSlot
687
718
  currentRoute={page.route}
719
+ fragmentBase={navFragmentBase}
720
+ ids={navIds}
688
721
  items={sidebar}
689
722
  strings={navStrings}
690
723
  />
@@ -782,8 +815,10 @@ const bannerKey = banner?.dismissible ? banner.key : null;
782
815
  import { syncDrawerInert } from "./drawer-inert.ts";
783
816
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
784
817
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
785
- // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
786
- 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";
787
822
  // Registers the <blume-toc> custom element: scrollspy for the table of
788
823
  // contents, highlighting the section currently in view as you scroll.
789
824
  import "./toc-element.ts";
@@ -791,6 +826,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
791
826
  // Tree-shaken out of production builds.
792
827
  import "./hydration-hint.ts";
793
828
 
829
+ loadMermaid?.();
830
+
794
831
  // Runs once per real page load; re-syncs itself after client-router
795
832
  // swaps (see drawer-inert.ts).
796
833
  syncDrawerInert();
@@ -1044,5 +1081,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
1044
1081
  }
1045
1082
  </style>
1046
1083
  <WebMcp />
1084
+ <IconSprite />
1047
1085
  </body>
1048
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
+ };