blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -152,9 +152,13 @@ const basedRoute = withBase(route);
152
152
  // strip it before joining with the root-relative route so canonical/og URLs
153
153
  // don't come out double-slashed — the catch-all strips it the same way.
154
154
  const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
155
+ // The route-derived part is percent-encoded (the sitemap convention) so a
156
+ // Unicode route slug yields a legal URI that byte-matches the sitemap <loc>.
155
157
  const resolvedCanonical =
156
158
  canonical ??
157
- (siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
159
+ (siteBase
160
+ ? `${siteBase}${basedRoute === "/" ? "" : encodeURI(basedRoute)}`
161
+ : null);
158
162
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
159
163
  // `public/`) is resolved against the site URL so crawlers get an absolute
160
164
  // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
@@ -164,7 +168,7 @@ const absolutizeOgImage = (value: string): string =>
164
168
  const resolvedOgImage = ogImage
165
169
  ? absolutizeOgImage(ogImage)
166
170
  : ogEnabled && siteBase
167
- ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
171
+ ? `${siteBase}${encodeURI(withBase(`/og/${ogSlug}.png`))}`
168
172
  : null;
169
173
  // Only the generated card has a known size and format, so its dimensions are
170
174
  // declared for crawlers; a user-supplied `ogImage` could be any image.
@@ -305,36 +309,12 @@ const bannerKey = banner?.dismissible ? banner.key : null;
305
309
  )
306
310
  }
307
311
  <script>
312
+ import { syncDrawerInert } from "./drawer-inert.ts";
308
313
  // Dev-only: friendly hint after a React island hydration mismatch;
309
314
  // tree-shaken out of production builds.
310
315
  import "./hydration-hint.ts";
311
316
 
312
- // The closed tabs drawer is only translated off-canvas, so its links
313
- // would stay in the tab order. Mirror the header's `data-blume-nav-open`
314
- // toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
315
- // breakpoint where the drawer is display-hidden anyway.
316
- const drawer = document.querySelector<HTMLElement>(
317
- "[data-blume-nav-drawer]"
318
- );
319
- if (drawer) {
320
- const desktop = window.matchMedia("(min-width: 64rem)");
321
- const syncDrawer = () => {
322
- const hidden =
323
- !desktop.matches &&
324
- !document.documentElement.hasAttribute("data-blume-nav-open");
325
- drawer.inert = hidden;
326
- if (hidden) {
327
- drawer.setAttribute("aria-hidden", "true");
328
- } else {
329
- drawer.removeAttribute("aria-hidden");
330
- }
331
- };
332
- syncDrawer();
333
- desktop.addEventListener("change", syncDrawer);
334
- new MutationObserver(syncDrawer).observe(document.documentElement, {
335
- attributeFilter: ["data-blume-nav-open"],
336
- });
337
- }
317
+ syncDrawerInert();
338
318
  </script>
339
319
  </body>
340
320
  </html>
@@ -8,6 +8,7 @@ import type {
8
8
  Heading,
9
9
  LocaleSwitchOption,
10
10
  Navigation,
11
+ NavSelector as NavSelectorType,
11
12
  } from "../../core/types.ts";
12
13
  import "blume:theme";
13
14
  import type { ComponentOverride } from "../../core/define-components.ts";
@@ -46,6 +47,7 @@ import PageActions from "./PageActions.astro";
46
47
  import PageFeedback from "./PageFeedback.astro";
47
48
  import Pagination from "./Pagination.astro";
48
49
  import TableOfContents from "./TableOfContents.astro";
50
+ import VersionBanner from "./VersionBanner.astro";
49
51
 
