blume 1.2.1 → 1.3.1

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 (71) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli/index.js +1765 -603
  3. package/dist/cli/index.js.map +37 -28
  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 +2 -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/link-headers.ts +53 -0
  28. package/src/ai/llms.ts +16 -3
  29. package/src/ai/markdown.ts +49 -3
  30. package/src/ai/mcp/discovery.ts +70 -15
  31. package/src/ai/mcp/server.ts +5 -4
  32. package/src/ai/skills.ts +193 -0
  33. package/src/ai/tar.ts +104 -0
  34. package/src/ai/web-bot-auth.ts +30 -0
  35. package/src/astro/generate.ts +118 -8
  36. package/src/astro/integration.ts +54 -14
  37. package/src/astro/templates.ts +184 -36
  38. package/src/audit/catalog.ts +20 -0
  39. package/src/audit/checks/dns-aid.ts +190 -0
  40. package/src/audit/report.ts +5 -0
  41. package/src/audit/run.ts +2 -0
  42. package/src/cli/commands/build.ts +184 -9
  43. package/src/cli/init/scaffold.ts +1 -1
  44. package/src/components/islands/ask-ai.tsx +4 -1
  45. package/src/components/islands/webmcp.ts +203 -0
  46. package/src/components/layout/PageLayout.astro +2 -0
  47. package/src/components/layout/ReferenceLayout.astro +2 -0
  48. package/src/components/layout/RootLayout.astro +62 -10
  49. package/src/components/layout/Search.astro +2 -2
  50. package/src/components/layout/WebMcp.astro +49 -0
  51. package/src/core/config-input.ts +143 -11
  52. package/src/core/config.ts +17 -1
  53. package/src/core/content-assets.ts +199 -0
  54. package/src/core/data.ts +21 -5
  55. package/src/core/diagnostics.ts +6 -5
  56. package/src/core/i18n-ui.ts +19 -28
  57. package/src/core/project-graph.ts +6 -0
  58. package/src/core/schema.ts +224 -71
  59. package/src/core/sources/normalize.ts +5 -5
  60. package/src/deploy/headers.ts +45 -3
  61. package/src/deploy/vercel-negotiation.ts +250 -0
  62. package/src/markdown/mermaid.ts +7 -1
  63. package/src/markdown/table-wrap.ts +33 -1
  64. package/src/og/card.ts +91 -22
  65. package/src/og/derive.ts +200 -0
  66. package/src/og/index.ts +6 -1
  67. package/src/search/orama-index.ts +98 -4
  68. package/src/theme/entry.ts +34 -13
  69. package/src/theme/fonts.ts +185 -30
  70. package/dist/types/og/card.d.ts +0 -63
  71. package/dist/types/og/dimensions.d.ts +0 -12
@@ -1,12 +1,35 @@
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
+ agentMarkdown,
19
+ buildRawMarkdown,
20
+ markdownRoutePaths,
21
+ markdownTokenCount,
22
+ } from "../../ai/markdown.ts";
23
+ import {
24
+ AGENT_SKILLS_DIR,
25
+ buildSkillsIndex,
26
+ collectSkills,
27
+ } from "../../ai/skills.ts";
28
+ import {
29
+ buildSignaturesDirectory,
30
+ SIGNATURES_DIRECTORY_PATH,
31
+ SIGNATURES_DIRECTORY_TYPE,
32
+ } from "../../ai/web-bot-auth.ts";
10
33
  import { ensureGitignore } from "../../core/gitignore.ts";
11
34
  import type { BlumeProject } from "../../core/project-graph.ts";
12
35
  import type { ResolvedConfig } from "../../core/schema.ts";
@@ -26,6 +49,7 @@ import {
26
49
  } from "../../deploy/redirects.ts";
27
50
  import { buildRobots } from "../../deploy/robots.ts";
28
51
  import { buildSitemap } from "../../deploy/sitemap.ts";
52
+ import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
29
53
  import { buildSearchIndex } from "../../search/build.ts";
30
54
  import { syncSearchProvider } from "../../search/sync/index.ts";
31
55
  import { refuseIfDevRunning } from "../dev-lock.ts";
