Every accessibility tool I have used reports violations like this:
Images must have alternative text
body > main > div:nth-child(2) > form > div.field > img
That selector is correct. It is also useless. It describes the rendered DOM,
and I do not write rendered DOM — I write templates. Somewhere in a few hundred
.component.html files there is an <img> that produced it, and finding it is
manual work: grep for img, get forty hits, open them one by one, compare
surrounding markup until something matches.
Multiply that by sixty violations and the scan stops being useful. Not because it
is wrong, but because acting on it costs more than ignoring it.
React solved this years ago
If you write JSX, babel-plugin-transform-react-jsx-source puts a _debugSource
on every element at build time — file, line, column. That is how React DevTools
can jump you straight to source, and how error overlays point at the right line.
Angular has no equivalent. The compiler knows the position of every element in
every template: it has to, to report template errors. But nothing carries that
knowledge into the DOM.
So I built the bridge.
parseTemplate hands you the positions
@angular/compiler exports parseTemplate, the same entry point
@angular-eslint uses. Give it a template string and you get an AST where every
node carries a sourceSpan with byte offsets, lines and columns:
import { parseTemplate } from '@angular/compiler';
const parsed = parseTemplate(source, filePath, { preserveWhitespaces: true });
// each element node has startSourceSpan.start.{offset,line,col}
Two things to know immediately. The compiler counts lines and columns from
zero, and every editor counts from one — so you add one, or every location you
report is off by one in both axes and nobody trusts the tool again:
line: span.start.line + 1, // the compiler counts from zero, editors do not
column: span.start.col + 1,
And preserveWhitespaces: true matters: without it the offsets you get back
describe a template that is not the one on disk.
The important decision: do not re-serialise the AST
The obvious move is to parse the template, add an attribute node to each element,
and print the AST back out. Do not do this.
Printing an AST means regenerating the file from a model, and that model does not
capture everything a template contains — attribute quoting style, whitespace
inside tags, line breaks between attributes, comments, the exact shape of a
binding. Every one of those is something you can silently change in someone
else's code. The first time you reformat a colleague's carefully aligned
template, you have lost their trust permanently, and you did it for an attribute
they cannot even see.
So the rewrite is pure text insertion at offsets the compiler reported. Find
where the opening tag ends, splice a string in, leave every other byte alone:
function insertionOffset(source: string, element: TemplateElement): number | null {
const openingTag = source.slice(element.openStart, element.openEnd);
const closingBracket = openingTag.lastIndexOf('>');
if (closingBracket === -1) return null;
const selfClosing = openingTag[closingBracket - 1] === '/';
return element.openStart + (selfClosing ? closingBracket - 1 : closingBracket);
}
Then apply the edits back to front, so each offset still refers to the
original string rather than to one you have already shifted:
edits.sort((a, b) => b.at - a.at);
let code = source;
for (const edit of edits) {
code = code.slice(0, edit.at) + edit.text + code.slice(edit.at);
}
The output is byte-identical to the input everywhere nothing was added. That
property is worth more than it sounds: it means the instrumentation cannot
break a template, and you can prove it by diffing.
Three things that bit me
1. Control-flow blocks hide their children under different keys
This one cost me an afternoon. A naive walk over node.children silently skips
everything inside @if, @for, @switch and @defer — those blocks are not
elements, and their children live under different property names depending on the
block.
@if uses branches. @defer uses placeholder, loading and error. And
@switch uses groups — not cases, which is what I assumed, and what made six
elements vanish from my output with no error at all:
function childrenOf(node: AstNode): AstNode[] {
const children: AstNode[] = [...(node.children ?? [])];
for (const group of [
...(node.branches ?? []),
...(node.groups ?? []),
...(node.cases ?? []),
]) {
children.push(...(group.children ?? []));
}
for (const slot of [node.empty, node.placeholder, node.loading, node.error]) {
if (slot?.children) children.push(...slot.children);
}
return children;
}
The failure mode here is what makes it dangerous: nothing throws. You just get
fewer results, and unless you have a fixture with a known count you will never
notice.
2. instanceof does not work
The natural way to identify an element node is node instanceof Element. It
fails in a workspace where @angular/compiler is installed more than once — the
class identity differs between copies, and your check quietly returns false for
everything.
Recognise structurally instead:
function isElement(node: AstNode): boolean {
// Structural rather than `instanceof`: the compiler's class identity is not
// stable across duplicated installs of @angular/compiler in a workspace.
return typeof node.name === 'string' && !!node.startSourceSpan;
}
This turned out to matter for a second reason. parseTemplate's output shape is
not a public contract — it changed between 19.2.1 and 19.2.2, renaming keys
and breaking angular-eslint implementations in a patch release. Structural checks
survive that; instanceof and destructured key names do not. I confined every
version-fragile line to a single file for the same reason.
3. You are editing files you did not write
Instrumenting in place means that between the rewrite and the restore, someone's
working tree contains modified templates. If the process is killed — Ctrl-C, a
failed build, a crash — you have left their code in a state they did not ask for.
An in-memory backup does not survive SIGKILL. So the original goes to disk
before each rewrite:
node_modules/.cache/rgaa-restore/
Already git-ignored, and checked on startup: a killed run is recovered
automatically the next time the tool starts.
My first design instead refused to run on an uncommitted file. That was worse. A
scan is something you run while working, when the tree is dirty by definition,
and a guard that trips constantly is a guard people disable once and never
re-enable — leaving them unprotected for the case that actually matters.
Reading it back
At runtime, axe-core can hand you the real element rather than a selector:
await axe.run(document, { elementRef: true });
From there, the violation reads its own address off the node it failed on. No
matching, no heuristic, no fuzzy correlation between DOM and source.
That last part is the design rule I would keep even if everything else changed:
exact or absent, never guessed. A developer sent to the wrong line loses more
time than a developer sent nowhere at all, and one bad location poisons trust in
every good one.
What this does not solve
Anything the runtime produces has no source line, and pretending otherwise would
be a lie: an avatar coming from an API, the contents of a third-party component.
Those get reported separately rather than pinned to an approximate location.
Locations only exist on an instrumented build, so scanning an arbitrary
production URL still gives you violations — just no files.
The tool
I packaged this as rgaa-source, which
runs axe-core plus some rules of its own against your built app and reports every
violation with its file and line. It also maps findings onto RGAA 4.1.2, the
French accessibility reference frame, because the European Accessibility Act made
that a legal question for a lot of people this year.
npx @rgaa-source/cli check --project .
MIT, runs locally, nothing hosted. Verified on Angular 15 through 22.
But the bridge is the part I think is worth sharing regardless of the tool
wrapped around it — it is about two hundred lines, and nothing about it is
specific to accessibility. Any analysis that runs against the rendered DOM could
use the same trick to point back at source.
If you try it on a real Angular project, I would genuinely like to know when a
location comes out wrong. That is the failure mode I cannot find on my own, and
it is the one that matters most.
Top comments (0)