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.
- package/CHANGELOG.md +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +2609 -1041
- package/dist/cli/index.js.map +110 -103
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +47 -0
- package/dist/types/core/config-input.d.ts +110 -12
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +477 -135
- package/dist/types/core/schema.d.ts +309 -195
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +24 -9
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +33 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +30 -3
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/islands.mdx +8 -0
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +17 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +15 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +11 -5
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/ask.ts +12 -7
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +143 -23
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +33 -8
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +24 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +16 -4
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +393 -189
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +9 -5
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +2 -2
- package/src/astro/pages.ts +89 -22
- package/src/astro/templates.ts +259 -25
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +131 -38
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +71 -17
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +44 -2
- package/src/cli/dev-lock.ts +34 -19
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- 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/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +25 -9
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +10 -2
- package/src/components/index.ts +23 -1
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +68 -12
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +44 -9
- package/src/components/layout/Banner.astro +12 -3
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +15 -5
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +13 -4
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +22 -7
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +51 -9
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +74 -13
- package/src/components/layout/Search.astro +107 -27
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +8 -2
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +8 -2
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +94 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +118 -17
- package/src/core/config.ts +8 -5
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +4 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +166 -88
- package/src/core/i18n-ui.ts +63 -3
- package/src/core/last-modified.ts +15 -6
- package/src/core/links.ts +69 -25
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +144 -58
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +25 -15
- package/src/core/schema.ts +605 -620
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +76 -63
- package/src/core/sources/normalize.ts +236 -91
- package/src/core/sources/notion.ts +27 -18
- 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/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +25 -2
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +59 -13
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +60 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +46 -9
- package/src/markdown/inline-code.ts +14 -4
- package/src/markdown/package-commands.ts +10 -4
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +15 -5
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +75 -21
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +247 -19
- package/src/registry/registry.ts +0 -3
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +36 -4
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +28 -17
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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" ?
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
114
|
+
if (current === generation.current) {
|
|
115
|
+
setResults(result);
|
|
116
|
+
}
|
|
98
117
|
return result;
|
|
99
118
|
} finally {
|
|
100
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
49
|
-
|
|
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">
|