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.
- package/CHANGELOG.md +72 -0
- package/dist/cli/index.js +3512 -814
- package/dist/cli/index.js.map +99 -87
- 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 +15 -1
- 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 +22 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/component-markdown.ts +7 -6
- 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 +4 -13
- package/src/astro/integration.ts +3 -1
- package/src/astro/islands.ts +4 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +69 -22
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/env.ts +6 -52
- package/src/cli/index.ts +2 -0
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- 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/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- 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/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +18 -25
- package/src/components/layout/search/orama.ts +3 -1
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/base-path.ts +9 -0
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config-input.ts +84 -6
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- 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/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/notion.ts +17 -23
- package/src/core/sources/types.ts +5 -0
- package/src/core/tsconfig-aliases.ts +39 -172
- 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/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/agents.ts +1 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- 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/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +142 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +354 -0
- package/src/translate/run.ts +357 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
package/src/audit/crawl.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (&) 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:
|
|
96
|
-
* whether the document is a
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
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 = {
|
|
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 === "
|
|
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
|
}
|
package/src/audit/image-size.ts
CHANGED
|
@@ -1,63 +1,23 @@
|
|
|
1
|
+
import { imageSize as measureImage } from "image-size";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Pixel dimensions read
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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);
|
package/src/audit/report.ts
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 ` ${
|
|
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
|
-
?
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
` ${
|
|
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(` ${
|
|
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(` ${
|
|
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
|
|
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(` ${
|
|
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(` ${
|
|
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(` ${
|
|
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]
|
|
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.`, "");
|
package/src/audit/types.ts
CHANGED
|
@@ -135,8 +135,12 @@ export interface LlmsDoc {
|
|
|
135
135
|
/** A parsed `robots.txt`. */
|
|
136
136
|
export interface RobotsDoc {
|
|
137
137
|
file: string;
|
|
138
|
-
/**
|
|
139
|
-
|
|
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 =
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
147
|
-
existsSync(join(
|
|
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
|
-
|
|
153
|
-
|
|
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 (
|
|
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),
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|