50
52
  interface Props {
51
53
  site: { title: string; description?: string };
@@ -149,6 +151,16 @@ interface Props {
149
151
  xDefault?: string | null;
150
152
  /** Language-switcher entries for the current page. */
151
153
  localeSwitch?: LocaleSwitchOption[];
154
+ /** Auto-populated version switcher (`null` when versioning is off or a user selector owns it). */
155
+ versionSelector?: NavSelectorType | null;
156
+ /** Old-version notice for archived pages (`null` on current-docs pages). */
157
+ versionNotice?: {
158
+ message: string;
159
+ latestHref: string;
160
+ latestLabel: string;
161
+ } | null;
162
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
163
+ searchVersion?: string | null;
152
164
  /**
153
165
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
154
166
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
@@ -220,6 +232,9 @@ const {
220
232
  localeAlternates,
221
233
  xDefault,
222
234
  localeSwitch,
235
+ versionSelector,
236
+ versionNotice,
237
+ searchVersion = null,
223
238
  layout = {},
224
239
  clientData,
225
240
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
@@ -499,16 +514,19 @@ const bannerKey = banner?.dismissible ? banner.key : null;
499
514
  href="#blume-content">{strings.page.skipToContent}</a
500
515
  >
501
516
  <Banner banner={banner} strings={strings.banner} />
517
+ <VersionBanner notice={versionNotice ?? null} />
502
518
  <HeaderSlot
503
519
  askEnabled={askEnabled}
504
520
  askStrings={strings.ask}
505
521
  layout={layout}
506
522
  localeSwitch={localeSwitch}
523
+ versionSelector={versionSelector}
507
524
  logo={logo}
508
525
  navigation={navigation}
509
526
  route={page.route}
510
527
  searchEnabled={searchEnabled}
511
528
  searchLocale={searchLocale}
529
+ searchVersion={searchVersion}
512
530
  navStrings={navStrings}
513
531
  searchStrings={strings.search}
514
532
  site={site}
@@ -712,6 +730,8 @@ const bannerKey = banner?.dismissible ? banner.key : null;
712
730
  type="button"
713
731
  ></button>
714
732
  <script>
733
+ import { copyText, createCopyFlash } from "../copy-feedback.ts";
734
+ import { syncDrawerInert } from "./drawer-inert.ts";
715
735
  import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
716
736
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
717
737
  // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
@@ -723,32 +743,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
723
743
  // Tree-shaken out of production builds.
724
744
  import "./hydration-hint.ts";
725
745
 
726
- // The closed mobile drawer is only translated off-canvas, so its links
727
- // would stay in the tab order on every page. Mirror the header's
728
- // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
729
- // below `lg` (64rem), where the same element isn't the static sidebar.
730
- const drawer = document.querySelector<HTMLElement>(
731
- "[data-blume-nav-drawer]"
732
- );
733
- if (drawer) {
734
- const desktop = window.matchMedia("(min-width: 64rem)");
735
- const syncDrawer = () => {
736
- const hidden =
737
- !desktop.matches &&
738
- !document.documentElement.hasAttribute("data-blume-nav-open");
739
- drawer.inert = hidden;
740
- if (hidden) {
741
- drawer.setAttribute("aria-hidden", "true");
742
- } else {
743
- drawer.removeAttribute("aria-hidden");
744
- }
745
- };
746
- syncDrawer();
747
- desktop.addEventListener("change", syncDrawer);
748
- new MutationObserver(syncDrawer).observe(document.documentElement, {
749
- attributeFilter: ["data-blume-nav-open"],
750
- });
751
- }
746
+ syncDrawerInert();
752
747
 
753
748
  const svg = (name: string, cls = "") =>
754
749
  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${cls ? ` class="${cls}"` : ""}>${icons[name]}</svg>`;
@@ -767,14 +762,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
767
762
  const copiedLabel =
768
763
  document.body.getAttribute("data-i18n-copied") || "Copied!";
769
764
 
770
- // Shared polite live region: the icon swap is invisible to screen
771
- // readers, so copy success is announced here. Cleared when a button's
772
- // checked state resets so the next copy re-announces.
773
- const copyStatus = document.createElement("span");
774
- copyStatus.className = "sr-only";
775
- copyStatus.setAttribute("aria-live", "polite");
776
- document.body.appendChild(copyStatus);
777
-
778
765
  const languageLabels: Record<string, string> = {
779
766
  astro: "Astro",
780
767
  bash: "Bash",
@@ -854,7 +841,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
854
841
  button.classList.toggle(cls, !checked);
855
842
  }
856
843
  };
857
- let resetTimeout: number | undefined;
844
+ const flash = createCopyFlash(setChecked, copiedLabel);
858
845
  button.addEventListener("click", async () => {
859
846
  const code = pre.querySelector("code");
860
847
  let text = code?.textContent ?? "";
@@ -870,20 +857,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
870
857
  }
871
858
  text = clone.textContent ?? "";
872
859
  }
873
- try {
874
- await navigator.clipboard.writeText(text);
875
- } catch {
876
- return;
877
- }
878
- if (resetTimeout) {
879
- window.clearTimeout(resetTimeout);
860
+ if (await copyText(text)) {
861
+ flash();
880
862
  }
881
- setChecked(true);
882
- copyStatus.textContent = copiedLabel;
883
- resetTimeout = window.setTimeout(() => {
884
- setChecked(false);
885
- copyStatus.textContent = "";
886
- }, 1500);
887
863
  });
888
864
  pre.appendChild(button);
889
865
  }
@@ -19,10 +19,21 @@ interface Props {
19
19
  strings?: UIStrings["search"];
20
20
  /** Active locale to filter results to; omitted disables locale filtering. */
21
21
  locale?: string;
22
+ /**
23
+ * Docs version to filter results to (`""` = the current docs — a meaningful
24
+ * value, so `null`/omitted is what disables version filtering).
25
+ */
26
+ version?: string | null;
22
27
  }
23
28
 
24
- const { askEnabled = false, navigation, popularPages, strings, locale } =
25
- Astro.props;
29
+ const {
30
+ askEnabled = false,
31
+ navigation,
32
+ popularPages,
33
+ strings,
34
+ locale,
35
+ version = null,
36
+ } = Astro.props;
26
37
  // Merge over the English baseline per key (rather than `strings ?? …`) so a
27
38
  // partial — or empty `{}` — strings object still resolves every label to a
28
39
  // default, matching the pattern PageActions uses for its own dictionary.
@@ -59,13 +70,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
59
70
  data-i18n-popular={s.popular}
60
71
  data-i18n-results={s.results}
61
72
  data-locale={locale || undefined}
73
+ data-version={version ?? undefined}
74
+ data-versioned={version === null ? undefined : ""}
62
75
  >
63
76
  {/* The label and shortcut hint wait until `lg`: below it the hamburger and
64
77
  inline tab bar share the header row, and a full-width search field would
65
78
  press into the language switcher. */}
66
79
  <button
67
80
  aria-label={s.button}
68
- class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-48"
81
+ class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-40"
69
82
  data-blume-search-open
70
83
  type="button"
71
84
  >
@@ -86,11 +99,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
86
99
  >
87
100
  <Icon name="search" size={18} />
88
101
  <input
102
+ aria-autocomplete="list"
103
+ aria-controls="blume-search-listbox"
104
+ aria-expanded="false"
89
105
  aria-label={s.label}
90
106
  autocomplete="off"
91
107
  class="flex-1 border-0 bg-transparent text-foreground text-sm pointer-coarse:text-base focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
92
108
  data-blume-search-input
93
109
  placeholder={s.placeholder}
110
+ role="combobox"
94
111
  type="search"
95
112
  />
96
113
  <kbd class={`${kbd} text-[0.7rem]`}>Esc</kbd>
@@ -107,8 +124,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
107
124
  >
108
125
  </div>
109
126
  <div
127
+ aria-label={s.label}
110
128
  class="min-h-0 flex-1 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-2"
111
129
  data-blume-search-results
130
+ id="blume-search-listbox"
131
+ role="listbox"
112
132
  >
113
133
  </div>
114
134
  <p
@@ -128,15 +148,29 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
128
148
  class="flex items-center justify-between gap-3 border-border border-t px-3 py-2 text-muted-foreground text-xs"
129
149
  >
130
150
  {
131
- locale ? (
132
- <label class="flex cursor-pointer select-none items-center gap-1.5">
133
- <input
134
- class="size-3.5 accent-accent"
135
- data-blume-search-all-locales
136
- type="checkbox"
137
- />
138
- {s.allLanguages}
139
- </label>
151
+ locale || version !== null ? (
152
+ <span class="flex items-center gap-3">
153
+ {locale && (
154
+ <label class="flex cursor-pointer select-none items-center gap-1.5">
155
+ <input
156
+ class="size-3.5 accent-accent"
157
+ data-blume-search-all-locales
158
+ type="checkbox"
159
+ />
160
+ {s.allLanguages}
161
+ </label>
162
+ )}
163
+ {version !== null && (
164
+ <label class="flex cursor-pointer select-none items-center gap-1.5">
165
+ <input
166
+ class="size-3.5 accent-accent"
167
+ data-blume-search-all-versions
168
+ type="checkbox"
169
+ />
170
+ {s.allVersions}
171
+ </label>
172
+ )}
173
+ </span>
140
174
  ) : (
141
175
  <span />
142
176
  )
@@ -207,6 +241,26 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
207
241
  const svg = (name: string, size = 16): string =>
208
242
  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] ?? ""}</svg>`;
209
243
 
244
+ // localStorage access throws SecurityError when storage is blocked (Safari
245
+ // "Block All Cookies", some embedded webviews). These guards make blocked
246
+ // storage degrade to session-default preferences instead of throwing inside
247
+ // connectedCallback before the open/keyboard listeners are attached — which
248
+ // would leave search completely dead.
249
+ const readStorage = (key: string): string | null => {
250
+ try {
251
+ return localStorage.getItem(key);
252
+ } catch {
253
+ return null;
254
+ }
255
+ };
256
+ const writeStorage = (key: string, value: string): void => {
257
+ try {
258
+ localStorage.setItem(key, value);
259
+ } catch {
260
+ // Preference simply isn't remembered.
261
+ }
262
+ };
263
+
210
264
  class BlumeSearch extends HTMLElement {
211
265
  dialog!: HTMLDialogElement;
212
266
  input!: HTMLInputElement;
@@ -226,6 +280,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
226
280
  selectedIndex = -1;
227
281
  activeSection: string | null = null;
228
282
  renderGeneration = 0;
283
+ /** Monotonic id source for option rows (aria-activedescendant). */
284
+ optionSeq = 0;
229
285
  previewOn = true;
230
286
  devOnlyMsg = "Search is available in the production build.";
231
287
  noResultsMsg = "No results found.";
@@ -239,6 +295,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
239
295
  // reader has opted to search across every language instead.
240
296
  locale: string | null = null;
241
297
  allLocales = false;
298
+ // The viewed docs version ("" = current; null when versioning is off),
299
+ // and the opt-in to search across every version.
300
+ versioned = false;
301
+ version = "";
302
+ allVersions = false;
242
303
 
243
304
  connectedCallback() {
244
305
  this.devOnlyMsg =
@@ -255,6 +316,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
255
316
  this.resultsMsg =
256
317
  this.getAttribute("data-i18n-results") || this.resultsMsg;
257
318
  this.locale = this.getAttribute("data-locale");
319
+ // "" (the current docs) is a real version value, so a presence flag —
320
+ // not the attribute's truthiness — decides whether filtering is on.
321
+ this.versioned = this.hasAttribute("data-versioned");
322
+ this.version = this.getAttribute("data-version") ?? "";
258
323
  this.dialog = this.querySelector("[data-blume-search-dialog]")!;
259
324
  this.input = this.querySelector("[data-blume-search-input]")!;
260
325
  this.grid = this.querySelector("[data-blume-search-grid]")!;
@@ -273,8 +338,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
273
338
  this.popular = [];
274
339
  }
275
340
 
276
- this.previewOn =
277
- localStorage.getItem("blume-search-preview") !== "0";
341
+ this.previewOn = readStorage("blume-search-preview") !== "0";
278
342
  this.applyPreviewState();
279
343
 
280
344
  // Per-language filtering: default to the active locale, with an opt-in
@@ -283,12 +347,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
283
347
  "[data-blume-search-all-locales]"
284
348
  );
285
349
  if (allToggle) {
286
- this.allLocales =
287
- localStorage.getItem("blume-search-all-locales") === "1";
350
+ this.allLocales = readStorage("blume-search-all-locales") === "1";
288
351
  allToggle.checked = this.allLocales;
289
352
  allToggle.addEventListener("change", () => {
290
353
  this.allLocales = allToggle.checked;
291
- localStorage.setItem(
354
+ writeStorage(
292
355
  "blume-search-all-locales",
293
356
  this.allLocales ? "1" : "0"
294
357
  );
@@ -296,6 +359,25 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
296
359
  });
297
360
  }
298
361
 
362
+ // Per-version filtering mirrors the locale toggle: default to the
363
+ // viewed version, with a remembered opt-in to search every version.
364
+ const allVersionsToggle = this.querySelector<HTMLInputElement>(
365
+ "[data-blume-search-all-versions]"
366
+ );
367
+ if (allVersionsToggle) {
368
+ this.allVersions =
369
+ readStorage("blume-search-all-versions") === "1";
370
+ allVersionsToggle.checked = this.allVersions;
371
+ allVersionsToggle.addEventListener("change", () => {
372
+ this.allVersions = allVersionsToggle.checked;
373
+ writeStorage(
374
+ "blume-search-all-versions",
375
+ this.allVersions ? "1" : "0"
376
+ );
377
+ this.render();
378
+ });
379
+ }
380
+
299
381
  // The handlers accept both ⌘ and Ctrl chords; show the right modifier
300
382
  // per platform on the button hint and the footer's preview hint.
301
383
  const isApple = /mac|iphone|ipad|ipod/iu.test(navigator.platform);
@@ -456,11 +538,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
456
538
 
457
539
  const localeFilter =
458
540
  this.locale && !this.allLocales ? this.locale : undefined;
541
+ const versionFilter =
542
+ this.versioned && !this.allVersions ? this.version : undefined;
459
543
  let result: Awaited<ReturnType<SearchFn>>;
460
544
  try {
461
545
  result = await this.searchFn(query, {
462
546
  locale: localeFilter,
463
547
  section: this.activeSection ?? undefined,
548
+ version: versionFilter,
464
549
  });
465
550
  } catch {
466
551
  // A hosted provider can reject (network error, outage); the results
@@ -568,7 +653,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
568
653
  header.className =
569
654
  "m-0 px-2.5 pt-3 pb-1 font-normal text-muted-foreground text-xs";
570
655
  header.textContent = label;
656
+ // The listbox tree allows only group/option descendants: the group
657
+ // carries the label for assistive tech, the visual header is
658
+ // decoration.
659
+ header.setAttribute("aria-hidden", "true");
571
660
  const container = document.createElement("div");
661
+ container.setAttribute("role", "group");
662
+ container.setAttribute("aria-label", label);
572
663
  this.results.append(header, container);
573
664
  return container;
574
665
  }
@@ -603,10 +694,16 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
603
694
  const excerpt = hit.excerpt
604
695
  ? `<span class="mt-0.5 line-clamp-2 text-muted-foreground text-xs">${hit.excerpt}</span>`
605
696
  : "";
697
+ // A cross-version hit (all-versions search) names its version so the
698
+ // reader knows they're about to leave the docs they're viewing.
699
+ const versionTag =
700
+ this.versioned && hit.version !== undefined && hit.version !== this.version
701
+ ? `<span class="ms-2 inline-block rounded-full bg-muted px-1.5 py-0.5 align-middle text-[0.65rem] text-muted-foreground">${escapeHtml(hit.version || "latest")}</span>`
702
+ : "";
606
703
  el.innerHTML = `
607
704
  <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
608
705
  <span class="flex-1">
609
- <span class="block truncate font-normal text-foreground text-sm">${hit.title}</span>
706
+ <span class="block truncate font-normal text-foreground text-sm">${hit.title}${versionTag}</span>
610
707
  ${excerpt}
611
708
  </span>`;
612
709
  const item: Selectable = { el, hit, kind: "link", url: href };
@@ -632,6 +729,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
632
729
  }
633
730
 
634
731
  bindRow(item: Selectable) {
732
+ // Options for the combobox pattern: selection is announced through
733
+ // aria-activedescendant on the input (focus never leaves it), so
734
+ // every row needs a stable id and an aria-selected to flip.
735
+ item.el.setAttribute("role", "option");
736
+ item.el.setAttribute("aria-selected", "false");
737
+ this.optionSeq += 1;
738
+ item.el.id = `blume-search-option-${this.optionSeq}`;
635
739
  item.el.addEventListener("mouseenter", () => {
636
740
  this.selectIndex(this.selectables.indexOf(item));
637
741
  });
@@ -644,9 +748,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
644
748
  }
645
749
 
646
750
  finishRender() {
751
+ this.input.setAttribute(
752
+ "aria-expanded",
753
+ String(this.selectables.length > 0)
754
+ );
647
755
  if (this.selectables.length > 0) {
648
756
  this.selectIndex(0);
649
757
  } else {
758
+ this.input.removeAttribute("aria-activedescendant");
650
759
  this.clearPreview();
651
760
  }
652
761
  }
@@ -659,11 +768,16 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
659
768
  if (current) {
660
769
  current.el.classList.remove(...ROW_ON);
661
770
  current.el.classList.add(...ROW_OFF);
771
+ current.el.setAttribute("aria-selected", "false");
662
772
  }
663
773
  this.selectedIndex = index;
664
774
  const next = this.selectables[index];
665
775
  next.el.classList.remove(...ROW_OFF);
666
776
  next.el.classList.add(...ROW_ON);
777
+ next.el.setAttribute("aria-selected", "true");
778
+ // Focus stays on the input; the selection is surfaced to assistive
779
+ // tech through the active descendant.
780
+ this.input.setAttribute("aria-activedescendant", next.el.id);
667
781
  next.el.scrollIntoView({ block: "nearest" });
668
782
  this.updatePreview();
669
783
  }
@@ -714,10 +828,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
714
828
 
715
829
  togglePreview() {
716
830
  this.previewOn = !this.previewOn;
717
- localStorage.setItem(
718
- "blume-search-preview",
719
- this.previewOn ? "1" : "0"
720
- );
831
+ writeStorage("blume-search-preview", this.previewOn ? "1" : "0");
721
832
  this.applyPreviewState();
722
833
  this.updatePreview();
723
834
  }
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { withBase } from "../islands/base-path.ts";
3
+ import Icon from "../Icon.astro";
4
+
5
+ // The "you're viewing an old version" notice, shared by RootLayout and
6
+ // ReferenceLayout so every shell shows the same bar on archived pages. Unlike
7
+ // the announcement Banner it is never dismissible — the reader should know
8
+ // they're on frozen docs for as long as they are. The message and link label
9
+ // arrive fully resolved (localized, `{version}` substituted) from the
10
+ // catch-all, so this component carries no string fallbacks of its own.
11
+ interface Props {
12
+ notice: {
13
+ message: string;
14
+ /** The same page in the latest docs when it exists, else the docs root. */
15
+ latestHref: string;
16
+ latestLabel: string;
17
+ } | null;
18
+ }
19
+
20
+ const { notice } = Astro.props;
21
+ ---
22
+
23
+ {
24
+ notice && (
25
+ <div
26
+ class="flex items-center justify-center gap-x-2 gap-y-0.5 border-amber-300 border-b bg-amber-100 px-10 py-2.5 text-center text-amber-900 text-sm max-sm:flex-wrap dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
27
+ data-blume-version-banner
28
+ >
29
+ <span>{notice.message}</span>
30
+ <a
31
+ class="inline-flex items-center gap-1 font-medium underline underline-offset-2"
32
+ href={withBase(notice.latestHref)}
33
+ >
34
+ {notice.latestLabel}
35
+ <Icon name="arrow-right" size={14} />
36
+ </a>
37
+ </div>
38
+ )
39
+ }
@@ -19,10 +19,15 @@ interface AnalyticsWindow {
19
19
  }
20
20
 
21
21
  export const track = (event: string, props: TrackProps): void => {
22
- if (typeof window === "undefined") {
22
+ // Read through `globalThis` so an SSR/import-time call sees `undefined`
23
+ // instead of a bare-identifier ReferenceError.
24
+ const browserWindow = globalThis.window;
25
+ if (browserWindow === undefined) {
23
26
  return;
24
27
  }
25
- const w = window as typeof window & AnalyticsWindow;
28
+ // SAFETY: AnalyticsWindow only adds optional provider globals, so any window
29
+ // satisfies the intersection; each provider is feature-checked before use.
30
+ const w = browserWindow as typeof browserWindow & AnalyticsWindow;
26
31
 
27
32
  // Vercel Web Analytics — self-gates to a no-op until `window.va` is set up.
28
33
  vercelTrack(event, props);
@@ -32,7 +37,5 @@ export const track = (event: string, props: TrackProps): void => {
32
37
  w.gtag?.("event", event, props);
33
38
  w.plausible?.(event, { props });
34
39
  // Universal hook for any other integration.
35
- window.dispatchEvent(
36
- new CustomEvent("blume:track", { detail: { event, props } })
37
- );
40
+ w.dispatchEvent(new CustomEvent("blume:track", { detail: { event, props } }));
38
41
  };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Keep the mobile nav drawer out of the tab order while it is closed. The
3
+ * closed drawer is only translated off-canvas, so its links would otherwise
4
+ * stay focusable on every page. Mirrors the header's `data-blume-nav-open`
5
+ * toggle into `inert`/`aria-hidden` — but only below `lg` (64rem), where the
6
+ * same element isn't the static sidebar (RootLayout) or is display-hidden
7
+ * anyway (PageLayout). Shared by both layouts' inline scripts.
8
+ */
9
+ export const syncDrawerInert = (): void => {
10
+ const drawer = document.querySelector<HTMLElement>("[data-blume-nav-drawer]");
11
+ if (!drawer) {
12
+ return;
13
+ }
14
+ const desktop = window.matchMedia("(min-width: 64rem)");
15
+ const sync = () => {
16
+ const hidden =
17
+ !desktop.matches &&
18
+ !Object.hasOwn(document.documentElement.dataset, "blumeNavOpen");
19
+ drawer.inert = hidden;
20
+ if (hidden) {
21
+ drawer.setAttribute("aria-hidden", "true");
22
+ } else {
23
+ drawer.removeAttribute("aria-hidden");
24
+ }
25
+ };
26
+ sync();
27
+ desktop.addEventListener("change", sync);
28
+ new MutationObserver(sync).observe(document.documentElement, {
29
+ attributeFilter: ["data-blume-nav-open"],
30
+ });
31
+ };
@@ -13,7 +13,7 @@ const PATTERNS = [
13
13
  /server rendered html/iu,
14
14
  ];
15
15
 
16
- if (import.meta.env.DEV && typeof window !== "undefined") {
16
+ if (import.meta.env.DEV && "window" in globalThis) {
17
17
  const original = console.error.bind(console);
18
18
  let shown = false;
19
19
  console.error = (...args: unknown[]) => {
@@ -209,10 +209,7 @@ export const sidebarForRoute = (
209
209
  };
210
210
 
211
211
  /** Resolve previous/next pages around the current route. */
212
- export const getPagination = (
213
- flat: FlatPage[],
214
- route: string
215
- ): { prev: FlatPage | null; next: FlatPage | null } => {
212
+ export const getPagination = (flat: FlatPage[], route: string) => {
216
213
  const index = flat.findIndex((page) => page.route === route);
217
214
  if (index === -1) {
218
215
  return { next: null, prev: null };