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}`, label: fix === "add-title" ? "Add accessible title" : "Add description", }; }