blume 1.5.1 → 1.5.3
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 +23 -0
- package/dist/cli/index.js +340 -132
- package/dist/cli/index.js.map +21 -20
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +53 -1
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +13 -1
- package/docs/configuration/search.mdx +4 -4
- package/docs/reference/cli.mdx +1 -1
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +162 -13
- package/src/astro/templates.ts +63 -11
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +15 -3
- package/src/components/layout/ReferenceLayout.astro +10 -8
- package/src/components/layout/RootLayout.astro +155 -120
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +57 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +66 -0
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +13 -0
- package/src/core/last-modified.ts +28 -3
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/schema.ts +59 -0
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +6 -15
|
@@ -3,7 +3,8 @@ import { EN_UI } from "../../core/i18n-ui.ts";
|
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
4
|
import { resolveDateFormatOptions } from "../../core/date-format.ts";
|
|
5
5
|
import type { ResolvedDateFormat } from "../../core/schema.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { OpenInChatProvider } from "../../core/open-in-chat.ts";
|
|
7
|
+
import type { BlumeClientData, BlumeFavicon } from "../../core/data.ts";
|
|
7
8
|
import type {
|
|
8
9
|
Heading,
|
|
9
10
|
LocaleSwitchOption,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
NavSelector as NavSelectorType,
|
|
12
13
|
} from "../../core/types.ts";
|
|
13
14
|
import "blume:theme";
|
|
15
|
+
import { ClientRouter } from "astro:transitions";
|
|
14
16
|
import type { FontHead } from "../../theme/fonts.ts";
|
|
15
17
|
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
16
18
|
import {
|
|
@@ -31,12 +33,13 @@ import Fonts from "./Fonts.astro";
|
|
|
31
33
|
import {
|
|
32
34
|
BANNER_INIT_SCRIPT,
|
|
33
35
|
SIDEBAR_SCROLL_INIT_SCRIPT,
|
|
36
|
+
SWAP_STYLESHEET_INIT_SCRIPT,
|
|
34
37
|
THEME_INIT_SCRIPT,
|
|
35
38
|
} from "./head-scripts.ts";
|
|
36
39
|
import Header from "./Header.astro";
|
|
37
40
|
import Icon from "../Icon.astro";
|
|
38
41
|
import {
|
|
39
|
-
|
|
42
|
+
currentTabForRoute,
|
|
40
43
|
findBreadcrumbs,
|
|
41
44
|
flattenPages,
|
|
42
45
|
getPagination,
|
|
@@ -60,14 +63,8 @@ interface Props {
|
|
|
60
63
|
href: string;
|
|
61
64
|
text?: string;
|
|
62
65
|
} | null;
|
|
63
|
-
favicon?:
|
|
64
|
-
|
|
65
|
-
type?: string;
|
|
66
|
-
} | null;
|
|
67
|
-
appleIcon?: {
|
|
68
|
-
href: string;
|
|
69
|
-
type?: string;
|
|
70
|
-
} | null;
|
|
66
|
+
favicon?: BlumeFavicon | null;
|
|
67
|
+
appleIcon?: BlumeFavicon | null;
|
|
71
68
|
banner?: {
|
|
72
69
|
content: string;
|
|
73
70
|
link?: { text: string; href: string };
|
|
@@ -119,6 +116,11 @@ interface Props {
|
|
|
119
116
|
feedback?: boolean;
|
|
120
117
|
exportPdf?: boolean;
|
|
121
118
|
exportEpub?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* "Open in chat" providers to list, in order; an empty list hides the
|
|
121
|
+
* action, and omitting the prop shows every provider.
|
|
122
|
+
*/
|
|
123
|
+
openInChat?: readonly OpenInChatProvider[];
|
|
122
124
|
feeds?: { title: string; href: string }[];
|
|
123
125
|
/**
|
|
124
126
|
* Which agent-discovery resources exist, advertised as `describedby` head
|
|
@@ -218,6 +220,7 @@ const {
|
|
|
218
220
|
feedback = false,
|
|
219
221
|
exportPdf,
|
|
220
222
|
exportEpub,
|
|
223
|
+
openInChat,
|
|
221
224
|
feeds,
|
|
222
225
|
discovery,
|
|
223
226
|
siteUrl,
|
|
@@ -356,14 +359,21 @@ const mcpUrl =
|
|
|
356
359
|
// pages. Without tabs — or on a route under none — this is the full sidebar.
|
|
357
360
|
// `navigation.root` keeps the root-tab check in the tabs' localized/based
|
|
358
361
|
// path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
|
|
359
|
-
// root tab as a section tab
|
|
362
|
+
// root tab as a section tab; in an archived version tree the root is
|
|
363
|
+
// versionized (`/v1.0`) while tabs stay in current-docs space, so the root
|
|
364
|
+
// tab is matched by containment and the snapshot renders unscoped — the same
|
|
365
|
+
// reason no tab is marked current there (tabs link back to current docs).
|
|
360
366
|
const sidebar = sidebarForRoute(
|
|
361
367
|
navigation.sidebar,
|
|
362
368
|
navigation.tabs,
|
|
363
369
|
page.route,
|
|
364
370
|
navigation.root
|
|
365
371
|
);
|
|
366
|
-
const activeTab =
|
|
372
|
+
const activeTab = currentTabForRoute(
|
|
373
|
+
navigation.tabs,
|
|
374
|
+
page.route,
|
|
375
|
+
navigation.root
|
|
376
|
+
);
|
|
367
377
|
const crumbs = findBreadcrumbs(sidebar, page.route);
|
|
368
378
|
const { prev, next } = getPagination(flattenPages(sidebar), page.route);
|
|
369
379
|
|
|
@@ -398,6 +408,16 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
398
408
|
<head>
|
|
399
409
|
<meta charset="utf-8" />
|
|
400
410
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
411
|
+
{/* Client-side navigation: same-origin link clicks swap the DOM in place
|
|
412
|
+
instead of tearing the document down, so no browser ever paints a blank
|
|
413
|
+
frame between pages (Firefox has no cross-document paint holding and
|
|
414
|
+
flickered on every full load). Uses native view transitions where
|
|
415
|
+
supported, with Astro's simulated fade elsewhere; pairs with the
|
|
416
|
+
prefetch option in the generated Astro config. */}
|
|
417
|
+
<ClientRouter />
|
|
418
|
+
{/* Loads a next page's body-hoisted CSS before the router swaps it in —
|
|
419
|
+
see SWAP_STYLESHEET_INIT_SCRIPT for the unstyled-frame failure mode. */}
|
|
420
|
+
<script is:inline set:html={SWAP_STYLESHEET_INIT_SCRIPT} />
|
|
401
421
|
<title>{pageTitle}</title>
|
|
402
422
|
<Favicon favicon={favicon} appleIcon={appleIcon} />
|
|
403
423
|
<Fonts cssVars={fontCssVars ?? []} />
|
|
@@ -706,6 +726,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
706
726
|
exportPdf={exportPdf}
|
|
707
727
|
mcpName={mcp?.name}
|
|
708
728
|
mcpUrl={mcpUrl}
|
|
729
|
+
openInChat={openInChat}
|
|
709
730
|
route={page.route}
|
|
710
731
|
strings={strings.actions}
|
|
711
732
|
/>
|
|
@@ -744,6 +765,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
744
765
|
// Tree-shaken out of production builds.
|
|
745
766
|
import "./hydration-hint.ts";
|
|
746
767
|
|
|
768
|
+
// Runs once per real page load; re-syncs itself after client-router
|
|
769
|
+
// swaps (see drawer-inert.ts).
|
|
747
770
|
syncDrawerInert();
|
|
748
771
|
|
|
749
772
|
const svg = (name: string, cls = "") =>
|
|
@@ -755,14 +778,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
755
778
|
"absolute right-3 z-[2] inline-flex size-[1.875rem] select-none items-center justify-center rounded-md text-muted-foreground bg-background transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0";
|
|
756
779
|
const idleClasses = ["hover:bg-muted", "hover:text-foreground"];
|
|
757
780
|
|
|
758
|
-
// Localized copy-button labels, stamped on <body> by the layout markup
|
|
759
|
-
// (the Search.astro data-attribute channel) since this bundled script
|
|
760
|
-
// can't interpolate server values directly.
|
|
761
|
-
const copyCodeLabel =
|
|
762
|
-
document.body.getAttribute("data-i18n-copy-code") || "Copy code";
|
|
763
|
-
const copiedLabel =
|
|
764
|
-
document.body.getAttribute("data-i18n-copied") || "Copied!";
|
|
765
|
-
|
|
766
781
|
const languageLabels: Record<string, string> = {
|
|
767
782
|
astro: "Astro",
|
|
768
783
|
bash: "Bash",
|
|
@@ -784,111 +799,131 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
784
799
|
zsh: "Zsh",
|
|
785
800
|
};
|
|
786
801
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
// The code element is the scroll container (see the theme entry), but
|
|
805
|
-
// Shiki's tab stop lands on the pre, which no longer scrolls. Move the
|
|
806
|
-
// stop to the code so keyboard users can actually scroll the block
|
|
807
|
-
// (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
|
|
808
|
-
// API-panel blocks keep the pre as their scroller, so theirs stays.
|
|
809
|
-
const scroller = pre.querySelector("code");
|
|
810
|
-
if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
|
|
811
|
-
scroller.setAttribute("tabindex", "0");
|
|
812
|
-
pre.removeAttribute("tabindex");
|
|
813
|
-
}
|
|
814
|
-
const button = document.createElement("button");
|
|
815
|
-
button.type = "button";
|
|
816
|
-
// The language-label bar (prose) vs flush code (tabs) need a different
|
|
817
|
-
// offset; pick the Tailwind class by context instead of a CSS override.
|
|
818
|
-
const topClass = pre.closest("blume-tabs, .not-prose")
|
|
819
|
-
? "top-2.5"
|
|
820
|
-
: "top-2";
|
|
821
|
-
button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
|
|
822
|
-
button.setAttribute("data-blume-copy", "");
|
|
823
|
-
button.setAttribute("aria-label", copyCodeLabel);
|
|
824
|
-
button.innerHTML =
|
|
825
|
-
svg(
|
|
826
|
-
"check",
|
|
827
|
-
"scale-0 text-green-600 transition-transform dark:text-green-500"
|
|
828
|
-
) + svg("copy", "absolute transition-transform");
|
|
829
|
-
const [checkIcon, copyIcon] = button.querySelectorAll("svg");
|
|
830
|
-
// While checked the button drops its idle hover tint so the green
|
|
831
|
-
// check reads as a steady confirmation, mirroring Lina's copied state.
|
|
832
|
-
// The accessible name tracks the visual state for anyone probing the
|
|
833
|
-
// button mid-confirmation.
|
|
834
|
-
const setChecked = (checked: boolean) => {
|
|
835
|
-
checkIcon?.classList.toggle("scale-0", !checked);
|
|
836
|
-
copyIcon?.classList.toggle("scale-0", checked);
|
|
837
|
-
button.setAttribute(
|
|
838
|
-
"aria-label",
|
|
839
|
-
checked ? copiedLabel : copyCodeLabel
|
|
840
|
-
);
|
|
841
|
-
for (const cls of idleClasses) {
|
|
842
|
-
button.classList.toggle(cls, !checked);
|
|
802
|
+
// Per-page setup, run on the initial load and again after every
|
|
803
|
+
// client-router swap (which replaces the body with server-rendered
|
|
804
|
+
// markup that has none of this applied). Everything in here binds to
|
|
805
|
+
// elements the swap just created, so nothing double-attaches.
|
|
806
|
+
const initPage = async () => {
|
|
807
|
+
// Localized copy-button labels, stamped on <body> by the layout markup
|
|
808
|
+
// (the Search.astro data-attribute channel) since this bundled script
|
|
809
|
+
// can't interpolate server values directly. Read per page so a
|
|
810
|
+
// cross-locale navigation picks up the new language's labels.
|
|
811
|
+
const copyCodeLabel =
|
|
812
|
+
document.body.getAttribute("data-i18n-copy-code") || "Copy code";
|
|
813
|
+
const copiedLabel =
|
|
814
|
+
document.body.getAttribute("data-i18n-copied") || "Copied!";
|
|
815
|
+
|
|
816
|
+
for (const pre of document.querySelectorAll(".prose pre")) {
|
|
817
|
+
if (pre.querySelector("[data-blume-copy]")) {
|
|
818
|
+
continue;
|
|
843
819
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
let text = code?.textContent ?? "";
|
|
849
|
-
// Twoslash nests each hover popup's type signature and docs inside
|
|
850
|
-
// the <code>; copying textContent verbatim would interleave them
|
|
851
|
-
// with the source. Strip the popups from a clone first.
|
|
852
|
-
if (code?.querySelector(".twoslash-popup-container")) {
|
|
853
|
-
const clone = code.cloneNode(true) as HTMLElement;
|
|
854
|
-
for (const popup of clone.querySelectorAll(
|
|
855
|
-
".twoslash-popup-container"
|
|
856
|
-
)) {
|
|
857
|
-
popup.remove();
|
|
858
|
-
}
|
|
859
|
-
text = clone.textContent ?? "";
|
|
820
|
+
// Skip nested <pre>: Twoslash renders each hover popup's type signature
|
|
821
|
+
// as a <pre> inside the code block, which shouldn't get its own button.
|
|
822
|
+
if (pre.parentElement?.closest("pre")) {
|
|
823
|
+
continue;
|
|
860
824
|
}
|
|
861
|
-
|
|
862
|
-
|
|
825
|
+
const language = pre.getAttribute("data-language");
|
|
826
|
+
if (language) {
|
|
827
|
+
pre.setAttribute(
|
|
828
|
+
"data-language",
|
|
829
|
+
languageLabels[language.toLowerCase()] ?? language
|
|
830
|
+
);
|
|
863
831
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
"
|
|
888
|
-
|
|
832
|
+
pre.classList.add("group", "relative");
|
|
833
|
+
// The code element is the scroll container (see the theme entry), but
|
|
834
|
+
// Shiki's tab stop lands on the pre, which no longer scrolls. Move the
|
|
835
|
+
// stop to the code so keyboard users can actually scroll the block
|
|
836
|
+
// (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
|
|
837
|
+
// API-panel blocks keep the pre as their scroller, so theirs stays.
|
|
838
|
+
const scroller = pre.querySelector("code");
|
|
839
|
+
if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
|
|
840
|
+
scroller.setAttribute("tabindex", "0");
|
|
841
|
+
pre.removeAttribute("tabindex");
|
|
842
|
+
}
|
|
843
|
+
const button = document.createElement("button");
|
|
844
|
+
button.type = "button";
|
|
845
|
+
// The language-label bar (prose) vs flush code (tabs) need a different
|
|
846
|
+
// offset; pick the Tailwind class by context instead of a CSS override.
|
|
847
|
+
const topClass = pre.closest("blume-tabs, .not-prose")
|
|
848
|
+
? "top-2.5"
|
|
849
|
+
: "top-2";
|
|
850
|
+
button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
|
|
851
|
+
button.setAttribute("data-blume-copy", "");
|
|
852
|
+
button.setAttribute("aria-label", copyCodeLabel);
|
|
853
|
+
button.innerHTML =
|
|
854
|
+
svg(
|
|
855
|
+
"check",
|
|
856
|
+
"scale-0 text-green-600 transition-transform dark:text-green-500"
|
|
857
|
+
) + svg("copy", "absolute transition-transform");
|
|
858
|
+
const [checkIcon, copyIcon] = button.querySelectorAll("svg");
|
|
859
|
+
// While checked the button drops its idle hover tint so the green
|
|
860
|
+
// check reads as a steady confirmation, mirroring Lina's copied state.
|
|
861
|
+
// The accessible name tracks the visual state for anyone probing the
|
|
862
|
+
// button mid-confirmation.
|
|
863
|
+
const setChecked = (checked: boolean) => {
|
|
864
|
+
checkIcon?.classList.toggle("scale-0", !checked);
|
|
865
|
+
copyIcon?.classList.toggle("scale-0", checked);
|
|
866
|
+
button.setAttribute(
|
|
867
|
+
"aria-label",
|
|
868
|
+
checked ? copiedLabel : copyCodeLabel
|
|
869
|
+
);
|
|
870
|
+
for (const cls of idleClasses) {
|
|
871
|
+
button.classList.toggle(cls, !checked);
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
const flash = createCopyFlash(setChecked, copiedLabel);
|
|
875
|
+
button.addEventListener("click", async () => {
|
|
876
|
+
const code = pre.querySelector("code");
|
|
877
|
+
let text = code?.textContent ?? "";
|
|
878
|
+
// Twoslash nests each hover popup's type signature and docs inside
|
|
879
|
+
// the <code>; copying textContent verbatim would interleave them
|
|
880
|
+
// with the source. Strip the popups from a clone first.
|
|
881
|
+
if (code?.querySelector(".twoslash-popup-container")) {
|
|
882
|
+
const clone = code.cloneNode(true) as HTMLElement;
|
|
883
|
+
for (const popup of clone.querySelectorAll(
|
|
884
|
+
".twoslash-popup-container"
|
|
885
|
+
)) {
|
|
886
|
+
popup.remove();
|
|
887
|
+
}
|
|
888
|
+
text = clone.textContent ?? "";
|
|
889
|
+
}
|
|
890
|
+
if (await copyText(text)) {
|
|
891
|
+
flash();
|
|
892
|
+
}
|
|
889
893
|
});
|
|
894
|
+
pre.appendChild(button);
|
|
890
895
|
}
|
|
891
|
-
|
|
896
|
+
|
|
897
|
+
// Click-to-zoom for content images (gated by `markdown.imageZoom`),
|
|
898
|
+
// via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
|
|
899
|
+
// and the open/close transition races are its problem, not ours.
|
|
900
|
+
// Opt out per-image with `data-no-zoom`.
|
|
901
|
+
if (document.body.hasAttribute("data-blume-image-zoom")) {
|
|
902
|
+
const zoomTargets = Array.from(
|
|
903
|
+
document.querySelectorAll<HTMLImageElement>(
|
|
904
|
+
".prose img:not([data-no-zoom])"
|
|
905
|
+
)
|
|
906
|
+
// An image that is itself a link navigates on click — binding zoom
|
|
907
|
+
// to it would flash a zoom overlay in the instant before navigation
|
|
908
|
+
// and advertise (via the cursor) a zoom that never happens.
|
|
909
|
+
).filter((image) => !image.closest("a"));
|
|
910
|
+
if (zoomTargets.length > 0) {
|
|
911
|
+
// Lazy: pages without a zoomable image never load the library,
|
|
912
|
+
// matching how mermaid is only fetched on pages with a diagram.
|
|
913
|
+
const { default: mediumZoom } = await import("medium-zoom");
|
|
914
|
+
mediumZoom(zoomTargets, {
|
|
915
|
+
background:
|
|
916
|
+
"color-mix(in oklab, var(--color-background) 80%, transparent)",
|
|
917
|
+
margin: 24,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
void initPage();
|
|
924
|
+
document.addEventListener("astro:after-swap", () => {
|
|
925
|
+
void initPage();
|
|
926
|
+
});
|
|
892
927
|
</script>
|
|
893
928
|
<style is:global>
|
|
894
929
|
/* medium-zoom ships no z-index; lift the lightbox above the chrome
|
|
@@ -203,6 +203,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
203
203
|
/>
|
|
204
204
|
|
|
205
205
|
<script>
|
|
206
|
+
import { navigate } from "astro:transitions/client";
|
|
206
207
|
import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
|
|
207
208
|
import { prefixBase } from "../islands/base-path.ts";
|
|
208
209
|
import { escape as escapeHtml } from "html-escaper";
|
|
@@ -301,6 +302,36 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
301
302
|
version = "";
|
|
302
303
|
allVersions = false;
|
|
303
304
|
|
|
305
|
+
// Document-level, so it outlives this element unless removed: each
|
|
306
|
+
// client-router swap rebuilds the header (and this element with it), and
|
|
307
|
+
// an orphaned copy would keep toggling a dialog that is no longer in the
|
|
308
|
+
// document. Held as a field so disconnectedCallback can detach it.
|
|
309
|
+
#onDocumentKeydown = (event: KeyboardEvent) => {
|
|
310
|
+
if (
|
|
311
|
+
(event.key === "k" || event.key === "K") &&
|
|
312
|
+
(event.metaKey || event.ctrlKey) &&
|
|
313
|
+
// Ctrl+Shift+K is Firefox's web console; a shifted or alted chord
|
|
314
|
+
// belongs to the browser, not the search dialog.
|
|
315
|
+
!event.shiftKey &&
|
|
316
|
+
!event.altKey
|
|
317
|
+
) {
|
|
318
|
+
// ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
|
|
319
|
+
// the dialog open must close it, not re-showModal an open dialog
|
|
320
|
+
// (an InvalidStateError on older engines).
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
if (this.dialog.open) {
|
|
323
|
+
this.dialog.close();
|
|
324
|
+
} else {
|
|
325
|
+
this.open();
|
|
326
|
+
}
|
|
327
|
+
} else if (event.key === "/" && !this.isField(event.target)) {
|
|
328
|
+
// "/" stays open-only; the field guard keeps it inert while
|
|
329
|
+
// typing (including in the search input itself).
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
this.open();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
304
335
|
connectedCallback() {
|
|
305
336
|
this.devOnlyMsg =
|
|
306
337
|
this.getAttribute("data-i18n-dev") || this.devOnlyMsg;
|
|
@@ -397,31 +428,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
397
428
|
() => this.open()
|
|
398
429
|
);
|
|
399
430
|
|
|
400
|
-
document.addEventListener("keydown",
|
|
401
|
-
if (
|
|
402
|
-
(event.key === "k" || event.key === "K") &&
|
|
403
|
-
(event.metaKey || event.ctrlKey) &&
|
|
404
|
-
// Ctrl+Shift+K is Firefox's web console; a shifted or alted chord
|
|
405
|
-
// belongs to the browser, not the search dialog.
|
|
406
|
-
!event.shiftKey &&
|
|
407
|
-
!event.altKey
|
|
408
|
-
) {
|
|
409
|
-
// ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
|
|
410
|
-
// the dialog open must close it, not re-showModal an open dialog
|
|
411
|
-
// (an InvalidStateError on older engines).
|
|
412
|
-
event.preventDefault();
|
|
413
|
-
if (this.dialog.open) {
|
|
414
|
-
this.dialog.close();
|
|
415
|
-
} else {
|
|
416
|
-
this.open();
|
|
417
|
-
}
|
|
418
|
-
} else if (event.key === "/" && !this.isField(event.target)) {
|
|
419
|
-
// "/" stays open-only; the field guard keeps it inert while
|
|
420
|
-
// typing (including in the search input itself).
|
|
421
|
-
event.preventDefault();
|
|
422
|
-
this.open();
|
|
423
|
-
}
|
|
424
|
-
});
|
|
431
|
+
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
425
432
|
|
|
426
433
|
this.input.addEventListener("input", () => this.render());
|
|
427
434
|
this.dialog.addEventListener("keydown", (event) =>
|
|
@@ -434,6 +441,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
434
441
|
});
|
|
435
442
|
}
|
|
436
443
|
|
|
444
|
+
disconnectedCallback() {
|
|
445
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
446
|
+
}
|
|
447
|
+
|
|
437
448
|
isField(target: EventTarget | null): boolean {
|
|
438
449
|
const el = target as HTMLElement | null;
|
|
439
450
|
return Boolean(
|
|
@@ -799,7 +810,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
799
810
|
new CustomEvent("blume:open-ask-ai", { detail: { query } })
|
|
800
811
|
);
|
|
801
812
|
} else if (item.url) {
|
|
802
|
-
|
|
813
|
+
// Close first so the dialog isn't left open over the transition;
|
|
814
|
+
// navigate() rides the client router (and falls back to a normal
|
|
815
|
+
// full load on pages without it).
|
|
816
|
+
this.dialog.close();
|
|
817
|
+
navigate(item.url);
|
|
803
818
|
}
|
|
804
819
|
}
|
|
805
820
|
|
|
@@ -4,15 +4,19 @@
|
|
|
4
4
|
* stay focusable on every page. Mirrors the header's `data-blume-nav-open`
|
|
5
5
|
* toggle into `inert`/`aria-hidden` — but only below `lg` (64rem), where the
|
|
6
6
|
* same element isn't the static sidebar (RootLayout) or is display-hidden
|
|
7
|
-
* anyway (PageLayout). Shared by both layouts'
|
|
7
|
+
* anyway (PageLayout). Shared by both layouts' bundled scripts, which run once
|
|
8
|
+
* per real page load; the drawer element is rebuilt by every client-router
|
|
9
|
+
* swap, so `sync` re-queries it each time and re-runs on `astro:after-swap`.
|
|
8
10
|
*/
|
|
9
11
|
export const syncDrawerInert = (): void => {
|
|
10
|
-
const drawer = document.querySelector<HTMLElement>("[data-blume-nav-drawer]");
|
|
11
|
-
if (!drawer) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
12
|
const desktop = window.matchMedia("(min-width: 64rem)");
|
|
15
13
|
const sync = () => {
|
|
14
|
+
const drawer = document.querySelector<HTMLElement>(
|
|
15
|
+
"[data-blume-nav-drawer]"
|
|
16
|
+
);
|
|
17
|
+
if (!drawer) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
16
20
|
const hidden =
|
|
17
21
|
!desktop.matches &&
|
|
18
22
|
!Object.hasOwn(document.documentElement.dataset, "blumeNavOpen");
|
|
@@ -28,4 +32,5 @@ export const syncDrawerInert = (): void => {
|
|
|
28
32
|
new MutationObserver(sync).observe(document.documentElement, {
|
|
29
33
|
attributeFilter: ["data-blume-nav-open"],
|
|
30
34
|
});
|
|
35
|
+
document.addEventListener("astro:after-swap", sync);
|
|
31
36
|
};
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* scrolled away from the current page. Kept in one place so the layouts can't
|
|
7
7
|
* drift on this timing-critical logic.
|
|
8
8
|
*
|
|
9
|
+
* Under the client router (`<ClientRouter />`), each script's element reappears
|
|
10
|
+
* on every navigated-to page but is executed only once per real page load —
|
|
11
|
+
* Astro skips scripts whose content it has already run. Anything that must hold
|
|
12
|
+
* per navigation therefore also registers an `astro:after-swap` listener on the
|
|
13
|
+
* first (and only) execution: the swap replaces the `<html>` attributes and the
|
|
14
|
+
* body wholesale, wiping `data-theme`/`data-blume-banner-hidden` and rebuilding
|
|
15
|
+
* the sidebar, and `after-swap` fires before the new page paints — the same
|
|
16
|
+
* no-flash timing the initial inline run has.
|
|
17
|
+
*
|
|
9
18
|
* All are constants, never built by interpolating config into source text: any
|
|
10
19
|
* values they need ride in as `data-*` attributes on the script tag and are read
|
|
11
20
|
* back through `document.currentScript`. Baking a config string into JS — even
|
|
@@ -16,31 +25,63 @@
|
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Set `data-theme` from the stored preference (or the configured default, or the
|
|
19
|
-
* OS setting for `"system"`) before the body paints, avoiding a theme flash
|
|
28
|
+
* OS setting for `"system"`) before the body paints, avoiding a theme flash —
|
|
29
|
+
* and again after every client-router swap, which resets `<html>` attributes to
|
|
30
|
+
* the incoming page's server-rendered (theme-less) set.
|
|
20
31
|
*
|
|
21
32
|
* Reads `data-mode` — `"system" | "light" | "dark"`.
|
|
22
33
|
*/
|
|
23
|
-
export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.mode??"system";const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
|
|
34
|
+
export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.mode??"system";const apply=()=>{const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);};apply();document.addEventListener("astro:after-swap",apply);})();`;
|
|
24
35
|
|
|
25
36
|
/**
|
|
26
|
-
* Hide a previously-dismissed banner before it can flash in
|
|
37
|
+
* Hide a previously-dismissed banner before it can flash in — and again after
|
|
38
|
+
* every client-router swap, which wipes the `<html>` marker attribute.
|
|
27
39
|
*
|
|
28
40
|
* Reads `data-key` — the banner's dismissal key.
|
|
29
41
|
*/
|
|
30
|
-
export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k
|
|
42
|
+
export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(!k){return;}const apply=()=>{if(localStorage.getItem("blume-banner:"+k)){document.documentElement.setAttribute("data-blume-banner-hidden","");}};apply();document.addEventListener("astro:after-swap",apply);})();`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Keep the page styled across client-router swaps. Astro hoists the CSS of a
|
|
46
|
+
* component rendered after the head has streamed (the page's MDX content, the
|
|
47
|
+
* WebMcp island) into the **body** as `<link rel="stylesheet">` tags — and the
|
|
48
|
+
* client router only preloads and persists stylesheets it finds in the head.
|
|
49
|
+
* A swapped-in body `<link>` applies asynchronously, so every navigation to a
|
|
50
|
+
* page with body CSS painted one or two completely unstyled frames (giant raw
|
|
51
|
+
* SVG logo, default link colors) before the sheet kicked in — even when the
|
|
52
|
+
* same sheet was already loaded on the outgoing page, because the swap throws
|
|
53
|
+
* the old body (and its link element) away.
|
|
54
|
+
*
|
|
55
|
+
* Two listeners close the gap. `astro:before-preparation` wraps the router's
|
|
56
|
+
* loader: after the next document is fetched, any of its body stylesheets not
|
|
57
|
+
* already in the live head are appended there and awaited, so their rules
|
|
58
|
+
* apply before the swap. `astro:before-swap` then moves the incoming
|
|
59
|
+
* document's body stylesheets into its head, where the router's head diff
|
|
60
|
+
* keeps the already-loaded copy (matched by `href`) instead of re-inserting a
|
|
61
|
+
* fresh, not-yet-applied link — and drops it again on a later navigation to a
|
|
62
|
+
* page that doesn't use it. A sheet that fails to load resolves rather than
|
|
63
|
+
* wedging the navigation; the page renders as it would have without this.
|
|
64
|
+
*/
|
|
65
|
+
export const SWAP_STYLESHEET_INIT_SCRIPT = `(()=>{const sel='body link[rel="stylesheet"]';document.addEventListener("astro:before-preparation",(e)=>{const load=e.loader;e.loader=async()=>{await load();const links=[...e.newDocument.querySelectorAll(sel)].filter((l)=>!document.head.querySelector('link[rel="stylesheet"][href="'+l.getAttribute("href")+'"]'));await Promise.all(links.map((l)=>new Promise((done)=>{const c=document.createElement("link");for(const a of l.attributes){c.setAttribute(a.name,a.value);}c.onload=done;c.onerror=done;document.head.append(c);})));};});document.addEventListener("astro:before-swap",(e)=>{for(const l of e.newDocument.querySelectorAll(sel)){e.newDocument.head.append(l);}});})();`;
|
|
31
66
|
|
|
32
67
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
68
|
+
* Keep the sidebar's scroll useful across page changes. The sidebar is its own
|
|
69
|
+
* scroll container, reborn scrolled to the top whenever its markup is rebuilt —
|
|
70
|
+
* on a long sidebar the viewport would visibly jump away from the link you just
|
|
71
|
+
* clicked.
|
|
72
|
+
*
|
|
73
|
+
* On the initial load it centers the current page's link before the sidebar
|
|
74
|
+
* paints (it runs inline immediately after the sidebar `<aside>`, not in
|
|
75
|
+
* `<head>`: it needs that markup parsed). On client-router navigations it first
|
|
76
|
+
* restores the exact scroll position saved at `astro:before-swap` — so clicking
|
|
77
|
+
* through nearby links doesn't move the sidebar at all — and only re-centers
|
|
78
|
+
* when the new page's link sits outside the visible scroll area.
|
|
37
79
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* (`
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* scroll area, so a short sidebar never moves.
|
|
80
|
+
* The lookup is scoped to the page tree (`data-blume-nav-tree`) because the
|
|
81
|
+
* drawer also holds the mobile tabs list, whose active tab is `aria-current`
|
|
82
|
+
* too. `getClientRects()` skips links that aren't rendered — `hidden` drill-in
|
|
83
|
+
* panels and breakpoint-hidden duplicates — and centering no-ops when the
|
|
84
|
+
* active link is already inside the visible scroll area, so a short sidebar
|
|
85
|
+
* never moves.
|
|
45
86
|
*/
|
|
46
|
-
export const SIDEBAR_SCROLL_INIT_SCRIPT = `(()=>{const
|
|
87
|
+
export const SIDEBAR_SCROLL_INIT_SCRIPT = `(()=>{const drawer=()=>document.querySelector("[data-blume-nav-drawer]");const center=()=>{const n=drawer();const s=n&&(n.querySelector("[data-blume-nav-tree]")||n);if(!s)return;let l=null;for(const a of s.querySelectorAll('a[aria-current="page"]')){if(a.getClientRects().length){l=a;break;}}if(!l)return;const r=n.getBoundingClientRect();const t=l.getBoundingClientRect();if(t.top>=r.top&&t.bottom<=r.bottom)return;n.scrollTop+=t.top-r.top-(n.clientHeight-t.height)/2;};let saved=-1;document.addEventListener("astro:before-swap",()=>{const n=drawer();saved=n?n.scrollTop:-1;});document.addEventListener("astro:after-swap",()=>{const n=drawer();if(n&&saved>=0){n.scrollTop=saved;}center();});center();})();`;
|