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.
Files changed (110) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli/index.js +3290 -254
  3. package/dist/cli/index.js.map +79 -67
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +16 -2
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/link-headers.ts +4 -3
  24. package/src/ai/llms.ts +4 -2
  25. package/src/ai/markdown.ts +34 -1
  26. package/src/ai/mcp/data.ts +10 -4
  27. package/src/ai/mcp/server.ts +74 -3
  28. package/src/ai/mcp/tools.ts +2 -2
  29. package/src/astro/generate.ts +2 -2
  30. package/src/astro/integration.ts +6 -2
  31. package/src/astro/markdown-negotiation.ts +5 -0
  32. package/src/astro/templates.ts +74 -21
  33. package/src/audit/url.ts +5 -10
  34. package/src/cli/commands/build.ts +145 -34
  35. package/src/cli/commands/translate.ts +300 -0
  36. package/src/cli/index.ts +2 -0
  37. package/src/components/Icon.astro +2 -7
  38. package/src/components/content/Step.astro +3 -8
  39. package/src/components/content/Tab.astro +20 -1
  40. package/src/components/layout/LanguageSwitcher.astro +2 -1
  41. package/src/components/layout/Logo.astro +4 -4
  42. package/src/components/layout/PageActions.astro +12 -7
  43. package/src/components/layout/Search.astro +15 -20
  44. package/src/components/layout/search/orama.ts +3 -1
  45. package/src/core/base-path.ts +9 -0
  46. package/src/core/config-input.ts +84 -6
  47. package/src/core/graph.ts +46 -2
  48. package/src/core/i18n-ui.ts +2 -0
  49. package/src/core/i18n.ts +31 -0
  50. package/src/core/nav-diagnostics.ts +13 -34
  51. package/src/core/project-graph.ts +13 -2
  52. package/src/core/schema.ts +174 -74
  53. package/src/core/sources/normalize.ts +25 -12
  54. package/src/core/sources/types.ts +5 -0
  55. package/src/core/types.ts +4 -3
  56. package/src/core/ui-packs/ar.ts +42 -1
  57. package/src/core/ui-packs/bg.ts +42 -1
  58. package/src/core/ui-packs/bn.ts +42 -1
  59. package/src/core/ui-packs/ca.ts +44 -1
  60. package/src/core/ui-packs/cs.ts +42 -1
  61. package/src/core/ui-packs/da.ts +42 -1
  62. package/src/core/ui-packs/de.ts +42 -1
  63. package/src/core/ui-packs/el.ts +44 -1
  64. package/src/core/ui-packs/es.ts +44 -1
  65. package/src/core/ui-packs/fa.ts +42 -1
  66. package/src/core/ui-packs/fi.ts +42 -1
  67. package/src/core/ui-packs/fr.ts +44 -1
  68. package/src/core/ui-packs/he.ts +42 -1
  69. package/src/core/ui-packs/hi.ts +42 -1
  70. package/src/core/ui-packs/hr.ts +42 -1
  71. package/src/core/ui-packs/hu.ts +42 -1
  72. package/src/core/ui-packs/id.ts +42 -1
  73. package/src/core/ui-packs/it.ts +44 -1
  74. package/src/core/ui-packs/ja.ts +44 -1
  75. package/src/core/ui-packs/ko.ts +44 -1
  76. package/src/core/ui-packs/nl.ts +42 -1
  77. package/src/core/ui-packs/no.ts +42 -1
  78. package/src/core/ui-packs/pl.ts +42 -1
  79. package/src/core/ui-packs/pt-br.ts +44 -1
  80. package/src/core/ui-packs/pt.ts +44 -1
  81. package/src/core/ui-packs/ro.ts +42 -1
  82. package/src/core/ui-packs/ru.ts +42 -1
  83. package/src/core/ui-packs/sk.ts +42 -1
  84. package/src/core/ui-packs/sr.ts +42 -1
  85. package/src/core/ui-packs/sv.ts +42 -1
  86. package/src/core/ui-packs/th.ts +44 -1
  87. package/src/core/ui-packs/tr.ts +42 -1
  88. package/src/core/ui-packs/uk.ts +42 -1
  89. package/src/core/ui-packs/vi.ts +44 -1
  90. package/src/core/ui-packs/zh-tw.ts +44 -1
  91. package/src/core/ui-packs/zh.ts +44 -1
  92. package/src/deploy/adapter-output.ts +44 -5
  93. package/src/deploy/cloudflare-negotiation.ts +527 -0
  94. package/src/deploy/redirects.ts +13 -0
  95. package/src/deploy/vercel-negotiation.ts +30 -13
  96. package/src/eval/agents.ts +1 -1
  97. package/src/search/documents.ts +11 -0
  98. package/src/search/facets.ts +33 -0
  99. package/src/search/orama-index.ts +48 -6
  100. package/src/search/popular-icon.ts +33 -0
  101. package/src/theme/fonts.ts +3 -1
  102. package/src/theme/icon-kind.ts +20 -0
  103. package/src/translate/agents.ts +51 -0
  104. package/src/translate/ledger.ts +148 -0
  105. package/src/translate/meta.ts +149 -0
  106. package/src/translate/prompts.ts +95 -0
  107. package/src/translate/report.ts +360 -0
  108. package/src/translate/run.ts +376 -0
  109. package/src/translate/validate.ts +171 -0
  110. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * `Accept: text/markdown` content negotiation for Cloudflare server builds.
