blume 1.4.2 → 1.4.3

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 (90) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/index.js +694 -579
  3. package/dist/cli/index.js.map +56 -55
  4. package/dist/types/core/base-path.d.ts +8 -0
  5. package/dist/types/core/config-input.d.ts +8 -0
  6. package/dist/types/core/schema.d.ts +4 -0
  7. package/dist/types/core/sources/types.d.ts +9 -1
  8. package/dist/types/openapi/references.d.ts +8 -2
  9. package/docs/configuration/ai.mdx +26 -8
  10. package/docs/content/sources.mdx +1 -1
  11. package/package.json +11 -1
  12. package/src/ai/agent-readability.ts +3 -2
  13. package/src/ai/api-catalog.ts +2 -2
  14. package/src/ai/ask-context.ts +45 -12
  15. package/src/ai/llms.ts +2 -1
  16. package/src/ai/mcp/discovery.ts +25 -6
  17. package/src/ai/mcp/server.ts +108 -98
  18. package/src/ai/tar.ts +29 -70
  19. package/src/astro/examples.ts +7 -3
  20. package/src/astro/generate.ts +59 -34
  21. package/src/astro/islands.ts +7 -3
  22. package/src/astro/templates.ts +34 -9
  23. package/src/audit/agent.ts +14 -29
  24. package/src/audit/crawl.ts +41 -16
  25. package/src/audit/run.ts +10 -3
  26. package/src/audit/snapshot.ts +27 -2
  27. package/src/cli/commands/audit.ts +12 -17
  28. package/src/cli/commands/build.ts +15 -7
  29. package/src/cli/commands/dev.ts +13 -15
  30. package/src/cli/commands/eject.ts +4 -4
  31. package/src/cli/commands/eval.ts +17 -27
  32. package/src/cli/env.ts +13 -30
  33. package/src/cli/init/scaffold.ts +21 -0
  34. package/src/cli/report-format.ts +22 -0
  35. package/src/components/content/AccordionItem.astro +2 -9
  36. package/src/components/content/ColorItem.astro +5 -13
  37. package/src/components/content/Component.astro +12 -8
  38. package/src/components/content/Frame.astro +2 -12
  39. package/src/components/content/Prompt.astro +12 -31
  40. package/src/components/content/Tab.astro +2 -9
  41. package/src/components/content/Tooltip.astro +1 -9
  42. package/src/components/content/Update.astro +2 -9
  43. package/src/components/content/inline-markdown.ts +28 -0
  44. package/src/components/copy-feedback.ts +96 -0
  45. package/src/components/islands/ask-ai.tsx +78 -9
  46. package/src/components/layout/PageActions.astro +20 -32
  47. package/src/components/layout/PageLayout.astro +8 -28
  48. package/src/components/layout/RootLayout.astro +6 -48
  49. package/src/components/layout/Search.astro +56 -9
  50. package/src/components/layout/drawer-inert.ts +31 -0
  51. package/src/components/layout/search/pagefind.ts +6 -5
  52. package/src/components/layout/search/types.ts +32 -0
  53. package/src/components/openapi/panel.ts +11 -8
  54. package/src/components/raf-throttle.ts +21 -0
  55. package/src/components/slug.ts +14 -0
  56. package/src/core/base-path.ts +18 -1
  57. package/src/core/config-input.ts +8 -0
  58. package/src/core/frontmatter.ts +45 -1
  59. package/src/core/probe.ts +7 -19
  60. package/src/core/project-graph.ts +12 -1
  61. package/src/core/schema.ts +6 -0
  62. package/src/core/site-url.ts +27 -0
  63. package/src/core/sources/cache.ts +10 -8
  64. package/src/core/sources/github-releases.ts +21 -1
  65. package/src/core/sources/normalize.ts +26 -2
  66. package/src/core/sources/notion.ts +27 -5
  67. package/src/core/sources/portable-text.ts +16 -1
  68. package/src/core/sources/resolve.ts +1 -0
  69. package/src/core/sources/types.ts +13 -1
  70. package/src/deploy/cloudflare-negotiation.ts +15 -1
  71. package/src/deploy/robots.ts +2 -1
  72. package/src/deploy/rss.ts +2 -1
  73. package/src/deploy/sitemap.ts +56 -7
  74. package/src/eval/agents.ts +13 -10
  75. package/src/eval/report.ts +1 -14
  76. package/src/markdown/package-commands.ts +61 -54
  77. package/src/og/card.ts +24 -26
  78. package/src/openapi/model.ts +9 -9
  79. package/src/openapi/parse.ts +69 -28
  80. package/src/openapi/references.ts +35 -12
  81. package/src/openapi/render-mdx.ts +64 -25
  82. package/src/openapi/scalar.ts +2 -2
  83. package/src/openapi/source.ts +28 -1
  84. package/src/search/documents.ts +78 -34
  85. package/src/search/orama-index.ts +51 -12
  86. package/src/theme/palette.ts +6 -2
  87. package/src/translate/ledger.ts +4 -2
  88. package/src/translate/report.ts +1 -14
  89. package/src/translate/run.ts +20 -35
  90. package/src/cli/coalesce.ts +0 -43
