@staticforge/checker 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +28 -0
- package/dist/cache.js.map +1 -0
- package/dist/check.d.ts +79 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +336 -0
- package/dist/check.js.map +1 -0
- package/dist/headless.d.ts +25 -0
- package/dist/headless.d.ts.map +1 -0
- package/dist/headless.js +209 -0
- package/dist/headless.js.map +1 -0
- package/dist/html-validate.d.ts +4 -0
- package/dist/html-validate.d.ts.map +1 -0
- package/dist/html-validate.js +103 -0
- package/dist/html-validate.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/output-rules.d.ts +25 -0
- package/dist/output-rules.d.ts.map +1 -0
- package/dist/output-rules.js +265 -0
- package/dist/output-rules.js.map +1 -0
- package/dist/report/index.d.ts +5 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +5 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/junit.d.ts +8 -0
- package/dist/report/junit.d.ts.map +1 -0
- package/dist/report/junit.js +51 -0
- package/dist/report/junit.js.map +1 -0
- package/dist/report/pretty.d.ts +9 -0
- package/dist/report/pretty.d.ts.map +1 -0
- package/dist/report/pretty.js +92 -0
- package/dist/report/pretty.js.map +1 -0
- package/dist/report/sarif.d.ts +14 -0
- package/dist/report/sarif.d.ts.map +1 -0
- package/dist/report/sarif.js +65 -0
- package/dist/report/sarif.js.map +1 -0
- package/dist/report/schema.d.ts +3 -0
- package/dist/report/schema.d.ts.map +1 -0
- package/dist/report/schema.js +57 -0
- package/dist/report/schema.js.map +1 -0
- package/dist/source-rules.d.ts +24 -0
- package/dist/source-rules.d.ts.map +1 -0
- package/dist/source-rules.js +327 -0
- package/dist/source-rules.js.map +1 -0
- package/dist/tasks.d.ts +36 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +30 -0
- package/dist/tasks.js.map +1 -0
- package/dist/text.d.ts +32 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +55 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +62 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const ANSI = {
|
|
2
|
+
error: '\x1b[31m',
|
|
3
|
+
warning: '\x1b[33m',
|
|
4
|
+
info: '\x1b[36m',
|
|
5
|
+
dim: '\x1b[90m',
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
};
|
|
8
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
9
|
+
/**
|
|
10
|
+
* Human-readable report, grouped by file. A problem that appears at the same
|
|
11
|
+
* place on several pages (usually the theme) is listed once, with its pages.
|
|
12
|
+
*/
|
|
13
|
+
export function formatPretty(result, options = {}) {
|
|
14
|
+
const paint = (key, text) => options.color ? `${ANSI[key]}${text}${ANSI.reset}` : text;
|
|
15
|
+
// Output and rendered findings have no line; group the identical ones across pages.
|
|
16
|
+
const repeated = new Map();
|
|
17
|
+
for (const d of result.diagnostics) {
|
|
18
|
+
if (d.line !== undefined)
|
|
19
|
+
continue;
|
|
20
|
+
const key = [d.ruleId, d.severity, d.message, d.selector ?? ''].join('\u0000');
|
|
21
|
+
repeated.set(key, [...(repeated.get(key) ?? []), d]);
|
|
22
|
+
}
|
|
23
|
+
const shared = [...repeated.values()].filter((group) => group.length > 1);
|
|
24
|
+
const sharedSet = new Set(shared.flat());
|
|
25
|
+
const byFile = new Map();
|
|
26
|
+
for (const d of result.diagnostics) {
|
|
27
|
+
if (sharedSet.has(d))
|
|
28
|
+
continue;
|
|
29
|
+
const key = d.file ?? d.url ?? '(site)';
|
|
30
|
+
byFile.set(key, [...(byFile.get(key) ?? []), d]);
|
|
31
|
+
}
|
|
32
|
+
const line = (d, location) => {
|
|
33
|
+
const wcag = d.wcag !== undefined ? paint('dim', ` WCAG ${d.wcag}`) : '';
|
|
34
|
+
const fixable = d.fixable ? paint('dim', ' (fixable)') : '';
|
|
35
|
+
const out = [
|
|
36
|
+
` ${location.padEnd(7)} ${paint(d.severity, d.severity.padEnd(7))} ${d.message}${wcag}${fixable} ${paint('dim', d.ruleId)}`,
|
|
37
|
+
];
|
|
38
|
+
const where = [
|
|
39
|
+
d.file !== undefined && d.url !== undefined && d.line === undefined
|
|
40
|
+
? `on ${d.url}`
|
|
41
|
+
: undefined,
|
|
42
|
+
d.selector !== undefined ? `at ${d.selector}` : undefined,
|
|
43
|
+
].filter((s) => s !== undefined);
|
|
44
|
+
if (where.length > 0)
|
|
45
|
+
out.push(paint('dim', ` ${where.join(' ')}`));
|
|
46
|
+
if (d.hint !== undefined)
|
|
47
|
+
out.push(paint('dim', ` ${d.hint}`));
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
const lines = [];
|
|
51
|
+
for (const [file, diagnostics] of byFile) {
|
|
52
|
+
lines.push(file);
|
|
53
|
+
for (const d of diagnostics) {
|
|
54
|
+
lines.push(...line(d, d.line !== undefined ? `${d.line}:${d.column ?? 1}` : ''));
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
if (shared.length > 0) {
|
|
59
|
+
lines.push('On several pages (likely the theme or a shared template)');
|
|
60
|
+
for (const group of shared) {
|
|
61
|
+
const first = group[0];
|
|
62
|
+
const urls = group.map((d) => d.url ?? d.file ?? '?');
|
|
63
|
+
const shown = urls.slice(0, 3).join(', ') + (urls.length > 3 ? `, +${urls.length - 3} more` : '');
|
|
64
|
+
lines.push(...line({ ...first, url: undefined, file: undefined }, ''));
|
|
65
|
+
lines.push(paint('dim', ` on ${plural(group.length, 'page')}: ${shown}`));
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
const { error, warning, info } = result.summary;
|
|
70
|
+
const fixable = result.diagnostics.filter((d) => d.fixable).length;
|
|
71
|
+
const rendered = result.rendered === 'ran'
|
|
72
|
+
? 'with rendered checks'
|
|
73
|
+
: result.rendered === 'skipped'
|
|
74
|
+
? 'rendered checks skipped'
|
|
75
|
+
: 'static checks only';
|
|
76
|
+
const mark = result.ok ? paint('info', '✔') : paint('error', '✖');
|
|
77
|
+
let summary = `${mark} ${plural(error, 'error')}, ${plural(warning, 'warning')}, ${info} info ` +
|
|
78
|
+
`in ${plural(result.pages, 'page')} (${rendered}, ${result.durationMs}ms)`;
|
|
79
|
+
if (fixable > 0 && result.fixes === undefined) {
|
|
80
|
+
summary += `\n ${plural(fixable, 'problem')} can be fixed with \`sforge check --fix\``;
|
|
81
|
+
}
|
|
82
|
+
if (result.fixes !== undefined) {
|
|
83
|
+
const files = result.fixes.files;
|
|
84
|
+
const edits = files.reduce((n, f) => n + f.edits, 0);
|
|
85
|
+
summary += result.fixes.applied
|
|
86
|
+
? `\n Fixed ${plural(edits, 'problem')} in ${plural(files.length, 'file')}: ${files.map((f) => f.file).join(', ') || 'none'}`
|
|
87
|
+
: `\n Would fix ${plural(edits, 'problem')} in ${plural(files.length, 'file')} (dry run)`;
|
|
88
|
+
}
|
|
89
|
+
lines.push(summary);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=pretty.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pretty.js","sourceRoot":"","sources":["../../src/report/pretty.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CheckResult } from '../check.js';
|
|
2
|
+
export interface SarifOptions {
|
|
3
|
+
toolVersion: string;
|
|
4
|
+
/**
|
|
5
|
+
* Prefix for file URIs, so they are relative to the repository root as
|
|
6
|
+
* GitHub code scanning expects (e.g. "site/" when the project is in site/).
|
|
7
|
+
*/
|
|
8
|
+
uriPrefix?: string;
|
|
9
|
+
/** Location for problems without a source file, e.g. the generated 404 page. */
|
|
10
|
+
fallbackFile: string;
|
|
11
|
+
}
|
|
12
|
+
/** SARIF 2.1.0 log for GitHub code scanning and other SARIF viewers. */
|
|
13
|
+
export declare function formatSarif(result: CheckResult, options: SarifOptions): Record<string, unknown>;
|
|
14
|
+
//# sourceMappingURL=sarif.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sarif.d.ts","sourceRoot":"","sources":["../../src/report/sarif.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,YAAY,EAAE,MAAM,CAAC;CACtB;AAID,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA4D/F"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const LEVEL = { error: 'error', warning: 'warning', info: 'note' };
|
|
3
|
+
/** SARIF 2.1.0 log for GitHub code scanning and other SARIF viewers. */
|
|
4
|
+
export function formatSarif(result, options) {
|
|
5
|
+
const prefix = options.uriPrefix ?? '';
|
|
6
|
+
const rules = new Map();
|
|
7
|
+
for (const d of result.diagnostics) {
|
|
8
|
+
if (!rules.has(d.ruleId))
|
|
9
|
+
rules.set(d.ruleId, { index: rules.size, diagnostic: d });
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
13
|
+
version: '2.1.0',
|
|
14
|
+
runs: [
|
|
15
|
+
{
|
|
16
|
+
tool: {
|
|
17
|
+
driver: {
|
|
18
|
+
name: 'staticforge',
|
|
19
|
+
version: options.toolVersion,
|
|
20
|
+
rules: [...rules].map(([id, { diagnostic: d }]) => ({
|
|
21
|
+
id,
|
|
22
|
+
shortDescription: { text: id },
|
|
23
|
+
...(d.helpUrl !== undefined && { helpUri: d.helpUrl }),
|
|
24
|
+
properties: {
|
|
25
|
+
tags: ['accessibility', ...(d.wcag !== undefined ? [`WCAG ${d.wcag}`] : [])],
|
|
26
|
+
},
|
|
27
|
+
})),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
results: result.diagnostics.map((d) => {
|
|
31
|
+
const where = [
|
|
32
|
+
d.url !== undefined ? `on ${d.url}` : undefined,
|
|
33
|
+
d.selector !== undefined ? `at ${d.selector}` : undefined,
|
|
34
|
+
].filter((s) => s !== undefined);
|
|
35
|
+
const text = [
|
|
36
|
+
d.message + (where.length > 0 ? ` (${where.join(' ')})` : ''),
|
|
37
|
+
d.hint,
|
|
38
|
+
].filter((s) => s !== undefined);
|
|
39
|
+
const fingerprint = createHash('sha256')
|
|
40
|
+
.update([d.ruleId, d.file, d.url, d.selector, d.message].join('\u0000'))
|
|
41
|
+
.digest('hex');
|
|
42
|
+
return {
|
|
43
|
+
ruleId: d.ruleId,
|
|
44
|
+
ruleIndex: rules.get(d.ruleId)?.index ?? 0,
|
|
45
|
+
level: LEVEL[d.severity],
|
|
46
|
+
message: { text: text.join('\n') },
|
|
47
|
+
locations: [
|
|
48
|
+
{
|
|
49
|
+
physicalLocation: {
|
|
50
|
+
artifactLocation: { uri: prefix + (d.file ?? options.fallbackFile) },
|
|
51
|
+
region: {
|
|
52
|
+
startLine: d.line ?? 1,
|
|
53
|
+
...(d.column !== undefined && { startColumn: d.column }),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
partialFingerprints: { 'staticforge/v1': fingerprint },
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=sarif.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sarif.js","sourceRoot":"","sources":["../../src/report/sarif.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/report/schema.ts"],"names":[],"mappings":"AAEA,2FAA2F;AAC3F,wBAAgB,qBAAqB,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAqD/D"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { diagnosticJsonSchema } from '@staticforge/core';
|
|
2
|
+
/** JSON Schema of `sforge check --json` data (and of a single diagnostic, under $defs). */
|
|
3
|
+
export function checkReportJsonSchema() {
|
|
4
|
+
return {
|
|
5
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
6
|
+
$id: 'urn:staticforge:schema:diagnostics:v1',
|
|
7
|
+
title: 'staticforge check report',
|
|
8
|
+
type: 'object',
|
|
9
|
+
required: ['ok', 'failOn', 'pages', 'rendered', 'diagnostics', 'summary', 'durationMs'],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
ok: { type: 'boolean', description: 'No diagnostics at or above failOn.' },
|
|
13
|
+
failOn: { enum: ['error', 'warning'] },
|
|
14
|
+
pages: { type: 'integer', minimum: 0, description: 'Built pages checked.' },
|
|
15
|
+
rendered: {
|
|
16
|
+
enum: ['ran', 'skipped', 'off'],
|
|
17
|
+
description: 'Whether headless (axe-core) checks ran; skipped when Chromium is missing.',
|
|
18
|
+
},
|
|
19
|
+
diagnostics: { type: 'array', items: { $ref: '#/$defs/diagnostic' } },
|
|
20
|
+
summary: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
required: ['error', 'warning', 'info'],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {
|
|
25
|
+
error: { type: 'integer', minimum: 0 },
|
|
26
|
+
warning: { type: 'integer', minimum: 0 },
|
|
27
|
+
info: { type: 'integer', minimum: 0 },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
fixes: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
required: ['applied', 'files', 'diff'],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
properties: {
|
|
35
|
+
applied: { type: 'boolean', description: 'False for --dry-run.' },
|
|
36
|
+
files: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
required: ['file', 'ruleIds', 'edits'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
file: { type: 'string' },
|
|
44
|
+
ruleIds: { type: 'array', items: { type: 'string' } },
|
|
45
|
+
edits: { type: 'integer', minimum: 0 },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
diff: { type: 'string', description: 'Unified diff of the fixes.' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
durationMs: { type: 'integer', minimum: 0 },
|
|
53
|
+
},
|
|
54
|
+
$defs: { diagnostic: diagnosticJsonSchema() },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/report/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEzD,2FAA2F;AAC3F,MAAM,UAAU,qBAAqB;IACnC,OAAO;QACL,OAAO,EAAE,8CAA8C;QACvD,GAAG,EAAE,uCAAuC;QAC5C,KAAK,EAAE,0BAA0B;QACjC,IAAI,EAAE,QAAQ;QACd,QAAQ,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,SAAS,EAAE,YAAY,CAAC;QACvF,oBAAoB,EAAE,KAAK;QAC3B,UAAU,EAAE;YACV,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,oCAAoC,EAAE;YAC1E,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE;YACtC,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,sBAAsB,EAAE;YAC3E,QAAQ,EAAE;gBACR,IAAI,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC;gBAC/B,WAAW,EAAE,2EAA2E;aACzF;YACD,WAAW,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,oBAAoB,EAAE,EAAE;YACrE,OAAO,EAAE;gBACP,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC;gBACtC,oBAAoB,EAAE,KAAK;gBAC3B,UAAU,EAAE;oBACV,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;oBACtC,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;oBACxC,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;iBACtC;aACF;YACD,KAAK,EAAE;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC,SAAS,EAAE,OAAO,EAAE,MAAM,CAAC;gBACtC,oBAAoB,EAAE,KAAK;gBAC3B,UAAU,EAAE;oBACV,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,sBAAsB,EAAE;oBACjE,KAAK,EAAE;wBACL,IAAI,EAAE,OAAO;wBACb,KAAK,EAAE;4BACL,IAAI,EAAE,QAAQ;4BACd,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC;4BACtC,oBAAoB,EAAE,KAAK;4BAC3B,UAAU,EAAE;gCACV,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gCACxB,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE;gCACrD,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;6BACvC;yBACF;qBACF;oBACD,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,4BAA4B,EAAE;iBACpE;aACF;YACD,UAAU,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;SAC5C;QACD,KAAK,EAAE,EAAE,UAAU,EAAE,oBAAoB,EAAE,EAAE;KAC9C,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type PreparedSource } from '@staticforge/content';
|
|
2
|
+
import type { CheckPage, Finding } from './types.js';
|
|
3
|
+
export interface SourceContext {
|
|
4
|
+
page: CheckPage & {
|
|
5
|
+
source: string;
|
|
6
|
+
};
|
|
7
|
+
/** The source file as on disk. */
|
|
8
|
+
text: string;
|
|
9
|
+
prepared: PreparedSource;
|
|
10
|
+
/** Site language, for `lang` fixes. */
|
|
11
|
+
language: string;
|
|
12
|
+
/** Page URL by project-relative source path. */
|
|
13
|
+
pageUrls: ReadonlyMap<string, string>;
|
|
14
|
+
trailingSlash: 'always' | 'never';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Source rules re-parse the author's file, so their findings point at exact
|
|
18
|
+
* lines and can carry fixes. They only report what they can locate; the
|
|
19
|
+
* output rules cover everything else (including theme templates).
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkSource(ctx: SourceContext): Finding[];
|
|
22
|
+
/** Page URL by project-relative source path, as the build assigned them. */
|
|
23
|
+
export declare function pageUrlMap(pages: CheckPage[]): Map<string, string>;
|
|
24
|
+
//# sourceMappingURL=source-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source-rules.d.ts","sourceRoot":"","sources":["../src/source-rules.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,cAAc,EAAiB,MAAM,sBAAsB,CAAC;AAG1E,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAErD,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACrC,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,aAAa,EAAE,QAAQ,GAAG,OAAO,CAAC;CACnC;AAoBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,EAAE,CA0DzD;AA4SD,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAMlE"}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { fromHtml } from 'hast-util-from-html';
|
|
3
|
+
import { toString as hastToString } from 'hast-util-to-string';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import remarkParse from 'remark-parse';
|
|
6
|
+
import { unified } from 'unified';
|
|
7
|
+
import { visit } from 'unist-util-visit';
|
|
8
|
+
import { createRefResolver } from '@staticforge/build';
|
|
9
|
+
import { titleFromPath } from '@staticforge/content';
|
|
10
|
+
import { LineIndex, escapeHtml } from './text.js';
|
|
11
|
+
const markdownParser = unified().use(remarkParse).use(remarkGfm);
|
|
12
|
+
const verbose = (node) => node.data?.position;
|
|
13
|
+
/**
|
|
14
|
+
* Source rules re-parse the author's file, so their findings point at exact
|
|
15
|
+
* lines and can carry fixes. They only report what they can locate; the
|
|
16
|
+
* output rules cover everything else (including theme templates).
|
|
17
|
+
*/
|
|
18
|
+
export function checkSource(ctx) {
|
|
19
|
+
const { prepared, text } = ctx;
|
|
20
|
+
const body = prepared.body;
|
|
21
|
+
const bodyIndex = new LineIndex(body);
|
|
22
|
+
const fileIndex = new LineIndex(text);
|
|
23
|
+
// Front-matter is blanked line for line, so body and file share line
|
|
24
|
+
// numbers; only a stripped BOM shifts the first line.
|
|
25
|
+
const bom = text.startsWith('') && !body.startsWith('') ? 1 : 0;
|
|
26
|
+
const toFile = (offset) => {
|
|
27
|
+
const point = bodyIndex.point(offset);
|
|
28
|
+
return fileIndex.offset(point) + (point.line === 1 ? bom : 0);
|
|
29
|
+
};
|
|
30
|
+
const findings = [];
|
|
31
|
+
const report = (diagnostic, at, edits, match) => {
|
|
32
|
+
const point = fileIndex.point(toFile(at));
|
|
33
|
+
findings.push({
|
|
34
|
+
layer: 'source',
|
|
35
|
+
diagnostic: {
|
|
36
|
+
...diagnostic,
|
|
37
|
+
file: ctx.page.source,
|
|
38
|
+
line: point.line,
|
|
39
|
+
column: point.column,
|
|
40
|
+
url: ctx.page.url,
|
|
41
|
+
fixable: edits !== undefined,
|
|
42
|
+
},
|
|
43
|
+
...(edits && {
|
|
44
|
+
edits: edits.map((e) => ({ start: toFile(e.start), end: toFile(e.end), text: e.text })),
|
|
45
|
+
}),
|
|
46
|
+
...(match !== undefined && { match }),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const elements = [];
|
|
50
|
+
let mdast;
|
|
51
|
+
if (prepared.format === 'markdown') {
|
|
52
|
+
mdast = markdownParser.parse(body);
|
|
53
|
+
visit(mdast, 'html', (node) => {
|
|
54
|
+
const base = node.position?.start.offset;
|
|
55
|
+
if (base === undefined)
|
|
56
|
+
return;
|
|
57
|
+
collectElements(fromHtml(node.value, { fragment: true, verbose: true }), base, elements);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const tree = fromHtml(body, { fragment: ctx.page.kind !== 'document', verbose: true });
|
|
62
|
+
collectElements(tree, 0, elements);
|
|
63
|
+
if (ctx.page.kind === 'document') {
|
|
64
|
+
checkHtmlLang(elements, ctx, report);
|
|
65
|
+
checkDocumentTitle(elements, ctx, bodyIndex, report);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (mdast !== undefined)
|
|
69
|
+
checkMarkdownHeadings(mdast, body, prepared, report);
|
|
70
|
+
checkTrailingSlashLinks(mdast, elements, body, ctx, report);
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
function collectElements(tree, base, into) {
|
|
74
|
+
visit(tree, 'element', (node) => {
|
|
75
|
+
into.push({ node, base });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const openingOf = (el) => {
|
|
79
|
+
const opening = verbose(el.node)?.opening;
|
|
80
|
+
if (opening?.start.offset === undefined || opening.end.offset === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
return { start: el.base + opening.start.offset, end: el.base + opening.end.offset };
|
|
83
|
+
};
|
|
84
|
+
const explicit = (elements, tagName) => elements.find((el) => el.node.tagName === tagName && openingOf(el) !== undefined);
|
|
85
|
+
function checkHtmlLang(elements, ctx, report) {
|
|
86
|
+
const html = explicit(elements, 'html');
|
|
87
|
+
if (html === undefined)
|
|
88
|
+
return;
|
|
89
|
+
const lang = html.node.properties.lang;
|
|
90
|
+
if (typeof lang === 'string' && lang.trim() !== '')
|
|
91
|
+
return;
|
|
92
|
+
const opening = openingOf(html);
|
|
93
|
+
if (opening === undefined)
|
|
94
|
+
return;
|
|
95
|
+
const attribute = verbose(html.node)?.properties?.lang;
|
|
96
|
+
const replacement = `lang="${escapeHtml(ctx.language)}"`;
|
|
97
|
+
const edit = attribute?.start.offset !== undefined && attribute.end.offset !== undefined
|
|
98
|
+
? {
|
|
99
|
+
start: html.base + attribute.start.offset,
|
|
100
|
+
end: html.base + attribute.end.offset,
|
|
101
|
+
text: replacement,
|
|
102
|
+
}
|
|
103
|
+
: {
|
|
104
|
+
start: opening.start + '<html'.length,
|
|
105
|
+
end: opening.start + '<html'.length,
|
|
106
|
+
text: ` ${replacement}`,
|
|
107
|
+
};
|
|
108
|
+
report({
|
|
109
|
+
ruleId: 'html-lang',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
wcag: '3.1.1',
|
|
112
|
+
message: 'The <html> element has no lang attribute',
|
|
113
|
+
hint: `Add ${replacement} so assistive technology uses the right language.`,
|
|
114
|
+
}, opening.start, [edit]);
|
|
115
|
+
}
|
|
116
|
+
function checkDocumentTitle(elements, ctx, index, report) {
|
|
117
|
+
const title = elements.find((el) => el.node.tagName === 'title');
|
|
118
|
+
if (title !== undefined && hastToString(title.node).trim() !== '')
|
|
119
|
+
return;
|
|
120
|
+
const h1 = elements.find((el) => el.node.tagName === 'h1');
|
|
121
|
+
const h1Text = h1 === undefined ? '' : hastToString(h1.node).replace(/\s+/g, ' ').trim();
|
|
122
|
+
const text = escapeHtml(h1Text === '' ? titleFromPath(ctx.page.source) : h1Text);
|
|
123
|
+
const diagnostic = {
|
|
124
|
+
ruleId: 'document-title',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
wcag: '2.4.2',
|
|
127
|
+
message: title === undefined ? 'Page has no <title>' : 'Page has an empty <title>',
|
|
128
|
+
hint: 'Give every page a <title> that describes its topic or purpose.',
|
|
129
|
+
};
|
|
130
|
+
const titlePosition = title === undefined ? undefined : verbose(title.node);
|
|
131
|
+
if (title !== undefined) {
|
|
132
|
+
const start = titlePosition?.opening?.end.offset;
|
|
133
|
+
const end = titlePosition?.closing?.start.offset;
|
|
134
|
+
const opening = openingOf(title);
|
|
135
|
+
if (opening === undefined)
|
|
136
|
+
return;
|
|
137
|
+
report(diagnostic, opening.start, start === undefined || end === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: [{ start: title.base + start, end: title.base + end, text }]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Insert at the top of <head>, or straight after <html> when <head> is implied.
|
|
143
|
+
const parent = explicit(elements, 'head') ?? explicit(elements, 'html');
|
|
144
|
+
const opening = parent === undefined ? undefined : openingOf(parent);
|
|
145
|
+
if (opening === undefined) {
|
|
146
|
+
report(diagnostic, 0, undefined);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// After leading <meta> elements: the charset declaration must come first.
|
|
150
|
+
let at = opening.end;
|
|
151
|
+
let indent = `${index.indentAt(opening.start)} `;
|
|
152
|
+
if (parent?.node.tagName === 'head') {
|
|
153
|
+
for (const child of parent.node.children) {
|
|
154
|
+
if (child.type === 'text' && child.value.trim() === '')
|
|
155
|
+
continue;
|
|
156
|
+
if (child.type !== 'element' || child.tagName !== 'meta')
|
|
157
|
+
break;
|
|
158
|
+
const meta = openingOf({ node: child, base: parent.base });
|
|
159
|
+
if (meta === undefined)
|
|
160
|
+
break;
|
|
161
|
+
at = meta.end;
|
|
162
|
+
indent = index.indentAt(meta.start);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
report(diagnostic, opening.start, [
|
|
166
|
+
{ start: at, end: at, text: `\n${indent}<title>${text}</title>` },
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Mirror the build: a leading `# Title` becomes the layout's <h1>, and other
|
|
171
|
+
* `#` headings are demoted. Then no heading may skip a level below its
|
|
172
|
+
* predecessor. ATX headings (`###`) are fixed by changing the `#` count.
|
|
173
|
+
*/
|
|
174
|
+
function checkMarkdownHeadings(tree, body, prepared, report) {
|
|
175
|
+
const headings = [];
|
|
176
|
+
visit(tree, 'heading', (node) => {
|
|
177
|
+
headings.push(node);
|
|
178
|
+
});
|
|
179
|
+
if (headings.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const normalize = (s) => s.replace(/\s+/g, ' ').trim();
|
|
182
|
+
const first = headings[0];
|
|
183
|
+
const metaTitle = prepared.meta.title;
|
|
184
|
+
const rest = first?.depth === 1 &&
|
|
185
|
+
(metaTitle === undefined || normalize(metaTitle) === normalize(mdText(first)))
|
|
186
|
+
? headings.slice(1)
|
|
187
|
+
: headings;
|
|
188
|
+
const shift = rest.some((h) => h.depth === 1) ? 1 : 0;
|
|
189
|
+
let previous = 1;
|
|
190
|
+
for (const heading of rest) {
|
|
191
|
+
const depth = Math.min(heading.depth + shift, 6);
|
|
192
|
+
if (depth <= previous + 1) {
|
|
193
|
+
previous = depth;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const target = previous + 1;
|
|
197
|
+
const start = heading.position?.start.offset;
|
|
198
|
+
if (start === undefined)
|
|
199
|
+
continue;
|
|
200
|
+
const hashes = /^ {0,3}(#{1,6})(?=[ \t]|$)/.exec(body.slice(start));
|
|
201
|
+
const marker = hashes?.[1];
|
|
202
|
+
const edits = marker === undefined
|
|
203
|
+
? undefined
|
|
204
|
+
: [
|
|
205
|
+
{
|
|
206
|
+
start: start + (hashes?.[0].length ?? 0) - marker.length,
|
|
207
|
+
end: start + (hashes?.[0].length ?? 0),
|
|
208
|
+
text: '#'.repeat(target - shift),
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
report({
|
|
212
|
+
ruleId: 'heading-order',
|
|
213
|
+
severity: 'warning',
|
|
214
|
+
wcag: '1.3.1',
|
|
215
|
+
message: `Heading level skips from h${previous} to h${depth}`,
|
|
216
|
+
hint: `Use an h${target} here (${'#'.repeat(target - shift)}), so the outline has no gaps.`,
|
|
217
|
+
}, start, edits);
|
|
218
|
+
previous = target;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const mdText = (node) => {
|
|
222
|
+
if ('value' in node && typeof node.value === 'string')
|
|
223
|
+
return node.value;
|
|
224
|
+
if ('children' in node)
|
|
225
|
+
return node.children.map(mdText).join('');
|
|
226
|
+
return '';
|
|
227
|
+
};
|
|
228
|
+
const EXTERNAL = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i;
|
|
229
|
+
/**
|
|
230
|
+
* Links that resolve to a page except for the trailing slash cost a redirect
|
|
231
|
+
* on every click (`/about` → 301 `/about/`). Rewrite them to the canonical URL.
|
|
232
|
+
*/
|
|
233
|
+
function checkTrailingSlashLinks(mdast, elements, body, ctx, report) {
|
|
234
|
+
const refs = [];
|
|
235
|
+
if (mdast !== undefined) {
|
|
236
|
+
visit(mdast, (node) => {
|
|
237
|
+
if (node.type !== 'link' && node.type !== 'definition')
|
|
238
|
+
return;
|
|
239
|
+
const { url, position } = node;
|
|
240
|
+
const start = position?.start.offset;
|
|
241
|
+
const end = position?.end.offset;
|
|
242
|
+
if (start === undefined || end === undefined)
|
|
243
|
+
return;
|
|
244
|
+
// The URL follows the link text (or the definition label).
|
|
245
|
+
const after = node.type === 'link'
|
|
246
|
+
? (node.children.at(-1)?.position?.end.offset ?? start)
|
|
247
|
+
: start + body.slice(start, end).indexOf(']:');
|
|
248
|
+
const found = body.indexOf(url, after);
|
|
249
|
+
refs.push({
|
|
250
|
+
value: url,
|
|
251
|
+
range: found !== -1 && found + url.length <= end
|
|
252
|
+
? { start: found, end: found + url.length }
|
|
253
|
+
: undefined,
|
|
254
|
+
at: start,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
for (const el of elements) {
|
|
259
|
+
const href = el.node.properties.href;
|
|
260
|
+
if (el.node.tagName !== 'a' || typeof href !== 'string')
|
|
261
|
+
continue;
|
|
262
|
+
const attribute = verbose(el.node)?.properties?.href;
|
|
263
|
+
const start = attribute?.start.offset;
|
|
264
|
+
const end = attribute?.end.offset;
|
|
265
|
+
const opening = openingOf(el);
|
|
266
|
+
if (opening === undefined)
|
|
267
|
+
continue;
|
|
268
|
+
let range;
|
|
269
|
+
if (start !== undefined && end !== undefined) {
|
|
270
|
+
const raw = body.slice(el.base + start, el.base + end);
|
|
271
|
+
const at = raw.indexOf(href, raw.indexOf('=') + 1);
|
|
272
|
+
if (at !== -1)
|
|
273
|
+
range = { start: el.base + start + at, end: el.base + start + at + href.length };
|
|
274
|
+
}
|
|
275
|
+
refs.push({ value: href, range, at: opening.start });
|
|
276
|
+
}
|
|
277
|
+
if (refs.length === 0)
|
|
278
|
+
return;
|
|
279
|
+
const resolve = createRefResolver({
|
|
280
|
+
sourcePath: ctx.page.source,
|
|
281
|
+
pageUrls: ctx.pageUrls,
|
|
282
|
+
report: () => undefined,
|
|
283
|
+
});
|
|
284
|
+
const urls = new Set(ctx.pageUrls.values());
|
|
285
|
+
const placeholder = { type: 'element', tagName: 'a', properties: {}, children: [] };
|
|
286
|
+
for (const ref of refs) {
|
|
287
|
+
if (ref.value === '' || EXTERNAL.test(ref.value))
|
|
288
|
+
continue;
|
|
289
|
+
const [, path = '', suffix = ''] = /^([^?#]*)(.*)$/s.exec(ref.value) ?? [];
|
|
290
|
+
if (path === '' || /\.(?:md|markdown)$/i.test(path))
|
|
291
|
+
continue;
|
|
292
|
+
const pathname = (value) => new URL(resolve(value, placeholder) ?? value, `http://site.invalid${ctx.page.url}`).pathname;
|
|
293
|
+
const current = pathname(path);
|
|
294
|
+
if (urls.has(current) || current === '/')
|
|
295
|
+
continue;
|
|
296
|
+
let fixed;
|
|
297
|
+
if (ctx.trailingSlash === 'always' && !current.endsWith('/') && urls.has(`${current}/`)) {
|
|
298
|
+
fixed = `${path}/`;
|
|
299
|
+
}
|
|
300
|
+
else if (ctx.trailingSlash === 'never' &&
|
|
301
|
+
current.endsWith('/') &&
|
|
302
|
+
urls.has(current.slice(0, -1)) &&
|
|
303
|
+
path.endsWith('/')) {
|
|
304
|
+
fixed = path.slice(0, -1);
|
|
305
|
+
}
|
|
306
|
+
if (fixed === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
report({
|
|
309
|
+
ruleId: 'link-trailing-slash',
|
|
310
|
+
severity: 'warning',
|
|
311
|
+
message: `Link to ${ref.value} redirects to ${pathname(fixed)}${suffix}`,
|
|
312
|
+
hint: "Link to the page's canonical URL to save a redirect.",
|
|
313
|
+
}, ref.at, ref.range === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: [{ start: ref.range.start, end: ref.range.end, text: fixed + suffix }], current);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Page URL by project-relative source path, as the build assigned them. */
|
|
319
|
+
export function pageUrlMap(pages) {
|
|
320
|
+
const map = new Map();
|
|
321
|
+
for (const page of pages) {
|
|
322
|
+
if (page.source !== undefined)
|
|
323
|
+
map.set(posix.normalize(page.source), page.url);
|
|
324
|
+
}
|
|
325
|
+
return map;
|
|
326
|
+
}
|
|
327
|
+
//# sourceMappingURL=source-rules.js.map
|