blume 1.2.0 → 1.3.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 +45 -0
- package/dist/cli/index.js +1715 -539
- package/dist/cli/index.js.map +40 -31
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +17 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +14 -8
- package/src/ai/mcp/stdio.ts +4 -1
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +191 -37
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +178 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/NavTree.astro +4 -4
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +63 -11
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/components/layout/search/orama.ts +5 -2
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +233 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +151 -7
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
4
|
import { build } from "astro";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
|
-
import { join } from "pathe";
|
|
6
|
+
import { dirname, join, resolve } from "pathe";
|
|
7
7
|
|
|
8
8
|
import { buildAgentReadability } from "../../ai/agent-readability.ts";
|
|
9
|
+
import {
|
|
10
|
+
API_CATALOG_PATH,
|
|
11
|
+
API_CATALOG_TYPE,
|
|
12
|
+
buildApiCatalog,
|
|
13
|
+
hasApiCatalog,
|
|
14
|
+
} from "../../ai/api-catalog.ts";
|
|
15
|
+
import { buildHomeLinkHeader } from "../../ai/link-headers.ts";
|
|
9
16
|
import { buildLlmsFiles } from "../../ai/llms.ts";
|
|
17
|
+
import {
|
|
18
|
+
AGENT_SKILLS_DIR,
|
|
19
|
+
buildSkillsIndex,
|
|
20
|
+
collectSkills,
|
|
21
|
+
} from "../../ai/skills.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildSignaturesDirectory,
|
|
24
|
+
SIGNATURES_DIRECTORY_PATH,
|
|
25
|
+
SIGNATURES_DIRECTORY_TYPE,
|
|
26
|
+
} from "../../ai/web-bot-auth.ts";
|
|
10
27
|
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
11
28
|
import type { BlumeProject } from "../../core/project-graph.ts";
|
|
12
29
|
import type { ResolvedConfig } from "../../core/schema.ts";
|
|
@@ -26,6 +43,7 @@ import {
|
|
|
26
43
|
} from "../../deploy/redirects.ts";
|
|
27
44
|
import { buildRobots } from "../../deploy/robots.ts";
|
|
28
45
|
import { buildSitemap } from "../../deploy/sitemap.ts";
|
|
46
|
+
import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
|
|
29
47
|
import { buildSearchIndex } from "../../search/build.ts";
|
|
30
48
|
import { syncSearchProvider } from "../../search/sync/index.ts";
|
|
31
49
|
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
@@ -107,14 +125,17 @@ const emitRedirectFiles = async (
|
|
|
107
125
|
* hosts serve the raw AI-ready endpoints (`*.md`, `*.mdx`, `*.txt`) with an
|
|
108
126
|
* explicit `charset=utf-8`. Without it those hosts send `text/markdown` /
|
|
109
127
|
* `text/plain` with no charset and browsers fall back to Windows-1252, garbling
|
|
110
|
-
* any non-ASCII docs (#82).
|
|
111
|
-
*
|
|
112
|
-
*
|
|
128
|
+
* any non-ASCII docs (#82). The same file also stamps the homepage
|
|
129
|
+
* agent-discovery `Link` header (RFC 8288, see `ai/link-headers.ts`). A
|
|
130
|
+
* `_headers` shipped in `public/` (copied into dist by Astro before this runs)
|
|
131
|
+
* wins, exactly like `_redirects`. Server adapters set the Content-Type on the
|
|
132
|
+
* Response directly, so this is static-only.
|
|
113
133
|
*/
|
|
114
134
|
const emitHeaderFiles = async (
|
|
115
|
-
|
|
135
|
+
project: BlumeProject,
|
|
116
136
|
distDir: string
|
|
117
137
|
): Promise<void> => {
|
|
138
|
+
const { config } = project;
|
|
118
139
|
if (
|
|
119
140
|
config.deployment.output !== "static" ||
|
|
120
141
|
existsSync(join(distDir, "_headers"))
|
|
@@ -123,10 +144,147 @@ const emitHeaderFiles = async (
|
|
|
123
144
|
}
|
|
124
145
|
await writeFile(
|
|
125
146
|
join(distDir, "_headers"),
|
|
126
|
-
buildNetlifyHeaders(
|
|
147
|
+
buildNetlifyHeaders(
|
|
148
|
+
config,
|
|
149
|
+
buildHomeLinkHeader(
|
|
150
|
+
config,
|
|
151
|
+
project.manifest.routes.map((route) => route.path)
|
|
152
|
+
)
|
|
153
|
+
),
|
|
127
154
|
"utf-8"
|
|
128
155
|
);
|
|
129
|
-
logger.success(
|
|
156
|
+
logger.success(
|
|
157
|
+
"Emitted _headers (UTF-8 Content-Type + homepage Link header)"
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Publish the configured Agent Skills: copy each skill artifact under
|
|
163
|
+
* `.well-known/agent-skills/` and emit the discovery index. A user-shipped
|
|
164
|
+
* `public/.well-known/agent-skills/index.json` takes over the whole surface,
|
|
165
|
+
* matching every other generated artifact.
|
|
166
|
+
*/
|
|
167
|
+
const emitAgentSkills = async (
|
|
168
|
+
project: BlumeProject,
|
|
169
|
+
distDir: string
|
|
170
|
+
): Promise<void> => {
|
|
171
|
+
const configured = project.config.ai.skills;
|
|
172
|
+
if (!configured) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const dir = resolve(project.context.root, configured);
|
|
176
|
+
if (!existsSync(dir)) {
|
|
177
|
+
logger.warn(
|
|
178
|
+
`ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
|
|
183
|
+
if (existsSync(join(outDir, "index.json"))) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const { skills, warnings } = await collectSkills(dir);
|
|
187
|
+
for (const warning of warnings) {
|
|
188
|
+
logger.warn(warning);
|
|
189
|
+
}
|
|
190
|
+
if (skills.length === 0) {
|
|
191
|
+
logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await Promise.all(
|
|
195
|
+
skills.map(async (skill) => {
|
|
196
|
+
const target = join(outDir, skill.path);
|
|
197
|
+
await mkdir(dirname(target), { recursive: true });
|
|
198
|
+
await writeFile(target, skill.content);
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
await writeFile(
|
|
202
|
+
join(outDir, "index.json"),
|
|
203
|
+
buildSkillsIndex(skills, project.config),
|
|
204
|
+
"utf-8"
|
|
205
|
+
);
|
|
206
|
+
logger.success(
|
|
207
|
+
`Published ${skills.length} agent skill(s) (.well-known/agent-skills/index.json)`
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Emit the generated `.well-known` discovery files — the RFC 9727 API catalog
|
|
213
|
+
* and the Web Bot Auth signature directory — each skipped when the feature is
|
|
214
|
+
* off or when the user ships their own copy via public/ (already in dist by
|
|
215
|
+
* the time this runs).
|
|
216
|
+
*/
|
|
217
|
+
const emitWellKnownFiles = async (
|
|
218
|
+
config: ResolvedConfig,
|
|
219
|
+
distDir: string
|
|
220
|
+
): Promise<void> => {
|
|
221
|
+
const files = [
|
|
222
|
+
{
|
|
223
|
+
content: buildSignaturesDirectory(config),
|
|
224
|
+
label: "Web Bot Auth",
|
|
225
|
+
path: SIGNATURES_DIRECTORY_PATH,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
content: buildApiCatalog(config),
|
|
229
|
+
label: "RFC 9727",
|
|
230
|
+
path: API_CATALOG_PATH,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
const target = join(distDir, file.path.slice(1));
|
|
235
|
+
if (!file.content || existsSync(target)) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Sequential by nature: both files share the .well-known dir creation.
|
|
239
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
240
|
+
await mkdir(join(distDir, ".well-known"), { recursive: true });
|
|
241
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
242
|
+
await writeFile(target, file.content, "utf-8");
|
|
243
|
+
logger.success(`Generated ${file.path.slice(1)} (${file.label})`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Splice `Accept: text/markdown` negotiation routes into the Vercel adapter's
|
|
249
|
+
* Build Output config, so a content-page request that prefers Markdown gets the
|
|
250
|
+
* page's prerendered `.md` mirror (content pages are prerendered even in server
|
|
251
|
+
* output, so Astro middleware never sees them — the routing layer is the only
|
|
252
|
+
* request-time hook). Vercel server builds only; the adapter writes the config
|
|
253
|
+
* straight to the project root (see `withAdapterRoot`).
|
|
254
|
+
*/
|
|
255
|
+
const emitVercelNegotiation = async (
|
|
256
|
+
config: ResolvedConfig,
|
|
257
|
+
routePaths: string[],
|
|
258
|
+
root: string
|
|
259
|
+
): Promise<void> => {
|
|
260
|
+
const configPath = join(root, ".vercel", "output", "config.json");
|
|
261
|
+
if (!existsSync(configPath)) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const overrides: Record<string, string> = {
|
|
265
|
+
...(hasApiCatalog(config)
|
|
266
|
+
? { [API_CATALOG_PATH.slice(1)]: API_CATALOG_TYPE }
|
|
267
|
+
: {}),
|
|
268
|
+
...(config.ai.webBotAuth.keys.length > 0
|
|
269
|
+
? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
|
|
270
|
+
: {}),
|
|
271
|
+
};
|
|
272
|
+
const injected = injectNegotiationRoutes(
|
|
273
|
+
await readFile(configPath, "utf-8"),
|
|
274
|
+
routePaths,
|
|
275
|
+
buildHomeLinkHeader(config, routePaths),
|
|
276
|
+
overrides
|
|
277
|
+
);
|
|
278
|
+
if (injected === null) {
|
|
279
|
+
logger.warn(
|
|
280
|
+
"Could not wire Accept: text/markdown negotiation into .vercel/output/config.json — raw Markdown stays available at the .md URLs."
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
await writeFile(configPath, injected, "utf-8");
|
|
285
|
+
logger.success(
|
|
286
|
+
"Wired Accept: text/markdown negotiation into the Vercel routing config"
|
|
287
|
+
);
|
|
130
288
|
};
|
|
131
289
|
|
|
132
290
|
const formatBytes = (bytes: number): string => {
|
|
@@ -375,8 +533,11 @@ const publishBuildArtifacts = async (
|
|
|
375
533
|
logger.success("Generated agent-readability.json");
|
|
376
534
|
}
|
|
377
535
|
|
|
536
|
+
await emitWellKnownFiles(project.config, distDir);
|
|
537
|
+
await emitAgentSkills(project, distDir);
|
|
538
|
+
|
|
378
539
|
await emitRedirectFiles(project.config, distDir);
|
|
379
|
-
await emitHeaderFiles(project
|
|
540
|
+
await emitHeaderFiles(project, distDir);
|
|
380
541
|
|
|
381
542
|
const { config } = project;
|
|
382
543
|
const features = serverFeatures(config);
|
|
@@ -547,6 +708,14 @@ export const buildCommand = defineCommand({
|
|
|
547
708
|
logger.success(`Surfaced ${adapter} output to ${surfaced.to}`);
|
|
548
709
|
}
|
|
549
710
|
|
|
711
|
+
if (project.config.deployment.output === "server" && adapter === "vercel") {
|
|
712
|
+
await emitVercelNegotiation(
|
|
713
|
+
project.config,
|
|
714
|
+
project.manifest.routes.map((route) => route.path),
|
|
715
|
+
root
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
550
719
|
await publishBuildArtifacts(
|
|
551
720
|
project,
|
|
552
721
|
deployStaticDir(project.config, project.context),
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -287,7 +287,7 @@ export default defineConfig({
|
|
|
287
287
|
/** SDK dependencies required by the selected remote sources. */
|
|
288
288
|
const extraDepsFor = (sources: SourceKind[]): Record<string, string> => ({
|
|
289
289
|
...(sources.includes("notion") && { "@notionhq/client": "^2.2.15" }),
|
|
290
|
-
...(sources.includes("sanity") && { "@sanity/client": "^
|
|
290
|
+
...(sources.includes("sanity") && { "@sanity/client": "^7.25.0" }),
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
/** Every file `init` should write for the given answers, package.json first. */
|
|
@@ -392,7 +392,10 @@ const AskAI = ({
|
|
|
392
392
|
</div>
|
|
393
393
|
</header>
|
|
394
394
|
|
|
395
|
-
<div
|
|
395
|
+
<div
|
|
396
|
+
className="flex flex-1 flex-col scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto"
|
|
397
|
+
ref={scrollRef}
|
|
398
|
+
>
|
|
396
399
|
{hasMessages ? (
|
|
397
400
|
<div className="flex flex-col gap-4 p-4">
|
|
398
401
|
{messages.map((message) =>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { SearchFn } from "../layout/search/types.ts";
|
|
2
|
+
import { joinBase, prefixBase } from "./base-path.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WebMCP (W3C Web Machine Learning CG): in-page tools an agentic browser can
|
|
6
|
+
* call, registered on the page's model context. Blume exposes the same
|
|
7
|
+
* read-only surface its hosted MCP server has — search, page Markdown, the
|
|
8
|
+
* docs index — so an agent driving the browser needs no separate connection.
|
|
9
|
+
* The module is inert everywhere else: registration is attempted only when
|
|
10
|
+
* the page exposes a model context.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** MCP-style tool result: text content, with `isError` on failures. */
|
|
14
|
+
interface WebMcpResult {
|
|
15
|
+
content: { text: string; type: "text" }[];
|
|
16
|
+
isError?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WebMcpTool {
|
|
20
|
+
annotations: { openWorldHint: boolean; readOnlyHint: boolean };
|
|
21
|
+
description: string;
|
|
22
|
+
execute: (input: Record<string, unknown>) => Promise<WebMcpResult>;
|
|
23
|
+
inputSchema: Record<string, unknown>;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The registration surface, in either of the shapes the moving spec has
|
|
29
|
+
* shipped: Chrome's early preview exposes `navigator.modelContext` with
|
|
30
|
+
* `provideContext({ tools })`, while the editor's draft registers tools
|
|
31
|
+
* individually via `registerTool`.
|
|
32
|
+
*/
|
|
33
|
+
export interface ModelContext {
|
|
34
|
+
provideContext?: (context: { tools: WebMcpTool[] }) => unknown;
|
|
35
|
+
registerTool?: (tool: WebMcpTool) => unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const text = (value: string, isError = false): WebMcpResult => ({
|
|
39
|
+
content: [{ text: value, type: "text" }],
|
|
40
|
+
...(isError ? { isError: true } : {}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const TAG = /<[^>]*>?/gu;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Drop the `<mark>` highlighting (and any other markup) search hits carry.
|
|
47
|
+
* The closing `>` is optional so every `<` starts a strip: a dangling
|
|
48
|
+
* `<script` fragment can't survive the way it would if a full `<...>` pair
|
|
49
|
+
* were required. The input is HTML, where a literal `<` is `<`, so
|
|
50
|
+
* consuming from every raw `<` loses nothing legitimate. Stripping repeats
|
|
51
|
+
* to a fixed point so the no-fragment guarantee is explicit rather than an
|
|
52
|
+
* artifact of the regex shape.
|
|
53
|
+
*/
|
|
54
|
+
const plain = (html: string): string => {
|
|
55
|
+
let stripped = html;
|
|
56
|
+
let previous: string;
|
|
57
|
+
do {
|
|
58
|
+
previous = stripped;
|
|
59
|
+
stripped = stripped.replaceAll(TAG, "");
|
|
60
|
+
} while (stripped !== previous);
|
|
61
|
+
return stripped;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface WebMcpToolOptions {
|
|
65
|
+
/** The deployment base (`import.meta.env.BASE_URL`). */
|
|
66
|
+
base: string;
|
|
67
|
+
/** Injectable for tests; defaults to the page's `fetch`. */
|
|
68
|
+
fetchFn?: typeof fetch;
|
|
69
|
+
/** Lazy provider-specific search loader (`blume:search-client`). */
|
|
70
|
+
loadSearch: () => Promise<SearchFn>;
|
|
71
|
+
/** Whether the site publishes llms.txt (gates the list tool). */
|
|
72
|
+
llms: boolean;
|
|
73
|
+
/** Whether site search is configured (gates the search tool). */
|
|
74
|
+
search: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build the tool set. Pure — registration is the caller's business. */
|
|
78
|
+
export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
|
|
79
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
80
|
+
const readOnly = { openWorldHint: false, readOnlyHint: true };
|
|
81
|
+
const tools: WebMcpTool[] = [];
|
|
82
|
+
|
|
83
|
+
if (options.search) {
|
|
84
|
+
// One search instance per page; a transient load failure retries on the
|
|
85
|
+
// next call rather than latching broken.
|
|
86
|
+
let searchFn: SearchFn | null = null;
|
|
87
|
+
tools.push({
|
|
88
|
+
annotations: readOnly,
|
|
89
|
+
description:
|
|
90
|
+
"Full-text search across this documentation site. Returns matching pages with their title, URL, and a short excerpt.",
|
|
91
|
+
async execute(input) {
|
|
92
|
+
const query = typeof input.query === "string" ? input.query : "";
|
|
93
|
+
if (!query.trim()) {
|
|
94
|
+
return text("Provide a non-empty `query` string.", true);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
searchFn ??= await options.loadSearch();
|
|
98
|
+
const { hits } = await searchFn(query);
|
|
99
|
+
if (hits.length === 0) {
|
|
100
|
+
return text(`No results for "${query}".`);
|
|
101
|
+
}
|
|
102
|
+
return text(
|
|
103
|
+
hits
|
|
104
|
+
.map(
|
|
105
|
+
(hit) =>
|
|
106
|
+
`${plain(hit.title)} — ${prefixBase(options.base, hit.url)}\n${plain(hit.excerpt)}`
|
|
107
|
+
)
|
|
108
|
+
.join("\n\n")
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
searchFn = null;
|
|
112
|
+
return text("Search is unavailable right now.", true);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
inputSchema: {
|
|
116
|
+
properties: {
|
|
117
|
+
query: { description: "The search query.", type: "string" },
|
|
118
|
+
},
|
|
119
|
+
required: ["query"],
|
|
120
|
+
type: "object",
|
|
121
|
+
},
|
|
122
|
+
name: "search_docs",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
tools.push({
|
|
127
|
+
annotations: readOnly,
|
|
128
|
+
description:
|
|
129
|
+
"Fetch a page of this site as plain Markdown. Pass the page's root-relative route, e.g. `/quickstart`.",
|
|
130
|
+
async execute(input) {
|
|
131
|
+
const route = typeof input.route === "string" ? input.route : "";
|
|
132
|
+
if (!route.startsWith("/")) {
|
|
133
|
+
return text("Pass a root-relative route, e.g. `/quickstart`.", true);
|
|
134
|
+
}
|
|
135
|
+
const trimmed = route.length > 1 ? route.replace(/\/+$/u, "") : route;
|
|
136
|
+
const target = trimmed === "/" ? "/index" : trimmed;
|
|
137
|
+
const response = await fetchFn(
|
|
138
|
+
`${prefixBase(options.base, target)}.md`
|
|
139
|
+
).catch(() => null);
|
|
140
|
+
if (!response?.ok) {
|
|
141
|
+
return text(`No Markdown found for ${route}.`, true);
|
|
142
|
+
}
|
|
143
|
+
return text(await response.text());
|
|
144
|
+
},
|
|
145
|
+
inputSchema: {
|
|
146
|
+
properties: {
|
|
147
|
+
route: {
|
|
148
|
+
description: "Root-relative page route, e.g. `/quickstart`.",
|
|
149
|
+
type: "string",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
required: ["route"],
|
|
153
|
+
type: "object",
|
|
154
|
+
},
|
|
155
|
+
name: "get_page",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (options.llms) {
|
|
159
|
+
tools.push({
|
|
160
|
+
annotations: readOnly,
|
|
161
|
+
description:
|
|
162
|
+
"List this site's pages: the llms.txt index of every page with its URL and summary, organized by section.",
|
|
163
|
+
async execute() {
|
|
164
|
+
const response = await fetchFn(
|
|
165
|
+
joinBase(options.base, "llms.txt")
|
|
166
|
+
).catch(() => null);
|
|
167
|
+
if (!response?.ok) {
|
|
168
|
+
return text("The page index (llms.txt) is unavailable.", true);
|
|
169
|
+
}
|
|
170
|
+
return text(await response.text());
|
|
171
|
+
},
|
|
172
|
+
inputSchema: { properties: {}, type: "object" },
|
|
173
|
+
name: "list_pages",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return tools;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Register the tools on whichever model context the page exposes. Returns
|
|
182
|
+
* whether a registration surface was found — false in every browser that
|
|
183
|
+
* doesn't implement WebMCP, which is the common, silent case.
|
|
184
|
+
*/
|
|
185
|
+
export const registerWebMcpTools = (
|
|
186
|
+
tools: WebMcpTool[],
|
|
187
|
+
context?: ModelContext | null
|
|
188
|
+
): boolean => {
|
|
189
|
+
if (!context) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (typeof context.provideContext === "function") {
|
|
193
|
+
context.provideContext({ tools });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (typeof context.registerTool === "function") {
|
|
197
|
+
for (const tool of tools) {
|
|
198
|
+
context.registerTool(tool);
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
};
|
|
@@ -137,7 +137,7 @@ const initialId =
|
|
|
137
137
|
<div class="mb-3 flex items-center gap-0.5">
|
|
138
138
|
<button
|
|
139
139
|
aria-label={n.back}
|
|
140
|
-
class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
140
|
+
class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded-[0.65rem] px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
141
141
|
data-nav-back={panel.parentId}
|
|
142
142
|
type="button"
|
|
143
143
|
>
|
|
@@ -145,7 +145,7 @@ const initialId =
|
|
|
145
145
|
</button>
|
|
146
146
|
<a
|
|
147
147
|
aria-current={panel.route === currentRoute ? "page" : undefined}
|
|
148
|
-
class="flex-1 truncate rounded px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
148
|
+
class="flex-1 truncate rounded-[0.65rem] px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
149
149
|
href={withBase(panel.route)}
|
|
150
150
|
>
|
|
151
151
|
{panel.label}
|
|
@@ -154,7 +154,7 @@ const initialId =
|
|
|
154
154
|
) : (
|
|
155
155
|
<button
|
|
156
156
|
aria-label={`${n.back}: ${panel.label}`}
|
|
157
|
-
class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
157
|
+
class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
158
158
|
data-nav-back={panel.parentId}
|
|
159
159
|
type="button"
|
|
160
160
|
>
|
|
@@ -317,7 +317,7 @@ const initialId =
|
|
|
317
317
|
{item.route ? (
|
|
318
318
|
<a
|
|
319
319
|
aria-current={item.route === currentRoute ? "page" : undefined}
|
|
320
|
-
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"
|
|
320
|
+
class="-ml-1 flex flex-1 items-center gap-1.5 rounded-[0.65rem] px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
|
|
321
321
|
href={withBase(item.route)}
|
|
322
322
|
>
|
|
323
323
|
{item.icon && (
|
|
@@ -40,6 +40,7 @@ import Fonts from "./Fonts.astro";
|
|
|
40
40
|
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
41
41
|
import Header from "./Header.astro";
|
|
42
42
|
import { activeTabForRoute } from "./nav-utils.ts";
|
|
43
|
+
import WebMcp from "./WebMcp.astro";
|
|
43
44
|
|
|
44
45
|
interface Props {
|
|
45
46
|
site: { title: string; description?: string };
|
|
@@ -254,6 +255,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
254
255
|
/>
|
|
255
256
|
<main id="blume-content"><slot /></main>
|
|
256
257
|
<slot name="footer" />
|
|
258
|
+
<WebMcp />
|
|
257
259
|
{
|
|
258
260
|
// Mobile nav for chrome-only pages: the header's inline tab bar is hidden
|
|
259
261
|
// below `lg` and there's no sidebar drawer, so surface the tabs in a
|
|
@@ -4,6 +4,7 @@ import { EN_UI } from "../../core/i18n-ui.ts";
|
|
|
4
4
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
5
5
|
import type { Navigation } from "../../core/types.ts";
|
|
6
6
|
import Analytics from "./Analytics.astro";
|
|
7
|
+
import WebMcp from "./WebMcp.astro";
|
|
7
8
|
import Banner from "./Banner.astro";
|
|
8
9
|
import Favicon from "./Favicon.astro";
|
|
9
10
|
import Fonts from "./Fonts.astro";
|
|
@@ -126,5 +127,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
126
127
|
>
|
|
127
128
|
<slot />
|
|
128
129
|
</div>
|
|
130
|
+
<WebMcp />
|
|
129
131
|
</body>
|
|
130
132
|
</html>
|
|
@@ -20,6 +20,7 @@ import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
|
|
|
20
20
|
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
21
21
|
import { withBase } from "../islands/base-path.ts";
|
|
22
22
|
import Analytics from "./Analytics.astro";
|
|
23
|
+
import WebMcp from "./WebMcp.astro";
|
|
23
24
|
import Banner from "./Banner.astro";
|
|
24
25
|
import Breadcrumbs from "./Breadcrumbs.astro";
|
|
25
26
|
import Empty from "./Empty.astro";
|
|
@@ -448,6 +449,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
448
449
|
class="bg-background font-sans text-foreground antialiased"
|
|
449
450
|
data-blume-code-wrap={codeWrap ? "" : undefined}
|
|
450
451
|
data-blume-image-zoom={imageZoom ? "" : undefined}
|
|
452
|
+
data-i18n-copied={actionStrings.copied}
|
|
451
453
|
data-i18n-copy-code={actionStrings.copyCode}
|
|
452
454
|
data-i18n-diagram-error={contentStrings.diagramError}
|
|
453
455
|
>
|
|
@@ -479,7 +481,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
479
481
|
aria-label={navStrings.primary}
|
|
480
482
|
data-blume-nav-drawer
|
|
481
483
|
class:list={[
|
|
482
|
-
"fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
|
|
484
|
+
"fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:scrollbar-thin lg:scrollbar-thumb-border lg:scrollbar-track-transparent lg:border-e-0 lg:bg-transparent lg:px-4",
|
|
483
485
|
// A "bare" landing (the changelog index) has no sidebar column on
|
|
484
486
|
// desktop, but the header hamburger still needs a drawer to open on
|
|
485
487
|
// mobile — without it the toggle only locked page scroll.
|
|
@@ -630,7 +632,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
630
632
|
showToc && (
|
|
631
633
|
<aside
|
|
632
634
|
aria-label={strings.toc.title}
|
|
633
|
-
class="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
|
|
635
|
+
class="sticky top-16 hidden h-[calc(100dvh-4rem)] scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
|
|
634
636
|
data-blume-toc
|
|
635
637
|
>
|
|
636
638
|
<TableOfContentsSlot
|
|
@@ -707,17 +709,30 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
707
709
|
});
|
|
708
710
|
}
|
|
709
711
|
|
|
710
|
-
const svg = (name: string) =>
|
|
711
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
712
|
+
const svg = (name: string, cls = "") =>
|
|
713
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${cls ? ` class="${cls}"` : ""}>${icons[name]}</svg>`;
|
|
712
714
|
|
|
715
|
+
// Opaque icon chip over the code (in the style of Lina's code block),
|
|
716
|
+
// with the check icon scale-swapping in over the copy icon after a copy.
|
|
713
717
|
const buttonClass =
|
|
714
|
-
"absolute right-3 inline-flex size-
|
|
718
|
+
"absolute right-3 z-[2] inline-flex size-[1.875rem] select-none items-center justify-center rounded-md text-muted-foreground bg-background transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0";
|
|
719
|
+
const idleClasses = ["hover:bg-muted", "hover:text-foreground"];
|
|
715
720
|
|
|
716
|
-
// Localized copy-button
|
|
721
|
+
// Localized copy-button labels, stamped on <body> by the layout markup
|
|
717
722
|
// (the Search.astro data-attribute channel) since this bundled script
|
|
718
723
|
// can't interpolate server values directly.
|
|
719
724
|
const copyCodeLabel =
|
|
720
725
|
document.body.getAttribute("data-i18n-copy-code") || "Copy code";
|
|
726
|
+
const copiedLabel =
|
|
727
|
+
document.body.getAttribute("data-i18n-copied") || "Copied!";
|
|
728
|
+
|
|
729
|
+
// Shared polite live region: the icon swap is invisible to screen
|
|
730
|
+
// readers, so copy success is announced here. Cleared when a button's
|
|
731
|
+
// checked state resets so the next copy re-announces.
|
|
732
|
+
const copyStatus = document.createElement("span");
|
|
733
|
+
copyStatus.className = "sr-only";
|
|
734
|
+
copyStatus.setAttribute("aria-live", "polite");
|
|
735
|
+
document.body.appendChild(copyStatus);
|
|
721
736
|
|
|
722
737
|
const languageLabels: Record<string, string> = {
|
|
723
738
|
astro: "Astro",
|
|
@@ -757,6 +772,16 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
757
772
|
);
|
|
758
773
|
}
|
|
759
774
|
pre.classList.add("group", "relative");
|
|
775
|
+
// The code element is the scroll container (see the theme entry), but
|
|
776
|
+
// Shiki's tab stop lands on the pre, which no longer scrolls. Move the
|
|
777
|
+
// stop to the code so keyboard users can actually scroll the block
|
|
778
|
+
// (WCAG 2.1.1 — the same rule the table wrapper handles). Twoslash and
|
|
779
|
+
// API-panel blocks keep the pre as their scroller, so theirs stays.
|
|
780
|
+
const scroller = pre.querySelector("code");
|
|
781
|
+
if (scroller && !pre.matches(".twoslash, blume-panel-tabs *")) {
|
|
782
|
+
scroller.setAttribute("tabindex", "0");
|
|
783
|
+
pre.removeAttribute("tabindex");
|
|
784
|
+
}
|
|
760
785
|
const button = document.createElement("button");
|
|
761
786
|
button.type = "button";
|
|
762
787
|
// The language-label bar (prose) vs flush code (tabs) need a different
|
|
@@ -764,10 +789,31 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
764
789
|
const topClass = pre.closest("blume-tabs, .not-prose")
|
|
765
790
|
? "top-2.5"
|
|
766
791
|
: "top-2";
|
|
767
|
-
button.className = `${buttonClass} ${topClass}`;
|
|
792
|
+
button.className = `${buttonClass} ${idleClasses.join(" ")} ${topClass}`;
|
|
768
793
|
button.setAttribute("data-blume-copy", "");
|
|
769
794
|
button.setAttribute("aria-label", copyCodeLabel);
|
|
770
|
-
button.innerHTML =
|
|
795
|
+
button.innerHTML =
|
|
796
|
+
svg(
|
|
797
|
+
"check",
|
|
798
|
+
"scale-0 text-green-600 transition-transform dark:text-green-500"
|
|
799
|
+
) + svg("copy", "absolute transition-transform");
|
|
800
|
+
const [checkIcon, copyIcon] = button.querySelectorAll("svg");
|
|
801
|
+
// While checked the button drops its idle hover tint so the green
|
|
802
|
+
// check reads as a steady confirmation, mirroring Lina's copied state.
|
|
803
|
+
// The accessible name tracks the visual state for anyone probing the
|
|
804
|
+
// button mid-confirmation.
|
|
805
|
+
const setChecked = (checked: boolean) => {
|
|
806
|
+
checkIcon?.classList.toggle("scale-0", !checked);
|
|
807
|
+
copyIcon?.classList.toggle("scale-0", checked);
|
|
808
|
+
button.setAttribute(
|
|
809
|
+
"aria-label",
|
|
810
|
+
checked ? copiedLabel : copyCodeLabel
|
|
811
|
+
);
|
|
812
|
+
for (const cls of idleClasses) {
|
|
813
|
+
button.classList.toggle(cls, !checked);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
let resetTimeout: number | undefined;
|
|
771
817
|
button.addEventListener("click", async () => {
|
|
772
818
|
const code = pre.querySelector("code");
|
|
773
819
|
let text = code?.textContent ?? "";
|
|
@@ -788,9 +834,14 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
788
834
|
} catch {
|
|
789
835
|
return;
|
|
790
836
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
837
|
+
if (resetTimeout) {
|
|
838
|
+
window.clearTimeout(resetTimeout);
|
|
839
|
+
}
|
|
840
|
+
setChecked(true);
|
|
841
|
+
copyStatus.textContent = copiedLabel;
|
|
842
|
+
resetTimeout = window.setTimeout(() => {
|
|
843
|
+
setChecked(false);
|
|
844
|
+
copyStatus.textContent = "";
|
|
794
845
|
}, 1500);
|
|
795
846
|
});
|
|
796
847
|
pre.appendChild(button);
|
|
@@ -912,5 +963,6 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
912
963
|
}
|
|
913
964
|
}
|
|
914
965
|
</script>
|
|
966
|
+
<WebMcp />
|
|
915
967
|
</body>
|
|
916
968
|
</html>
|