@@ -4,8 +4,15 @@ import {
4
4
  CallToolRequestSchema,
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
+ import { z } from "zod";
7
8
 
8
- import { stripBasePath, withBasePath } from "../../core/base-path.ts";
9
+ import {
10
+ normalizeRoute as normalizePageRoute,
11
+ stripBasePath,
12
+ withBasePath,
13
+ } from "../../core/base-path.ts";
14
+ import { absoluteUrl } from "../../core/site-url.ts";
15
+ import { trimEnd } from "../../core/trim.ts";
9
16
  import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
10
17
  import type { OramaDoc } from "../../search/orama-index.ts";
11
18
  import type { McpData } from "./data.ts";
@@ -15,8 +22,9 @@ import { MCP_TOOLS } from "./tools.ts";
15
22
  * The low-level SDK `Server` is used (rather than the high-level `McpServer`)
16
23
  * because the latter's `registerTool` is generic over the caller's Zod instance;
17
24
  * Blume's zod and the SDK's may resolve to different copies, whose types don't
18
- * unify. Hand-written JSON Schema and the SDK's own request schemas avoid that
19
- * entirely.
25
+ * unify. Each tool's input is defined once in Blume's own zod: the runtime
26
+ * parse and the JSON Schema advertised by `tools/list` (via `z.toJSONSchema`)
27
+ * derive from the same definition, so they cannot drift.
20
28
  */
21
29
 
22
30
  /** Default and maximum number of hits returned by `search_docs`. */
@@ -33,88 +41,31 @@ const CORS_HEADERS: Record<string, string> = {
33
41
  "Access-Control-Expose-Headers": "Mcp-Session-Id",
34
42
  };
35
43
 
36
- /** The optional content-type filter `search_docs` and `list_pages` share. */
37
- const CONTENT_TYPES_SCHEMA = {
38
- description:
39
- 'Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.',
40
- items: { type: "string" },
41
- type: "array",
42
- } as const;
43
-
44
- /** The optional facet filter `search_docs` and `list_pages` share. */
45
- const FILTERS_SCHEMA = {
46
- additionalProperties: { type: "string" },
47
- description:
48
- 'Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.',
49
- type: "object",
50
- } as const;
51
-
52
- /** JSON Schema for each tool's input, keyed by tool name. */
53
- const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
54
- get_navigation: { properties: {}, type: "object" },
55
- get_page: {
56
- properties: {
57
- route: {
58
- description: "The page route, e.g. `/guides/install`.",
59
- type: "string",
60
- },
61
- },
62
- required: ["route"],
63
- type: "object",
64
- },
65
- list_pages: {
66
- properties: {
67
- contentTypes: CONTENT_TYPES_SCHEMA,
68
- filters: FILTERS_SCHEMA,
69
- },
70
- type: "object",
71
- },
72
- search_docs: {
73
- properties: {
74
- contentTypes: CONTENT_TYPES_SCHEMA,
75
- filters: FILTERS_SCHEMA,
76
- limit: {
77
- description: `Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`,
78
- maximum: MAX_SEARCH_LIMIT,
79
- minimum: 1,
80
- type: "integer",
81
- },
82
- query: { description: "The search query.", type: "string" },
83
- },
84
- required: ["query"],
85
- type: "object",
86
- },
87
- };
88
-
89
- /** The `tools/list` payload, derived from shared metadata + input schemas. */
90
- const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
91
- annotations: tool.annotations,
92
- description: tool.description,
93
- inputSchema: INPUT_SCHEMAS[tool.name],
94
- name: tool.name,
95
- title: tool.title,
96
- }));
97
-
98
- const asString = (value: unknown): string =>
99
- typeof value === "string" ? value : "";
44
+ // Each field is a preprocess pipe: the input side accepts the sloppy shapes
45
+ // LLM callers actually send (a bare string for an array field, `[]`/`{}`
46
+ // meaning "no filter", out-of-range limits clamped rather than rejected), and
47
+ // the pipe's *output* side is the clean shape which is exactly what
48
+ // `z.toJSONSchema` emits for `tools/list`. No coercion can ever fail, so a
49
+ // tool call is never rejected on argument shape, matching the previous
50
+ // hand-rolled coercions.
100
51
 
