blume 0.6.7 → 0.7.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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -15,9 +15,10 @@ export const checkRequiredSecrets = (config: ResolvedConfig): Diagnostic[] => {
15
15
  if (process.env[env]) {
16
16
  return;
17
17
  }
18
+ const noteSuffix = note ? ` (${note})` : "";
18
19
  diagnostics.push({
19
20
  code: "BLUME_MISSING_SECRET",
20
- message: `${feature} is enabled but ${env} is not set${note ? ` (${note})` : ""}.`,
21
+ message: `${feature} is enabled but ${env} is not set${noteSuffix}.`,
21
22
  severity: "warning",
22
23
  suggestion: `Set ${env} in .env.local for local dev, or in your host's environment for production.`,
23
24
  });
@@ -10,6 +10,8 @@
10
10
  // `.prose` container (`max-w-none` so it fills its own column rather than the
11
11
  // prose reading measure); that's what makes the light/dark swap work on a bare
12
12
  // page without the manual `[data-theme]` CSS it would otherwise need.
13
+ import data from "blume:data";
14
+
13
15
  import { highlightCode } from "../../markdown/index.ts";
14
16
 
15
17
  interface Props {
@@ -26,6 +28,7 @@ interface Props {
26
28
  const { code, lang = "txt", icons, title } = Astro.props;
27
29
  const html = await highlightCode(code.replace(/\n+$/u, ""), lang, {
28
30
  icons,
31
+ themes: data.config.codeThemes,
29
32
  title,
30
33
  });
31
34
  ---
@@ -5,14 +5,18 @@
5
5
  // `<Component path="forms/login" />` for `examples/forms/login.tsx`.
6
6
  //
7
7
  // Blume discovers `examples/` at build time and generates `blume:examples`: a
8
- // map of path → { Component (a static hydration wrapper), code, lang }. React,
9
- // Vue, Svelte, and Astro examples are all supported. The source is highlighted
10
- // with the same Shiki setup as ordinary code fences.
8
+ // map of path → { Component (a static hydration wrapper), code, lang } plus
9
+ // the route base preview frames live under. React, Vue, Svelte, and Astro
10
+ // examples are all supported. The source is highlighted with the same Shiki
11
+ // setup as ordinary code fences.
12
+ import data from "blume:data";
13
+
11
14
  import { highlightCode } from "../../markdown/index.ts";
15
+ import { withBase } from "../islands/base-path.ts";
12
16
  import Tab from "./Tab.astro";
13
17
  import Tabs from "./Tabs.astro";
14
18
  // Generated per project; resolves to an empty map when there are no examples.
15
- import { examples } from "blume:examples";
19
+ import { examples, examplesBase } from "blume:examples";
16
20
 
17
21
  interface Props {
18
22
  path: string;
@@ -20,14 +24,27 @@ interface Props {
20
24
 
21
25
  const { path } = Astro.props;
22
26
  const entry = examples[path];
23
- const Preview = entry?.Component;
27
+
28
+ // The live preview renders in its own generated route, embedded as an iframe:
29
+ // the frame boundary is what keeps the docs CSS (prose typography, component
30
+ // chrome) out of the example entirely, so users can preview components styled
31
+ // by their own design system (e.g. shadcn, via the configured `examples.css`).
32
+ // The frame syncs its theme with this document before first paint.
33
+ const previewSrc = entry
34
+ ? withBase(
35
+ `${examplesBase}/${path.split("/").map(encodeURIComponent).join("/")}`
36
+ )
37
+ : undefined;
24
38
 
25
39
  // Highlight the source with the shared helper so it matches Markdown fences.
26
40
  // `blume-source` makes the inner `<code>` (not the `<pre>`) the scroll/height-
27
41
  // capped box — see the theme entry — so the copy button, pinned to the `<pre>`,
28
42
  // stays put. This pane already lives inside `.prose`, so the theme styles it.
29
43
  const codeHtml = entry
30
- ? await highlightCode(entry.code, entry.lang, { className: "blume-source" })
44
+ ? await highlightCode(entry.code, entry.lang, {
45
+ className: "blume-source",
46
+ themes: data.config.codeThemes,
47
+ })
31
48
  : undefined;
32
49
 
33
50
  // Both tabs share one height so toggling them never shifts the layout. Size it
@@ -45,16 +62,13 @@ const paneStyle = `height:${paneHeight}px`;
45
62
  // `sync={false}`: each preview's Preview/Code tabs are independent — unlike
46
63
  // CodeGroup, switching one Component must not switch the others.
47
64
  <Tabs hash={false} sync={false}>
48
- <Tab
49
- // `not-prose`: the preview lives inside the page's `.prose` wrapper, so
50
- // without this the typography styles bleed into the live component
51
- // (headings, links, lists, spacing). The Code pane below keeps prose on
52
- // purpose — that's what styles the highlighted source.
53
- class="not-prose flex items-center justify-center overflow-auto"
54
- style={paneStyle}
55
- title="Preview"
56
- >
57
- <Preview />
65
+ <Tab class="overflow-hidden p-0!" style={paneStyle} title="Preview">
66
+ <iframe
67
+ class="h-full w-full"
68
+ loading="lazy"
69
+ src={previewSrc}
70
+ title={`Preview of ${path}`}
71
+ />
58
72
  </Tab>
59
73
  <Tab class="overflow-hidden" style={paneStyle} title="Code">
60
74
  <Fragment set:html={codeHtml} />
@@ -6,6 +6,8 @@
6
6
  //
7
7
  // Inputs (one of): `patch`/`src` (unified patch), `before`+`after` (file paths),
8
8
  // or `old`+`new` (inline strings, with optional `lang`).
9
+ import data from "blume:data";
10
+
9
11
  import { renderDiff } from "./diff.ts";
10
12
 
11
13
  interface Props {
@@ -22,7 +24,7 @@ let html: string | undefined;
22
24
  let error: string | undefined;
23
25
 
24
26
  try {
25
- html = await renderDiff(Astro.props);
27
+ html = await renderDiff({ ...Astro.props, theme: data.config.codeThemes });
26
28
  } catch (cause) {
27
29
  error = cause instanceof Error ? cause.message : String(cause);
28
30
  }
@@ -9,7 +9,17 @@
9
9
  */
10
10
  import nodePath from "node:path";
11
11
 
12
- import type * as TypeScriptApi from "typescript";
12
+ import type {
13
+ CompilerHost,
14
+ CompilerOptions,
15
+ DeclarationStatement,
16
+ Program,
17
+ } from "typescript";
18
+
19
+ // `typeof import(...)` is the only way to name the whole lazily-imported
20
+ // `typescript` module without a value import (see components/props.ts).
21
+ // oxlint-disable-next-line typescript/consistent-type-imports
22
+ type TypeScriptApi = typeof import("typescript");
13
23
 
14
24
  /** A single documented property — one generated row of a type table. */
15
25
  export interface TypeTableProperty {
@@ -36,10 +46,10 @@ const VIRTUAL_FILE = "__blume_auto_type_table__.ts";
36
46
 
37
47
  /** Build a compiler host that serves a single in-memory file plus the real libs. */
38
48
  const inMemoryHost = (
39
- ts: typeof TypeScriptApi,
40
- options: TypeScriptApi.CompilerOptions,
49
+ ts: TypeScriptApi,
50
+ options: CompilerOptions,
41
51
  source: string
42
- ): TypeScriptApi.CompilerHost => {
52
+ ): CompilerHost => {
43
53
  const host = ts.createCompilerHost(options, true);
44
54
  const getSourceFile = host.getSourceFile.bind(host);
45
55
  host.getSourceFile = (requested, languageVersion, onError, shouldCreate) =>
@@ -65,9 +75,9 @@ export const extractTypeTable = async (
65
75
  ): Promise<TypeTableProperty[]> => {
66
76
  const { name, path, root = process.cwd(), source } = options;
67
77
  const tsModule = await import("typescript");
68
- const ts = (tsModule.default ?? tsModule) as typeof TypeScriptApi;
78
+ const ts = (tsModule.default ?? tsModule) as TypeScriptApi;
69
79
 
70
- const compilerOptions: TypeScriptApi.CompilerOptions = {
80
+ const compilerOptions: CompilerOptions = {
71
81
  allowJs: true,
72
82
  module: ts.ModuleKind.ESNext,
73
83
  moduleResolution: ts.ModuleResolutionKind.Bundler,
@@ -78,7 +88,7 @@ export const extractTypeTable = async (
78
88
  };
79
89
 
80
90
  let fileName: string;
81
- let program: TypeScriptApi.Program;
91
+ let program: Program;
82
92
  if (source === undefined) {
83
93
  if (path === undefined) {
84
94
  throw new Error("AutoTypeTable needs a `path` or inline `type` source.");
@@ -100,7 +110,7 @@ export const extractTypeTable = async (
100
110
  }
101
111
 
102
112
  const declaration = sourceFile.statements.find(
103
- (statement): statement is TypeScriptApi.DeclarationStatement =>
113
+ (statement): statement is DeclarationStatement =>
104
114
  (ts.isInterfaceDeclaration(statement) ||
105
115
  ts.isTypeAliasDeclaration(statement)) &&
106
116
  statement.name?.text === name
@@ -13,6 +13,9 @@ import { readFile } from "node:fs/promises";
13
13
  import { preloadDiffHTML, preloadPatchDiff } from "@pierre/diffs/ssr";
14
14
  import { isAbsolute, join } from "pathe";
15
15
 
16
+ import { DEFAULT_CODE_THEMES } from "../../markdown/themes.ts";
17
+ import type { CodeThemes } from "../../markdown/themes.ts";
18
+
16
19
  export interface DiffOptions {
17
20
  /** Path to the "after" file, resolved relative to {@link DiffOptions.root}. */
18
21
  after?: string;
@@ -30,11 +33,13 @@ export interface DiffOptions {
30
33
  root?: string;
31
34
  /** Path to a `.patch`/`.diff` file, resolved relative to {@link DiffOptions.root}. */
32
35
  src?: string;
36
+ /**
37
+ * Light/dark Shiki themes (`markdown.codeBlocks.theme`). Defaults to the same
38
+ * github pair Blume's code blocks use, keeping diffs in lockstep.
39
+ */
40
+ theme?: CodeThemes;
33
41
  }
34
42
 
35
- /** Dual-theme config mirroring Blume's Shiki convention (see `templates.ts`). */
36
- const THEME = { dark: "github-dark", light: "github-light" } as const;
37
-
38
43
  const resolvePath = (path: string, root: string): string =>
39
44
  isAbsolute(path) ? path : join(root, path);
40
45
 
@@ -56,12 +61,13 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
56
61
  patch,
57
62
  root = process.cwd(),
58
63
  src,
64
+ theme = DEFAULT_CODE_THEMES,
59
65
  } = options;
60
66
 
61
67
  if (patch !== undefined || src !== undefined) {
62
68
  const text = patch ?? (await readText(src as string, root));
63
69
  const result = await preloadPatchDiff({
64
- options: { theme: THEME },
70
+ options: { theme },
65
71
  patch: text,
66
72
  });
67
73
  return result.prerenderedHTML;
@@ -74,7 +80,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
74
80
  return await preloadDiffHTML({
75
81
  newFile: { contents: await readText(after, root), name: after },
76
82
  oldFile: { contents: await readText(before, root), name: before },
77
- options: { theme: THEME },
83
+ options: { theme },
78
84
  });
79
85
  }
80
86
 
@@ -85,7 +91,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
85
91
  return await preloadDiffHTML({
86
92
  newFile: { contents: newText, lang, name: "snippet" },
87
93
  oldFile: { contents: old, lang, name: "snippet" },
88
- options: { disableFileHeader: true, theme: THEME },
94
+ options: { disableFileHeader: true, theme },
89
95
  });
90
96
  }
91
97
 
@@ -55,6 +55,9 @@ class BlumeMermaid extends HTMLElement {
55
55
  // A newer render (rapid theme toggles) superseded this one — dropping
56
56
  // the stale result keeps the diagram in the latest theme.
57
57
  if (token === this.#renderToken) {
58
+ // Mermaid's own render output is SVG markup; it must be injected as
59
+ // HTML, not text. securityLevel "strict" (see initialize) sanitizes it.
60
+ // oxlint-disable-next-line github/no-inner-html -- Mermaid-generated SVG must be injected as HTML
58
61
  output.innerHTML = svg;
59
62
  }
60
63
  } catch {
@@ -11,4 +11,26 @@ export type {
11
11
  IslandDescriptor,
12
12
  } from "../core/define-components.ts";
13
13
  export type { HydrationMode } from "../core/schema.ts";
14
- export type * from "./props.ts";
14
+ export type {
15
+ AccordionItemProps,
16
+ AccordionProps,
17
+ BadgeProps,
18
+ CalloutProps,
19
+ CardGroupProps,
20
+ CardProps,
21
+ CodeGroupProps,
22
+ ColumnProps,
23
+ ColumnsProps,
24
+ ExpandableProps,
25
+ FrameProps,
26
+ IconProps,
27
+ PanelProps,
28
+ PromptProps,
29
+ StepProps,
30
+ StepsProps,
31
+ TabProps,
32
+ TabsProps,
33
+ TileProps,
34
+ TooltipProps,
35
+ YouTubeProps,
36
+ } from "./props.ts";
@@ -78,6 +78,9 @@ const renderMarkdown = (content: string): string =>
78
78
  const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
79
79
  <svg
80
80
  aria-hidden="true"
81
+ // `path` is a trusted, server-resolved Lucide glyph body (inline SVG),
82
+ // not user content; it must be injected as markup to render the icon.
83
+ // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
81
84
  dangerouslySetInnerHTML={{ __html: path }}
82
85
  fill="none"
83
86
  height={size}
@@ -126,7 +129,9 @@ const AskAI = ({
126
129
  const inputRef = useRef<HTMLTextAreaElement>(null);
127
130
  const scrollRef = useRef<HTMLDivElement>(null);
128
131
 
129
- // Portal target (document.body) only exists after mount; guards SSR.
132
+ // Portal target (document.body) only exists after mount; guards SSR. The
133
+ // one-time false→true flip is deliberate, so the initial `false` is required.
134
+ // oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate post-mount portal guard
130
135
  useEffect(() => setMounted(true), []);
131
136
 
132
137
  // The search modal forwards its query so "Ask AI: <query>" carries straight in.
@@ -213,17 +218,19 @@ const AskAI = ({
213
218
  const decoder = new TextDecoder();
214
219
  let done = false;
215
220
  while (!done) {
216
- // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
221
+ // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
217
222
  const chunk = await reader.read();
218
223
  ({ done } = chunk);
219
224
  if (chunk.value) {
220
225
  // Streaming mode: a multi-byte UTF-8 sequence split across chunks
221
226
  // must not flush as U+FFFD garbage.
227
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
222
228
  assistant.content += decoder.decode(chunk.value, { stream: true });
223
229
  setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
224
230
  }
225
231
  }
226
232
  } catch {
233
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
227
234
  assistant.content = t.error;
228
235
  setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
229
236
  } finally {
@@ -268,7 +275,6 @@ const AskAI = ({
268
275
  className={ICON_BUTTON_CLASS}
269
276
  disabled={!hasMessages}
270
277
  onClick={copyConversation}
271
- title={t.copy}
272
278
  type="button"
273
279
  >
274
280
  <Glyph path={icons.copy} />
@@ -278,7 +284,6 @@ const AskAI = ({
278
284
  className={ICON_BUTTON_CLASS}
279
285
  disabled={!hasMessages}
280
286
  onClick={() => setMessages([])}
281
- title={t.clear}
282
287
  type="button"
283
288
  >
284
289
  <Glyph path={icons.clear} />
@@ -287,7 +292,6 @@ const AskAI = ({
287
292
  aria-label={t.close}
288
293
  className={ICON_BUTTON_CLASS}
289
294
  onClick={() => setOpen(false)}
290
- title={t.close}
291
295
  type="button"
292
296
  >
293
297
  <Glyph path={icons.close} size={18} />
@@ -311,6 +315,8 @@ const AskAI = ({
311
315
  {message.content ? (
312
316
  // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
313
317
  <div
318
+ // renderMarkdown runs marked output through DOMPurify.sanitize.
319
+ // oxlint-disable-next-line react/no-danger -- sanitized (DOMPurify) rendered-markdown output
314
320
  dangerouslySetInnerHTML={{
315
321
  __html: renderMarkdown(message.content),
316
322
  }}
@@ -339,6 +345,7 @@ const AskAI = ({
339
345
  {suggestion.icon && (
340
346
  <span
341
347
  className="shrink-0 text-muted-foreground [&_svg]:h-[18px] [&_svg]:w-[18px]"
348
+ // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
342
349
  dangerouslySetInnerHTML={{ __html: suggestion.icon }}
343
350
  />
344
351
  )}
@@ -391,7 +398,6 @@ const AskAI = ({
391
398
  aria-label={t.title}
392
399
  className={TRIGGER_CLASS}
393
400
  onClick={() => setOpen((value) => !value)}
394
- title={t.title}
395
401
  type="button"
396
402
  >
397
403
  <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
  };
@@ -86,6 +90,10 @@ export const useSearch = (): UseSearch => {
86
90
  const [loading, setLoading] = useState(false);
87
91
  const searchFn = useRef<SearchFn | null>(null);
88
92
 
93
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
94
+ // this useCallback keeps a stable `search` identity for consumers that use it
95
+ // as an effect/memo dependency. With the compiler on it's redundant but inert.
96
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
89
97
  const search = useCallback<UseSearch["search"]>(async (query, options) => {
90
98
  if (!searchFn.current) {
91
99
  const { createSearch } = await import("blume:search-client");
@@ -132,6 +140,10 @@ export const useAskAI = (): UseAskAI => {
132
140
  const [messages, setMessages] = useState<AskMessage[]>([]);
133
141
  const [loading, setLoading] = useState(false);
134
142
 
143
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
144
+ // preserves a stable `ask` identity for consumers that depend on it. With the
145
+ // compiler on it's redundant but inert.
146
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
135
147
  const ask = useCallback<UseAskAI["ask"]>(
136
148
  async (question) => {
137
149
  const trimmed = question.trim();
@@ -167,7 +179,7 @@ export const useAskAI = (): UseAskAI => {
167
179
  if (reader) {
168
180
  let done = false;
169
181
  while (!done) {
170
- // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
182
+ // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
171
183
  const chunk = await reader.read();
172
184
  ({ done } = chunk);
173
185
  if (chunk.value) {
@@ -190,6 +202,9 @@ export const useAskAI = (): UseAskAI => {
190
202
  [loading, messages]
191
203
  );
192
204
 
205
+ // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
206
+ // keeps a stable `reset` identity. With the compiler on it's redundant but inert.
207
+ // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
193
208
  const reset = useCallback(() => setMessages([]), []);
194
209
 
195
210
  return { ask, loading, messages, reset };
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import Icon from "../Icon.astro";
3
4
 
4
5
  // The site-wide announcement banner, shared by RootLayout and ReferenceLayout so
@@ -29,7 +30,7 @@ const { banner } = Astro.props;
29
30
  {banner.link && (
30
31
  <a
31
32
  class="inline-flex items-center gap-1 font-medium underline underline-offset-2"
32
- href={banner.link.href}
33
+ href={withBase(banner.link.href)}
33
34
  >
34
35
  {banner.link.text}
35
36
  <Icon name="arrow-right" size={14} />
@@ -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,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import type { ComponentOverride } from "../../core/define-components.ts";
3
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
5
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
@@ -139,7 +140,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
139
140
  : undefined
140
141
  }
141
142
  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}
143
+ href={withBase(tab.path)}
143
144
  >
144
145
  {tab.label}
145
146
  </a>
@@ -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 && (
@@ -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">
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import type { NavNode } from "../../core/types.ts";
3
3
  import Icon from "../Icon.astro";
4
+ import { withBase } from "../islands/base-path.ts";
4
5
  import Self from "./NavTree.astro";
5
6
 
6
7
  interface PagePanel {
@@ -130,7 +131,7 @@ const initialId =
130
131
  <a
131
132
  aria-current={panel.route === currentRoute ? "page" : undefined}
132
133
  class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
133
- href={panel.route}
134
+ href={withBase(panel.route)}
134
135
  >
135
136
  {panel.label}
136
137
  </a>
@@ -159,7 +160,7 @@ const initialId =
159
160
  <a
160
161
  aria-current={item.route === currentRoute ? "page" : undefined}
161
162
  class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground"
162
- href={item.route}
163
+ href={withBase(item.route)}
163
164
  >
164
165
  <span class="flex items-center gap-2">
165
166
  {item.icon && (
@@ -231,7 +232,7 @@ const initialId =
231
232
  item.route === currentRoute ? "page" : undefined
232
233
  }
233
234
  class="-my-1 flex flex-1 items-center gap-1.5 rounded py-1 transition-colors aria-[current=page]:text-foreground aria-[current=page]:font-semibold"
234
- href={item.route}
235
+ href={withBase(item.route)}
235
236
  >
236
237
  {item.icon && (
237
238
  <Icon
@@ -286,7 +287,7 @@ const initialId =
286
287
  <a
287
288
  aria-current={item.route === currentRoute ? "page" : undefined}
288
289
  class="-ml-1 flex flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
289
- href={item.route}
290
+ href={withBase(item.route)}
290
291
  >
291
292
  {item.icon && (
292
293
  <Icon
@@ -9,7 +9,10 @@ interface Props {
9
9
  }
10
10
 
11
11
  const { strings } = Astro.props;
12
- const f = strings ?? EN_UI.feedback;
12
+ // Merge over the English baseline per key (rather than `strings ?? …`) so a
13
+ // partial — or empty `{}` — strings object still resolves every label to a
14
+ // default, matching the pattern PageActions and Search use.
15
+ const f = { ...EN_UI.feedback, ...strings };
13
16
 
14
17
  const buttonClass =
15
18
  "inline-flex items-center gap-2 rounded-full border border-border px-4 py-2 text-foreground text-sm transition-colors hover:border-foreground";