blume 0.7.0 → 1.0.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 (185) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1852 -380
  5. package/dist/cli/index.js.map +98 -91
  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 +47 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +7 -0
  12. package/dist/types/core/i18n-ui.d.ts +526 -132
  13. package/dist/types/core/schema.d.ts +293 -146
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/dist/types/seo/x-handle.d.ts +12 -0
  17. package/docs/01-quickstart.mdx +5 -2
  18. package/docs/02-deployment.mdx +8 -8
  19. package/docs/03-faq.mdx +46 -16
  20. package/docs/advanced/api-reference.mdx +1 -1
  21. package/docs/advanced/changelog.mdx +1 -1
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/skills.mdx +1 -1
  24. package/docs/configuration/ai.mdx +49 -10
  25. package/docs/configuration/customization.mdx +11 -0
  26. package/docs/configuration/export.mdx +1 -1
  27. package/docs/configuration/index.mdx +27 -3
  28. package/docs/configuration/seo.mdx +35 -5
  29. package/docs/content/components.mdx +2 -2
  30. package/docs/content/i18n.mdx +1 -1
  31. package/docs/content/navigation.mdx +3 -3
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +6 -4
  34. package/docs/index.mdx +2 -2
  35. package/docs/reference/cli.mdx +9 -7
  36. package/docs/reference/frontmatter.mdx +1 -1
  37. package/package.json +22 -4
  38. package/skills/blume/SKILL.md +5 -3
  39. package/skills/blume-update-docs/SKILL.md +3 -2
  40. package/src/ai/agent-readability.ts +9 -8
  41. package/src/ai/ask-context.ts +7 -2
  42. package/src/ai/ask-data.ts +3 -0
  43. package/src/ai/component-markdown.ts +461 -0
  44. package/src/ai/llms.ts +135 -26
  45. package/src/ai/markdown.ts +35 -6
  46. package/src/ai/mcp/data.ts +25 -4
  47. package/src/ai/mcp/discovery.ts +10 -3
  48. package/src/ai/mcp/server.ts +21 -7
  49. package/src/ai/mcp/tools.ts +1 -1
  50. package/src/ai/visibility.ts +74 -0
  51. package/src/astro/component-slots.ts +11 -1
  52. package/src/astro/generate.ts +77 -45
  53. package/src/astro/integration.ts +1 -1
  54. package/src/astro/markdown-negotiation.ts +1 -1
  55. package/src/astro/pages.ts +81 -19
  56. package/src/astro/templates.ts +150 -19
  57. package/src/blume-modules.d.ts +8 -0
  58. package/src/cli/commands/build.ts +120 -23
  59. package/src/cli/commands/check.ts +1 -1
  60. package/src/cli/commands/dev.ts +26 -5
  61. package/src/cli/commands/eject.ts +47 -19
  62. package/src/cli/commands/init.ts +120 -180
  63. package/src/cli/commands/preview.ts +4 -1
  64. package/src/cli/commands/validate.ts +43 -2
  65. package/src/cli/dev-lock.ts +8 -4
  66. package/src/cli/eject-scripts.ts +72 -0
  67. package/src/cli/env.ts +15 -5
  68. package/src/cli/init/questions.ts +158 -0
  69. package/src/cli/init/scaffold.ts +380 -0
  70. package/src/cli/internal-error.ts +9 -4
  71. package/src/cli/prepare.ts +3 -2
  72. package/src/components/Icon.astro +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/Step.astro +10 -1
  77. package/src/components/content/Tabs.astro +15 -3
  78. package/src/components/content/Tile.astro +2 -1
  79. package/src/components/content/Tooltip.astro +3 -1
  80. package/src/components/content/Update.astro +9 -2
  81. package/src/components/content/auto-type-table.ts +7 -1
  82. package/src/components/content/base-href.ts +33 -0
  83. package/src/components/content/changelog-element.ts +9 -2
  84. package/src/components/content/mermaid-element.ts +7 -2
  85. package/src/components/islands/AskAI.astro +5 -2
  86. package/src/components/islands/ask-ai.tsx +86 -11
  87. package/src/components/islands/hooks.ts +28 -8
  88. package/src/components/layout/Banner.astro +10 -2
  89. package/src/components/layout/Breadcrumbs.astro +11 -2
  90. package/src/components/layout/Header.astro +13 -4
  91. package/src/components/layout/Logo.astro +11 -3
  92. package/src/components/layout/NavTree.astro +19 -5
  93. package/src/components/layout/PageActions.astro +25 -10
  94. package/src/components/layout/PageLayout.astro +85 -9
  95. package/src/components/layout/Pagination.astro +10 -4
  96. package/src/components/layout/ReferenceLayout.astro +20 -2
  97. package/src/components/layout/RootLayout.astro +142 -12
  98. package/src/components/layout/Search.astro +117 -27
  99. package/src/components/layout/search/algolia.ts +11 -2
  100. package/src/components/layout/search/endpoint.ts +11 -5
  101. package/src/components/layout/search/orama-cloud.ts +8 -2
  102. package/src/components/layout/search/types.ts +5 -1
  103. package/src/components/layout/search/typesense.ts +4 -1
  104. package/src/components/layout/toc-element.ts +1 -1
  105. package/src/components/openapi/ApiTagOperations.astro +2 -1
  106. package/src/components/openapi/Operation.astro +47 -40
  107. package/src/components/openapi/RequestPanel.astro +1 -1
  108. package/src/components/openapi/helpers.ts +71 -3
  109. package/src/components/openapi/panel.ts +1 -1
  110. package/src/core/base-path.ts +24 -0
  111. package/src/core/builtin-tags.ts +2 -0
  112. package/src/core/config-input.ts +48 -2
  113. package/src/core/config.ts +3 -2
  114. package/src/core/data.ts +4 -0
  115. package/src/core/frontmatter.ts +7 -0
  116. package/src/core/graph.ts +15 -5
  117. package/src/core/i18n-ui.ts +54 -0
  118. package/src/core/i18n.ts +16 -8
  119. package/src/core/last-modified.ts +13 -6
  120. package/src/core/links.ts +32 -8
  121. package/src/core/navigation.ts +29 -4
  122. package/src/core/package-json.ts +17 -2
  123. package/src/core/project-graph.ts +15 -6
  124. package/src/core/schema.ts +71 -2
  125. package/src/core/sources/assets.ts +6 -1
  126. package/src/core/sources/filesystem.ts +4 -0
  127. package/src/core/sources/mdx-remote.ts +23 -14
  128. package/src/core/sources/normalize.ts +152 -50
  129. package/src/core/sources/notion.ts +8 -8
  130. package/src/core/ui-packs/ar.ts +8 -0
  131. package/src/core/ui-packs/bg.ts +8 -0
  132. package/src/core/ui-packs/bn.ts +8 -0
  133. package/src/core/ui-packs/ca.ts +8 -0
  134. package/src/core/ui-packs/cs.ts +8 -0
  135. package/src/core/ui-packs/da.ts +8 -0
  136. package/src/core/ui-packs/de.ts +8 -0
  137. package/src/core/ui-packs/el.ts +8 -0
  138. package/src/core/ui-packs/es.ts +8 -0
  139. package/src/core/ui-packs/fa.ts +8 -0
  140. package/src/core/ui-packs/fi.ts +8 -0
  141. package/src/core/ui-packs/fr.ts +9 -1
  142. package/src/core/ui-packs/he.ts +8 -0
  143. package/src/core/ui-packs/hi.ts +8 -0
  144. package/src/core/ui-packs/hr.ts +8 -0
  145. package/src/core/ui-packs/hu.ts +8 -0
  146. package/src/core/ui-packs/id.ts +8 -0
  147. package/src/core/ui-packs/it.ts +8 -0
  148. package/src/core/ui-packs/ja.ts +8 -0
  149. package/src/core/ui-packs/ko.ts +8 -0
  150. package/src/core/ui-packs/nl.ts +8 -0
  151. package/src/core/ui-packs/no.ts +8 -0
  152. package/src/core/ui-packs/pl.ts +8 -0
  153. package/src/core/ui-packs/pt-br.ts +8 -0
  154. package/src/core/ui-packs/pt.ts +8 -0
  155. package/src/core/ui-packs/ro.ts +8 -0
  156. package/src/core/ui-packs/ru.ts +8 -0
  157. package/src/core/ui-packs/sk.ts +8 -0
  158. package/src/core/ui-packs/sr.ts +8 -0
  159. package/src/core/ui-packs/sv.ts +8 -0
  160. package/src/core/ui-packs/th.ts +8 -0
  161. package/src/core/ui-packs/tr.ts +8 -0
  162. package/src/core/ui-packs/uk.ts +8 -0
  163. package/src/core/ui-packs/vi.ts +8 -0
  164. package/src/core/ui-packs/zh-tw.ts +8 -0
  165. package/src/core/ui-packs/zh.ts +8 -0
  166. package/src/deploy/adapter-output.ts +18 -8
  167. package/src/deploy/redirects.ts +7 -2
  168. package/src/deploy/sitemap.ts +53 -11
  169. package/src/index.ts +5 -0
  170. package/src/markdown/base-links.ts +10 -8
  171. package/src/markdown/index.ts +15 -3
  172. package/src/markdown/inline-code.ts +7 -2
  173. package/src/markdown/package-commands.ts +10 -4
  174. package/src/og/card.ts +4 -2
  175. package/src/og/dimensions.ts +12 -0
  176. package/src/openapi/model.ts +12 -4
  177. package/src/openapi/parse.ts +21 -0
  178. package/src/openapi/references.ts +38 -8
  179. package/src/openapi/render-mdx.ts +62 -1
  180. package/src/openapi/source.ts +59 -10
  181. package/src/registry/eject.ts +184 -12
  182. package/src/registry/registry.ts +0 -3
  183. package/src/search/documents.ts +34 -2
  184. package/src/seo/jsonld.ts +20 -13
  185. package/src/seo/x-handle.ts +18 -0
