Accessibility Linting

Static accessibility checks for templates — img-alt, heading-order, html-lang, input-label

3 min read 694 words

Kida can statically analyze parsed templates for common accessibility issues — missing alt text, skipped heading levels, missinglangattributes, and unlabeled form elements. Checks run against the template AST, so they catch issues in static markup without requiring a render pass.

from kida.analysis.a11y import check_a11y

Available Rules

Rule Severity What It Checks
img-alt error <img> tags must have an alt attribute. Images with role="presentation" or role="none"are exempt.
heading-order warning Heading levels must not skip (e.g.,<h2> directly followed by <h4>).
html-lang warning The<html> tag must have a langattribute.
input-label warning <input>, <select>, and <textarea> elements must have an associated <label for="...">, aria-label, aria-labelledby, or titleattribute. Hidden inputs are exempt.

Usage

Python API

Run accessibility checks on any parsed template AST:

from kida import Environment, DictLoader
from kida.analysis.a11y import check_a11y

env = Environment(
    loader=DictLoader({
        "page.html": """
            <html>
            <body>
                <img src="photo.jpg">
                <h1>Title</h1>
                <h3>Skipped h2</h3>
                <input type="text" name="email">
            </body>
            </html>
        """
    }),
    preserve_ast=True,
)

template = env.get_template("page.html")
issues = check_a11y(template._optimized_ast)

for issue in issues:
    print(f"Line {issue.lineno}: [{issue.rule}] {issue.message}")

Output:

Line 2: [html-lang] <html> missing lang attribute
Line 4: [img-alt] <img> missing alt attribute
Line 6: [heading-order] Heading level skipped: <h3> after <h1> (expected <h2>)
Line 7: [input-label] Form element missing associated <label> or aria-label

CLI

kida check --a11y templates/

A11yIssue

Each finding is returned as anA11yIssuedataclass:

Field Type Description
lineno int Line number in the template source
col_offset int Column offset
rule str Rule identifier (img-alt, heading-order, html-lang, input-label)
message str Human-readable description of the issue
severity str "error" or "warning" (default: "warning")

Results are sorted by(lineno, col_offset).

Build System Integration

Integrate accessibility linting into a build pipeline to fail on errors:

from kida import Environment, FileSystemLoader
from kida.analysis.a11y import check_a11y

env = Environment(
    loader=FileSystemLoader("templates/"),
    preserve_ast=True,
)

errors = []
for template_name in env.loader.list_templates():
    template = env.get_template(template_name)
    issues = check_a11y(template._optimized_ast)
    for issue in issues:
        errors.append((template_name, issue))

if errors:
    for name, issue in errors:
        print(f"{name}:{issue.lineno} [{issue.rule}] {issue.message}")
    raise SystemExit(1)

Examples of Violations and Fixes

img-alt

Missingalt attribute on an <img>tag.

<!-- violation -->
<img src="hero.jpg">

<!-- fix: add descriptive alt text -->
<img src="hero.jpg" alt="Mountain landscape at sunset">

<!-- fix: decorative image — mark as presentational -->
<img src="divider.png" role="presentation">

heading-order

Heading levels must increase by one. Skipping from<h1> to <h3>is a violation.

<!-- violation -->
<h1>Page Title</h1>
<h3>Subsection</h3>

<!-- fix -->
<h1>Page Title</h1>
<h2>Subsection</h2>

html-lang

The<html>element must declare a language.

<!-- violation -->
<html>

<!-- fix -->
<html lang="en">

input-label

Form elements need an associated label. The linter accepts any of: a<label> with a matching for attribute, aria-label, aria-labelledby, or title.

<!-- violation -->
<input type="text" name="email">

<!-- fix: explicit label -->
<label for="email">Email</label>
<input type="text" name="email" id="email">

<!-- fix: aria-label -->
<input type="text" name="email" aria-label="Email address">

<!-- exempt: hidden inputs are skipped -->
<input type="hidden" name="csrf_token" value="abc">

Configuration

Thecheck_a11y()function runs all rules unconditionally. To filter results by rule or severity, post-process the returned list:

issues = check_a11y(template._optimized_ast)

# Only errors
errors = [i for i in issues if i.severity == "error"]

# Exclude specific rules
filtered = [i for i in issues if i.rule not in {"heading-order"}]

# Group by rule
from itertools import groupby
by_rule = {k: list(v) for k, v in groupby(issues, key=lambda i: i.rule)}

To enforce a strict policy in CI, treat all issues as failures:

issues = check_a11y(template._optimized_ast)
if issues:
    for issue in issues:
        print(f"  Line {issue.lineno}: [{issue.rule}] {issue.message}")
    raise SystemExit(1)

To enforce only errors (ignoring warnings):

errors = [i for i in check_a11y(template._optimized_ast) if i.severity == "error"]
if errors:
    raise SystemExit(1)

See Also