From e765491e64930bd77302cebdc0ed809897c6076f Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Tue, 8 Sep 2026 13:55:31 +0100 Subject: [PATCH] fix: Adds accessible labels to MathJax SVG equations --- docs/web/astro.config.mjs | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/web/astro.config.mjs b/docs/web/astro.config.mjs index 8a700a1..d5aca34 100644 --- a/docs/web/astro.config.mjs +++ b/docs/web/astro.config.mjs @@ -43,6 +43,116 @@ const cookieConsentHead = [ }, ]; +/** @typedef {{ type?: string, tagName?: string, properties?: Record, children?: HastNode[] }} HastNode */ + +/** + * Adds accessible semantics to the SVG output generated by MathJax. + * + * MathJax's SVG output is visually understandable but otherwise exposes its + * drawing primitives to assistive technology. The wrapper and SVG get an + * accessible math role and label, while the SVG internals are hidden so the + * equation is announced once rather than as a collection of vector nodes. + * + * @returns {(tree: HastNode) => void} + */ +function rehypeLabelMathJaxSvg() { + return (tree) => { + /** + * Selects the most useful available label for a rendered equation. + * + * @param {HastNode | undefined} container + * @returns {string} + */ + const getMathLabelFromContainer = (container) => { + const properties = container?.properties ?? {}; + const candidates = [ + properties["data-tex"], + properties["data-latex"], + properties["aria-label"], + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + + return "Mathematical expression"; + }; + + /** + * Hides visual-only SVG nodes from assistive technology. + * + * @param {HastNode | undefined} node + * @returns {void} + */ + const hideDecorativeSvgNodes = (node) => { + if (!node || typeof node !== "object") { + return; + } + + if (node.type === "element") { + const decorativeTags = new Set(["defs", "path", "use", "g", "rect"]); + if (typeof node.tagName === "string" && decorativeTags.has(node.tagName)) { + const properties = node.properties ?? {}; + properties["aria-hidden"] = "true"; + node.properties = properties; + } + } + + if (Array.isArray(node.children)) { + for (const child of node.children) { + hideDecorativeSvgNodes(child); + } + } + }; + + /** + * Applies accessible semantics to MathJax containers and their SVGs. + * + * @param {HastNode | undefined} node + * @returns {void} + */ + const visit = (node) => { + if (!node || typeof node !== "object") { + return; + } + + if (node.type === "element" && node.tagName === "mjx-container") { + const containerProperties = node.properties ?? {}; + containerProperties.role = "math"; + node.properties = containerProperties; + + const mathLabel = getMathLabelFromContainer(node); + const children = Array.isArray(node.children) ? node.children : []; + + for (const child of children) { + if (child?.type === "element" && child.tagName === "svg") { + const svgProperties = child.properties ?? {}; + svgProperties.role = "math"; + svgProperties.focusable = "false"; + + if (!svgProperties["aria-label"] && !svgProperties["aria-labelledby"]) { + svgProperties["aria-label"] = mathLabel; + } + + child.properties = svgProperties; + hideDecorativeSvgNodes(child); + } + } + } + + if (Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + }; + + visit(tree); + }; +} + // https://astro.build/config export default defineConfig({ site: "https://docs.lowcarboncontracts.uk", @@ -105,7 +215,7 @@ export default defineConfig({ }, markdown: { remarkPlugins: [remarkMath], - rehypePlugins: [rehypeMathjax], + rehypePlugins: [rehypeMathjax, rehypeLabelMathJaxSvg], }, integrations: [ starlight({