blume 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/cli/index.js +3221 -201
  3. package/dist/cli/index.js.map +73 -61
  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 +15 -1
  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/mcp/data.ts +10 -4
  24. package/src/ai/mcp/server.ts +74 -3
  25. package/src/ai/mcp/tools.ts +2 -2
  26. package/src/astro/integration.ts +3 -1
  27. package/src/astro/markdown-negotiation.ts +5 -0
  28. package/src/astro/templates.ts +66 -18
  29. package/src/audit/url.ts +5 -10
  30. package/src/cli/commands/build.ts +129 -24
  31. package/src/cli/commands/translate.ts +300 -0
  32. package/src/cli/index.ts +2 -0
  33. package/src/components/Icon.astro +2 -7
  34. package/src/components/content/Step.astro +3 -8
  35. package/src/components/content/Tab.astro +20 -1
  36. package/src/components/layout/LanguageSwitcher.astro +2 -1
  37. package/src/components/layout/Logo.astro +4 -4
  38. package/src/components/layout/PageActions.astro +12 -7
  39. package/src/components/layout/Search.astro +15 -20
  40. package/src/components/layout/search/orama.ts +3 -1
  41. package/src/core/base-path.ts +9 -0
  42. package/src/core/config-input.ts +84 -6
  43. package/src/core/graph.ts +46 -2
  44. package/src/core/i18n-ui.ts +2 -0
  45. package/src/core/i18n.ts +31 -0
  46. package/src/core/nav-diagnostics.ts +13 -34
  47. package/src/core/project-graph.ts +13 -2
  48. package/src/core/schema.ts +174 -74
  49. package/src/core/sources/normalize.ts +25 -12
  50. package/src/core/sources/types.ts +5 -0
  51. package/src/core/types.ts +4 -3
  52. package/src/core/ui-packs/ar.ts +42 -1
  53. package/src/core/ui-packs/bg.ts +42 -1
  54. package/src/core/ui-packs/bn.ts +42 -1
  55. package/src/core/ui-packs/ca.ts +44 -1
  56. package/src/core/ui-packs/cs.ts +42 -1
  57. package/src/core/ui-packs/da.ts +42 -1
  58. package/src/core/ui-packs/de.ts +42 -1
  59. package/src/core/ui-packs/el.ts +44 -1
  60. package/src/core/ui-packs/es.ts +44 -1
  61. package/src/core/ui-packs/fa.ts +42 -1
  62. package/src/core/ui-packs/fi.ts +42 -1
  63. package/src/core/ui-packs/fr.ts +44 -1
  64. package/src/core/ui-packs/he.ts +42 -1
  65. package/src/core/ui-packs/hi.ts +42 -1
  66. package/src/core/ui-packs/hr.ts +42 -1
  67. package/src/core/ui-packs/hu.ts +42 -1
  68. package/src/core/ui-packs/id.ts +42 -1
  69. package/src/core/ui-packs/it.ts +44 -1
  70. package/src/core/ui-packs/ja.ts +44 -1
  71. package/src/core/ui-packs/ko.ts +44 -1
  72. package/src/core/ui-packs/nl.ts +42 -1
  73. package/src/core/ui-packs/no.ts +42 -1
  74. package/src/core/ui-packs/pl.ts +42 -1
  75. package/src/core/ui-packs/pt-br.ts +44 -1
  76. package/src/core/ui-packs/pt.ts +44 -1
  77. package/src/core/ui-packs/ro.ts +42 -1
  78. package/src/core/ui-packs/ru.ts +42 -1
  79. package/src/core/ui-packs/sk.ts +42 -1
  80. package/src/core/ui-packs/sr.ts +42 -1
  81. package/src/core/ui-packs/sv.ts +42 -1
  82. package/src/core/ui-packs/th.ts +44 -1
  83. package/src/core/ui-packs/tr.ts +42 -1
  84. package/src/core/ui-packs/uk.ts +42 -1
  85. package/src/core/ui-packs/vi.ts +44 -1
  86. package/src/core/ui-packs/zh-tw.ts +44 -1
  87. package/src/core/ui-packs/zh.ts +44 -1
  88. package/src/deploy/adapter-output.ts +44 -5
  89. package/src/deploy/cloudflare-negotiation.ts +527 -0
  90. package/src/deploy/redirects.ts +13 -0
  91. package/src/eval/agents.ts +1 -1
  92. package/src/search/documents.ts +11 -0
  93. package/src/search/facets.ts +33 -0
  94. package/src/search/orama-index.ts +48 -6
  95. package/src/search/popular-icon.ts +33 -0
  96. package/src/theme/icon-kind.ts +20 -0
  97. package/src/translate/agents.ts +51 -0
  98. package/src/translate/ledger.ts +148 -0
  99. package/src/translate/meta.ts +149 -0
  100. package/src/translate/prompts.ts +95 -0
  101. package/src/translate/report.ts +360 -0
  102. package/src/translate/run.ts +376 -0
  103. package/src/translate/validate.ts +171 -0
  104. package/src/translate/work-list.ts +0 -0