@@ -51,6 +51,10 @@ const hashEnabled = String(hash) !== "false";
51
51
  "w-full rounded-blume border border-border bg-background px-3 py-2 font-medium text-foreground text-sm";
52
52
  const SYNC_EVENT = "blume-tabs-change";
53
53
 
54
+ // Disambiguates generated trigger/panel ids when the same tab labels appear
55
+ // in several <Tabs> instances on one page.
56
+ let instanceCount = 0;
57
+
54
58
  const panelTitle = (panel: HTMLElement, index: number) =>
55
59
  panel.dataset.title ?? `Tab ${index + 1}`;
56
60
  const panelId = (panel: HTMLElement) => panel.dataset.tabId ?? panel.id;
@@ -119,7 +123,8 @@ const hashEnabled = String(hash) !== "false";
119
123
  if (this.dataset.dropdown === "true") {
120
124
  this.#select = document.createElement("select");
121
125
  this.#select.className = SELECT_CLASS;
122
- this.#select.setAttribute("aria-label", "Select code example");
126
+ // Neutral default: tab sets aren't necessarily code examples.
127
+ this.#select.setAttribute("aria-label", "Select tab");
123
128
  panels.forEach((panel, index) => {
124
129
  const option = document.createElement("option");
125
130
  option.value = String(index);
@@ -131,6 +136,7 @@ const hashEnabled = String(hash) !== "false";
131
136
  });
132
137
  list.appendChild(this.#select);
133
138
  } else {
139
+ const instance = ++instanceCount;
134
140
  panels.forEach((panel, index) => {
135
141
  const trigger = document.createElement("button");
136
142
  trigger.type = "button";
@@ -138,9 +144,15 @@ const hashEnabled = String(hash) !== "false";
138
144
  trigger.className = TRIGGER_CLASS;
139
145
  appendTriggerContent(trigger, panel, index);
140
146
  trigger.tabIndex = index === 0 ? 0 : -1;
141
- if (panel.id) {
142
- trigger.setAttribute("aria-controls", panel.id);
147
+ // Wire tab and panel both ways. An author-supplied panel id is kept;
148
+ // otherwise derive unique ids from the label slug per instance.
149
+ const slug = panelId(panel) || `tab-${index + 1}`;
150
+ if (!panel.id) {
151
+ panel.id = `blume-tabpanel-${slug}-${instance}`;
143
152
  }
153
+ trigger.id = `blume-tab-${slug}-${instance}`;
154
+ trigger.setAttribute("aria-controls", panel.id);
155
+ panel.setAttribute("aria-labelledby", trigger.id);
144
156
  trigger.setAttribute(
145
157
  "aria-selected",
146
158
  index === 0 ? "true" : "false"
@@ -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,16 @@ 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);
157
+ // The stream writes into the conversation via functional updates, so "Clear
158
+ // conversation" mid-answer must revoke the in-flight stream's right to write
159
+ // — otherwise its next chunk re-appends the assistant bubble onto the
160
+ // emptied list as an orphaned answer. Clearing bumps the generation (stale
161
+ // streams stop writing) and aborts the request (the stream stops arriving).
162
+ const abortRef = useRef<AbortController | null>(null);
163
+ const generationRef = useRef(0);
131
164
 
132
165
  // Portal target (document.body) only exists after mount; guards SSR. The
133
166
  // one-time false→true flip is deliberate, so the initial `false` is required.
@@ -164,10 +197,24 @@ const AskAI = ({
164
197
  // Drive the desktop content push from a body attribute (see AskAI.astro CSS).
165
198
  useEffect(() => {
166
199
  if (open) {
200
+ returnFocusRef.current =
201
+ document.activeElement instanceof HTMLElement
202
+ ? document.activeElement
203
+ : null;
167
204
  document.body.dataset.blumeAsk = "open";
168
205
  inputRef.current?.focus();
169
206
  } else {
170
207
  delete document.body.dataset.blumeAsk;
208
+ // Return focus to the element that opened the panel (or the trigger when
209
+ // it's gone), so closing doesn't strand keyboard focus in an inert tree.
210
+ // `returnFocusRef` is only set on open, so initial mount is a no-op.
211
+ if (returnFocusRef.current) {
212
+ const target = returnFocusRef.current.isConnected
213
+ ? returnFocusRef.current
214
+ : triggerRef.current;
215
+ returnFocusRef.current = null;
216
+ target?.focus();
217
+ }
171
218
  }
172
219
  return () => {
173
220
  delete document.body.dataset.blumeAsk;
@@ -199,6 +246,9 @@ const AskAI = ({
199
246
  setMessages([...history, assistant]);
200
247
  setInput("");
201
248
  setBusy(true);
249
+ const generation = generationRef.current;
250
+ const controller = new AbortController();
251
+ abortRef.current = controller;
202
252
 
203
253
  try {
204
254
  const response = await fetch(ASK_ENDPOINT, {
@@ -208,6 +258,7 @@ const AskAI = ({
208
258
  }),
209
259
  headers: { "content-type": "application/json" },
210
260
  method: "POST",
261
+ signal: controller.signal,
211
262
  });
212
263
  // A 4xx/5xx still has a body; without this guard its error text would be
213
264
  // decoded and shown as the assistant's answer instead of the error notice.
@@ -226,25 +277,45 @@ const AskAI = ({
226
277
  // must not flush as U+FFFD garbage.
227
278
  // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
228
279
  assistant.content += decoder.decode(chunk.value, { stream: true });
229
- setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
280
+ if (generationRef.current === generation) {
281
+ setMessages((current) => [
282
+ ...current.slice(0, -1),
283
+ { ...assistant },
284
+ ]);
285
+ }
230
286
  }
231
287
  }
232
288
  } catch {
233
- // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
234
- assistant.content = t.error;
235
- setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
289
+ // A cleared (aborted) stream must not resurrect its bubble as an error.
290
+ if (generationRef.current === generation) {
291
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
292
+ assistant.content = t.error;
293
+ setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
294
+ }
236
295
  } finally {
237
296
  setBusy(false);
238
297
  }
239
298
  };
240
299
 
300
+ const clearConversation = () => {
301
+ generationRef.current += 1;
302
+ abortRef.current?.abort();
303
+ setMessages([]);
304
+ };
305
+
241
306
  const onSubmit = (event: FormEvent) => {
242
307
  event.preventDefault();
243
308
  void runQuestion(input);
244
309
  };
245
310
 
246
311
  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
247
- if (event.key === "Enter" && !event.shiftKey) {
312
+ // `isComposing` guards IME input: Enter confirming a CJK conversion must
313
+ // commit the text, not submit the question.
314
+ if (
315
+ event.key === "Enter" &&
316
+ !event.shiftKey &&
317
+ !event.nativeEvent.isComposing
318
+ ) {
248
319
  event.preventDefault();
249
320
  void runQuestion(input);
250
321
  }
@@ -252,7 +323,7 @@ const AskAI = ({
252
323
 
253
324
  const copyConversation = () => {
254
325
  const text = messages
255
- .map((m) => `${m.role === "user" ? "You" : "AI"}: ${m.content}`)
326
+ .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
256
327
  .join("\n\n");
257
328
  void navigator.clipboard?.writeText(text);
258
329
  };
@@ -263,6 +334,9 @@ const AskAI = ({
263
334
  <aside
264
335
  aria-hidden={open ? undefined : "true"}
265
336
  aria-label={t.title}
337
+ // The closed panel is only translated off-screen; `inert` drops its
338
+ // buttons/textarea from the tab order and the accessibility tree.
339
+ inert={!open}
266
340
  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
341
  open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
268
342
  }`}
@@ -283,7 +357,7 @@ const AskAI = ({
283
357
  aria-label={t.clear}
284
358
  className={ICON_BUTTON_CLASS}
285
359
  disabled={!hasMessages}
286
- onClick={() => setMessages([])}
360
+ onClick={clearConversation}
287
361
  type="button"
288
362
  >
289
363
  <Glyph path={icons.clear} />
@@ -355,7 +429,7 @@ const AskAI = ({
355
429
  <p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
356
430
  {t.tip}
357
431
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
358
-
432
+ {IS_APPLE ? "" : "Ctrl"}
359
433
  </kbd>
360
434
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
361
435
  I
@@ -371,7 +445,7 @@ const AskAI = ({
371
445
  >
372
446
  <textarea
373
447
  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"
448
+ 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
449
  onChange={(event) => setInput(event.target.value)}
376
450
  onKeyDown={onInputKeyDown}
377
451
  placeholder={t.placeholder}
@@ -398,6 +472,7 @@ const AskAI = ({
398
472
  aria-label={t.title}
399
473
  className={TRIGGER_CLASS}
400
474
  onClick={() => setOpen((value) => !value)}
475
+ ref={triggerRef}
401
476
  type="button"
402
477
  >
403
478
  <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,15 +1,24 @@
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 type { Crumb } from "./nav-utils.ts";
4
6
 
5
7
  interface Props {
6
8
  /** Full breadcrumb trail from the site root to the current page. */
7
9
  crumbs: Crumb[];
10
+ /** Localized nav labels (the landmark's `aria-label`); English when omitted. */
11
+ strings?: UIStrings["nav"];
8
12
  /** Left-align full width (for the wide API layout) instead of the prose measure. */
9
13
  wide?: boolean;
10
14
  }
11
15
 
12
- const { crumbs, wide = false } = Astro.props;
16
+ const { crumbs, strings, wide = false } = Astro.props;
17
+
18
+ // Merge over the English defaults so a label missing from a translation (or
19
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
20
+ // blank — the PageActions pattern.
21
+ const n = { ...EN_UI.nav, ...strings };
13
22
 
14
23
  // The built-in shows a single "eyebrow" crumb — the parent group — rather than
15
24
  // the whole trail. An override receives the complete list and can render more.
@@ -19,7 +28,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
19
28
  {
20
29
  eyebrowCrumb && (
21
30
  <nav
22
- aria-label="Breadcrumb"
31
+ aria-label={n.breadcrumb}
23
32
  class:list={[
24
33
  "mb-2 text-muted-foreground text-sm",
25
34
  wide ? "max-w-none" : "mx-auto max-w-[42rem]",