blume 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/cli/index.js +13255 -10232
- package/dist/cli/index.js.map +91 -60
- package/dist/types/core/config-input.d.ts +61 -1
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +131 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +13 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +21 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +50 -19
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +65 -22
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +66 -1
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +86 -3
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +13 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { relative } from "pathe";
|
|
2
|
+
|
|
3
|
+
import { countBySeverity } from "../core/diagnostics.ts";
|
|
4
|
+
import type { Diagnostic, DiagnosticSeverity } from "../core/types.ts";
|
|
5
|
+
import { CHECKS, checkMeta } from "./catalog.ts";
|
|
6
|
+
import type { CheckId } from "./catalog.ts";
|
|
7
|
+
import type { AuditResult } from "./run.ts";
|
|
8
|
+
import type { AuditCategory, AuditTier } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const ESC = String.fromCodePoint(27);
|
|
11
|
+
const COLORS = {
|
|
12
|
+
bold: `${ESC}[1m`,
|
|
13
|
+
cyan: `${ESC}[36m`,
|
|
14
|
+
dim: `${ESC}[2m`,
|
|
15
|
+
green: `${ESC}[32m`,
|
|
16
|
+
red: `${ESC}[31m`,
|
|
17
|
+
reset: `${ESC}[0m`,
|
|
18
|
+
yellow: `${ESC}[33m`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SEVERITY_COLOR: Record<DiagnosticSeverity, string> = {
|
|
22
|
+
error: COLORS.red,
|
|
23
|
+
info: `${ESC}[34m`,
|
|
24
|
+
warning: COLORS.yellow,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const GLYPH: Record<DiagnosticSeverity, string> = {
|
|
28
|
+
error: "✖",
|
|
29
|
+
info: "ℹ",
|
|
30
|
+
warning: "⚠",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** How many affected pages to list before collapsing the rest. */
|
|
34
|
+
const PREVIEW = 3;
|
|
35
|
+
|
|
36
|
+
/** The tier a category belongs to, for the "skipped" line. */
|
|
37
|
+
const TIER_FLAG: Partial<Record<AuditTier, string>> = {
|
|
38
|
+
external: "--external",
|
|
39
|
+
network: "--url <origin>",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface CheckRollup {
|
|
43
|
+
id: CheckId;
|
|
44
|
+
count: number;
|
|
45
|
+
severity: DiagnosticSeverity;
|
|
46
|
+
category: AuditCategory;
|
|
47
|
+
title: string;
|
|
48
|
+
findings: Diagnostic[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Group findings by check. This is the difference between a report people read
|
|
53
|
+
* and one they close: 214 pages × 6 findings is an unreadable wall, but "Meta
|
|
54
|
+
* description missing — 12 pages" is a to-do list.
|
|
55
|
+
*/
|
|
56
|
+
export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
|
|
57
|
+
const groups = new Map<string, Diagnostic[]>();
|
|
58
|
+
for (const diagnostic of diagnostics) {
|
|
59
|
+
const group = groups.get(diagnostic.code);
|
|
60
|
+
if (group) {
|
|
61
|
+
group.push(diagnostic);
|
|
62
|
+
} else {
|
|
63
|
+
groups.set(diagnostic.code, [diagnostic]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const order: Record<DiagnosticSeverity, number> = {
|
|
68
|
+
error: 0,
|
|
69
|
+
info: 2,
|
|
70
|
+
warning: 1,
|
|
71
|
+
};
|
|
72
|
+
const checks = [...groups.entries()].map(([id, findings]) => {
|
|
73
|
+
const { category, severity, title } = checkMeta(id as CheckId);
|
|
74
|
+
return {
|
|
75
|
+
category,
|
|
76
|
+
count: findings.length,
|
|
77
|
+
findings,
|
|
78
|
+
id: id as CheckId,
|
|
79
|
+
severity,
|
|
80
|
+
title,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Rank each category by the worst thing in it, so the categories that need
|
|
85
|
+
// attention lead. Sorting on severity alone would interleave the categories
|
|
86
|
+
// and print "content" three separate times.
|
|
87
|
+
const worst = new Map<AuditCategory, number>();
|
|
88
|
+
for (const check of checks) {
|
|
89
|
+
const rank = order[check.severity];
|
|
90
|
+
worst.set(
|
|
91
|
+
check.category,
|
|
92
|
+
Math.min(worst.get(check.category) ?? rank, rank)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return checks.toSorted(
|
|
97
|
+
(a, b) =>
|
|
98
|
+
(worst.get(a.category) ?? 0) - (worst.get(b.category) ?? 0) ||
|
|
99
|
+
a.category.localeCompare(b.category) ||
|
|
100
|
+
order[a.severity] - order[b.severity] ||
|
|
101
|
+
b.count - a.count
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Categories that had no findings but were never run, and the flag that runs them. */
|
|
106
|
+
const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
|
|
107
|
+
(Object.keys(TIER_FLAG) as AuditTier[])
|
|
108
|
+
.filter((tier) => !tiers[tier])
|
|
109
|
+
.map((tier) => {
|
|
110
|
+
const label = CHECKS.filter((check) => check.tier === tier).length;
|
|
111
|
+
return ` ${COLORS.dim}⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)${COLORS.reset}`;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** How many checks actually ran, i.e. those whose tier was enabled. */
|
|
115
|
+
const activeChecks = (tiers: Record<AuditTier, boolean>): number =>
|
|
116
|
+
CHECKS.filter((check) => tiers[check.tier]).length;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Individual checks performed: every rule that ran, against every page crawled.
|
|
120
|
+
* The headline number — it's what makes "39 warnings" legible as a proportion
|
|
121
|
+
* rather than a bare count.
|
|
122
|
+
*/
|
|
123
|
+
export const auditCount = (result: AuditResult): number =>
|
|
124
|
+
activeChecks(result.tiers) * result.pages;
|
|
125
|
+
|
|
126
|
+
const summaryLine = (
|
|
127
|
+
counts: Record<DiagnosticSeverity, number>,
|
|
128
|
+
audits: number
|
|
129
|
+
): string =>
|
|
130
|
+
[
|
|
131
|
+
`${audits.toLocaleString("en-US")} audit${audits === 1 ? "" : "s"}`,
|
|
132
|
+
`${counts.error} error${counts.error === 1 ? "" : "s"}`,
|
|
133
|
+
`${counts.warning} warning${counts.warning === 1 ? "" : "s"}`,
|
|
134
|
+
`${counts.info} note${counts.info === 1 ? "" : "s"}`,
|
|
135
|
+
].join(" · ");
|
|
136
|
+
|
|
137
|
+
/** One affected page: the URL, and the source file that fixes it. */
|
|
138
|
+
const findingLine = (diagnostic: Diagnostic, root: string): string => {
|
|
139
|
+
const url = diagnostic.url ?? "";
|
|
140
|
+
const source = diagnostic.file
|
|
141
|
+
? `${COLORS.dim}${relative(root, diagnostic.file)}${
|
|
142
|
+
diagnostic.line === undefined ? "" : `:${diagnostic.line}`
|
|
143
|
+
}${COLORS.reset}`
|
|
144
|
+
: "";
|
|
145
|
+
// padEnd alone yields no gap once the URL reaches the column width.
|
|
146
|
+
return ` ${url.padEnd(34)} ${source}`.trimEnd();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render the audit as a report grouped by check, with each check's affected
|
|
151
|
+
* pages, the source file to edit, and the fix.
|
|
152
|
+
*/
|
|
153
|
+
export const formatReport = (
|
|
154
|
+
result: AuditResult,
|
|
155
|
+
root: string,
|
|
156
|
+
options: { verbose?: boolean } = {}
|
|
157
|
+
): string => {
|
|
158
|
+
const counts = countBySeverity(result.diagnostics);
|
|
159
|
+
const groups = rollup(result.diagnostics);
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
|
|
162
|
+
const where = result.origin
|
|
163
|
+
? `${relative(root, result.staticDir) || "dist"} + ${result.origin}`
|
|
164
|
+
: `${relative(root, result.staticDir) || "dist"} · offline`;
|
|
165
|
+
lines.push(
|
|
166
|
+
"",
|
|
167
|
+
` ${COLORS.bold}blume audit${COLORS.reset} ${COLORS.dim}${result.pages} pages · ${where}${COLORS.reset}`,
|
|
168
|
+
` ${summaryLine(counts, auditCount(result))}`,
|
|
169
|
+
""
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (groups.length === 0) {
|
|
173
|
+
lines.push(` ${COLORS.green}✔ No issues found.${COLORS.reset}`, "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let category: AuditCategory | null = null;
|
|
177
|
+
for (const group of groups) {
|
|
178
|
+
const { category: next } = group;
|
|
179
|
+
if (next !== category) {
|
|
180
|
+
category = next;
|
|
181
|
+
lines.push(` ${COLORS.bold}${category}${COLORS.reset}`, "");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const color = SEVERITY_COLOR[group.severity];
|
|
185
|
+
const pages = `${group.count} page${group.count === 1 ? "" : "s"}`;
|
|
186
|
+
lines.push(
|
|
187
|
+
` ${color}${GLYPH[group.severity]} ${group.title}${COLORS.reset} ${COLORS.dim}${pages}${COLORS.reset}`
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const shown = options.verbose
|
|
191
|
+
? group.findings
|
|
192
|
+
: group.findings.slice(0, PREVIEW);
|
|
193
|
+
for (const diagnostic of shown) {
|
|
194
|
+
lines.push(findingLine(diagnostic, root));
|
|
195
|
+
}
|
|
196
|
+
const hidden = group.count - shown.length;
|
|
197
|
+
if (hidden > 0) {
|
|
198
|
+
lines.push(
|
|
199
|
+
` ${COLORS.dim}… and ${hidden} more (--verbose)${COLORS.reset}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Every finding in a group shares the catalog's fix unless it overrode it,
|
|
204
|
+
// so showing the first one's is showing the group's.
|
|
205
|
+
const [first] = group.findings;
|
|
206
|
+
const fix = first?.suggestion;
|
|
207
|
+
if (fix) {
|
|
208
|
+
lines.push(` ${COLORS.cyan}fix: ${fix}${COLORS.reset}`);
|
|
209
|
+
}
|
|
210
|
+
lines.push("");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const skipped = skippedTiers(result.tiers);
|
|
214
|
+
if (skipped.length > 0) {
|
|
215
|
+
lines.push(...skipped, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The machine-readable report. The existing `diagnostics` + `summary` shape is
|
|
223
|
+
* preserved exactly — anything already parsing `blume validate --json` keeps
|
|
224
|
+
* working — with the audit-specific rollup added alongside it.
|
|
225
|
+
*/
|
|
226
|
+
export const reportJson = (result: AuditResult, root: string): string => {
|
|
227
|
+
const diagnostics = result.diagnostics.map((diagnostic) =>
|
|
228
|
+
diagnostic.file
|
|
229
|
+
? { ...diagnostic, file: relative(root, diagnostic.file) }
|
|
230
|
+
: diagnostic
|
|
231
|
+
);
|
|
232
|
+
return `${JSON.stringify(
|
|
233
|
+
{
|
|
234
|
+
audit: {
|
|
235
|
+
/** Checks run × pages crawled — the total number of individual audits. */
|
|
236
|
+
audits: auditCount(result),
|
|
237
|
+
checks: rollup(result.diagnostics).map((group) => ({
|
|
238
|
+
category: group.category,
|
|
239
|
+
count: group.count,
|
|
240
|
+
id: group.id,
|
|
241
|
+
severity: group.severity,
|
|
242
|
+
})),
|
|
243
|
+
origin: result.origin,
|
|
244
|
+
pages: result.pages,
|
|
245
|
+
staticDir: relative(root, result.staticDir),
|
|
246
|
+
tiers: result.tiers,
|
|
247
|
+
},
|
|
248
|
+
diagnostics,
|
|
249
|
+
summary: countBySeverity(result.diagnostics),
|
|
250
|
+
},
|
|
251
|
+
null,
|
|
252
|
+
2
|
|
253
|
+
)}\n`;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** `--list-checks`: the catalog, which is also the docs' source of truth. */
|
|
257
|
+
export const formatCatalog = (): string => {
|
|
258
|
+
const lines: string[] = [""];
|
|
259
|
+
let category: AuditCategory | null = null;
|
|
260
|
+
for (const check of [...CHECKS].toSorted((a, b) =>
|
|
261
|
+
a.category.localeCompare(b.category)
|
|
262
|
+
)) {
|
|
263
|
+
const { category: next } = check;
|
|
264
|
+
if (next !== category) {
|
|
265
|
+
category = next;
|
|
266
|
+
lines.push(` ${COLORS.bold}${category}${COLORS.reset}`);
|
|
267
|
+
}
|
|
268
|
+
const tier =
|
|
269
|
+
check.tier === "static"
|
|
270
|
+
? ""
|
|
271
|
+
: ` ${COLORS.dim}[${check.tier}]${COLORS.reset}`;
|
|
272
|
+
lines.push(
|
|
273
|
+
` ${SEVERITY_COLOR[check.severity]}${GLYPH[check.severity]}${COLORS.reset} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${COLORS.dim}${check.title}${COLORS.reset}${tier}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
lines.push("", ` ${CHECKS.length} checks.`, "");
|
|
277
|
+
return lines.join("\n");
|
|
278
|
+
};
|
package/src/audit/run.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
4
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
|
+
import type { Diagnostic } from "../core/types.ts";
|
|
6
|
+
import { deployStaticDir } from "../deploy/adapter-output.ts";
|
|
7
|
+
import { CHECKS } from "./catalog.ts";
|
|
8
|
+
import type { CheckId } from "./catalog.ts";
|
|
9
|
+
import { assetChecks } from "./checks/assets.ts";
|
|
10
|
+
import { contentChecks } from "./checks/content.ts";
|
|
11
|
+
import { duplicateChecks } from "./checks/duplicates.ts";
|
|
12
|
+
import { i18nChecks } from "./checks/i18n.ts";
|
|
13
|
+
import { indexabilityChecks } from "./checks/indexability.ts";
|
|
14
|
+
import { linkChecks } from "./checks/links.ts";
|
|
15
|
+
import { llmsChecks } from "./checks/llms.ts";
|
|
16
|
+
import { externalChecks, networkChecks } from "./checks/network.ts";
|
|
17
|
+
import { ogImageChecks } from "./checks/og-image.ts";
|
|
18
|
+
import { redirectChecks } from "./checks/redirects.ts";
|
|
19
|
+
import { robotsChecks } from "./checks/robots.ts";
|
|
20
|
+
import { sitemapChecks } from "./checks/sitemap.ts";
|
|
21
|
+
import {
|
|
22
|
+
socialChecks,
|
|
23
|
+
structuredDataChecks,
|
|
24
|
+
urlChecks,
|
|
25
|
+
} from "./checks/social.ts";
|
|
26
|
+
import { crawlStaticDir } from "./crawl.ts";
|
|
27
|
+
import { buildGraph } from "./graph.ts";
|
|
28
|
+
import { resolveRedirects } from "./redirects.ts";
|
|
29
|
+
import { DEFAULT_THRESHOLDS } from "./types.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AuditContext,
|
|
32
|
+
AuditTier,
|
|
33
|
+
CheckModule,
|
|
34
|
+
PageSnapshot,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import { normalizePath, siteOrigin } from "./url.ts";
|
|
37
|
+
|
|
38
|
+
const MODULES: CheckModule[] = [
|
|
39
|
+
contentChecks,
|
|
40
|
+
duplicateChecks,
|
|
41
|
+
indexabilityChecks,
|
|
42
|
+
linkChecks,
|
|
43
|
+
redirectChecks,
|
|
44
|
+
socialChecks,
|
|
45
|
+
ogImageChecks,
|
|
46
|
+
i18nChecks,
|
|
47
|
+
assetChecks,
|
|
48
|
+
sitemapChecks,
|
|
49
|
+
robotsChecks,
|
|
50
|
+
llmsChecks,
|
|
51
|
+
structuredDataChecks,
|
|
52
|
+
urlChecks,
|
|
53
|
+
networkChecks,
|
|
54
|
+
externalChecks,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export interface AuditOptions {
|
|
58
|
+
project: BlumeProject;
|
|
59
|
+
/** Origin to probe for the network tier (`--url`). */
|
|
60
|
+
origin?: string;
|
|
61
|
+
/** Probe outbound links (`--external`). */
|
|
62
|
+
external?: boolean;
|
|
63
|
+
/** Only report these check ids or categories. */
|
|
64
|
+
only?: string[];
|
|
65
|
+
/** Suppress these check ids or categories. */
|
|
66
|
+
skip?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuditResult {
|
|
70
|
+
diagnostics: Diagnostic[];
|
|
71
|
+
staticDir: string;
|
|
72
|
+
pages: number;
|
|
73
|
+
origin: string | null;
|
|
74
|
+
/** Which tiers actually ran. A skipped tier is reported, never hidden. */
|
|
75
|
+
tiers: Record<AuditTier, boolean>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Thrown when there's no build to audit. */
|
|
79
|
+
export class NoBuildError extends Error {
|
|
80
|
+
readonly staticDir: string;
|
|
81
|
+
|
|
82
|
+
constructor(staticDir: string) {
|
|
83
|
+
super(`No build found at ${staticDir}.`);
|
|
84
|
+
this.name = "NoBuildError";
|
|
85
|
+
this.staticDir = staticDir;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read every page's source file once, so findings can cite front matter lines. */
|
|
90
|
+
const readSources = async (
|
|
91
|
+
pages: PageSnapshot[]
|
|
92
|
+
): Promise<Map<string, string>> => {
|
|
93
|
+
const paths = [
|
|
94
|
+
...new Set(pages.flatMap((page) => (page.source ? [page.source] : []))),
|
|
95
|
+
];
|
|
96
|
+
const entries = await Promise.all(
|
|
97
|
+
paths.map(async (path) => {
|
|
98
|
+
try {
|
|
99
|
+
return [path, await readFile(path, "utf-8")] as const;
|
|
100
|
+
} catch {
|
|
101
|
+
// A staged (non-filesystem) source may not exist on disk. The finding
|
|
102
|
+
// still names the URL; it just can't cite a line.
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
return new Map(entries.filter((entry) => entry !== null));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Does a check id or its category match one of the user's `--only`/`--skip` terms? */
|
|
111
|
+
const matches = (id: CheckId, terms: string[]): boolean => {
|
|
112
|
+
const meta = CHECKS.find((check) => check.id === id);
|
|
113
|
+
const short = id.replace("BLUME_AUDIT_", "").toLowerCase();
|
|
114
|
+
return terms.some((raw) => {
|
|
115
|
+
const term = raw.trim().toLowerCase();
|
|
116
|
+
return (
|
|
117
|
+
term === short || term === id.toLowerCase() || term === meta?.category
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Audit a built site. */
|
|
123
|
+
export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
124
|
+
const { project } = options;
|
|
125
|
+
const staticDir = deployStaticDir(project.config, project.context);
|
|
126
|
+
|
|
127
|
+
const crawl = await crawlStaticDir({
|
|
128
|
+
basePath: normalizeBasePath(project.config.basePath),
|
|
129
|
+
manifest: project.manifest,
|
|
130
|
+
staticDir,
|
|
131
|
+
});
|
|
132
|
+
if (crawl.pages.length === 0) {
|
|
133
|
+
throw new NoBuildError(staticDir);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const origin = options.origin ?? null;
|
|
137
|
+
const byUrl = new Map(crawl.pages.map((page) => [page.url, page]));
|
|
138
|
+
const context: AuditContext = {
|
|
139
|
+
byUrl,
|
|
140
|
+
files: crawl.files,
|
|
141
|
+
graph: buildGraph(
|
|
142
|
+
crawl.pages,
|
|
143
|
+
siteOrigin(project.config.deployment.site),
|
|
144
|
+
normalizeBasePath(project.config.deployment.base)
|
|
145
|
+
),
|
|
146
|
+
llms: crawl.llms,
|
|
147
|
+
origin,
|
|
148
|
+
pages: crawl.pages,
|
|
149
|
+
project,
|
|
150
|
+
redirects: resolveRedirects(
|
|
151
|
+
project.config.redirects,
|
|
152
|
+
// Pages and static files both: a redirect may legitimately land on a
|
|
153
|
+
// served asset (`/old-whitepaper` -> `/files/whitepaper.pdf`).
|
|
154
|
+
new Set(
|
|
155
|
+
[...byUrl.keys(), ...crawl.files.keys()].map((path) =>
|
|
156
|
+
normalizePath(path)
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
robots: crawl.robots,
|
|
161
|
+
sitemap: crawl.sitemap,
|
|
162
|
+
sources: await readSources(crawl.pages),
|
|
163
|
+
staticDir,
|
|
164
|
+
thresholds: DEFAULT_THRESHOLDS,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const tiers: Record<AuditTier, boolean> = {
|
|
168
|
+
external: Boolean(options.external),
|
|
169
|
+
network: origin !== null,
|
|
170
|
+
static: true,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const results = await Promise.all(
|
|
174
|
+
MODULES.filter((module) => tiers[module.tier]).map((module) =>
|
|
175
|
+
module.run(context)
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
let diagnostics = results.flat();
|
|
180
|
+
if (options.only?.length) {
|
|
181
|
+
diagnostics = diagnostics.filter((d) =>
|
|
182
|
+
matches(d.code as CheckId, options.only ?? [])
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (options.skip?.length) {
|
|
186
|
+
diagnostics = diagnostics.filter(
|
|
187
|
+
(d) => !matches(d.code as CheckId, options.skip ?? [])
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
diagnostics,
|
|
193
|
+
origin,
|
|
194
|
+
pages: crawl.pages.length,
|
|
195
|
+
staticDir,
|
|
196
|
+
tiers,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { RouteManifestEntry } from "../core/types.ts";
|
|
4
|
+
import { attr, metaContents, parseHtml, visibleText } from "./html.ts";
|
|
5
|
+
import type { HtmlDocument } from "./html.ts";
|
|
6
|
+
import type { PageSnapshot, SnapshotAsset, SnapshotLink } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/** Site chrome: links here are navigation, not editorial. */
|
|
9
|
+
const CHROME = "nav, aside, header, footer";
|
|
10
|
+
|
|
11
|
+
/** Where a page's prose lives, in preference order. */
|
|
12
|
+
const CONTENT_ROOTS = ["main", "article", "body"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The elements holding the page's prose. Falls back through `main` → `article`
|
|
16
|
+
* → `body` so a custom `.astro` page with no semantic landmark still yields a
|
|
17
|
+
* word count and a content hash instead of silently measuring as empty.
|
|
18
|
+
*/
|
|
19
|
+
const contentRoot = (document: HtmlDocument) => {
|
|
20
|
+
for (const selector of CONTENT_ROOTS) {
|
|
21
|
+
const found = document.querySelector(selector);
|
|
22
|
+
if (found) {
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `<a>` elements that sit in the page's prose rather than its chrome.
|
|
31
|
+
*
|
|
32
|
+
* This split is what makes the link graph mean anything. Blume renders a sidebar
|
|
33
|
+
* linking every nav page from every page, so a graph that treats a sidebar link
|
|
34
|
+
* the same as a body link finds that every page has hundreds of inbound links
|
|
35
|
+
* and no page is ever an orphan.
|
|
36
|
+
*/
|
|
37
|
+
const contentLinks = (document: HtmlDocument): Set<unknown> => {
|
|
38
|
+
const root = contentRoot(document);
|
|
39
|
+
if (!root) {
|
|
40
|
+
return new Set();
|
|
41
|
+
}
|
|
42
|
+
return new Set(
|
|
43
|
+
root.querySelectorAll("a[href]").filter((anchor) => !anchor.closest(CHROME))
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const collectAssets = (
|
|
48
|
+
document: HtmlDocument,
|
|
49
|
+
selector: string,
|
|
50
|
+
srcAttr: string
|
|
51
|
+
): SnapshotAsset[] =>
|
|
52
|
+
document
|
|
53
|
+
.querySelectorAll(selector)
|
|
54
|
+
.map((element) => ({
|
|
55
|
+
alt: element.getAttribute("alt") ?? null,
|
|
56
|
+
height: attr(element, "height"),
|
|
57
|
+
src: element.getAttribute(srcAttr)?.trim() ?? "",
|
|
58
|
+
width: attr(element, "width"),
|
|
59
|
+
}))
|
|
60
|
+
.filter((asset) => asset.src.length > 0);
|
|
61
|
+
|
|
62
|
+
/** Parse each JSON-LD block, keeping the parse failures rather than dropping them. */
|
|
63
|
+
const collectJsonLd = (
|
|
64
|
+
document: HtmlDocument
|
|
65
|
+
): { jsonld: unknown[]; jsonldErrors: string[] } => {
|
|
66
|
+
const jsonld: unknown[] = [];
|
|
67
|
+
const jsonldErrors: string[] = [];
|
|
68
|
+
for (const script of document.querySelectorAll(
|
|
69
|
+
'script[type="application/ld+json"]'
|
|
70
|
+
)) {
|
|
71
|
+
try {
|
|
72
|
+
jsonld.push(JSON.parse(script.rawText));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
jsonldErrors.push(error instanceof Error ? error.message : String(error));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { jsonld, jsonldErrors };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** `meta` tags whose key lives in `property` (Open Graph) or `name` (X). */
|
|
81
|
+
const prefixedMeta = (
|
|
82
|
+
document: HtmlDocument,
|
|
83
|
+
keyAttr: "property" | "name",
|
|
84
|
+
prefix: string
|
|
85
|
+
): Record<string, string> => {
|
|
86
|
+
const found: Record<string, string> = {};
|
|
87
|
+
for (const element of document.querySelectorAll(
|
|
88
|
+
`meta[${keyAttr}^="${prefix}"]`
|
|
89
|
+
)) {
|
|
90
|
+
const key = element.getAttribute(keyAttr)?.trim();
|
|
91
|
+
const content = element.getAttribute("content")?.trim();
|
|
92
|
+
if (key && content) {
|
|
93
|
+
found[key] = content;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reduce one built HTML page to everything the checks read. Parsed once — every
|
|
101
|
+
* check works off this record, so a page is never re-parsed per check.
|
|
102
|
+
*/
|
|
103
|
+
export const buildSnapshot = (options: {
|
|
104
|
+
file: string;
|
|
105
|
+
url: string;
|
|
106
|
+
html: string;
|
|
107
|
+
route?: RouteManifestEntry;
|
|
108
|
+
}): PageSnapshot => {
|
|
109
|
+
const { file, url, html, route } = options;
|
|
110
|
+
const document = parseHtml(html);
|
|
111
|
+
|
|
112
|
+
const inContent = contentLinks(document);
|
|
113
|
+
const links: SnapshotLink[] = document
|
|
114
|
+
.querySelectorAll("a[href]")
|
|
115
|
+
.map((anchor) => ({
|
|
116
|
+
content: inContent.has(anchor),
|
|
117
|
+
href: anchor.getAttribute("href")?.trim() ?? "",
|
|
118
|
+
rel: attr(anchor, "rel"),
|
|
119
|
+
text: anchor.text.trim(),
|
|
120
|
+
}))
|
|
121
|
+
.filter((link) => link.href.length > 0);
|
|
122
|
+
|
|
123
|
+
const root = contentRoot(document);
|
|
124
|
+
const prose = root ? visibleText(root) : "";
|
|
125
|
+
const robots = document.querySelector('meta[name="robots"]');
|
|
126
|
+
const { jsonld, jsonldErrors } = collectJsonLd(document);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
bytes: Buffer.byteLength(html, "utf-8"),
|
|
130
|
+
canonical:
|
|
131
|
+
document
|
|
132
|
+
.querySelector('link[rel="canonical"]')
|
|
133
|
+
?.getAttribute("href")
|
|
134
|
+
?.trim() ?? null,
|
|
135
|
+
contentHash: createHash("sha256").update(prose).digest("hex").slice(0, 16),
|
|
136
|
+
descriptions: metaContents(document, 'meta[name="description"]'),
|
|
137
|
+
file,
|
|
138
|
+
headings: document
|
|
139
|
+
.querySelectorAll("h1, h2, h3, h4, h5, h6")
|
|
140
|
+
.map((heading) => ({
|
|
141
|
+
depth: Number(heading.tagName.slice(1)),
|
|
142
|
+
text: heading.text.trim(),
|
|
143
|
+
})),
|
|
144
|
+
hreflang: document
|
|
145
|
+
.querySelectorAll("link[rel=alternate][hreflang]")
|
|
146
|
+
.map((link) => ({
|
|
147
|
+
href: link.getAttribute("href")?.trim() ?? "",
|
|
148
|
+
lang: link.getAttribute("hreflang")?.trim() ?? "",
|
|
149
|
+
}))
|
|
150
|
+
.filter((alternate) => alternate.href && alternate.lang),
|
|
151
|
+
ids: new Set(
|
|
152
|
+
document
|
|
153
|
+
.querySelectorAll("[id]")
|
|
154
|
+
.map((element) => element.getAttribute("id") ?? "")
|
|
155
|
+
.filter((id) => id.length > 0)
|
|
156
|
+
),
|
|
157
|
+
images: collectAssets(document, "img[src]", "src"),
|
|
158
|
+
// A page is indexable unless it says otherwise. Blume only ever emits
|
|
159
|
+
// `noindex` (never `nofollow`), but an ejected layout could emit either.
|
|
160
|
+
indexable: !robots?.getAttribute("content")?.includes("noindex"),
|
|
161
|
+
jsonld,
|
|
162
|
+
jsonldErrors,
|
|
163
|
+
lang: attr(document.querySelector("html") ?? document, "lang"),
|
|
164
|
+
links,
|
|
165
|
+
metaRefresh:
|
|
166
|
+
document
|
|
167
|
+
.querySelector('meta[http-equiv="refresh" i]')
|
|
168
|
+
?.getAttribute("content")
|
|
169
|
+
?.trim() ?? null,
|
|
170
|
+
og: prefixedMeta(document, "property", "og:"),
|
|
171
|
+
robots: robots?.getAttribute("content")?.trim() ?? null,
|
|
172
|
+
route,
|
|
173
|
+
scripts: collectAssets(document, "script[src]", "src"),
|
|
174
|
+
source: route?.sourcePath,
|
|
175
|
+
styles: collectAssets(document, 'link[rel="stylesheet"][href]', "href"),
|
|
176
|
+
titles: document
|
|
177
|
+
.querySelectorAll("title")
|
|
178
|
+
.map((title) => title.text.trim())
|
|
179
|
+
.filter((text) => text.length > 0),
|
|
180
|
+
twitter: prefixedMeta(document, "name", "twitter:"),
|
|
181
|
+
url,
|
|
182
|
+
viewport:
|
|
183
|
+
document
|
|
184
|
+
.querySelector('meta[name="viewport"]')
|
|
185
|
+
?.getAttribute("content")
|
|
186
|
+
?.trim() ?? null,
|
|
187
|
+
wordCount: prose ? prose.split(/\s+/u).length : 0,
|
|
188
|
+
};
|
|
189
|
+
};
|