@@ -107,14 +131,17 @@ const emitRedirectFiles = async (
107
131
  * hosts serve the raw AI-ready endpoints (`*.md`, `*.mdx`, `*.txt`) with an
108
132
  * explicit `charset=utf-8`. Without it those hosts send `text/markdown` /
109
133
  * `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.
134
+ * any non-ASCII docs (#82). The same file also stamps the homepage
135
+ * agent-discovery `Link` header (RFC 8288, see `ai/link-headers.ts`). A
136
+ * `_headers` shipped in `public/` (copied into dist by Astro before this runs)
137
+ * wins, exactly like `_redirects`. Server adapters set the Content-Type on the
138
+ * Response directly, so this is static-only.
113
139
  */
114
140
  const emitHeaderFiles = async (
115
- config: ResolvedConfig,
141
+ project: BlumeProject,
116
142
  distDir: string
117
143
  ): Promise<void> => {
144
+ const { config } = project;
118
145
  if (
119
146
  config.deployment.output !== "static" ||
120
147
  existsSync(join(distDir, "_headers"))
@@ -123,10 +150,151 @@ const emitHeaderFiles = async (
123
150
  }
124
151
  await writeFile(
125
152
  join(distDir, "_headers"),
126
- buildNetlifyHeaders(config),
153
+ buildNetlifyHeaders(
154
+ config,
155
+ buildHomeLinkHeader(config, markdownRoutePaths(project))
156
+ ),
157
+ "utf-8"
158
+ );
159
+ logger.success(
160
+ "Emitted _headers (UTF-8 Content-Type + homepage Link header)"
161
+ );
162
+ };
163
+
164
+ /**
165
+ * Publish the configured Agent Skills: copy each skill artifact under
166
+ * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
167
+ * `public/.well-known/agent-skills/index.json` takes over the whole surface,
168
+ * matching every other generated artifact.
169
+ */
170
+ const emitAgentSkills = async (
171
+ project: BlumeProject,
172
+ distDir: string
173
+ ): Promise<void> => {
174
+ const configured = project.config.ai.skills;
175
+ if (!configured) {
176
+ return;
177
+ }
178
+ const dir = resolve(project.context.root, configured);
179
+ if (!existsSync(dir)) {
180
+ logger.warn(
181
+ `ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
182
+ );
183
+ return;
184
+ }
185
+ const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
186
+ if (existsSync(join(outDir, "index.json"))) {
187
+ return;
188
+ }
189
+ const { skills, warnings } = await collectSkills(dir);
190
+ for (const warning of warnings) {
191
+ logger.warn(warning);
192
+ }
193
+ if (skills.length === 0) {
194
+ logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
195
+ return;
196
+ }
197
+ await Promise.all(
198
+ skills.map(async (skill) => {
199
+ const target = join(outDir, skill.path);
200
+ await mkdir(dirname(target), { recursive: true });
201
+ await writeFile(target, skill.content);
202
+ })
203
+ );
204
+ await writeFile(
205
+ join(outDir, "index.json"),
206
+ buildSkillsIndex(skills, project.config),
127
207
  "utf-8"
128
208
  );
