blume 0.7.0 → 0.8.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 (168) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -68,6 +68,7 @@ const menuRowClass =
68
68
  class="mt-8 space-y-0.5 border-border border-t pt-4"
69
69
  data-blume-page-actions
70
70
  data-i18n-copied={a.copied}
71
+ data-i18n-generating={a.generating}
71
72
  data-mcp-name={mcpName ?? undefined}
72
73
  data-mcp-url={mcpUrl ?? undefined}
73
74
  data-md={mdPath}
@@ -98,7 +99,7 @@ const menuRowClass =
98
99
  class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
99
100
  >
100
101
  <Icon name="download" size={16} />
101
- Export
102
+ {a.export}
102
103
  <Icon
103
104
  class="ms-auto transition-transform group-open:rotate-180"
104
105
  name="chevron-down"
@@ -112,14 +113,14 @@ const menuRowClass =
112
113
  {exportPdf && (
113
114
  <button class={menuRowClass} data-blume-export-pdf type="button">
114
115
  <Icon name="file" size={16} />
115
- <span class="flex-1">Export to PDF</span>
116
+ <span class="flex-1">{a.exportPdf}</span>
116
117
  </button>
117
118
  )}
118
119
  {exportEpub && (
119
120
  <button class={menuRowClass} data-blume-export-epub type="button">
120
121
  <Icon name="book-open" size={16} />
121
122
  <span class="flex-1" data-blume-epub-label>
122
- Export to EPUB
123
+ {a.exportEpub}
123
124
  </span>
124
125
  </button>
125
126
  )}
@@ -218,6 +219,8 @@ const menuRowClass =
218
219
  </div>
219
220
 
220
221
  <script>
222
+ import { prefixBase } from "../islands/base-path.ts";
223
+
221
224
  const CHAT_URLS: Record<string, (q: string) => string> = {
222
225
  chatgpt: (q) => `https://chatgpt.com/?hints=search&prompt=${q}`,
223
226
  claude: (q) => `https://claude.ai/new?q=${q}`,
@@ -317,8 +320,14 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
317
320
  }
318
321
  });
319
322
 
320
- const md = root.getAttribute("data-md") ?? "";
323
+ // `data-md` is the base-less logical route; the raw-markdown endpoint is a
324
+ // generated page route, so it's served under the deployment base like any
325
+ // other page. Prefix at emit time (the Search-island pattern).
326
+ const mdRoute = root.getAttribute("data-md") ?? "";
327
+ const md = prefixBase(import.meta.env.BASE_URL, mdRoute);
321
328
  const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
329
+ const generatingLabel =
330
+ root.getAttribute("data-i18n-generating") || "Generating…";
322
331
  const absolute = new URL(md, location.origin).href;
323
332
  const query = encodeURIComponent(
324
333
  `Read ${absolute} so I can ask you questions about this page.`
@@ -414,6 +423,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
414
423
  ?.addEventListener("click", async () => {
415
424
  try {
416
425
  const response = await fetch(md);
426
+ if (!response.ok) {
427
+ throw new Error(`Fetching ${md} failed (${response.status})`);
428
+ }
417
429
  await navigator.clipboard.writeText(await response.text());
418
430
  if (label) {
419
431
  label.textContent = copiedLabel;
@@ -421,8 +433,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
421
433
  label.textContent = original;
422
434
  }, 1500);
423
435
  }
424
- } catch {
425
- // Clipboard or fetch unavailable; nothing to do.
436
+ } catch (error) {
437
+ // Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.
438
+ console.error("[blume] Copy as Markdown failed", error);
426
439
  }
427
440
  });
428
441
 
@@ -432,6 +445,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
432
445
 
433
446
  const epubButton = root.querySelector("[data-blume-export-epub]");
434
447
  const epubLabel = root.querySelector("[data-blume-epub-label]");