101
52
  /**
102
- * The `contentTypes` filter as a string array, or `undefined` when absent or
103
- * empty an agent sending `[]` means "no filter", not "match nothing". A bare
53
+ * The optional content-type filter `search_docs` and `list_pages` share.
54
+ * `[]` or no usable strings mean "no filter", not "match nothing"; a bare
104
55
  * string is accepted as a one-element list.
105
56
  */
106
- const asContentTypes = (value: unknown): string[] | undefined => {
107
- const list = Array.isArray(value)
108
- ? value.filter((entry): entry is string => typeof entry === "string")
109
- : [value].filter((entry): entry is string => typeof entry === "string");
57
+ const contentTypesField = z.preprocess((value) => {
58
+ const list = (Array.isArray(value) ? value : [value]).filter(
59
+ (entry): entry is string => typeof entry === "string"
60
+ );
110
61
  return list.length > 0 ? list : undefined;
111
- };
62
+ }, z.array(z.string()).optional().describe('Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.'));
112
63
 
113
64
  /**
114
- * The `filters` facet map with only its string-valued entries, or `undefined`
115
- * when nothing usable remains — an empty `{}` means "no filter".
65
+ * The optional facet filter `search_docs` and `list_pages` share. Only
66
+ * string-valued entries survive; an empty `{}` means "no filter".
116
67
  */
117
- const asFacetFilters = (value: unknown): Record<string, string> | undefined => {
68
+ const filtersField = z.preprocess((value) => {
118
69
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
119
70
  return;
120
71
  }
@@ -122,8 +73,75 @@ const asFacetFilters = (value: unknown): Record<string, string> | undefined => {
122
73
  (entry): entry is [string, string] => typeof entry[1] === "string"
123
74
  );
124
75
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
76
+ }, z.record(z.string(), z.string()).optional().describe('Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.'));
77
+
78
+ /** Clamped into range rather than rejected; non-numeric means the default. */
79
+ const limitField = z.preprocess(
80
+ (value) => {
81
+ const num = typeof value === "number" ? value : Number(value);
82
+ return Number.isFinite(num)
83
+ ? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
84
+ : undefined;
85
+ },
86
+ z
87
+ .int()
88
+ .min(1)
89
+ .max(MAX_SEARCH_LIMIT)
90
+ .optional()
91
+ .describe(`Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`)
92
+ );
93
+
94
+ /** A required text field; a missing or non-string value coerces to "". */
95
+ const textField = (description: string) =>
96
+ z.preprocess(
97
+ (value) => (typeof value === "string" ? value : ""),
98
+ z.string().describe(description)
99
+ );
100
+
101
+ /** Every tool's input schema — the runtime parse and tools/list source. */
102
+ const TOOL_INPUTS = {
103
+ get_navigation: z.object({}),
104
+ get_page: z.object({
105
+ route: textField("The page route, e.g. `/guides/install`."),
106
+ }),
107
+ list_pages: z.object({
108
+ contentTypes: contentTypesField,
109
+ filters: filtersField,
110
+ }),
111
+ search_docs: z.object({
112
+ contentTypes: contentTypesField,
113
+ filters: filtersField,
114
+ limit: limitField,
115
+ query: textField("The search query."),
116
+ }),
125
117
  };
126
118
 
