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.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. 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). A `_headers` shipped in `public/` (copied into dist
111
- * by Astro before this runs) wins, exactly like `_redirects`. Server adapters
112
- * set the Content-Type on the Response directly, so this is static-only.
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
- config: ResolvedConfig,
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(config),
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("Emitted _headers (UTF-8 Content-Type for raw endpoints)");
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.config, distDir);
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),
@@ -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": "^6.21.0" }),
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 className="flex flex-1 flex-col overflow-y-auto" ref={scrollRef}>
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 `&lt;`, 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="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>`;
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-7 items-center justify-center rounded-full bg-transparent text-muted-foreground opacity-70 transition hover:bg-muted hover:text-foreground hover:opacity-100 focus-visible:opacity-100";
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 label, stamped on <body> by the layout markup
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 = svg("copy");
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
- button.innerHTML = svg("check");
792
- setTimeout(() => {
793
- button.innerHTML = svg("copy");
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>