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.
Files changed (80) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli/index.js +340 -132
  3. package/dist/cli/index.js.map +21 -20
  4. package/dist/types/ai/ask-context.d.ts +78 -0
  5. package/dist/types/core/config-input.d.ts +53 -1
  6. package/dist/types/core/data.d.ts +16 -0
  7. package/dist/types/core/open-in-chat.d.ts +9 -0
  8. package/dist/types/core/schema.d.ts +48 -1
  9. package/dist/types/core/types.d.ts +10 -3
  10. package/dist/types/openapi/references.d.ts +9 -0
  11. package/dist/types/search/orama-index.d.ts +70 -0
  12. package/docs/advanced/api-reference.mdx +67 -5
  13. package/docs/configuration/ai.mdx +35 -0
  14. package/docs/configuration/index.mdx +13 -1
  15. package/docs/configuration/search.mdx +4 -4
  16. package/docs/reference/cli.mdx +1 -1
  17. package/package.json +1 -1
  18. package/skills/blume-migrate/SKILL.md +1 -1
  19. package/skills/blume-migrate/references/mintlify.md +1 -1
  20. package/src/ai/ask-context.ts +51 -11
  21. package/src/ai/mcp/data.ts +3 -2
  22. package/src/ai/mcp/server.ts +3 -2
  23. package/src/assets/icon-dark.png +0 -0
  24. package/src/astro/generate.ts +162 -13
  25. package/src/astro/templates.ts +63 -11
  26. package/src/components/content/AccordionItem.astro +4 -0
  27. package/src/components/content/Update.astro +3 -0
  28. package/src/components/islands/AskAI.astro +6 -0
  29. package/src/components/islands/ask-ai.tsx +39 -9
  30. package/src/components/layout/Analytics.astro +9 -1
  31. package/src/components/layout/Favicon.astro +29 -8
  32. package/src/components/layout/Header.astro +2 -2
  33. package/src/components/layout/NavSelector.astro +1 -1
  34. package/src/components/layout/PageActions.astro +120 -78
  35. package/src/components/layout/PageFeedback.astro +12 -3
  36. package/src/components/layout/PageLayout.astro +15 -3
  37. package/src/components/layout/ReferenceLayout.astro +10 -8
  38. package/src/components/layout/RootLayout.astro +155 -120
  39. package/src/components/layout/Search.astro +41 -26
  40. package/src/components/layout/drawer-inert.ts +10 -5
  41. package/src/components/layout/head-scripts.ts +57 -16
  42. package/src/components/layout/nav-utils.ts +34 -15
  43. package/src/components/layout/search/orama.ts +3 -2
  44. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  45. package/src/components/openapi/MessageComposer.astro +238 -0
  46. package/src/components/openapi/Operation.astro +26 -12
  47. package/src/components/openapi/PanelTabs.astro +7 -0
  48. package/src/components/openapi/Playground.astro +320 -0
  49. package/src/components/openapi/RequestPanel.astro +1 -0
  50. package/src/components/openapi/async-snippets.ts +20 -7
  51. package/src/components/openapi/async.ts +13 -2
  52. package/src/components/openapi/message-composer.ts +242 -0
  53. package/src/components/openapi/message-model.ts +108 -0
  54. package/src/components/openapi/message.ts +153 -0
  55. package/src/components/openapi/operation-model.ts +260 -0
  56. package/src/components/openapi/playground-client.ts +486 -0
  57. package/src/components/openapi/playground-schema.ts +109 -0
  58. package/src/components/openapi/request.ts +287 -0
  59. package/src/components/openapi/security.ts +0 -56
  60. package/src/components/openapi/snippets.ts +23 -136
  61. package/src/components/openapi/validate-json.ts +144 -0
  62. package/src/components/openapi/ws-client.ts +194 -0
  63. package/src/core/config-input.ts +66 -0
  64. package/src/core/content-assets.ts +66 -15
  65. package/src/core/data.ts +13 -0
  66. package/src/core/last-modified.ts +28 -3
  67. package/src/core/links.ts +30 -4
  68. package/src/core/navigation.ts +26 -1
  69. package/src/core/open-in-chat.ts +17 -0
  70. package/src/core/schema.ts +59 -0
  71. package/src/core/server-features.ts +11 -0
  72. package/src/core/sources/normalize.ts +10 -2
  73. package/src/core/types.ts +10 -3
  74. package/src/openapi/model.ts +7 -0
  75. package/src/openapi/proxy.ts +217 -0
  76. package/src/openapi/references.ts +8 -0
  77. package/src/openapi/source.ts +13 -0
  78. package/src/registry/eject.ts +4 -5
  79. package/src/search/orama-index.ts +109 -36
  80. 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 { BlumeClientData } from "../../core/data.ts";
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
- activeTabForRoute,
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
- href: string;
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 = activeTabForRoute(navigation.tabs, page.route);
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
- for (const pre of document.querySelectorAll(".prose pre")) {
788
- if (pre.querySelector("[data-blume-copy]")) {
789
- continue;
790
- }
791
- // Skip nested <pre>: Twoslash renders each hover popup's type signature
792
- // as a <pre> inside the code block, which shouldn't get its own button.
793
- if (pre.parentElement?.closest("pre")) {
794
- continue;
795
- }
796
- const language = pre.getAttribute("data-language");
797
- if (language) {
798
- pre.setAttribute(
799
- "data-language",
800
- languageLabels[language.toLowerCase()] ?? language
801
- );
802
- }
803
- pre.classList.add("group", "relative");
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
- const flash = createCopyFlash(setChecked, copiedLabel);
846
- button.addEventListener("click", async () => {
847
- const code = pre.querySelector("code");
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
- if (await copyText(text)) {
862
- flash();
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
- pre.appendChild(button);
866
- }
867
-
868
- // Click-to-zoom for content images (gated by `markdown.imageZoom`),
869
- // via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
870
- // and the open/close transition races are its problem, not ours.
871
- // Opt out per-image with `data-no-zoom`.
872
- if (document.body.hasAttribute("data-blume-image-zoom")) {
873
- const zoomTargets = Array.from(
874
- document.querySelectorAll<HTMLImageElement>(
875
- ".prose img:not([data-no-zoom])"
876
- )
877
- // An image that is itself a link navigates on click — binding zoom
878
- // to it would flash a zoom overlay in the instant before navigation
879
- // and advertise (via the cursor) a zoom that never happens.
880
- ).filter((image) => !image.closest("a"));
881
- if (zoomTargets.length > 0) {
882
- // Lazy: pages without a zoomable image never load the library,
883
- // matching how mermaid is only fetched on pages with a diagram.
884
- const { default: mediumZoom } = await import("medium-zoom");
885
- mediumZoom(zoomTargets, {
886
- background:
887
- "color-mix(in oklab, var(--color-background) 80%, transparent)",
888
- margin: 24,
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", (event) => {
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
- window.location.href = item.url;
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' inline scripts.
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&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
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
- * Center the current page's sidebar link before the sidebar paints. Every
34
- * navigation is a full page load, and the sidebar is its own scroll container,
35
- * so without this it is reborn scrolled to the top on every click — on a long
36
- * sidebar the viewport visibly jumps away from the link you just clicked.
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
- * Runs inline immediately after the sidebar `<aside>` (not in `<head>`: it
39
- * needs that markup parsed). The lookup is scoped to the page tree
40
- * (`data-blume-nav-tree`) because the drawer also holds the mobile tabs list,
41
- * whose active tab is `aria-current` too. `getClientRects()` skips links that
42
- * aren't rendered — `hidden` drill-in panels and breakpoint-hidden duplicates —
43
- * and the script no-ops when the active link is already inside the visible
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 n=document.querySelector("[data-blume-nav-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;})();`;
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();})();`;