119
+ /**
120
+ * A tool's advertised JSON Schema. The dialect key is dropped (noise in a
121
+ * tools/list payload), as is the root `additionalProperties: false` — the
122
+ * runtime strips unknown keys rather than rejecting them, and the advertised
123
+ * schema shouldn't promise stricter validation than the server performs.
124
+ */
125
+ const inputSchemaFor = (schema: z.ZodType): Record<string, unknown> => {
126
+ const {
127
+ $schema: _dialect,
128
+ additionalProperties: _closed,
129
+ ...rest
130
+ } = z.toJSONSchema(schema);
131
+ return rest;
132
+ };
133
+
134
+ /** The `tools/list` payload, derived from shared metadata + input schemas. */
135
+ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
136
+ annotations: tool.annotations,
137
+ description: tool.description,
138
+ inputSchema: inputSchemaFor(
139
+ TOOL_INPUTS[tool.name as keyof typeof TOOL_INPUTS]
140
+ ),
141
+ name: tool.name,
142
+ title: tool.title,
143
+ }));
144
+
127
145
  /** Whether a page's facet values satisfy every requested filter entry. */
128
146
  const matchesFacets = (
129
147
  facets: Record<string, string> | undefined,
@@ -131,14 +149,6 @@ const matchesFacets = (
131
149
  ): boolean =>
132
150
  Object.entries(filters).every(([key, value]) => facets?.[key] === value);
133
151
 
134
- const asLimit = (value: unknown): number => {
135
- const num = typeof value === "number" ? value : Number(value);
136
- if (!Number.isFinite(num)) {
137
- return DEFAULT_SEARCH_LIMIT;
138
- }
139
- return Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT);
140
- };
141
-
142
152
  /**
143
153
  * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
144
154
  * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
@@ -160,11 +170,10 @@ const normalizeRoute = (input: string, data: McpData): string => {
160
170
  } catch {
161
171
  // Malformed percent sequence — compare it as written.
162
172
  }
163
- const noTrailing = value.replace(/\/+$/u, "");
164
- const noSuffix = noTrailing.replace(/\.mdx?$/u, "");
165
- const withSlash = noSuffix.startsWith("/") ? noSuffix : `/${noSuffix}`;
166
- const based = stripBasePath(data.base, withSlash);
167
- return based === "" ? "/" : based;
173
+ // Trailing slashes come off before the suffix so `/a/b.md/` still loses its
174
+ // `.md`; normalizePageRoute then settles the leading slash.
175
+ const noSuffix = trimEnd(value, "/").replace(/\.mdx?$/u, "");
176
+ return stripBasePath(data.base, normalizePageRoute(noSuffix));
168
177
  };
169
178
 
170
179
  /** Build the absolute (or root-relative) URL for a route. */
@@ -174,7 +183,7 @@ const urlFor = (route: string, data: McpData): string => {
174
183
  const path = withBasePath(data.base, route);
175
184
  // Concatenate rather than `new URL(path, site)` — a root-absolute path
176
185
  // would drop the base path of a subpath deployment (`acme.com/docs`).
177
- return data.site ? `${data.site.replace(/\/+$/u, "")}${path}` : path;
186
+ return data.site ? absoluteUrl(data.site, path) : path;
178
187
  };
179
188
 
