blume 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/dist/cli/index.js +3221 -201
- package/dist/cli/index.js.map +73 -61
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/integration.ts +3 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +66 -18
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/eval/agents.ts +1 -1
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
package/src/ai/mcp/data.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { normalizeBasePath } from "../../core/base-path.ts";
|
|
|
2
2
|
import type { BlumeProject } from "../../core/project-graph.ts";
|
|
3
3
|
import type { Navigation } from "../../core/types.ts";
|
|
4
4
|
import { buildSearchDocuments } from "../../search/documents.ts";
|
|
5
|
+
import { pageFacets } from "../../search/facets.ts";
|
|
5
6
|
import type { OramaDoc } from "../../search/orama-index.ts";
|
|
6
7
|
import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
|
|
7
8
|
|
|
@@ -9,6 +10,8 @@ import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
|
|
|
9
10
|
export interface McpRoute {
|
|
10
11
|
contentType: string;
|
|
11
12
|
description?: string;
|
|
13
|
+
/** Declared facet values (`content.types.<type>.facets`), key → value. */
|
|
14
|
+
facets?: Record<string, string>;
|
|
12
15
|
indexable: boolean;
|
|
13
16
|
lastModified: string | null;
|
|
14
17
|
route: string;
|
|
@@ -67,18 +70,19 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
|
|
|
67
70
|
])
|
|
68
71
|
);
|
|
69
72
|
|
|
70
|
-
const
|
|
71
|
-
graph.pages.map((page) => [page.id, page.description])
|
|
72
|
-
);
|
|
73
|
+
const pageById = new Map(graph.pages.map((page) => [page.id, page]));
|
|
73
74
|
|
|
74
75
|
const routes: McpRoute[] = [];
|
|
75
76
|
for (const route of manifest.routes) {
|
|
76
77
|
if (route.hidden) {
|
|
77
78
|
continue;
|
|
78
79
|
}
|
|
80
|
+
const page = pageById.get(route.id);
|
|
81
|
+
const facets = page ? pageFacets(page, config) : undefined;
|
|
79
82
|
routes.push({
|
|
80
83
|
contentType: route.contentType,
|
|
81
|
-
description:
|
|
84
|
+
description: page?.description,
|
|
85
|
+
...(facets ? { facets } : {}),
|
|
82
86
|
indexable: route.indexable,
|
|
83
87
|
lastModified: route.lastModified ?? null,
|
|
84
88
|
route: route.path,
|
|
@@ -91,7 +95,9 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
|
|
|
91
95
|
defaultLocale: config.i18n?.defaultLocale,
|
|
92
96
|
documents: documents.map((doc) => ({
|
|
93
97
|
content: doc.content,
|
|
98
|
+
contentType: doc.contentType,
|
|
94
99
|
description: doc.description,
|
|
100
|
+
...(doc.facets ? { facets: doc.facets } : {}),
|
|
95
101
|
route: doc.route,
|
|
96
102
|
title: doc.title,
|
|
97
103
|
})),
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -33,6 +33,22 @@ const CORS_HEADERS: Record<string, string> = {
|
|
|
33
33
|
"Access-Control-Expose-Headers": "Mcp-Session-Id",
|
|
34
34
|
};
|
|
35
35
|
|
|
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
|
+
|
|
36
52
|
/** JSON Schema for each tool's input, keyed by tool name. */
|
|
37
53
|
const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
|
|
38
54
|
get_navigation: { properties: {}, type: "object" },
|
|
@@ -46,9 +62,17 @@ const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
|
|
|
46
62
|
required: ["route"],
|
|
47
63
|
type: "object",
|
|
48
64
|
},
|
|
49
|
-
list_pages: {
|
|
65
|
+
list_pages: {
|
|
66
|
+
properties: {
|
|
67
|
+
contentTypes: CONTENT_TYPES_SCHEMA,
|
|
68
|
+
filters: FILTERS_SCHEMA,
|
|
69
|
+
},
|
|
70
|
+
type: "object",
|
|
71
|
+
},
|
|
50
72
|
search_docs: {
|
|
51
73
|
properties: {
|
|
74
|
+
contentTypes: CONTENT_TYPES_SCHEMA,
|
|
75
|
+
filters: FILTERS_SCHEMA,
|
|
52
76
|
limit: {
|
|
53
77
|
description: `Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`,
|
|
54
78
|
maximum: MAX_SEARCH_LIMIT,
|
|
@@ -74,6 +98,39 @@ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
|
|
|
74
98
|
const asString = (value: unknown): string =>
|
|
75
99
|
typeof value === "string" ? value : "";
|
|
76
100
|
|
|
101
|
+
/**
|
|
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
|
|
104
|
+
* string is accepted as a one-element list.
|
|
105
|
+
*/
|
|
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");
|
|
110
|
+
return list.length > 0 ? list : undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The `filters` facet map with only its string-valued entries, or `undefined`
|
|
115
|
+
* when nothing usable remains — an empty `{}` means "no filter".
|
|
116
|
+
*/
|
|
117
|
+
const asFacetFilters = (value: unknown): Record<string, string> | undefined => {
|
|
118
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const entries = Object.entries(value).filter(
|
|
122
|
+
(entry): entry is [string, string] => typeof entry[1] === "string"
|
|
123
|
+
);
|
|
124
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Whether a page's facet values satisfy every requested filter entry. */
|
|
128
|
+
const matchesFacets = (
|
|
129
|
+
facets: Record<string, string> | undefined,
|
|
130
|
+
filters: Record<string, string>
|
|
131
|
+
): boolean =>
|
|
132
|
+
Object.entries(filters).every(([key, value]) => facets?.[key] === value);
|
|
133
|
+
|
|
77
134
|
const asLimit = (value: unknown): number => {
|
|
78
135
|
const num = typeof value === "number" ? value : Number(value);
|
|
79
136
|
if (!Number.isFinite(num)) {
|
|
@@ -181,12 +238,18 @@ export const buildServer = (
|
|
|
181
238
|
const hits = await queryOramaIndex(
|
|
182
239
|
db,
|
|
183
240
|
asString(args.query),
|
|
184
|
-
asLimit(args.limit)
|
|
241
|
+
asLimit(args.limit),
|
|
242
|
+
{
|
|
243
|
+
contentTypes: asContentTypes(args.contentTypes),
|
|
244
|
+
facets: asFacetFilters(args.filters),
|
|
245
|
+
}
|
|
185
246
|
);
|
|
186
247
|
// `route` is the key `get_page` takes (the tool descriptions promise
|
|
187
248
|
// it); `url` is where the page is served.
|
|
188
249
|
const results = hits.map((doc: OramaDoc) => ({
|
|
250
|
+
contentType: doc.contentType,
|
|
189
251
|
excerpt: excerptFor(doc),
|
|
252
|
+
facets: doc.facets,
|
|
190
253
|
route: doc.route,
|
|
191
254
|
title: doc.title,
|
|
192
255
|
url: urlFor(doc.route, data),
|
|
@@ -207,11 +270,19 @@ export const buildServer = (
|
|
|
207
270
|
}
|
|
208
271
|
|
|
209
272
|
if (name === "list_pages") {
|
|
273
|
+
const contentTypes = asContentTypes(args.contentTypes);
|
|
274
|
+
const filters = asFacetFilters(args.filters);
|
|
275
|
+
const routes = data.routes.filter(
|
|
276
|
+
(route) =>
|
|
277
|
+
(!contentTypes || contentTypes.includes(route.contentType)) &&
|
|
278
|
+
(!filters || matchesFacets(route.facets, filters))
|
|
279
|
+
);
|
|
210
280
|
return text(
|
|
211
281
|
JSON.stringify(
|
|
212
|
-
|
|
282
|
+
routes.map((route) => ({
|
|
213
283
|
contentType: route.contentType,
|
|
214
284
|
description: route.description,
|
|
285
|
+
facets: route.facets,
|
|
215
286
|
lastModified: route.lastModified,
|
|
216
287
|
route: route.route,
|
|
217
288
|
title: route.title,
|
package/src/ai/mcp/tools.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
|
|
|
19
19
|
{
|
|
20
20
|
annotations: READ_ONLY,
|
|
21
21
|
description:
|
|
22
|
-
|
|
22
|
+
'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), and `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`). Use this first to discover relevant pages, then `get_page` to read one in full.',
|
|
23
23
|
name: "search_docs",
|
|
24
24
|
title: "Search documentation",
|
|
25
25
|
},
|
|
@@ -33,7 +33,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
|
|
|
33
33
|
{
|
|
34
34
|
annotations: READ_ONLY,
|
|
35
35
|
description:
|
|
36
|
-
"List every documentation page with its route, title, description, and
|
|
36
|
+
"List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes` and/or `filters` to narrow the list. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.",
|
|
37
37
|
name: "list_pages",
|
|
38
38
|
title: "List pages",
|
|
39
39
|
},
|
package/src/astro/integration.ts
CHANGED
|
@@ -114,7 +114,9 @@ const isHomeUrl = (rawUrl: string | undefined, base?: string): boolean => {
|
|
|
114
114
|
* production the content pages are prerendered and served from the platform's
|
|
115
115
|
* static layer, which this middleware never fronts. Vercel server builds get
|
|
116
116
|
* the same negotiation from routing rules spliced into the Build Output config
|
|
117
|
-
* (see `deploy/vercel-negotiation.ts`)
|
|
117
|
+
* (see `deploy/vercel-negotiation.ts`), Cloudflare server builds from a
|
|
118
|
+
* wrapper Worker routed to by `assets.run_worker_first` (see
|
|
119
|
+
* `deploy/cloudflare-negotiation.ts`); every other build exposes the same
|
|
118
120
|
* content at the `.md` URL. Only routes with a Markdown variant are rewritten,
|
|
119
121
|
* so user `.astro` pages keep serving HTML — except the homepage, whose
|
|
120
122
|
* variant falls back to the synthesized llms.txt mirror when it's a landing
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* endpoints already serve a page's source verbatim; these helpers let the dev
|
|
4
4
|
* server honor `Accept: text/markdown` by transparently rewriting a page
|
|
5
5
|
* request to its `.md` variant.
|
|
6
|
+
*
|
|
7
|
+
* The Cloudflare negotiation Worker embeds a plain-JavaScript copy of these
|
|
8
|
+
* helpers (`deploy/cloudflare-negotiation.ts` — its deploy bundle is uploaded
|
|
9
|
+
* unbundled, so it cannot import this module); when editing here, mirror the
|
|
10
|
+
* change there. Parity is enforced by `test/cloudflare-negotiation.test.ts`.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
interface AcceptEntry {
|
package/src/astro/templates.ts
CHANGED
|
@@ -370,6 +370,38 @@ const renderImageOption = (config: ResolvedConfig): string =>
|
|
|
370
370
|
? `\n image: ${JSON.stringify(config.image)},`
|
|
371
371
|
: "";
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Startup-scan entry points and forced includes for the dev dep optimizer:
|
|
375
|
+
* the Vite root is the generated runtime, so user pages, convention islands,
|
|
376
|
+
* and alias-reachable components all live outside it and are otherwise only
|
|
377
|
+
* crawled when first requested. The compiler runtime rides the include list
|
|
378
|
+
* because it is Babel-injected and no source scan can see it. See the
|
|
379
|
+
* optimizeDeps comment in the generated config for the failure this prevents.
|
|
380
|
+
*/
|
|
381
|
+
const resolveOptimizeDeps = (options: {
|
|
382
|
+
aliases: Record<string, string> | undefined;
|
|
383
|
+
context: ProjectContext;
|
|
384
|
+
needsReact: boolean;
|
|
385
|
+
reactCompilerPath: string | null | undefined;
|
|
386
|
+
}): { optimizeDepsEntries: string[]; optimizeDepsInclude: string[] } => {
|
|
387
|
+
const { context } = options;
|
|
388
|
+
const optimizeDepsEntries = [
|
|
389
|
+
...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
|
|
390
|
+
`${context.root}/islands/**/*.{jsx,svelte,tsx,vue}`,
|
|
391
|
+
...[...new Set(Object.values(options.aliases ?? {}))]
|
|
392
|
+
.toSorted()
|
|
393
|
+
.map((dir) => `${dir}/**/*.{astro,jsx,svelte,tsx,vue}`),
|
|
394
|
+
];
|
|
395
|
+
const optimizeDepsInclude = [
|
|
396
|
+
"blume > mermaid",
|
|
397
|
+
"blume > epub-gen-memory/bundle",
|
|
398
|
+
...(options.needsReact && options.reactCompilerPath
|
|
399
|
+
? ["react/compiler-runtime"]
|
|
400
|
+
: []),
|
|
401
|
+
];
|
|
402
|
+
return { optimizeDepsEntries, optimizeDepsInclude };
|
|
403
|
+
};
|
|
404
|
+
|
|
373
405
|
export const astroConfigTemplate = (options: {
|
|
374
406
|
context: ProjectContext;
|
|
375
407
|
config: ResolvedConfig;
|
|
@@ -424,6 +456,13 @@ export const astroConfigTemplate = (options: {
|
|
|
424
456
|
// KaTeX fonts under a monorepo's root node_modules) stay servable in dev.
|
|
425
457
|
const fsAllow = [...new Set([findWorkspaceRoot(context.root), context.root])];
|
|
426
458
|
|
|
459
|
+
const { optimizeDepsEntries, optimizeDepsInclude } = resolveOptimizeDeps({
|
|
460
|
+
aliases: options.aliases,
|
|
461
|
+
context,
|
|
462
|
+
needsReact,
|
|
463
|
+
reactCompilerPath: options.reactCompilerPath,
|
|
464
|
+
});
|
|
465
|
+
|
|
427
466
|
const adapterImport =
|
|
428
467
|
server && deployment.adapter
|
|
429
468
|
? `import adapter from "${ADAPTER_IMPORTS[deployment.adapter]}";\n`
|
|
@@ -642,24 +681,32 @@ ${userConfigSetup}export default defineConfig({
|
|
|
642
681
|
devToolbar: { enabled: false },
|
|
643
682
|
vite: {
|
|
644
683
|
plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
684
|
+
// Everything hydration can reach must be part of the dev dep optimizer's
|
|
685
|
+
// FIRST run. The Vite root is the generated runtime, so user pages,
|
|
686
|
+
// islands, and aliased components live outside it and are only crawled
|
|
687
|
+
// when first requested — and \`react/compiler-runtime\` is Babel-injected,
|
|
688
|
+
// so no source scan can ever see it. A dependency discovered after
|
|
689
|
+
// hydration begins triggers a mid-session re-optimization whose new
|
|
690
|
+
// generation imports React through new \`?v=\` URLs; the browser then
|
|
691
|
+
// evaluates a second React copy and every island tears down with
|
|
692
|
+
// "Invalid hook call" (#157). \`entries\` points the startup scanner at
|
|
693
|
+
// the user's files (the scanner follows their imports, so their deps land
|
|
694
|
+
// in the initial optimization); the compiler runtime rides the include
|
|
695
|
+
// list because only the transform pipeline knows it exists.
|
|
696
|
+
//
|
|
697
|
+
// The mermaid/epub includes fix CJS interop instead: both lazy client-side
|
|
698
|
+
// imports land on CJS/UMD files (mermaid statically imports dayjs as CJS,
|
|
699
|
+
// epub-gen-memory's browser bundle is a browserified UMD) that break when
|
|
700
|
+
// served as raw ESM — mermaid throws on load and the EPUB export throws
|
|
701
|
+
// \`epub is not a function\`. They resolve through the \`blume\` package
|
|
702
|
+
// (they aren't direct deps of the generated project), so the nested
|
|
703
|
+
// \`blume > x\` form is required, and epub-gen-memory must name the
|
|
704
|
+
// \`/bundle\` subpath that is actually imported: optimizing the package
|
|
705
|
+
// root leaves that entry out. Production (Rollup) already handles the
|
|
706
|
+
// interop, so all of this only affects dev.
|
|
661
707
|
optimizeDeps: {
|
|
662
|
-
|
|
708
|
+
entries: ${JSON.stringify(optimizeDepsEntries)},
|
|
709
|
+
include: ${JSON.stringify(optimizeDepsInclude)},
|
|
663
710
|
},
|
|
664
711
|
// Blume's render-time deps are forced external on both build environments so
|
|
665
712
|
// native bindings resolve at runtime and isolated linkers don't bundle
|
|
@@ -1309,11 +1356,12 @@ export const mcpEndpointTemplate = (route: string): string => {
|
|
|
1309
1356
|
return `// Generated by Blume. Do not edit.
|
|
1310
1357
|
import type { APIRoute } from "astro";
|
|
1311
1358
|
import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
|
|
1359
|
+
import type { McpData } from "blume/ai/mcp/data.ts";
|
|
1312
1360
|
import data from "${up}generated/mcp-data.json";
|
|
1313
1361
|
|
|
1314
1362
|
export const prerender = false;
|
|
1315
1363
|
|
|
1316
|
-
const handler = createMcpFetchHandler(data);
|
|
1364
|
+
const handler = createMcpFetchHandler(data as McpData);
|
|
1317
1365
|
|
|
1318
1366
|
export const ALL: APIRoute = ({ request }) => handler(request);
|
|
1319
1367
|
`;
|
package/src/audit/url.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { stripBasePath } from "../core/base-path.ts";
|
|
1
|
+
import { normalizePath, stripBasePath } from "../core/base-path.ts";
|
|
2
|
+
|
|
3
|
+
// Re-exported from its home next to the other path helpers; the audit checks
|
|
4
|
+
// (and their tests) import it from here.
|
|
5
|
+
export { normalizePath } from "../core/base-path.ts";
|
|
2
6
|
|
|
3
7
|
/** What an `href` in built HTML turned out to point at. */
|
|
4
8
|
export type ResolvedHref =
|
|
@@ -13,15 +17,6 @@ export type ResolvedHref =
|
|
|
13
17
|
|
|
14
18
|
const NON_HTTP_SCHEME = /^(?!https?:)[a-z][a-z0-9+.-]*:/iu;
|
|
15
19
|
|
|
16
|
-
/**
|
|
17
|
-
* Normalize a site path for comparison: drop the trailing slash (Astro serves
|
|
18
|
-
* `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
|
|
19
|
-
*/
|
|
20
|
-
export const normalizePath = (path: string): string => {
|
|
21
|
-
const trimmed = path.replace(/\/+$/u, "");
|
|
22
|
-
return trimmed === "" ? "/" : trimmed;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
20
|
/**
|
|
26
21
|
* Percent-decode a pathname for comparison against the built file tree. Page
|
|
27
22
|
* URLs and file-index keys come from raw on-disk names, while `URL#pathname`
|
|
@@ -38,14 +38,20 @@ import type { ProjectContext } from "../../core/types.ts";
|
|
|
38
38
|
import {
|
|
39
39
|
ADAPTER_IGNORE_DIRS,
|
|
40
40
|
deployStaticDir,
|
|
41
|
+
readsHeaderFiles,
|
|
42
|
+
servesClientSubdir,
|
|
41
43
|
surfaceAdapterOutput,
|
|
42
44
|
} from "../../deploy/adapter-output.ts";
|
|
45
|
+
import {
|
|
46
|
+
injectWorkerNegotiation,
|
|
47
|
+
NEGOTIATION_WORKER_FILE,
|
|
48
|
+
} from "../../deploy/cloudflare-negotiation.ts";
|
|
43
49
|
import { buildNetlifyHeaders } from "../../deploy/headers.ts";
|
|
44
50
|
import {
|
|
45
|
-
applyBaseToPlatformRedirects,
|
|
46
51
|
buildNetlifyRedirects,
|
|
47
52
|
buildRedirectManifest,
|
|
48
53
|
buildVercelConfig,
|
|
54
|
+
platformRedirects,
|
|
49
55
|
} from "../../deploy/redirects.ts";
|
|
50
56
|
import { buildRobots } from "../../deploy/robots.ts";
|
|
51
57
|
import { buildSitemap } from "../../deploy/sitemap.ts";
|
|
@@ -99,11 +105,7 @@ const emitRedirectFiles = async (
|
|
|
99
105
|
config: ResolvedConfig,
|
|
100
106
|
distDir: string
|
|
101
107
|
): Promise<void> => {
|
|
102
|
-
const redirects =
|
|
103
|
-
config.redirects,
|
|
104
|
-
config.basePath,
|
|
105
|
-
config.deployment.base ?? ""
|
|
106
|
-
);
|
|
108
|
+
const redirects = platformRedirects(config);
|
|
107
109
|
if (redirects.length === 0 || config.deployment.output !== "static") {
|
|
108
110
|
return;
|
|
109
111
|
}
|
|
@@ -127,33 +129,61 @@ const emitRedirectFiles = async (
|
|
|
127
129
|
};
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
|
-
* Emit a `_headers` file
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
132
|
+
* Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
|
|
133
|
+
* endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
|
|
134
|
+
* it those hosts send `text/markdown` / `text/plain` with no charset and
|
|
135
|
+
* browsers fall back to Windows-1252, garbling any non-ASCII docs (#82).
|
|
136
|
+
*
|
|
137
|
+
* The same file carries the rest of the agent-discovery surface that only a
|
|
138
|
+
* response header can express: the homepage `Link` header (RFC 8288, see
|
|
139
|
+
* `ai/link-headers.ts`), and the registered media types for the extensionless
|
|
140
|
+
* well-known files — `application/linkset+json` for the API catalog, the
|
|
141
|
+
* signatures directory, and the Agent Skills archives. A static host serves
|
|
142
|
+
* those as `octet-stream` or nothing at all without a rule.
|
|
143
|
+
*
|
|
144
|
+
* A `_headers` shipped in `public/` wins, exactly like `_redirects` — the opt-out
|
|
145
|
+
* is checked at its source rather than in `dist`, because on Cloudflare the file
|
|
146
|
+
* in `dist` is not necessarily the user's: `@astrojs/cloudflare` writes its own
|
|
147
|
+
* `_headers` (an immutable `Cache-Control` rule for `/_astro/*`) during the
|
|
148
|
+
* build, before this runs. Testing `dist` therefore read an adapter-generated
|
|
149
|
+
* file as a user opt-out and skipped silently. When both exist, the adapter's
|
|
150
|
+
* rules are preserved and ours are appended.
|
|
151
|
+
*
|
|
152
|
+
* Gated on {@link readsHeaderFiles}, not on `output === "static"`. A **Cloudflare
|
|
153
|
+
* server** build serves `dist/client` through the Worker's ASSETS binding, and
|
|
154
|
+
* Workers static assets honor `_headers` from that directory — so the file
|
|
155
|
+
* applies there too, and skipping it left every Cloudflare server build with no
|
|
156
|
+
* `Link` header and no media type on its own discovery files. The charset half
|
|
157
|
+
* of this file *is* redundant on a server build, because the runtime endpoint
|
|
158
|
+
* sets Content-Type on the Response itself; the `Link` and well-known halves are
|
|
159
|
+
* not, and one conclusion about the first was applied to all three.
|
|
160
|
+
*
|
|
161
|
+
* Exported for the test suite, which exercises it in a subprocess like the
|
|
162
|
+
* other command helpers.
|
|
139
163
|
*/
|
|
140
|
-
const emitHeaderFiles = async (
|
|
164
|
+
export const emitHeaderFiles = async (
|
|
141
165
|
project: BlumeProject,
|
|
142
166
|
distDir: string
|
|
143
167
|
): Promise<void> => {
|
|
144
168
|
const { config } = project;
|
|
145
169
|
if (
|
|
146
|
-
config.deployment
|
|
147
|
-
existsSync(join(
|
|
170
|
+
!readsHeaderFiles(config.deployment) ||
|
|
171
|
+
existsSync(join(project.context.root, "public", "_headers"))
|
|
148
172
|
) {
|
|
149
173
|
return;
|
|
150
174
|
}
|
|
175
|
+
const ours = buildNetlifyHeaders(
|
|
176
|
+
config,
|
|
177
|
+
buildHomeLinkHeader(config, markdownRoutePaths(project))
|
|
178
|
+
);
|
|
179
|
+
// An adapter may have written its own rules here already (Cloudflare adds an
|
|
180
|
+
// immutable Cache-Control for /_astro/*). Keep them and append ours: both
|
|
181
|
+
// sets are wanted, and `_headers` has no merge semantics beyond order.
|
|
182
|
+
const target = join(distDir, "_headers");
|
|
183
|
+
const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
|
|
151
184
|
await writeFile(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
config,
|
|
155
|
-
buildHomeLinkHeader(config, markdownRoutePaths(project))
|
|
156
|
-
),
|
|
185
|
+
target,
|
|
186
|
+
existing ? `${existing.trimEnd()}\n${ours}` : ours,
|
|
157
187
|
"utf-8"
|
|
158
188
|
);
|
|
159
189
|
logger.success(
|
|
@@ -297,6 +327,72 @@ const emitVercelNegotiation = async (
|
|
|
297
327
|
);
|
|
298
328
|
};
|
|
299
329
|
|
|
330
|
+
const warnCloudflareNegotiationSkipped = (): void =>
|
|
331
|
+
logger.warn(
|
|
332
|
+
"Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json — raw Markdown stays available at the .md URLs."
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Wire `Accept: text/markdown` negotiation into a Cloudflare server build. The
|
|
337
|
+
* ASSETS binding serves the prerendered content pages before the Worker runs —
|
|
338
|
+
* and even a request that reaches the Worker is answered by the adapter's
|
|
339
|
+
* handler from that binding, ahead of the only place middleware runs — so the
|
|
340
|
+
* negotiation lives in a generated wrapper Worker, routed to by
|
|
341
|
+
* `assets.run_worker_first` (see `deploy/cloudflare-negotiation.ts`). Both
|
|
342
|
+
* pieces are spliced into the adapter's emitted `dist/server` bundle.
|
|
343
|
+
*/
|
|
344
|
+
const emitCloudflareNegotiation = async (
|
|
345
|
+
project: BlumeProject,
|
|
346
|
+
routePaths: string[]
|
|
347
|
+
): Promise<void> => {
|
|
348
|
+
const { config, context } = project;
|
|
349
|
+
const serverDir = join(
|
|
350
|
+
context.distDir ?? join(context.root, "dist"),
|
|
351
|
+
"server"
|
|
352
|
+
);
|
|
353
|
+
const wranglerPath = join(serverDir, "wrangler.json");
|
|
354
|
+
if (!existsSync(wranglerPath)) {
|
|
355
|
+
warnCloudflareNegotiationSkipped();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// The homepage mirror is served from the static layer, so its
|
|
359
|
+
// `x-markdown-tokens` estimate rides the wrapper Worker, mirroring the
|
|
360
|
+
// Vercel routing config.
|
|
361
|
+
const rawMarkdown = await buildRawMarkdown(project);
|
|
362
|
+
const home = rawMarkdown["/"];
|
|
363
|
+
const injected = injectWorkerNegotiation(
|
|
364
|
+
await readFile(wranglerPath, "utf-8"),
|
|
365
|
+
{
|
|
366
|
+
base: config.deployment.base,
|
|
367
|
+
// The manifest routes guard the wrapper's redirect table; `routePaths`
|
|
368
|
+
// also carries the synthesized homepage mirror, which must not block a
|
|
369
|
+
// configured root redirect.
|
|
370
|
+
contentRoutePaths: project.manifest.routes.map((route) => route.path),
|
|
371
|
+
homeLinkHeader: buildHomeLinkHeader(config, routePaths),
|
|
372
|
+
homeTokens: home ? markdownTokenCount(agentMarkdown(home)) : undefined,
|
|
373
|
+
// The wrapper Worker matches full served URLs, so the redirects are
|
|
374
|
+
// based the same way the platform files are — it answers any the
|
|
375
|
+
// worker-first rules claim, where `_redirects` is never consulted and
|
|
376
|
+
// Astro would default their status.
|
|
377
|
+
redirects: platformRedirects(config),
|
|
378
|
+
routePaths,
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
if (injected === null) {
|
|
382
|
+
warnCloudflareNegotiationSkipped();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await writeFile(
|
|
386
|
+
join(serverDir, NEGOTIATION_WORKER_FILE),
|
|
387
|
+
injected.worker,
|
|
388
|
+
"utf-8"
|
|
389
|
+
);
|
|
390
|
+
await writeFile(wranglerPath, injected.wrangler, "utf-8");
|
|
391
|
+
logger.success(
|
|
392
|
+
"Wired Accept: text/markdown negotiation into the Cloudflare Worker"
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
300
396
|
const formatBytes = (bytes: number): string => {
|
|
301
397
|
if (bytes < 1024) {
|
|
302
398
|
return `${bytes} B`;
|
|
@@ -435,6 +531,8 @@ export const isolatedOutputDir = (
|
|
|
435
531
|
* bundle to the project root — a Vercel server build's static output stays at
|
|
436
532
|
* `<runtime>/.vercel/output/static`, where `deployStaticDir` would instead
|
|
437
533
|
* point at the project-root copy (a previous real build's assets, or nothing).
|
|
534
|
+
* Node and Cloudflare server builds serve one level down, at `client/` — see
|
|
535
|
+
* {@link servesClientSubdir}.
|
|
438
536
|
*/
|
|
439
537
|
export const isolatedStaticDir = (
|
|
440
538
|
config: ResolvedConfig,
|
|
@@ -445,7 +543,7 @@ export const isolatedStaticDir = (
|
|
|
445
543
|
if (output === "server" && adapter === "vercel") {
|
|
446
544
|
return join(outputDir, "static");
|
|
447
545
|
}
|
|
448
|
-
if (
|
|
546
|
+
if (servesClientSubdir(config.deployment)) {
|
|
449
547
|
return join(outputDir, "client");
|
|
450
548
|
}
|
|
451
549
|
return outputDir;
|
|
@@ -722,6 +820,13 @@ export const buildCommand = defineCommand({
|
|
|
722
820
|
await emitVercelNegotiation(project, markdownRoutePaths(project), root);
|
|
723
821
|
}
|
|
724
822
|
|
|
823
|
+
if (
|
|
824
|
+
project.config.deployment.output === "server" &&
|
|
825
|
+
adapter === "cloudflare"
|
|
826
|
+
) {
|
|
827
|
+
await emitCloudflareNegotiation(project, markdownRoutePaths(project));
|
|
828
|
+
}
|
|
829
|
+
|
|
725
830
|
await publishBuildArtifacts(
|
|
726
831
|
project,
|
|
727
832
|
deployStaticDir(project.config, project.context),
|