better formatting
This commit is contained in:
parent
ae8b4e6659
commit
62c042aa4d
4 changed files with 345 additions and 55 deletions
78
index.ts
78
index.ts
|
|
@ -24,33 +24,87 @@ export interface AuditOptions {
|
|||
includeDisclosures?: boolean;
|
||||
}
|
||||
|
||||
export async function analyzeHtml(html: string, kind: "accessible" | "overflow", options: AuditOptions = {}): Promise<AuditReport[]> {
|
||||
export async function analyzeHtml(
|
||||
html: string,
|
||||
kind: "accessible" | "overflow",
|
||||
options: AuditOptions = {},
|
||||
): Promise<AuditReport[]> {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const reports: AuditReport[] = [];
|
||||
for (const viewport of options.viewports ?? [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
|
||||
const context = await browser.newContext({ viewport, javaScriptEnabled: false });
|
||||
for (const viewport of options.viewports ?? [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
javaScriptEnabled: false,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.route("**/*", (route) => route.abort());
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const result = await page.evaluate(inspectDocument, { kind, ignore: options.ignore ?? [], disclosures: options.includeDisclosures ?? false });
|
||||
const result = await page.evaluate(inspectDocument, {
|
||||
kind,
|
||||
ignore: options.ignore ?? [],
|
||||
disclosures: options.includeDisclosures ?? false,
|
||||
});
|
||||
const findings = result.findings as AuditFinding[];
|
||||
const score = Math.max(0, Math.round(100 - findings.reduce((sum, finding) => sum + (finding.severity === "error" ? 10 : finding.severity === "warning" ? 4 : 2), 0)));
|
||||
const report: AuditReport = { viewport, findings, score, checked: result.checked, method: "static-rendered-heuristic" };
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
100 -
|
||||
findings.reduce(
|
||||
(sum, finding) =>
|
||||
sum +
|
||||
(finding.severity === "error"
|
||||
? 10
|
||||
: finding.severity === "warning"
|
||||
? 4
|
||||
: 2),
|
||||
0,
|
||||
),
|
||||
),
|
||||
);
|
||||
const report: AuditReport = {
|
||||
viewport,
|
||||
findings,
|
||||
score,
|
||||
checked: result.checked,
|
||||
method: "static-rendered-heuristic",
|
||||
};
|
||||
reports.push(report);
|
||||
if (options.failBelow !== undefined && score < options.failBelow) throw new Error(`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`);
|
||||
if (options.failOnError && findings.some((finding) => finding.severity === "error")) throw new Error(`${kind} audit found errors at ${viewport.width}px`);
|
||||
} finally { await context.close(); }
|
||||
if (options.failBelow !== undefined && score < options.failBelow) {
|
||||
throw new Error(
|
||||
`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.failOnError &&
|
||||
findings.some((finding) => finding.severity === "error")
|
||||
) {
|
||||
throw new Error(`${kind} audit found errors at ${viewport.width}px`);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
} finally { await browser.close(); }
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function accessibleIntegration(options: AuditOptions = {}): Integration {
|
||||
return { name: "accessible", analyze: (page) => analyzeHtml(page.html, "accessible", options) };
|
||||
return {
|
||||
name: "accessible",
|
||||
analyze: (page) => analyzeHtml(page.html, "accessible", options),
|
||||
};
|
||||
}
|
||||
|
||||
export function overflowIntegration(options: AuditOptions = {}): Integration {
|
||||
return { name: "overflow", analyze: (page) => analyzeHtml(page.html, "overflow", options) };
|
||||
return {
|
||||
name: "overflow",
|
||||
analyze: (page) => analyzeHtml(page.html, "overflow", options),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
307
inspect.ts
307
inspect.ts
|
|
@ -1,9 +1,22 @@
|
|||
/** Runs inside the browser against static DOM and resolved CSS, without executing page scripts. */
|
||||
export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean }) {
|
||||
export function inspectDocument(options: {
|
||||
kind: "accessible" | "overflow";
|
||||
ignore: string[];
|
||||
disclosures: boolean;
|
||||
}) {
|
||||
const { document, getComputedStyle, innerWidth } = globalThis as any;
|
||||
const findings: { rule: string; selector: string; severity: "error" | "warning" | "review"; message: string; evidence: Record<string, unknown> }[] = [];
|
||||
const findings: {
|
||||
rule: string;
|
||||
selector: string;
|
||||
severity: "error" | "warning" | "review";
|
||||
message: string;
|
||||
evidence: Record<string, unknown>;
|
||||
}[] = [];
|
||||
const elements = [...document.querySelectorAll("body *")];
|
||||
if (options.disclosures) for (const element of elements) if (element.tagName === "DETAILS") element.open = true;
|
||||
if (options.disclosures) {
|
||||
for (const element of elements) {
|
||||
if (element.tagName === "DETAILS") element.open = true;
|
||||
}
|
||||
}
|
||||
const selector = (node: any): string => {
|
||||
if (node.id) return `[id=${JSON.stringify(node.id)}]`;
|
||||
const parts: string[] = [];
|
||||
|
|
@ -14,62 +27,282 @@ export function inspectDocument(options: { kind: "accessible" | "overflow"; igno
|
|||
}
|
||||
return `body > ${parts.join(" > ")}`;
|
||||
};
|
||||
const add = (node: any, rule: string, message: string, evidence: Record<string, unknown> = {}, severity: "error" | "warning" | "review" = "error") => findings.push({ selector: selector(node), rule, message, evidence, severity });
|
||||
const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1;
|
||||
const add = (
|
||||
node: any,
|
||||
rule: string,
|
||||
message: string,
|
||||
evidence: Record<string, unknown> = {},
|
||||
severity: "error" | "warning" | "review" = "error",
|
||||
) =>
|
||||
findings.push({
|
||||
selector: selector(node),
|
||||
rule,
|
||||
message,
|
||||
evidence,
|
||||
severity,
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const color = (value: string): number[] => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; };
|
||||
const composite = (front: number[], back: number[]): number[] => front.slice(0, 3).map((channel, index) => channel * front[3]! / 255 + back[index]! * (1 - front[3]! / 255)).concat(255);
|
||||
const luminance = (rgb: number[]): number => rgb.slice(0, 3).map((value) => { const c = value / 255; return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0);
|
||||
const color = (value: string): number[] => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = value;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data];
|
||||
};
|
||||
const composite = (front: number[], back: number[]): number[] =>
|
||||
front
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(channel, index) =>
|
||||
(channel * front[3]!) / 255 + back[index]! * (1 - front[3]! / 255),
|
||||
)
|
||||
.concat(255);
|
||||
const luminance = (rgb: number[]): number =>
|
||||
rgb
|
||||
.slice(0, 3)
|
||||
.map((value) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index]!,
|
||||
0,
|
||||
);
|
||||
let checked = 0;
|
||||
for (const node of elements) {
|
||||
if (options.ignore.some((ignore) => node.closest(ignore))) continue;
|
||||
const style = getComputedStyle(node);
|
||||
const box = node.getBoundingClientRect();
|
||||
if (!box.width || !box.height || style.display === "none" || style.visibility !== "visible" || node.closest("[hidden], [inert]")) continue;
|
||||
if (
|
||||
!box.width ||
|
||||
!box.height ||
|
||||
style.display === "none" ||
|
||||
style.visibility !== "visible" ||
|
||||
node.closest("[hidden], [inert]")
|
||||
)
|
||||
continue;
|
||||
checked++;
|
||||
if (options.kind === "overflow") {
|
||||
const clippedX = ["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
|
||||
const clippedY = ["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1;
|
||||
const truncated = style.textOverflow === "ellipsis" || (style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0);
|
||||
if ((clippedX || clippedY) && !truncated) add(node, "clipping", "Content exceeds a clipping boundary", { overflowX: style.overflowX, overflowY: style.overflowY, clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight });
|
||||
const clippedX =
|
||||
["hidden", "clip"].includes(style.overflowX) &&
|
||||
node.scrollWidth > node.clientWidth + 1;
|
||||
const clippedY =
|
||||
["hidden", "clip"].includes(style.overflowY) &&
|
||||
node.scrollHeight > node.clientHeight + 1;
|
||||
const truncated =
|
||||
style.textOverflow === "ellipsis" ||
|
||||
(style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0);
|
||||
if ((clippedX || clippedY) && !truncated) {
|
||||
add(node, "clipping", "Content exceeds a clipping boundary", {
|
||||
overflowX: style.overflowX,
|
||||
overflowY: style.overflowY,
|
||||
clientWidth: node.clientWidth,
|
||||
scrollWidth: node.scrollWidth,
|
||||
clientHeight: node.clientHeight,
|
||||
scrollHeight: node.scrollHeight,
|
||||
});
|
||||
}
|
||||
let managed = false;
|
||||
for (let parent = node.parentElement; parent; parent = parent.parentElement) if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(parent).overflowX)) { managed = true; break; }
|
||||
if (!managed && (box.right > innerWidth + 1 || box.left < -1)) add(node, "viewport-overflow", "Element extends outside the viewport without a containing scroll region", { left: box.left, right: box.right, viewportWidth: innerWidth });
|
||||
for (
|
||||
let parent = node.parentElement;
|
||||
parent;
|
||||
parent = parent.parentElement
|
||||
) {
|
||||
if (
|
||||
["auto", "scroll", "hidden", "clip"].includes(
|
||||
getComputedStyle(parent).overflowX,
|
||||
)
|
||||
) {
|
||||
managed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!managed && (box.right > innerWidth + 1 || box.left < -1)) {
|
||||
add(
|
||||
node,
|
||||
"viewport-overflow",
|
||||
"Element extends outside the viewport without a containing scroll region",
|
||||
{
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
viewportWidth: innerWidth,
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const label = node.getAttribute("aria-label") || (node.getAttribute("aria-labelledby") || "").split(/\s+/).map((id: string) => document.getElementById(id)?.textContent || "").join("").trim() || [...(node.labels || [])].map((label: any) => label.textContent).join("").trim();
|
||||
for (const id of (node.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean)) if (!document.getElementById(id)) add(node, "aria-reference", "aria-labelledby references a missing element", { id });
|
||||
if (["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) && !node.disabled && (box.width < 24 || box.height < 24)) add(node, "target-size", "Interactive target is smaller than 24 CSS pixels; spacing exceptions require review", { width: box.width, height: box.height }, "review");
|
||||
if (node.tagName === "IMG" && !node.hasAttribute("alt")) add(node, "image-alt", "Image lacks an alt attribute");
|
||||
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !["hidden", "submit", "button", "reset"].includes(node.type) && !label) add(node, "control-name", "Form control lacks a programmatic label");
|
||||
if (["A", "BUTTON"].includes(node.tagName) && !label && !node.textContent.trim() && !node.querySelector("img[alt]")) add(node, "control-name", "Interactive element lacks an accessible name");
|
||||
const ownText = [...node.childNodes].some((child: any) => child.nodeType === 3 && child.textContent.trim());
|
||||
const label =
|
||||
node.getAttribute("aria-label") ||
|
||||
(node.getAttribute("aria-labelledby") || "")
|
||||
.split(/\s+/)
|
||||
.map((id: string) => document.getElementById(id)?.textContent || "")
|
||||
.join("")
|
||||
.trim() ||
|
||||
[...(node.labels || [])]
|
||||
.map((label: any) => label.textContent)
|
||||
.join("")
|
||||
.trim();
|
||||
for (const id of (node.getAttribute("aria-labelledby") || "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)) {
|
||||
if (!document.getElementById(id)) {
|
||||
add(
|
||||
node,
|
||||
"aria-reference",
|
||||
"aria-labelledby references a missing element",
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) &&
|
||||
!node.disabled &&
|
||||
(box.width < 24 || box.height < 24)
|
||||
) {
|
||||
add(
|
||||
node,
|
||||
"target-size",
|
||||
"Interactive target is smaller than 24 CSS pixels; spacing exceptions require review",
|
||||
{ width: box.width, height: box.height },
|
||||
"review",
|
||||
);
|
||||
}
|
||||
if (node.tagName === "IMG" && !node.hasAttribute("alt")) {
|
||||
add(node, "image-alt", "Image lacks an alt attribute");
|
||||
}
|
||||
if (
|
||||
["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) &&
|
||||
!["hidden", "submit", "button", "reset"].includes(node.type) &&
|
||||
!label
|
||||
)
|
||||
add(node, "control-name", "Form control lacks a programmatic label");
|
||||
if (
|
||||
["A", "BUTTON"].includes(node.tagName) &&
|
||||
!label &&
|
||||
!node.textContent.trim() &&
|
||||
!node.querySelector("img[alt]")
|
||||
) {
|
||||
add(node, "control-name", "Interactive element lacks an accessible name");
|
||||
}
|
||||
const ownText = [...node.childNodes].some(
|
||||
(child: any) => child.nodeType === 3 && child.textContent.trim(),
|
||||
);
|
||||
if (!ownText) continue;
|
||||
const chain: any[] = []; let opacity = 1;
|
||||
for (let parent = node; parent; parent = parent.parentElement) { chain.unshift(parent); opacity *= Number(getComputedStyle(parent).opacity); }
|
||||
const chain: any[] = [];
|
||||
let opacity = 1;
|
||||
for (let parent = node; parent; parent = parent.parentElement) {
|
||||
chain.unshift(parent);
|
||||
opacity *= Number(getComputedStyle(parent).opacity);
|
||||
}
|
||||
let background = [255, 255, 255, 255];
|
||||
const effects: Record<string, unknown>[] = [];
|
||||
for (const parent of chain) {
|
||||
const css = getComputedStyle(parent);
|
||||
background = composite(color(css.backgroundColor), background);
|
||||
if (css.filter !== "none" || css.backdropFilter !== "none" || css.mixBlendMode !== "normal" || css.backgroundImage !== "none" || css.clipPath !== "none" || css.maskImage !== "none") effects.push({ selector: selector(parent), filter: css.filter, backdropFilter: css.backdropFilter, blend: css.mixBlendMode, backgroundImage: css.backgroundImage, clipPath: css.clipPath, maskImage: css.maskImage });
|
||||
if (
|
||||
css.filter !== "none" ||
|
||||
css.backdropFilter !== "none" ||
|
||||
css.mixBlendMode !== "normal" ||
|
||||
css.backgroundImage !== "none" ||
|
||||
css.clipPath !== "none" ||
|
||||
css.maskImage !== "none"
|
||||
) {
|
||||
effects.push({
|
||||
selector: selector(parent),
|
||||
filter: css.filter,
|
||||
backdropFilter: css.backdropFilter,
|
||||
blend: css.mixBlendMode,
|
||||
backgroundImage: css.backgroundImage,
|
||||
clipPath: css.clipPath,
|
||||
maskImage: css.maskImage,
|
||||
});
|
||||
}
|
||||
}
|
||||
const foreground = color(style.color); foreground[3] = foreground[3]! * opacity;
|
||||
const foreground = color(style.color);
|
||||
foreground[3] = foreground[3]! * opacity;
|
||||
const text = composite(foreground, background);
|
||||
const l1 = luminance(text), l2 = luminance(background);
|
||||
const contrast = (Math.max(l1, l2) + .05) / (Math.min(l1, l2) + .05);
|
||||
const threshold = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700) ? 3 : 4.5;
|
||||
if (effects.length) add(node, "visibility-effects", "Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive", { effects, opacity, nominalContrast: contrast }, "review");
|
||||
if (contrast < threshold) add(node, "text-contrast", "Estimated text contrast falls below the configured text threshold", { contrast, threshold, opacity, estimate: effects.length > 0 }, effects.length ? "review" : "error");
|
||||
if (opacity < .5) add(node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning");
|
||||
const l1 = luminance(text),
|
||||
l2 = luminance(background);
|
||||
const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
const threshold =
|
||||
parseFloat(style.fontSize) >= 24 ||
|
||||
(parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700)
|
||||
? 3
|
||||
: 4.5;
|
||||
if (effects.length) {
|
||||
add(
|
||||
node,
|
||||
"visibility-effects",
|
||||
"Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive",
|
||||
{ effects, opacity, nominalContrast: contrast },
|
||||
"review",
|
||||
);
|
||||
}
|
||||
if (contrast < threshold) {
|
||||
add(
|
||||
node,
|
||||
"text-contrast",
|
||||
"Estimated text contrast falls below the configured text threshold",
|
||||
{ contrast, threshold, opacity, estimate: effects.length > 0 },
|
||||
effects.length ? "review" : "error",
|
||||
);
|
||||
}
|
||||
if (opacity < 0.5) {
|
||||
add(
|
||||
node,
|
||||
"low-opacity",
|
||||
"Effective ancestor opacity reduces text visibility",
|
||||
{ opacity },
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.kind === "accessible") {
|
||||
const ids = new Set<string>();
|
||||
for (const node of document.querySelectorAll("[id]")) { if (ids.has(node.id)) add(node, "duplicate-id", "Duplicate identifiers make references ambiguous", { id: node.id }); ids.add(node.id); }
|
||||
for (const node of document.querySelectorAll("[id]")) {
|
||||
if (ids.has(node.id)) {
|
||||
add(
|
||||
node,
|
||||
"duplicate-id",
|
||||
"Duplicate identifiers make references ambiguous",
|
||||
{ id: node.id },
|
||||
);
|
||||
}
|
||||
ids.add(node.id);
|
||||
}
|
||||
let previous = 0;
|
||||
for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) { const level = Number(heading.tagName.slice(1)); if (previous && level > previous + 1) add(heading, "heading-order", "Heading hierarchy skips a level", { previous, level }, "warning"); previous = level; }
|
||||
if (!document.documentElement.lang) findings.push({ selector: "html", rule: "document-language", severity: "error", message: "Document language is missing", evidence: {} });
|
||||
if (!document.title.trim()) findings.push({ selector: "head", rule: "document-title", severity: "error", message: "Document title is missing", evidence: {} });
|
||||
for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
|
||||
const level = Number(heading.tagName.slice(1));
|
||||
if (previous && level > previous + 1) {
|
||||
add(
|
||||
heading,
|
||||
"heading-order",
|
||||
"Heading hierarchy skips a level",
|
||||
{ previous, level },
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
previous = level;
|
||||
}
|
||||
if (!document.documentElement.lang) {
|
||||
findings.push({
|
||||
selector: "html",
|
||||
rule: "document-language",
|
||||
severity: "error",
|
||||
message: "Document language is missing",
|
||||
evidence: {},
|
||||
});
|
||||
}
|
||||
if (!document.title.trim()) {
|
||||
findings.push({
|
||||
selector: "head",
|
||||
rule: "document-title",
|
||||
severity: "error",
|
||||
message: "Document title is missing",
|
||||
evidence: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { findings, checked };
|
||||
}
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"name": "@muddle/analysis",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": "./index.ts",
|
||||
"peerDependencies": { "muddle": "*", "playwright": "^1.0.0" }
|
||||
"name": "@muddle/analysis",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": "./index.ts",
|
||||
"peerDependencies": {
|
||||
"muddle": "*",
|
||||
"playwright": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue