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
|
@@ -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
|
+
};
|
package/src/deploy/redirects.ts
CHANGED
|
@@ -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 *
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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:
|
|
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:
|
|
174
|
-
* `handle: "filesystem"` with `continue`, so the header
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
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
|
};
|
package/src/eval/agents.ts
CHANGED
|
@@ -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",
|
package/src/search/documents.ts
CHANGED
|
@@ -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",
|