3
+ *
4
+ * Blume prerenders every content page — even under `deployment.output:
5
+ * "server"` — and on Cloudflare the ASSETS binding serves those files before
6
+ * the Worker script runs, so no server-side code (Astro middleware included)
7
+ * ever sees a content-page request. Worse, even a request that does reach the
8
+ * Worker is answered by `@astrojs/cloudflare`'s handler straight from the
9
+ * ASSETS binding, ahead of `app.render` — the only place middleware runs.
10
+ *
11
+ * Negotiation therefore needs two coordinated pieces, both applied to the
12
+ * adapter's emitted deploy bundle after `astro build`:
13
+ *
14
+ * 1. `assets.run_worker_first` in `dist/server/wrangler.json`, scoped to the
15
+ * content routes so the platform routes their requests to the Worker
16
+ * instead of serving the static HTML directly (other assets keep their
17
+ * zero-Worker fast path).
18
+ * 2. A generated entry Worker that fronts the adapter's: when the client
19
+ * prefers `text/markdown` it serves the page's prerendered `.md` mirror
20
+ * from the ASSETS binding, and it delegates everything else to the Astro
21
+ * Worker untouched.
22
+ *
23
+ * Cloudflare does not apply `_headers` to worker-first routes, so the wrapper
24
+ * also re-stamps what the static layer would otherwise add on the routes it
25
+ * takes over: the homepage agent-discovery `Link` header and the Markdown
26
+ * `charset=utf-8` (see `deploy/headers.ts`). The raw `.md`/`.mdx` URLs are
27
+ * exempted from worker-first routing with negative rules, keeping their
28
+ * `_headers` treatment and their zero-Worker serving path.
29
+ *
30
+ * Configured redirects are baked into the wrapper as well, and answered there
31
+ * with their exact configured status. On a server build Blume routes
32
+ * `redirects` through Astro's own config, and `@astrojs/cloudflare` turns
33
+ * those into `_redirects` entries carrying the exact status — but only
34
+ * Cloudflare's static layer reads that file, and a worker-first route never
35
+ * reaches it. Astro's SSR redirect handler would answer instead, and it honors
36
+ * the configured status only when the destination resolves to a discrete
37
+ * route: Blume serves every page from `[...slug]`, so it never does, and
38
+ * `computeRedirectStatus` defaults a GET to **301** — a permanent redirect
39
+ * that browsers cache indefinitely. The wrapper checking its own redirect
40
+ * table before delegating closes that hole at zero rule cost: unlike negative
41
+ * `run_worker_first` exemptions, a baked-in table spends nothing against
42
+ * Wrangler's 100-rule / 100-character limits, cannot collide with
43
+ * user-configured rules (or a bare `true`), and needs no basing gymnastics —
44
+ * its keys are full served URLs, which is exactly what the Worker sees.
45
+ * Redirects outside every worker-first rule still never invoke the Worker;
46
+ * the static layer serves them from `_redirects` as before.
47
+ */
48
+
49
+ import { normalizePath } from "../core/base-path.ts";
50
+
51
+ /** Filename of the generated wrapper Worker, next to the adapter's entry. */
52
+ export const NEGOTIATION_WORKER_FILE = "blume-worker.mjs";
53
+
54
+ /**
55
+ * Wrangler's limits on `assets.run_worker_first`: at most 100 rules of at
56
+ * most 100 characters each. A rule set over either limit fails
57
+ * `wrangler deploy` outright, so the builder falls back to a coarse set.
58
+ */
59
+ const MAX_RULES = 100;
60
+ const MAX_RULE_LENGTH = 100;
61
+
62
+ /**
63
+ * Coarse fallback when the grouped rules would exceed Wrangler's limits:
64
+ * route everything through the Worker except the fingerprinted build assets
65
+ * and the raw AI-ready endpoints, whose `charset=utf-8` comes from `_headers`
66
+ * (not applied on worker-first routes) and whose responses never negotiate.
67
+ */
68
+ const FALLBACK_RULES = ["/*", "!/_astro/*", "!/*.md", "!/*.mdx", "!/*.txt"];
69
+
70
+ /**
71
+ * The deployment base as a rule/URL prefix: trailing slash stripped, empty
72
+ * for a root deploy — the same normalization the dev middleware applies in
73
+ * `astro/markdown-negotiation.ts`.
74
+ */
75
+ const basePrefix = (base?: string): string =>
76
+ base && base !== "/" ? base.replace(/\/$/u, "") : "";
77
+
78
+ const isNegativeRule = (rule: string): boolean => rule.startsWith("!");
79
+
80
+ const ruleBody = (rule: string): string =>
81
+ isNegativeRule(rule) ? rule.slice(1) : rule;
82
+
83
+ /**
84
+ * The `run_worker_first` rules for the given content routes: the routes that
85
+ * must reach the Worker for negotiation, grouped by first path segment so the
86
+ * set stays far under Wrangler's 100-rule cap on real sites. Nested groups
87
+ * get a `/{segment}/*` glob plus negative rules exempting their raw
88
+ * `.md`/`.mdx` mirrors; a bare route gets its exact path in both request
89
+ * spellings (with and without the trailing slash) so no unrelated URL pays
90
+ * the Worker hop. On a subpath deploy the whole base is routed as one group —
91
+ * every route lives under it anyway.
92
+ *
93
+ * Configured redirects need no exemption from these rules: the wrapper Worker
94
+ * answers any it claims from its baked-in redirect table with the configured
95
+ * status (see the module comment).
96
+ */
97
+ export const buildRunWorkerFirstRules = (
98
+ routePaths: readonly string[],
99
+ base?: string
100
+ ): string[] => {
101
+ const prefix = encodeURI(basePrefix(base));
102
+ if (prefix) {
103
+ return [
104
+ prefix,
105
+ `${prefix}/*`,
106
+ `!${prefix}/*.md`,
107
+ `!${prefix}/*.mdx`,
108
+ `!${prefix}/*.txt`,
109
+ `!${prefix}/_astro/*`,
110
+ ];
111
+ }
112
+ const groups = new Map<string, { bare: boolean; nested: boolean }>();
113
+ let home = false;
114
+ for (const route of routePaths) {
115
+ if (route === "/") {
116
+ home = true;
117
+ continue;
118
+ }
119
+ const segments = route.split("/").filter(Boolean);
120
+ const head = segments[0] ?? "";
121
+ const group = groups.get(head) ?? { bare: false, nested: false };
122
+ if (segments.length === 1) {
123
+ group.bare = true;
124
+ } else {
125
+ group.nested = true;
126
+ }
127
+ groups.set(head, group);
128
+ }
129
+ const rules: string[] = home ? ["/"] : [];
130
+ const negatives: string[] = [];
131
+ for (const [head, group] of groups) {
132
+ const segment = `/${encodeURI(head)}`;
133
+ if (group.bare) {
134
+ rules.push(segment);
135
+ }
136
+ if (group.nested) {
137
+ rules.push(`${segment}/*`);
138
+ negatives.push(`!${segment}/*.md`, `!${segment}/*.mdx`);
139
+ } else {
140
+ rules.push(`${segment}/`);
141
+ }
142
+ }
143
+ return [...rules, ...negatives];
144
+ };
145
+
146
+ /**
147
+ * Whether `other` is a same-polarity glob that already covers `rule`.
148
+ * Wrangler's deploy-time validator *rejects* a rule set containing a rule
149
+ * another glob makes redundant, so covered rules must be dropped, not kept.
150
+ */
151
+ const coveredBy = (rule: string, other: string): boolean =>
152
+ other !== rule &&
153
+ isNegativeRule(other) === isNegativeRule(rule) &&
154
+ ruleBody(other).endsWith("*") &&
155
+ ruleBody(rule).startsWith(ruleBody(other).slice(0, -1));
156
+
157
+ /**
158
+ * Merge the generated rules into a user-configured `run_worker_first` (which
159
+ * flows into the adapter's emitted config from the project's own wrangler
160
+ * file). `true` already routes everything through the Worker, so it is kept
161
+ * as-is; an array is unioned with the generated rules and then swept for
162
+ * redundancy, since coverage by a glob is equivalent routing but a hard
163
+ * validation error at deploy time.
164
+ */
165
+ export const mergeRunWorkerFirstRules = (
166
+ existing: unknown,
167
+ added: readonly string[]
168
+ ): string[] | true => {
169
+ if (existing === true) {
170
+ return true;
171
+ }
172
+ const user = Array.isArray(existing)
173
+ ? existing.filter((rule): rule is string => typeof rule === "string")
174
+ : [];
175
+ const merged = [...user, ...added.filter((rule) => !user.includes(rule))];
176
+ return merged.filter(
177
+ (rule) => !merged.some((other) => coveredBy(rule, other))
178
+ );
179
+ };
180
+
181
+ const withinWranglerLimits = (rules: string[] | true): boolean =>
182
+ rules === true ||
183
+ (rules.length <= MAX_RULES &&
184
+ rules.every((rule) => rule.length <= MAX_RULE_LENGTH));
185
+
186
+ /** A configured redirect the wrapper Worker serves itself. */
187
+ export interface WorkerRedirect {
188
+ /**
189
+ * Served path of the redirect, based the way the host platform matches it
190
+ * (see `applyBaseToPlatformRedirects`) — the full URL path the Worker sees.
191
+ */
192
+ from: string;
193
+ /** Configured HTTP status (301, 302, 307, or 308). */
194
+ status: number;
195
+ /** Destination, percent-encoded into the `Location` header. */
196
+ to: string;
197
+ }
198
+
199
+ export interface NegotiationWorkerOptions {
200
+ /** Import specifier of the adapter's built entry, relative to the Worker. */
201
+ mainSpecifier: string;
202
+ /** Content routes with a raw-Markdown mirror (see `markdownRoutePaths`). */
203
+ routePaths: readonly string[];
204
+ /** Name of the assets binding the wrapper serves the `.md` mirrors from. */
205
+ assetsBinding: string;
206
+ /** `deployment.base` for subpath deploys. */
207
+ base?: string;
208
+ /** Homepage agent-discovery `Link` header (see `ai/link-headers.ts`). */
209
+ homeLinkHeader?: string | null;
210
+ /** Estimated token count of the homepage Markdown mirror. */
211
+ homeTokens?: number;
212
+ /** Configured redirects the wrapper answers with their exact status. */
213
+ redirects?: readonly WorkerRedirect[];
214
+ }
215
+
216
+ /**
217
+ * The wrapper Worker module. The negotiation helpers are a JavaScript copy of
218
+ * `astro/markdown-negotiation.ts` — the deploy bundle is uploaded with
219
+ * `no_bundle`, so the module must be self-contained; behavioral parity with
220
+ * the dev middleware is enforced by `test/cloudflare-negotiation.test.ts`.
221
+ */
222
+ export const buildNegotiationWorker = (
223
+ options: NegotiationWorkerOptions
224
+ ): string => {
225
+ const routes = JSON.stringify(options.routePaths);
226
+ const binding = JSON.stringify(options.assetsBinding);
227
+ const prefix = JSON.stringify(encodeURI(basePrefix(options.base)));
228
+ const homeLinkHeader = JSON.stringify(options.homeLinkHeader ?? null);
229
+ const homeTokens = JSON.stringify(
230
+ options.homeTokens === undefined ? null : String(options.homeTokens)
231
+ );
232
+ // Keyed by the normalized served path; the runtime lookup decodes and
233
+ // trims the request path the same way, so both spellings of a URL match.
234
+ // The destination is percent-encoded here because it ships as a `Location`
235
+ // header, which cannot carry non-ASCII.
236
+ const redirects = JSON.stringify(
237
+ Object.fromEntries(
238
+ (options.redirects ?? []).map((redirect) => [
239
+ normalizePath(redirect.from),
240
+ [encodeURI(redirect.to), redirect.status],
241
+ ])
242
+ )
243
+ );
244
+ return `// Generated by Blume. Do not edit; this file is recreated on each build.
245
+ //
246
+ // Request-time \`Accept: text/markdown\` negotiation for a Cloudflare server
247
+ // build. \`assets.run_worker_first\` routes content-page requests here instead
248
+ // of the platform's static layer; a client that prefers Markdown gets the
249
+ // page's prerendered \`.md\` mirror from the assets binding, a configured
250
+ // redirect is answered with its exact configured status, and every other
251
+ // request is delegated to the Astro Worker untouched. \`_headers\` does not
252
+ // apply to worker-first routes, so the homepage Link header and the Markdown
253
+ // charset are re-stamped here.
254
+ import server from ${JSON.stringify(options.mainSpecifier)};
255
+
256
+ const ROUTES = new Set(${routes});
257
+ const BASE_PREFIX = ${prefix};
258
+ const ASSETS_BINDING = ${binding};
259
+ const HOME_LINK_HEADER = ${homeLinkHeader};
260
+ const HOME_TOKENS = ${homeTokens};
261
+ const REDIRECTS = ${redirects};
262
+
263
+ // Configured redirects live in \`_redirects\`, which only the static layer
264
+ // reads — a worker-first route never reaches it. Answering from this baked-in
265
+ // copy keeps the exact configured status; delegating instead would let Astro's
266
+ // SSR handler default a GET to a permanent 301.
267
+ const redirectFor = (pathname) => {
268
+ const trimmed =
269
+ pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
270
+ let path = trimmed;
271
+ try {
272
+ path = decodeURIComponent(trimmed);
273
+ } catch {
274
+ // Keep the raw path; it simply won't match a configured redirect.
275
+ }
276
+ return Object.hasOwn(REDIRECTS, path) ? REDIRECTS[path] : null;
277
+ };
278
+
279
+ const parseAccept = (accept) =>
280
+ accept.split(",").map((part) => {
281
+ const segments = part.trim().split(";");
282
+ const type = (segments[0] ?? "").trim().toLowerCase();
283
+ const qSegment = segments
284
+ .slice(1)
285
+ .map((segment) => segment.trim())
286
+ .find((segment) => segment.startsWith("q="));
287
+ const q = qSegment ? Number(qSegment.slice(2)) : 1;
288
+ return { q: Number.isNaN(q) ? 1 : q, type };
289
+ });
290
+
291
+ const prefersMarkdown = (accept) => {
292
+ if (!accept) {
293
+ return false;
294
+ }
295
+ let markdownQ = -1;
296
+ let htmlQ = 0;
297
+ for (const { q, type } of parseAccept(accept)) {
298
+ if (type === "text/markdown" || type === "text/x-markdown") {
299
+ markdownQ = Math.max(markdownQ, q);
300
+ } else if (type === "text/html") {
301
+ htmlQ = Math.max(htmlQ, q);
302
+ }
303
+ }
304
+ return markdownQ > 0 && markdownQ >= htmlQ;
305
+ };
306
+
307
+ const markdownVariantUrl = (rawUrl) => {
308
+ const queryIndex = rawUrl.indexOf("?");
309
+ const query = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
310
+ const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
311
+ let path = rawPath;
312
+ if (BASE_PREFIX) {
313
+ if (path === BASE_PREFIX || path.startsWith(BASE_PREFIX + "/")) {
314
+ path = path.slice(BASE_PREFIX.length) || "/";
315
+ } else {
316
+ return null;
317
+ }
318
+ }
319
+ const trimmed = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
320
+ let pathname = trimmed;
321
+ try {
322
+ pathname = decodeURIComponent(trimmed);
323
+ } catch {
324
+ // Keep the raw path; it simply won't match a content route.
325
+ }
326
+ if (!ROUTES.has(pathname)) {
327
+ return null;
328
+ }
329
+ const target = pathname === "/" ? "/index" : pathname;
330
+ return BASE_PREFIX + encodeURI(target) + ".md" + query;
331
+ };
332
+
333
+ const isHomePath = (pathname) => {
334
+ let path = pathname;
335
+ if (BASE_PREFIX) {
336
+ if (path !== BASE_PREFIX && !path.startsWith(BASE_PREFIX + "/")) {
337
+ return false;
338
+ }
339
+ path = path.slice(BASE_PREFIX.length);
340
+ }
341
+ return path === "" || path === "/";
342
+ };
343
+
344
+ const withHeaders = (response, apply) => {
345
+ const patched = new Response(response.body, response);
346
+ apply(patched.headers);
347
+ return patched;
348
+ };
349
+
350
+ export default {
351
+ async fetch(request, env, context) {
352
+ const url = new URL(request.url);
353
+ // Before the method guard: the static layer applies \`_redirects\` to every
354
+ // method, so the wrapper does too.
355
+ const redirect = redirectFor(url.pathname);
356
+ if (redirect !== null) {
357
+ return new Response(null, {
358
+ headers: { location: redirect[0] },
359
+ status: redirect[1],
360
+ });
361
+ }
362
+ if (request.method !== "GET" && request.method !== "HEAD") {
363
+ return server.fetch(request, env, context);
364
+ }
365
+ const variant = markdownVariantUrl(url.pathname + url.search);
366
+ const home = isHomePath(url.pathname);
367
+ const assets = env[ASSETS_BINDING];
368
+ if (
369
+ variant !== null &&
370
+ assets !== undefined &&
371
+ prefersMarkdown(request.headers.get("accept"))
372
+ ) {
373
+ const asset = await assets.fetch(
374
+ new Request(new URL(variant, url), request)
375
+ );
376
+ if (asset.ok) {
377
+ return withHeaders(asset, (headers) => {
378
+ headers.set("content-type", "text/markdown; charset=utf-8");
379
+ headers.append("vary", "Accept");
380
+ if (home) {
381
+ if (HOME_LINK_HEADER !== null) {
382
+ headers.set("link", HOME_LINK_HEADER);
383
+ }
384
+ if (HOME_TOKENS !== null) {
385
+ headers.set("x-markdown-tokens", HOME_TOKENS);
386
+ }
387
+ }
388
+ });
389
+ }
390
+ }
391
+ const response = await server.fetch(request, env, context);
392
+ if (variant === null && !(home && HOME_LINK_HEADER !== null)) {
393
+ return response;
394
+ }
395
+ return withHeaders(response, (headers) => {
396
+ if (variant !== null) {
397
+ headers.append("vary", "Accept");
398
+ }
399
+ if (home && HOME_LINK_HEADER !== null && !headers.has("link")) {
400
+ headers.set("link", HOME_LINK_HEADER);
401
+ }
402
+ });
403
+ },
404
+ };
405
+ `;
406
+ };
407
+
408
+ export interface WorkerNegotiation {
409
+ /** Updated `wrangler.json` text (worker-first rules + swapped `main`). */
410
+ wrangler: string;
411
+ /** The wrapper Worker module, to write as {@link NEGOTIATION_WORKER_FILE}. */
412
+ worker: string;
413
+ }
414
+
415
+ export interface WorkerNegotiationOptions extends Omit<
416
+ NegotiationWorkerOptions,
417
+ "assetsBinding" | "mainSpecifier"
418
+ > {
419
+ /**
420
+ * The manifest content routes, based like `routePaths` — the guard that
421
+ * keeps the wrapper's redirect table off real pages. Defaults to
422
+ * `routePaths`, which also carries the synthesized homepage mirror (see
423
+ * `markdownRoutePaths`); passing the manifest routes keeps that synthetic
424
+ * `/` from blocking a configured root redirect.
425
+ */
426
+ contentRoutePaths?: readonly string[];
427
+ }
428
+
429
+ /**
430
+ * Wire the negotiation into the adapter's emitted `dist/server/wrangler.json`:
431
+ * point `main` at the wrapper Worker and scope `assets.run_worker_first` to
432
+ * the content routes (merged with any user-configured rules). Returns the
433
+ * updated config text plus the wrapper module, or `null` when there is
434
+ * nothing to do or nowhere safe to do it: no routes, unparsable config, no
435
+ * usable `main` or assets binding (the wrapper serves the `.md` mirrors from
436
+ * it), an already-swapped `main` (the original entry is unrecoverable), or a
437
+ * rule set that cannot fit Wrangler's limits even after the coarse fallback.
438
+ *
439
+ * The configured redirects are baked into the wrapper, which answers any the
440
+ * worker-first rules claim with the exact configured status (see the module
441
+ * comment) — whichever rules do the claiming: the generated groups, the
442
+ * coarse fallback's `/*`, or the user's own (including a bare `true`). A
443
+ * redirect at a content route's own path is never baked: the page owns it,
444
+ * and answering a redirect there would take a real page off the air. When
445
+ * `null` is returned no rule set is written at all, so every request stays on
446
+ * the static layer and `_redirects` serves the configured statuses as before.
447
+ */
448
+ export const injectWorkerNegotiation = (
449
+ wranglerText: string,
450
+ options: WorkerNegotiationOptions
451
+ ): WorkerNegotiation | null => {
452
+ if (options.routePaths.length === 0) {
453
+ return null;
454
+ }
455
+ let config: Record<string, unknown>;
456
+ try {
457
+ config = JSON.parse(wranglerText);
458
+ } catch {
459
+ return null;
460
+ }
461
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
462
+ return null;
463
+ }
464
+ const { main } = config;
465
+ if (
466
+ typeof main !== "string" ||
467
+ main.length === 0 ||
468
+ main === NEGOTIATION_WORKER_FILE
469
+ ) {
470
+ return null;
471
+ }
472
+ const assets = config.assets as Record<string, unknown> | undefined;
473
+ if (
474
+ assets === null ||
475
+ typeof assets !== "object" ||
476
+ typeof assets.binding !== "string"
477
+ ) {
478
+ return null;
479
+ }
480
+ const {
481
+ contentRoutePaths = options.routePaths,
482
+ redirects = [],
483
+ ...workerOptions
484
+ } = options;
485
+ // The content-route guard compares against redirect `from`s, which carry the
486
+ // full `{deployment.base}{basePath}` stack; the routes carry only
487
+ // `basePath`, so the deployment base is applied here.
488
+ const deployPrefix = basePrefix(options.base);
489
+ const guardRoutes = new Set(
490
+ contentRoutePaths.map((route) =>
491
+ normalizePath(
492
+ deployPrefix && route !== "/"
493
+ ? `${deployPrefix}${route}`
494
+ : deployPrefix || route
495
+ )
496
+ )
497
+ );
498
+ const workerRedirects = redirects.filter(
499
+ (redirect) =>
500
+ redirect.from.startsWith("/") &&
501
+ !guardRoutes.has(normalizePath(redirect.from))
502
+ );
503
+ let rules = mergeRunWorkerFirstRules(
504
+ assets.run_worker_first,
505
+ buildRunWorkerFirstRules(options.routePaths, options.base)
506
+ );
507
+ if (!withinWranglerLimits(rules)) {
508
+ rules = mergeRunWorkerFirstRules(assets.run_worker_first, FALLBACK_RULES);
509
+ }
510
+ if (!withinWranglerLimits(rules)) {
511
+ return null;
512
+ }
513
+ if (rules !== true) {
514
+ assets.run_worker_first = rules;
515
+ }
516
+ config.main = NEGOTIATION_WORKER_FILE;
517
+ const mainSpecifier =
518
+ main.startsWith(".") || main.startsWith("/") ? main : `./${main}`;
519
+ const worker = buildNegotiationWorker({
520
+ ...workerOptions,
521
+ assetsBinding: assets.binding,
522
+ mainSpecifier,
523
+ redirects: workerRedirects,
524
+ });
525
+ // The adapter and Wrangler both write this file unformatted; match them.
526
+ return { worker, wrangler: JSON.stringify(config) };
527
+ };
@@ -68,6 +68,19 @@ export const applyBaseToPlatformRedirects = (
68
68
  : redirects;
69
69
  };