180
189
  /** A hit's excerpt: its description, else the head of its content with an
@@ -234,14 +243,15 @@ export const buildServer = (
234
243
  const { arguments: args = {}, name } = request.params;
235
244
 
236
245
  if (name === "search_docs") {
246
+ const input = TOOL_INPUTS.search_docs.parse(args);
237
247
  const db = await index();
238
248
  const hits = await queryOramaIndex(
239
249
  db,
240
- asString(args.query),
241
- asLimit(args.limit),
250
+ input.query,
251
+ input.limit ?? DEFAULT_SEARCH_LIMIT,
242
252
  {
243
- contentTypes: asContentTypes(args.contentTypes),
244
- facets: asFacetFilters(args.filters),
253
+ contentTypes: input.contentTypes,
254
+ facets: input.filters,
245
255
  }
246
256
  );
247
257
  // `route` is the key `get_page` takes (the tool descriptions promise
@@ -258,7 +268,8 @@ export const buildServer = (
258
268
  }
259
269
 
260
270
  if (name === "get_page") {
261
- const key = normalizeRoute(asString(args.route), data);
271
+ const input = TOOL_INPUTS.get_page.parse(args);
272
+ const key = normalizeRoute(input.route, data);
262
273
  const markdown = data.pages[key];
263
274
  if (markdown === undefined) {
264
275
  return text(
@@ -270,8 +281,7 @@ export const buildServer = (
270
281
  }
271
282
 
272
283
  if (name === "list_pages") {
273
- const contentTypes = asContentTypes(args.contentTypes);
274
- const filters = asFacetFilters(args.filters);
284
+ const { contentTypes, filters } = TOOL_INPUTS.list_pages.parse(args);
275
285
  const routes = data.routes.filter(
276
286
  (route) =>
277
287
  (!contentTypes || contentTypes.includes(route.contentType)) &&
package/src/ai/tar.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { gzipSync } from "node:zlib";
2
2
 
3
+ import { createTar } from "nanotar";
4
+
3
5
  /**
4
- * Minimal, dependency-free `.tar.gz` writer for agent-skill archives (POSIX
5
- * ustar). Deterministic by construction — fixed mtime/uid/gid, caller-ordered
6
- * entries, and Node's gzip header carries no timestamp — so a skill's archive
7
- * digest only changes when its content does.
6
+ * `.tar.gz` writer for agent-skill archives, on nanotar's ustar writer.
7
+ * Deterministic by construction — fixed mtime/uid/gid/owner attrs,
8
+ * caller-ordered entries, and Node's gzip header carries no timestamp — so a
9
+ * skill's archive digest only changes when its content does. That holds per
10
+ * machine: the tar bytes are portable, but zlib's compressed stream differs
11
+ * across architectures, so gzip-layer digests are not comparable across
12
+ * platforms.
8
13
  */
9
14
 
10
15
  /** One regular file to archive. Paths are `/`-separated, relative, no `..`. */
@@ -17,67 +22,18 @@ export interface TarEntry {
17
22
  path: string;
18
23
  }
19
24
 
20
- const BLOCK = 512;
21
25
  /** ustar `name` field capacity; skill layouts are shallow, so no `prefix`. */
22
26
  const NAME_MAX = 100;
23
27
 
24
28
  const encoder = new TextEncoder();
25
29
 
26
- /** Write an octal field: zero-padded digits followed by a NUL terminator. */
27
- const octal = (
28
- header: Uint8Array,
29
- offset: number,
30
- length: number,
31
- value: number
32
- ): void => {
33
- const text = value.toString(8).padStart(length - 1, "0");
34
- header.set(encoder.encode(text), offset);
35
- header[offset + length - 1] = 0;
36
- };
37
-
38
- const text = (header: Uint8Array, offset: number, value: string): void => {
39
- header.set(encoder.encode(value), offset);
40
- };
41
-
42
- const fileHeader = (entry: TarEntry): Uint8Array => {
43
- const header = new Uint8Array(BLOCK);
44
- text(header, 0, entry.path);
45
- octal(header, 100, 8, entry.executable ? 0o755 : 0o644);
46
- // uid and gid: root-owned, fixed for determinism.
47
- octal(header, 108, 8, 0);
48
- octal(header, 116, 8, 0);
49
- octal(header, 124, 12, entry.content.byteLength);
50
- // mtime: fixed at the epoch for determinism.
51
- octal(header, 136, 12, 0);
52
- // typeflag "0": regular file.
53
- text(header, 156, "0");
54
- text(header, 257, "ustar");
55
- header[262] = 0;
56
- text(header, 263, "00");
57
- // devmajor and devminor.
58
- octal(header, 329, 8, 0);
59
- octal(header, 337, 8, 0);
60
- // Checksum: computed with the checksum field treated as eight spaces, then
61
- // written as six octal digits, NUL, space (the historical ustar format).
62
- header.fill(0x20, 148, 156);
63
- let sum = 0;
64
- for (const byte of header) {
65
- sum += byte;
66
- }
67
- text(header, 148, sum.toString(8).padStart(6, "0"));
68
- header[154] = 0;
69
- header[155] = 0x20;
70
- return header;
71
- };
72
-
73
30
  /**
74
31
  * Build a gzipped ustar archive of the given files, in the given order. Paths
75
- * longer than the ustar `name` field or escaping the archive root are the
76
- * caller's responsibility to filterthis throws to surface a programming
77
- * error rather than emitting a corrupt archive.
32
+ * longer than the ustar `name` field or escaping the archive root throw to
33
+ * surface a programming errornanotar would silently truncate an oversized
34
+ * name into a corrupt archive, so the validation stays here.
78
35
  */
