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.
- package/dist/cli/index.js +1179 -738
- package/dist/cli/index.js.map +52 -51
- package/dist/types/core/base-path.d.ts +38 -0
- package/dist/types/core/config-input.d.ts +74 -10
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +1 -3
- package/dist/types/core/schema.d.ts +95 -52
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/docs/02-deployment.mdx +16 -1
- package/docs/03-faq.mdx +8 -8
- package/docs/configuration/index.mdx +6 -0
- package/docs/content/components.mdx +29 -2
- package/docs/content/islands.mdx +8 -0
- package/docs/content/syntax.mdx +13 -0
- package/package.json +2 -1
- package/src/ai/agent-readability.ts +7 -2
- package/src/ai/ask.ts +12 -7
- package/src/ai/llms.ts +15 -4
- package/src/ai/mcp/data.ts +8 -4
- package/src/ai/mcp/server.ts +3 -0
- package/src/astro/component-slots.ts +5 -3
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +317 -144
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +8 -4
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +8 -3
- package/src/astro/templates.ts +166 -19
- package/src/cli/commands/build.ts +32 -19
- package/src/cli/commands/dev.ts +48 -15
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/dev-lock.ts +26 -15
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/auto-type-table.ts +18 -8
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +3 -0
- package/src/components/index.ts +23 -1
- package/src/components/islands/ask-ai.tsx +12 -6
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +16 -1
- package/src/components/layout/Banner.astro +2 -1
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +2 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +2 -1
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +5 -4
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +9 -4
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/RootLayout.astro +7 -4
- package/src/components/layout/Search.astro +13 -5
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/toc-element.ts +7 -1
- package/src/components/openapi/RequestPanel.astro +7 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +70 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +81 -15
- package/src/core/config.ts +5 -3
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +156 -88
- package/src/core/i18n-ui.ts +18 -3
- package/src/core/last-modified.ts +2 -0
- package/src/core/links.ts +38 -18
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +116 -55
- package/src/core/project-graph.ts +10 -9
- package/src/core/schema.ts +572 -621
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +58 -54
- package/src/core/sources/normalize.ts +116 -73
- package/src/core/sources/notion.ts +19 -10
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/deploy/redirects.ts +18 -0
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +14 -10
- package/src/markdown/base-links.ts +58 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +34 -9
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +3 -1
- package/src/openapi/references.ts +41 -17
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/registry/eject.ts +64 -8
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +2 -2
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +16 -6
- 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${
|
|
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 }
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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:
|
|
40
|
-
options:
|
|
49
|
+
ts: TypeScriptApi,
|
|
50
|
+
options: CompilerOptions,
|
|
41
51
|
source: string
|
|
42
|
-
):
|
|
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
|
|
78
|
+
const ts = (tsModule.default ?? tsModule) as TypeScriptApi;
|
|
69
79
|
|
|
70
|
-
const 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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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";
|