70
70
 
71
+ /**
72
+ * The configured redirects as the host platform matches them, via
73
+ * {@link applyBaseToPlatformRedirects}. The one basing every consumer must
74
+ * share: the emitted redirect files and the Cloudflare worker-first redirect
75
+ * exemptions both compare these paths against real served URLs.
76
+ */
77
+ export const platformRedirects = (config: ResolvedConfig): Redirect[] =>
78
+ applyBaseToPlatformRedirects(
79
+ config.redirects,
80
+ config.basePath,
81
+ config.deployment.base ?? ""
82
+ );
83
+
71
84
  /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
72
85
  export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
73
86
  `${redirects
@@ -85,9 +85,12 @@ const chunkPatterns = (patterns: readonly string[]): string[][] => {
85
85
  export interface NegotiationRoutes {
86
86
  /**
87
87
  * `Vary: Accept` for the plain-HTML side of every negotiated URL, so shared
88
- * caches keep the two variants apart. Spliced *after* `handle: "filesystem"`
89
- * routes there run against filesystem matches (the same slot the Vercel
90
- * adapter uses for its `_astro` cache headers).
88
+ * caches keep the two variants apart. Spliced *before* `handle:
89
+ * "filesystem"` with `continue`: main-phase headers accumulate and ride on
90
+ * whatever ultimately serves the request. Routes placed after the filesystem
91
+ * marker are the miss phase — they run only when no static file matches, and
92
+ * every Blume content page is a prerendered static file, so a header route
93
+ * there never fires.
91
94
  */
92
95
  headerRoutes: VercelRoute[];
93
96
  /**
@@ -102,10 +105,15 @@ export interface NegotiationRoutes {
102
105
  * Build the routes for the given content-route paths (the routes that have a
103
106
  * raw-Markdown mirror, straight from the manifest). Paths are matched with an
104
107
  * optional trailing slash and rewritten `/{route}` → `/{route}.md`; the home
105
- * page's mirror lives at `/index.md`.
108
+ * page's mirror lives at `/index.md`. When `homeTokens` is given, the home
109
+ * rewrite also stamps `x-markdown-tokens` — the estimated token count of the
110
+ * homepage mirror (Cloudflare's Markdown for Agents convention). Only the home
111
+ * route can carry it: the other rewrites are chunked alternations spanning
112
+ * many pages, and a count is per-page.
106
113
  */
107
114
  export const buildNegotiationRoutes = (
108
- routePaths: readonly string[]
115
+ routePaths: readonly string[],
116
+ homeTokens?: number
109
117
  ): NegotiationRoutes => {
110
118
  const home = routePaths.includes("/");
111
119
  const rest = routePaths
@@ -118,7 +126,10 @@ export const buildNegotiationRoutes = (
118
126
  {
119
127
  dest: "/index.md",
120
128
  has: ACCEPT_MARKDOWN_CONDITION,
121
- headers: VARY_ACCEPT,
129
+ headers:
130
+ homeTokens === undefined
131
+ ? VARY_ACCEPT
132
+ : { ...VARY_ACCEPT, "x-markdown-tokens": String(homeTokens) },
122
133
  src: "^/$",
123
134
  },
124
135
  ]
@@ -170,9 +181,9 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
170
181
  /**
171
182
  * Splice the negotiation routes into a Build Output `config.json`, plus — when
172
183
  * given — a homepage `Link` header route for agent discovery (see
173
- * `ai/link-headers.ts`), applied the same way the `Vary` routes are: after
174
- * `handle: "filesystem"` with `continue`, so the header rides on the
175
- * prerendered homepage response. `contentTypeOverrides` maps static-dir
184
+ * `ai/link-headers.ts`), applied the same way the `Vary` routes are: in the
185
+ * main phase before `handle: "filesystem"` with `continue`, so the header
186
+ * rides on the prerendered homepage response. `contentTypeOverrides` maps static-dir
176
187
  * relative paths to media types via the Build Output `overrides` field — the
177
188
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
178
189
  * signature directory). Returns the updated JSON text (tab-indented, like the
@@ -184,7 +195,8 @@ export const injectNegotiationRoutes = (
184
195
  configText: string,
185
196
  routePaths: readonly string[],
186
197
  homeLinkHeader?: string | null,
187
- contentTypeOverrides?: Record<string, string>
198
+ contentTypeOverrides?: Record<string, string>,
199
+ homeTokens?: number
188
200
  ): string | null => {
189
201
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
190
202
  if (
@@ -218,7 +230,10 @@ export const injectNegotiationRoutes = (
218
230
  if (filesystemIndex === -1) {
219
231
  return null;
220
232
  }
221
- const { headerRoutes, rewriteRoutes } = buildNegotiationRoutes(routePaths);
233
+ const { headerRoutes, rewriteRoutes } = buildNegotiationRoutes(
234
+ routePaths,
235
+ homeTokens
236
+ );
222
237
  if (homeLinkHeader) {
223
238
  headerRoutes.push({
224
239
  continue: true,
@@ -226,8 +241,10 @@ export const injectNegotiationRoutes = (
226
241
  src: HOME_SRC,
227
242
  });
228
243
  }
229
- routes.splice(filesystemIndex + 1, 0, ...headerRoutes);
230
- routes.splice(filesystemIndex, 0, ...rewriteRoutes);
244
+ // Headers first: `continue` routes accumulate, so a request the rewrite
245
+ // route then terminates (Markdown negotiation on the homepage) still carries
246
+ // the Link header.
247
+ routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
231
248
  config.routes = routes;
232
249
  return `${JSON.stringify(config, null, "\t")}\n`;
233
250
  };
@@ -25,7 +25,7 @@ const MCP_SERVER_NAME = "docs";
25
25
  * sandbox: the reader must not read the repo, run commands, or search the
26
26
  * web — it sees the documentation the way a stranger does, through MCP.
27
27
  */
28
- const DISALLOWED_TOOLS = [
28
+ export const DISALLOWED_TOOLS = [
29
29
  "Bash",
30
30
  "Read",
31
31
  "Glob",
@@ -5,6 +5,7 @@ import { contentIndexable } from "../core/manifest.ts";
5
5
  import type { BlumeProject } from "../core/project-graph.ts";
6
6
  import { readEntryText } from "../core/sources/read.ts";
7
7
  import type { NavNode } from "../core/types.ts";
8
+ import { pageFacets } from "./facets.ts";
8
9
 
9
10
  /** A document indexed by the client-side search providers (Orama, FlexSearch). */
10
11
  export interface SearchDocument {
@@ -18,8 +19,15 @@ export interface SearchDocument {
18
19
  section: string;
19
20
  /** Locale code, so the dialog can filter results to the active language. */
20
21
  locale: string;
22
+ /** Resolved page `type` (`doc`, `blog`, a custom `rfc`…), for type filters. */
23
+ contentType: string;
21
24
  /** Frontmatter `search.tags`, surfaced for hosted-provider faceting. */
22
25
  tags?: string[];
26
+ /**
27
+ * Declared facet values (`content.types.<type>.facets`), key → value.
28
+ * Filterable through the MCP tools' `filters` input.
29
+ */
30
+ facets?: Record<string, string>;
23
31
  }
24
32
 
25
33
  /**
@@ -193,10 +201,13 @@ export const buildSearchDocuments = async (
193
201
  options?.content === "markdown" ? visible.trim() : toPlainText(visible);
194
202
  const tags = page?.meta?.search?.tags;
195
203
  const crumb = crumbs.get(route.path);
204
+ const facets = page ? pageFacets(page, project.config) : undefined;
196
205
  return {
197
206
  breadcrumb: crumb?.breadcrumb ?? [],
198
207
  content: body,
208
+ contentType: route.contentType,
199
209
  description: page?.description ?? "",
210
+ ...(facets ? { facets } : {}),
200
211
  locale: route.locale,
201
212
  route: route.path,
202
213
  section: crumb?.section || "Docs",