79
36
  export const buildTarGz = (entries: readonly TarEntry[]): Uint8Array => {
80
- const blocks: Uint8Array[] = [];
81
37
  for (const entry of entries) {
82
38
  if (encoder.encode(entry.path).byteLength > NAME_MAX) {
83
39
  throw new Error(`tar path exceeds ${NAME_MAX} bytes: ${entry.path}`);
@@ -85,20 +41,23 @@ export const buildTarGz = (entries: readonly TarEntry[]): Uint8Array => {
85
41
  if (entry.path.startsWith("/") || entry.path.split("/").includes("..")) {
86
42
  throw new Error(`tar path must be archive-relative: ${entry.path}`);
87
43
  }
88
- blocks.push(fileHeader(entry), entry.content);
89
- const overhang = entry.content.byteLength % BLOCK;
90
- if (overhang > 0) {
91
- blocks.push(new Uint8Array(BLOCK - overhang));
92
- }
93
- }
94
- // End-of-archive marker: two zero blocks.
95
- blocks.push(new Uint8Array(BLOCK * 2));
96
- const total = blocks.reduce((sum, block) => sum + block.byteLength, 0);
97
- const tar = new Uint8Array(total);
98
- let offset = 0;
99
- for (const block of blocks) {
100
- tar.set(block, offset);
101
- offset += block.byteLength;
102
44
  }
45
+ const tar = createTar(
46
+ entries.map((entry) => ({
47
+ // Root-owned, epoch-mtime, empty owner names: every field a rebuild
48
+ // could vary is pinned so the archive bytes are a function of content.
49
+ attrs: {
50
+ gid: 0,
51
+ group: "",
52
+ mode: entry.executable ? "755" : "644",
53
+ mtime: 0,
54
+ uid: 0,
55
+ user: "",
56
+ },
57
+ data: entry.content,
58
+ name: entry.path,
59
+ }))
60
+ );
61
+ // Sync gzip with a pinned level; Node writes no timestamp into the header.
103
62
  return new Uint8Array(gzipSync(tar, { level: 9 }));
104
63
  };
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import pMap from "p-map";
3
4
  import { join, relative } from "pathe";
4
5
  import { glob } from "tinyglobby";
5
6
 
@@ -48,6 +49,9 @@ const EXAMPLE_FILE = /\.(?<ext>astro|jsx|svelte|tsx|vue)$/u;
48
49
  // Renderable example files when `examples` names a plain directory.
49
50
  const DEFAULT_EXAMPLE_GLOB = "**/*.{astro,jsx,svelte,tsx,vue}";
50
51
 
52
+ /** Ceiling on concurrent example-file reads; unbounded fan-out risks EMFILE. */
53
+ const READ_CONCURRENCY = 16;
54
+
51
55
  // Glob magic that turns `examples` from a plain directory into a pattern. `()`,
52
56
  // `@`, and `+` are excluded so literal path segments (npm scopes, parens) keep
53
57
  // resolving as directories; the extglob leads `*?!` still trigger here.
@@ -102,9 +106,9 @@ export const discoverExamples = async (
102
106
  onlyFiles: true,
103
107
  });
104
108
  const files = matches.toSorted();
105
- const sources = await Promise.all(
106
- files.map((file) => readFile(file, "utf-8"))
107
- );
109
+ const sources = await pMap(files, (file) => readFile(file, "utf-8"), {
110
+ concurrency: READ_CONCURRENCY,
111
+ });
108
112
 
109
113
  const examples: ExampleSpec[] = [];
110
114
  const warnings: string[] = [];
@@ -12,11 +12,13 @@ import {
12
12
  import { createRequire } from "node:module";
13
13
  import { pathToFileURL } from "node:url";
14
14
 
15
+ import { imageSize } from "image-size";
16
+ import pMap from "p-map";
15
17
  import { basename, dirname, join, normalize, relative, resolve } from "pathe";
16
18
  import { glob } from "tinyglobby";
17
19
 
18
20
  import { buildAskData } from "../ai/ask-data.ts";
19
- import { resolveAskBackend } from "../ai/ask.ts";
21
+ import { askBackendRuntimeDep, resolveAskBackend } from "../ai/ask.ts";
20
22
  import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
21
23
  import { buildMcpData } from "../ai/mcp/data.ts";
22
24
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
@@ -638,6 +640,33 @@ export const searchProviderWarnings = (
638
640
  return warnings;
639
641
  };
640
642
 
643
+ /**
644
+ * Warn when the Ask AI backend's provider SDK is missing. Like search provider
645
+ * SDKs, these are optional peers the project must install (only `gateway`
646
+ * needs nothing beyond the core `ai` package Blume ships) — warn early with
647
+ * the package name rather than let Vite fail to resolve the import opaquely.
648
+ * Same resolution rule as {@link searchProviderWarnings}: available if the
649
+ * project installed it or Blume can resolve it. `pkgDir` is injectable for
650
+ * testing.
651
+ */
652
+ export const askProviderWarnings = (
653
+ ask: ResolvedConfig["ai"]["ask"],
654
+ root: string,
655
+ pkgDir: string = packageRoot()
656
+ ): string[] => {
657
+ // An external `endpoint` means no generated route, so no SDK is imported.
658
+ if (!ask?.enabled || ask.endpoint) {
659
+ return [];
660
+ }
661
+ const dep = askBackendRuntimeDep(ask);
662
+ if (!dep || canResolveFrom(root, dep) || canResolveFrom(pkgDir, dep)) {
663
+ return [];
664
+ }
665
+ return [
666
+ `Ask AI provider "${ask.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
667
+ ];
668
+ };
669
+
641
670
  /** Absolute path to the configured `examples.css`, or null when unset. */
642
671
  const examplesCssFile = (
643
672
  root: string,
@@ -708,6 +737,9 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
708
737
  const containsMath = (content: string): boolean =>
709
738
  content.includes("$$") || content.includes("<Math");
710
739
 
740
+ /** Ceiling on concurrent content-file reads; unbounded fan-out risks EMFILE. */
741
+ const READ_CONCURRENCY = 16;
742
+
711
743
  /**
712
744
  * Detect whether the project can render math: block math (`$$…$$`) or an
713
745
  * explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
@@ -726,9 +758,9 @@ export const detectUsesMath = async (
726
758
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
727
759
  onlyFiles: true,
728
760
  });
729
- const contents = await Promise.all(
730
- files.map((file) => readOptional(join(root, file)))
731
- );
761
+ const contents = await pMap(files, (file) => readOptional(join(root, file)), {
762
+ concurrency: READ_CONCURRENCY,
763
+ });
732
764
  return [...contents, ...staged].some(containsMath);
733
765
  };
734
766
 
@@ -838,33 +870,24 @@ interface LogoDimensions {
838
870
  width: number;
839
871
  }
840
872
 
841
- const SVG_ROOT = /<svg\b(?<attributes>[^>]*)>/u;
842
- const SVG_WIDTH = /\bwidth\s*=\s*["'](?<value>[^"']+)["']/u;
843
- const SVG_HEIGHT = /\bheight\s*=\s*["'](?<value>[^"']+)["']/u;
844
- const SVG_LENGTH = /^\s*(?<value>[\d.]+)(?:px)?\s*$/u;
845
- const SVG_VIEW_BOX =
846
- /\bviewBox\s*=\s*["'][\d.-]+[\s,]+[\d.-]+[\s,]+(?<width>[\d.]+)[\s,]+(?<height>[\d.]+)["']/u;
847
-
848
- const parseSvgLength = (value: string | undefined): number | undefined => {
849
- const length = Number(value?.match(SVG_LENGTH)?.groups?.value);
850
- return length > 0 ? length : undefined;
851
- };
852
-
853
- /** Read dimensions from an SVG's explicit size or its view box. */
873
+ /**
874
+ * Read dimensions from an SVG's explicit size or its view box. Measured with
875
+ * image-size — the same parser og/card.ts uses for the OG brand mark, so the
876
+ * header and the card can't disagree about one logo — which also tolerates
877
+ * the spellings the old regex missed (unquoted values, `em`/`pt` lengths, a
878
+ * `>` inside another attribute). An SVG with no usable size returns partial
879
+ * dimensions or throws; both collapse to undefined.
880
+ */
854
881
  const svgDimensions = (svg: string | undefined): LogoDimensions | undefined => {
855
- const attributes = svg?.match(SVG_ROOT)?.groups?.attributes;
856
- const width = parseSvgLength(attributes?.match(SVG_WIDTH)?.groups?.value);
857
- const height = parseSvgLength(attributes?.match(SVG_HEIGHT)?.groups?.value);
858
- if (width && height) {
859
- return { height, width };
882
+ if (!svg) {
883
+ return;
884
+ }
885
+ try {
886
+ const { height, width } = imageSize(Buffer.from(svg));
887
+ return height && width ? { height, width } : undefined;
888
+ } catch {
889
+ return undefined;
860
890
  }
861
-
862
- const viewBox = attributes?.match(SVG_VIEW_BOX);
863
- const viewBoxWidth = Number(viewBox?.groups?.width);
864
- const viewBoxHeight = Number(viewBox?.groups?.height);
865
- return viewBoxWidth > 0 && viewBoxHeight > 0
866
- ? { height: viewBoxHeight, width: viewBoxWidth }
867
- : undefined;
868
891
  };
869
892
 
870
893
  /** Read a local SVG logo from the project root or public directory. */
@@ -1370,7 +1393,7 @@ const writeAskFiles = async (
1370
1393
  }
1371
1394
  await write(
1372
1395
  join(srcDir, "pages", "api", "ask.ts"),
1373
- askEndpointTemplate(resolveAskBackend(ask), grounded)
1396
+ askEndpointTemplate(resolveAskBackend(ask), grounded, ask.instructions)
1374
1397
  );
1375
1398
  };
1376
1399
 
@@ -1878,10 +1901,11 @@ export const generateRuntime = async (
1878
1901
  ...islandDiscovery.islands.map((island) => island.name),
1879
1902
  ...overrideTags,
1880
1903
  ]);
1881
- // Missing-dependency preflights: the search provider's SDK, the deployment
1882
- // adapter's package, and — since React ships with Blume while Vue/Svelte
1883
- // don't — any island framework's Astro integration. Warn early rather than
1884
- // let Vite fail to resolve them opaquely.
1904
+ // Missing-dependency preflights: the search provider's SDK, the Ask AI
1905
+ // backend's provider SDK, the deployment adapter's package, and — since
1906
+ // React ships with Blume while Vue/Svelte don't — any island framework's
1907
+ // Astro integration. Warn early rather than let Vite fail to resolve them
1908
+ // opaquely.
1885
1909
  warnings.push(
1886
1910
  ...validateUsedComponents(
1887
1911
  project.graph.pages,
@@ -1889,6 +1913,7 @@ export const generateRuntime = async (
1889
1913
  new Set(registry.map((item) => item.name))
1890
1914
  ).map(diagnosticWarning),
1891
1915
  ...searchProviderWarnings(config.search.provider, context.root),
1916
+ ...askProviderWarnings(config.ai.ask, context.root),
1892
1917
  ...deploymentAdapterWarnings(config.deployment, context.root),
1893
1918
  ...islandFrameworkWarnings(frameworks, context.root)
1894
1919
  );
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import pMap from "p-map";
3
4
  import { basename, join } from "pathe";
4
5
  import { glob } from "tinyglobby";
5
6
 
@@ -29,6 +30,9 @@ export interface IslandDiscovery {
29
30
  /** Hydration mode used when an island doesn't declare one. */
30
31
  const DEFAULT_CLIENT: IslandClientMode = "visible";
31
32
 
33
+ /** Ceiling on concurrent island-file reads; unbounded fan-out risks EMFILE. */
34
+ const READ_CONCURRENCY = 16;
35
+
32
36
  const VALID_MODES = new Set<IslandClientMode>([
33
37
  "idle",
34
38
  "load",
@@ -92,9 +96,9 @@ export const discoverIslands = async (
92
96
  onlyFiles: true,
93
97
  });
94
98
  const files = matches.toSorted();
95
- const sources = await Promise.all(
96
- files.map((file) => readFile(file, "utf-8"))
97
- );
99
+ const sources = await pMap(files, (file) => readFile(file, "utf-8"), {
100
+ concurrency: READ_CONCURRENCY,
101
+ });
98
102
 
99
103
  const islands: IslandSpec[] = [];
100
104
  const warnings: string[] = [];