129
- logger.success("Emitted _headers (UTF-8 Content-Type for raw endpoints)");
209
+ logger.success(
210
+ `Published ${skills.length} agent skill(s) (.well-known/agent-skills/index.json)`
211
+ );
212
+ };
213
+
214
+ /**
215
+ * Emit the generated `.well-known` discovery files — the RFC 9727 API catalog
216
+ * and the Web Bot Auth signature directory — each skipped when the feature is
217
+ * off or when the user ships their own copy via public/ (already in dist by
218
+ * the time this runs).
219
+ */
220
+ const emitWellKnownFiles = async (
221
+ config: ResolvedConfig,
222
+ distDir: string
223
+ ): Promise<void> => {
224
+ const files = [
225
+ {
226
+ content: buildSignaturesDirectory(config),
227
+ label: "Web Bot Auth",
228
+ path: SIGNATURES_DIRECTORY_PATH,
229
+ },
230
+ {
231
+ content: buildApiCatalog(config),
232
+ label: "RFC 9727",
233
+ path: API_CATALOG_PATH,
234
+ },
235
+ ];
236
+ for (const file of files) {
237
+ const target = join(distDir, file.path.slice(1));
238
+ if (!file.content || existsSync(target)) {
239
+ continue;
240
+ }
241
+ // Sequential by nature: both files share the .well-known dir creation.
242
+ // oxlint-disable-next-line no-await-in-loop
243
+ await mkdir(join(distDir, ".well-known"), { recursive: true });
244
+ // oxlint-disable-next-line no-await-in-loop
245
+ await writeFile(target, file.content, "utf-8");
246
+ logger.success(`Generated ${file.path.slice(1)} (${file.label})`);
247
+ }
248
+ };
249
+
250
+ /**
251
+ * Splice `Accept: text/markdown` negotiation routes into the Vercel adapter's
252
+ * Build Output config, so a content-page request that prefers Markdown gets the
253
+ * page's prerendered `.md` mirror (content pages are prerendered even in server
254
+ * output, so Astro middleware never sees them — the routing layer is the only
255
+ * request-time hook). Vercel server builds only; the adapter writes the config
256
+ * straight to the project root (see `withAdapterRoot`).
257
+ */
258
+ const emitVercelNegotiation = async (
259
+ project: BlumeProject,
260
+ routePaths: string[],
261
+ root: string
262
+ ): Promise<void> => {
263
+ const { config } = project;
264
+ const configPath = join(root, ".vercel", "output", "config.json");
265
+ if (!existsSync(configPath)) {
266
+ return;
267
+ }
268
+ const overrides: Record<string, string> = {
269
+ ...(hasApiCatalog(config)
270
+ ? { [API_CATALOG_PATH.slice(1)]: API_CATALOG_TYPE }
271
+ : {}),
272
+ ...(config.ai.webBotAuth.keys.length > 0
273
+ ? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
274
+ : {}),
275
+ };
276
+ // The homepage rewrite serves `/index.md` from the static layer, so its
277
+ // `x-markdown-tokens` estimate has to ride the routing config; the runtime
278
+ // endpoint stamps it on dev/server-rendered responses itself.
279
+ const rawMarkdown = await buildRawMarkdown(project);
280
+ const home = rawMarkdown["/"];
281
+ const injected = injectNegotiationRoutes(
282
+ await readFile(configPath, "utf-8"),
283
+ routePaths,
284
+ buildHomeLinkHeader(config, routePaths),
285
+ overrides,
286
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined
287
+ );
288
+ if (injected === null) {
289
+ logger.warn(
290
+ "Could not wire Accept: text/markdown negotiation into .vercel/output/config.json — raw Markdown stays available at the .md URLs."
291
+ );
292
+ return;
293
+ }
294
+ await writeFile(configPath, injected, "utf-8");
295
+ logger.success(
296
+ "Wired Accept: text/markdown negotiation into the Vercel routing config"
297
+ );
130
298
  };
131
299
 
132
300
  const formatBytes = (bytes: number): string => {
@@ -375,8 +543,11 @@ const publishBuildArtifacts = async (
375
543
  logger.success("Generated agent-readability.json");
376
544
  }
377
545
 
546
+ await emitWellKnownFiles(project.config, distDir);
547
+ await emitAgentSkills(project, distDir);
548
+
378
549
  await emitRedirectFiles(project.config, distDir);
379
- await emitHeaderFiles(project.config, distDir);
550
+ await emitHeaderFiles(project, distDir);
380
551
 
381
552
  const { config } = project;
382
553
  const features = serverFeatures(config);
@@ -547,6 +718,10 @@ export const buildCommand = defineCommand({
547
718
  logger.success(`Surfaced ${adapter} output to ${surfaced.to}`);
548
719
  }
549
720
 
721
+ if (project.config.deployment.output === "server" && adapter === "vercel") {
722
+ await emitVercelNegotiation(project, markdownRoutePaths(project), root);
723
+ }
724
+
550
725
  await publishBuildArtifacts(
551
726
  project,
552
727
  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
+ };
@@ -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
  >
@@ -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>
@@ -112,7 +112,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
112
112
  >
113
113
  </div>
114
114
  <div
115
- class="min-h-0 flex-1 overflow-y-auto p-2"
115
+ class="min-h-0 flex-1 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-2"
116
116
  data-blume-search-results
117
117
  >
118
118
  </div>
@@ -124,7 +124,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
124
124
  </p>
125
125
  </div>
126
126
  <div
127
- class="hidden min-h-0 overflow-y-auto p-5 md:block"
127
+ class="hidden min-h-0 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-5 md:block"
128
128
  data-blume-search-preview
129
129
  >
130
130
  </div>