blume 1.3.1 → 1.4.1

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 (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  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 +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. package/src/deploy/xml.ts +0 -8
@@ -1,5 +1,6 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
 
3
+ import { XMLParser } from "fast-xml-parser";
3
4
  import { join, relative } from "pathe";
4
5
  import { glob } from "tinyglobby";
5
6
 
@@ -74,27 +75,25 @@ const routeIndex = (
74
75
  return index;
75
76
  };
76
77
 
77
- const SITEMAP_URL = /<url>(?<block>[\s\S]*?)<\/url>/gu;
78
- const SITEMAP_LOC = /<loc>(?<loc>[\s\S]*?)<\/loc>/gu;
79
- const SITEMAP_LASTMOD = /<lastmod>(?<date>[\s\S]*?)<\/lastmod>/u;
80
- const XML_ENTITIES: Record<string, string> = {
81
- "&amp;": "&",
82
- "&apos;": "'",
83
- "&gt;": ">",
84
- "&lt;": "<",
85
- "&quot;": '"',
86
- };
87
-
88
- const unescapeXml = (value: string): string =>
89
- value.replaceAll(
90
- /&(?:amp|apos|gt|lt|quot);/gu,
91
- (entity) => XML_ENTITIES[entity] ?? entity
92
- );
78
+ /**
79
+ * Sitemaps arrive from arbitrary generators (the audit also fetches remote
80
+ * ones), so parsing is fast-xml-parser's job: CDATA sections, numeric
81
+ * entities, and namespace-prefixed elements are all legal there and all
82
+ * invisible to a regex scan. Values stay strings (`parseTagValue: false`) so
83
+ * a numeric-looking `<lastmod>` isn't coerced.
84
+ */
85
+ const sitemapParser = new XMLParser({
86
+ // htmlEntities adds numeric character references (&#38;) on top of the
87
+ // default XML five; a sitemap loc legitimately carries either form.
88
+ htmlEntities: true,
89
+ ignoreAttributes: true,
90
+ parseTagValue: false,
91
+ removeNSPrefix: true,
92
+ });
93
93
 
94
94
  /**
95
- * Parse `sitemap.xml`. Deliberately shallow: we only need the `<loc>` list and
96
- * whether the document is a well-formed urlset, and pulling in an XML parser to
97
- * learn that would be a dependency for one regex.
95
+ * Parse `sitemap.xml`. Deliberately shallow: the checks only need the `<loc>`
96
+ * list, each loc's `<lastmod>`, and whether the document is a urlset at all.
98
97
  */
99
98
  export const parseSitemap = (
100
99
  file: string,
@@ -102,31 +101,34 @@ export const parseSitemap = (
102
101
  bytes: number
103
102
  ): SitemapDoc => {
104
103
  const doc: SitemapDoc = { bytes, file, lastmod: new Map(), urls: [] };
105
- if (!xml.includes("<urlset")) {
106
- doc.error = xml.includes("<sitemapindex")
104
+ let parsed: Record<string, unknown>;
105
+ try {
106
+ parsed = sitemapParser.parse(xml) as Record<string, unknown>;
107
+ } catch {
108
+ doc.error = "no <urlset> element";
109
+ return doc;
110
+ }
111
+ if (!Object.hasOwn(parsed, "urlset")) {
112
+ doc.error = Object.hasOwn(parsed, "sitemapindex")
107
113
  ? "sitemap is an index, not a urlset"
108
114
  : "no <urlset> element";
109
115
  return doc;
110
116
  }
111
- for (const match of xml.matchAll(SITEMAP_LOC)) {
112
- const loc = unescapeXml((match.groups?.loc ?? "").trim());
113
- if (loc) {
114
- doc.urls.push(loc);
117
+ const urlset = parsed.urlset as { url?: unknown } | string | null;
118
+ const entries =
119
+ typeof urlset === "object" && urlset !== null ? [urlset.url].flat() : [];
120
+ for (const entry of entries) {
121
+ if (typeof entry !== "object" || entry === null) {
122
+ continue;
115
123
  }
116
- }
117
- // `<lastmod>` is scoped per `<url>` block so it stays attached to its `<loc>`
118
- // — the flat loc scan above deliberately isn't, so a sitemap with stray text
119
- // between blocks still yields its URL list.
120
- for (const match of xml.matchAll(SITEMAP_URL)) {
121
- const block = match.groups?.block ?? "";
122
- const loc = unescapeXml(
123
- (
124
- new RegExp(SITEMAP_LOC.source, "u").exec(block)?.groups?.loc ?? ""
125
- ).trim()
126
- );
127
- const lastmod = SITEMAP_LASTMOD.exec(block)?.groups?.date?.trim();
128
- if (loc && lastmod) {
129
- doc.lastmod?.set(loc, lastmod);
124
+ const { loc, lastmod } = entry as { loc?: unknown; lastmod?: unknown };
125
+ const locText = typeof loc === "string" ? loc.trim() : "";
126
+ if (!locText) {
127
+ continue;
128
+ }
129
+ doc.urls.push(locText);
130
+ if (typeof lastmod === "string" && lastmod.trim() !== "") {
131
+ doc.lastmod?.set(locText, lastmod.trim());
130
132
  }
131
133
  }
132
134
  return doc;
@@ -153,12 +155,14 @@ export const parseLlms = (file: string, text: string): LlmsDoc => {
153
155
 
154
156
  const ROBOTS_DIRECTIVE = /^(?<field>[a-z-]+)\s*:\s*(?<value>.*)$/iu;
155
157
 
156
- /** Parse `robots.txt` into the directives the audit cares about. */
158
+ /**
159
+ * Parse `robots.txt` into the pieces the audit cares about. Sitemap
160
+ * declarations and a not-a-directive lint come from a line scan; rule
161
+ * *matching* is robots-parser's job at check time (see `checks/robots.ts`),
162
+ * so the raw text rides along instead of a pre-extracted rule list.
163
+ */
157
164
  export const parseRobots = (file: string, text: string): RobotsDoc => {
158
- const doc: RobotsDoc = { disallow: [], file, invalid: [], sitemaps: [] };
159
- // Only `User-agent: *` rules bind the crawlers we're auditing for; a block
160
- // scoped to some other agent isn't a finding about our indexable pages.
161
- let appliesToAll = false;
165
+ const doc: RobotsDoc = { file, invalid: [], raw: text, sitemaps: [] };
162
166
  for (const [index, raw] of text.split(/\r?\n/u).entries()) {
163
167
  const line = raw.trim();
164
168
  if (line === "" || line.startsWith("#")) {
@@ -171,11 +175,7 @@ export const parseRobots = (file: string, text: string): RobotsDoc => {
171
175
  }
172
176
  const field = (match.groups?.field ?? "").toLowerCase();
173
177
  const value = (match.groups?.value ?? "").trim();
174
- if (field === "user-agent") {
175
- appliesToAll = value === "*";
176
- } else if (field === "disallow" && appliesToAll && value) {
177
- doc.disallow.push(value);
178
- } else if (field === "sitemap" && value) {
178
+ if (field === "sitemap" && value) {
179
179
  doc.sitemaps.push(value);
180
180
  }
181
181
  }
@@ -1,63 +1,23 @@
1
+ import { imageSize as measureImage } from "image-size";
2
+
1
3
  /**
2
- * Pixel dimensions read straight from a PNG, JPEG, or GIF header. A dedicated
3
- * image library would be a dependency for three well-documented byte layouts;
4
- * anything else (SVG, WebP, AVIF) yields null and its checks simply don't run.
4
+ * Pixel dimensions read from an image header via the image-size package,
5
+ * which covers the formats a modern pipeline actually emits — WebP and AVIF
6
+ * included, where the previous hand parser (PNG/JPEG/GIF only) went silent
7
+ * and the dimension checks never ran. An unrecognized or truncated buffer
8
+ * yields null and its checks simply don't run.
5
9
  */
6
10
  export interface ImageSize {
7
11
  width: number;
8
12
  height: number;
9
13
  }
10
14
 
11
- const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
12
-
13
- const pngSize = (bytes: Buffer): ImageSize | null => {
14
- // Signature, then the IHDR chunk is required to come first: width and height
15
- // are big-endian u32s at fixed offsets 16 and 20.
16
- if (bytes.length < 24 || !bytes.subarray(0, 4).equals(PNG_SIGNATURE)) {
17
- return null;
18
- }
19
- return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
20
- };
21
-
22
- /** JPEG start-of-frame markers (C0–CF minus DHT C4, JPG C8, DAC CC). */
23
- const isSof = (marker: number): boolean =>
24
- marker >= 0xc0 &&
25
- marker <= 0xcf &&
26
- marker !== 0xc4 &&
27
- marker !== 0xc8 &&
28
- marker !== 0xcc;
29
-
30
- const jpegSize = (bytes: Buffer): ImageSize | null => {
31
- if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
32
- return null;
33
- }
34
- // Walk the segment list: each is FF <marker> <u16 length> <payload>. The
35
- // dimensions live in the first start-of-frame segment's payload, as
36
- // big-endian u16s after a one-byte precision field.
37
- let offset = 2;
38
- while (offset + 9 < bytes.length) {
39
- if (bytes[offset] !== 0xff) {
40
- return null;
41
- }
42
- const marker = bytes[offset + 1] ?? 0;
43
- if (isSof(marker)) {
44
- return {
45
- height: bytes.readUInt16BE(offset + 5),
46
- width: bytes.readUInt16BE(offset + 7),
47
- };
48
- }
49
- offset += 2 + bytes.readUInt16BE(offset + 2);
50
- }
51
- return null;
52
- };
53
-
54
- const gifSize = (bytes: Buffer): ImageSize | null => {
55
- if (bytes.length < 10 || bytes.subarray(0, 4).toString("latin1") !== "GIF8") {
15
+ /** The image's pixel dimensions, or null when the format isn't recognized. */
16
+ export const imageSize = (bytes: Buffer): ImageSize | null => {
17
+ try {
18
+ const { width, height } = measureImage(bytes);
19
+ return width > 0 && height > 0 ? { height, width } : null;
20
+ } catch {
56
21
  return null;
57
22
  }
58
- return { height: bytes.readUInt16LE(8), width: bytes.readUInt16LE(6) };
59
23
  };
60
-
61
- /** The image's pixel dimensions, or null when the format isn't recognized. */
62
- export const imageSize = (bytes: Buffer): ImageSize | null =>
63
- pngSize(bytes) ?? jpegSize(bytes) ?? gifSize(bytes);
@@ -1,3 +1,5 @@
1
+ import { colors } from "consola/utils";
2
+ import type { ColorFunction } from "consola/utils";
1
3
  import { relative } from "pathe";
2
4
 
3
5
  import { countBySeverity } from "../core/diagnostics.ts";
@@ -7,21 +9,10 @@ import type { CheckId } from "./catalog.ts";
7
9
  import type { AuditResult } from "./run.ts";
8
10
  import type { AuditCategory, AuditTier } from "./types.ts";
9
11
 
10
- const ESC = String.fromCodePoint(27);
11
- const COLORS = {
12
- bold: `${ESC}[1m`,
13
- cyan: `${ESC}[36m`,
14
- dim: `${ESC}[2m`,
15
- green: `${ESC}[32m`,
16
- red: `${ESC}[31m`,
17
- reset: `${ESC}[0m`,
18
- yellow: `${ESC}[33m`,
19
- };
20
-
21
- const SEVERITY_COLOR: Record<DiagnosticSeverity, string> = {
22
- error: COLORS.red,
23
- info: `${ESC}[34m`,
24
- warning: COLORS.yellow,
12
+ const SEVERITY_COLOR: Record<DiagnosticSeverity, ColorFunction> = {
13
+ error: colors.red,
14
+ info: colors.blue,
15
+ warning: colors.yellow,
25
16
  };
26
17
 
27
18
  const GLYPH: Record<DiagnosticSeverity, string> = {
@@ -108,7 +99,7 @@ const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
108
99
  .filter((tier) => !tiers[tier])
109
100
  .map((tier) => {
110
101
  const label = CHECKS.filter((check) => check.tier === tier).length;
111
- return ` ${COLORS.dim}⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)${COLORS.reset}`;
102
+ return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
112
103
  });
113
104
 
114
105
  /** How many checks actually ran, i.e. those whose tier was enabled. */
@@ -138,9 +129,11 @@ const summaryLine = (
138
129
  const findingLine = (diagnostic: Diagnostic, root: string): string => {
139
130
  const url = diagnostic.url ?? "";
140
131
  const source = diagnostic.file
141
- ? `${COLORS.dim}${relative(root, diagnostic.file)}${
142
- diagnostic.line === undefined ? "" : `:${diagnostic.line}`
143
- }${COLORS.reset}`
132
+ ? colors.dim(
133
+ `${relative(root, diagnostic.file)}${
134
+ diagnostic.line === undefined ? "" : `:${diagnostic.line}`
135
+ }`
136
+ )
144
137
  : "";
145
138
  // padEnd alone yields no gap once the URL reaches the column width.
146
139
  return ` ${url.padEnd(34)} ${source}`.trimEnd();
@@ -164,13 +157,13 @@ export const formatReport = (
164
157
  : `${relative(root, result.staticDir) || "dist"} · offline`;
165
158
  lines.push(
166
159
  "",
167
- ` ${COLORS.bold}blume audit${COLORS.reset} ${COLORS.dim}${result.pages} pages · ${where}${COLORS.reset}`,
160
+ ` ${colors.bold("blume audit")} ${colors.dim(`${result.pages} pages · ${where}`)}`,
168
161
  ` ${summaryLine(counts, auditCount(result))}`,
169
162
  ""
170
163
  );
171
164
 
172
165
  if (groups.length === 0) {
173
- lines.push(` ${COLORS.green}✔ No issues found.${COLORS.reset}`, "");
166
+ lines.push(` ${colors.green("✔ No issues found.")}`, "");
174
167
  }
175
168
 
176
169
  let category: AuditCategory | null = null;
@@ -178,13 +171,13 @@ export const formatReport = (
178
171
  const { category: next } = group;
179
172
  if (next !== category) {
180
173
  category = next;
181
- lines.push(` ${COLORS.bold}${category}${COLORS.reset}`, "");
174
+ lines.push(` ${colors.bold(category)}`, "");
182
175
  }
183
176
 
184
177
  const color = SEVERITY_COLOR[group.severity];
185
178
  const pages = `${group.count} page${group.count === 1 ? "" : "s"}`;
186
179
  lines.push(
187
- ` ${color}${GLYPH[group.severity]} ${group.title}${COLORS.reset} ${COLORS.dim}${pages}${COLORS.reset}`
180
+ ` ${color(`${GLYPH[group.severity]} ${group.title}`)} ${colors.dim(pages)}`
188
181
  );
189
182
 
190
183
  const shown = options.verbose
@@ -195,14 +188,12 @@ export const formatReport = (
195
188
  // The message names the specifics the rolled-up line can't — which target
196
189
  // is broken, what the duplicate is — so --verbose prints it per finding.
197
190
  if (options.verbose) {
198
- lines.push(` ${COLORS.dim}${diagnostic.message}${COLORS.reset}`);
191
+ lines.push(` ${colors.dim(diagnostic.message)}`);
199
192
  }
200
193
  }
201
194
  const hidden = group.count - shown.length;
202
195
  if (hidden > 0) {
203
- lines.push(
204
- ` ${COLORS.dim}… and ${hidden} more (--verbose)${COLORS.reset}`
205
- );
196
+ lines.push(` ${colors.dim(`… and ${hidden} more (--verbose)`)}`);
206
197
  }
207
198
 
208
199
  // Every finding in a group shares the catalog's fix unless it overrode it,
@@ -210,7 +201,7 @@ export const formatReport = (
210
201
  const [first] = group.findings;
211
202
  const fix = first?.suggestion;
212
203
  if (fix) {
213
- lines.push(` ${COLORS.cyan}fix: ${fix}${COLORS.reset}`);
204
+ lines.push(` ${colors.cyan(`fix: ${fix}`)}`);
214
205
  }
215
206
  lines.push("");
216
207
  }
@@ -268,14 +259,12 @@ export const formatCatalog = (): string => {
268
259
  const { category: next } = check;
269
260
  if (next !== category) {
270
261
  category = next;
271
- lines.push(` ${COLORS.bold}${category}${COLORS.reset}`);
262
+ lines.push(` ${colors.bold(category)}`);
272
263
  }
273
264
  const tier =
274
- check.tier === "static"
275
- ? ""
276
- : ` ${COLORS.dim}[${check.tier}]${COLORS.reset}`;
265
+ check.tier === "static" ? "" : ` ${colors.dim(`[${check.tier}]`)}`;
277
266
  lines.push(
278
- ` ${SEVERITY_COLOR[check.severity]}${GLYPH[check.severity]}${COLORS.reset} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${COLORS.dim}${check.title}${COLORS.reset}${tier}`
267
+ ` ${SEVERITY_COLOR[check.severity](GLYPH[check.severity])} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${colors.dim(check.title)}${tier}`
279
268
  );
280
269
  }
281
270
  lines.push("", ` ${CHECKS.length} checks.`, "");
@@ -135,8 +135,12 @@ export interface LlmsDoc {
135
135
  /** A parsed `robots.txt`. */
136
136
  export interface RobotsDoc {
137
137
  file: string;
138
- /** `Disallow:` paths for `User-agent: *`. */
139
- disallow: string[];
138
+ /**
139
+ * The file's full text. Rule matching runs through robots-parser (which
140
+ * owns longest-match Allow/Disallow semantics), so the raw text is the
141
+ * source of truth rather than a pre-extracted rule list.
142
+ */
143
+ raw: string;
140
144
  /** `Sitemap:` declarations. */
141
145
  sitemaps: string[];
142
146
  /** Lines that aren't a recognized directive, with their 1-based line number. */
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),
@@ -1,8 +1,6 @@
1
- import { watch } from "node:fs";
2
-
3
1
  import { dev } from "astro";
2
+ import { watch } from "chokidar";
4
3
  import { defineCommand } from "citty";
5
- import { basename, dirname } from "pathe";
6
4
 
7
5
  import { generateRuntime } from "../../astro/generate.ts";
8
6
  import { showBlumeErrorOverlay } from "../../astro/integration.ts";
@@ -212,26 +210,16 @@ export const devCommand = defineCommand({
212
210
  project.context.componentsFile,
213
211
  ].filter((target) => target !== null);
214
212
 
213
+ // chokidar handles what raw fs.watch made us hand-roll: recursive
214
+ // directory watching on every platform, and single files surviving a
215
+ // rename-replace save (vim and most "atomic save" editors), which orphans
216
+ // an inode-tracking fs.watch watcher after the first write.
217
+ const projectWatcher = watch([...dirTargets, ...fileTargets], {
218
+ ignoreInitial: true,
219
+ }).on("all", regenerate);
215
220
  const disposers = [
216
221
  ...project.sources.map((source) => source.watch?.(regenerate)),
217
- ...dirTargets.map((target) => {
218
- const watcher = watch(target, { recursive: true }, regenerate);
219
- return () => watcher.close();
220
- }),
221
- // Single files are watched via their parent directory: fs.watch on the
222
- // file itself tracks the inode, so a rename-replace save (vim and most
223
- // "atomic save" editors) orphans the watcher after the first write and
224
- // every later edit is silently ignored.
225
- ...fileTargets.map((target) => {
226
- const name = basename(target);
227
- const watcher = watch(dirname(target), (_event, filename) => {
228
- // A null filename (some platforms) can't be filtered — regenerate.
229
- if (!filename || filename === name) {
230
- regenerate();
231
- }
232
- });
233
- return () => watcher.close();
234
- }),
222
+ () => void projectWatcher.close(),
235
223
  ].filter((dispose) => dispose !== undefined);
236
224
 
237
225
  const shutdown = async () => {