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.
- package/CHANGELOG.md +54 -0
- package/dist/cli/index.js +694 -579
- package/dist/cli/index.js.map +56 -55
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/schema.d.ts +4 -0
- package/dist/types/core/sources/types.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +8 -2
- package/docs/configuration/ai.mdx +26 -8
- package/docs/content/sources.mdx +1 -1
- package/package.json +11 -1
- package/src/ai/agent-readability.ts +3 -2
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +45 -12
- package/src/ai/llms.ts +2 -1
- package/src/ai/mcp/discovery.ts +25 -6
- package/src/ai/mcp/server.ts +108 -98
- package/src/ai/tar.ts +29 -70
- package/src/astro/examples.ts +7 -3
- package/src/astro/generate.ts +59 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +34 -9
- package/src/audit/agent.ts +14 -29
- package/src/audit/crawl.ts +41 -16
- package/src/audit/run.ts +10 -3
- package/src/audit/snapshot.ts +27 -2
- package/src/cli/commands/audit.ts +12 -17
- package/src/cli/commands/build.ts +15 -7
- package/src/cli/commands/dev.ts +13 -15
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +17 -27
- package/src/cli/env.ts +13 -30
- package/src/cli/init/scaffold.ts +21 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +78 -9
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +6 -48
- package/src/components/layout/Search.astro +56 -9
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/search/pagefind.ts +6 -5
- package/src/components/layout/search/types.ts +32 -0
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/config-input.ts +8 -0
- package/src/core/frontmatter.ts +45 -1
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +12 -1
- package/src/core/schema.ts +6 -0
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/cache.ts +10 -8
- package/src/core/sources/github-releases.ts +21 -1
- package/src/core/sources/normalize.ts +26 -2
- package/src/core/sources/notion.ts +27 -5
- package/src/core/sources/portable-text.ts +16 -1
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/types.ts +13 -1
- package/src/deploy/cloudflare-negotiation.ts +15 -1
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +56 -7
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +1 -14
- package/src/markdown/package-commands.ts +61 -54
- package/src/og/card.ts +24 -26
- package/src/openapi/model.ts +9 -9
- package/src/openapi/parse.ts +69 -28
- package/src/openapi/references.ts +35 -12
- package/src/openapi/render-mdx.ts +64 -25
- package/src/openapi/scalar.ts +2 -2
- package/src/openapi/source.ts +28 -1
- package/src/search/documents.ts +78 -34
- package/src/search/orama-index.ts +51 -12
- package/src/theme/palette.ts +6 -2
- package/src/translate/ledger.ts +4 -2
- package/src/translate/report.ts +1 -14
- package/src/translate/run.ts +20 -35
- package/src/cli/coalesce.ts +0 -43
package/src/ai/mcp/server.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
19
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
|
107
|
-
const list = Array.isArray(value)
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
115
|
-
*
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
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 ?
|
|
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
|
-
|
|
241
|
-
|
|
250
|
+
input.query,
|
|
251
|
+
input.limit ?? DEFAULT_SEARCH_LIMIT,
|
|
242
252
|
{
|
|
243
|
-
contentTypes:
|
|
244
|
-
facets:
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* entries, and Node's gzip header carries no timestamp — so a
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
32
|
+
* longer than the ustar `name` field or escaping the archive root throw to
|
|
33
|
+
* surface a programming error — nanotar 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
|
};
|
package/src/astro/examples.ts
CHANGED
|
@@ -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
|
|
106
|
-
|
|
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[] = [];
|
package/src/astro/generate.ts
CHANGED
|
@@ -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
|
|
730
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
1882
|
-
// adapter's package, and — since
|
|
1883
|
-
// don't — any island framework's
|
|
1884
|
-
// let Vite fail to resolve them
|
|
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
|
);
|
package/src/astro/islands.ts
CHANGED
|
@@ -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
|
|
96
|
-
|
|
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[] = [];
|