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.
- package/CHANGELOG.md +17 -0
- package/dist/cli/{chunk-nyqzjdhj.js → chunk-0qhq7b8q.js} +5 -5
- package/dist/cli/{chunk-cnvm6k3e.js → chunk-18tjv4f7.js} +11 -11
- package/dist/cli/{chunk-62qsssnh.js → chunk-5d4q7121.js} +401 -145
- package/dist/cli/chunk-5d4q7121.js.map +40 -0
- package/dist/cli/{chunk-ag1zyr5x.js → chunk-9qs6acpw.js} +11 -11
- package/dist/cli/{chunk-aerwpe14.js → chunk-agy5rzxy.js} +98 -15
- package/dist/cli/chunk-agy5rzxy.js.map +15 -0
- package/dist/cli/{chunk-x1vrdjyk.js → chunk-cfw6x4rm.js} +5 -5
- package/dist/cli/{chunk-bawgnt8x.js → chunk-ckh3a410.js} +3 -3
- package/dist/cli/{chunk-j00ezcg5.js → chunk-drke6t0h.js} +9 -9
- package/dist/cli/{chunk-3k0kzs6d.js → chunk-j6pxe0dt.js} +2 -2
- package/dist/cli/{chunk-n0y172hf.js → chunk-jk1zwka1.js} +4 -4
- package/dist/cli/{chunk-f75cqye8.js → chunk-jxkxjsc1.js} +10 -10
- package/dist/cli/{chunk-s4k1pnvf.js → chunk-kwx90v78.js} +11 -11
- package/dist/cli/{chunk-9sh49q0h.js → chunk-n0nyat6g.js} +2 -2
- package/dist/cli/{chunk-wkq5tbtq.js → chunk-qq9nm3qd.js} +3 -3
- package/dist/cli/{chunk-etsqspj6.js → chunk-s102bysw.js} +2 -2
- package/dist/cli/{chunk-wb067mv3.js → chunk-s5dsk8bj.js} +18 -7
- package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
- package/dist/cli/{chunk-m3p3wahd.js → chunk-tnskyrej.js} +4 -4
- package/dist/cli/{chunk-vv237fp3.js → chunk-v2ymm99c.js} +26 -12
- package/dist/cli/{chunk-vv237fp3.js.map → chunk-v2ymm99c.js.map} +3 -3
- package/dist/cli/{chunk-5yvt556e.js → chunk-v5mm027v.js} +2 -2
- package/dist/cli/{chunk-vv3f8mb6.js → chunk-xv91q4nm.js} +24 -24
- package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-xv91q4nm.js.map} +3 -3
- package/dist/cli/{chunk-0ewz4trd.js → chunk-y3g15rvv.js} +6 -6
- package/dist/cli/{chunk-tc89yh2r.js → chunk-ye9zdkgv.js} +2 -2
- package/dist/cli/{chunk-n4qjabmt.js → chunk-ynacq3ev.js} +4 -4
- package/dist/cli/{chunk-s4jn7f1q.js → chunk-zr3ygrq3.js} +2 -2
- package/dist/cli/index.js +13 -13
- package/dist/types/components/layout/nav-utils.d.ts +33 -1
- package/dist/types/theme/fonts.d.ts +22 -22
- package/docs/02-deployment.mdx +21 -0
- package/docs/content/navigation.mdx +2 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/discoverability/open-graph.mdx +4 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +141 -5
- package/src/astro/integration.ts +12 -1
- package/src/astro/module-types.ts +9 -0
- package/src/astro/templates.ts +171 -11
- package/src/cli/commands/build.ts +28 -0
- package/src/components/Icon.astro +24 -0
- package/src/components/icon-sprite-middleware.ts +41 -0
- package/src/components/icon-sprite.ts +93 -0
- package/src/components/layout/IconSprite.astro +11 -0
- package/src/components/layout/NavTree.astro +156 -188
- package/src/components/layout/NavTreeCache.astro +45 -0
- package/src/components/layout/NavTreeScript.astro +256 -0
- package/src/components/layout/PageActions.astro +11 -5
- package/src/components/layout/PageLayout.astro +7 -0
- package/src/components/layout/ReferenceLayout.astro +7 -0
- package/src/components/layout/RootLayout.astro +30 -2
- package/src/components/layout/nav-cache.ts +49 -0
- package/src/components/layout/nav-utils.ts +69 -1
- package/src/markdown/language-icon.ts +64 -20
- package/src/markdown/mermaid.ts +11 -0
- package/src/og/cache.ts +236 -0
- package/src/og/card.ts +12 -4
- package/src/og/index.ts +8 -1
- package/src/registry/eject.ts +23 -8
- package/src/theme/entry.ts +41 -7
- package/src/theme/fonts.ts +30 -23
- package/dist/cli/chunk-62qsssnh.js.map +0 -36
- package/dist/cli/chunk-aerwpe14.js.map +0 -15
- package/dist/cli/chunk-wb067mv3.js.map +0 -13
- /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-0qhq7b8q.js.map} +0 -0
- /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-18tjv4f7.js.map} +0 -0
- /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-9qs6acpw.js.map} +0 -0
- /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-cfw6x4rm.js.map} +0 -0
- /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-ckh3a410.js.map} +0 -0
- /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-drke6t0h.js.map} +0 -0
- /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-j6pxe0dt.js.map} +0 -0
- /package/dist/cli/{chunk-n0y172hf.js.map → chunk-jk1zwka1.js.map} +0 -0
- /package/dist/cli/{chunk-f75cqye8.js.map → chunk-jxkxjsc1.js.map} +0 -0
- /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-kwx90v78.js.map} +0 -0
- /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-n0nyat6g.js.map} +0 -0
- /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-qq9nm3qd.js.map} +0 -0
- /package/dist/cli/{chunk-etsqspj6.js.map → chunk-s102bysw.js.map} +0 -0
- /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-tnskyrej.js.map} +0 -0
- /package/dist/cli/{chunk-5yvt556e.js.map → chunk-v5mm027v.js.map} +0 -0
- /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-y3g15rvv.js.map} +0 -0
- /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-ye9zdkgv.js.map} +0 -0
- /package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ynacq3ev.js.map} +0 -0
- /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.
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
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)
|
|
796
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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");
|