ultra11y 2.22.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 +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
package/src/color.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Zero-dependency colour maths for the static contrast check (theme 3). Parses the
|
|
2
|
+
// literal colour values an author can write inline, and computes the WCAG 2.x
|
|
3
|
+
// contrast ratio. Anything non-literal (var(), currentColor, named-but-unknown,
|
|
4
|
+
// gradients) returns null so the engine stays conservative and never invents a NC.
|
|
5
|
+
|
|
6
|
+
export interface RGBA {
|
|
7
|
+
r: number;
|
|
8
|
+
g: number;
|
|
9
|
+
b: number;
|
|
10
|
+
a: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The subset of CSS named colours common in hand-written inline styles. Unknown
|
|
14
|
+
// names fall through to null (→ criterion stays a residual risk, not a false NC).
|
|
15
|
+
const NAMED: Record<string, RGBA> = {
|
|
16
|
+
transparent: { r: 0, g: 0, b: 0, a: 0 },
|
|
17
|
+
white: { r: 255, g: 255, b: 255, a: 1 },
|
|
18
|
+
black: { r: 0, g: 0, b: 0, a: 1 },
|
|
19
|
+
red: { r: 255, g: 0, b: 0, a: 1 },
|
|
20
|
+
lime: { r: 0, g: 255, b: 0, a: 1 },
|
|
21
|
+
green: { r: 0, g: 128, b: 0, a: 1 },
|
|
22
|
+
blue: { r: 0, g: 0, b: 255, a: 1 },
|
|
23
|
+
yellow: { r: 255, g: 255, b: 0, a: 1 },
|
|
24
|
+
cyan: { r: 0, g: 255, b: 255, a: 1 },
|
|
25
|
+
aqua: { r: 0, g: 255, b: 255, a: 1 },
|
|
26
|
+
magenta: { r: 255, g: 0, b: 255, a: 1 },
|
|
27
|
+
fuchsia: { r: 255, g: 0, b: 255, a: 1 },
|
|
28
|
+
silver: { r: 192, g: 192, b: 192, a: 1 },
|
|
29
|
+
gray: { r: 128, g: 128, b: 128, a: 1 },
|
|
30
|
+
grey: { r: 128, g: 128, b: 128, a: 1 },
|
|
31
|
+
maroon: { r: 128, g: 0, b: 0, a: 1 },
|
|
32
|
+
olive: { r: 128, g: 128, b: 0, a: 1 },
|
|
33
|
+
purple: { r: 128, g: 0, b: 128, a: 1 },
|
|
34
|
+
teal: { r: 0, g: 128, b: 128, a: 1 },
|
|
35
|
+
navy: { r: 0, g: 0, b: 128, a: 1 },
|
|
36
|
+
orange: { r: 255, g: 165, b: 0, a: 1 },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function hex(part: string): number {
|
|
40
|
+
return parseInt(part.length === 1 ? part + part : part, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse a literal CSS colour to RGBA, or null if it isn't a static literal. */
|
|
44
|
+
export function parseColor(input: string): RGBA | null {
|
|
45
|
+
const s = input.trim().toLowerCase();
|
|
46
|
+
if (!s) return null;
|
|
47
|
+
if (s in NAMED) return { ...NAMED[s]! };
|
|
48
|
+
|
|
49
|
+
if (s.startsWith("#")) {
|
|
50
|
+
const h = s.slice(1);
|
|
51
|
+
if (/^[0-9a-f]{3}$/.test(h)) return { r: hex(h[0]!), g: hex(h[1]!), b: hex(h[2]!), a: 1 };
|
|
52
|
+
if (/^[0-9a-f]{4}$/.test(h)) return { r: hex(h[0]!), g: hex(h[1]!), b: hex(h[2]!), a: hex(h[3]!) / 255 };
|
|
53
|
+
if (/^[0-9a-f]{6}$/.test(h)) return { r: hex(h.slice(0, 2)), g: hex(h.slice(2, 4)), b: hex(h.slice(4, 6)), a: 1 };
|
|
54
|
+
if (/^[0-9a-f]{8}$/.test(h)) return { r: hex(h.slice(0, 2)), g: hex(h.slice(2, 4)), b: hex(h.slice(4, 6)), a: hex(h.slice(6, 8)) / 255 };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const m = /^rgba?\(([^)]+)\)$/.exec(s);
|
|
59
|
+
if (m) {
|
|
60
|
+
const parts = m[1]!.split(/[,/\s]+/).filter(Boolean);
|
|
61
|
+
if (parts.length < 3) return null;
|
|
62
|
+
const chan = (p: string): number => (p.endsWith("%") ? Math.round((parseFloat(p) / 100) * 255) : parseFloat(p));
|
|
63
|
+
const r = chan(parts[0]!);
|
|
64
|
+
const g = chan(parts[1]!);
|
|
65
|
+
const b = chan(parts[2]!);
|
|
66
|
+
const a = parts[3] !== undefined ? (parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3])) : 1;
|
|
67
|
+
if ([r, g, b, a].some((n) => Number.isNaN(n))) return null;
|
|
68
|
+
return { r, g, b, a };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function channelLuminance(c: number): number {
|
|
74
|
+
const cs = c / 255;
|
|
75
|
+
return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** WCAG relative luminance of an opaque colour. */
|
|
79
|
+
export function relativeLuminance({ r, g, b }: RGBA): number {
|
|
80
|
+
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** WCAG 2.x contrast ratio between two opaque colours (1 … 21). */
|
|
84
|
+
export function contrastRatio(a: RGBA, b: RGBA): number {
|
|
85
|
+
const la = relativeLuminance(a);
|
|
86
|
+
const lb = relativeLuminance(b);
|
|
87
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
88
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Split an inline `style` attribute into a lowercased property → value map. */
|
|
92
|
+
export function parseInlineStyle(style: string): Map<string, string> {
|
|
93
|
+
const out = new Map<string, string>();
|
|
94
|
+
for (const decl of style.split(";")) {
|
|
95
|
+
const i = decl.indexOf(":");
|
|
96
|
+
if (i === -1) continue;
|
|
97
|
+
const prop = decl.slice(0, i).trim().toLowerCase();
|
|
98
|
+
const val = decl.slice(i + 1).trim();
|
|
99
|
+
if (prop && val) out.set(prop, val);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Runtime configuration so external standards PACKS + GUIDANCE can be plugged WITHOUT
|
|
2
|
+
// rebuilding the bundle. Two explicit sources — no fs glob, no auto-discovery walk:
|
|
3
|
+
// • a `.ultra11yrc.json` in the cwd: { packs?: string[]; guidance?: string[]; standard?: string }
|
|
4
|
+
// • repeated/comma-separated `--pack <path>` flags on the CLI
|
|
5
|
+
// A pack path is either a pack JSON file, or a directory holding pack.json (+ optional
|
|
6
|
+
// glossary.json / guidance.json). Every pack is run through `validatePack` before it is
|
|
7
|
+
// registered (src/standards/validate.ts); an invalid pack is a HARD error returned to the
|
|
8
|
+
// caller, never a silent skip.
|
|
9
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
|
+
import { join, isAbsolute } from "node:path";
|
|
11
|
+
import { readText } from "./util.js";
|
|
12
|
+
import { registerRuntimePack, getPack, enableSecondaryMapping } from "./standards/registry.js";
|
|
13
|
+
import { formatIssues } from "./standards/validate.js";
|
|
14
|
+
import { checkGuidance } from "./pack.js";
|
|
15
|
+
import { registerRuntimeGuidance, hasGuidance } from "./guidance/index.js";
|
|
16
|
+
import type { GuidanceDataset } from "./guidance/types.js";
|
|
17
|
+
import type { LocaleString } from "./standards/types.js";
|
|
18
|
+
import type { Glossary, SampleConfig } from "./types.js";
|
|
19
|
+
|
|
20
|
+
export interface Ultra11yConfig {
|
|
21
|
+
packs?: string[];
|
|
22
|
+
guidance?: string[];
|
|
23
|
+
standard?: string;
|
|
24
|
+
// Normative page sample (échantillon) — standard-agnostic mechanics. Validated by
|
|
25
|
+
// `validateSample` (src/sample.ts) when a `scan --sample` / `sample check` reads it; NOT
|
|
26
|
+
// validated at pack-load time (an audit with no sample block is the common case).
|
|
27
|
+
sample?: SampleConfig;
|
|
28
|
+
// Opt-in SECONDARY crosswalk mappings to activate (see src/standards/types.ts
|
|
29
|
+
// SecondaryMapping). Each entry names a registered `standard` (pack key), a `ruleId`, and
|
|
30
|
+
// the additional `criterion` it should also project onto, with an optional localized
|
|
31
|
+
// `note`. Applied AFTER packs are registered (registry.enableSecondaryMapping): a pack
|
|
32
|
+
// ships the mapping DISABLED; this flips it on (or appends a new enabled one).
|
|
33
|
+
secondaryMappings?: Array<{ standard: string; ruleId: string; criterion: string; note?: LocaleString }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoadResult {
|
|
37
|
+
defaultStandard?: string; // config.standard, if set (applied when no --standard flag)
|
|
38
|
+
errors: string[]; // fatal: a bad config or an invalid pack
|
|
39
|
+
loadedPacks: string[];
|
|
40
|
+
loadedGuidance: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONFIG_FILE = ".ultra11yrc.json";
|
|
44
|
+
|
|
45
|
+
export function loadConfig(cwd: string): Ultra11yConfig | null {
|
|
46
|
+
const p = join(cwd, CONFIG_FILE);
|
|
47
|
+
if (!existsSync(p)) return null;
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(readText(p));
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`${CONFIG_FILE}: not valid JSON`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
55
|
+
throw new Error(`${CONFIG_FILE}: must be a JSON object`);
|
|
56
|
+
}
|
|
57
|
+
return parsed as Ultra11yConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A --pack path: a .json file IS the pack; a directory holds pack.json (+ optional
|
|
61
|
+
* glossary.json / guidance.json). Returns the resolved file paths, or null if absent. */
|
|
62
|
+
function packPaths(path: string): { pack: string; glossary?: string; guidance?: string } | null {
|
|
63
|
+
if (!existsSync(path)) return null;
|
|
64
|
+
if (statSync(path).isDirectory()) {
|
|
65
|
+
const pack = join(path, "pack.json");
|
|
66
|
+
if (!existsSync(pack)) return null;
|
|
67
|
+
const glossary = join(path, "glossary.json");
|
|
68
|
+
const guidance = join(path, "guidance.json");
|
|
69
|
+
return {
|
|
70
|
+
pack,
|
|
71
|
+
glossary: existsSync(glossary) ? glossary : undefined,
|
|
72
|
+
guidance: existsSync(guidance) ? guidance : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { pack: path };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Load + register every configured/flagged pack (and its guidance) into the registries.
|
|
79
|
+
* Must run BEFORE any `resolveStandard`/`loadPack` so a runtime pack is resolvable. */
|
|
80
|
+
export function loadRuntimeStandards(cwd: string, packFlags: string[], onWarn: (m: string) => void, override = false): LoadResult {
|
|
81
|
+
const result: LoadResult = { errors: [], loadedPacks: [], loadedGuidance: [] };
|
|
82
|
+
let config: Ultra11yConfig | null = null;
|
|
83
|
+
try {
|
|
84
|
+
config = loadConfig(cwd);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
result.defaultStandard = config?.standard;
|
|
90
|
+
|
|
91
|
+
for (const raw of [...(config?.packs ?? []), ...packFlags]) {
|
|
92
|
+
// Relative pack paths resolve against the working dir (= the config dir), not the
|
|
93
|
+
// process cwd, so `.ultra11yrc.json` entries work regardless of where node runs.
|
|
94
|
+
const paths = packPaths(isAbsolute(raw) ? raw : join(cwd, raw));
|
|
95
|
+
if (!paths) {
|
|
96
|
+
result.errors.push(`--pack ${raw}: not found (expected a pack JSON file or a directory with pack.json)`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let packObj: unknown;
|
|
100
|
+
try {
|
|
101
|
+
packObj = JSON.parse(readText(paths.pack));
|
|
102
|
+
} catch {
|
|
103
|
+
result.errors.push(`--pack ${paths.pack}: not valid JSON`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let glossary: Glossary = {};
|
|
107
|
+
if (paths.glossary) {
|
|
108
|
+
try {
|
|
109
|
+
glossary = JSON.parse(readText(paths.glossary)) as Glossary;
|
|
110
|
+
} catch {
|
|
111
|
+
onWarn(`ultra11y: ${paths.glossary} is not valid JSON — ignoring glossary.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const v = registerRuntimePack(packObj, glossary, { override });
|
|
115
|
+
for (const issue of v.issues) if (issue.severity === "warn") onWarn(`ultra11y: ${raw}: ${issue.path ? `${issue.path}: ` : ""}${issue.message}`);
|
|
116
|
+
if (!v.ok || !v.pack) {
|
|
117
|
+
result.errors.push(`--pack ${paths.pack}: invalid pack\n${formatIssues(v.issues).join("\n")}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
result.loadedPacks.push(v.pack.key);
|
|
121
|
+
if (paths.guidance) loadGuidanceFile(paths.guidance, result, onWarn);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const g of config?.guidance ?? []) loadGuidanceFile(isAbsolute(g) ? g : join(cwd, g), result, onWarn);
|
|
125
|
+
|
|
126
|
+
// Activate any opt-in secondary crosswalk mappings — AFTER every pack is registered, so a
|
|
127
|
+
// mapping can target a built-in (rgaa) OR a just-loaded runtime pack. An unknown standard
|
|
128
|
+
// (or the WCAG core, which has no pack criteria) is a hard error, never a silent skip.
|
|
129
|
+
for (const sm of config?.secondaryMappings ?? []) {
|
|
130
|
+
if (!sm || typeof sm.standard !== "string" || typeof sm.ruleId !== "string" || typeof sm.criterion !== "string") {
|
|
131
|
+
result.errors.push("secondaryMappings: each entry must be { standard, ruleId, criterion, note? }");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!getPack(sm.standard)) {
|
|
135
|
+
result.errors.push(`secondaryMappings: unknown standard "${sm.standard}" (register its pack first; the WCAG core has no pack criteria)`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
enableSecondaryMapping(sm.standard, { ruleId: sm.ruleId, criterion: sm.criterion, note: sm.note });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function loadGuidanceFile(path: string, result: LoadResult, onWarn: (m: string) => void): void {
|
|
145
|
+
if (!existsSync(path)) {
|
|
146
|
+
onWarn(`ultra11y: guidance ${path} not found — skipping.`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let ds: GuidanceDataset;
|
|
150
|
+
try {
|
|
151
|
+
ds = JSON.parse(readText(path)) as GuidanceDataset;
|
|
152
|
+
} catch {
|
|
153
|
+
onWarn(`ultra11y: guidance ${path} is not valid JSON — skipping.`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!ds || typeof ds.pack !== "string" || !Array.isArray(ds.entries)) {
|
|
157
|
+
onWarn(`ultra11y: guidance ${path} is not a valid dataset (needs { pack, entries[] }) — skipping.`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Gate runtime guidance the SAME way `pack check` does, so fabrication can't sneak in via
|
|
161
|
+
// the load path (a bogus criterionId / SC, or a malformed entry, is a hard error — never a
|
|
162
|
+
// silent register). When the augmented pack isn't loaded, fall back to a structural check.
|
|
163
|
+
const pack = getPack(ds.pack);
|
|
164
|
+
if (pack) {
|
|
165
|
+
const g = checkGuidance(ds, pack);
|
|
166
|
+
for (const w of g.warnings) onWarn(`ultra11y: guidance ${path}: ${w}`);
|
|
167
|
+
if (g.errors.length) {
|
|
168
|
+
result.errors.push(`guidance ${path}: invalid\n${g.errors.map((e) => ` ✗ ${e}`).join("\n")}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
for (const e of ds.entries) {
|
|
173
|
+
if (!Array.isArray(e?.wcag) || !Array.isArray(e?.examples)) {
|
|
174
|
+
onWarn(`ultra11y: guidance ${path}: entry "${e?.id ?? "?"}" missing wcag[]/examples[] — skipping dataset.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (hasGuidance(ds.pack)) onWarn(`ultra11y: guidance ${path} overrides the existing guidance for pack "${ds.pack}".`);
|
|
180
|
+
registerRuntimeGuidance(ds);
|
|
181
|
+
result.loadedGuidance.push(path);
|
|
182
|
+
}
|
package/src/crawl.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Zero-dependency page discovery for the dynamic tier: turn one entry point into
|
|
2
|
+
// the set of URLs to scan, either from a sitemap.xml or by breadth-first crawling
|
|
3
|
+
// the same-origin links found in the SERVED HTML. The latter sees links present in
|
|
4
|
+
// the response body (SSR/MPA); pure client-rendered SPA routes should come from a
|
|
5
|
+
// sitemap instead. The actual axe run per URL stays in the Docker tier (scan.ts).
|
|
6
|
+
|
|
7
|
+
/** Pull every `<loc>` value out of a sitemap.xml (urlset or sitemapindex). */
|
|
8
|
+
export function parseSitemapUrls(xml: string): string[] {
|
|
9
|
+
const out: string[] = [];
|
|
10
|
+
const re = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
|
|
11
|
+
let m: RegExpExecArray | null;
|
|
12
|
+
while ((m = re.exec(xml)) !== null) out.push(m[1]!);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Same-origin, hash-stripped, de-duplicated absolute links from a page's HTML. */
|
|
17
|
+
export function extractLinks(html: string, baseUrl: string): string[] {
|
|
18
|
+
const origin = new URL(baseUrl).origin;
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
22
|
+
let m: RegExpExecArray | null;
|
|
23
|
+
while ((m = re.exec(html)) !== null) {
|
|
24
|
+
const href = m[1]!.trim();
|
|
25
|
+
if (!href || href.startsWith("#")) continue; // pure fragment → current page
|
|
26
|
+
let abs: URL;
|
|
27
|
+
try {
|
|
28
|
+
abs = new URL(href, baseUrl);
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (abs.protocol !== "http:" && abs.protocol !== "https:") continue; // mailto/tel/js
|
|
33
|
+
if (abs.origin !== origin) continue;
|
|
34
|
+
abs.hash = "";
|
|
35
|
+
const url = abs.href;
|
|
36
|
+
if (seen.has(url)) continue;
|
|
37
|
+
seen.add(url);
|
|
38
|
+
out.push(url);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CrawlOpts {
|
|
44
|
+
fetchHtml: (url: string) => Promise<string>;
|
|
45
|
+
depth?: number;
|
|
46
|
+
max?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Breadth-first crawl from `start`, following same-origin links in served HTML.
|
|
50
|
+
* Visits the start URL first; bounded by `depth` (link hops) and `max` (pages). */
|
|
51
|
+
export async function crawlUrls(start: string, opts: CrawlOpts): Promise<string[]> {
|
|
52
|
+
const depth = opts.depth ?? 1;
|
|
53
|
+
const max = opts.max ?? 50;
|
|
54
|
+
const order: string[] = [];
|
|
55
|
+
const seen = new Set<string>([start]);
|
|
56
|
+
const queue: { url: string; d: number }[] = [{ url: start, d: 0 }];
|
|
57
|
+
|
|
58
|
+
while (queue.length > 0 && order.length < max) {
|
|
59
|
+
const { url, d } = queue.shift()!;
|
|
60
|
+
order.push(url);
|
|
61
|
+
if (d >= depth) continue;
|
|
62
|
+
let html = "";
|
|
63
|
+
try {
|
|
64
|
+
html = await opts.fetchHtml(url);
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const link of extractLinks(html, url)) {
|
|
69
|
+
if (seen.has(link)) continue;
|
|
70
|
+
seen.add(link);
|
|
71
|
+
queue.push({ url: link, d: d + 1 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return order;
|
|
75
|
+
}
|
package/src/criteria.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// `criteria` — offline reference lookup. The CORE reference is WCAG 2.2 AA: one
|
|
2
|
+
// success criterion (`criteria 1.4.3`), or the full list grouped by guideline
|
|
3
|
+
// (`criteria --list`). A country pack adds `--standard <pack>`: one pack criterion
|
|
4
|
+
// (`criteria --standard rgaa 8.3`), a pack theme (`--theme N`), or the theme list.
|
|
5
|
+
// Also generates references/criteria.md (never hand-edited).
|
|
6
|
+
import type { Lang, Sc } from "./types.js";
|
|
7
|
+
import { allSC, allGuidelines, allPrinciples, getSC, scsByGuideline, principleTitle, guidelineTitle, scTitle, meta } from "./wcag.js";
|
|
8
|
+
import {
|
|
9
|
+
type StandardId,
|
|
10
|
+
isCore,
|
|
11
|
+
loadPack,
|
|
12
|
+
listPacks,
|
|
13
|
+
packsForSc,
|
|
14
|
+
getCriterion as getPackCriterion,
|
|
15
|
+
listTheme as listPackTheme,
|
|
16
|
+
titlePlain as packTitlePlain,
|
|
17
|
+
themeName,
|
|
18
|
+
type StandardPack,
|
|
19
|
+
type PackCriterion,
|
|
20
|
+
} from "./standards/index.js";
|
|
21
|
+
|
|
22
|
+
const AUTO_LABEL: Record<string, { fr: string; en: string }> = {
|
|
23
|
+
static: { fr: "automatisable (moteur)", en: "automatable (engine)" },
|
|
24
|
+
"needs-rendering": { fr: "nécessite un rendu (tiers scan)", en: "needs rendering (scan tier)" },
|
|
25
|
+
judgment: { fr: "jugement de l'agent (gaté)", en: "agent judgment (gated)" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ---- WCAG core formatting -------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export function formatSC(c: Sc, lang: Lang = "en"): string {
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
out.push(`${c.sc} — ${scTitle(c.sc, lang) ?? c.title} (${c.level}, ${lang === "fr" ? "ajouté en" : "added in"} WCAG ${c.addedIn})`);
|
|
33
|
+
const auto = AUTO_LABEL[c.automatability]![lang];
|
|
34
|
+
const gl = guidelineTitle(c.guideline, lang) ?? "";
|
|
35
|
+
const pr = principleTitle(c.principle, lang) ?? "";
|
|
36
|
+
out.push(
|
|
37
|
+
`${lang === "fr" ? "Règle" : "Guideline"} ${c.guideline} (${gl}) · ${lang === "fr" ? "principe" : "principle"} ${c.principle} (${pr}) · ${lang === "fr" ? "automatisabilité" : "automatability"} : ${auto}${c.ruleIds.length ? ` · ${lang === "fr" ? "règles" : "rules"} : ${c.ruleIds.join(", ")}` : ""}`,
|
|
38
|
+
);
|
|
39
|
+
out.push(`${lang === "fr" ? "Comprendre" : "Understanding"} : ${c.understanding}`);
|
|
40
|
+
if (c.techniques?.length) out.push(`Techniques : ${c.techniques.join(", ")}`);
|
|
41
|
+
const packs = packsForSc(c.sc);
|
|
42
|
+
if (packs.length)
|
|
43
|
+
out.push(`${lang === "fr" ? "Référencé par" : "Mapped by"} : ${packs.map((p) => `${p.key.toUpperCase()} ${p.ids.join(", ")}`).join(" · ")}`);
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wcagList(lang: Lang): string {
|
|
48
|
+
const m = meta();
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
out.push(
|
|
51
|
+
lang === "fr"
|
|
52
|
+
? `WCAG ${m.wcagVersion} niveau ${m.level} — ${allPrinciples().length} principes, ${allGuidelines().length} règles, ${allSC().length} critères de succès`
|
|
53
|
+
: `WCAG ${m.wcagVersion} Level ${m.level} — ${allPrinciples().length} principles, ${allGuidelines().length} guidelines, ${allSC().length} success criteria`,
|
|
54
|
+
);
|
|
55
|
+
const byG = scsByGuideline();
|
|
56
|
+
for (const g of allGuidelines()) {
|
|
57
|
+
out.push(`${g.number} ${guidelineTitle(g.number, lang) ?? g.title}`);
|
|
58
|
+
for (const c of byG.get(g.number) ?? []) out.push(` ${c.sc.padEnd(8)} [${c.level.padEnd(2)}] ${scTitle(c.sc, lang) ?? c.title} [${c.automatability}]`);
|
|
59
|
+
}
|
|
60
|
+
return out.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- pack formatting ------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function formatPackCriterion(pack: StandardPack, c: PackCriterion, lang: Lang = "en"): string {
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
out.push(`${pack.name} ${c.id} — ${packTitlePlain(pack, c, lang)}`);
|
|
68
|
+
out.push(`${lang === "fr" ? "Thématique" : "Theme"} ${c.theme} (${themeName(pack, c.theme, lang) ?? ""}) · WCAG : ${c.wcag.join(", ") || "—"}`);
|
|
69
|
+
if (c.techniques?.length) out.push(`Techniques : ${c.techniques.join(", ")}`);
|
|
70
|
+
const testKeys = Object.keys(c.tests ?? {});
|
|
71
|
+
if (testKeys.length) {
|
|
72
|
+
out.push(`${lang === "fr" ? "Tests" : "Tests"} :`);
|
|
73
|
+
for (const k of testKeys) for (const line of c.tests![k]!) out.push(` ${c.id}.${k} ${line.replace(/\[([^\]]+)\]\(#[^)]*\)/g, "$1")}`);
|
|
74
|
+
}
|
|
75
|
+
if (c.technicalNote?.length) out.push(`${lang === "fr" ? "Note technique" : "Technical note"} : ${c.technicalNote.join(" ")}`);
|
|
76
|
+
if (c.particularCases?.length) out.push(`${lang === "fr" ? "Cas particuliers" : "Particular cases"} : ${c.particularCases.join(" ")}`);
|
|
77
|
+
return out.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function packThemeList(pack: StandardPack, lang: Lang): string {
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
out.push(
|
|
83
|
+
`${pack.name} ${pack.baseVersion} — ${pack.themes.length} ${lang === "fr" ? "thématiques" : "themes"}, ${pack.criteria.length} ${lang === "fr" ? "critères" : "criteria"}`,
|
|
84
|
+
);
|
|
85
|
+
for (const t of pack.themes) {
|
|
86
|
+
out.push(
|
|
87
|
+
`${String(t.number).padStart(2)}. ${(themeName(pack, t.number, lang) ?? "").padEnd(32).slice(0, 32)} ${String(t.count).padStart(3)} ${lang === "fr" ? "critères" : "criteria"}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return out.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- query + run ----------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export interface CriteriaOpts {
|
|
96
|
+
id?: string;
|
|
97
|
+
theme?: number;
|
|
98
|
+
list?: boolean;
|
|
99
|
+
json?: boolean;
|
|
100
|
+
lang: Lang;
|
|
101
|
+
standard: StandardId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function runWcag(opts: CriteriaOpts): number {
|
|
105
|
+
if (opts.id) {
|
|
106
|
+
const sc = getSC(opts.id);
|
|
107
|
+
if (!sc) {
|
|
108
|
+
console.error(`ultra11y criteria: unknown WCAG success criterion "${opts.id}".`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
console.log(opts.json ? JSON.stringify(sc, null, 2) : formatSC(sc, opts.lang));
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (typeof opts.theme === "number") {
|
|
115
|
+
console.error(
|
|
116
|
+
`ultra11y criteria: WCAG has no themes; use --list (grouped by guideline) or a success-criterion id, or pass --standard <pack> for a pack's themes.`,
|
|
117
|
+
);
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
console.log(opts.json ? JSON.stringify(allSC(), null, 2) : wcagList(opts.lang));
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function runPack(opts: CriteriaOpts): number {
|
|
125
|
+
const pack = loadPack(opts.standard);
|
|
126
|
+
if (opts.id) {
|
|
127
|
+
const c = getPackCriterion(pack, opts.id);
|
|
128
|
+
if (!c) {
|
|
129
|
+
console.error(`ultra11y criteria: unknown ${pack.name} criterion "${opts.id}".`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
console.log(opts.json ? JSON.stringify(c, null, 2) : formatPackCriterion(pack, c, opts.lang));
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
if (typeof opts.theme === "number") {
|
|
136
|
+
const crits = listPackTheme(pack, opts.theme);
|
|
137
|
+
if (!crits.length) {
|
|
138
|
+
console.error(`ultra11y criteria: unknown ${pack.name} theme "${opts.theme}".`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
if (opts.json) console.log(JSON.stringify(crits, null, 2));
|
|
142
|
+
else for (const c of crits) console.log(`${c.id}\t${packTitlePlain(pack, c, opts.lang)}\t→ WCAG ${c.wcag.join(", ")}`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
console.log(opts.json ? JSON.stringify(pack.themes, null, 2) : packThemeList(pack, opts.lang));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function runCriteria(opts: CriteriaOpts): number {
|
|
150
|
+
return isCore(opts.standard) ? runWcag(opts) : runPack(opts);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Generate the references/criteria.md doc bundled with the skill (WCAG 2.2 AA core). */
|
|
154
|
+
export function renderCriteriaReference(): string {
|
|
155
|
+
const out: string[] = [];
|
|
156
|
+
out.push("<!-- GENERATED from src/data/wcag.json by `ultra11y criteria --generate` — do not edit by hand. -->");
|
|
157
|
+
out.push("");
|
|
158
|
+
out.push("# WCAG 2.2 Level AA — success-criteria reference");
|
|
159
|
+
out.push("");
|
|
160
|
+
out.push("The 55 Level A + AA success criteria across the 4 principles / 13 guidelines, with each");
|
|
161
|
+
out.push("SC's level, the ultra11y automatability class (automatable / needs rendering / judgment),");
|
|
162
|
+
out.push("the engine rules that cover it, and — one column per registered standards pack — the pack");
|
|
163
|
+
out.push("criteria that map to it. SC ids, titles and levels are derived from the W3C source");
|
|
164
|
+
out.push("(https://github.com/w3c/wcag); WCAG 2.2 © W3C.");
|
|
165
|
+
out.push("");
|
|
166
|
+
const packs = listPacks();
|
|
167
|
+
const byG = scsByGuideline();
|
|
168
|
+
for (const g of allGuidelines()) {
|
|
169
|
+
out.push(`## ${g.number} ${g.title}`);
|
|
170
|
+
out.push("");
|
|
171
|
+
const head = ["SC", "Title", "Level", "Automatability", "Rules", ...packs.map((p) => p.name)];
|
|
172
|
+
out.push(`| ${head.join(" | ")} |`);
|
|
173
|
+
out.push(`|${"---|".repeat(head.length)}`);
|
|
174
|
+
for (const c of byG.get(g.number) ?? []) {
|
|
175
|
+
const hits = packsForSc(c.sc);
|
|
176
|
+
const packCols = packs.map((p) => hits.find((h) => h.key === p.key)?.ids.join(", ") ?? "—");
|
|
177
|
+
const row = [c.sc, c.title.replace(/\|/g, "\\|"), c.level, c.automatability, c.ruleIds.join(", ") || "—", ...packCols];
|
|
178
|
+
out.push(`| ${row.join(" | ")} |`);
|
|
179
|
+
}
|
|
180
|
+
out.push("");
|
|
181
|
+
}
|
|
182
|
+
return out.join("\n");
|
|
183
|
+
}
|