blume 1.6.4 → 1.6.6
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 +41 -0
- package/bin/blume.mjs +3 -2
- package/dist/cli/chunk-0ewz4trd.js +679 -0
- package/dist/cli/chunk-0ewz4trd.js.map +15 -0
- package/dist/cli/chunk-27gtm2ym.js +69 -0
- package/dist/cli/chunk-27gtm2ym.js.map +11 -0
- package/dist/cli/chunk-2aj8ddew.js +72 -0
- package/dist/cli/chunk-2aj8ddew.js.map +10 -0
- package/dist/cli/chunk-3k0kzs6d.js +69 -0
- package/dist/cli/chunk-3k0kzs6d.js.map +11 -0
- package/dist/cli/chunk-3r94j3tc.js +221 -0
- package/dist/cli/chunk-3r94j3tc.js.map +10 -0
- package/dist/cli/chunk-4trphnvy.js +102 -0
- package/dist/cli/chunk-4trphnvy.js.map +11 -0
- package/dist/cli/chunk-4xyggvgf.js +21 -0
- package/dist/cli/chunk-4xyggvgf.js.map +10 -0
- package/dist/cli/chunk-5hs6gb7n.js +32 -0
- package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
- package/dist/cli/chunk-5yvt556e.js +185 -0
- package/dist/cli/chunk-5yvt556e.js.map +11 -0
- package/dist/cli/chunk-62qsssnh.js +3808 -0
- package/dist/cli/chunk-62qsssnh.js.map +36 -0
- package/dist/cli/chunk-6kzzpsx8.js +26 -0
- package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
- package/dist/cli/chunk-8gnpdsn1.js +952 -0
- package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
- package/dist/cli/chunk-9sh49q0h.js +30 -0
- package/dist/cli/chunk-9sh49q0h.js.map +10 -0
- package/dist/cli/chunk-aerwpe14.js +2370 -0
- package/dist/cli/chunk-aerwpe14.js.map +15 -0
- package/dist/cli/chunk-ag1zyr5x.js +176 -0
- package/dist/cli/chunk-ag1zyr5x.js.map +10 -0
- package/dist/cli/chunk-bawgnt8x.js +277 -0
- package/dist/cli/chunk-bawgnt8x.js.map +11 -0
- package/dist/cli/chunk-bcy492zc.js +16 -0
- package/dist/cli/chunk-bcy492zc.js.map +10 -0
- package/dist/cli/chunk-btfr9yvw.js +41 -0
- package/dist/cli/chunk-btfr9yvw.js.map +10 -0
- package/dist/cli/chunk-cbjnx4s8.js +73 -0
- package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
- package/dist/cli/chunk-cnvm6k3e.js +96 -0
- package/dist/cli/chunk-cnvm6k3e.js.map +10 -0
- package/dist/cli/chunk-etsqspj6.js +5170 -0
- package/dist/cli/chunk-etsqspj6.js.map +47 -0
- package/dist/cli/chunk-ev67ycx0.js +15 -0
- package/dist/cli/chunk-ev67ycx0.js.map +10 -0
- package/dist/cli/chunk-ey89bjj1.js +209 -0
- package/dist/cli/chunk-ey89bjj1.js.map +11 -0
- package/dist/cli/chunk-f75cqye8.js +76 -0
- package/dist/cli/chunk-f75cqye8.js.map +10 -0
- package/dist/cli/chunk-j00ezcg5.js +259 -0
- package/dist/cli/chunk-j00ezcg5.js.map +11 -0
- package/dist/cli/chunk-jtb45atp.js +467 -0
- package/dist/cli/chunk-jtb45atp.js.map +14 -0
- package/dist/cli/chunk-m3p3wahd.js +117 -0
- package/dist/cli/chunk-m3p3wahd.js.map +10 -0
- package/dist/cli/chunk-n0y172hf.js +387 -0
- package/dist/cli/chunk-n0y172hf.js.map +12 -0
- package/dist/cli/chunk-n4qjabmt.js +1062 -0
- package/dist/cli/chunk-n4qjabmt.js.map +25 -0
- package/dist/cli/chunk-nyqzjdhj.js +111 -0
- package/dist/cli/chunk-nyqzjdhj.js.map +11 -0
- package/dist/cli/chunk-pxj10x8y.js +35 -0
- package/dist/cli/chunk-pxj10x8y.js.map +10 -0
- package/dist/cli/chunk-s4jn7f1q.js +54 -0
- package/dist/cli/chunk-s4jn7f1q.js.map +10 -0
- package/dist/cli/chunk-s4k1pnvf.js +81 -0
- package/dist/cli/chunk-s4k1pnvf.js.map +10 -0
- package/dist/cli/chunk-s5e5jt53.js +227 -0
- package/dist/cli/chunk-s5e5jt53.js.map +11 -0
- package/dist/cli/chunk-sbdqrjbb.js +81 -0
- package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
- package/dist/cli/chunk-tc89yh2r.js +136 -0
- package/dist/cli/chunk-tc89yh2r.js.map +10 -0
- package/dist/cli/chunk-vt8fgygt.js +23 -0
- package/dist/cli/chunk-vt8fgygt.js.map +10 -0
- package/dist/cli/chunk-vv237fp3.js +1002 -0
- package/dist/cli/chunk-vv237fp3.js.map +13 -0
- package/dist/cli/chunk-vv3f8mb6.js +5314 -0
- package/dist/cli/chunk-vv3f8mb6.js.map +58 -0
- package/dist/cli/chunk-vxv4x1n8.js +17 -0
- package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
- package/dist/cli/chunk-wb067mv3.js +758 -0
- package/dist/cli/chunk-wb067mv3.js.map +13 -0
- package/dist/cli/chunk-wd27zjcz.js +60 -0
- package/dist/cli/chunk-wd27zjcz.js.map +10 -0
- package/dist/cli/chunk-wkq5tbtq.js +1141 -0
- package/dist/cli/chunk-wkq5tbtq.js.map +19 -0
- package/dist/cli/chunk-x1vrdjyk.js +1967 -0
- package/dist/cli/chunk-x1vrdjyk.js.map +34 -0
- package/dist/cli/chunk-x66c5yjn.js +23 -0
- package/dist/cli/chunk-x66c5yjn.js.map +10 -0
- package/dist/cli/index.js +55 -27587
- package/dist/cli/index.js.map +5 -243
- package/dist/types/ai/ask-context.d.ts +26 -0
- package/dist/types/core/code-fences.d.ts +11 -0
- package/dist/types/core/config-input.d.ts +10 -0
- package/dist/types/core/package-root.d.ts +1 -1
- package/dist/types/core/schema.d.ts +74 -1
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/analytics.mdx +21 -2
- package/docs/configuration/ask-ai.mdx +1 -1
- package/docs/configuration/customization.mdx +2 -9
- package/docs/content/syntax.mdx +1 -1
- package/docs/reference/cli.mdx +1 -1
- package/package.json +16 -14
- package/src/ai/api/handlers.ts +4 -7
- package/src/ai/api/paths.ts +8 -0
- package/src/ai/api/spec.ts +2 -1
- package/src/ai/ask-context.ts +378 -22
- package/src/astro/generate.ts +25 -29
- package/src/astro/include-hmr.ts +10 -13
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +6 -1
- package/src/astro/integration.ts +269 -53
- package/src/astro/module-types.ts +74 -0
- package/src/astro/templates.ts +85 -97
- package/src/audit/image-size.ts +10 -8
- package/src/cli/command-meta.ts +77 -0
- package/src/cli/commands/add.ts +2 -4
- package/src/cli/commands/audit.ts +2 -4
- package/src/cli/commands/build.ts +42 -346
- package/src/cli/commands/check.ts +2 -4
- package/src/cli/commands/dev.ts +31 -42
- package/src/cli/commands/doctor.ts +2 -4
- package/src/cli/commands/eject.ts +3 -41
- package/src/cli/commands/eval.ts +2 -5
- package/src/cli/commands/init.ts +2 -4
- package/src/cli/commands/mcp-stdio.ts +2 -5
- package/src/cli/commands/preview.ts +3 -5
- package/src/cli/commands/sync.ts +2 -4
- package/src/cli/commands/translate.ts +2 -5
- package/src/cli/commands/validate.ts +2 -4
- package/src/cli/commands/version.ts +2 -4
- package/src/cli/eject-scripts.ts +0 -45
- package/src/cli/host-args.ts +16 -0
- package/src/cli/index.ts +84 -35
- package/src/cli/lazy-command.ts +47 -0
- package/src/components/content/GithubInfo.astro +4 -1
- package/src/components/content/mermaid-element.ts +8 -0
- package/src/components/layout/Analytics.astro +20 -1
- package/src/components/layout/PageLayout.astro +14 -3
- package/src/components/layout/ReferenceLayout.astro +15 -4
- package/src/components/layout/RootLayout.astro +15 -4
- package/src/components/layout/analytics-client.ts +2 -1
- package/src/components/layout/page-locale.ts +29 -0
- package/src/components/openapi/AsyncApiOperation.astro +5 -3
- package/src/components/openapi/Authorization.astro +4 -6
- package/src/components/openapi/Bindings.astro +2 -2
- package/src/components/openapi/Description.astro +109 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +5 -7
- package/src/components/openapi/GraphqlOperation.astro +4 -3
- package/src/components/openapi/GraphqlType.astro +4 -6
- package/src/components/openapi/ParametersTable.astro +5 -7
- package/src/components/openapi/RequestBody.astro +2 -4
- package/src/components/openapi/Responses.astro +4 -3
- package/src/components/openapi/SchemaProperty.astro +11 -6
- package/src/components/openapi/description.ts +91 -0
- package/src/core/api-name.ts +18 -0
- package/src/core/code-fences.ts +48 -0
- package/src/core/config-input.ts +10 -0
- package/src/core/content-assets.ts +3 -7
- package/src/core/includes.ts +3 -7
- package/src/core/package-root.ts +1 -1
- package/src/core/schema.ts +27 -0
- package/src/core/sources/normalize.ts +2 -37
- package/src/core/sources/obsidian.ts +3 -2
- package/src/core/svg-dimensions.ts +97 -0
- package/src/core/version-cut.ts +2 -2
- package/src/deploy/artifacts.ts +370 -0
- package/src/deploy/cloudflare-negotiation.ts +97 -32
- package/src/deploy/function-bundle.ts +66 -20
- package/src/deploy/sitemap.ts +6 -0
- package/src/deploy/vercel-negotiation.ts +8 -30
- package/src/og/card.ts +6 -12
- package/src/openapi/render-mdx.ts +9 -5
- package/src/registry/eject.ts +0 -2
- package/src/theme/entry.ts +9 -2
|
@@ -0,0 +1,1967 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AGENTS,
|
|
4
|
+
CHECKS,
|
|
5
|
+
finding,
|
|
6
|
+
fixPrompt,
|
|
7
|
+
formatCatalog,
|
|
8
|
+
formatReport,
|
|
9
|
+
launchAgent,
|
|
10
|
+
reportJson,
|
|
11
|
+
writeAgentReport
|
|
12
|
+
} from "./chunk-8gnpdsn1.js";
|
|
13
|
+
import {
|
|
14
|
+
gradeExternal,
|
|
15
|
+
probeAll
|
|
16
|
+
} from "./chunk-cbjnx4s8.js";
|
|
17
|
+
import {
|
|
18
|
+
reportInternalError
|
|
19
|
+
} from "./chunk-btfr9yvw.js";
|
|
20
|
+
import {
|
|
21
|
+
deployStaticDir,
|
|
22
|
+
examplesRouteBase
|
|
23
|
+
} from "./chunk-aerwpe14.js";
|
|
24
|
+
import"./chunk-3k0kzs6d.js";
|
|
25
|
+
import"./chunk-vxv4x1n8.js";
|
|
26
|
+
import"./chunk-wd27zjcz.js";
|
|
27
|
+
import {
|
|
28
|
+
frontmatter_default,
|
|
29
|
+
scanProject
|
|
30
|
+
} from "./chunk-etsqspj6.js";
|
|
31
|
+
import {
|
|
32
|
+
SITE_INFERRING_ADAPTERS
|
|
33
|
+
} from "./chunk-vv3f8mb6.js";
|
|
34
|
+
import {
|
|
35
|
+
normalizeBasePath,
|
|
36
|
+
normalizePath,
|
|
37
|
+
stripBasePath,
|
|
38
|
+
withBasePath
|
|
39
|
+
} from "./chunk-27gtm2ym.js";
|
|
40
|
+
import"./chunk-4xyggvgf.js";
|
|
41
|
+
import"./chunk-6kzzpsx8.js";
|
|
42
|
+
import {
|
|
43
|
+
BlumeError,
|
|
44
|
+
flushStdout,
|
|
45
|
+
locateFrontmatterKey,
|
|
46
|
+
logger
|
|
47
|
+
} from "./chunk-ey89bjj1.js";
|
|
48
|
+
import {
|
|
49
|
+
commandMeta
|
|
50
|
+
} from "./chunk-2aj8ddew.js";
|
|
51
|
+
|
|
52
|
+
// src/cli/commands/audit.ts
|
|
53
|
+
import { defineCommand } from "citty";
|
|
54
|
+
|
|
55
|
+
// src/audit/run.ts
|
|
56
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
57
|
+
import pMap2 from "p-map";
|
|
58
|
+
|
|
59
|
+
// src/audit/locate.ts
|
|
60
|
+
var pageSite = (context, page, key) => {
|
|
61
|
+
const file = page.source;
|
|
62
|
+
if (!file) {
|
|
63
|
+
return { url: page.url };
|
|
64
|
+
}
|
|
65
|
+
const source = key ? context.sources.get(file) : undefined;
|
|
66
|
+
const position = source ? locateFrontmatterKey(source, key ?? []) : undefined;
|
|
67
|
+
return {
|
|
68
|
+
column: position?.column,
|
|
69
|
+
file,
|
|
70
|
+
line: position?.line,
|
|
71
|
+
url: page.url
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/audit/url.ts
|
|
76
|
+
var serverRoutes = (context) => {
|
|
77
|
+
const mcp = context.project.config.ai?.mcp;
|
|
78
|
+
return mcp?.enabled ? [normalizePath(mcp.route)] : [];
|
|
79
|
+
};
|
|
80
|
+
var isServed = (context, path) => context.byUrl.has(path) || context.files.has(path) || context.files.has(`${path}/index.html`) || serverRoutes(context).includes(path);
|
|
81
|
+
var NON_HTTP_SCHEME = /^(?!https?:)[a-z][a-z0-9+.-]*:/iu;
|
|
82
|
+
var decodePath = (path) => {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURI(path);
|
|
85
|
+
} catch {
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var siteOrigin = (site) => {
|
|
90
|
+
if (!site) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return new URL(site).origin;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var resolveHref = (pageUrl, href, origin, deployBase = "") => {
|
|
100
|
+
const target = href.trim();
|
|
101
|
+
if (target === "" || target.startsWith("#")) {
|
|
102
|
+
return { kind: "ignored" };
|
|
103
|
+
}
|
|
104
|
+
if (NON_HTTP_SCHEME.test(target)) {
|
|
105
|
+
return { kind: "ignored" };
|
|
106
|
+
}
|
|
107
|
+
const absolute = /^https?:\/\//iu.test(target) || target.startsWith("//");
|
|
108
|
+
if (absolute) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = new URL(target.startsWith("//") ? `https:${target}` : target);
|
|
112
|
+
} catch {
|
|
113
|
+
return { kind: "ignored" };
|
|
114
|
+
}
|
|
115
|
+
if (origin && parsed.origin === origin) {
|
|
116
|
+
return {
|
|
117
|
+
hash: parsed.hash.slice(1),
|
|
118
|
+
kind: "self-origin",
|
|
119
|
+
path: normalizePath(stripBasePath(deployBase, decodePath(parsed.pathname)))
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { kind: "external", url: parsed.toString() };
|
|
123
|
+
}
|
|
124
|
+
const base = pageUrl === "/" ? "https://blume.invalid/" : `https://blume.invalid${pageUrl}/`;
|
|
125
|
+
let resolved;
|
|
126
|
+
try {
|
|
127
|
+
resolved = new URL(target, base);
|
|
128
|
+
} catch {
|
|
129
|
+
return { kind: "ignored" };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
hash: resolved.hash.slice(1),
|
|
133
|
+
kind: "internal",
|
|
134
|
+
path: normalizePath(stripBasePath(deployBase, decodePath(resolved.pathname)))
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// src/audit/checks/assets.ts
|
|
139
|
+
var formatBytes = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
140
|
+
var resolveAsset = (context, page, asset, missingId, kind, found) => {
|
|
141
|
+
if (asset.src.startsWith("http://")) {
|
|
142
|
+
found.push(finding("BLUME_AUDIT_MIXED_CONTENT", pageSite(context, page), `Page loads ${kind} over plain HTTP: ${asset.src}`));
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (asset.src.startsWith("data:")) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const origin = siteOrigin(context.project.config.deployment.site);
|
|
149
|
+
const resolved = resolveHref(page.url, asset.src, origin, normalizeBasePath(context.project.config.deployment.base));
|
|
150
|
+
if (resolved.kind === "external" || resolved.kind === "ignored") {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const bytes = context.files.get(resolved.path);
|
|
154
|
+
if (bytes === undefined) {
|
|
155
|
+
found.push(finding(missingId, pageSite(context, page), `Page references ${asset.src}, which is not in the build.`));
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return bytes;
|
|
159
|
+
};
|
|
160
|
+
var assetChecks = {
|
|
161
|
+
category: "assets",
|
|
162
|
+
run(context) {
|
|
163
|
+
const found = [];
|
|
164
|
+
const { maxAssetBytes } = context.thresholds;
|
|
165
|
+
const weigh = (page, asset, bytes, kind) => {
|
|
166
|
+
if (bytes !== null && bytes > maxAssetBytes) {
|
|
167
|
+
found.push(finding("BLUME_AUDIT_ASSET_TOO_LARGE", pageSite(context, page), `${kind} ${asset.src} is ${formatBytes(bytes)} (over ${formatBytes(maxAssetBytes)}).`));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
for (const page of context.pages) {
|
|
171
|
+
for (const image of page.images) {
|
|
172
|
+
const bytes = resolveAsset(context, page, image, "BLUME_AUDIT_IMAGE_BROKEN", "an image", found);
|
|
173
|
+
weigh(page, image, bytes, "Image");
|
|
174
|
+
if (image.alt === null) {
|
|
175
|
+
found.push(finding("BLUME_AUDIT_IMAGE_ALT_MISSING", pageSite(context, page), `Image ${image.src} has no alt attribute.`));
|
|
176
|
+
}
|
|
177
|
+
if (!(image.width && image.height)) {
|
|
178
|
+
found.push(finding("BLUME_AUDIT_IMAGE_MISSING_DIMENSIONS", pageSite(context, page), `Image ${image.src} has no width/height, so it will shift the layout as it loads.`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const script of page.scripts) {
|
|
182
|
+
weigh(page, script, resolveAsset(context, page, script, "BLUME_AUDIT_SUBRESOURCE_MISSING", "a script", found), "Script");
|
|
183
|
+
}
|
|
184
|
+
for (const style of page.styles) {
|
|
185
|
+
weigh(page, style, resolveAsset(context, page, style, "BLUME_AUDIT_SUBRESOURCE_MISSING", "a stylesheet", found), "Stylesheet");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return found;
|
|
189
|
+
},
|
|
190
|
+
tier: "static"
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/audit/checks/content.ts
|
|
194
|
+
import stringWidth from "string-width";
|
|
195
|
+
|
|
196
|
+
// src/audit/types.ts
|
|
197
|
+
var ERROR_ROUTES = new Set(["/404", "/500"]);
|
|
198
|
+
var DEFAULT_THRESHOLDS = {
|
|
199
|
+
descriptionMax: 160,
|
|
200
|
+
descriptionMin: 110,
|
|
201
|
+
maxAssetBytes: 500 * 1024,
|
|
202
|
+
maxHtmlBytes: 2 * 1024 * 1024,
|
|
203
|
+
maxRedirectHops: 3,
|
|
204
|
+
minWordCount: 50,
|
|
205
|
+
titleMax: 60,
|
|
206
|
+
titleMin: 10
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/audit/checks/content.ts
|
|
210
|
+
var WHITESPACE = /\s+/gu;
|
|
211
|
+
var isDateValue = (value) => typeof value === "string" || value instanceof Date;
|
|
212
|
+
var frontmatterDate = (source) => {
|
|
213
|
+
try {
|
|
214
|
+
const { date } = frontmatter_default(source).data;
|
|
215
|
+
if (!isDateValue(date)) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const parsed = date instanceof Date ? date : new Date(date);
|
|
219
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
var titleChecks = (context, page) => {
|
|
225
|
+
const { titleMin, titleMax } = context.thresholds;
|
|
226
|
+
const [title] = page.titles;
|
|
227
|
+
if (!title) {
|
|
228
|
+
return [
|
|
229
|
+
finding("BLUME_AUDIT_TITLE_MISSING", pageSite(context, page), "Page has no <title>.")
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
const found = [];
|
|
233
|
+
if (page.titles.length > 1) {
|
|
234
|
+
found.push(finding("BLUME_AUDIT_TITLE_MULTIPLE", pageSite(context, page), `Page has ${page.titles.length} <title> tags; search engines use only the first.`));
|
|
235
|
+
}
|
|
236
|
+
const width = stringWidth(title.replaceAll(WHITESPACE, " "));
|
|
237
|
+
if (width > titleMax || width < titleMin) {
|
|
238
|
+
const direction = width > titleMax ? "long" : "short";
|
|
239
|
+
found.push(finding("BLUME_AUDIT_TITLE_LENGTH", pageSite(context, page, ["title"]), `Title renders ${width} columns wide — too ${direction} (aim for ${titleMin}–${titleMax}).`));
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
};
|
|
243
|
+
var descriptionChecks = (context, page) => {
|
|
244
|
+
if (ERROR_ROUTES.has(page.url)) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
const { descriptionMin, descriptionMax } = context.thresholds;
|
|
248
|
+
const [description] = page.descriptions;
|
|
249
|
+
if (!description) {
|
|
250
|
+
return [
|
|
251
|
+
finding("BLUME_AUDIT_DESCRIPTION_MISSING", pageSite(context, page), "Page has no meta description.")
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
const found = [];
|
|
255
|
+
if (page.descriptions.length > 1) {
|
|
256
|
+
found.push(finding("BLUME_AUDIT_DESCRIPTION_MULTIPLE", pageSite(context, page), `Page has ${page.descriptions.length} meta description tags.`));
|
|
257
|
+
}
|
|
258
|
+
const width = stringWidth(description.replaceAll(WHITESPACE, " "));
|
|
259
|
+
if (width > descriptionMax || width < descriptionMin) {
|
|
260
|
+
const direction = width > descriptionMax ? "long" : "short";
|
|
261
|
+
found.push(finding("BLUME_AUDIT_DESCRIPTION_LENGTH", pageSite(context, page, ["description"]), `Meta description renders ${width} columns wide — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`));
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
};
|
|
265
|
+
var headingChecks = (context, page) => {
|
|
266
|
+
const h1s = page.headings.filter((heading) => heading.depth === 1);
|
|
267
|
+
if (h1s.length === 0) {
|
|
268
|
+
return [
|
|
269
|
+
finding("BLUME_AUDIT_H1_MISSING", pageSite(context, page), "Page has no <h1>.")
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
if (h1s.length > 1) {
|
|
273
|
+
return [
|
|
274
|
+
finding("BLUME_AUDIT_H1_MULTIPLE", pageSite(context, page), `Page has ${h1s.length} <h1> tags: ${h1s.map((h) => `"${h.text}"`).join(", ")}.`)
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
let previous = null;
|
|
278
|
+
for (const heading of page.headings) {
|
|
279
|
+
if (previous !== null && heading.depth > previous + 1) {
|
|
280
|
+
return [
|
|
281
|
+
finding("BLUME_AUDIT_HEADING_SKIP", pageSite(context, page), `Headings jump from h${previous} to h${heading.depth} at "${heading.text}".`)
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
previous = heading.depth;
|
|
285
|
+
}
|
|
286
|
+
return [];
|
|
287
|
+
};
|
|
288
|
+
var futureDateChecks = (context, page) => {
|
|
289
|
+
const source = page.source && context.sources.get(page.source);
|
|
290
|
+
if (!source) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
const date = frontmatterDate(source);
|
|
294
|
+
if (date && date.getTime() > Date.now()) {
|
|
295
|
+
return [
|
|
296
|
+
finding("BLUME_AUDIT_FUTURE_DATED_PAGE", pageSite(context, page, ["date"]), `Page is dated ${date.toISOString().slice(0, 10)}, which is in the future.`)
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
return [];
|
|
300
|
+
};
|
|
301
|
+
var contentChecks = {
|
|
302
|
+
category: "content",
|
|
303
|
+
run(context) {
|
|
304
|
+
const found = [];
|
|
305
|
+
for (const page of context.pages) {
|
|
306
|
+
found.push(...titleChecks(context, page), ...descriptionChecks(context, page), ...headingChecks(context, page), ...futureDateChecks(context, page));
|
|
307
|
+
if (page.wordCount < context.thresholds.minWordCount && page.indexable) {
|
|
308
|
+
found.push(finding("BLUME_AUDIT_LOW_WORD_COUNT", pageSite(context, page), `Page has ${page.wordCount} words of prose (excluding code blocks).`));
|
|
309
|
+
}
|
|
310
|
+
if (!page.viewport) {
|
|
311
|
+
found.push(finding("BLUME_AUDIT_VIEWPORT_MISSING", pageSite(context, page), "Page has no viewport <meta> — it will not render correctly on mobile."));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return found;
|
|
315
|
+
},
|
|
316
|
+
tier: "static"
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// src/audit/checks/dns-aid.ts
|
|
320
|
+
var RR_SVCB = 64;
|
|
321
|
+
var RR_HTTPS = 65;
|
|
322
|
+
var DOH_TIMEOUT_MS = 5000;
|
|
323
|
+
var DOH_RESOLVERS = [
|
|
324
|
+
"https://dns.google/resolve",
|
|
325
|
+
"https://cloudflare-dns.com/dns-query"
|
|
326
|
+
];
|
|
327
|
+
var resolvers = () => {
|
|
328
|
+
const override = process.env.BLUME_DOH_URL;
|
|
329
|
+
return override ? [override] : DOH_RESOLVERS;
|
|
330
|
+
};
|
|
331
|
+
var fetchDohJson = async (base, name, type, fetchFn) => {
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetchFn(`${base}?name=${encodeURIComponent(name)}&type=${type}`, {
|
|
334
|
+
headers: { accept: "application/dns-json" },
|
|
335
|
+
signal: AbortSignal.timeout(DOH_TIMEOUT_MS)
|
|
336
|
+
});
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return await response.json();
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
var queryDoh = async (name, type, fetchFn) => {
|
|
346
|
+
for (const base of resolvers()) {
|
|
347
|
+
const response = await fetchDohJson(base, name, type, fetchFn);
|
|
348
|
+
if (response) {
|
|
349
|
+
return response;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
};
|
|
354
|
+
var lookupDnsAid = async (host, fetchFn = fetch) => {
|
|
355
|
+
const name = `_index._agents.${host}`;
|
|
356
|
+
let answered = false;
|
|
357
|
+
let found = false;
|
|
358
|
+
let authenticated = true;
|
|
359
|
+
const responses = await Promise.all([RR_HTTPS, RR_SVCB].map((type) => queryDoh(name, type, fetchFn)));
|
|
360
|
+
for (const response of responses) {
|
|
361
|
+
if (!response) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
answered = true;
|
|
365
|
+
const records = (response.Answer ?? []).filter((answer) => answer.type === RR_SVCB || answer.type === RR_HTTPS);
|
|
366
|
+
if (records.length > 0) {
|
|
367
|
+
found = true;
|
|
368
|
+
authenticated &&= response.AD === true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return answered ? { authenticated, found } : null;
|
|
372
|
+
};
|
|
373
|
+
var dnsAidFindings = (host, result) => {
|
|
374
|
+
if (!result.found) {
|
|
375
|
+
return [
|
|
376
|
+
finding("BLUME_AUDIT_DNS_AID_MISSING", { url: "/" }, `No SVCB or HTTPS records at _index._agents.${host} — agents probing DNS for AI Discovery (DNS-AID) find nothing.`, `Publish a ServiceMode record with your DNS provider, e.g. \`_index._agents.${host}. 3600 IN HTTPS 1 ${host}. alpn=h2\`, and sign the zone with DNSSEC if the provider supports it.`)
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
if (!result.authenticated) {
|
|
380
|
+
return [
|
|
381
|
+
finding("BLUME_AUDIT_DNS_AID_UNSIGNED", { url: "/" }, `The DNS-AID records at _index._agents.${host} exist but are not DNSSEC-authenticated, so validating resolvers can't prove they're genuine.`)
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
return [];
|
|
385
|
+
};
|
|
386
|
+
var dnsAidHost = (site) => {
|
|
387
|
+
if (!site) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
let hostname;
|
|
391
|
+
try {
|
|
392
|
+
({ hostname } = new URL(site));
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
if (hostname === "localhost" || hostname.endsWith(".local") || !hostname.includes(".")) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
return hostname;
|
|
400
|
+
};
|
|
401
|
+
var dnsAidChecks = {
|
|
402
|
+
category: "ai",
|
|
403
|
+
async run(context) {
|
|
404
|
+
if (!context.origin) {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const host = dnsAidHost(context.project.config.deployment.site);
|
|
408
|
+
if (!host) {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
const result = await lookupDnsAid(host);
|
|
412
|
+
return result ? dnsAidFindings(host, result) : [];
|
|
413
|
+
},
|
|
414
|
+
tier: "network"
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// src/audit/checks/duplicates.ts
|
|
418
|
+
var isNonCanonical = (page, deployBase) => {
|
|
419
|
+
if (!page.canonical) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
return stripBasePath(deployBase, decodePath(new URL(page.canonical).pathname)).replace(/\/$/u, "") !== page.url.replace(/\/$/u, "");
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
var comparable = (context) => {
|
|
429
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
430
|
+
return context.pages.filter((page) => page.indexable && !page.route?.fallback && !isNonCanonical(page, deployBase));
|
|
431
|
+
};
|
|
432
|
+
var reportGroups = (context, pages, id, key, describe, frontmatterKey) => {
|
|
433
|
+
const groups = new Map;
|
|
434
|
+
for (const page of pages) {
|
|
435
|
+
const value = key(page);
|
|
436
|
+
if (!value) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const groupKey = `${page.route?.locale ?? ""}\x00${value}`;
|
|
440
|
+
const group = groups.get(groupKey);
|
|
441
|
+
if (group) {
|
|
442
|
+
group.push(page);
|
|
443
|
+
} else {
|
|
444
|
+
groups.set(groupKey, [page]);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const found = [];
|
|
448
|
+
for (const group of groups.values()) {
|
|
449
|
+
if (group.length < 2) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
for (const page of group) {
|
|
453
|
+
const others = group.filter((other) => other !== page);
|
|
454
|
+
found.push(finding(id, pageSite(context, page, frontmatterKey), describe(key(page) ?? "", others)));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return found;
|
|
458
|
+
};
|
|
459
|
+
var list = (pages) => {
|
|
460
|
+
const shown = pages.slice(0, 3).map((page) => page.url);
|
|
461
|
+
const rest = pages.length - shown.length;
|
|
462
|
+
return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
|
|
463
|
+
};
|
|
464
|
+
var duplicateChecks = {
|
|
465
|
+
category: "duplicates",
|
|
466
|
+
run(context) {
|
|
467
|
+
const pages = comparable(context);
|
|
468
|
+
return [
|
|
469
|
+
...reportGroups(context, pages, "BLUME_AUDIT_DUPLICATE_TITLE", (page) => page.titles[0], (value, others) => `Title "${value}" is also used by ${list(others)}.`, ["title"]),
|
|
470
|
+
...reportGroups(context, pages, "BLUME_AUDIT_DUPLICATE_DESCRIPTION", (page) => page.descriptions[0], (_value, others) => `Meta description is identical to ${list(others)}.`, ["description"]),
|
|
471
|
+
...reportGroups(context, pages.filter((page) => page.wordCount > 0), "BLUME_AUDIT_DUPLICATE_CONTENT", (page) => page.contentHash, (_value, others) => `Page content is byte-identical to ${list(others)}, and none declares a canonical.`)
|
|
472
|
+
];
|
|
473
|
+
},
|
|
474
|
+
tier: "static"
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/audit/checks/i18n.ts
|
|
478
|
+
var deployBaseOf = (context) => normalizeBasePath(context.project.config.deployment.base);
|
|
479
|
+
var X_DEFAULT = "x-default";
|
|
480
|
+
var isValidBcp47 = (tag) => {
|
|
481
|
+
try {
|
|
482
|
+
Intl.getCanonicalLocales(tag);
|
|
483
|
+
return true;
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
var alternatePath = (href, deployBase) => {
|
|
489
|
+
try {
|
|
490
|
+
return normalizePath(stripBasePath(deployBase, new URL(href).pathname));
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
var langChecks = (context, page) => {
|
|
496
|
+
if (!page.lang) {
|
|
497
|
+
return [
|
|
498
|
+
finding("BLUME_AUDIT_HTML_LANG_MISSING", pageSite(context, page), "The <html> element has no lang attribute.")
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
if (!isValidBcp47(page.lang)) {
|
|
502
|
+
return [
|
|
503
|
+
finding("BLUME_AUDIT_HTML_LANG_INVALID", pageSite(context, page), `"${page.lang}" is not a valid BCP 47 language tag.`)
|
|
504
|
+
];
|
|
505
|
+
}
|
|
506
|
+
return [];
|
|
507
|
+
};
|
|
508
|
+
var hreflangChecks = (context, page) => {
|
|
509
|
+
const found = [];
|
|
510
|
+
const seen = new Map;
|
|
511
|
+
const deployBase = deployBaseOf(context);
|
|
512
|
+
for (const alternate of page.hreflang) {
|
|
513
|
+
if (alternate.lang !== X_DEFAULT && !isValidBcp47(alternate.lang)) {
|
|
514
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_INVALID", pageSite(context, page), `hreflang="${alternate.lang}" is not a valid BCP 47 language tag.`));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const hrefs = seen.get(alternate.lang) ?? [];
|
|
518
|
+
hrefs.push(alternate.href);
|
|
519
|
+
seen.set(alternate.lang, hrefs);
|
|
520
|
+
const path = alternatePath(alternate.href, deployBase);
|
|
521
|
+
if (path === null) {
|
|
522
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_BAD_TARGET", pageSite(context, page), `hreflang="${alternate.lang}" points at "${alternate.href}", which is not an absolute URL.`));
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const target = context.byUrl.get(path);
|
|
526
|
+
const redirect = context.redirects.find((entry) => normalizePath(entry.from) === path);
|
|
527
|
+
if (redirect) {
|
|
528
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_BAD_TARGET", pageSite(context, page), `hreflang="${alternate.lang}" points at ${path}, which redirects to ${redirect.to}.`));
|
|
529
|
+
} else if (!target) {
|
|
530
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_BAD_TARGET", pageSite(context, page), `hreflang="${alternate.lang}" points at ${path}, which the build does not serve.`));
|
|
531
|
+
} else if (target.canonical && alternatePath(target.canonical, deployBase) !== normalizePath(target.url)) {
|
|
532
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_BAD_TARGET", pageSite(context, page), `hreflang="${alternate.lang}" points at ${path}, which canonicalizes elsewhere.`));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const [lang, hrefs] of seen) {
|
|
536
|
+
if (hrefs.length > 1) {
|
|
537
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_CONFLICT", pageSite(context, page), `hreflang="${lang}" is declared ${hrefs.length} times, pointing at ${hrefs.join(", ")}.`));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const self = page.hreflang.find((alternate) => alternatePath(alternate.href, deployBase) === normalizePath(page.url));
|
|
541
|
+
if (self) {
|
|
542
|
+
if (page.lang && self.lang !== X_DEFAULT && self.lang !== page.lang) {
|
|
543
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_LANG_MISMATCH", pageSite(context, page), `Page declares <html lang="${page.lang}"> but its own hreflang is "${self.lang}".`));
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_SELF_MISSING", pageSite(context, page), "Page's hreflang set does not include a self-reference."));
|
|
547
|
+
}
|
|
548
|
+
if (!page.hreflang.some((alternate) => alternate.lang === X_DEFAULT)) {
|
|
549
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_XDEFAULT_MISSING", pageSite(context, page), "Page's hreflang set has no x-default alternate."));
|
|
550
|
+
}
|
|
551
|
+
return found;
|
|
552
|
+
};
|
|
553
|
+
var returnTagChecks = (context) => {
|
|
554
|
+
const found = [];
|
|
555
|
+
const deployBase = deployBaseOf(context);
|
|
556
|
+
for (const page of context.pages) {
|
|
557
|
+
for (const alternate of page.hreflang) {
|
|
558
|
+
if (alternate.lang === X_DEFAULT) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const path = alternatePath(alternate.href, deployBase);
|
|
562
|
+
if (path === null || path === normalizePath(page.url)) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const target = context.byUrl.get(path);
|
|
566
|
+
if (!target || target.hreflang.length === 0) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const returns = target.hreflang.some((back) => alternatePath(back.href, deployBase) === normalizePath(page.url));
|
|
570
|
+
if (!returns) {
|
|
571
|
+
found.push(finding("BLUME_AUDIT_HREFLANG_NO_RETURN_TAG", pageSite(context, page), `Page names ${path} as its "${alternate.lang}" alternate, but ${path} does not name it back.`));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return found;
|
|
576
|
+
};
|
|
577
|
+
var i18nChecks = {
|
|
578
|
+
category: "i18n",
|
|
579
|
+
run(context) {
|
|
580
|
+
const found = [];
|
|
581
|
+
for (const page of context.pages) {
|
|
582
|
+
found.push(...langChecks(context, page));
|
|
583
|
+
if (page.hreflang.length > 0) {
|
|
584
|
+
found.push(...hreflangChecks(context, page));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
found.push(...returnTagChecks(context));
|
|
588
|
+
return found;
|
|
589
|
+
},
|
|
590
|
+
tier: "static"
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// src/audit/checks/indexability.ts
|
|
594
|
+
var parseCanonical = (canonical) => {
|
|
595
|
+
try {
|
|
596
|
+
return new URL(canonical);
|
|
597
|
+
} catch {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
var canonicalChecks = (context, page) => {
|
|
602
|
+
const { site } = context.project.config.deployment;
|
|
603
|
+
const origin = siteOrigin(site);
|
|
604
|
+
if (ERROR_ROUTES.has(page.url)) {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
if (!page.canonical) {
|
|
608
|
+
return site ? [
|
|
609
|
+
finding("BLUME_AUDIT_CANONICAL_MISSING", pageSite(context, page), "Page has no canonical URL.")
|
|
610
|
+
] : [];
|
|
611
|
+
}
|
|
612
|
+
const canonical = parseCanonical(page.canonical);
|
|
613
|
+
if (!canonical) {
|
|
614
|
+
return [
|
|
615
|
+
finding("BLUME_AUDIT_CANONICAL_BAD_TARGET", pageSite(context, page, ["seo", "canonical"]), `Canonical "${page.canonical}" is not a valid absolute URL.`)
|
|
616
|
+
];
|
|
617
|
+
}
|
|
618
|
+
const found = [];
|
|
619
|
+
if (origin && canonical.origin !== origin) {
|
|
620
|
+
const sameHost = canonical.host === new URL(origin).host;
|
|
621
|
+
if (sameHost) {
|
|
622
|
+
found.push(finding("BLUME_AUDIT_CANONICAL_PROTOCOL_MISMATCH", pageSite(context, page, ["seo", "canonical"]), `Canonical uses ${canonical.protocol}// but the site is ${new URL(origin).protocol}//.`));
|
|
623
|
+
}
|
|
624
|
+
return found;
|
|
625
|
+
}
|
|
626
|
+
const target = normalizePath(stripBasePath(normalizeBasePath(context.project.config.deployment.base), decodePath(canonical.pathname)));
|
|
627
|
+
if (target === normalizePath(page.url)) {
|
|
628
|
+
return found;
|
|
629
|
+
}
|
|
630
|
+
const redirect = context.redirects.find((entry) => normalizePath(entry.from) === target);
|
|
631
|
+
if (redirect) {
|
|
632
|
+
found.push(finding("BLUME_AUDIT_CANONICAL_BAD_TARGET", pageSite(context, page, ["seo", "canonical"]), `Canonical points at ${target}, which is a redirect to ${redirect.to}.`));
|
|
633
|
+
} else if (context.byUrl.has(target)) {
|
|
634
|
+
const version = page.route?.version;
|
|
635
|
+
const archived = version ? context.project.config.versions?.archived.find((entry) => entry.id === version) : undefined;
|
|
636
|
+
const latestAlternate = page.route?.versionAlternates.find((alternate) => alternate.version === "");
|
|
637
|
+
const intentional = archived?.canonical === "latest" && latestAlternate !== undefined && normalizePath(latestAlternate.path) === target;
|
|
638
|
+
if (!intentional) {
|
|
639
|
+
found.push(finding("BLUME_AUDIT_CANONICAL_NOT_SELF", pageSite(context, page, ["seo", "canonical"]), `Page declares ${target} as its canonical, so it will not be indexed itself.`));
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
found.push(finding("BLUME_AUDIT_CANONICAL_BAD_TARGET", pageSite(context, page, ["seo", "canonical"]), `Canonical points at ${target}, which is not a page on this site.`));
|
|
643
|
+
}
|
|
644
|
+
return found;
|
|
645
|
+
};
|
|
646
|
+
var indexabilityChecks = {
|
|
647
|
+
category: "indexability",
|
|
648
|
+
run(context) {
|
|
649
|
+
const found = [];
|
|
650
|
+
if (!context.project.config.deployment.site) {
|
|
651
|
+
const { adapter } = context.project.config.deployment;
|
|
652
|
+
const site = {
|
|
653
|
+
file: context.project.context.configFile ?? undefined,
|
|
654
|
+
url: "/"
|
|
655
|
+
};
|
|
656
|
+
found.push(adapter && SITE_INFERRING_ADAPTERS.has(adapter) ? finding("BLUME_AUDIT_SITE_INFERRED_AT_DEPLOY", site, `deployment.site is not set in this build — on ${adapter} it is inferred from the platform env at deploy time, so canonical URLs, Open Graph images, and the sitemap are only missing from this local artifact.`) : finding("BLUME_AUDIT_SITE_NOT_SET", site, "deployment.site is not set, so no canonical URLs, Open Graph images, or sitemap can be generated."));
|
|
657
|
+
}
|
|
658
|
+
for (const page of context.pages) {
|
|
659
|
+
found.push(...canonicalChecks(context, page));
|
|
660
|
+
if (page.robots && !page.indexable && !ERROR_ROUTES.has(page.url)) {
|
|
661
|
+
found.push(finding("BLUME_AUDIT_ROBOTS_META_UNEXPECTED", pageSite(context, page, ["noindex"]), `Page declares robots "${page.robots}" and will not be indexed.`));
|
|
662
|
+
if (!page.indexable && page.canonical) {
|
|
663
|
+
found.push(finding("BLUME_AUDIT_CANONICAL_ON_NOINDEX", pageSite(context, page, ["noindex"]), `Page is noindex but declares ${page.canonical} as its canonical.`));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (page.route?.draft) {
|
|
667
|
+
found.push(finding("BLUME_AUDIT_DRAFT_PAGE_PUBLISHED", pageSite(context, page, ["draft"]), `${page.url} is marked draft in its front matter but is in the build.`));
|
|
668
|
+
}
|
|
669
|
+
if (page.bytes > context.thresholds.maxHtmlBytes) {
|
|
670
|
+
const mb = (page.bytes / 1024 / 1024).toFixed(1);
|
|
671
|
+
found.push(finding("BLUME_AUDIT_HTML_TOO_LARGE", pageSite(context, page), `Page HTML is ${mb} MB — past Googlebot's 2 MB limit, the rest is not crawled.`));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return found;
|
|
675
|
+
},
|
|
676
|
+
tier: "static"
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/audit/graph.ts
|
|
680
|
+
var add = (map, key, value) => {
|
|
681
|
+
const existing = map.get(key);
|
|
682
|
+
if (existing) {
|
|
683
|
+
existing.add(value);
|
|
684
|
+
} else {
|
|
685
|
+
map.set(key, new Set([value]));
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
var buildGraph = (pages, origin, deployBase = "") => {
|
|
689
|
+
const graph = {
|
|
690
|
+
chromeIn: new Map,
|
|
691
|
+
chromeOut: new Map,
|
|
692
|
+
contentIn: new Map,
|
|
693
|
+
contentOut: new Map
|
|
694
|
+
};
|
|
695
|
+
for (const page of pages) {
|
|
696
|
+
for (const link of page.links) {
|
|
697
|
+
const resolved = resolveHref(page.url, link.href, origin, deployBase);
|
|
698
|
+
if (resolved.kind !== "internal" && resolved.kind !== "self-origin") {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const out = link.content ? graph.contentOut : graph.chromeOut;
|
|
702
|
+
const incoming = link.content ? graph.contentIn : graph.chromeIn;
|
|
703
|
+
add(out, page.url, resolved.path);
|
|
704
|
+
add(incoming, resolved.path, page.url);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return graph;
|
|
708
|
+
};
|
|
709
|
+
var orphanPages = (pages, graph, homeUrl = "/") => pages.filter((page) => page.indexable && page.url !== "/" && page.url !== homeUrl && (graph.contentIn.get(page.url)?.size ?? 0) === 0);
|
|
710
|
+
|
|
711
|
+
// src/audit/checks/links.ts
|
|
712
|
+
var MAGIC_FRAGMENTS = new Set(["", "top"]);
|
|
713
|
+
var anchorResolves = (target, fragment) => {
|
|
714
|
+
if (MAGIC_FRAGMENTS.has(fragment)) {
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
return target.ids.has(fragment) || target.ids.has(decodeURIComponent(fragment));
|
|
719
|
+
} catch {
|
|
720
|
+
return target.ids.has(fragment);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
var redirectFrom = (context, path) => context.redirects.find((entry) => normalizePath(entry.from) === path);
|
|
724
|
+
var linkChecks = {
|
|
725
|
+
category: "links",
|
|
726
|
+
run(context) {
|
|
727
|
+
const found = [];
|
|
728
|
+
const origin = siteOrigin(context.project.config.deployment.site);
|
|
729
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
730
|
+
const brokenChrome = new Map;
|
|
731
|
+
const redirectedChrome = new Map;
|
|
732
|
+
const brokenChromeAnchors = new Map;
|
|
733
|
+
const checkAnchor = (page, link, target, fragment) => {
|
|
734
|
+
if (anchorResolves(target, fragment)) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const where = target === page ? "this page" : `${target.url}, which has no such id`;
|
|
738
|
+
if (link.content) {
|
|
739
|
+
found.push(finding("BLUME_AUDIT_ANCHOR_BROKEN", pageSite(context, page), `Link to ${link.href} points at #${fragment} on ${where}.`));
|
|
740
|
+
} else {
|
|
741
|
+
const key = `${target.url}#${fragment}`;
|
|
742
|
+
if (!brokenChromeAnchors.has(key)) {
|
|
743
|
+
brokenChromeAnchors.set(key, page);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const classify = (page, link) => {
|
|
748
|
+
if (link.href.startsWith("#")) {
|
|
749
|
+
checkAnchor(page, link, page, link.href.slice(1));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const resolved = resolveHref(page.url, link.href, origin, deployBase);
|
|
753
|
+
if (resolved.kind === "external" || resolved.kind === "ignored") {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const anchorTarget = resolved.hash ? context.byUrl.get(resolved.path) : undefined;
|
|
757
|
+
if (anchorTarget) {
|
|
758
|
+
checkAnchor(page, link, anchorTarget, resolved.hash);
|
|
759
|
+
}
|
|
760
|
+
if (resolved.kind === "self-origin") {
|
|
761
|
+
found.push(finding("BLUME_AUDIT_INTERNAL_LINK_ABSOLUTE", pageSite(context, page), `Link to ${link.href} hardcodes the site's origin; use ${resolved.path} instead.`));
|
|
762
|
+
}
|
|
763
|
+
if (link.rel?.includes("nofollow")) {
|
|
764
|
+
found.push(finding("BLUME_AUDIT_INTERNAL_LINK_NOFOLLOW", pageSite(context, page), `Internal link to ${resolved.path} is rel="nofollow".`));
|
|
765
|
+
}
|
|
766
|
+
const { path } = resolved;
|
|
767
|
+
const redirect = redirectFrom(context, path);
|
|
768
|
+
if (redirect) {
|
|
769
|
+
if (link.content) {
|
|
770
|
+
found.push(finding("BLUME_AUDIT_LINK_TO_REDIRECT", pageSite(context, page), `Link to ${path} goes through a redirect to ${redirect.to}.`));
|
|
771
|
+
} else if (!redirectedChrome.has(path)) {
|
|
772
|
+
redirectedChrome.set(path, page);
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (isServed(context, path)) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (link.content) {
|
|
780
|
+
found.push(finding("BLUME_AUDIT_LINK_TO_BROKEN", pageSite(context, page), `Link to ${link.href} resolves to ${path}, which the build does not serve.`));
|
|
781
|
+
} else if (!brokenChrome.has(path)) {
|
|
782
|
+
brokenChrome.set(path, page);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
for (const page of context.pages) {
|
|
786
|
+
for (const link of page.links) {
|
|
787
|
+
classify(page, link);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const [path, page] of brokenChrome) {
|
|
791
|
+
found.push(finding("BLUME_AUDIT_LINK_TO_BROKEN", { url: page.url }, `Navigation links to ${path}, which the build does not serve.`, "Fix the entry in your navigation config or meta file."));
|
|
792
|
+
}
|
|
793
|
+
for (const [path, page] of redirectedChrome) {
|
|
794
|
+
const redirect = redirectFrom(context, path);
|
|
795
|
+
found.push(finding("BLUME_AUDIT_LINK_TO_REDIRECT", { url: page.url }, `Navigation links to ${path}, which redirects to ${redirect?.to}.`, "Point the navigation entry straight at the destination."));
|
|
796
|
+
}
|
|
797
|
+
for (const [key, page] of brokenChromeAnchors) {
|
|
798
|
+
found.push(finding("BLUME_AUDIT_ANCHOR_BROKEN", { url: page.url }, `Navigation links to ${key}, but the target page has no such id.`));
|
|
799
|
+
}
|
|
800
|
+
const homeUrl = normalizeBasePath(context.project.config.basePath) || "/";
|
|
801
|
+
for (const page of orphanPages(context.pages, context.graph, homeUrl)) {
|
|
802
|
+
found.push(finding("BLUME_AUDIT_ORPHAN_PAGE", pageSite(context, page), "No other page's body links here — it is reachable only from the sidebar."));
|
|
803
|
+
}
|
|
804
|
+
return found;
|
|
805
|
+
},
|
|
806
|
+
tier: "static"
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// src/audit/checks/llms.ts
|
|
810
|
+
var isLlmsToggleObject = (value) => typeof value === "object" && value !== null;
|
|
811
|
+
var llmsConfig = (context) => {
|
|
812
|
+
const value = context.project.config.ai?.llmsTxt;
|
|
813
|
+
if (isLlmsToggleObject(value)) {
|
|
814
|
+
return { enabled: value.enabled, openapi: value.openapi };
|
|
815
|
+
}
|
|
816
|
+
return { enabled: value !== false, openapi: true };
|
|
817
|
+
};
|
|
818
|
+
var entryPath = (url, origin, deployBase) => {
|
|
819
|
+
if (/^https?:\/\//iu.test(url)) {
|
|
820
|
+
try {
|
|
821
|
+
const parsed = new URL(url);
|
|
822
|
+
if (origin && parsed.origin !== origin) {
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
return normalizePath(stripBasePath(deployBase, decodeURI(parsed.pathname)));
|
|
826
|
+
} catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (!url.startsWith("/")) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
return normalizePath(stripBasePath(deployBase, decodeURI(url)));
|
|
835
|
+
} catch {
|
|
836
|
+
return normalizePath(stripBasePath(deployBase, url));
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
var llmsChecks = {
|
|
840
|
+
category: "ai",
|
|
841
|
+
run(context) {
|
|
842
|
+
const { enabled, openapi } = llmsConfig(context);
|
|
843
|
+
if (!enabled) {
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
const { llms } = context;
|
|
847
|
+
if (!llms) {
|
|
848
|
+
return [
|
|
849
|
+
finding("BLUME_AUDIT_LLMS_TXT_MISSING", { url: "/llms.txt" }, "The build has no llms.txt.")
|
|
850
|
+
];
|
|
851
|
+
}
|
|
852
|
+
const found = [];
|
|
853
|
+
const origin = siteOrigin(context.project.config.deployment.site);
|
|
854
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
855
|
+
const listed = new Set;
|
|
856
|
+
for (const entry of llms.entries) {
|
|
857
|
+
const path = entryPath(entry.url, origin, deployBase);
|
|
858
|
+
if (path === null) {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
listed.add(path);
|
|
862
|
+
if (!isServed(context, path)) {
|
|
863
|
+
found.push(finding("BLUME_AUDIT_LLMS_TXT_STALE_ENTRY", { file: llms.file, line: entry.line, url: entry.url }, `llms.txt lists ${path}, which the build does not serve.`));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
for (const page of context.pages) {
|
|
867
|
+
const { route } = page;
|
|
868
|
+
if (!route || route.hidden || route.draft || !page.indexable || ERROR_ROUTES.has(page.url) || !openapi && route.source.name === "openapi" || listed.has(normalizePath(page.url))) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
found.push(finding("BLUME_AUDIT_LLMS_TXT_PAGE_MISSING", pageSite(context, page), `${page.url} is built and indexable but is not listed in llms.txt.`));
|
|
872
|
+
}
|
|
873
|
+
return found;
|
|
874
|
+
},
|
|
875
|
+
tier: "static"
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// src/audit/checks/network.ts
|
|
879
|
+
var CLIENT_ERROR = 400;
|
|
880
|
+
var SERVER_ERROR = 500;
|
|
881
|
+
var SLOW_MS = 1500;
|
|
882
|
+
var liveUrl = (origin, page, deployBase) => new URL(`${deployBase}${page.url}`, origin).toString();
|
|
883
|
+
var badResponse = (context, page, result) => {
|
|
884
|
+
const site = pageSite(context, page);
|
|
885
|
+
if (result.timedOut) {
|
|
886
|
+
return finding("BLUME_AUDIT_HTTP_TIMEOUT", site, `${page.url} did not respond within the timeout.`);
|
|
887
|
+
}
|
|
888
|
+
const { status } = result;
|
|
889
|
+
if (status !== undefined && status >= SERVER_ERROR) {
|
|
890
|
+
return finding("BLUME_AUDIT_HTTP_5XX", site, `${page.url} returned HTTP ${status}.`);
|
|
891
|
+
}
|
|
892
|
+
if (status !== undefined && status >= CLIENT_ERROR) {
|
|
893
|
+
return finding("BLUME_AUDIT_HTTP_4XX", site, `${page.url} is in the build but returned HTTP ${status}.`);
|
|
894
|
+
}
|
|
895
|
+
if (!result.ok) {
|
|
896
|
+
return finding("BLUME_AUDIT_HTTP_5XX", site, `${page.url} is unreachable: ${result.error ?? "no response"}.`);
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
};
|
|
900
|
+
var servedPageChecks = (context, page, result, origin) => {
|
|
901
|
+
const site = pageSite(context, page);
|
|
902
|
+
const found = [];
|
|
903
|
+
if (result.redirected && result.finalUrl?.startsWith("http://") && origin.startsWith("https://")) {
|
|
904
|
+
found.push(finding("BLUME_AUDIT_REDIRECT_TO_HTTP", site, `${page.url} redirects to ${result.finalUrl}, downgrading to HTTP.`));
|
|
905
|
+
}
|
|
906
|
+
if (!result.encoding) {
|
|
907
|
+
found.push(finding("BLUME_AUDIT_NOT_COMPRESSED", site, `${page.url} is served without gzip or brotli compression.`));
|
|
908
|
+
}
|
|
909
|
+
if (result.ms !== undefined && result.ms > SLOW_MS) {
|
|
910
|
+
found.push(finding("BLUME_AUDIT_SLOW_RESPONSE", site, `${page.url} took ${result.ms}ms to respond.`));
|
|
911
|
+
}
|
|
912
|
+
const tag = result.robotsTag;
|
|
913
|
+
if (tag?.includes("noindex") && page.indexable) {
|
|
914
|
+
found.push(finding("BLUME_AUDIT_ROBOTS_HEADER_CONFLICT", site, `${page.url} sends "X-Robots-Tag: ${tag}" but its HTML has no noindex — the header wins.`));
|
|
915
|
+
}
|
|
916
|
+
return found;
|
|
917
|
+
};
|
|
918
|
+
var networkChecks = {
|
|
919
|
+
category: "network",
|
|
920
|
+
async run(context) {
|
|
921
|
+
const { origin } = context;
|
|
922
|
+
if (!origin) {
|
|
923
|
+
return [];
|
|
924
|
+
}
|
|
925
|
+
const found = [];
|
|
926
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
927
|
+
const targets = context.pages.map((page) => liveUrl(origin, page, deployBase));
|
|
928
|
+
const robotsUrl = new URL(`${deployBase}/robots.txt`, origin).toString();
|
|
929
|
+
const sitemapUrl = new URL(`${deployBase}/sitemap.xml`, origin).toString();
|
|
930
|
+
const results = await probeAll([...targets, robotsUrl, sitemapUrl]);
|
|
931
|
+
for (const page of context.pages) {
|
|
932
|
+
const result = results.get(liveUrl(origin, page, deployBase));
|
|
933
|
+
if (!result) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const failure = badResponse(context, page, result);
|
|
937
|
+
if (failure) {
|
|
938
|
+
found.push(failure);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
found.push(...servedPageChecks(context, page, result, origin));
|
|
942
|
+
}
|
|
943
|
+
const robots = results.get(robotsUrl);
|
|
944
|
+
if (robots && !robots.ok) {
|
|
945
|
+
found.push(finding("BLUME_AUDIT_ROBOTS_NOT_ACCESSIBLE", { url: "/robots.txt" }, `robots.txt is not reachable at ${robotsUrl}.`));
|
|
946
|
+
}
|
|
947
|
+
const sitemap = results.get(sitemapUrl);
|
|
948
|
+
if (context.sitemap && sitemap && !sitemap.ok) {
|
|
949
|
+
found.push(finding("BLUME_AUDIT_SITEMAP_NOT_ACCESSIBLE", { url: "/sitemap.xml" }, `sitemap.xml is in the build but is not reachable at ${sitemapUrl}.`));
|
|
950
|
+
}
|
|
951
|
+
return found;
|
|
952
|
+
},
|
|
953
|
+
tier: "network"
|
|
954
|
+
};
|
|
955
|
+
var externalChecks = {
|
|
956
|
+
category: "network",
|
|
957
|
+
async run(context) {
|
|
958
|
+
const origin = siteOrigin(context.project.config.deployment.site);
|
|
959
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
960
|
+
const linkers = new Map;
|
|
961
|
+
for (const page of context.pages) {
|
|
962
|
+
for (const link of page.links) {
|
|
963
|
+
const resolved = resolveHref(page.url, link.href, origin, deployBase);
|
|
964
|
+
if (resolved.kind !== "external") {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const pages = linkers.get(resolved.url);
|
|
968
|
+
if (pages) {
|
|
969
|
+
if (!pages.includes(page)) {
|
|
970
|
+
pages.push(page);
|
|
971
|
+
}
|
|
972
|
+
} else {
|
|
973
|
+
linkers.set(resolved.url, [page]);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (linkers.size === 0) {
|
|
978
|
+
return [];
|
|
979
|
+
}
|
|
980
|
+
const results = await probeAll([...linkers.keys()]);
|
|
981
|
+
const found = [];
|
|
982
|
+
for (const [url, pages] of linkers) {
|
|
983
|
+
const result = results.get(url);
|
|
984
|
+
if (!result) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const site = pageSite(context, pages[0]);
|
|
988
|
+
const grade = gradeExternal(result);
|
|
989
|
+
if (grade) {
|
|
990
|
+
found.push({
|
|
991
|
+
...finding("BLUME_AUDIT_EXTERNAL_LINK_BROKEN", site, `${url} is unreachable (${grade.detail}), linked from ${pages.length} page(s).`),
|
|
992
|
+
severity: grade.severity
|
|
993
|
+
});
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (result.redirected && result.finalUrl && result.finalUrl !== url) {
|
|
997
|
+
found.push(finding("BLUME_AUDIT_EXTERNAL_LINK_REDIRECT", site, `${url} redirects to ${result.finalUrl}.`));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return found;
|
|
1001
|
+
},
|
|
1002
|
+
tier: "external"
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// src/audit/checks/og-image.ts
|
|
1006
|
+
import { readFile } from "node:fs/promises";
|
|
1007
|
+
import { join } from "pathe";
|
|
1008
|
+
|
|
1009
|
+
// src/audit/image-size.ts
|
|
1010
|
+
import sharp from "sharp";
|
|
1011
|
+
var imageSize = async (bytes) => {
|
|
1012
|
+
try {
|
|
1013
|
+
const { width, height } = await sharp(bytes).metadata();
|
|
1014
|
+
return width > 0 && height > 0 ? { height, width } : null;
|
|
1015
|
+
} catch {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
// src/audit/checks/og-image.ts
|
|
1021
|
+
var MIN_WIDTH = 600;
|
|
1022
|
+
var MIN_HEIGHT = 315;
|
|
1023
|
+
var ogImageChecks = {
|
|
1024
|
+
category: "social",
|
|
1025
|
+
async run(context) {
|
|
1026
|
+
const found = [];
|
|
1027
|
+
const sizes = new Map;
|
|
1028
|
+
const read = async (path) => {
|
|
1029
|
+
try {
|
|
1030
|
+
return imageSize(await readFile(join(context.staticDir, path)));
|
|
1031
|
+
} catch {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
const measure = (path) => {
|
|
1036
|
+
const cached = sizes.get(path);
|
|
1037
|
+
if (cached) {
|
|
1038
|
+
return cached;
|
|
1039
|
+
}
|
|
1040
|
+
const pending = read(path);
|
|
1041
|
+
sizes.set(path, pending);
|
|
1042
|
+
return pending;
|
|
1043
|
+
};
|
|
1044
|
+
const origin = siteOrigin(context.project.config.deployment.site);
|
|
1045
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
1046
|
+
const candidates = [];
|
|
1047
|
+
for (const page of context.pages) {
|
|
1048
|
+
const src = page.og["og:image"];
|
|
1049
|
+
if (!src) {
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
const resolved = resolveHref(page.url, src, origin, deployBase);
|
|
1053
|
+
if (resolved.kind === "external" || resolved.kind === "ignored") {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const { path } = resolved;
|
|
1057
|
+
if (!(context.files.has(path) || context.files.has(`${path}/index.html`))) {
|
|
1058
|
+
found.push(finding("BLUME_AUDIT_OG_IMAGE_BROKEN", pageSite(context, page, ["seo", "image"]), `og:image points at ${path}, which is not in the build.`));
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
candidates.push({ page, path });
|
|
1062
|
+
}
|
|
1063
|
+
const measured = await Promise.all(candidates.map(async (candidate) => ({
|
|
1064
|
+
...candidate,
|
|
1065
|
+
size: await measure(candidate.path)
|
|
1066
|
+
})));
|
|
1067
|
+
for (const { page, path, size } of measured) {
|
|
1068
|
+
if (size && (size.width < MIN_WIDTH || size.height < MIN_HEIGHT)) {
|
|
1069
|
+
found.push(finding("BLUME_AUDIT_OG_IMAGE_SMALL", pageSite(context, page, ["seo", "image"]), `og:image ${path} is ${size.width}×${size.height} — below the ${MIN_WIDTH}×${MIN_HEIGHT} floor for large social cards.`));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return found;
|
|
1073
|
+
},
|
|
1074
|
+
tier: "static"
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// src/audit/checks/redirects.ts
|
|
1078
|
+
var redirectChecks = {
|
|
1079
|
+
category: "redirects",
|
|
1080
|
+
run(context) {
|
|
1081
|
+
const found = [];
|
|
1082
|
+
const configFile = context.project.context.configFile ?? undefined;
|
|
1083
|
+
const site = { file: configFile, url: "/" };
|
|
1084
|
+
for (const redirect of context.redirects) {
|
|
1085
|
+
const from = normalizePath(redirect.from);
|
|
1086
|
+
if (context.byUrl.has(from)) {
|
|
1087
|
+
found.push(finding("BLUME_AUDIT_REDIRECT_SOURCE_IS_PAGE", { ...site, url: from }, `A redirect is configured from ${from}, but ${from} is also a real page — the redirect never fires.`));
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (redirect.outcome === "loop") {
|
|
1091
|
+
found.push(finding("BLUME_AUDIT_REDIRECT_LOOP", { ...site, url: from }, `Redirect loop: ${redirect.chain.join(" → ")}`));
|
|
1092
|
+
} else if (redirect.outcome === "broken") {
|
|
1093
|
+
found.push(finding("BLUME_AUDIT_REDIRECT_BROKEN", { ...site, url: from }, `Redirect from ${from} lands on ${redirect.chain.at(-1)}, which the build does not serve.`));
|
|
1094
|
+
} else if (redirect.outcome === "chain") {
|
|
1095
|
+
const hops = redirect.chain.length - 1;
|
|
1096
|
+
const severity = hops > context.thresholds.maxRedirectHops ? " (too long)" : "";
|
|
1097
|
+
found.push(finding("BLUME_AUDIT_REDIRECT_CHAIN", { ...site, url: from }, `Redirect from ${from} passes through ${hops} hops${severity}: ${redirect.chain.join(" → ")}`));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
for (const page of context.pages) {
|
|
1101
|
+
if (page.metaRefresh) {
|
|
1102
|
+
found.push(finding("BLUME_AUDIT_META_REFRESH", pageSite(context, page), `Page uses a meta refresh redirect ("${page.metaRefresh}").`));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return found;
|
|
1106
|
+
},
|
|
1107
|
+
tier: "static"
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
// src/audit/checks/robots.ts
|
|
1111
|
+
import robotsParser from "robots-parser";
|
|
1112
|
+
var MATCH_ORIGIN = "https://robots-audit.invalid";
|
|
1113
|
+
var robotsChecks = {
|
|
1114
|
+
category: "robots",
|
|
1115
|
+
run(context) {
|
|
1116
|
+
const { robots } = context;
|
|
1117
|
+
const { site } = context.project.config.deployment;
|
|
1118
|
+
if (!context.project.config.seo.robots) {
|
|
1119
|
+
return [];
|
|
1120
|
+
}
|
|
1121
|
+
if (!robots) {
|
|
1122
|
+
return [
|
|
1123
|
+
finding("BLUME_AUDIT_ROBOTS_MISSING", { url: "/robots.txt" }, "The build has no robots.txt.")
|
|
1124
|
+
];
|
|
1125
|
+
}
|
|
1126
|
+
const found = robots.invalid.map((line) => finding("BLUME_AUDIT_ROBOTS_INVALID", { file: robots.file, line: line.line, url: "/robots.txt" }, `robots.txt line ${line.line} is not a directive: "${line.text}"`));
|
|
1127
|
+
if (site && robots.sitemaps.length === 0) {
|
|
1128
|
+
found.push(finding("BLUME_AUDIT_ROBOTS_SITEMAP_MISSING", { file: robots.file, url: "/robots.txt" }, "robots.txt does not declare a Sitemap."));
|
|
1129
|
+
}
|
|
1130
|
+
const parser = robotsParser(`${MATCH_ORIGIN}/robots.txt`, robots.raw);
|
|
1131
|
+
const lines = robots.raw.split(/\r?\n/u);
|
|
1132
|
+
for (const loc of context.sitemap?.urls ?? []) {
|
|
1133
|
+
let pathname;
|
|
1134
|
+
try {
|
|
1135
|
+
({ pathname } = new URL(loc));
|
|
1136
|
+
} catch {
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const path = normalizePath(pathname);
|
|
1140
|
+
const url = `${MATCH_ORIGIN}${pathname}`;
|
|
1141
|
+
if (parser.isDisallowed(url, "*")) {
|
|
1142
|
+
const line = parser.getMatchingLineNumber(url, "*");
|
|
1143
|
+
const rule = line > 0 ? lines[line - 1]?.trim() : undefined;
|
|
1144
|
+
found.push(finding("BLUME_AUDIT_ROBOTS_DISALLOWS_INDEXABLE", { file: robots.file, url: path }, `robots.txt "${rule ?? "Disallow"}" blocks ${path}, which sitemap.xml advertises.`));
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return found;
|
|
1148
|
+
},
|
|
1149
|
+
tier: "static"
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
// src/audit/checks/sitemap.ts
|
|
1153
|
+
var MAX_SITEMAP_BYTES = 50 * 1024 * 1024;
|
|
1154
|
+
var MAX_SITEMAP_URLS = 50000;
|
|
1155
|
+
var LASTMOD_SLACK_MS = 24 * 60 * 60 * 1000;
|
|
1156
|
+
var ERROR_ROUTES2 = new Set(["/404", "/500"]);
|
|
1157
|
+
var sitemapPaths = (context, deployBase) => {
|
|
1158
|
+
const paths = new Map;
|
|
1159
|
+
for (const loc of context.sitemap?.urls ?? []) {
|
|
1160
|
+
try {
|
|
1161
|
+
paths.set(normalizePath(stripBasePath(deployBase, decodePath(new URL(loc).pathname))), loc);
|
|
1162
|
+
} catch {}
|
|
1163
|
+
}
|
|
1164
|
+
return paths;
|
|
1165
|
+
};
|
|
1166
|
+
var canonicalPath = (canonical, deployBase) => {
|
|
1167
|
+
try {
|
|
1168
|
+
return normalizePath(stripBasePath(deployBase, decodePath(new URL(canonical).pathname)));
|
|
1169
|
+
} catch {
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
var checkListedUrl = (context, loc, origin, file, deployBase) => {
|
|
1174
|
+
let parsed;
|
|
1175
|
+
try {
|
|
1176
|
+
parsed = new URL(loc);
|
|
1177
|
+
} catch {
|
|
1178
|
+
return [
|
|
1179
|
+
finding("BLUME_AUDIT_SITEMAP_INVALID", { file, url: "/sitemap.xml" }, `sitemap.xml lists "${loc}", which is not a valid absolute URL.`)
|
|
1180
|
+
];
|
|
1181
|
+
}
|
|
1182
|
+
if (origin && parsed.origin !== origin) {
|
|
1183
|
+
return [
|
|
1184
|
+
finding("BLUME_AUDIT_SITEMAP_OUT_OF_SCOPE", { file, url: loc }, `sitemap.xml lists ${loc}, which is on another origin.`)
|
|
1185
|
+
];
|
|
1186
|
+
}
|
|
1187
|
+
const path = normalizePath(stripBasePath(deployBase, decodePath(parsed.pathname)));
|
|
1188
|
+
const page = context.byUrl.get(path);
|
|
1189
|
+
if (!page) {
|
|
1190
|
+
const redirect = context.redirects.find((entry) => normalizePath(entry.from) === path);
|
|
1191
|
+
return [
|
|
1192
|
+
finding("BLUME_AUDIT_SITEMAP_BAD_URL", { file, url: loc }, redirect ? `sitemap.xml lists ${path}, which redirects to ${redirect.to}.` : `sitemap.xml lists ${path}, which the build does not serve.`)
|
|
1193
|
+
];
|
|
1194
|
+
}
|
|
1195
|
+
const found = [];
|
|
1196
|
+
if (!page.indexable) {
|
|
1197
|
+
found.push(finding("BLUME_AUDIT_NOINDEX_IN_SITEMAP", pageSite(context, page, ["noindex"]), `${path} is in the sitemap but declares robots "${page.robots}".`));
|
|
1198
|
+
}
|
|
1199
|
+
const canonical = page.canonical && canonicalPath(page.canonical, deployBase);
|
|
1200
|
+
if (canonical && canonical !== path) {
|
|
1201
|
+
found.push(finding("BLUME_AUDIT_NON_CANONICAL_IN_SITEMAP", pageSite(context, page, ["seo", "canonical"]), `${path} is in the sitemap but canonicalizes to ${canonical}.`));
|
|
1202
|
+
}
|
|
1203
|
+
return found;
|
|
1204
|
+
};
|
|
1205
|
+
var sitemapChecks = {
|
|
1206
|
+
category: "sitemap",
|
|
1207
|
+
run(context) {
|
|
1208
|
+
const { sitemap } = context;
|
|
1209
|
+
const { site } = context.project.config.deployment;
|
|
1210
|
+
if (!(site && context.project.config.seo.sitemap)) {
|
|
1211
|
+
return [];
|
|
1212
|
+
}
|
|
1213
|
+
if (!sitemap) {
|
|
1214
|
+
return [
|
|
1215
|
+
finding("BLUME_AUDIT_SITEMAP_INVALID", { url: "/sitemap.xml" }, "The build has no sitemap.xml.", "Set `seo.sitemap: true` and `deployment.site` in blume.config.ts.")
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1218
|
+
const found = [];
|
|
1219
|
+
if (sitemap.error) {
|
|
1220
|
+
found.push(finding("BLUME_AUDIT_SITEMAP_INVALID", { file: sitemap.file, url: "/sitemap.xml" }, `sitemap.xml is not a valid urlset: ${sitemap.error}`));
|
|
1221
|
+
return found;
|
|
1222
|
+
}
|
|
1223
|
+
if (sitemap.bytes > MAX_SITEMAP_BYTES || sitemap.urls.length > MAX_SITEMAP_URLS) {
|
|
1224
|
+
found.push(finding("BLUME_AUDIT_SITEMAP_TOO_LARGE", { file: sitemap.file, url: "/sitemap.xml" }, `sitemap.xml holds ${sitemap.urls.length} URLs in ${Math.round(sitemap.bytes / 1024 / 1024)} MB.`));
|
|
1225
|
+
}
|
|
1226
|
+
for (const [loc, lastmod] of sitemap.lastmod ?? []) {
|
|
1227
|
+
const time = Date.parse(lastmod);
|
|
1228
|
+
if (Number.isNaN(time)) {
|
|
1229
|
+
found.push(finding("BLUME_AUDIT_SITEMAP_LASTMOD_INVALID", { file: sitemap.file, url: loc }, `sitemap.xml gives ${loc} a lastmod of "${lastmod}", which is not a valid W3C date.`));
|
|
1230
|
+
} else if (time > Date.now() + LASTMOD_SLACK_MS) {
|
|
1231
|
+
found.push(finding("BLUME_AUDIT_SITEMAP_LASTMOD_INVALID", { file: sitemap.file, url: loc }, `sitemap.xml gives ${loc} a lastmod of ${lastmod}, which is in the future.`));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const origin = siteOrigin(site);
|
|
1235
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
1236
|
+
const listed = sitemapPaths(context, deployBase);
|
|
1237
|
+
for (const loc of sitemap.urls) {
|
|
1238
|
+
found.push(...checkListedUrl(context, loc, origin, sitemap.file, deployBase));
|
|
1239
|
+
}
|
|
1240
|
+
for (const page of context.pages) {
|
|
1241
|
+
if (!page.indexable || ERROR_ROUTES2.has(page.url) || listed.has(normalizePath(page.url))) {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
found.push(finding("BLUME_AUDIT_INDEXABLE_PAGE_NOT_IN_SITEMAP", pageSite(context, page), `${page.url} is built and indexable but is not listed in sitemap.xml.`));
|
|
1245
|
+
}
|
|
1246
|
+
return found;
|
|
1247
|
+
},
|
|
1248
|
+
tier: "static"
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// src/audit/checks/social.ts
|
|
1252
|
+
var OG_REQUIRED = ["og:title", "og:type", "og:description"];
|
|
1253
|
+
var ogRequired = (hasSite) => hasSite ? [...OG_REQUIRED, "og:url"] : OG_REQUIRED;
|
|
1254
|
+
var socialChecks = {
|
|
1255
|
+
category: "social",
|
|
1256
|
+
run(context) {
|
|
1257
|
+
const found = [];
|
|
1258
|
+
const hasSite = Boolean(context.project.config.deployment.site);
|
|
1259
|
+
const required = ogRequired(hasSite);
|
|
1260
|
+
for (const page of context.pages) {
|
|
1261
|
+
if (!page.indexable) {
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
const missing = required.filter((property) => !page.og[property]);
|
|
1265
|
+
if (missing.length > 0) {
|
|
1266
|
+
found.push(finding("BLUME_AUDIT_OG_INCOMPLETE", pageSite(context, page, ["description"]), missing.length === required.length ? "Page has no Open Graph tags." : `Open Graph is missing ${missing.join(", ")}.`));
|
|
1267
|
+
}
|
|
1268
|
+
if (hasSite && !page.og["og:image"]) {
|
|
1269
|
+
found.push(finding("BLUME_AUDIT_OG_IMAGE_MISSING", pageSite(context, page, ["seo", "image"]), "Page has no og:image — shares will render without a preview card."));
|
|
1270
|
+
}
|
|
1271
|
+
const ogUrl = page.og["og:url"];
|
|
1272
|
+
if (ogUrl && page.canonical && ogUrl !== page.canonical) {
|
|
1273
|
+
found.push(finding("BLUME_AUDIT_OG_URL_MISMATCH", pageSite(context, page), `og:url is ${ogUrl} but the canonical is ${page.canonical}.`));
|
|
1274
|
+
}
|
|
1275
|
+
if (!page.twitter["twitter:card"]) {
|
|
1276
|
+
found.push(finding("BLUME_AUDIT_TWITTER_CARD_INCOMPLETE", pageSite(context, page), "Page has no twitter:card — X will render a plain link, not a card."));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return found;
|
|
1280
|
+
},
|
|
1281
|
+
tier: "static"
|
|
1282
|
+
};
|
|
1283
|
+
var isJsonLdNode = (value) => typeof value === "object" && value !== null;
|
|
1284
|
+
var jsonLdProblems = (node) => {
|
|
1285
|
+
if (!isJsonLdNode(node)) {
|
|
1286
|
+
return ["it is not an object"];
|
|
1287
|
+
}
|
|
1288
|
+
const problems = [];
|
|
1289
|
+
if (!node["@context"]) {
|
|
1290
|
+
problems.push("@context");
|
|
1291
|
+
}
|
|
1292
|
+
const graph = node["@graph"];
|
|
1293
|
+
if (Array.isArray(graph)) {
|
|
1294
|
+
const untyped = graph.filter((entry) => !isJsonLdNode(entry) || !entry["@type"]).length;
|
|
1295
|
+
if (untyped > 0) {
|
|
1296
|
+
problems.push(`@type on ${untyped} of its ${graph.length} @graph nodes`);
|
|
1297
|
+
}
|
|
1298
|
+
} else if (!node["@type"]) {
|
|
1299
|
+
problems.push("@type");
|
|
1300
|
+
}
|
|
1301
|
+
return problems;
|
|
1302
|
+
};
|
|
1303
|
+
var structuredDataChecks = {
|
|
1304
|
+
category: "structured-data",
|
|
1305
|
+
run(context) {
|
|
1306
|
+
const found = [];
|
|
1307
|
+
for (const page of context.pages) {
|
|
1308
|
+
for (const error of page.jsonldErrors) {
|
|
1309
|
+
found.push(finding("BLUME_AUDIT_JSONLD_INVALID", pageSite(context, page), `A JSON-LD block failed to parse: ${error}`));
|
|
1310
|
+
}
|
|
1311
|
+
for (const node of page.jsonld) {
|
|
1312
|
+
const problems = jsonLdProblems(node);
|
|
1313
|
+
if (problems.length > 0) {
|
|
1314
|
+
found.push(finding("BLUME_AUDIT_JSONLD_INCOMPLETE", pageSite(context, page), `A JSON-LD block is missing ${problems.join(" and ")}.`));
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return found;
|
|
1319
|
+
},
|
|
1320
|
+
tier: "static"
|
|
1321
|
+
};
|
|
1322
|
+
var URL_STYLE = [
|
|
1323
|
+
{ name: "uppercase letters", test: /[A-Z]/u },
|
|
1324
|
+
{ name: "underscores", test: /_/u },
|
|
1325
|
+
{ name: "spaces", test: /%20| /u }
|
|
1326
|
+
];
|
|
1327
|
+
var DOTTED_HOST = /^\/\/[^/]*\./u;
|
|
1328
|
+
var doubledSlash = (href) => {
|
|
1329
|
+
if (href.startsWith("//")) {
|
|
1330
|
+
return !DOTTED_HOST.test(href);
|
|
1331
|
+
}
|
|
1332
|
+
if (!href.startsWith("/")) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
const [path] = href.split(/[?#]/u);
|
|
1336
|
+
return (path ?? "").includes("//");
|
|
1337
|
+
};
|
|
1338
|
+
var urlChecks = {
|
|
1339
|
+
category: "links",
|
|
1340
|
+
run(context) {
|
|
1341
|
+
const found = [];
|
|
1342
|
+
const doubled = new Map;
|
|
1343
|
+
for (const page of context.pages) {
|
|
1344
|
+
if (page.url.includes("//")) {
|
|
1345
|
+
found.push(finding("BLUME_AUDIT_DOUBLE_SLASH_URL", pageSite(context, page), `URL ${normalizePath(page.url)} contains a double slash.`));
|
|
1346
|
+
}
|
|
1347
|
+
for (const link of page.links) {
|
|
1348
|
+
const href = link.href.trim();
|
|
1349
|
+
if (doubledSlash(href) && !doubled.has(href)) {
|
|
1350
|
+
doubled.set(href, page);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const untidy = URL_STYLE.filter((style) => style.test.test(page.url));
|
|
1354
|
+
if (untidy.length > 0) {
|
|
1355
|
+
found.push(finding("BLUME_AUDIT_URL_STYLE", pageSite(context, page), `URL ${page.url} contains ${untidy.map((style) => style.name).join(" and ")}.`));
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
for (const [href, page] of doubled) {
|
|
1359
|
+
found.push(finding("BLUME_AUDIT_DOUBLE_SLASH_URL", pageSite(context, page), `Link to ${href} contains a double slash.`));
|
|
1360
|
+
}
|
|
1361
|
+
return found;
|
|
1362
|
+
},
|
|
1363
|
+
tier: "static"
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
// src/audit/crawl.ts
|
|
1367
|
+
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
1368
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1369
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
1370
|
+
import pMap from "p-map";
|
|
1371
|
+
import { join as join2, relative } from "pathe";
|
|
1372
|
+
import { glob } from "tinyglobby";
|
|
1373
|
+
|
|
1374
|
+
// src/audit/snapshot.ts
|
|
1375
|
+
import { createHash } from "node:crypto";
|
|
1376
|
+
|
|
1377
|
+
// src/audit/html.ts
|
|
1378
|
+
import { parse } from "node-html-parser";
|
|
1379
|
+
var parseHtml = (html) => parse(html, {
|
|
1380
|
+
blockTextElements: { noscript: true, pre: true, script: true, style: true },
|
|
1381
|
+
comment: false
|
|
1382
|
+
});
|
|
1383
|
+
var attr = (element, name) => {
|
|
1384
|
+
const value = element.getAttribute(name)?.trim();
|
|
1385
|
+
return value || null;
|
|
1386
|
+
};
|
|
1387
|
+
var metaContents = (document, selector) => document.querySelectorAll(selector).map((element) => element.getAttribute("content")?.trim() ?? "").filter((value) => value.length > 0);
|
|
1388
|
+
var visibleText = (element) => {
|
|
1389
|
+
const clone = parse(element.outerHTML, {
|
|
1390
|
+
blockTextElements: { noscript: true, pre: true, script: true, style: true },
|
|
1391
|
+
comment: false
|
|
1392
|
+
});
|
|
1393
|
+
for (const node of clone.querySelectorAll("script, style, pre, code")) {
|
|
1394
|
+
node.remove();
|
|
1395
|
+
}
|
|
1396
|
+
return clone.structuredText.replaceAll(/\s+/gu, " ").trim();
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// src/audit/snapshot.ts
|
|
1400
|
+
var CHROME = "nav, aside, header, footer";
|
|
1401
|
+
var countWords = (prose, lang) => {
|
|
1402
|
+
let segmenter;
|
|
1403
|
+
try {
|
|
1404
|
+
segmenter = new Intl.Segmenter(lang ?? undefined, { granularity: "word" });
|
|
1405
|
+
} catch {
|
|
1406
|
+
segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
1407
|
+
}
|
|
1408
|
+
let count = 0;
|
|
1409
|
+
for (const segment of segmenter.segment(prose)) {
|
|
1410
|
+
if (segment.isWordLike) {
|
|
1411
|
+
count += 1;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return count;
|
|
1415
|
+
};
|
|
1416
|
+
var CONTENT_ROOTS = ["main", "article", "body"];
|
|
1417
|
+
var contentRoot = (document) => {
|
|
1418
|
+
for (const selector of CONTENT_ROOTS) {
|
|
1419
|
+
const found = document.querySelector(selector);
|
|
1420
|
+
if (found) {
|
|
1421
|
+
return found;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return null;
|
|
1425
|
+
};
|
|
1426
|
+
var contentLinks = (document) => {
|
|
1427
|
+
const root = contentRoot(document);
|
|
1428
|
+
if (!root) {
|
|
1429
|
+
return new Set;
|
|
1430
|
+
}
|
|
1431
|
+
return new Set(root.querySelectorAll("a[href]").filter((anchor) => !anchor.closest(CHROME)));
|
|
1432
|
+
};
|
|
1433
|
+
var collectAssets = (document, selector, srcAttr) => document.querySelectorAll(selector).map((element) => ({
|
|
1434
|
+
alt: element.getAttribute("alt") ?? null,
|
|
1435
|
+
height: attr(element, "height"),
|
|
1436
|
+
src: element.getAttribute(srcAttr)?.trim() ?? "",
|
|
1437
|
+
width: attr(element, "width")
|
|
1438
|
+
})).filter((asset) => asset.src.length > 0);
|
|
1439
|
+
var collectJsonLd = (document) => {
|
|
1440
|
+
const jsonld = [];
|
|
1441
|
+
const jsonldErrors = [];
|
|
1442
|
+
for (const script of document.querySelectorAll('script[type="application/ld+json"]')) {
|
|
1443
|
+
try {
|
|
1444
|
+
jsonld.push(JSON.parse(script.rawText));
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
jsonldErrors.push(error instanceof Error ? error.message : String(error));
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return { jsonld, jsonldErrors };
|
|
1450
|
+
};
|
|
1451
|
+
var prefixedMeta = (document, keyAttr, prefix) => {
|
|
1452
|
+
const found = {};
|
|
1453
|
+
for (const element of document.querySelectorAll(`meta[${keyAttr}^="${prefix}"]`)) {
|
|
1454
|
+
const key = element.getAttribute(keyAttr)?.trim();
|
|
1455
|
+
const content = element.getAttribute("content")?.trim();
|
|
1456
|
+
if (key && content) {
|
|
1457
|
+
found[key] = content;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return found;
|
|
1461
|
+
};
|
|
1462
|
+
var buildSnapshot = (options) => {
|
|
1463
|
+
const { file, url, html, route } = options;
|
|
1464
|
+
const document = parseHtml(html);
|
|
1465
|
+
const inContent = contentLinks(document);
|
|
1466
|
+
const links = document.querySelectorAll("a[href]").map((anchor) => ({
|
|
1467
|
+
content: inContent.has(anchor),
|
|
1468
|
+
href: anchor.getAttribute("href")?.trim() ?? "",
|
|
1469
|
+
rel: attr(anchor, "rel"),
|
|
1470
|
+
text: anchor.text.trim()
|
|
1471
|
+
})).filter((link) => link.href.length > 0);
|
|
1472
|
+
const root = contentRoot(document);
|
|
1473
|
+
const prose = root ? visibleText(root) : "";
|
|
1474
|
+
const robots = document.querySelector('meta[name="robots"]');
|
|
1475
|
+
const lang = attr(document.querySelector("html") ?? document, "lang");
|
|
1476
|
+
const { jsonld, jsonldErrors } = collectJsonLd(document);
|
|
1477
|
+
return {
|
|
1478
|
+
bytes: Buffer.byteLength(html, "utf-8"),
|
|
1479
|
+
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute("href")?.trim() ?? null,
|
|
1480
|
+
contentHash: createHash("sha256").update(prose).digest("hex").slice(0, 16),
|
|
1481
|
+
descriptions: metaContents(document, 'meta[name="description"]'),
|
|
1482
|
+
file,
|
|
1483
|
+
headings: document.querySelectorAll("h1, h2, h3, h4, h5, h6").map((heading) => ({
|
|
1484
|
+
depth: Number(heading.tagName.slice(1)),
|
|
1485
|
+
text: heading.text.trim()
|
|
1486
|
+
})),
|
|
1487
|
+
hreflang: document.querySelectorAll("link[rel=alternate][hreflang]").map((link) => ({
|
|
1488
|
+
href: link.getAttribute("href")?.trim() ?? "",
|
|
1489
|
+
lang: link.getAttribute("hreflang")?.trim() ?? ""
|
|
1490
|
+
})).filter((alternate) => alternate.href && alternate.lang),
|
|
1491
|
+
ids: new Set(document.querySelectorAll("[id]").map((element) => element.getAttribute("id") ?? "").filter((id) => id.length > 0)),
|
|
1492
|
+
images: collectAssets(document, "img[src]", "src"),
|
|
1493
|
+
indexable: !robots?.getAttribute("content")?.includes("noindex"),
|
|
1494
|
+
jsonld,
|
|
1495
|
+
jsonldErrors,
|
|
1496
|
+
lang,
|
|
1497
|
+
links,
|
|
1498
|
+
metaRefresh: document.querySelector('meta[http-equiv="refresh" i]')?.getAttribute("content")?.trim() ?? null,
|
|
1499
|
+
og: prefixedMeta(document, "property", "og:"),
|
|
1500
|
+
robots: robots?.getAttribute("content")?.trim() ?? null,
|
|
1501
|
+
route,
|
|
1502
|
+
scripts: collectAssets(document, "script[src]", "src"),
|
|
1503
|
+
source: route?.sourcePath,
|
|
1504
|
+
styles: collectAssets(document, 'link[rel="stylesheet"][href]', "href"),
|
|
1505
|
+
titles: document.querySelectorAll("title").map((title) => title.text.trim()).filter((text) => text.length > 0),
|
|
1506
|
+
twitter: prefixedMeta(document, "name", "twitter:"),
|
|
1507
|
+
url,
|
|
1508
|
+
viewport: document.querySelector('meta[name="viewport"]')?.getAttribute("content")?.trim() ?? null,
|
|
1509
|
+
wordCount: countWords(prose, lang)
|
|
1510
|
+
};
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// src/audit/crawl.ts
|
|
1514
|
+
var EXAMPLES_PREFIX = `${examplesRouteBase("")}/`;
|
|
1515
|
+
var CRAWL_CONCURRENCY = 16;
|
|
1516
|
+
var fileToUrl = (staticDir, file) => {
|
|
1517
|
+
const rel = relative(staticDir, file).replaceAll("\\", "/");
|
|
1518
|
+
const path = rel.replace(/(?:^|\/)index\.html$/u, "").replace(/\.html$/u, "");
|
|
1519
|
+
return path === "" ? "/" : `/${path}`;
|
|
1520
|
+
};
|
|
1521
|
+
var indexFiles = async (staticDir) => {
|
|
1522
|
+
const found = await glob("**/*", { cwd: staticDir, dot: true });
|
|
1523
|
+
const sized = await pMap(found, async (file) => {
|
|
1524
|
+
const path = `/${file.replaceAll("\\", "/")}`;
|
|
1525
|
+
const info = await stat(join2(staticDir, file));
|
|
1526
|
+
return [path, info.size];
|
|
1527
|
+
}, { concurrency: CRAWL_CONCURRENCY });
|
|
1528
|
+
return new Map(sized);
|
|
1529
|
+
};
|
|
1530
|
+
var routeIndex = (manifest, basePath) => {
|
|
1531
|
+
const index = new Map;
|
|
1532
|
+
for (const route of manifest.routes) {
|
|
1533
|
+
index.set(route.path, route);
|
|
1534
|
+
const stripped = stripBasePath(basePath, route.path);
|
|
1535
|
+
if (!index.has(stripped)) {
|
|
1536
|
+
index.set(stripped, route);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return index;
|
|
1540
|
+
};
|
|
1541
|
+
var sitemapParser = new XMLParser({
|
|
1542
|
+
htmlEntities: true,
|
|
1543
|
+
ignoreAttributes: true,
|
|
1544
|
+
parseTagValue: false,
|
|
1545
|
+
removeNSPrefix: true
|
|
1546
|
+
});
|
|
1547
|
+
var isUrlsetElement = (value) => typeof value === "object" && value !== null;
|
|
1548
|
+
var isUrlEntry = (value) => typeof value === "object" && value !== null;
|
|
1549
|
+
var isText = (value) => typeof value === "string";
|
|
1550
|
+
var parseSitemap = (file, xml, bytes) => {
|
|
1551
|
+
const doc = { bytes, file, lastmod: new Map, urls: [] };
|
|
1552
|
+
let parsed;
|
|
1553
|
+
try {
|
|
1554
|
+
parsed = sitemapParser.parse(xml);
|
|
1555
|
+
} catch {
|
|
1556
|
+
doc.error = "no <urlset> element";
|
|
1557
|
+
return doc;
|
|
1558
|
+
}
|
|
1559
|
+
if (!Object.hasOwn(parsed, "urlset")) {
|
|
1560
|
+
doc.error = Object.hasOwn(parsed, "sitemapindex") ? "sitemap is an index, not a urlset" : "no <urlset> element";
|
|
1561
|
+
return doc;
|
|
1562
|
+
}
|
|
1563
|
+
const { urlset } = parsed;
|
|
1564
|
+
const entries = isUrlsetElement(urlset) ? [urlset.url].flat() : [];
|
|
1565
|
+
for (const entry of entries) {
|
|
1566
|
+
if (!isUrlEntry(entry)) {
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
const { loc, lastmod } = entry;
|
|
1570
|
+
const locText = isText(loc) ? loc.trim() : "";
|
|
1571
|
+
if (!locText) {
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
doc.urls.push(locText);
|
|
1575
|
+
if (isText(lastmod) && lastmod.trim() !== "") {
|
|
1576
|
+
doc.lastmod?.set(locText, lastmod.trim());
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return doc;
|
|
1580
|
+
};
|
|
1581
|
+
var parseLlms = (file, text) => {
|
|
1582
|
+
const entries = [];
|
|
1583
|
+
const collect = (node) => {
|
|
1584
|
+
if ((node.type === "link" || node.type === "image" || node.type === "definition") && node.url) {
|
|
1585
|
+
entries.push({ line: node.position?.start.line ?? 1, url: node.url });
|
|
1586
|
+
}
|
|
1587
|
+
if ("children" in node) {
|
|
1588
|
+
for (const child of node.children) {
|
|
1589
|
+
collect(child);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
collect(fromMarkdown(text));
|
|
1594
|
+
return { entries, file };
|
|
1595
|
+
};
|
|
1596
|
+
var ROBOTS_DIRECTIVE = /^(?<field>[a-z-]+)\s*:\s*(?<value>.*)$/iu;
|
|
1597
|
+
var parseRobots = (file, text) => {
|
|
1598
|
+
const doc = { file, invalid: [], raw: text, sitemaps: [] };
|
|
1599
|
+
for (const [index, raw] of text.split(/\r?\n/u).entries()) {
|
|
1600
|
+
const line = raw.trim();
|
|
1601
|
+
if (line === "" || line.startsWith("#")) {
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
const match = ROBOTS_DIRECTIVE.exec(line);
|
|
1605
|
+
if (!match) {
|
|
1606
|
+
doc.invalid.push({ line: index + 1, text: line });
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
const field = (match.groups?.field ?? "").toLowerCase();
|
|
1610
|
+
const value = (match.groups?.value ?? "").trim();
|
|
1611
|
+
if (field === "sitemap" && value) {
|
|
1612
|
+
doc.sitemaps.push(value);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return doc;
|
|
1616
|
+
};
|
|
1617
|
+
var isDocument = (html) => /<head[\s>]/iu.test(html);
|
|
1618
|
+
var readIfPresent = async (file) => {
|
|
1619
|
+
try {
|
|
1620
|
+
return await readFile2(file, "utf-8");
|
|
1621
|
+
} catch {
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
var crawlStaticDir = async (options) => {
|
|
1626
|
+
const { staticDir, manifest, basePath } = options;
|
|
1627
|
+
const routes = routeIndex(manifest, basePath);
|
|
1628
|
+
const htmlFiles = await glob("**/*.html", { absolute: true, cwd: staticDir });
|
|
1629
|
+
const snapshots = await pMap(htmlFiles.toSorted(), async (file) => {
|
|
1630
|
+
const url = fileToUrl(staticDir, file);
|
|
1631
|
+
if (stripBasePath(basePath, url).startsWith(EXAMPLES_PREFIX)) {
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
const html = await readFile2(file, "utf-8");
|
|
1635
|
+
if (!isDocument(html)) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
return buildSnapshot({
|
|
1639
|
+
file,
|
|
1640
|
+
html,
|
|
1641
|
+
route: routes.get(url) ?? routes.get(stripBasePath(basePath, url)),
|
|
1642
|
+
url
|
|
1643
|
+
});
|
|
1644
|
+
}, { concurrency: CRAWL_CONCURRENCY });
|
|
1645
|
+
const pages = snapshots.filter((page) => page !== null);
|
|
1646
|
+
const sitemapFile = join2(staticDir, "sitemap.xml");
|
|
1647
|
+
const sitemapXml = await readIfPresent(sitemapFile);
|
|
1648
|
+
const robotsFile = join2(staticDir, "robots.txt");
|
|
1649
|
+
const robotsTxt = await readIfPresent(robotsFile);
|
|
1650
|
+
const llmsFile = join2(staticDir, "llms.txt");
|
|
1651
|
+
const llmsText = await readIfPresent(llmsFile);
|
|
1652
|
+
const files = await indexFiles(staticDir);
|
|
1653
|
+
return {
|
|
1654
|
+
files,
|
|
1655
|
+
llms: llmsText === null ? null : parseLlms(llmsFile, llmsText),
|
|
1656
|
+
pages,
|
|
1657
|
+
robots: robotsTxt === null ? null : parseRobots(robotsFile, robotsTxt),
|
|
1658
|
+
sitemap: sitemapXml === null ? null : parseSitemap(sitemapFile, sitemapXml, files.get("/sitemap.xml") ?? Buffer.byteLength(sitemapXml, "utf-8"))
|
|
1659
|
+
};
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
// src/audit/redirects.ts
|
|
1663
|
+
var pathOnly = (value) => {
|
|
1664
|
+
const cut = value.search(/[?#]/u);
|
|
1665
|
+
return cut === -1 ? value : value.slice(0, cut);
|
|
1666
|
+
};
|
|
1667
|
+
var resolveRedirects = (redirects, served) => {
|
|
1668
|
+
const byFrom = new Map;
|
|
1669
|
+
for (const redirect of redirects) {
|
|
1670
|
+
byFrom.set(normalizePath(redirect.from), redirect);
|
|
1671
|
+
}
|
|
1672
|
+
return redirects.map((redirect) => {
|
|
1673
|
+
const from = normalizePath(redirect.from);
|
|
1674
|
+
const chain = [from];
|
|
1675
|
+
const seen = new Set([from]);
|
|
1676
|
+
let current = redirect.to;
|
|
1677
|
+
for (;; ) {
|
|
1678
|
+
if (/^https?:\/\//iu.test(current)) {
|
|
1679
|
+
chain.push(current);
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1682
|
+
const next = normalizePath(pathOnly(current));
|
|
1683
|
+
if (seen.has(next)) {
|
|
1684
|
+
chain.push(next);
|
|
1685
|
+
return {
|
|
1686
|
+
...redirect,
|
|
1687
|
+
chain,
|
|
1688
|
+
outcome: "loop"
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
chain.push(next);
|
|
1692
|
+
seen.add(next);
|
|
1693
|
+
const hop = byFrom.get(next);
|
|
1694
|
+
if (!hop) {
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
current = hop.to;
|
|
1698
|
+
}
|
|
1699
|
+
const destination = chain.at(-1) ?? from;
|
|
1700
|
+
const external = /^https?:\/\//iu.test(destination);
|
|
1701
|
+
if (!(external || served(destination))) {
|
|
1702
|
+
return { ...redirect, chain, outcome: "broken" };
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
...redirect,
|
|
1706
|
+
chain,
|
|
1707
|
+
outcome: chain.length > 2 ? "chain" : "ok"
|
|
1708
|
+
};
|
|
1709
|
+
});
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
// src/audit/run.ts
|
|
1713
|
+
var MODULES = [
|
|
1714
|
+
contentChecks,
|
|
1715
|
+
duplicateChecks,
|
|
1716
|
+
indexabilityChecks,
|
|
1717
|
+
linkChecks,
|
|
1718
|
+
redirectChecks,
|
|
1719
|
+
socialChecks,
|
|
1720
|
+
ogImageChecks,
|
|
1721
|
+
i18nChecks,
|
|
1722
|
+
assetChecks,
|
|
1723
|
+
sitemapChecks,
|
|
1724
|
+
robotsChecks,
|
|
1725
|
+
llmsChecks,
|
|
1726
|
+
structuredDataChecks,
|
|
1727
|
+
urlChecks,
|
|
1728
|
+
networkChecks,
|
|
1729
|
+
dnsAidChecks,
|
|
1730
|
+
externalChecks
|
|
1731
|
+
];
|
|
1732
|
+
|
|
1733
|
+
class NoBuildError extends Error {
|
|
1734
|
+
staticDir;
|
|
1735
|
+
constructor(staticDir) {
|
|
1736
|
+
super(`No build found at ${staticDir}.`);
|
|
1737
|
+
this.name = "NoBuildError";
|
|
1738
|
+
this.staticDir = staticDir;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
var READ_CONCURRENCY = 16;
|
|
1742
|
+
var readSources = async (pages) => {
|
|
1743
|
+
const paths = [
|
|
1744
|
+
...new Set(pages.flatMap((page) => page.source ? [page.source] : []))
|
|
1745
|
+
];
|
|
1746
|
+
const entries = await pMap2(paths, async (path) => {
|
|
1747
|
+
try {
|
|
1748
|
+
return [path, await readFile3(path, "utf-8")];
|
|
1749
|
+
} catch {
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
}, { concurrency: READ_CONCURRENCY });
|
|
1753
|
+
return new Map(entries.filter((entry) => entry !== null));
|
|
1754
|
+
};
|
|
1755
|
+
var matches = (id, terms) => {
|
|
1756
|
+
const meta = CHECKS.find((check) => check.id === id);
|
|
1757
|
+
const short = id.replace("BLUME_AUDIT_", "").toLowerCase();
|
|
1758
|
+
return terms.some((raw) => {
|
|
1759
|
+
const term = raw.trim().toLowerCase();
|
|
1760
|
+
return term === short || term === id.toLowerCase() || term === meta?.category;
|
|
1761
|
+
});
|
|
1762
|
+
};
|
|
1763
|
+
var runAudit = async (options) => {
|
|
1764
|
+
const { project } = options;
|
|
1765
|
+
const staticDir = deployStaticDir(project.config, project.context);
|
|
1766
|
+
const basePath = normalizeBasePath(project.config.basePath);
|
|
1767
|
+
const crawl = await crawlStaticDir({
|
|
1768
|
+
basePath,
|
|
1769
|
+
manifest: project.manifest,
|
|
1770
|
+
staticDir
|
|
1771
|
+
});
|
|
1772
|
+
if (crawl.pages.length === 0) {
|
|
1773
|
+
throw new NoBuildError(staticDir);
|
|
1774
|
+
}
|
|
1775
|
+
const origin = options.origin ?? null;
|
|
1776
|
+
const byUrl = new Map(crawl.pages.map((page) => [page.url, page]));
|
|
1777
|
+
const context = {
|
|
1778
|
+
byUrl,
|
|
1779
|
+
files: crawl.files,
|
|
1780
|
+
graph: buildGraph(crawl.pages, siteOrigin(project.config.deployment.site), normalizeBasePath(project.config.deployment.base)),
|
|
1781
|
+
llms: crawl.llms,
|
|
1782
|
+
origin,
|
|
1783
|
+
pages: crawl.pages,
|
|
1784
|
+
project,
|
|
1785
|
+
redirects: resolveRedirects(project.config.redirects.map((redirect) => ({
|
|
1786
|
+
...redirect,
|
|
1787
|
+
from: withBasePath(basePath, redirect.from),
|
|
1788
|
+
to: withBasePath(basePath, redirect.to)
|
|
1789
|
+
})), (path) => isServed({ byUrl, files: crawl.files, project }, path)),
|
|
1790
|
+
robots: crawl.robots,
|
|
1791
|
+
sitemap: crawl.sitemap,
|
|
1792
|
+
sources: await readSources(crawl.pages),
|
|
1793
|
+
staticDir,
|
|
1794
|
+
thresholds: DEFAULT_THRESHOLDS
|
|
1795
|
+
};
|
|
1796
|
+
const tiers = {
|
|
1797
|
+
external: Boolean(options.external),
|
|
1798
|
+
network: origin !== null,
|
|
1799
|
+
static: true
|
|
1800
|
+
};
|
|
1801
|
+
const results = await Promise.all(MODULES.filter((module) => tiers[module.tier]).map((module) => module.run(context)));
|
|
1802
|
+
let diagnostics = results.flat();
|
|
1803
|
+
if (options.only?.length) {
|
|
1804
|
+
diagnostics = diagnostics.filter((d) => matches(d.code, options.only ?? []));
|
|
1805
|
+
}
|
|
1806
|
+
if (options.skip?.length) {
|
|
1807
|
+
diagnostics = diagnostics.filter((d) => !matches(d.code, options.skip ?? []));
|
|
1808
|
+
}
|
|
1809
|
+
return {
|
|
1810
|
+
diagnostics,
|
|
1811
|
+
origin,
|
|
1812
|
+
pages: crawl.pages.length,
|
|
1813
|
+
staticDir,
|
|
1814
|
+
tiers
|
|
1815
|
+
};
|
|
1816
|
+
};
|
|
1817
|
+
|
|
1818
|
+
// src/cli/commands/audit.ts
|
|
1819
|
+
var SEVERITIES = ["error", "warning", "info"];
|
|
1820
|
+
var failingSeverities = (gate) => new Set(SEVERITIES.slice(0, SEVERITIES.indexOf(gate) + 1));
|
|
1821
|
+
var splitTerms = (value) => value ? value.split(",").map((term) => term.trim()).filter(Boolean) : [];
|
|
1822
|
+
var shouldFail = (result, gate) => {
|
|
1823
|
+
const failing = failingSeverities(gate);
|
|
1824
|
+
return result.diagnostics.some((d) => failing.has(d.severity));
|
|
1825
|
+
};
|
|
1826
|
+
var launchAgentCode = async (agent, prompt) => {
|
|
1827
|
+
const cli = AGENTS[agent];
|
|
1828
|
+
try {
|
|
1829
|
+
return await launchAgent(cli.bin, prompt);
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
if (error?.code !== "ENOENT") {
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
logger.error(`${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`);
|
|
1835
|
+
return process.exit(1);
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
var auditCommand = defineCommand({
|
|
1839
|
+
args: {
|
|
1840
|
+
claude: {
|
|
1841
|
+
description: "Hand the findings to Claude Code to fix interactively.",
|
|
1842
|
+
type: "boolean"
|
|
1843
|
+
},
|
|
1844
|
+
codex: {
|
|
1845
|
+
description: "Hand the findings to Codex to fix interactively.",
|
|
1846
|
+
type: "boolean"
|
|
1847
|
+
},
|
|
1848
|
+
external: {
|
|
1849
|
+
description: "Probe outbound links over the network.",
|
|
1850
|
+
type: "boolean"
|
|
1851
|
+
},
|
|
1852
|
+
"fail-on": {
|
|
1853
|
+
description: "Exit non-zero at this severity or above: error | warning | info. Defaults to error.",
|
|
1854
|
+
type: "string"
|
|
1855
|
+
},
|
|
1856
|
+
json: {
|
|
1857
|
+
description: "Emit the report as JSON on stdout (for CI/editors).",
|
|
1858
|
+
type: "boolean"
|
|
1859
|
+
},
|
|
1860
|
+
"list-checks": {
|
|
1861
|
+
description: "Print every check the audit can report, then exit.",
|
|
1862
|
+
type: "boolean"
|
|
1863
|
+
},
|
|
1864
|
+
only: {
|
|
1865
|
+
description: "Only report these checks or categories (comma-separated).",
|
|
1866
|
+
type: "string"
|
|
1867
|
+
},
|
|
1868
|
+
skip: {
|
|
1869
|
+
description: "Suppress these checks or categories (comma-separated).",
|
|
1870
|
+
type: "string"
|
|
1871
|
+
},
|
|
1872
|
+
strict: {
|
|
1873
|
+
description: "Alias for --fail-on warning.",
|
|
1874
|
+
type: "boolean"
|
|
1875
|
+
},
|
|
1876
|
+
url: {
|
|
1877
|
+
description: "Also probe a live deployment (e.g. https://docs.example.com) for status codes, headers, and redirects.",
|
|
1878
|
+
type: "string"
|
|
1879
|
+
},
|
|
1880
|
+
verbose: {
|
|
1881
|
+
description: "List every affected page instead of the first few.",
|
|
1882
|
+
type: "boolean"
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
meta: commandMeta.audit,
|
|
1886
|
+
async run({ args }) {
|
|
1887
|
+
if (args["list-checks"]) {
|
|
1888
|
+
process.stdout.write(formatCatalog());
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const root = process.cwd();
|
|
1892
|
+
const gateInput = args["fail-on"] ?? (args.strict ? "warning" : "error");
|
|
1893
|
+
const gate = SEVERITIES.find((severity) => severity === gateInput);
|
|
1894
|
+
if (gate === undefined) {
|
|
1895
|
+
logger.error(`Invalid --fail-on "${gateInput}" (use ${SEVERITIES.join(" | ")}).`);
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
const agents = Object.keys(AGENTS).filter((kind) => args[kind]);
|
|
1899
|
+
if (agents.length > 1) {
|
|
1900
|
+
logger.error("Pass at most one of --claude or --codex.");
|
|
1901
|
+
process.exit(1);
|
|
1902
|
+
}
|
|
1903
|
+
const [agent] = agents;
|
|
1904
|
+
if (agent && args.json) {
|
|
1905
|
+
logger.error(`--json and --${agent} are mutually exclusive.`);
|
|
1906
|
+
process.exit(1);
|
|
1907
|
+
}
|
|
1908
|
+
let result;
|
|
1909
|
+
try {
|
|
1910
|
+
const project = await scanProject(root, { mode: "build" });
|
|
1911
|
+
result = await runAudit({
|
|
1912
|
+
external: args.external,
|
|
1913
|
+
only: splitTerms(args.only),
|
|
1914
|
+
origin: args.url,
|
|
1915
|
+
project,
|
|
1916
|
+
skip: splitTerms(args.skip)
|
|
1917
|
+
});
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
if (error instanceof NoBuildError) {
|
|
1920
|
+
logger.error(`${error.message} Run \`blume build\` first.`);
|
|
1921
|
+
process.exit(1);
|
|
1922
|
+
}
|
|
1923
|
+
if (error instanceof BlumeError) {
|
|
1924
|
+
logger.error(error.diagnostic.message);
|
|
1925
|
+
process.exit(1);
|
|
1926
|
+
}
|
|
1927
|
+
reportInternalError(error);
|
|
1928
|
+
process.exit(1);
|
|
1929
|
+
}
|
|
1930
|
+
if (agent) {
|
|
1931
|
+
process.stderr.write(formatReport(result, root, { verbose: args.verbose }));
|
|
1932
|
+
if (result.diagnostics.length === 0) {
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const cli = AGENTS[agent];
|
|
1936
|
+
const report = await writeAgentReport(result, root);
|
|
1937
|
+
const count = result.diagnostics.length;
|
|
1938
|
+
process.stderr.write(` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…
|
|
1939
|
+
|
|
1940
|
+
`);
|
|
1941
|
+
const code = await launchAgentCode(agent, fixPrompt(report));
|
|
1942
|
+
if (code !== 0) {
|
|
1943
|
+
process.exit(code);
|
|
1944
|
+
}
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
if (args.json) {
|
|
1948
|
+
process.stdout.write(reportJson(result, root));
|
|
1949
|
+
if (shouldFail(result, gate)) {
|
|
1950
|
+
await flushStdout();
|
|
1951
|
+
process.exit(1);
|
|
1952
|
+
}
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
process.stderr.write(formatReport(result, root, { verbose: args.verbose }));
|
|
1956
|
+
if (shouldFail(result, gate)) {
|
|
1957
|
+
process.exit(1);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
export {
|
|
1962
|
+
auditCommand,
|
|
1963
|
+
shouldFail
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
//# debugId=B282B1370257AC4E64756E2164756E21
|
|
1967
|
+
//# sourceMappingURL=chunk-x1vrdjyk.js.map
|