blume 1.5.0 → 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 (89) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +449 -135
  4. package/dist/cli/index.js.map +24 -23
  5. package/dist/types/ai/ask-context.d.ts +78 -0
  6. package/dist/types/core/config-input.d.ts +54 -2
  7. package/dist/types/core/data.d.ts +19 -2
  8. package/dist/types/core/open-in-chat.d.ts +9 -0
  9. package/dist/types/core/schema.d.ts +48 -1
  10. package/dist/types/core/types.d.ts +10 -3
  11. package/dist/types/openapi/references.d.ts +9 -0
  12. package/dist/types/search/orama-index.d.ts +70 -0
  13. package/dist/types/theme/fonts.d.ts +11 -2
  14. package/docs/advanced/api-reference.mdx +67 -5
  15. package/docs/advanced/custom-pages.mdx +5 -1
  16. package/docs/configuration/ai.mdx +35 -0
  17. package/docs/configuration/index.mdx +14 -2
  18. package/docs/configuration/search.mdx +4 -4
  19. package/docs/configuration/theming.mdx +4 -2
  20. package/docs/reference/cli.mdx +2 -2
  21. package/package.json +1 -1
  22. package/skills/blume-migrate/SKILL.md +1 -1
  23. package/skills/blume-migrate/references/mintlify.md +1 -1
  24. package/src/ai/ask-context.ts +51 -11
  25. package/src/ai/mcp/data.ts +3 -2
  26. package/src/ai/mcp/server.ts +3 -2
  27. package/src/assets/icon-dark.png +0 -0
  28. package/src/astro/generate.ts +172 -18
  29. package/src/astro/templates.ts +89 -15
  30. package/src/components/content/AccordionItem.astro +4 -0
  31. package/src/components/content/Update.astro +3 -0
  32. package/src/components/islands/AskAI.astro +6 -0
  33. package/src/components/islands/ask-ai.tsx +39 -9
  34. package/src/components/layout/Analytics.astro +9 -1
  35. package/src/components/layout/Favicon.astro +29 -8
  36. package/src/components/layout/Fonts.astro +23 -3
  37. package/src/components/layout/Header.astro +2 -2
  38. package/src/components/layout/NavSelector.astro +1 -1
  39. package/src/components/layout/PageActions.astro +120 -78
  40. package/src/components/layout/PageFeedback.astro +12 -3
  41. package/src/components/layout/PageLayout.astro +79 -5
  42. package/src/components/layout/ReferenceLayout.astro +12 -9
  43. package/src/components/layout/RootLayout.astro +153 -121
  44. package/src/components/layout/Search.astro +41 -26
  45. package/src/components/layout/drawer-inert.ts +10 -5
  46. package/src/components/layout/head-scripts.ts +34 -16
  47. package/src/components/layout/nav-utils.ts +34 -15
  48. package/src/components/layout/search/orama.ts +3 -2
  49. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  50. package/src/components/openapi/MessageComposer.astro +238 -0
  51. package/src/components/openapi/Operation.astro +26 -12
  52. package/src/components/openapi/PanelTabs.astro +7 -0
  53. package/src/components/openapi/Playground.astro +320 -0
  54. package/src/components/openapi/RequestPanel.astro +1 -0
  55. package/src/components/openapi/async-snippets.ts +20 -7
  56. package/src/components/openapi/async.ts +13 -2
  57. package/src/components/openapi/message-composer.ts +242 -0
  58. package/src/components/openapi/message-model.ts +108 -0
  59. package/src/components/openapi/message.ts +153 -0
  60. package/src/components/openapi/operation-model.ts +260 -0
  61. package/src/components/openapi/playground-client.ts +486 -0
  62. package/src/components/openapi/playground-schema.ts +109 -0
  63. package/src/components/openapi/request.ts +287 -0
  64. package/src/components/openapi/security.ts +0 -56
  65. package/src/components/openapi/snippets.ts +23 -136
  66. package/src/components/openapi/validate-json.ts +144 -0
  67. package/src/components/openapi/ws-client.ts +194 -0
  68. package/src/core/config-input.ts +67 -1
  69. package/src/core/content-assets.ts +66 -15
  70. package/src/core/data.ts +16 -2
  71. package/src/core/last-modified.ts +76 -2
  72. package/src/core/links.ts +30 -4
  73. package/src/core/navigation.ts +26 -1
  74. package/src/core/open-in-chat.ts +17 -0
  75. package/src/core/project-graph.ts +11 -0
  76. package/src/core/schema.ts +60 -1
  77. package/src/core/server-features.ts +11 -0
  78. package/src/core/sources/normalize.ts +10 -2
  79. package/src/core/types.ts +10 -3
  80. package/src/deploy/vercel-negotiation.ts +34 -14
  81. package/src/og/card.ts +3 -1
  82. package/src/openapi/model.ts +7 -0
  83. package/src/openapi/proxy.ts +217 -0
  84. package/src/openapi/references.ts +8 -0
  85. package/src/openapi/source.ts +13 -0
  86. package/src/registry/eject.ts +4 -5
  87. package/src/search/orama-index.ts +109 -36
  88. package/src/theme/entry.ts +15 -2
  89. package/src/theme/fonts.ts +75 -3
