Skip to content

Commit 00c5f56

Browse files
Programcodeclaude
andcommitted
Resolve accessible names in check-site the way browsers do
Codex review r2 on #156: - labelledby text now includes nested elements, not just text before the first closing tag; - a labelledby list names the element if any referenced element has text; - implicit labels count by field position, so an identical unlabelled input elsewhere on the page is still caught; - aria-label="" no longer passes by matching into the next attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 77f1765 commit 00c5f56

1 file changed

Lines changed: 29 additions & 14 deletions

File tree

‎scripts/check-site.mjs‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,31 @@ function checkUrl(url, where, fromFile) {
8888

8989
const textOf = (html) => html.replace(/<[^>]+>/g, '').replace(/&[#\w]+;/g, 'x').trim();
9090

91-
/** Whether attributes give an accessible name: aria-label, or aria-labelledby naming elements with text in `html`. */
91+
/** The text inside the element whose start tag begins at `start`, nested elements included. */
92+
function elementText(html, start) {
93+
const name = html.slice(start).match(/^<([\w-]+)/)?.[1];
94+
const open = html.indexOf('>', start);
95+
if (!name || open < 0) return '';
96+
const tags = new RegExp(`<(/?)${name}\\b[^>]*>`, 'gi');
97+
tags.lastIndex = open + 1;
98+
let depth = 1;
99+
for (let match = tags.exec(html); match; match = tags.exec(html)) {
100+
depth += match[1] ? -1 : 1;
101+
if (depth === 0) return textOf(html.slice(open + 1, match.index));
102+
}
103+
return '';
104+
}
105+
106+
/**
107+
* Whether attributes give an accessible name: a non-blank aria-label, or an
108+
* aria-labelledby whose referenced elements in `html` together have text.
109+
*/
92110
function namedByAria(attributes, html) {
93-
if (/\saria-label="[^"]*\S[^"]*"/.test(attributes)) return true;
94-
const ids = attributes.match(/\saria-labelledby="([^"]+)"/)?.[1].trim().split(/\s+/);
95-
if (!ids) return false;
96-
return ids.every((id) => {
97-
const start = html.search(new RegExp(`\\sid="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
98-
if (start < 0) return false;
99-
const open = html.indexOf('>', start);
100-
return Boolean(textOf(html.slice(open + 1, html.indexOf('</', open))));
111+
if (attributes.match(/\saria-label="([^"]*)"/)?.[1].trim()) return true;
112+
const ids = attributes.match(/\saria-labelledby="([^"]+)"/)?.[1].trim().split(/\s+/) ?? [];
113+
return ids.some((id) => {
114+
const at = html.search(new RegExp(`\\sid="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
115+
return at >= 0 && Boolean(elementText(html, html.lastIndexOf('<', at)));
101116
});
102117
}
103118

@@ -135,11 +150,11 @@ for (const file of htmlFiles(DIST)) {
135150
if (!namedByAria(attributes, body) && !textOf(content)) report(where, 'button without an accessible name');
136151
}
137152
const labelled = new Set([...body.matchAll(/<label\b[^>]*\sfor="([^"]+)"/g)].map((match) => match[1]));
138-
// A field inside its <label> is labelled by it.
139-
const wrapped = new Set([...body.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)]
140-
.flatMap((match) => [...match[1].matchAll(/<(?:input|select|textarea)\b[^>]*>/g)].map((field) => field[0])));
141-
for (const [tag] of body.matchAll(/<(?:input|select|textarea)\b[^>]*>/g)) {
142-
if (/\stype="hidden"/.test(tag) || wrapped.has(tag) || namedByAria(tag, body)) continue;
153+
// A field inside a <label> element is labelled by it.
154+
const labelSpans = [...body.matchAll(/<label\b[^>]*>[\s\S]*?<\/label>/g)].map((match) => [match.index, match.index + match[0].length]);
155+
const insideLabel = (index) => labelSpans.some(([from, to]) => index > from && index < to);
156+
for (const { 0: tag, index } of body.matchAll(/<(?:input|select|textarea)\b[^>]*>/g)) {
157+
if (/\stype="hidden"/.test(tag) || insideLabel(index) || namedByAria(tag, body)) continue;
143158
const id = tag.match(/\sid="([^"]+)"/)?.[1];
144159
if (!id || !labelled.has(id)) report(where, `form field without a label: ${tag.slice(0, 80)}`);
145160
}

0 commit comments

Comments
 (0)