blume 1.5.1 → 1.5.2

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 +17 -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 +7 -2
  37. package/src/components/layout/ReferenceLayout.astro +10 -8
  38. package/src/components/layout/RootLayout.astro +151 -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 +34 -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 {
@@ -36,7 +38,7 @@ import {
36
38
  import Header from "./Header.astro";
37
39
  import Icon from "../Icon.astro";
38
40
  import {
39
- activeTabForRoute,
41
+ currentTabForRoute,
40
42
  findBreadcrumbs,
41
43
  flattenPages,
42
44
  getPagination,
@@ -60,14 +62,8 @@ interface Props {
60
62
  href: string;
61
63
  text?: string;
62
64
  } | null;
63
- favicon?: {
64
- href: string;
65
- type?: string;
66
- } | null;
67
- appleIcon?: {
68
- href: string;
69
- type?: string;
70
- } | null;
65
+ favicon?: BlumeFavicon | null;
66
+ appleIcon?: BlumeFavicon | null;
71
67
  banner?: {
72
68
  content: string;
73
69
  link?: { text: string; href: string };
@@ -119,6 +115,11 @@ interface Props {
119
115
  feedback?: boolean;
120
116
  exportPdf?: boolean;
121
117
  exportEpub?: boolean;
118
+ /**
119
+ * "Open in chat" providers to list, in order; an empty list hides the
120
+ * action, and omitting the prop shows every provider.
121
+ */
122
+ openInChat?: readonly OpenInChatProvider[];
122
123
  feeds?: { title: string; href: string }[];
123
124
  /**
124
125
  * Which agent-discovery resources exist, advertised as `describedby` head
@@ -218,6 +219,7 @@ const {
218
219
  feedback = false,
219
220
  exportPdf,
220
221
  exportEpub,
222
+ openInChat,
221
223
  feeds,
222
224
  discovery,
223
225
  siteUrl,
@@ -356,14 +358,21 @@ const mcpUrl =
356
358
  // pages. Without tabs — or on a route under none — this is the full sidebar.
357
359
  // `navigation.root` keeps the root-tab check in the tabs' localized/based
358
360
  // path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
359
- // root tab as a section tab.
361
+ // root tab as a section tab; in an archived version tree the root is
362
+ // versionized (`/v1.0`) while tabs stay in current-docs space, so the root
363
+ // tab is matched by containment and the snapshot renders unscoped — the same
364
+ // reason no tab is marked current there (tabs link back to current docs).
360
365
  const sidebar = sidebarForRoute(
361
366
  navigation.sidebar,
362
367
  navigation.tabs,
363
368
  page.route,
364
369
  navigation.root
365
370
  );
366
- const activeTab = activeTabForRoute(navigation.tabs, page.route);
371
+ const activeTab = currentTabForRoute(
372
+ navigation.tabs,
373
+ page.route,
374
+ navigation.root
375
+ );
367
376
  const crumbs = findBreadcrumbs(sidebar, page.route);
368
377
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
369
378
 
@@ -398,6 +407,13 @@ const bannerKey = banner?.dismissible ? banner.key : null;
398
407
  <head>
399
408
  <meta charset="utf-8" />
400
409
  <meta name="viewport" content="width=device-width, initial-scale=1" />
410
+ {/* Client-side navigation: same-origin link clicks swap the DOM in place
411
+ instead of tearing the document down, so no browser ever paints a blank
412
+ frame between pages (Firefox has no cross-document paint holding and
413
+ flickered on every full load). Uses native view transitions where
414
+ supported, with Astro's simulated fade elsewhere; pairs with the
415
+ prefetch option in the generated Astro config. */}
416
+ <ClientRouter />
401
417
  <title>{pageTitle}</title>
402
418
  <Favicon favicon={favicon} appleIcon={appleIcon} />
403
419
  <Fonts cssVars={fontCssVars ?? []} />
@@ -706,6 +722,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
706
722
  exportPdf={exportPdf}
707
723
  mcpName={mcp?.name}
708
724
  mcpUrl={mcpUrl}
725
+ openInChat={openInChat}
709
726
  route={page.route}
710
727
  strings={strings.actions}
711
728
  />
@@ -744,6 +761,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
744
761
  // Tree-shaken out of production builds.
745
762
  import "./hydration-hint.ts";
746
763
 
764
+ // Runs once per real page load; re-syncs itself after client-router
765
+ // swaps (see drawer-inert.ts).
747
766
  syncDrawerInert();
748
767
 
749
768
  const svg = (name: string, cls = "") =>
@@ -755,14 +774,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
755
774
  "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
775
  const idleClasses = ["hover:bg-muted", "hover:text-foreground"];
757
776
 
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
777
  const languageLabels: Record<string, string> = {
767
778
  astro: "Astro",
768
779
  bash: "Bash",
@@ -784,111 +795,131 @@ const bannerKey = banner?.dismissible ? banner.key : null;
784
795
  zsh: "Zsh",
785
796
  };
786
797
 
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);
798
+ // Per-page setup, run on the initial load and again after every
799
+ // client-router swap (which replaces the body with server-rendered
800
+ // markup that has none of this applied). Everything in here binds to
801
+ // elements the swap just created, so nothing double-attaches.
802
+ const initPage = async () => {
803
+ // Localized copy-button labels, stamped on <body> by the layout markup
804
+ // (the Search.astro data-attribute channel) since this bundled script
805
+ // can't interpolate server values directly. Read per page so a
806
+ // cross-locale navigation picks up the new language's labels.
807
+ const copyCodeLabel =
808
+ document.body.getAttribute("data-i18n-copy-code") || "Copy code";
809
+ const copiedLabel =
810
+ document.body.getAttribute("data-i18n-copied") || "Copied!";
811
+
812
+ for (const pre of document.querySelectorAll(".prose pre")) {
813
+ if (pre.querySelector("[data-blume-copy]")) {
814
+ continue;
843
815
  }
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 ?? "";
816
+ // Skip nested <pre>: Twoslash renders each hover popup's type signature
817
+ // as a <pre> inside the code block, which shouldn't get its own button.
818
+ if (pre.parentElement?.closest("pre")) {
819
+ continue;
860
820
  }
861
- if (await copyText(text)) {
862
- flash();
821
+ const language = pre.getAttribute("data-language");
822
+ if (language) {
823
+ pre.setAttribute(
824
+ "data-language",
825
+ languageLabels[language.toLowerCase()] ?? language
826
+ );
863
827
  }
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,
828
+ pre.classList.add("group", "relative");
829
+ // The code element is the scroll container (see the theme entry), but
830
+ // Shiki's tab stop lands on the pre, which no longer scrolls. Move the
831
+ // stop to the code so keyboard users can actually scroll the block
832
+ // (WCAG 2.1.1 the same rule the table wrapper handles). Twoslash and
833
+ // API-panel blocks keep the pre as their scroller, so theirs stays.
834
+ const scroller = pre.querySelector("code");
835
+ if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
836
+ scroller.setAttribute("tabindex", "0");
837
+ pre.removeAttribute("tabindex");
838
+ }
839
+ const button = document.createElement("button");
840
+ button.type = "button";
841
+ // The language-label bar (prose) vs flush code (tabs) need a different
842
+ // offset; pick the Tailwind class by context instead of a CSS override.
843
+ const topClass = pre.closest("blume-tabs, .not-prose")
844
+ ? "top-2.5"
845
+ : "top-2";
846
+ button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
847
+ button.setAttribute("data-blume-copy", "");
848
+ button.setAttribute("aria-label", copyCodeLabel);
849
+ button.innerHTML =
850
+ svg(
851
+ "check",
852
+ "scale-0 text-green-600 transition-transform dark:text-green-500"
853
+ ) + svg("copy", "absolute transition-transform");
854
+ const [checkIcon, copyIcon] = button.querySelectorAll("svg");
855
+ // While checked the button drops its idle hover tint so the green
856
+ // check reads as a steady confirmation, mirroring Lina's copied state.
857
+ // The accessible name tracks the visual state for anyone probing the
858
+ // button mid-confirmation.
859
+ const setChecked = (checked: boolean) => {
860
+ checkIcon?.classList.toggle("scale-0", !checked);
861
+ copyIcon?.classList.toggle("scale-0", checked);
862
+ button.setAttribute(
863
+ "aria-label",
864
+ checked ? copiedLabel : copyCodeLabel
865
+ );
866
+ for (const cls of idleClasses) {
867
+ button.classList.toggle(cls, !checked);
868
+ }
869
+ };
870
+ const flash = createCopyFlash(setChecked, copiedLabel);
871
+ button.addEventListener("click", async () => {
872
+ const code = pre.querySelector("code");
873
+ let text = code?.textContent ?? "";
874
+ // Twoslash nests each hover popup's type signature and docs inside
875
+ // the <code>; copying textContent verbatim would interleave them
876
+ // with the source. Strip the popups from a clone first.
877
+ if (code?.querySelector(".twoslash-popup-container")) {
878
+ const clone = code.cloneNode(true) as HTMLElement;
879
+ for (const popup of clone.querySelectorAll(
880
+ ".twoslash-popup-container"
881
+ )) {
882
+ popup.remove();
883
+ }
884
+ text = clone.textContent ?? "";
885
+ }
886
+ if (await copyText(text)) {
887
+ flash();
888
+ }
889
889
  });
890
+ pre.appendChild(button);
890
891
  }
891
- }
892
+
893
+ // Click-to-zoom for content images (gated by `markdown.imageZoom`),
894
+ // via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
895
+ // and the open/close transition races are its problem, not ours.
896
+ // Opt out per-image with `data-no-zoom`.
897
+ if (document.body.hasAttribute("data-blume-image-zoom")) {
898
+ const zoomTargets = Array.from(
899
+ document.querySelectorAll<HTMLImageElement>(
900
+ ".prose img:not([data-no-zoom])"
901
+ )
902
+ // An image that is itself a link navigates on click — binding zoom
903
+ // to it would flash a zoom overlay in the instant before navigation
904
+ // and advertise (via the cursor) a zoom that never happens.
905
+ ).filter((image) => !image.closest("a"));
906
+ if (zoomTargets.length > 0) {
907
+ // Lazy: pages without a zoomable image never load the library,
908
+ // matching how mermaid is only fetched on pages with a diagram.
909
+ const { default: mediumZoom } = await import("medium-zoom");
910
+ mediumZoom(zoomTargets, {
911
+ background:
912
+ "color-mix(in oklab, var(--color-background) 80%, transparent)",
913
+ margin: 24,
914
+ });
915
+ }
916
+ }
917
+ };
918
+
919
+ void initPage();
920
+ document.addEventListener("astro:after-swap", () => {
921
+ void initPage();
922
+ });
892
923
  </script>
893
924
  <style is:global>
894
925
  /* 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,40 @@
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);})();`;
31
43
 
32
44
  /**
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.
45
+ * Keep the sidebar's scroll useful across page changes. The sidebar is its own
46
+ * scroll container, reborn scrolled to the top whenever its markup is rebuilt —
47
+ * on a long sidebar the viewport would visibly jump away from the link you just
48
+ * clicked.
49
+ *
50
+ * On the initial load it centers the current page's link before the sidebar
51
+ * paints (it runs inline immediately after the sidebar `<aside>`, not in
52
+ * `<head>`: it needs that markup parsed). On client-router navigations it first
53
+ * restores the exact scroll position saved at `astro:before-swap` — so clicking
54
+ * through nearby links doesn't move the sidebar at all — and only re-centers
55
+ * when the new page's link sits outside the visible scroll area.
37
56
  *
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.
57
+ * The lookup is scoped to the page tree (`data-blume-nav-tree`) because the
58
+ * drawer also holds the mobile tabs list, whose active tab is `aria-current`
59
+ * too. `getClientRects()` skips links that aren't rendered `hidden` drill-in
60
+ * panels and breakpoint-hidden duplicates and centering no-ops when the
61
+ * active link is already inside the visible scroll area, so a short sidebar
62
+ * never moves.
45
63
  */
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;})();`;
64
+ 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();})();`;