blume 1.4.2 → 1.5.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 +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +116 -15
- package/dist/types/core/sources/types.d.ts +11 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +13 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +8 -5
- package/src/components/layout/search/types.ts +45 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +74 -4
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- package/src/cli/coalesce.ts +0 -43
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import DOMPurify from "dompurify";
|
|
2
|
-
import {
|
|
2
|
+
import { Marked } from "marked";
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
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 { copyText } from "../copy-feedback.ts";
|
|
8
9
|
import { joinBase, prefixBase } from "./base-path.ts";
|
|
9
10
|
import { useAskAI } from "./hooks.ts";
|
|
10
11
|
|
|
@@ -57,12 +58,15 @@ const DEFAULT_ASK: UIStrings["ask"] = {
|
|
|
57
58
|
const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
58
59
|
|
|
59
60
|
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// A dedicated instance, not the shared `marked` singleton: `setOptions`/`use`
|
|
62
|
+
// on the singleton would leak `breaks` and the link rewriter into any other
|
|
63
|
+
// consumer of `marked` on the page (user components included).
|
|
64
|
+
const markdown = new Marked({
|
|
65
|
+
breaks: true,
|
|
66
|
+
gfm: true,
|
|
67
|
+
// The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
|
|
68
|
+
// link targets to served URLs so citations resolve under `deployment.base`.
|
|
69
|
+
// `prefixBase` leaves external URLs and fragments untouched and is idempotent.
|
|
66
70
|
walkTokens: (token) => {
|
|
67
71
|
if (token.type === "link") {
|
|
68
72
|
token.href = prefixBase(import.meta.env.BASE_URL, token.href);
|
|
@@ -71,7 +75,7 @@ marked.use({
|
|
|
71
75
|
});
|
|
72
76
|
|
|
73
77
|
const renderMarkdown = (content: string): string =>
|
|
74
|
-
DOMPurify.sanitize(
|
|
78
|
+
DOMPurify.sanitize(markdown.parse(content, { async: false }));
|
|
75
79
|
|
|
76
80
|
const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
77
81
|
<svg
|
|
@@ -96,12 +100,21 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
|
96
100
|
const EMPTY_SUGGESTIONS: Suggestion[] = [];
|
|
97
101
|
|
|
98
102
|
// The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
|
|
99
|
-
// platform (same detection Search.astro uses for its ⌘K hint). Guarded
|
|
100
|
-
// island still server-renders
|
|
101
|
-
// only renders client-side, inside the portaled panel.
|
|
103
|
+
// platform (same detection Search.astro uses for its ⌘K hint). Guarded via
|
|
104
|
+
// `globalThis` so the island still server-renders where `navigator` doesn't
|
|
105
|
+
// exist; the hint itself only renders client-side, inside the portaled panel.
|
|
102
106
|
const IS_APPLE =
|
|
103
|
-
|
|
104
|
-
/mac|iphone|ipad|ipod/iu.test(navigator.platform);
|
|
107
|
+
globalThis.navigator !== undefined &&
|
|
108
|
+
/mac|iphone|ipad|ipod/iu.test(globalThis.navigator.platform);
|
|
109
|
+
|
|
110
|
+
// Duck-typed (`closest` presence) rather than `instanceof Element`, which
|
|
111
|
+
// needs a DOM global the test environment doesn't provide.
|
|
112
|
+
const isElementLike = (target: EventTarget | null): target is Element => {
|
|
113
|
+
// SAFETY: the cast only names the probed surface; `closest` is verified to
|
|
114
|
+
// exist before the caller uses it.
|
|
115
|
+
const candidate = target as Partial<Element> | null;
|
|
116
|
+
return typeof candidate?.closest === "function";
|
|
117
|
+
};
|
|
105
118
|
|
|
106
119
|
// Ghost icon button, matching the header's theme toggle and repo link.
|
|
107
120
|
const TRIGGER_CLASS =
|
|
@@ -146,6 +159,8 @@ const AskAI = ({
|
|
|
146
159
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
147
160
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
148
161
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
162
|
+
// The portaled panel root, excluded from the overlay-mode inert sweep.
|
|
163
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
149
164
|
// Where focus came from when the panel opened, restored on close.
|
|
150
165
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
151
166
|
|
|
@@ -157,6 +172,8 @@ const AskAI = ({
|
|
|
157
172
|
// The search modal forwards its query so "Ask AI: <query>" carries straight in.
|
|
158
173
|
useEffect(() => {
|
|
159
174
|
const handler = (event: Event) => {
|
|
175
|
+
// SAFETY: `blume:open-ask-ai` is only ever dispatched as a CustomEvent
|
|
176
|
+
// whose optional detail carries the search query.
|
|
160
177
|
const query = (event as CustomEvent<{ query?: string }>).detail?.query;
|
|
161
178
|
if (query) {
|
|
162
179
|
setInput(query);
|
|
@@ -184,11 +201,8 @@ const AskAI = ({
|
|
|
184
201
|
// An Escape aimed at a modal surface stacked on top (the search
|
|
185
202
|
// dialog traps focus inside itself) dismisses that surface only —
|
|
186
203
|
// this window listener still fires for it, and closing the panel
|
|
187
|
-
// underneath too would eat the user's conversation view.
|
|
188
|
-
|
|
189
|
-
// a DOM global the test environment doesn't provide.
|
|
190
|
-
const target = event.target as Partial<Element> | null;
|
|
191
|
-
if (typeof target?.closest === "function" && target.closest("dialog")) {
|
|
204
|
+
// underneath too would eat the user's conversation view.
|
|
205
|
+
if (isElementLike(event.target) && event.target.closest("dialog")) {
|
|
192
206
|
return;
|
|
193
207
|
}
|
|
194
208
|
setOpen(false);
|
|
@@ -225,6 +239,68 @@ const AskAI = ({
|
|
|
225
239
|
};
|
|
226
240
|
}, [open]);
|
|
227
241
|
|
|
242
|
+
// Below the desktop dock breakpoint the open panel is a full-width overlay,
|
|
243
|
+
// so Tab must not escape into the page it covers: every other child of
|
|
244
|
+
// <body> (the panel portals to body) turns inert until close. The desktop
|
|
245
|
+
// dock keeps the page interactive on purpose — it's a non-modal side panel,
|
|
246
|
+
// so no sweep runs at ≥1024px. Elements that were already inert are left
|
|
247
|
+
// alone so closing doesn't accidentally re-enable them.
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!open) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const media = window.matchMedia("(min-width: 1024px)");
|
|
253
|
+
let inerted: Element[] = [];
|
|
254
|
+
const release = () => {
|
|
255
|
+
for (const el of inerted) {
|
|
256
|
+
el.removeAttribute("inert");
|
|
257
|
+
}
|
|
258
|
+
inerted = [];
|
|
259
|
+
};
|
|
260
|
+
const apply = () => {
|
|
261
|
+
release();
|
|
262
|
+
if (media.matches) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
inerted = [...document.body.children].filter(
|
|
266
|
+
(el) => el !== panelRef.current && !el.hasAttribute("inert")
|
|
267
|
+
);
|
|
268
|
+
for (const el of inerted) {
|
|
269
|
+
el.setAttribute("inert", "");
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
apply();
|
|
273
|
+
// The sweep snapshots body's children at open time, but overlays keep
|
|
274
|
+
// arriving afterwards — medium-zoom's backdrop, a mermaid render, another
|
|
275
|
+
// island's portal all append to <body> — and an unswept latecomer is a
|
|
276
|
+
// tab stop hiding behind the overlay. Fold additions into the sweep for
|
|
277
|
+
// as long as it is active.
|
|
278
|
+
const observer = new MutationObserver((records) => {
|
|
279
|
+
if (media.matches) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
for (const record of records) {
|
|
283
|
+
for (const node of record.addedNodes) {
|
|
284
|
+
if (
|
|
285
|
+
node instanceof HTMLElement &&
|
|
286
|
+
node !== panelRef.current &&
|
|
287
|
+
!node.hasAttribute("inert")
|
|
288
|
+
) {
|
|
289
|
+
node.setAttribute("inert", "");
|
|
290
|
+
inerted.push(node);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
observer.observe(document.body, { childList: true });
|
|
296
|
+
media.addEventListener("change", apply);
|
|
297
|
+
return () => {
|
|
298
|
+
observer.disconnect();
|
|
299
|
+
media.removeEventListener("change", apply);
|
|
300
|
+
release();
|
|
301
|
+
};
|
|
302
|
+
}, [open]);
|
|
303
|
+
|
|
228
304
|
// Keep the newest message in view as it streams in.
|
|
229
305
|
useEffect(() => {
|
|
230
306
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
@@ -265,7 +341,7 @@ const AskAI = ({
|
|
|
265
341
|
const text = messages
|
|
266
342
|
.map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
|
|
267
343
|
.join("\n\n");
|
|
268
|
-
void
|
|
344
|
+
void copyText(text);
|
|
269
345
|
};
|
|
270
346
|
|
|
271
347
|
const hasMessages = messages.length > 0;
|
|
@@ -274,15 +350,16 @@ const AskAI = ({
|
|
|
274
350
|
<aside
|
|
275
351
|
aria-hidden={open ? undefined : "true"}
|
|
276
352
|
aria-label={t.title}
|
|
353
|
+
ref={panelRef}
|
|
277
354
|
// The closed panel is only translated off-screen; `inert` drops its
|
|
278
355
|
// buttons/textarea from the tab order and the accessibility tree.
|
|
279
356
|
inert={!open}
|
|
280
|
-
className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-
|
|
357
|
+
className={`border-border bg-background fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-s shadow-2xl transition-transform duration-200 ease-out ${
|
|
281
358
|
open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
|
282
359
|
}`}
|
|
283
360
|
>
|
|
284
|
-
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-
|
|
285
|
-
<span className="font-semibold
|
|
361
|
+
<header className="border-border flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
|
|
362
|
+
<span className="text-foreground font-semibold">{t.title}</span>
|
|
286
363
|
<div className="flex items-center gap-0.5">
|
|
287
364
|
<button
|
|
288
365
|
aria-label={t.copy}
|
|
@@ -314,7 +391,7 @@ const AskAI = ({
|
|
|
314
391
|
</header>
|
|
315
392
|
|
|
316
393
|
<div
|
|
317
|
-
className="flex flex-1
|
|
394
|
+
className="scrollbar-thumb-border flex flex-1 scrollbar-thin scrollbar-track-transparent flex-col overflow-y-auto"
|
|
318
395
|
ref={scrollRef}
|
|
319
396
|
>
|
|
320
397
|
{hasMessages ? (
|
|
@@ -324,7 +401,7 @@ const AskAI = ({
|
|
|
324
401
|
{messages.map((message, index) =>
|
|
325
402
|
message.role === "user" ? (
|
|
326
403
|
<div
|
|
327
|
-
className="max-w-[85%] self-end
|
|
404
|
+
className="rounded-blume bg-muted text-foreground max-w-[85%] self-end px-3 py-2 text-sm whitespace-pre-wrap"
|
|
328
405
|
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
329
406
|
key={index}
|
|
330
407
|
>
|
|
@@ -343,7 +420,7 @@ const AskAI = ({
|
|
|
343
420
|
}}
|
|
344
421
|
/>
|
|
345
422
|
) : (
|
|
346
|
-
<span className="
|
|
423
|
+
<span className="text-muted-foreground animate-pulse">
|
|
347
424
|
…
|
|
348
425
|
</span>
|
|
349
426
|
)}
|
|
@@ -354,18 +431,18 @@ const AskAI = ({
|
|
|
354
431
|
) : (
|
|
355
432
|
<div className="mt-auto flex flex-col gap-0.5 p-4">
|
|
356
433
|
{suggestions.length === 0 && (
|
|
357
|
-
<p className="
|
|
434
|
+
<p className="text-muted-foreground px-2 text-sm">{t.empty}</p>
|
|
358
435
|
)}
|
|
359
436
|
{suggestions.map((suggestion) => (
|
|
360
437
|
<button
|
|
361
|
-
className="flex cursor-pointer items-center gap-2.5
|
|
438
|
+
className="rounded-blume text-foreground hover:bg-muted flex cursor-pointer items-center gap-2.5 px-2 py-2 text-start text-sm transition-colors"
|
|
362
439
|
key={suggestion.label}
|
|
363
440
|
onClick={() => runQuestion(suggestion.label)}
|
|
364
441
|
type="button"
|
|
365
442
|
>
|
|
366
443
|
{suggestion.icon && (
|
|
367
444
|
<span
|
|
368
|
-
className="
|
|
445
|
+
className="text-muted-foreground shrink-0 [&_svg]:h-[18px] [&_svg]:w-[18px]"
|
|
369
446
|
// oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
|
|
370
447
|
dangerouslySetInnerHTML={{ __html: suggestion.icon }}
|
|
371
448
|
/>
|
|
@@ -373,12 +450,12 @@ const AskAI = ({
|
|
|
373
450
|
<span>{suggestion.label}</span>
|
|
374
451
|
</button>
|
|
375
452
|
))}
|
|
376
|
-
<p className="mt-3 flex items-center gap-1.5 px-2 text-
|
|
453
|
+
<p className="text-muted-foreground mt-3 flex items-center gap-1.5 px-2 text-sm">
|
|
377
454
|
{t.tip}
|
|
378
|
-
<kbd className="
|
|
455
|
+
<kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
|
|
379
456
|
{IS_APPLE ? "⌘" : "Ctrl"}
|
|
380
457
|
</kbd>
|
|
381
|
-
<kbd className="
|
|
458
|
+
<kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
|
|
382
459
|
I
|
|
383
460
|
</kbd>
|
|
384
461
|
</p>
|
|
@@ -387,12 +464,12 @@ const AskAI = ({
|
|
|
387
464
|
</div>
|
|
388
465
|
|
|
389
466
|
<form
|
|
390
|
-
className="relative shrink-0 border-
|
|
467
|
+
className="border-border relative shrink-0 border-t"
|
|
391
468
|
onSubmit={onSubmit}
|
|
392
469
|
>
|
|
393
470
|
<textarea
|
|
394
471
|
aria-label={t.label}
|
|
395
|
-
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-
|
|
472
|
+
className="text-foreground placeholder:text-muted-foreground max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-sm outline-none pointer-coarse:text-base"
|
|
396
473
|
onChange={(event) => setInput(event.target.value)}
|
|
397
474
|
onKeyDown={onInputKeyDown}
|
|
398
475
|
placeholder={t.placeholder}
|
|
@@ -402,7 +479,7 @@ const AskAI = ({
|
|
|
402
479
|
/>
|
|
403
480
|
<button
|
|
404
481
|
aria-label={t.send}
|
|
405
|
-
className="absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center
|
|
482
|
+
className="rounded-blume bg-foreground text-background absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
|
406
483
|
disabled={busy || input.trim().length === 0}
|
|
407
484
|
type="submit"
|
|
408
485
|
>
|
|
@@ -29,7 +29,9 @@ const readClientData = (): BlumeClientData | null => {
|
|
|
29
29
|
if (cachedData) {
|
|
30
30
|
return cachedData;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
// `document` is undeclared on the server; probing `globalThis` avoids both
|
|
33
|
+
// the bare-reference ReferenceError and a `typeof` sniff.
|
|
34
|
+
if (!("document" in globalThis)) {
|
|
33
35
|
return null;
|
|
34
36
|
}
|
|
35
37
|
const element = document.querySelector("#blume-client-data");
|
|
@@ -37,6 +39,8 @@ const readClientData = (): BlumeClientData | null => {
|
|
|
37
39
|
return null;
|
|
38
40
|
}
|
|
39
41
|
try {
|
|
42
|
+
// SAFETY: the layout serialized this script tag's JSON from the same
|
|
43
|
+
// `BlumeClientData` snapshot this reads back.
|
|
40
44
|
cachedData = JSON.parse(element.textContent) as BlumeClientData;
|
|
41
45
|
return cachedData;
|
|
42
46
|
} catch {
|
|
@@ -16,11 +16,36 @@ interface WebMcpResult {
|
|
|
16
16
|
isError?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A JSON value as a WebMCP agent may supply it in a tool call. */
|
|
20
|
+
export type WebMcpJsonValue =
|
|
21
|
+
| boolean
|
|
22
|
+
| number
|
|
23
|
+
| string
|
|
24
|
+
| null
|
|
25
|
+
| WebMcpJsonValue[]
|
|
26
|
+
| { [key: string]: WebMcpJsonValue };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tool arguments exactly as the calling agent supplied them. WebMCP doesn't
|
|
30
|
+
* guarantee schema validation, so each tool checks its own fields at runtime.
|
|
31
|
+
*/
|
|
32
|
+
export interface WebMcpToolArgs {
|
|
33
|
+
query?: WebMcpJsonValue;
|
|
34
|
+
route?: WebMcpJsonValue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The JSON Schema subset these string-argument tools declare. */
|
|
38
|
+
export interface WebMcpInputSchema {
|
|
39
|
+
properties: Record<string, { description: string; type: "string" }>;
|
|
40
|
+
required?: string[];
|
|
41
|
+
type: "object";
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
export interface WebMcpTool {
|
|
20
45
|
annotations: { openWorldHint: boolean; readOnlyHint: boolean };
|
|
21
46
|
description: string;
|
|
22
|
-
execute: (input:
|
|
23
|
-
inputSchema:
|
|
47
|
+
execute: (input: WebMcpToolArgs) => Promise<WebMcpResult>;
|
|
48
|
+
inputSchema: WebMcpInputSchema;
|
|
24
49
|
name: string;
|
|
25
50
|
}
|
|
26
51
|
|
|
@@ -31,14 +56,26 @@ export interface WebMcpTool {
|
|
|
31
56
|
* individually via `registerTool`.
|
|
32
57
|
*/
|
|
33
58
|
export interface ModelContext {
|
|
34
|
-
provideContext?: (context: { tools: WebMcpTool[] }) =>
|
|
35
|
-
registerTool?: (tool: WebMcpTool) =>
|
|
59
|
+
provideContext?: (context: { tools: WebMcpTool[] }) => void;
|
|
60
|
+
registerTool?: (tool: WebMcpTool) => void;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
const text = (value: string, isError = false): WebMcpResult =>
|
|
39
|
-
content: [{ text: value, type: "text" }]
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
const text = (value: string, isError = false): WebMcpResult => {
|
|
64
|
+
const result: WebMcpResult = { content: [{ text: value, type: "text" }] };
|
|
65
|
+
if (isError) {
|
|
66
|
+
result.isError = true;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Validates an agent-supplied tool argument before it is used as a string. */
|
|
72
|
+
const isString = (value: WebMcpJsonValue | undefined): value is string =>
|
|
73
|
+
typeof value === "string";
|
|
74
|
+
|
|
75
|
+
/** Detects which registration surface a model context actually implements. */
|
|
76
|
+
const isCallable = <T extends (...args: never[]) => void>(
|
|
77
|
+
value: T | undefined
|
|
78
|
+
): value is T => typeof value === "function";
|
|
42
79
|
|
|
43
80
|
const TAG = /<[^>]*>?/gu;
|
|
44
81
|
|
|
@@ -89,7 +126,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
|
|
|
89
126
|
description:
|
|
90
127
|
"Full-text search across this documentation site. Returns matching pages with their title, URL, and a short excerpt.",
|
|
91
128
|
async execute(input) {
|
|
92
|
-
const query =
|
|
129
|
+
const query = isString(input.query) ? input.query : "";
|
|
93
130
|
if (!query.trim()) {
|
|
94
131
|
return text("Provide a non-empty `query` string.", true);
|
|
95
132
|
}
|
|
@@ -128,7 +165,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
|
|
|
128
165
|
description:
|
|
129
166
|
"Fetch a page of this site as plain Markdown. Pass the page's root-relative route, e.g. `/quickstart`.",
|
|
130
167
|
async execute(input) {
|
|
131
|
-
const route =
|
|
168
|
+
const route = isString(input.route) ? input.route : "";
|
|
132
169
|
if (!route.startsWith("/")) {
|
|
133
170
|
return text("Pass a root-relative route, e.g. `/quickstart`.", true);
|
|
134
171
|
}
|
|
@@ -189,11 +226,11 @@ export const registerWebMcpTools = (
|
|
|
189
226
|
if (!context) {
|
|
190
227
|
return false;
|
|
191
228
|
}
|
|
192
|
-
if (
|
|
229
|
+
if (isCallable(context.provideContext)) {
|
|
193
230
|
context.provideContext({ tools });
|
|
194
231
|
return true;
|
|
195
232
|
}
|
|
196
|
-
if (
|
|
233
|
+
if (isCallable(context.registerTool)) {
|
|
197
234
|
for (const tool of tools) {
|
|
198
235
|
context.registerTool(tool);
|
|
199
236
|
}
|
|
@@ -5,7 +5,11 @@ import { withBase } from "../islands/base-path.ts";
|
|
|
5
5
|
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
6
6
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
LocaleSwitchOption,
|
|
10
|
+
Navigation,
|
|
11
|
+
NavSelector as NavSelectorConfig,
|
|
12
|
+
} from "../../core/types.ts";
|
|
9
13
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
10
14
|
import Icon from "../Icon.astro";
|
|
11
15
|
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
|
@@ -50,8 +54,16 @@ interface Props {
|
|
|
50
54
|
/** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
|
|
51
55
|
navStrings?: UIStrings["nav"];
|
|
52
56
|
localeSwitch?: LocaleSwitchOption[];
|
|
57
|
+
/**
|
|
58
|
+
* Auto-populated version switcher, rendered ahead of the configured
|
|
59
|
+
* selectors. `null`/absent when versioning is off — or when the user
|
|
60
|
+
* declares their own `kind: "version"` selector, which then owns the UI.
|
|
61
|
+
*/
|
|
62
|
+
versionSelector?: NavSelectorConfig | null;
|
|
53
63
|
/** Active locale for per-language search filtering. */
|
|
54
64
|
searchLocale?: string;
|
|
65
|
+
/** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
|
|
66
|
+
searchVersion?: string | null;
|
|
55
67
|
/**
|
|
56
68
|
* Layout-slot overrides forwarded from the root layout. The header honors
|
|
57
69
|
* `Logo` and `Search` here so those pieces can be replaced without swapping
|
|
@@ -77,7 +89,9 @@ const {
|
|
|
77
89
|
switcherStrings,
|
|
78
90
|
navStrings,
|
|
79
91
|
localeSwitch,
|
|
92
|
+
versionSelector,
|
|
80
93
|
searchLocale,
|
|
94
|
+
searchVersion = null,
|
|
81
95
|
layout = {},
|
|
82
96
|
} = Astro.props;
|
|
83
97
|
|
|
@@ -167,6 +181,15 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
167
181
|
)
|
|
168
182
|
}
|
|
169
183
|
<div class="flex-1"></div>
|
|
184
|
+
{
|
|
185
|
+
/* Right-aligned next to the language switcher: the spacer absorbs its
|
|
186
|
+
width, so pages without a version selector (blog, generated references)
|
|
187
|
+
keep the logo, tabs, and the rest of this cluster in place — no layout
|
|
188
|
+
shift when crossing into the docs. */
|
|
189
|
+
versionSelector && (
|
|
190
|
+
<NavSelector align="end" route={route} selector={versionSelector} />
|
|
191
|
+
)
|
|
192
|
+
}
|
|
170
193
|
{
|
|
171
194
|
localeSwitch && localeSwitch.length > 1 && (
|
|
172
195
|
<LanguageSwitcher
|
|
@@ -184,6 +207,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
184
207
|
navigation={navigation}
|
|
185
208
|
popularPages={data.config.search.popular}
|
|
186
209
|
strings={searchStrings}
|
|
210
|
+
version={searchVersion}
|
|
187
211
|
/>
|
|
188
212
|
)
|
|
189
213
|
}
|
|
@@ -11,9 +11,16 @@ import { isUnderPath } from "./nav-utils.ts";
|
|
|
11
11
|
interface Props {
|
|
12
12
|
selector: NavSelector;
|
|
13
13
|
route: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which edge the dropdown panel anchors to. `start` (the default) suits the
|
|
16
|
+
* header's leading selector group; the version selector sits at the trailing
|
|
17
|
+
* edge next to the language switcher, where a start-anchored panel would
|
|
18
|
+
* overflow the viewport — pass `end` there.
|
|
19
|
+
*/
|
|
20
|
+
align?: "start" | "end";
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
const { selector, route } = Astro.props;
|
|
23
|
+
const { selector, route, align = "start" } = Astro.props;
|
|
17
24
|
|
|
18
25
|
// The active item is the deepest path the current route sits under (on a path
|
|
19
26
|
// boundary, so `/api` never claims `/api-reference` routes), falling back to
|
|
@@ -46,7 +53,9 @@ const menuRowClass =
|
|
|
46
53
|
size={14}
|
|
47
54
|
/>
|
|
48
55
|
</summary>
|
|
49
|
-
<div
|
|
56
|
+
<div
|
|
57
|
+
class={`absolute z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl ${align === "end" ? "end-0" : "start-0"}`}
|
|
58
|
+
>
|
|
50
59
|
{selector.items.map((item) => (
|
|
51
60
|
<a
|
|
52
61
|
aria-current={item.path === active?.path ? "true" : undefined}
|
|
@@ -45,8 +45,8 @@ const n = { ...EN_UI.nav, ...strings };
|
|
|
45
45
|
const badgeBase =
|
|
46
46
|
"shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
|
|
47
47
|
const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
|
|
48
|
-
// HTTP-method badges (from
|
|
49
|
-
// every other badge keeps the neutral style.
|
|
48
|
+
// HTTP-method and AsyncAPI-action badges (from a reference's sidebar) are
|
|
49
|
+
// color-coded to match MethodBadge; every other badge keeps the neutral style.
|
|
50
50
|
const METHOD_BADGE: Record<string, string> = {
|
|
51
51
|
DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
|
|
52
52
|
GET: "bg-green-500/15 text-green-700 dark:text-green-300",
|
|
@@ -55,6 +55,8 @@ const METHOD_BADGE: Record<string, string> = {
|
|
|
55
55
|
PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
|
|
56
56
|
POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
|
57
57
|
PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
|
|
58
|
+
RECEIVE: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
|
|
59
|
+
SEND: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
|
58
60
|
};
|
|
59
61
|
const badgeClassFor = (badge: string): string => {
|
|
60
62
|
const method = METHOD_BADGE[badge.toUpperCase()];
|
|
@@ -224,7 +224,9 @@ const menuRowClass =
|
|
|
224
224
|
</div>
|
|
225
225
|
|
|
226
226
|
<script>
|
|
227
|
+
import { copyText, flashLabel } from "../copy-feedback.ts";
|
|
227
228
|
import { prefixBase } from "../islands/base-path.ts";
|
|
229
|
+
import { rafThrottle } from "../raf-throttle.ts";
|
|
228
230
|
|
|
229
231
|
const CHAT_URLS: Record<string, (q: string) => string> = {
|
|
230
232
|
chatgpt: (q) => `https://chatgpt.com/?hints=search&prompt=${q}`,
|
|
@@ -318,12 +320,17 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
318
320
|
placeMenu(details);
|
|
319
321
|
});
|
|
320
322
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
// rAF-coalesced so a live resize drag re-reads layout once per frame,
|
|
324
|
+
// not once per event, while the open menu still tracks the viewport.
|
|
325
|
+
window.addEventListener(
|
|
326
|
+
"resize",
|
|
327
|
+
rafThrottle(() => {
|
|
328
|
+
const open = dropdowns.find((details) => details.open);
|
|
329
|
+
if (open) {
|
|
330
|
+
placeMenu(open);
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
);
|
|
327
334
|
|
|
328
335
|
// `data-md` is the base-less logical route; the raw-markdown endpoint is a
|
|
329
336
|
// generated page route, so it's served under the deployment base like any
|
|
@@ -376,24 +383,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
376
383
|
`vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: id, type: "http", url: mcpUrl }))}`
|
|
377
384
|
);
|
|
378
385
|
|
|
379
|
-
const flash = (el: Element | null, text: string) => {
|
|
380
|
-
if (!(el instanceof HTMLElement)) {
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
// Remember the element's own (localized) label once — capturing it at
|
|
384
|
-
// click time would capture "Copied!" on a double-click and stick.
|
|
385
|
-
el.dataset.blumeLabel ??= el.textContent ?? "";
|
|
386
|
-
el.textContent = text;
|
|
387
|
-
setTimeout(() => {
|
|
388
|
-
el.textContent = el.dataset.blumeLabel ?? "";
|
|
389
|
-
}, 1500);
|
|
390
|
-
};
|
|
391
386
|
const copy = async (value: string, el: Element | null) => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
flash(el, copiedLabel);
|
|
395
|
-
} catch {
|
|
396
|
-
// Clipboard unavailable; nothing to do.
|
|
387
|
+
if ((await copyText(value)) && el instanceof HTMLElement) {
|
|
388
|
+
flashLabel(el, copiedLabel);
|
|
397
389
|
}
|
|
398
390
|
};
|
|
399
391
|
root
|
|
@@ -420,9 +412,6 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
420
412
|
}
|
|
421
413
|
|
|
422
414
|
const label = root.querySelector("[data-blume-copy-label]");
|
|
423
|
-
// Captured once — inside the handler a double-click would capture and
|
|
424
|
-
// permanently restore "Copied!".
|
|
425
|
-
const original = label?.textContent ?? "";
|
|
426
415
|
root
|
|
427
416
|
.querySelector("[data-blume-copy-page]")
|
|
428
417
|
?.addEventListener("click", async () => {
|
|
@@ -431,12 +420,11 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
431
420
|
if (!response.ok) {
|
|
432
421
|
throw new Error(`Fetching ${md} failed (${response.status})`);
|
|
433
422
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
label
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}, 1500);
|
|
423
|
+
if (
|
|
424
|
+
(await copyText(await response.text())) &&
|
|
425
|
+
label instanceof HTMLElement
|
|
426
|
+
) {
|
|
427
|
+
flashLabel(label, copiedLabel);
|
|
440
428
|
}
|
|
441
429
|
} catch (error) {
|
|
442
430
|
// Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.
|