blume 1.3.0 → 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 +59 -0
- package/dist/cli/index.js +3290 -254
- package/dist/cli/index.js.map +79 -67
- 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 +16 -2
- 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/link-headers.ts +4 -3
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +34 -1
- 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/generate.ts +2 -2
- package/src/astro/integration.ts +6 -2
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +74 -21
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +145 -34
- 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/deploy/vercel-negotiation.ts +30 -13
- 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/fonts.ts +3 -1
- 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/llms.ts
CHANGED
|
@@ -60,8 +60,10 @@ const indexedNavigations = (
|
|
|
60
60
|
* Build the compact `llms.txt` index: title and summary, then the sidebar tree
|
|
61
61
|
* rendered as sections — group labels become headings, pages become link lists —
|
|
62
62
|
* so the file mirrors how the docs are organized rather than one flat blob.
|
|
63
|
+
* Also serves as the homepage's synthesized Markdown mirror when the home
|
|
64
|
+
* route is a landing page (see `buildRawMarkdown`).
|
|
63
65
|
*/
|
|
64
|
-
const
|
|
66
|
+
export const buildLlmsIndex = (project: BlumeProject): string => {
|
|
65
67
|
const { config } = project;
|
|
66
68
|
const { site } = config.deployment;
|
|
67
69
|
const base = normalizeBasePath(config.deployment.base);
|
|
@@ -222,5 +224,5 @@ export const buildLlmsFiles = async (
|
|
|
222
224
|
project: BlumeProject
|
|
223
225
|
): Promise<{ index: string; full: string }> => ({
|
|
224
226
|
full: await buildFull(project),
|
|
225
|
-
index:
|
|
227
|
+
index: buildLlmsIndex(project),
|
|
226
228
|
});
|
package/src/ai/markdown.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
downlevelComponents,
|
|
10
10
|
exampleComponentSerializers,
|
|
11
11
|
} from "./component-markdown.ts";
|
|
12
|
+
import { buildLlmsIndex } from "./llms.ts";
|
|
12
13
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
13
14
|
|
|
14
15
|
/** One route's raw-Markdown variants. */
|
|
@@ -28,6 +29,16 @@ export interface RawMarkdownEntry {
|
|
|
28
29
|
export const agentMarkdown = (entry: RawMarkdownEntry): string =>
|
|
29
30
|
entry.md ?? entry.mdx;
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Estimated token count of a Markdown document, for the `x-markdown-tokens`
|
|
34
|
+
* response header (the convention Cloudflare's Markdown for Agents ships; the
|
|
35
|
+
* tokenizer is unspecified there too, so this is the common ~4-characters-
|
|
36
|
+
* per-token estimate). Kept in sync with the inline computation in
|
|
37
|
+
* `rawMarkdownEndpointTemplate`, which can't import build-time code.
|
|
38
|
+
*/
|
|
39
|
+
export const markdownTokenCount = (text: string): number =>
|
|
40
|
+
Math.ceil(text.length / 4);
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Map every route to its raw source Markdown. Powers the `<route>.md` and
|
|
33
44
|
* `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
|
|
@@ -79,5 +90,27 @@ export const buildRawMarkdown = async (
|
|
|
79
90
|
return [route.path, entry] as const;
|
|
80
91
|
})
|
|
81
92
|
);
|
|
82
|
-
|
|
93
|
+
const map = Object.fromEntries(entries);
|
|
94
|
+
// A landing-page homepage (user `.astro` page, or no home route at all) has
|
|
95
|
+
// no Markdown source, but agents negotiating `Accept: text/markdown` on `/`
|
|
96
|
+
// still expect a Markdown answer. The llms.txt index — the machine-readable
|
|
97
|
+
// representation of the site a landing page fronts — becomes its mirror, so
|
|
98
|
+
// `/index.md` always exists (see `markdownRoutePaths`).
|
|
99
|
+
if (!map["/"]) {
|
|
100
|
+
map["/"] = { mdx: buildLlmsIndex(project) };
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every route path with a raw-Markdown mirror: the manifest routes, plus the
|
|
107
|
+
* homepage when its mirror is the synthesized llms.txt fallback (see
|
|
108
|
+
* `buildRawMarkdown`). This is the route list the negotiation surfaces (dev
|
|
109
|
+
* middleware, Vercel routing config) and the homepage `Link` header build
|
|
110
|
+
* from, so `Accept: text/markdown` on `/` resolves even when the homepage is
|
|
111
|
+
* a landing page.
|
|
112
|
+
*/
|
|
113
|
+
export const markdownRoutePaths = (project: BlumeProject): string[] => {
|
|
114
|
+
const paths = project.manifest.routes.map((route) => route.path);
|
|
115
|
+
return paths.includes("/") ? paths : [...paths, "/"];
|
|
83
116
|
};
|
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/generate.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { glob } from "tinyglobby";
|
|
|
19
19
|
|
|
20
20
|
import { buildAskData } from "../ai/ask-data.ts";
|
|
21
21
|
import { resolveAskBackend } from "../ai/ask.ts";
|
|
22
|
-
import { buildRawMarkdown } from "../ai/markdown.ts";
|
|
22
|
+
import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
|
|
23
23
|
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
24
24
|
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
25
25
|
import { normalizeBasePath } from "../core/base-path.ts";
|
|
@@ -1608,7 +1608,7 @@ export const generateRuntime = async (
|
|
|
1608
1608
|
aliases: resolveTsconfigAliases(context.root),
|
|
1609
1609
|
askPath,
|
|
1610
1610
|
config,
|
|
1611
|
-
contentRoutes: project
|
|
1611
|
+
contentRoutes: markdownRoutePaths(project),
|
|
1612
1612
|
contentWatchesRuntimeDir: contentWatchesRuntimeDir(
|
|
1613
1613
|
hasFilesystemSource,
|
|
1614
1614
|
docsCollection.base,
|
package/src/astro/integration.ts
CHANGED
|
@@ -114,9 +114,13 @@ 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
|
-
* so
|
|
121
|
+
* so user `.astro` pages keep serving HTML — except the homepage, whose
|
|
122
|
+
* variant falls back to the synthesized llms.txt mirror when it's a landing
|
|
123
|
+
* page (see `markdownRoutePaths`). The same
|
|
120
124
|
* middleware also stamps the homepage agent-discovery `Link` header, mirroring
|
|
121
125
|
* what the deployed site sends via `_headers` / the Vercel routing config.
|
|
122
126
|
*/
|
|
@@ -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
|
|
@@ -1181,10 +1228,15 @@ export function getStaticPaths() {
|
|
|
1181
1228
|
export function GET({ props }: { props: { route: string } }) {
|
|
1182
1229
|
const entries = raw as Record<string, { md?: string; mdx?: string }>;
|
|
1183
1230
|
const entry = entries[props.route];
|
|
1184
|
-
|
|
1231
|
+
const body = entry ? ${
|
|
1185
1232
|
kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
|
|
1186
|
-
} : ""
|
|
1187
|
-
|
|
1233
|
+
} : "";
|
|
1234
|
+
return new Response(body, {
|
|
1235
|
+
headers: {
|
|
1236
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
1237
|
+
// ~4 characters per token; keep in sync with markdownTokenCount.
|
|
1238
|
+
"x-markdown-tokens": String(Math.ceil(body.length / 4)),
|
|
1239
|
+
},
|
|
1188
1240
|
});
|
|
1189
1241
|
}
|
|
1190
1242
|
`;
|
|
@@ -1304,11 +1356,12 @@ export const mcpEndpointTemplate = (route: string): string => {
|
|
|
1304
1356
|
return `// Generated by Blume. Do not edit.
|
|
1305
1357
|
import type { APIRoute } from "astro";
|
|
1306
1358
|
import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
|
|
1359
|
+
import type { McpData } from "blume/ai/mcp/data.ts";
|
|
1307
1360
|
import data from "${up}generated/mcp-data.json";
|
|
1308
1361
|
|
|
1309
1362
|
export const prerender = false;
|
|
1310
1363
|
|
|
1311
|
-
const handler = createMcpFetchHandler(data);
|
|
1364
|
+
const handler = createMcpFetchHandler(data as McpData);
|
|
1312
1365
|
|
|
1313
1366
|
export const ALL: APIRoute = ({ request }) => handler(request);
|
|
1314
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`
|