131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
import type {
|
|
SemanticSvgDocument,
|
|
SourcePatch,
|
|
SourceRange,
|
|
} from "../document/document.types";
|
|
import { buildReferenceIndex } from "../structure/reference-index";
|
|
|
|
export interface AccessibilityFinding {
|
|
severity: "info" | "warning" | "error";
|
|
rule: string;
|
|
nodeKey: string;
|
|
evidence: string;
|
|
limitation: string;
|
|
suggestedFix: string;
|
|
automaticFix: "add-title" | "add-description" | "mark-decorative" | null;
|
|
sourceRange?: SourceRange;
|
|
}
|
|
|
|
export function auditAccessibility(
|
|
semantic: SemanticSvgDocument,
|
|
): AccessibilityFinding[] {
|
|
const root = semantic.nodes.get(semantic.rootKey)!;
|
|
const children = root.childKeys.map((key) => semantic.nodes.get(key)!);
|
|
const title = children.find((node) => node.localName === "title");
|
|
const description = children.find((node) => node.localName === "desc");
|
|
const role = root.attributes.role;
|
|
const labelledBy = root.attributes["aria-labelledby"];
|
|
const label = root.attributes["aria-label"];
|
|
const hidden = root.attributes["aria-hidden"] === "true";
|
|
const findings: AccessibilityFinding[] = [];
|
|
if (!hidden && !title && !label && !labelledBy) {
|
|
findings.push({
|
|
severity: "warning",
|
|
rule: "svg-accessible-name",
|
|
nodeKey: root.key,
|
|
evidence: "The root has no title, aria-label or aria-labelledby.",
|
|
limitation:
|
|
"The embedding page can provide an accessible name outside this file.",
|
|
suggestedFix:
|
|
"Add a concise root title or explicitly mark the image decorative.",
|
|
automaticFix: "add-title",
|
|
sourceRange: root.sourceRange.openTag,
|
|
});
|
|
}
|
|
if (!hidden && !description) {
|
|
findings.push({
|
|
severity: "info",
|
|
rule: "svg-description",
|
|
nodeKey: root.key,
|
|
evidence: "No root description element is present.",
|
|
limitation:
|
|
"Not every simple or decorative SVG needs a long description.",
|
|
suggestedFix:
|
|
"Add a description when the image communicates non-trivial content.",
|
|
automaticFix: "add-description",
|
|
sourceRange: root.sourceRange.openTag,
|
|
});
|
|
}
|
|
if (
|
|
!hidden &&
|
|
role &&
|
|
!["img", "graphics-document", "presentation", "none"].includes(role)
|
|
) {
|
|
findings.push({
|
|
severity: "info",
|
|
rule: "svg-role-review",
|
|
nodeKey: root.key,
|
|
evidence: `Root role is “${role}”.`,
|
|
limitation:
|
|
"Role validity depends on the embedding and interaction model.",
|
|
suggestedFix: "Review the role against the intended embedding context.",
|
|
automaticFix: null,
|
|
sourceRange: root.attributeRanges.role?.valueRange,
|
|
});
|
|
}
|
|
const references = buildReferenceIndex(semantic);
|
|
for (const edge of references.edges.filter(
|
|
(candidate) =>
|
|
candidate.attribute.startsWith("aria-") &&
|
|
candidate.status !== "resolved",
|
|
)) {
|
|
const node = semantic.nodes.get(edge.sourceKey)!;
|
|
findings.push({
|
|
severity: "error",
|
|
rule: "aria-reference",
|
|
nodeKey: edge.sourceKey,
|
|
evidence: `${edge.attribute} target “${edge.targetId}” is ${edge.status}.`,
|
|
limitation: "Only local SVG ID references are evaluated.",
|
|
suggestedFix: "Repair the referenced ID or remove the broken token.",
|
|
automaticFix: null,
|
|
sourceRange: node.attributeRanges[edge.attribute]?.valueRange,
|
|
});
|
|
}
|
|
for (const node of semantic.nodes.values()) {
|
|
if (node.localName === "path" && /text/i.test(node.id ?? "")) {
|
|
findings.push({
|
|
severity: "info",
|
|
rule: "text-as-path-review",
|
|
nodeKey: node.key,
|
|
evidence:
|
|
"A path ID suggests that visible text may have been outlined.",
|
|
limitation: "The audit cannot infer author intent from path geometry.",
|
|
suggestedFix:
|
|
"Prefer a text element when editable, selectable text is required.",
|
|
automaticFix: null,
|
|
sourceRange: node.sourceRange.openTag,
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
export function accessibilityFixPatch(
|
|
semantic: SemanticSvgDocument,
|
|
fix: "add-title" | "add-description",
|
|
text: string,
|
|
): SourcePatch {
|
|
const root = semantic.nodes.get(semantic.rootKey)!;
|
|
const escaped = text
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
const tag = fix === "add-title" ? "title" : "desc";
|
|
return {
|
|
from: root.sourceRange.openTag.to,
|
|
to: root.sourceRange.openTag.to,
|
|
insert: `${semantic.preferences.newline}${semantic.preferences.indentation}<${tag}>${escaped}</${tag}>`,
|
|
label: fix === "add-title" ? "Add accessible title" : "Add description",
|
|
};
|
|
}
|