fix(lint): enforce accessible SVG viewBox and title rules (#123)

* fix(lint): enforce viewBox presence and title length cap

- Add a11y check: non-decorative <svg> must carry a viewBox attribute
  with exactly four numeric tokens and positive width/height dimensions.
- Add a11y check: <title> text must be 60 characters or fewer, closing
  the gap between the CONTRIBUTING.md spec and linter enforcement.
- Update VALID_SVG in test-lint-a11y.py to include viewBox so all
  existing passing cases remain valid after the new checks land.
- Add three adversarial cases: missing-viewbox, invalid-viewbox,
  title-too-long — all verified against the gate suite (47/47 OK).

* fix(lint): accept SVG comma separators and reject non-finite viewBox values

- Re-split viewBox on [\s,]+ so comma-separated values (e.g. 0,0,800,600)
  are accepted as valid SVG
- Guard each component with math.isfinite() so values like inf and nan are
  rejected rather than passing the > 0 size check
- Add adversarial test cases: viewbox-comma-separated (pass),
  viewbox-nonfinite-width, viewbox-nonfinite-height (both fail)
- Bump plugin manifests to 2.4.1

* fix(lint): reject viewBox numbers with Python-only underscore separators

* test(lint): cover remaining viewBox cases

---------

Co-authored-by: Rupam Pal <rupam.pal@euromonitor.com>
This commit is contained in:
Cathryn Lavery
2026-08-19 19:53:43 -07:00
committed by GitHub
co-authored by Rupam Pal
parent cc2f51f3fd
commit 91fb4f243b
5 changed files with 98 additions and 5 deletions
+33 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import argparse
import hashlib
import math
import re
import sys
from dataclasses import dataclass, field
@@ -349,6 +350,30 @@ def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False)
if (svg.attrs.get("role") or "").casefold() != "img":
add(svg.line, svg.offset, 'diagram <svg> must carry role="img"')
viewbox = svg.attrs.get("viewbox") # HTMLParser lowercases all attribute names
if not viewbox:
add(svg.line, svg.offset, "diagram <svg> must have a viewBox attribute")
else:
parts = re.split(r"[\s,]+", viewbox.strip())
valid = False
_svg_num_re = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
if len(parts) == 4 and all(_svg_num_re.match(p) for p in parts):
try:
nums = [float(p) for p in parts]
valid = (
all(math.isfinite(n) for n in nums)
and nums[2] > 0
and nums[3] > 0
)
except ValueError:
pass
if not valid:
add(
svg.line,
svg.offset,
f'viewBox "{viewbox}" is not valid (expected "min-x min-y width height" with positive size)',
)
labelled_by = (svg.attrs.get("aria-labelledby") or "").split()
accessible_name_ids = labelled_by + [
element.element_id
@@ -402,8 +427,15 @@ def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False)
title.offset,
"<title> must be the first child element of <svg>",
)
if not "".join(title.text).strip():
title_text = "".join(title.text).strip()
if not title_text:
add(title.line, title.offset, "<title> must not be empty")
elif len(title_text) > 60:
add(
title.line,
title.offset,
f"<title> must be 60 characters or fewer (got {len(title_text)})",
)
if description is None:
add(svg.line, svg.offset, "diagram <svg> must contain a <desc>")
+62 -1
View File
@@ -18,7 +18,7 @@ BUILD_ICONS = ROOT / "scripts/build-icons.py"
MOTION_TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
VALID_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" role="img"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" role="img"
aria-labelledby="fixture-title fixture-desc">
<title id="fixture-title">Fixture diagram</title>
<desc id="fixture-desc">Diagram showing a fixture connected to a result.</desc>
@@ -156,6 +156,67 @@ def main() -> int:
'duplicate accessible-name id="fixture-desc" is not allowed',
directory,
)
require_failure(
"missing-viewbox",
VALID_SVG.replace(' viewBox="0 0 800 600"', ""),
"diagram <svg> must have a viewBox attribute",
directory,
)
require_failure(
"invalid-viewbox",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 -10 100"'),
'viewBox "0 0 -10 100" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_failure(
"viewbox-zero-height",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 800 0"'),
'viewBox "0 0 800 0" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_failure(
"viewbox-malformed-token",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 800px 600"'),
'viewBox "0 0 800px 600" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_failure(
"title-too-long",
VALID_SVG.replace("Fixture diagram", "A" * 61),
"<title> must be 60 characters or fewer (got 61)",
directory,
)
require_pass(
"viewbox-comma-separated",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0,0,800,600"').replace(
"fixture-title", "viewbox-comma-separated-title"
).replace("fixture-desc", "viewbox-comma-separated-desc"),
directory,
)
require_failure(
"viewbox-nonfinite-width",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 inf 600"').replace(
"fixture-title", "viewbox-nonfinite-width-title"
).replace("fixture-desc", "viewbox-nonfinite-width-desc"),
'viewBox "0 0 inf 600" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_failure(
"viewbox-nonfinite-height",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 800 inf"').replace(
"fixture-title", "viewbox-nonfinite-height-title"
).replace("fixture-desc", "viewbox-nonfinite-height-desc"),
'viewBox "0 0 800 inf" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_failure(
"viewbox-underscore-width",
VALID_SVG.replace('viewBox="0 0 800 600"', 'viewBox="0 0 1_000 600"').replace(
"fixture-title", "viewbox-underscore-width-title"
).replace("fixture-desc", "viewbox-underscore-width-desc"),
'viewBox "0 0 1_000 600" is not valid (expected "min-x min-y width height" with positive size)',
directory,
)
require_pass(
"decorative",
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'