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
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "../Icon.astro";
3
+ import { contentHref } from "./base-href.ts";
3
4
 
4
5
  const { description, href, title } = Astro.props;
5
6
  const Tag = href ? "a" : "div";
@@ -11,7 +12,7 @@ const previewPattern =
11
12
  <Tag
12
13
  class="not-prose block overflow-hidden rounded-blume border border-border bg-background text-inherit no-underline! transition-colors hover:no-underline! hover:[&[href]]:border-accent"
13
14
  data-blume-tile
14
- href={href}
15
+ href={href === undefined ? undefined : contentHref(href)}
15
16
  rel={external ? "noreferrer" : undefined}
16
17
  target={external ? "_blank" : undefined}
17
18
  >
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
+ import { contentHref } from "./base-href.ts";
5
+
4
6
  interface Props {
5
7
  cta?: string;
6
8
  headline?: string;
@@ -43,7 +45,7 @@ const labelHtml = Astro.slots.has("default")
43
45
  cta && href && (
44
46
  <a
45
47
  class="mt-2 inline-flex font-medium text-accent text-xs hover:underline"
46
- href={href}
48
+ href={contentHref(href)}
47
49
  rel={external ? "noreferrer" : undefined}
48
50
  target={external ? "_blank" : undefined}
49
51
  >
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { contentHref } from "./base-href.ts";
3
+
2
4
  interface RssMetadata {
3
5
  description?: string;
4
6
  title?: string;
@@ -6,7 +8,12 @@ interface RssMetadata {
6
8
 
7
9
  interface Props {
8
10
  description?: string;
9
- /** Link the heading to a dedicated page; falls back to the in-page anchor. */
11
+ /**
12
+ * Link the heading to a dedicated page; falls back to the in-page anchor.
13
+ * A root-relative route gains the composed `deployment.base` + `basePath`
14
+ * prefix at emit time (like a markdown link); anchors and external URLs
15
+ * pass through.
16
+ */
10
17
  href?: string;
11
18
  id?: string;
12
19
  label?: string;
@@ -47,7 +54,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
47
54
  <header class="md:border-border md:border-e md:pe-4">
48
55
  <a
49
56
  class="font-semibold text-foreground text-sm no-underline hover:text-accent"
50
- href={href ?? `#${id}`}
57
+ href={contentHref(href ?? `#${id}`)}
51
58
  >
52
59
  {updateLabel}
53
60
  </a>
@@ -144,7 +144,13 @@ export const extractTypeTable = async (
144
144
  default: defaultValue || undefined,
145
145
  description: description || undefined,
146
146
  name: symbol.getName(),
147
- required: !signature?.questionToken,
147
+ // Optionality lives on the checker's symbol, not the backing
148
+ // declaration: mapped/utility types synthesize members whose
149
+ // declaration has no question token (`Partial<Base>` would read as
150
+ // all-required), and `Required<Base>` strips optionality while the
151
+ // declaration keeps its `?` — the token is wrong in both directions.
152
+ // oxlint-disable-next-line no-bitwise -- SymbolFlags is a bitfield
153
+ required: (symbol.flags & ts.SymbolFlags.Optional) === 0,
148
154
  type: typeText,
149
155
  };
150
156
  });
@@ -0,0 +1,33 @@
1
+ import data from "blume:data";
2
+
3
+ import {
4
+ isInternalPath,
5
+ normalizeBasePath,
6
+ withComposedBasePath,
7
+ } from "../../core/base-path.ts";
8
+
9
+ // Mirrors `markdown/base-links.ts`: a path whose final segment carries a file
10
+ // extension is a `public/` asset, served at the site root and never moved
11
+ // under `basePath`.
12
+ const ASSET_PATH = /\.[a-z0-9]+$/iu;
13
+
14
+ /** Strip any `#fragment`/`?query` so only the path is extension-tested. */
15
+ const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
16
+
17
+ /**
18
+ * Rebase a component-emitted `href` the way `markdown/base-links.ts` rebases
19
+ * `[x](/guide)`: a root-relative internal page link gains the composed
20
+ * `deployment.base` + `basePath` prefix, so authors write component links
21
+ * (`<Card href="/guide">`) under the same "as if mounted at root" contract as
22
+ * markdown links. Idempotent per layer (a hand-written `/docs/x` isn't
23
+ * double-prefixed) and inert for external URLs, fragments, relative paths, and
24
+ * asset links.
25
+ */
26
+ export const contentHref = (href: string): string =>
27
+ isInternalPath(href) && !ASSET_PATH.test(pathOf(href))
28
+ ? withComposedBasePath(
29
+ normalizeBasePath(import.meta.env.BASE_URL),
30
+ data.config.basePath,
31
+ href
32
+ )
33
+ : href;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Client behaviour for the `<blume-changelog>` custom element wrapping the
2
+ * Client behavior for the `<blume-changelog>` custom element wrapping the
3
3
  * generated changelog timeline when its releases are semver-versioned. The
4
4
  * newest major line stays visible; every older major is collapsed into a group
5
5
  * revealed one major at a time by the "Show N.x releases" button at the bottom.
@@ -24,6 +24,10 @@ class BlumeChangelog extends HTMLElement {
24
24
  return;
25
25
  }
26
26
 
27
+ // Localized button template from the generated page markup (the
28
+ // data-attribute channel); `{version}` is replaced with the major line.
29
+ const template = this.dataset.i18nMore || "Show {version} releases";
30
+
27
31
  for (const group of groups) {
28
32
  group.hidden = true;
29
33
  // Focusable only programmatically, so revealing a group can move focus to
@@ -35,7 +39,10 @@ class BlumeChangelog extends HTMLElement {
35
39
  const sync = () => {
36
40
  const next = groups[revealed];
37
41
  if (next) {
38
- button.textContent = `Show ${next.dataset.changelogLabel} releases`;
42
+ button.textContent = template.replace(
43
+ "{version}",
44
+ next.dataset.changelogLabel ?? ""
45
+ );
39
46
  button.hidden = false;
40
47
  } else {
41
48
  button.hidden = true;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Client behaviour for the `<blume-mermaid>` custom element emitted by the
2
+ * Client behavior for the `<blume-mermaid>` custom element emitted by the
3
3
  * Mermaid markdown plugin. Mermaid is lazy-loaded — it needs a DOM and is large,
4
4
  * so the dependency only downloads on pages that actually contain a diagram —
5
5
  * and each diagram re-renders when the color theme flips so it tracks light/dark.
@@ -61,7 +61,12 @@ class BlumeMermaid extends HTMLElement {
61
61
  output.innerHTML = svg;
62
62
  }
63
63
  } catch {
64
- output.textContent = "Could not render this diagram.";
64
+ // Localized message stamped on <body> by RootLayout's markup (the
65
+ // data-attribute channel); English fallback when the attribute is
66
+ // missing (a stale snapshot or a custom layout).
67
+ output.textContent =
68
+ document.body.dataset.i18nDiagramError ||
69
+ "Could not render this diagram.";
65
70
  }
66
71
  output.removeAttribute("aria-busy");
67
72
  };
@@ -63,13 +63,16 @@ const icons = {
63
63
  }
64
64
 
65
65
  /* The table of contents only shows at xl; reclaim its column for the article
66
- while the panel is open so the shrunken content still has room to breathe. */
66
+ while the panel is open so the shrunken content still has room to breathe.
67
+ Scoped to grids that actually have a TOC column: a "bare" layout (the
68
+ changelog index) is single-column, and forcing the sidebar tracks onto it
69
+ would squeeze its only item — <main> — into the 17.5rem track. */
67
70
  @media (min-width: 1280px) {
68
71
  body[data-blume-ask="open"] [data-blume-toc] {
69
72
  display: none;
70
73
  }
71
74
 
72
- body[data-blume-ask="open"] [data-blume-doc-grid] {
75
+ body[data-blume-ask="open"] [data-blume-doc-grid]:has([data-blume-toc]) {
73
76
  grid-template-columns: 17.5rem minmax(0, 1fr);
74
77
  }
75
78
  }
@@ -5,7 +5,7 @@ import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
 
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
- import { joinBase, stripBase } from "./base-path.ts";
8
+ import { joinBase, prefixBase, stripBase } from "./base-path.ts";
9
9
 
10
10
  interface ChatMessage {
11
11
  content: string;
@@ -43,6 +43,7 @@ const EMPTY_ICONS: AskIcons = {
43
43
 
44
44
  // English fallback so the island renders even if no dictionary is passed.
45
45
  const DEFAULT_ASK: UIStrings["ask"] = {
46
+ ai: "AI",
46
47
  clear: "Clear conversation",
47
48
  close: "Close",
48
49
  copy: "Copy conversation",
@@ -53,6 +54,7 @@ const DEFAULT_ASK: UIStrings["ask"] = {
53
54
  send: "Send",
54
55
  tip: "Tip: You can open and close chat with",
55
56
  title: "Ask AI",
57
+ you: "You",
56
58
  };
57
59
 
58
60
  let idCounter = 0;
@@ -72,6 +74,17 @@ const currentPath = (): string =>
72
74
  // GitHub-flavored markdown with soft line breaks, matching how the docs read.
73
75
  marked.setOptions({ breaks: true, gfm: true });
74
76
 
77
+ // The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
78
+ // link targets to served URLs so citations resolve under `deployment.base`.
79
+ // `prefixBase` leaves external URLs and fragments untouched and is idempotent.
80
+ marked.use({
81
+ walkTokens: (token) => {
82
+ if (token.type === "link") {
83
+ token.href = prefixBase(import.meta.env.BASE_URL, token.href);
84
+ }
85
+ },
86
+ });
87
+
75
88
  const renderMarkdown = (content: string): string =>
76
89
  DOMPurify.sanitize(marked.parse(content, { async: false }));
77
90
 
@@ -97,6 +110,14 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
97
110
  // Stable empty default so an unset `suggestions` prop doesn't re-render.
98
111
  const EMPTY_SUGGESTIONS: Suggestion[] = [];
99
112
 
113
+ // The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
114
+ // platform (same detection Search.astro uses for its ⌘K hint). Guarded so the
115
+ // island still server-renders, where `navigator` doesn't exist; the hint itself
116
+ // only renders client-side, inside the portaled panel.
117
+ const IS_APPLE =
118
+ typeof navigator !== "undefined" &&
119
+ /mac|iphone|ipad|ipod/iu.test(navigator.platform);
120
+
100
121
  // Ghost icon button, matching the header's theme toggle and repo link.
101
122
  const TRIGGER_CLASS =
102
123
  "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
@@ -120,7 +141,9 @@ const AskAI = ({
120
141
  strings?: UIStrings["ask"];
121
142
  suggestions?: Suggestion[];
122
143
  }) => {
123
- const t = strings ?? DEFAULT_ASK;
144
+ // Merge per key (not `strings ?? …`) so a dictionary from a stale snapshot
145
+ // that predates newer keys still resolves every label to its English default.
146
+ const t = { ...DEFAULT_ASK, ...strings };
124
147
  const [mounted, setMounted] = useState(false);
125
148
  const [open, setOpen] = useState(false);
126
149
  const [input, setInput] = useState("");
@@ -128,6 +151,9 @@ const AskAI = ({
128
151
  const [busy, setBusy] = useState(false);
129
152
  const inputRef = useRef<HTMLTextAreaElement>(null);
130
153
  const scrollRef = useRef<HTMLDivElement>(null);
154
+ const triggerRef = useRef<HTMLButtonElement>(null);
155
+ // Where focus came from when the panel opened, restored on close.
156
+ const returnFocusRef = useRef<HTMLElement | null>(null);
131
157
 
132
158
  // Portal target (document.body) only exists after mount; guards SSR. The
133
159
  // one-time false→true flip is deliberate, so the initial `false` is required.
@@ -164,10 +190,24 @@ const AskAI = ({
164
190
  // Drive the desktop content push from a body attribute (see AskAI.astro CSS).
165
191
  useEffect(() => {
166
192
  if (open) {
193
+ returnFocusRef.current =
194
+ document.activeElement instanceof HTMLElement
195
+ ? document.activeElement
196
+ : null;
167
197
  document.body.dataset.blumeAsk = "open";
168
198
  inputRef.current?.focus();
169
199
  } else {
170
200
  delete document.body.dataset.blumeAsk;
201
+ // Return focus to the element that opened the panel (or the trigger when
202
+ // it's gone), so closing doesn't strand keyboard focus in an inert tree.
203
+ // `returnFocusRef` is only set on open, so initial mount is a no-op.
204
+ if (returnFocusRef.current) {
205
+ const target = returnFocusRef.current.isConnected
206
+ ? returnFocusRef.current
207
+ : triggerRef.current;
208
+ returnFocusRef.current = null;
209
+ target?.focus();
210
+ }
171
211
  }
172
212
  return () => {
173
213
  delete document.body.dataset.blumeAsk;
@@ -244,7 +284,13 @@ const AskAI = ({
244
284
  };
245
285
 
246
286
  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
247
- if (event.key === "Enter" && !event.shiftKey) {
287
+ // `isComposing` guards IME input: Enter confirming a CJK conversion must
288
+ // commit the text, not submit the question.
289
+ if (
290
+ event.key === "Enter" &&
291
+ !event.shiftKey &&
292
+ !event.nativeEvent.isComposing
293
+ ) {
248
294
  event.preventDefault();
249
295
  void runQuestion(input);
250
296
  }
@@ -252,7 +298,7 @@ const AskAI = ({
252
298
 
253
299
  const copyConversation = () => {
254
300
  const text = messages
255
- .map((m) => `${m.role === "user" ? "You" : "AI"}: ${m.content}`)
301
+ .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
256
302
  .join("\n\n");
257
303
  void navigator.clipboard?.writeText(text);
258
304
  };
@@ -263,6 +309,9 @@ const AskAI = ({
263
309
  <aside
264
310
  aria-hidden={open ? undefined : "true"}
265
311
  aria-label={t.title}
312
+ // The closed panel is only translated off-screen; `inert` drops its
313
+ // buttons/textarea from the tab order and the accessibility tree.
314
+ inert={!open}
266
315
  className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
267
316
  open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
268
317
  }`}
@@ -355,7 +404,7 @@ const AskAI = ({
355
404
  <p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
356
405
  {t.tip}
357
406
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
358
- ⌘
407
+ {IS_APPLE ? "⌘" : "Ctrl"}
359
408
  </kbd>
360
409
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
361
410
  I
@@ -371,7 +420,7 @@ const AskAI = ({
371
420
  >
372
421
  <textarea
373
422
  aria-label={t.label}
374
- className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm outline-none placeholder:text-muted-foreground"
423
+ className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm pointer-coarse:text-base outline-none placeholder:text-muted-foreground"
375
424
  onChange={(event) => setInput(event.target.value)}
376
425
  onKeyDown={onInputKeyDown}
377
426
  placeholder={t.placeholder}
@@ -398,6 +447,7 @@ const AskAI = ({
398
447
  aria-label={t.title}
399
448
  className={TRIGGER_CLASS}
400
449
  onClick={() => setOpen((value) => !value)}
450
+ ref={triggerRef}
401
451
  type="button"
402
452
  >
403
453
  <Glyph path={icons.chat} size={18} />
@@ -89,23 +89,36 @@ export const useSearch = (): UseSearch => {
89
89
  const [results, setResults] = useState<SearchResult | null>(null);
90
90
  const [loading, setLoading] = useState(false);
91
91
  const searchFn = useRef<SearchFn | null>(null);
92
+ const generation = useRef(0);
92
93
 
93
94
  // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
94
95
  // this useCallback keeps a stable `search` identity for consumers that use it
95
96
  // as an effect/memo dependency. With the compiler on it's redundant but inert.
96
97
  // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
97
98
  const search = useCallback<UseSearch["search"]>(async (query, options) => {
98
- if (!searchFn.current) {
99
- const { createSearch } = await import("blume:search-client");
100
- searchFn.current = await createSearch();
101
- }
99
+ // Stale-response guard, mirroring the built-in dialog's renderGeneration:
100
+ // provider responses can land out of order, so only the latest call may
101
+ // commit results or clear `loading` — otherwise the last response to land
102
+ // wins over the last query typed ("a" clobbering "ab").
103
+ generation.current += 1;
104
+ const { current } = generation;
105
+ // Before the lazy client import: the first search's heaviest phase is
106
+ // creating the provider client (index download), and it must show loading.
102
107
  setLoading(true);
103
108
  try {
109
+ if (!searchFn.current) {
110
+ const { createSearch } = await import("blume:search-client");
111
+ searchFn.current = await createSearch();
112
+ }
104
113
  const result = await searchFn.current(query, options);
105
- setResults(result);
114
+ if (current === generation.current) {
115
+ setResults(result);
116
+ }
106
117
  return result;
107
118
  } finally {
108
- setLoading(false);
119
+ if (current === generation.current) {
120
+ setLoading(false);
121
+ }
109
122
  }
110
123
  }, []);
111
124
 
@@ -128,6 +141,9 @@ export interface UseAskAI {
128
141
 
129
142
  const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
130
143
 
144
+ /** Shown as the assistant's answer when the request fails or throws. */
145
+ const ASK_ERROR = "Something went wrong answering that. Please try again.";
146
+
131
147
  /** The current route with the deployment base stripped, for page grounding. */
132
148
  const currentPath = (): string =>
133
149
  stripBase(import.meta.env.BASE_URL, window.location.pathname);
@@ -169,8 +185,7 @@ export const useAskAI = (): UseAskAI => {
169
185
  if (!response.ok) {
170
186
  // An error body (JSON, HTML error page) must not stream in as the
171
187
  // assistant's answer.
172
- assistant.content =
173
- "Something went wrong answering that. Please try again.";
188
+ assistant.content = ASK_ERROR;
174
189
  setMessages([...history, { ...assistant }]);
175
190
  return;
176
191
  }
@@ -195,6 +210,11 @@ export const useAskAI = (): UseAskAI => {
195
210
  }
196
211
  }
197
212
  }
213
+ } catch {
214
+ // A thrown fetch (offline, DNS failure, CORS) must not strand the
215
+ // pre-appended empty assistant message as a stuck placeholder.
216
+ assistant.content = ASK_ERROR;
217
+ setMessages([...history, { ...assistant }]);
198
218
  } finally {
199
219
  setLoading(false);
200
220
  }
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
2
4
  import { withBase } from "../islands/base-path.ts";
3
5
  import Icon from "../Icon.astro";
4
6
 
@@ -14,9 +16,15 @@ interface Props {
14
16
  key: string;
15
17
  }
16
18
  | null;
19
+ /** Localized banner labels. */
20
+ strings?: UIStrings["banner"];
17
21
  }
18
22
 
19
- const { banner } = Astro.props;
23
+ const { banner, strings } = Astro.props;
24
+ // Merge over the English baseline per key (rather than `strings ?? …`) so a
25
+ // partial — or missing — strings object still resolves every label to a
26
+ // default, matching the pattern PageFeedback and Search use.
27
+ const b = { ...EN_UI.banner, ...strings };
20
28
  ---
21
29
 
22
30
  {
@@ -38,7 +46,7 @@ const { banner } = Astro.props;
38
46
  )}
39
47
  {banner.dismissible && (
40
48
  <button
41
- aria-label="Dismiss announcement"
49
+ aria-label={b.dismiss}
42
50
  class="absolute end-1.5 inline-flex size-7 items-center justify-center rounded-full text-background/80 transition-colors hover:bg-white/10 hover:text-background dark:text-foreground/70 dark:hover:bg-foreground/10 dark:hover:text-foreground"
43
51
  data-blume-banner-dismiss
44
52
  type="button"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { withBase } from "../islands/base-path.ts";
3
3
  import type { ComponentOverride } from "../../core/define-components.ts";
4
+ import { EN_UI } from "../../core/i18n-ui.ts";
4
5
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
6
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
6
7
  import { GITHUB_MARK } from "../github-mark.ts";
@@ -37,6 +38,8 @@ interface Props {
37
38
  hasDrawer?: boolean;
38
39
  searchStrings?: UIStrings["search"];
39
40
  switcherStrings?: UIStrings["languageSwitcher"];
41
+ /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
42
+ navStrings?: UIStrings["nav"];
40
43
  localeSwitch?: LocaleSwitchOption[];
41
44
  /** Active locale for per-language search filtering. */
42
45
  searchLocale?: string;
@@ -59,11 +62,17 @@ const {
59
62
  hasDrawer = true,
60
63
  searchStrings,
61
64
  switcherStrings,
65
+ navStrings,
62
66
  localeSwitch,
63
67
  searchLocale,
64
68
  layout = {},
65
69
  } = Astro.props;
66
70
 
71
+ // Merge over the English defaults so a label missing from a translation (or
72
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
73
+ // blank — the PageActions pattern.
74
+ const n = { ...EN_UI.nav, ...navStrings };
75
+
67
76
  const LogoSlot = resolveSlot(layout.Logo, Logo);
68
77
  const SearchSlot = resolveSlot(layout.Search, Search);
69
78
 
@@ -109,7 +118,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
109
118
  {
110
119
  showNavToggle && (
111
120
  <button
112
- aria-label="Toggle navigation"
121
+ aria-label={n.toggleNavigation}
113
122
  class={`${iconButton} lg:hidden`}
114
123
  data-blume-nav-toggle
115
124
  type="button"
@@ -130,7 +139,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
130
139
  }
131
140
  {
132
141
  navigation.tabs.length > 0 && (
133
- <nav aria-label="Sections" class={tabsNavClass}>
142
+ <nav aria-label={n.sections} class={tabsNavClass}>
134
143
  {navigation.tabs.map((tab) => (
135
144
  <a
136
145
  aria-current={
@@ -172,7 +181,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
172
181
  {
173
182
  navigation.repoUrl && (
174
183
  <a
175
- aria-label="GitHub repository"
184
+ aria-label={n.githubRepository}
176
185
  class={iconButton}
177
186
  href={navigation.repoUrl}
178
187
  rel="noreferrer"
@@ -191,7 +200,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
191
200
  )
192
201
  }
193
202
  <button
194
- aria-label="Toggle color theme"
203
+ aria-label={n.toggleTheme}
195
204
  class={iconButton}
196
205
  data-blume-theme-toggle
197
206
  type="button"
@@ -43,11 +43,19 @@ const brandText = logo?.text ?? site.title;
43
43
  !logoSvg &&
44
44
  logoLight &&
45
45
  (logoLight === logoDark ? (
46
- <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
46
+ <img alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
47
47
  ) : (
48
48
  <>
49
- <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
50
- <img alt={logoAlt} class="hidden h-5 w-auto dark:block" src={logoDark} />
49
+ <img
50
+ alt={logoAlt}
51
+ class="h-5 w-auto dark:hidden"
52
+ src={withBase(logoLight)}
53
+ />
54
+ <img
55
+ alt={logoAlt}
56
+ class="hidden h-5 w-auto dark:block"
57
+ src={withBase(logoDark)}
58
+ />
51
59
  </>
52
60
  ))
53
61
  }
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
2
4
  import type { NavNode } from "../../core/types.ts";
3
5
  import Icon from "../Icon.astro";
4
6
  import { withBase } from "../islands/base-path.ts";
@@ -22,6 +24,8 @@ interface Props {
22
24
  idPrefix?: string;
23
25
  /** True only for the top-level call; renders the `<blume-nav>` panel stack. */
24
26
  root?: boolean;
27
+ /** Localized labels ("Back", the deprecated badge); English when omitted. */
28
+ strings?: UIStrings["nav"];
25
29
  }
26
30
 
27
31
  const {
@@ -30,12 +34,18 @@ const {
30
34
  depth = 0,
31
35
  idPrefix = "n",
32
36
  root = depth === 0,
37
+ strings,
33
38
  } = Astro.props;
34
39
 
40
+ // Merge over the English defaults so a label missing from a translation (or
41
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
42
+ // blank — the PageActions pattern.
43
+ const n = { ...EN_UI.nav, ...strings };
44
+
35
45
  const badgeBase =
36
46
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
37
47
  const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
38
- // HTTP-method badges (from an OpenAPI reference's sidebar) are colour-coded;
48
+ // HTTP-method badges (from an OpenAPI reference's sidebar) are color-coded;
39
49
  // every other badge keeps the neutral style.
40
50
  const METHOD_BADGE: Record<string, string> = {
41
51
  DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
@@ -114,13 +124,14 @@ const initialId =
114
124
  idPrefix={idPrefix}
115
125
  items={items}
116
126
  root={false}
127
+ strings={n}
117
128
  />
118
129
  </div>
119
130
  {panels.map((panel) => (
120
131
  <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
121
132
  <div class="mb-3 flex items-center gap-1.5">
122
133
  <button
123
- aria-label="Back"
134
+ aria-label={n.back}
124
135
  class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
125
136
  data-nav-back={panel.parentId}
126
137
  type="button"
@@ -147,6 +158,7 @@ const initialId =
147
158
  idPrefix={panel.id}
148
159
  items={panel.children}
149
160
  root={false}
161
+ strings={n}
150
162
  />
151
163
  </div>
152
164
  ))}
@@ -173,7 +185,7 @@ const initialId =
173
185
  <span class="flex-1 truncate">{item.label}</span>
174
186
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
175
187
  {item.deprecated && (
176
- <span class={deprecatedClass}>deprecated</span>
188
+ <span class={deprecatedClass}>{n.deprecated}</span>
177
189
  )}
178
190
  </span>
179
191
  </a>
@@ -272,6 +284,7 @@ const initialId =
272
284
  idPrefix={id}
273
285
  items={item.children}
274
286
  root={false}
287
+ strings={n}
275
288
  />
276
289
  </div>
277
290
  </details>
@@ -324,6 +337,7 @@ const initialId =
324
337
  idPrefix={id}
325
338
  items={item.children}
326
339
  root={false}
340
+ strings={n}
327
341
  />
328
342
  </div>
329
343
  </li>