448
+ // Captured once — inside the handler a click during a generation would
449
+ // capture and permanently restore "Generating…".
450
+ const epubReset = epubLabel?.textContent ?? "";
435
451
  epubButton?.addEventListener("click", async () => {
436
452
  const article = document.querySelector("#blume-content article");
437
453
  if (!article) {
@@ -439,9 +455,8 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
439
455
  }
440
456
  const heading = document.querySelector("#blume-content h1");
441
457
  const title = heading?.textContent?.trim() || document.title;
442
- const reset = epubLabel?.textContent ?? "Export to EPUB";
443
458
  if (epubLabel) {
444
- epubLabel.textContent = "Generating…";
459
+ epubLabel.textContent = generatingLabel;
445
460
  }
446
461
  try {
447
462
  // Browser bundle: avoids Node built-ins and returns a Blob. Lazy-loaded
@@ -461,7 +476,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
461
476
  ? result
462
477
  : new Blob([result], { type: "application/epub+zip" });
463
478
  const slug =
464
- md
479
+ mdRoute
465
480
  .replace(/\.md$/u, "")
466
481
  .replace(/^\//u, "")
467
482
  .replace(/\//gu, "-") || "index";
@@ -479,7 +494,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
479
494
  console.error("[blume] EPUB export failed", error);
480
495
  } finally {
481
496
  if (epubLabel) {
482
- epubLabel.textContent = reset;
497
+ epubLabel.textContent = epubReset;
483
498
  }
484
499
  }
485
500
  });
@@ -110,6 +110,10 @@ const clientDataJson = clientData
110
110
  : null;
111
111
 
112
112
  const strings = ui ?? EN_UI;
113
+ // Merge over the English defaults so a label missing from a translation (or
114
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
115
+ // blank — the PageActions pattern.
116
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
113
117
  // Filter search to the active language only when the site is multi-locale.
114
118
  const searchLocale =
115
119
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -125,19 +129,23 @@ const ogSlug = route === "/" ? "index" : route.slice(1);
125
129
  // Absolute URLs carry the deployment base too (`site + base + route`), since the
126
130
  // page is served under it. `withBase` handles a root base as a no-op.
127
131
  const basedRoute = withBase(route);
132
+ // The schema accepts a trailing-slash site URL (`https://docs.example.com/`);
133
+ // strip it before joining with the root-relative route so canonical/og URLs
134
+ // don't come out double-slashed — the catch-all strips it the same way.
135
+ const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
128
136
  const resolvedCanonical =
129
137
  canonical ??
130
- (siteUrl ? `${siteUrl}${basedRoute === "/" ? "" : basedRoute}` : null);
138
+ (siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
131
139
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
132
140
  // `public/`) is resolved against the site URL so crawlers get an absolute
133
141
  // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
134
142
  // back to the generated OG card for this route.
135
143
  const absolutizeOgImage = (value: string): string =>
136
- value.startsWith("/") && siteUrl ? `${siteUrl}${withBase(value)}` : value;
144
+ value.startsWith("/") && siteBase ? `${siteBase}${withBase(value)}` : value;
137
145
  const resolvedOgImage = ogImage
138
146
  ? absolutizeOgImage(ogImage)
139
- : ogEnabled && siteUrl
140
- ? `${siteUrl}${withBase(`/og/${ogSlug}.png`)}`
147
+ : ogEnabled && siteBase
148
+ ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
141
149
  : null;
142
150
 
143
151
  const initialThemeScript = themeInitScript(themeMode);
@@ -178,12 +186,13 @@ const bannerScript = banner?.dismissible
178
186
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
179
187
  href="#blume-content">{strings.page.skipToContent}</a
180
188
  >
181
- <Banner banner={banner} />
189
+ <Banner banner={banner} strings={strings.banner} />
182
190
  <Header
183
191
  askEnabled={askEnabled}
184
192
  hasSidebar={false}
185
193
  localeSwitch={localeSwitch}
186
194
  logo={logo}
195
+ navStrings={navStrings}
187
196
  navigation={navigation}
188
197
  route={route}
189
198
  searchEnabled={searchEnabled}
@@ -205,10 +214,11 @@ const bannerScript = banner?.dismissible
205
214
  navigation.tabs.length > 0 && (
206
215
  <>
207
216
  <aside
208
- aria-label="Navigation"
217
+ aria-label={navStrings.navigation}
209
218
  class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:hidden"
219
+ data-blume-nav-drawer
210
220
  >
211
- <nav aria-label="Sections">
221
+ <nav aria-label={navStrings.sections}>
212
222
  <ul class="m-0 list-none p-0">
213
223
  {navigation.tabs.map((tab) => (
214
224
  <li>
@@ -230,7 +240,7 @@ const bannerScript = banner?.dismissible
230
240
  </nav>
231
241
  </aside>
232
242
  <button
233
- aria-label="Close navigation"
243
+ aria-label={navStrings.closeNavigation}
234
244
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
235
245
  data-blume-nav-toggle
236
246
  type="button"
@@ -252,6 +262,33 @@ const bannerScript = banner?.dismissible
252
262
  // Dev-only: friendly hint after a React island hydration mismatch;
253
263
  // tree-shaken out of production builds.
254
264
  import "./hydration-hint.ts";
265
+
266
+ // The closed tabs drawer is only translated off-canvas, so its links
267
+ // would stay in the tab order. Mirror the header's `data-blume-nav-open`
268
+ // toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
269
+ // breakpoint where the drawer is display-hidden anyway.
270
+ const drawer = document.querySelector<HTMLElement>(
271
+ "[data-blume-nav-drawer]"
272
+ );
273
+ if (drawer) {
274
+ const desktop = window.matchMedia("(min-width: 64rem)");
275
+ const syncDrawer = () => {
276
+ const hidden =
277
+ !desktop.matches &&
278
+ !document.documentElement.hasAttribute("data-blume-nav-open");
279
+ drawer.inert = hidden;
280
+ if (hidden) {
281
+ drawer.setAttribute("aria-hidden", "true");
282
+ } else {
283
+ drawer.removeAttribute("aria-hidden");
284
+ }
285
+ };
286
+ syncDrawer();
287
+ desktop.addEventListener("change", syncDrawer);
288
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
289
+ attributeFilter: ["data-blume-nav-open"],
290
+ });
291
+ }
255
292
  </script>
256
293
  </body>
257
294
  </html>
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import "blume:theme";
3
+ import { EN_UI } from "../../core/i18n-ui.ts";
4
+ import type { UIStrings } from "../../core/i18n-ui.ts";
3
5
  import type { Navigation } from "../../core/types.ts";
4
6
  import Analytics from "./Analytics.astro";
5
7
  import Banner from "./Banner.astro";
@@ -50,6 +52,8 @@ interface Props {
50
52
  fontCssVars?: string[];
51
53
  searchEnabled: boolean;
52
54
  pageTitle: string;
55
+ /** Resolved UI dictionary; English baseline when omitted. */
56
+ ui?: UIStrings;
53
57
  }
54
58
 
55
59
  const {
@@ -65,8 +69,11 @@ const {
65
69
  fontCssVars,
66
70
  searchEnabled,
67
71
  pageTitle,
72
+ ui,
68
73
  } = Astro.props;
69
74
 
75
+ const strings = ui ?? EN_UI;
76
+
70
77
  // Set the theme before paint so the navbar never flashes the wrong colors
71
78
  // (mirrors RootLayout's pre-paint script).
72
79
  const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};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);})();`;
@@ -90,7 +97,7 @@ const bannerScript = banner?.dismissible
90
97
  <Analytics analytics={analytics} />
91
98
  </head>
92
99
  <body class="bg-background font-sans text-foreground antialiased">
93
- <Banner banner={banner} />
100
+ <Banner banner={banner} strings={strings.banner} />
94
101
  <Header
95
102
  hasDrawer={false}
96
103
  hasSidebar={false}
@@ -206,6 +206,12 @@ const PageFooterSlot = resolveSlot(layout.PageFooter, Empty);
206
206
  const FeedbackSlot = resolveSlot(layout.Feedback, PageFeedback);
207
207
 
208
208
  const strings = ui ?? EN_UI;
209
+ // Merge the groups this layout reads directly over the English defaults so a
210
+ // key missing from a translation (or from a not-yet-regenerated snapshot)
211
+ // still renders instead of coming out blank — the PageActions pattern.
212
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
213
+ const actionStrings = { ...EN_UI.actions, ...strings.actions };
214
+ const contentStrings = { ...EN_UI.content, ...strings.content };
209
215
  // Filter search to the active language only when the site is multi-locale.
210
216
  const searchLocale =
211
217
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -345,12 +351,14 @@ const bannerScript = banner?.dismissible
345
351
  class="bg-background font-sans text-foreground antialiased"
346
352
  data-blume-code-wrap={codeWrap ? "" : undefined}
347
353
  data-blume-image-zoom={imageZoom ? "" : undefined}
354
+ data-i18n-copy-code={actionStrings.copyCode}
355
+ data-i18n-diagram-error={contentStrings.diagramError}
348
356
  >
349
357
  <a
350
358
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
351
359
  href="#blume-content">{strings.page.skipToContent}</a
352
360
  >
353
- <Banner banner={banner} />
361
+ <Banner banner={banner} strings={strings.banner} />
354
362
  <HeaderSlot
355
363
  askEnabled={askEnabled}
356
364
  layout={layout}
@@ -360,6 +368,7 @@ const bannerScript = banner?.dismissible
360
368
  route={page.route}
361
369
  searchEnabled={searchEnabled}
362
370
  searchLocale={searchLocale}
371
+ navStrings={navStrings}
363
372
  searchStrings={strings.search}
364
373
  site={site}
365
374
  switcherStrings={strings.languageSwitcher}
@@ -371,7 +380,8 @@ const bannerScript = banner?.dismissible
371
380
  data-blume-doc-grid
372
381
  >
373
382
  <aside
374
- aria-label="Primary"
383
+ aria-label={navStrings.primary}
384
+ data-blume-nav-drawer
375
385
  class:list={[
376
386
  "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
377
387
  // A "bare" landing (the changelog index) has no sidebar column on
@@ -385,7 +395,10 @@ const bannerScript = banner?.dismissible
385
395
  // all breakpoints, outside the tab-scoped sidebar so they never change
386
396
  // with the active tab. External hrefs open in a new tab.
387
397
  navigation.featured.length > 0 && (
388
- <nav aria-label="Featured" class="mb-4 border-border border-b pb-4">
398
+ <nav
399
+ aria-label={navStrings.featured}
400
+ class="mb-4 border-border border-b pb-4"
401
+ >
389
402
  <ul class="m-0 list-none p-0">
390
403
  {navigation.featured.map((link) => {
391
404
  const external = /^https?:\/\//u.test(link.href);
@@ -431,7 +444,7 @@ const bannerScript = banner?.dismissible
431
444
  // from opening blank.
432
445
  navigation.tabs.length > 0 && (
433
446
  <nav
434
- aria-label="Sections"
447
+ aria-label={navStrings.sections}
435
448
  class:list={[
436
449
  "mb-4 border-border border-b pb-4",
437
450
  sidebar.length > 0 && "md:hidden",
@@ -463,14 +476,26 @@ const bannerScript = banner?.dismissible
463
476
  MobileNavSlot ? (
464
477
  <>
465
478
  <div class="lg:hidden">
466
- <MobileNavSlot currentRoute={page.route} items={sidebar} />
479
+ <MobileNavSlot
480
+ currentRoute={page.route}
481
+ items={sidebar}
482
+ strings={navStrings}
483
+ />
467
484
  </div>
468
485
  <div class="hidden lg:block">
469
- <SidebarSlot currentRoute={page.route} items={sidebar} />
486
+ <SidebarSlot
487
+ currentRoute={page.route}
488
+ items={sidebar}
489
+ strings={navStrings}
490
+ />
470
491
  </div>
471
492
  </>
472
493
  ) : (
473
- <SidebarSlot currentRoute={page.route} items={sidebar} />
494
+ <SidebarSlot
495
+ currentRoute={page.route}
496
+ items={sidebar}
497
+ strings={navStrings}
498
+ />
474
499
  )
475
500
  }
476
501
  </nav>
@@ -542,7 +567,7 @@ const bannerScript = banner?.dismissible
542
567
  )
543
568
  }
544
569
  <button
545
- aria-label="Close navigation"
570
+ aria-label={navStrings.closeNavigation}
546
571
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
547
572
  data-blume-nav-toggle
548
573
  type="button"
@@ -559,12 +584,45 @@ const bannerScript = banner?.dismissible
559
584
  // Tree-shaken out of production builds.
560
585
  import "./hydration-hint.ts";
561
586
 
587
+ // The closed mobile drawer is only translated off-canvas, so its links
588
+ // would stay in the tab order on every page. Mirror the header's
589
+ // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
590
+ // below `lg` (64rem), where the same element isn't the static sidebar.
591
+ const drawer = document.querySelector<HTMLElement>(
592
+ "[data-blume-nav-drawer]"
593
+ );
594
+ if (drawer) {
595
+ const desktop = window.matchMedia("(min-width: 64rem)");
596
+ const syncDrawer = () => {
597
+ const hidden =
598
+ !desktop.matches &&
599
+ !document.documentElement.hasAttribute("data-blume-nav-open");
600
+ drawer.inert = hidden;
601
+ if (hidden) {
602
+ drawer.setAttribute("aria-hidden", "true");
603
+ } else {
604
+ drawer.removeAttribute("aria-hidden");
605
+ }
606
+ };
607
+ syncDrawer();
608
+ desktop.addEventListener("change", syncDrawer);
609
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
610
+ attributeFilter: ["data-blume-nav-open"],
611
+ });
612
+ }
613
+
562
614
  const svg = (name: string) =>
563
615
  `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>`;
564
616
 
565
617
  const buttonClass =
566
618
  "absolute right-3 inline-flex size-7 items-center justify-center rounded-full bg-transparent text-muted-foreground opacity-70 transition hover:bg-muted hover:text-foreground hover:opacity-100 focus-visible:opacity-100";
567
619
 
620
+ // Localized copy-button label, stamped on <body> by the layout markup
621
+ // (the Search.astro data-attribute channel) since this bundled script
622
+ // can't interpolate server values directly.
623
+ const copyCodeLabel =
624
+ document.body.getAttribute("data-i18n-copy-code") || "Copy code";
625
+
568
626
  const languageLabels: Record<string, string> = {
569
627
  astro: "Astro",
570
628
  bash: "Bash",
@@ -612,7 +670,7 @@ const bannerScript = banner?.dismissible
612
670
  : "top-2";
613
671
  button.className = `${buttonClass} ${topClass}`;
614
672
  button.setAttribute("data-blume-copy", "");
615
- button.setAttribute("aria-label", "Copy code");
673
+ button.setAttribute("aria-label", copyCodeLabel);
616
674
  button.innerHTML = svg("copy");
617
675
  button.addEventListener("click", async () => {
618
676
  const code = pre.querySelector("code");
@@ -33,8 +33,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
33
33
  <blume-search
34
34
  class="contents"
35
35
  data-ask={askEnabled ? "" : undefined}
36
+ data-i18n-ask={s.askAi}
37
+ data-i18n-ask-hint={s.askAiHint}
36
38
  data-i18n-dev={s.devOnly}
37
39
  data-i18n-empty={s.noResults}
40
+ data-i18n-error={s.error}
41
+ data-i18n-popular={s.popular}
42
+ data-i18n-results={s.results}
38
43
  data-locale={locale || undefined}
39
44
  >
40
45
  <button
@@ -62,7 +67,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
62
67
  <input
63
68
  aria-label={s.label}
64
69
  autocomplete="off"
65
- class="flex-1 border-0 bg-transparent text-foreground text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
70
+ class="flex-1 border-0 bg-transparent text-foreground text-sm pointer-coarse:text-base focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
66
71
  data-blume-search-input
67
72
  placeholder={s.placeholder}
68
73
  type="search"
@@ -119,15 +124,17 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
119
124
  <span class="flex items-center gap-1">
120
125
  <kbd class={`${kbd} text-[0.65rem]`}>↑</kbd>
121
126
  <kbd class={`${kbd} text-[0.65rem]`}>↓</kbd>
122
- navigate
127
+ {s.navigate}
123
128
  </span>
124
129
  <span class="flex items-center gap-1">
125
130
  <kbd class={`${kbd} text-[0.65rem]`}>↵</kbd>
126
- open
131
+ {s.open}
127
132
  </span>
128
133
  <span class="flex items-center gap-1 max-md:hidden">
129
- <kbd class={`${kbd} text-[0.65rem]`}>⌘J</kbd>
130
- preview
134
+ <kbd class={`${kbd} text-[0.65rem]`} data-blume-search-preview-kbd
135
+ >⌘J</kbd
136
+ >
137
+ {s.preview}
131
138
  </span>
132
139
  </div>
133
140
  </div>
@@ -189,6 +196,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
189
196
  preview!: HTMLElement;
190
197
  searchFn: SearchFn | null = null;
191
198
  loaded = false;
199
+ loadFailed = false;
200
+ /** First-open client/index load still in flight. */
201
+ loading = false;
192
202
  askEnabled = false;
193
203
  popular: PopularPage[] = [];
194
204
  selectables: Selectable[] = [];
@@ -198,6 +208,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
198
208
  previewOn = true;
199
209
  devOnlyMsg = "Search is available in the production build.";
200
210
  noResultsMsg = "No results found.";
211
+ errorMsg = "Something went wrong. Please try again.";
212
+ askMsg = "Ask AI";
213
+ askHintMsg = "Get an instant answer from AI";
214
+ popularMsg = "Popular";
215
+ resultsMsg = "Results";
201
216
  // The active locale to filter to (null when i18n is off), and whether the
202
217
  // reader has opted to search across every language instead.
203
218
  locale: string | null = null;
@@ -208,6 +223,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
208
223
  this.getAttribute("data-i18n-dev") || this.devOnlyMsg;
209
224
  this.noResultsMsg =
210
225
  this.getAttribute("data-i18n-empty") || this.noResultsMsg;
226
+ this.errorMsg = this.getAttribute("data-i18n-error") || this.errorMsg;
227
+ this.askMsg = this.getAttribute("data-i18n-ask") || this.askMsg;
228
+ this.askHintMsg =
229
+ this.getAttribute("data-i18n-ask-hint") || this.askHintMsg;
230
+ this.popularMsg =
231
+ this.getAttribute("data-i18n-popular") || this.popularMsg;
232
+ this.resultsMsg =
233
+ this.getAttribute("data-i18n-results") || this.resultsMsg;
211
234
  this.locale = this.getAttribute("data-locale");
212
235
  this.dialog = this.querySelector("[data-blume-search-dialog]")!;
213
236
  this.input = this.querySelector("[data-blume-search-input]")!;
@@ -250,12 +273,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
250
273
  });
251
274
  }
252
275
 
253
- // The handler acceptsK and Ctrl+K; show the right one per platform.
276
+ // The handlers accept both ⌘ and Ctrl chords; show the right modifier
277
+ // per platform on the button hint and the footer's preview hint.
278
+ const isApple = /mac|iphone|ipad|ipod/iu.test(navigator.platform);
254
279
  const hint = this.querySelector("[data-blume-search-kbd]");
255
280
  if (hint) {
256
- hint.textContent = /mac|iphone|ipad|ipod/iu.test(navigator.platform)
257
- ? "⌘K"
258
- : "Ctrl K";
281
+ hint.textContent = isApple ? "⌘K" : "Ctrl K";
282
+ }
283
+ const previewHint = this.querySelector(
284
+ "[data-blume-search-preview-kbd]"
285
+ );
286
+ if (previewHint) {
287
+ previewHint.textContent = isApple ? "⌘J" : "Ctrl J";
259
288
  }
260
289
 
261
290
  this.querySelector("[data-blume-search-open]")?.addEventListener(
@@ -301,6 +330,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
301
330
  this.input.focus();
302
331
  this.input.select();
303
332
  if (!this.loaded) {
333
+ this.loading = true;
304
334
  try {
305
335
  const { createSearch } = await import("blume:search-client");
306
336
  this.searchFn = await createSearch();
@@ -308,8 +338,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
308
338
  // fetching the index) must retry on the next open, not disable
309
339
  // search until a full page reload.
310
340
  this.loaded = true;
341
+ this.loadFailed = false;
311
342
  } catch {
312
343
  this.searchFn = null;
344
+ // In dev a client can be missing by design (Pagefind's bundle
345
+ // only exists in the production build) — that's the "dev only"
346
+ // hint. The same failure in production is a real error.
347
+ this.loadFailed = !import.meta.env.DEV;
348
+ } finally {
349
+ this.loading = false;
313
350
  }
314
351
  }
315
352
  this.render();
@@ -322,7 +359,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
322
359
  } else if (event.key === "ArrowUp") {
323
360
  event.preventDefault();
324
361
  this.move(-1);
325
- } else if (event.key === "Enter") {
362
+ } else if (event.key === "Enter" && !event.isComposing) {
363
+ // `isComposing` guards IME input: Enter confirming a CJK conversion
364
+ // must commit the text, not activate the selected result.
326
365
  const item = this.selectables[this.selectedIndex];
327
366
  if (item) {
328
367
  event.preventDefault();
@@ -361,16 +400,36 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
361
400
  if (!this.searchFn) {
362
401
  this.renderFilters([]);
363
402
  this.clearPreview();
364
- this.setMessage(this.devOnlyMsg);
403
+ if (this.loading) {
404
+ // Typing while the first-open load (client import + index fetch)
405
+ // is still in flight: the dev-only hint would be wrong in
406
+ // production and the error message premature. Show a neutral
407
+ // placeholder; `open()` re-renders once the load settles.
408
+ this.setMessage("…");
409
+ } else {
410
+ this.setMessage(this.loadFailed ? this.errorMsg : this.devOnlyMsg);
411
+ }
365
412
  return;
366
413
  }
367
414
 
368
415
  const localeFilter =
369
416
  this.locale && !this.allLocales ? this.locale : undefined;
370
- const result = await this.searchFn(query, {
371
- locale: localeFilter,
372
- section: this.activeSection ?? undefined,
373
- });
417
+ let result: Awaited<ReturnType<SearchFn>>;
418
+ try {
419
+ result = await this.searchFn(query, {
420
+ locale: localeFilter,
421
+ section: this.activeSection ?? undefined,
422
+ });
423
+ } catch {
424
+ // A hosted provider can reject (network error, outage); the results
425
+ // list is already cleared, so show a message instead of a blank pane.
426
+ if (generation === this.renderGeneration) {
427
+ this.renderFilters([]);
428
+ this.clearPreview();
429
+ this.setMessage(this.errorMsg);
430
+ }
431
+ return;
432
+ }
374
433
  // Any newer render — a keystroke, a section pill, a locale toggle —
375
434
  // supersedes this one mid-await, even for the same query text;
376
435
  // appending the stale hits would duplicate rows and desync selection.
@@ -378,17 +437,30 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
378
437
  return;
379
438
  }
380
439
 
440
+ // A section picked for an earlier query can be missing from the new
441
+ // pool — and when the pool has fewer than two sections the pills that
442
+ // would clear it are hidden too, so the stale filter would silently
443
+ // empty the results. Drop it and search again unfiltered.
444
+ if (
445
+ this.activeSection &&
446
+ !result.sections.some((s) => s.label === this.activeSection)
447
+ ) {
448
+ this.activeSection = null;
449
+ this.render();
450
+ return;
451
+ }
452
+
381
453
  this.renderFilters(result.sections);
382
454
 
383
455
  if (this.askEnabled) {
384
- const group = this.addGroup("Ask AI");
456
+ const group = this.addGroup(this.askMsg);
385
457
  const ask = this.createAskRow(query);
386
458
  group.appendChild(ask.el);
387
459
  this.selectables.push(ask);
388
460
  }
389
461
 
390
462
  if (result.hits.length > 0) {
391
- const group = this.addGroup("Results");
463
+ const group = this.addGroup(this.resultsMsg);
392
464
  for (const hit of result.hits) {
393
465
  const item = this.createHitRow(hit, query);
394
466
  group.appendChild(item.el);
@@ -403,13 +475,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
403
475
 
404
476
  renderEmpty() {
405
477
  if (this.askEnabled) {
406
- const group = this.addGroup("Ask AI");
478
+ const group = this.addGroup(this.askMsg);
407
479
  const ask = this.createAskRow("");
408
480
  group.appendChild(ask.el);
409
481
  this.selectables.push(ask);
410
482
  }
411
483
  if (this.popular.length > 0) {
412
- const group = this.addGroup("Popular");
484
+ const group = this.addGroup(this.popularMsg);
413
485
  for (const page of this.popular) {
414
486
  const item = this.createLinkRow(page.route, page.label);
415
487
  group.appendChild(item.el);
@@ -464,13 +536,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
464
536
  el.type = "button";
465
537
  el.className = `${ROW_CLASS} ${MARK}`;
466
538
  const title = query
467
- ? `Ask AI: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
468
- : "Ask AI";
539
+ ? `${escapeHtml(this.askMsg)}: <span class="text-muted-foreground">“${escapeHtml(query)}”</span>`
540
+ : escapeHtml(this.askMsg);
469
541
  el.innerHTML = `
470
542
  <span class="mt-0.5 shrink-0 text-accent">${svg("sparkles")}</span>
471
543
  <span class="flex-1">
472
544
  <span class="block truncate font-normal text-foreground text-sm">${title}</span>
473
- <span class="block truncate text-muted-foreground text-sm">Get an instant answer from AI</span>
545
+ <span class="block truncate text-muted-foreground text-sm">${escapeHtml(this.askHintMsg)}</span>
474
546
  </span>`;
475
547
  const item: Selectable = { el, kind: "ask" };
476
548
  this.bindRow(item);