blume 0.6.7 → 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 (211) 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 +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  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 +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +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/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -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,12 +74,26 @@ 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
 
78
91
  const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
79
92
  <svg
80
93
  aria-hidden="true"
94
+ // `path` is a trusted, server-resolved Lucide glyph body (inline SVG),
95
+ // not user content; it must be injected as markup to render the icon.
96
+ // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
81
97
  dangerouslySetInnerHTML={{ __html: path }}
82
98
  fill="none"
83
99
  height={size}
@@ -94,6 +110,14 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
94
110
  // Stable empty default so an unset `suggestions` prop doesn't re-render.
95
111
  const EMPTY_SUGGESTIONS: Suggestion[] = [];
96
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
+
97
121
  // Ghost icon button, matching the header's theme toggle and repo link.
98
122
  const TRIGGER_CLASS =
99
123
  "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
@@ -117,7 +141,9 @@ const AskAI = ({
117
141
  strings?: UIStrings["ask"];
118
142
  suggestions?: Suggestion[];
119
143
  }) => {
120
- 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 };
121
147
  const [mounted, setMounted] = useState(false);
122
148
  const [open, setOpen] = useState(false);
123
149
  const [input, setInput] = useState("");
@@ -125,8 +151,13 @@ const AskAI = ({
125
151
  const [busy, setBusy] = useState(false);
126
152
  const inputRef = useRef<HTMLTextAreaElement>(null);
127
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);
128
157
 
129
- // Portal target (document.body) only exists after mount; guards SSR.
158
+ // Portal target (document.body) only exists after mount; guards SSR. The
159
+ // one-time false→true flip is deliberate, so the initial `false` is required.
160
+ // oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate post-mount portal guard
130
161
  useEffect(() => setMounted(true), []);
131
162
 
132
163
  // The search modal forwards its query so "Ask AI: <query>" carries straight in.
@@ -159,10 +190,24 @@ const AskAI = ({
159
190
  // Drive the desktop content push from a body attribute (see AskAI.astro CSS).
160
191
  useEffect(() => {
161
192
  if (open) {
193
+ returnFocusRef.current =
194
+ document.activeElement instanceof HTMLElement
195
+ ? document.activeElement
196
+ : null;
162
197
  document.body.dataset.blumeAsk = "open";
163
198
  inputRef.current?.focus();
164
199
  } else {
165
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
+ }
166
211
  }
167
212
  return () => {
168
213
  delete document.body.dataset.blumeAsk;
@@ -213,17 +258,19 @@ const AskAI = ({
213
258
  const decoder = new TextDecoder();
214
259
  let done = false;
215
260
  while (!done) {
216
- // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
261
+ // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
217
262
  const chunk = await reader.read();
218
263
  ({ done } = chunk);
219
264
  if (chunk.value) {
220
265
  // Streaming mode: a multi-byte UTF-8 sequence split across chunks
221
266
  // must not flush as U+FFFD garbage.
267
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
222
268
  assistant.content += decoder.decode(chunk.value, { stream: true });
223
269
  setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
224
270
  }
225
271
  }
226
272
  } catch {
273
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
227
274
  assistant.content = t.error;
228
275
  setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
229
276
  } finally {
@@ -237,7 +284,13 @@ const AskAI = ({
237
284
  };
238
285
 
239
286
  const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
240
- 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
+ ) {
241
294
  event.preventDefault();
242
295
  void runQuestion(input);
243
296
  }
@@ -245,7 +298,7 @@ const AskAI = ({
245
298
 
246
299
  const copyConversation = () => {
247
300
  const text = messages
248
- .map((m) => `${m.role === "user" ? "You" : "AI"}: ${m.content}`)
301
+ .map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
249
302
  .join("\n\n");
250
303
  void navigator.clipboard?.writeText(text);
251
304
  };
@@ -256,6 +309,9 @@ const AskAI = ({
256
309
  <aside
257
310
  aria-hidden={open ? undefined : "true"}
258
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}
259
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 ${
260
316
  open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
261
317
  }`}
@@ -268,7 +324,6 @@ const AskAI = ({
268
324
  className={ICON_BUTTON_CLASS}
269
325
  disabled={!hasMessages}
270
326
  onClick={copyConversation}
271
- title={t.copy}
272
327
  type="button"
273
328
  >
274
329
  <Glyph path={icons.copy} />
@@ -278,7 +333,6 @@ const AskAI = ({
278
333
  className={ICON_BUTTON_CLASS}
279
334
  disabled={!hasMessages}
280
335
  onClick={() => setMessages([])}
281
- title={t.clear}
282
336
  type="button"
283
337
  >
284
338
  <Glyph path={icons.clear} />
@@ -287,7 +341,6 @@ const AskAI = ({
287
341
  aria-label={t.close}
288
342
  className={ICON_BUTTON_CLASS}
289
343
  onClick={() => setOpen(false)}
290
- title={t.close}
291
344
  type="button"
292
345
  >
293
346
  <Glyph path={icons.close} size={18} />
@@ -311,6 +364,8 @@ const AskAI = ({
311
364
  {message.content ? (
312
365
  // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
313
366
  <div
367
+ // renderMarkdown runs marked output through DOMPurify.sanitize.
368
+ // oxlint-disable-next-line react/no-danger -- sanitized (DOMPurify) rendered-markdown output
314
369
  dangerouslySetInnerHTML={{
315
370
  __html: renderMarkdown(message.content),
316
371
  }}
@@ -339,6 +394,7 @@ const AskAI = ({
339
394
  {suggestion.icon && (
340
395
  <span
341
396
  className="shrink-0 text-muted-foreground [&_svg]:h-[18px] [&_svg]:w-[18px]"
397
+ // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
342
398
  dangerouslySetInnerHTML={{ __html: suggestion.icon }}
343
399
  />
344
400
  )}
@@ -348,7 +404,7 @@ const AskAI = ({
348
404
  <p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
349
405
  {t.tip}
350
406
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
351
- ⌘
407
+ {IS_APPLE ? "⌘" : "Ctrl"}
352
408
  </kbd>
353
409
  <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
354
410
  I
@@ -364,7 +420,7 @@ const AskAI = ({
364
420
  >
365
421
  <textarea
366
422
  aria-label={t.label}
367
- 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"
368
424
  onChange={(event) => setInput(event.target.value)}
369
425
  onKeyDown={onInputKeyDown}
370
426
  placeholder={t.placeholder}
@@ -391,7 +447,7 @@ const AskAI = ({
391
447
  aria-label={t.title}
392
448
  className={TRIGGER_CLASS}
393
449
  onClick={() => setOpen((value) => !value)}
394
- title={t.title}
450
+ ref={triggerRef}
395
451
  type="button"
396
452
  >
397
453
  <Glyph path={icons.chat} size={18} />
@@ -13,6 +13,34 @@ export const withTrailingSlash = (base: string): string =>
13
13
  export const joinBase = (base: string, path: string): string =>
14
14
  `${withTrailingSlash(base)}${path}`;
15
15
 
16
+ /**
17
+ * Prefix a root-relative internal route with the deployment base
18
+ * (`/guide` under base `/sub` -> `/sub/guide`), so a rendered link points at the
19
+ * page's real served URL. External URLs, protocol-relative URLs, and fragments
20
+ * pass through untouched, and it's idempotent (a route already under the base is
21
+ * returned unchanged). This is applied only where a URL is *emitted* — the
22
+ * navigation model and active-route matching stay in base-less logical space.
23
+ */
24
+ export const prefixBase = (base: string, route: string): string => {
25
+ if (!route.startsWith("/") || route.startsWith("//")) {
26
+ return route;
27
+ }
28
+ const trimmed = base.replace(/\/+$/u, "");
29
+ if (!trimmed || route === trimmed || route.startsWith(`${trimmed}/`)) {
30
+ return route;
31
+ }
32
+ return route === "/" ? trimmed : `${trimmed}${route}`;
33
+ };
34
+
35
+ /**
36
+ * {@link prefixBase} bound to the build-time `BASE_URL` (the resolved
37
+ * `deployment.base`). The ergonomic form for `.astro` templates — `href={
38
+ * withBase(route)}` — since `BASE_URL` is inlined by Vite wherever this module
39
+ * is bundled into the site.
40
+ */
41
+ export const withBase = (route: string): string =>
42
+ prefixBase(import.meta.env.BASE_URL ?? "/", route);
43
+
16
44
  /**
17
45
  * A pathname with the deployment base stripped (`/docs/guide` -> `/guide`),
18
46
  * for page-context lookups against base-less document routes.
@@ -50,6 +50,10 @@ const readClientData = (): BlumeClientData | null => {
50
50
  */
51
51
  const useClientData = (): BlumeClientData | null => {
52
52
  const [data, setData] = useState<BlumeClientData | null>(null);
53
+ // Intentional post-mount hydration guard: `null` on the server and first
54
+ // client render so hydration matches, then the snapshot once mounted. The
55
+ // extra render is required; do not seed the initial value from the DOM.
56
+ // oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate SSR hydration guard
53
57
  useEffect(() => setData(readClientData()), []);
54
58
  return data;
55
59
  };
@@ -85,19 +89,36 @@ export const useSearch = (): UseSearch => {
85
89
  const [results, setResults] = useState<SearchResult | null>(null);
86
90
  const [loading, setLoading] = useState(false);
87
91
  const searchFn = useRef<SearchFn | null>(null);
92
+ const generation = useRef(0);
88
93
 
94
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
95
+ // this useCallback keeps a stable `search` identity for consumers that use it
96
+ // as an effect/memo dependency. With the compiler on it's redundant but inert.
97
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
89
98
  const search = useCallback<UseSearch["search"]>(async (query, options) => {
90
- if (!searchFn.current) {
91
- const { createSearch } = await import("blume:search-client");
92
- searchFn.current = await createSearch();
93
- }
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.
94
107
  setLoading(true);
95
108
  try {
109
+ if (!searchFn.current) {
110
+ const { createSearch } = await import("blume:search-client");
111
+ searchFn.current = await createSearch();
112
+ }
96
113
  const result = await searchFn.current(query, options);
97
- setResults(result);
114
+ if (current === generation.current) {
115
+ setResults(result);
116
+ }
98
117
  return result;
99
118
  } finally {
100
- setLoading(false);
119
+ if (current === generation.current) {
120
+ setLoading(false);
121
+ }
101
122
  }
102
123
  }, []);
103
124
 
@@ -120,6 +141,9 @@ export interface UseAskAI {
120
141
 
121
142
  const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
122
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
+
123
147
  /** The current route with the deployment base stripped, for page grounding. */
124
148
  const currentPath = (): string =>
125
149
  stripBase(import.meta.env.BASE_URL, window.location.pathname);
@@ -132,6 +156,10 @@ export const useAskAI = (): UseAskAI => {
132
156
  const [messages, setMessages] = useState<AskMessage[]>([]);
133
157
  const [loading, setLoading] = useState(false);
134
158
 
159
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
160
+ // preserves a stable `ask` identity for consumers that depend on it. With the
161
+ // compiler on it's redundant but inert.
162
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
135
163
  const ask = useCallback<UseAskAI["ask"]>(
136
164
  async (question) => {
137
165
  const trimmed = question.trim();
@@ -157,8 +185,7 @@ export const useAskAI = (): UseAskAI => {
157
185
  if (!response.ok) {
158
186
  // An error body (JSON, HTML error page) must not stream in as the
159
187
  // assistant's answer.
160
- assistant.content =
161
- "Something went wrong answering that. Please try again.";
188
+ assistant.content = ASK_ERROR;
162
189
  setMessages([...history, { ...assistant }]);
163
190
  return;
164
191
  }
@@ -167,7 +194,7 @@ export const useAskAI = (): UseAskAI => {
167
194
  if (reader) {
168
195
  let done = false;
169
196
  while (!done) {
170
- // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
197
+ // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
171
198
  const chunk = await reader.read();
172
199
  ({ done } = chunk);
173
200
  if (chunk.value) {
@@ -183,6 +210,11 @@ export const useAskAI = (): UseAskAI => {
183
210
  }
184
211
  }
185
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 }]);
186
218
  } finally {
187
219
  setLoading(false);
188
220
  }
@@ -190,6 +222,9 @@ export const useAskAI = (): UseAskAI => {
190
222
  [loading, messages]
191
223
  );
192
224
 
225
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
226
+ // keeps a stable `reset` identity. With the compiler on it's redundant but inert.
227
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
193
228
  const reset = useCallback(() => setMessages([]), []);
194
229
 
195
230
  return { ask, loading, messages, reset };
@@ -1,4 +1,7 @@
1
1
  ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
4
+ import { withBase } from "../islands/base-path.ts";
2
5
  import Icon from "../Icon.astro";
3
6
 
4
7
  // The site-wide announcement banner, shared by RootLayout and ReferenceLayout so
@@ -13,9 +16,15 @@ interface Props {
13
16
  key: string;
14
17
  }
15
18
  | null;
19
+ /** Localized banner labels. */
20
+ strings?: UIStrings["banner"];
16
21
  }
17
22
 
18
- 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 };
19
28
  ---
20
29
 
21
30
  {
@@ -29,7 +38,7 @@ const { banner } = Astro.props;
29
38
  {banner.link && (
30
39
  <a
31
40
  class="inline-flex items-center gap-1 font-medium underline underline-offset-2"
32
- href={banner.link.href}
41
+ href={withBase(banner.link.href)}
33
42
  >
34
43
  {banner.link.text}
35
44
  <Icon name="arrow-right" size={14} />
@@ -37,7 +46,7 @@ const { banner } = Astro.props;
37
46
  )}
38
47
  {banner.dismissible && (
39
48
  <button
40
- aria-label="Dismiss announcement"
49
+ aria-label={b.dismiss}
41
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"
42
51
  data-blume-banner-dismiss
43
52
  type="button"
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import type { Crumb } from "./nav-utils.ts";
3
4
 
4
5
  interface Props {
@@ -25,7 +26,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
25
26
  ]}
26
27
  >
27
28
  {eyebrowCrumb.route ? (
28
- <a class="hover:text-foreground" href={eyebrowCrumb.route}>
29
+ <a class="hover:text-foreground" href={withBase(eyebrowCrumb.route)}>
29
30
  {eyebrowCrumb.label}
30
31
  </a>
31
32
  ) : (
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  // Emits the icon <link>s for the document <head>: the favicon (rel="icon") and,
3
4
  // when the project ships one, the Apple touch icon (rel="apple-touch-icon").
4
5
  // Each href is either a URL to a file the project ships in public/, or a data
@@ -18,5 +19,5 @@ interface Props {
18
19
  const { appleIcon, favicon } = Astro.props;
19
20
  ---
20
21
 
21
- {favicon && <link href={favicon.href} rel="icon" type={favicon.type} />}
22
- {appleIcon && <link href={appleIcon.href} rel="apple-touch-icon" />}
22
+ {favicon && <link href={withBase(favicon.href)} rel="icon" type={favicon.type} />}
23
+ {appleIcon && <link href={withBase(appleIcon.href)} rel="apple-touch-icon" />}
@@ -1,5 +1,7 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import type { ComponentOverride } from "../../core/define-components.ts";
4
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
5
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
6
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
5
7
  import { GITHUB_MARK } from "../github-mark.ts";
@@ -36,6 +38,8 @@ interface Props {
36
38
  hasDrawer?: boolean;
37
39
  searchStrings?: UIStrings["search"];
38
40
  switcherStrings?: UIStrings["languageSwitcher"];
41
+ /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
42
+ navStrings?: UIStrings["nav"];
39
43
  localeSwitch?: LocaleSwitchOption[];
40
44
  /** Active locale for per-language search filtering. */
41
45
  searchLocale?: string;
@@ -58,11 +62,17 @@ const {
58
62
  hasDrawer = true,
59
63
  searchStrings,
60
64
  switcherStrings,
65
+ navStrings,
61
66
  localeSwitch,
62
67
  searchLocale,
63
68
  layout = {},
64
69
  } = Astro.props;
65
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
+
66
76
  const LogoSlot = resolveSlot(layout.Logo, Logo);
67
77
  const SearchSlot = resolveSlot(layout.Search, Search);
68
78
 
@@ -108,7 +118,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
108
118
  {
109
119
  showNavToggle && (
110
120
  <button
111
- aria-label="Toggle navigation"
121
+ aria-label={n.toggleNavigation}
112
122
  class={`${iconButton} lg:hidden`}
113
123
  data-blume-nav-toggle
114
124
  type="button"
@@ -129,7 +139,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
129
139
  }
130
140
  {
131
141
  navigation.tabs.length > 0 && (
132
- <nav aria-label="Sections" class={tabsNavClass}>
142
+ <nav aria-label={n.sections} class={tabsNavClass}>
133
143
  {navigation.tabs.map((tab) => (
134
144
  <a
135
145
  aria-current={
@@ -139,7 +149,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
139
149
  : undefined
140
150
  }
141
151
  class="rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:text-foreground"
142
- href={tab.path}
152
+ href={withBase(tab.path)}
143
153
  >
144
154
  {tab.label}
145
155
  </a>
@@ -171,7 +181,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
171
181
  {
172
182
  navigation.repoUrl && (
173
183
  <a
174
- aria-label="GitHub repository"
184
+ aria-label={n.githubRepository}
175
185
  class={iconButton}
176
186
  href={navigation.repoUrl}
177
187
  rel="noreferrer"
@@ -190,7 +200,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
190
200
  )
191
201
  }
192
202
  <button
193
- aria-label="Toggle color theme"
203
+ aria-label={n.toggleTheme}
194
204
  class={iconButton}
195
205
  data-blume-theme-toggle
196
206
  type="button"
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import type { LocaleSwitchOption } from "../../core/types.ts";
3
4
  import Icon from "../Icon.astro";
4
5
 
@@ -37,7 +38,7 @@ const menuRowClass =
37
38
  <a
38
39
  aria-current={option.current ? "true" : undefined}
39
40
  class={menuRowClass}
40
- href={option.href}
41
+ href={withBase(option.href)}
41
42
  hreflang={option.code}
42
43
  lang={option.code}
43
44
  >
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  // The site brand: the logo mark (inline SVG so a `currentColor` mark follows the
3
4
  // theme, or light/dark `<img>` variants) followed by the site title, wrapped in
4
5
  // a link to the brand href. Extracted from the header so it can be replaced on
@@ -27,7 +28,7 @@ const brandText = logo?.text ?? site.title;
27
28
 
28
29
  <a
29
30
  class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
30
- href={brandHref}
31
+ href={withBase(brandHref)}
31
32
  >
32
33
  {
33
34
  logoSvg && (
@@ -42,11 +43,19 @@ const brandText = logo?.text ?? site.title;
42
43
  !logoSvg &&
43
44
  logoLight &&
44
45
  (logoLight === logoDark ? (
45
- <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
46
+ <img alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
46
47
  ) : (
47
48
  <>
48
- <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
49
- <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
+ />
50
59
  </>
51
60
  ))
52
61
  }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  // A top-level navigation selector: a dropdown that switches
3
4
  // between partitions of the site — a product, a version, or any grouped set of
4
5
  // destinations (`navigation.selectors` in the config). Zero-JS, built on
@@ -50,7 +51,7 @@ const menuRowClass =
50
51
  <a
51
52
  aria-current={item.path === active?.path ? "true" : undefined}
52
53
  class={menuRowClass}
53
- href={item.path}
54
+ href={withBase(item.path)}
54
55
  >
55
56
  {item.icon && <Icon class="mt-0.5" name={item.icon} size={16} />}
56
57
  <span class="flex-1">