@@ -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,8 @@ import type {
11
12
  NavSelector as NavSelectorType,
12
13
  } from "../../core/types.ts";
13
14
  import "blume:theme";
15
+ import { ClientRouter } from "astro:transitions";
16
+ import type { FontHead } from "../../theme/fonts.ts";
14
17
  import type { ComponentOverride } from "../../core/define-components.ts";
15
18
  import {
16
19
  OG_IMAGE_HEIGHT,
@@ -35,7 +38,7 @@ import {
35
38
  import Header from "./Header.astro";
36
39
  import Icon from "../Icon.astro";
37
40
  import {
38
- activeTabForRoute,
41
+ currentTabForRoute,
39
42
  findBreadcrumbs,
40
43
  flattenPages,
41
44
  getPagination,
@@ -59,14 +62,8 @@ interface Props {
59
62
  href: string;
60
63
  text?: string;
61
64
  } | null;
62
- favicon?: {
63
- href: string;
64
- type?: string;
65
- } | null;
66
- appleIcon?: {
67
- href: string;
68
- type?: string;
69
- } | null;
65
+ favicon?: BlumeFavicon | null;
66
+ appleIcon?: BlumeFavicon | null;
70
67
  banner?: {
71
68
  content: string;
72
69
  link?: { text: string; href: string };
@@ -90,7 +87,7 @@ interface Props {
90
87
  imageZoom?: boolean;
91
88
  codeWrap?: boolean;
92
89
  themeMode: "system" | "light" | "dark";
93
- fontCssVars?: string[];
90
+ fontCssVars?: (string | FontHead)[];
94
91
  searchEnabled: boolean;
95
92
  indexable: boolean;
96
93
  ogImage?: string | null;
@@ -118,6 +115,11 @@ interface Props {
118
115
  feedback?: boolean;
119
116
  exportPdf?: boolean;
120
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[];
121
123
  feeds?: { title: string; href: string }[];
122
124
  /**
123
125
  * Which agent-discovery resources exist, advertised as `describedby` head
@@ -217,6 +219,7 @@ const {
217
219
  feedback = false,
218
220
  exportPdf,
219
221
  exportEpub,
222
+ openInChat,
220
223
  feeds,
221
224
  discovery,
222
225
  siteUrl,
@@ -355,14 +358,21 @@ const mcpUrl =
355
358
  // pages. Without tabs — or on a route under none — this is the full sidebar.
356
359
  // `navigation.root` keeps the root-tab check in the tabs' localized/based
357
360
  // path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
358
- // 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).
359
365
  const sidebar = sidebarForRoute(
360
366
  navigation.sidebar,
361
367
  navigation.tabs,
362
368
  page.route,
363
369
  navigation.root
364
370
  );
365
- const activeTab = activeTabForRoute(navigation.tabs, page.route);
371
+ const activeTab = currentTabForRoute(
372
+ navigation.tabs,
373
+ page.route,
374
+ navigation.root
375
+ );
366
376
  const crumbs = findBreadcrumbs(sidebar, page.route);
367
377
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
368
378
 
@@ -397,6 +407,13 @@ const bannerKey = banner?.dismissible ? banner.key : null;
397
407
  <head>
398
408
  <meta charset="utf-8" />
399
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 />
400
417
  <title>{pageTitle}</title>
401
418
  <Favicon favicon={favicon} appleIcon={appleIcon} />
402
419
  <Fonts cssVars={fontCssVars ?? []} />
@@ -705,6 +722,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
705
722
  exportPdf={exportPdf}
706
723
  mcpName={mcp?.name}
707
724
  mcpUrl={mcpUrl}
725
+ openInChat={openInChat}
708
726
  route={page.route}
709
727
  strings={strings.actions}
710
728
  />
@@ -743,6 +761,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
743
761
  // Tree-shaken out of production builds.
744
762
  import "./hydration-hint.ts";
745
763
 
764
+ // Runs once per real page load; re-syncs itself after client-router
765
+ // swaps (see drawer-inert.ts).
746
766
  syncDrawerInert();
747
767
 
748
768
  const svg = (name: string, cls = "") =>
@@ -754,14 +774,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
754
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";
755
775
  const idleClasses = ["hover:bg-muted", "hover:text-foreground"];
756
776
 
757
- // Localized copy-button labels, stamped on <body> by the layout markup
758
- // (the Search.astro data-attribute channel) since this bundled script
759
- // can't interpolate server values directly.
760
- const copyCodeLabel =
761
- document.body.getAttribute("data-i18n-copy-code") || "Copy code";
762
- const copiedLabel =
763
- document.body.getAttribute("data-i18n-copied") || "Copied!";
764
-
765
777
  const languageLabels: Record<string, string> = {
766
778
  astro: "Astro",
767
779
  bash: "Bash",
@@ -783,111 +795,131 @@ const bannerKey = banner?.dismissible ? banner.key : null;
783
795
  zsh: "Zsh",
784
796
  };
785
797
 
786
- for (const pre of document.querySelectorAll(".prose pre")) {
787
- if (pre.querySelector("[data-blume-copy]")) {
788
- continue;
789
- }
790
- // Skip nested <pre>: Twoslash renders each hover popup's type signature
791
- // as a <pre> inside the code block, which shouldn't get its own button.
792
- if (pre.parentElement?.closest("pre")) {
793
- continue;
794
- }
795
- const language = pre.getAttribute("data-language");
796
- if (language) {
797
- pre.setAttribute(
798
- "data-language",
799
- languageLabels[language.toLowerCase()] ?? language
800
- );
801
- }
802
- pre.classList.add("group", "relative");
803
- // The code element is the scroll container (see the theme entry), but
804
- // Shiki's tab stop lands on the pre, which no longer scrolls. Move the
805
- // stop to the code so keyboard users can actually scroll the block
806
- // (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
807
- // API-panel blocks keep the pre as their scroller, so theirs stays.
808
- const scroller = pre.querySelector("code");
809
- if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
810
- scroller.setAttribute("tabindex", "0");
811
- pre.removeAttribute("tabindex");
812
- }
813
- const button = document.createElement("button");
814
- button.type = "button";
815
- // The language-label bar (prose) vs flush code (tabs) need a different
816
- // offset; pick the Tailwind class by context instead of a CSS override.
817
- const topClass = pre.closest("blume-tabs, .not-prose")
818
- ? "top-2.5"
819
- : "top-2";
820
- button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
821
- button.setAttribute("data-blume-copy", "");
822
- button.setAttribute("aria-label", copyCodeLabel);
823
- button.innerHTML =
824
- svg(
825
- "check",
826
- "scale-0 text-green-600 transition-transform dark:text-green-500"
827
- ) + svg("copy", "absolute transition-transform");
828
- const [checkIcon, copyIcon] = button.querySelectorAll("svg");
829
- // While checked the button drops its idle hover tint so the green
830
- // check reads as a steady confirmation, mirroring Lina's copied state.
831
- // The accessible name tracks the visual state for anyone probing the
832
- // button mid-confirmation.
833
- const setChecked = (checked: boolean) => {
834
- checkIcon?.classList.toggle("scale-0", !checked);
835
- copyIcon?.classList.toggle("scale-0", checked);
836
- button.setAttribute(
837
- "aria-label",
838
- checked ? copiedLabel : copyCodeLabel
839
- );
840
- for (const cls of idleClasses) {
841
- 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;
842
815
  }
843
- };
844
- const flash = createCopyFlash(setChecked, copiedLabel);
845
- button.addEventListener("click", async () => {
846
- const code = pre.querySelector("code");
847
- let text = code?.textContent ?? "";
848
- // Twoslash nests each hover popup's type signature and docs inside
849
- // the <code>; copying textContent verbatim would interleave them
850
- // with the source. Strip the popups from a clone first.
851
- if (code?.querySelector(".twoslash-popup-container")) {
852
- const clone = code.cloneNode(true) as HTMLElement;
853
- for (const popup of clone.querySelectorAll(
854
- ".twoslash-popup-container"
855
- )) {
856
- popup.remove();
857
- }
858
- 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;
859
820
  }
860
- if (await copyText(text)) {
861
- flash();
821
+ const language = pre.getAttribute("data-language");
822
+ if (language) {
823
+ pre.setAttribute(
824
+ "data-language",
825
+ languageLabels[language.toLowerCase()] ?? language
826
+ );
862
827
  }
863
- });
864
- pre.appendChild(button);
865
- }
866
-
867
- // Click-to-zoom for content images (gated by `markdown.imageZoom`),
868
- // via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
869
- // and the open/close transition races are its problem, not ours.
870
- // Opt out per-image with `data-no-zoom`.
871
- if (document.body.hasAttribute("data-blume-image-zoom")) {
872
- const zoomTargets = Array.from(
873
- document.querySelectorAll<HTMLImageElement>(
874
- ".prose img:not([data-no-zoom])"
875
- )
876
- // An image that is itself a link navigates on click — binding zoom
877
- // to it would flash a zoom overlay in the instant before navigation
878
- // and advertise (via the cursor) a zoom that never happens.
879
- ).filter((image) => !image.closest("a"));
880
- if (zoomTargets.length > 0) {
881
- // Lazy: pages without a zoomable image never load the library,
882
- // matching how mermaid is only fetched on pages with a diagram.
883
- const { default: mediumZoom } = await import("medium-zoom");
884
- mediumZoom(zoomTargets, {
885
- background:
886
- "color-mix(in oklab, var(--color-background) 80%, transparent)",
887
- 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
+ }
888
889
  });
890
+ pre.appendChild(button);
889
891
  }
890
- }
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
+ });
891
923
  </script>
892
924
  <style is:global>
893
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();})();`;