@@ -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 descriptionById = new Map(
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: descriptionById.get(route.id),
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
  })),
@@ -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: { properties: {}, type: "object" },
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
- data.routes.map((route) => ({
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,
@@ -19,7 +19,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
19
19
  {
20
20
  annotations: READ_ONLY,
21
21
  description:
22
- "Full-text search across the documentation. Returns matching pages with their title, route, and a short excerpt. Use this first to discover relevant pages, then `get_page` to read one in full.",
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 content type. Useful for enumerating the docs or finding a page when search is too narrow.",
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
  },
@@ -114,7 +114,9 @@ const isHomeUrl = (rawUrl: string | undefined, base?: string): boolean => {
114
114
  * production the content pages are prerendered and served from the platform's
115
115
  * static layer, which this middleware never fronts. Vercel server builds get
116
116
  * the same negotiation from routing rules spliced into the Build Output config
117
- * (see `deploy/vercel-negotiation.ts`); every other build exposes the same
117
+ * (see `deploy/vercel-negotiation.ts`), Cloudflare server builds from a
118
+ * wrapper Worker routed to by `assets.run_worker_first` (see
119
+ * `deploy/cloudflare-negotiation.ts`); every other build exposes the same
118
120
  * content at the `.md` URL. Only routes with a Markdown variant are rewritten,
119
121
  * so user `.astro` pages keep serving HTML — except the homepage, whose
120
122
  * variant falls back to the synthesized llms.txt mirror when it's a landing
@@ -3,6 +3,11 @@
3
3
  * endpoints already serve a page's source verbatim; these helpers let the dev
4
4
  * server honor `Accept: text/markdown` by transparently rewriting a page
5
5
  * request to its `.md` variant.
6
+ *
7
+ * The Cloudflare negotiation Worker embeds a plain-JavaScript copy of these
8
+ * helpers (`deploy/cloudflare-negotiation.ts` — its deploy bundle is uploaded
9
+ * unbundled, so it cannot import this module); when editing here, mirror the
10
+ * change there. Parity is enforced by `test/cloudflare-negotiation.test.ts`.
6
11
  */
7
12
 
8
13
  interface AcceptEntry {
@@ -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
- // The lazy client-side imports both land on CJS/UMD files: mermaid (for
646
- // diagrams) statically imports dayjs as CJS (\`dayjs/dayjs.min.js\`), and
647
- // epub-gen-memory's browser bundle is a browserified UMD. In dev, an
648
- // un-pre-bundled dependency is served as raw ESM, where such a file
649
- // exposes no \`default\` export mermaid throws on load and diagrams
650
- // render blank, and the EPUB export throws \`epub is not a function\`
651
- // (the UMD finds no \`exports\`/\`define\` and strands its callable on
652
- // \`window.epubGen\` instead). Forcing them through the dep optimizer
653
- // restores the CJS interop. In a standalone install these dynamic imports
654
- // live inside \`node_modules/blume\`, which Vite's optimizer scan doesn't
655
- // crawl, so neither is discovered on its own — hence the explicit
656
- // includes. They resolve through the \`blume\` package (they aren't direct
657
- // deps of the generated project), so the nested \`blume > x\` form is
658
- // required, and epub-gen-memory must name the \`/bundle\` subpath that is
659
- // actually imported: optimizing the package root leaves that entry out.
660
- // Production (Rollup) already handles the interop, so this only affects dev.
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
- include: ["blume > mermaid", "blume > epub-gen-memory/bundle"],
708
+ entries: ${JSON.stringify(optimizeDepsEntries)},
709
+ include: ${JSON.stringify(optimizeDepsInclude)},
663
710
  },
664
711
  // Blume's render-time deps are forced external on both build environments so
665
712
  // native bindings resolve at runtime and isolated linkers don't bundle
@@ -1309,11 +1356,12 @@ export const mcpEndpointTemplate = (route: string): string => {
1309
1356
  return `// Generated by Blume. Do not edit.
1310
1357
  import type { APIRoute } from "astro";
1311
1358
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1359
+ import type { McpData } from "blume/ai/mcp/data.ts";
1312
1360
  import data from "${up}generated/mcp-data.json";
1313
1361
 
1314
1362
  export const prerender = false;
1315
1363
 
1316
- const handler = createMcpFetchHandler(data);
1364
+ const handler = createMcpFetchHandler(data as McpData);
1317
1365
 
1318
1366
  export const ALL: APIRoute = ({ request }) => handler(request);
1319
1367
  `;
package/src/audit/url.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { stripBasePath } from "../core/base-path.ts";
1
+ import { normalizePath, stripBasePath } from "../core/base-path.ts";
2
+
3
+ // Re-exported from its home next to the other path helpers; the audit checks
4
+ // (and their tests) import it from here.
5
+ export { normalizePath } from "../core/base-path.ts";
2
6
 
3
7
  /** What an `href` in built HTML turned out to point at. */
4
8
  export type ResolvedHref =
@@ -13,15 +17,6 @@ export type ResolvedHref =
13
17
 
14
18
  const NON_HTTP_SCHEME = /^(?!https?:)[a-z][a-z0-9+.-]*:/iu;
15
19
 
16
- /**
17
- * Normalize a site path for comparison: drop the trailing slash (Astro serves
18
- * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
19
- */
20
- export const normalizePath = (path: string): string => {
21
- const trimmed = path.replace(/\/+$/u, "");
22
- return trimmed === "" ? "/" : trimmed;
23
- };
24
-
25
20
  /**
26
21
  * Percent-decode a pathname for comparison against the built file tree. Page
27
22
  * URLs and file-index keys come from raw on-disk names, while `URL#pathname`
@@ -38,14 +38,20 @@ import type { ProjectContext } from "../../core/types.ts";
38
38
  import {
39
39
  ADAPTER_IGNORE_DIRS,
40
40
  deployStaticDir,
41
+ readsHeaderFiles,
42
+ servesClientSubdir,
41
43
  surfaceAdapterOutput,
42
44
  } from "../../deploy/adapter-output.ts";
45
+ import {
46
+ injectWorkerNegotiation,
47
+ NEGOTIATION_WORKER_FILE,
48
+ } from "../../deploy/cloudflare-negotiation.ts";
43
49
  import { buildNetlifyHeaders } from "../../deploy/headers.ts";
44
50
  import {
45
- applyBaseToPlatformRedirects,
46
51
  buildNetlifyRedirects,
47
52
  buildRedirectManifest,
48
53
  buildVercelConfig,
54
+ platformRedirects,
49
55
  } from "../../deploy/redirects.ts";
50
56
  import { buildRobots } from "../../deploy/robots.ts";
51
57
  import { buildSitemap } from "../../deploy/sitemap.ts";
@@ -99,11 +105,7 @@ const emitRedirectFiles = async (
99
105
  config: ResolvedConfig,
100
106
  distDir: string
101
107
  ): Promise<void> => {
102
- const redirects = applyBaseToPlatformRedirects(
103
- config.redirects,
104
- config.basePath,
105
- config.deployment.base ?? ""
106
- );
108
+ const redirects = platformRedirects(config);
107
109
  if (redirects.length === 0 || config.deployment.output !== "static") {
108
110
  return;
109
111
  }
@@ -127,33 +129,61 @@ const emitRedirectFiles = async (
127
129
  };
128
130
 
129
131
  /**
130
- * Emit a `_headers` file for a static build so Netlify / Cloudflare static
131
- * hosts serve the raw AI-ready endpoints (`*.md`, `*.mdx`, `*.txt`) with an
132
- * explicit `charset=utf-8`. Without it those hosts send `text/markdown` /
133
- * `text/plain` with no charset and browsers fall back to Windows-1252, garbling
134
- * any non-ASCII docs (#82). The same file also stamps the homepage
135
- * agent-discovery `Link` header (RFC 8288, see `ai/link-headers.ts`). A
136
- * `_headers` shipped in `public/` (copied into dist by Astro before this runs)
137
- * wins, exactly like `_redirects`. Server adapters set the Content-Type on the
138
- * Response directly, so this is static-only.
132
+ * Emit a `_headers` file so Netlify / Cloudflare serve the raw AI-ready
133
+ * endpoints (`*.md`, `*.mdx`, `*.txt`) with an explicit `charset=utf-8`. Without
134
+ * it those hosts send `text/markdown` / `text/plain` with no charset and
135
+ * browsers fall back to Windows-1252, garbling any non-ASCII docs (#82).
136
+ *
137
+ * The same file carries the rest of the agent-discovery surface that only a
138
+ * response header can express: the homepage `Link` header (RFC 8288, see
139
+ * `ai/link-headers.ts`), and the registered media types for the extensionless
140
+ * well-known files `application/linkset+json` for the API catalog, the
141
+ * signatures directory, and the Agent Skills archives. A static host serves
142
+ * those as `octet-stream` or nothing at all without a rule.
143
+ *
144
+ * A `_headers` shipped in `public/` wins, exactly like `_redirects` — the opt-out
145
+ * is checked at its source rather than in `dist`, because on Cloudflare the file
146
+ * in `dist` is not necessarily the user's: `@astrojs/cloudflare` writes its own
147
+ * `_headers` (an immutable `Cache-Control` rule for `/_astro/*`) during the
148
+ * build, before this runs. Testing `dist` therefore read an adapter-generated
149
+ * file as a user opt-out and skipped silently. When both exist, the adapter's
150
+ * rules are preserved and ours are appended.
151
+ *
152
+ * Gated on {@link readsHeaderFiles}, not on `output === "static"`. A **Cloudflare
153
+ * server** build serves `dist/client` through the Worker's ASSETS binding, and
154
+ * Workers static assets honor `_headers` from that directory — so the file
155
+ * applies there too, and skipping it left every Cloudflare server build with no
156
+ * `Link` header and no media type on its own discovery files. The charset half
157
+ * of this file *is* redundant on a server build, because the runtime endpoint
158
+ * sets Content-Type on the Response itself; the `Link` and well-known halves are
159
+ * not, and one conclusion about the first was applied to all three.
160
+ *
161
+ * Exported for the test suite, which exercises it in a subprocess like the
162
+ * other command helpers.
139
163
  */
140
- const emitHeaderFiles = async (
164
+ export const emitHeaderFiles = async (
141
165
  project: BlumeProject,
142
166
  distDir: string
143
167
  ): Promise<void> => {
144
168
  const { config } = project;
145
169
  if (
146
- config.deployment.output !== "static" ||
147
- existsSync(join(distDir, "_headers"))
170
+ !readsHeaderFiles(config.deployment) ||
171
+ existsSync(join(project.context.root, "public", "_headers"))
148
172
  ) {
149
173
  return;
150
174
  }
175
+ const ours = buildNetlifyHeaders(
176
+ config,
177
+ buildHomeLinkHeader(config, markdownRoutePaths(project))
178
+ );
179
+ // An adapter may have written its own rules here already (Cloudflare adds an
180
+ // immutable Cache-Control for /_astro/*). Keep them and append ours: both
181
+ // sets are wanted, and `_headers` has no merge semantics beyond order.
182
+ const target = join(distDir, "_headers");
183
+ const existing = existsSync(target) ? await readFile(target, "utf-8") : "";
151
184
  await writeFile(
152
- join(distDir, "_headers"),
153
- buildNetlifyHeaders(
154
- config,
155
- buildHomeLinkHeader(config, markdownRoutePaths(project))
156
- ),
185
+ target,
186
+ existing ? `${existing.trimEnd()}\n${ours}` : ours,
157
187
  "utf-8"
158
188
  );
159
189
  logger.success(
@@ -297,6 +327,72 @@ const emitVercelNegotiation = async (
297
327
  );
298
328
  };
299
329
 
330
+ const warnCloudflareNegotiationSkipped = (): void =>
331
+ logger.warn(
332
+ "Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json — raw Markdown stays available at the .md URLs."
333
+ );
334
+
335
+ /**
336
+ * Wire `Accept: text/markdown` negotiation into a Cloudflare server build. The
337
+ * ASSETS binding serves the prerendered content pages before the Worker runs —
338
+ * and even a request that reaches the Worker is answered by the adapter's
339
+ * handler from that binding, ahead of the only place middleware runs — so the
340
+ * negotiation lives in a generated wrapper Worker, routed to by
341
+ * `assets.run_worker_first` (see `deploy/cloudflare-negotiation.ts`). Both
342
+ * pieces are spliced into the adapter's emitted `dist/server` bundle.
343
+ */
344
+ const emitCloudflareNegotiation = async (
345
+ project: BlumeProject,
346
+ routePaths: string[]
347
+ ): Promise<void> => {
348
+ const { config, context } = project;
349
+ const serverDir = join(
350
+ context.distDir ?? join(context.root, "dist"),
351
+ "server"
352
+ );
353
+ const wranglerPath = join(serverDir, "wrangler.json");
354
+ if (!existsSync(wranglerPath)) {
355
+ warnCloudflareNegotiationSkipped();
356
+ return;
357
+ }
358
+ // The homepage mirror is served from the static layer, so its
359
+ // `x-markdown-tokens` estimate rides the wrapper Worker, mirroring the
360
+ // Vercel routing config.
361
+ const rawMarkdown = await buildRawMarkdown(project);
362
+ const home = rawMarkdown["/"];
363
+ const injected = injectWorkerNegotiation(
364
+ await readFile(wranglerPath, "utf-8"),
365
+ {
366
+ base: config.deployment.base,
367
+ // The manifest routes guard the wrapper's redirect table; `routePaths`
368
+ // also carries the synthesized homepage mirror, which must not block a
369
+ // configured root redirect.
370
+ contentRoutePaths: project.manifest.routes.map((route) => route.path),
371
+ homeLinkHeader: buildHomeLinkHeader(config, routePaths),
372
+ homeTokens: home ? markdownTokenCount(agentMarkdown(home)) : undefined,
373
+ // The wrapper Worker matches full served URLs, so the redirects are
374
+ // based the same way the platform files are — it answers any the
375
+ // worker-first rules claim, where `_redirects` is never consulted and
376
+ // Astro would default their status.
377
+ redirects: platformRedirects(config),
378
+ routePaths,
379
+ }
380
+ );
381
+ if (injected === null) {
382
+ warnCloudflareNegotiationSkipped();
383
+ return;
384
+ }
385
+ await writeFile(
386
+ join(serverDir, NEGOTIATION_WORKER_FILE),
387
+ injected.worker,
388
+ "utf-8"
389
+ );
390
+ await writeFile(wranglerPath, injected.wrangler, "utf-8");
391
+ logger.success(
392
+ "Wired Accept: text/markdown negotiation into the Cloudflare Worker"
393
+ );
394
+ };
395
+
300
396
  const formatBytes = (bytes: number): string => {
301
397
  if (bytes < 1024) {
302
398
  return `${bytes} B`;
@@ -435,6 +531,8 @@ export const isolatedOutputDir = (
435
531
  * bundle to the project root — a Vercel server build's static output stays at
436
532
  * `<runtime>/.vercel/output/static`, where `deployStaticDir` would instead
437
533
  * point at the project-root copy (a previous real build's assets, or nothing).
534
+ * Node and Cloudflare server builds serve one level down, at `client/` — see
535
+ * {@link servesClientSubdir}.
438
536
  */
439
537
  export const isolatedStaticDir = (
440
538
  config: ResolvedConfig,
@@ -445,7 +543,7 @@ export const isolatedStaticDir = (
445
543
  if (output === "server" && adapter === "vercel") {
446
544
  return join(outputDir, "static");
447
545
  }
448
- if (output === "server" && adapter === "node") {
546
+ if (servesClientSubdir(config.deployment)) {
449
547
  return join(outputDir, "client");
450
548
  }
451
549
  return outputDir;
@@ -722,6 +820,13 @@ export const buildCommand = defineCommand({
722
820
  await emitVercelNegotiation(project, markdownRoutePaths(project), root);
723
821
  }
724
822
 
823
+ if (
824
+ project.config.deployment.output === "server" &&
825
+ adapter === "cloudflare"
826
+ ) {
827
+ await emitCloudflareNegotiation(project, markdownRoutePaths(project));
828
+ }
829
+
725
830
  await publishBuildArtifacts(
726
831
  project,
727
832
  deployStaticDir(project.config, project.context),