Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Current accessibility checks focus on objective failures only:
- Non-descriptive link text such as `click here`, `here`, `read more`, and Spanish equivalents like `haga clic aquí`
- `no label` and empty/missing labels on multi-field screens (allowed on single-field screens)
- Low contrast in custom Bootstrap theme CSS loaded by `features: bootstrap theme`; inspects actual CSS values for body text, navbar, dropdown menu, and buttons (minimum ratio 4.5:1)
- Templates used with `display_template()` that have a missing or empty `subject`

Optional runtime-gated accessibility checks:

Expand Down
61 changes: 60 additions & 1 deletion src/dayamlchecker/accessibility.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from dataclasses import dataclass, field
from pathlib import Path
import re
from typing import Any, Optional
from typing import Any, Iterable, Optional
from dayamlchecker.messages import Finding, FindingDraft, MessageId, draft

TEXT_SECTION_KEYS = ("question", "subquestion", "under", "help", "note", "html")
Expand Down Expand Up @@ -211,6 +211,10 @@ class TextSection:
)
_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[(.*?)\]\((.*?)\)")
_HTML_LINK_RE = re.compile(r"<a\b([^>]*)>(.*?)</a>", re.IGNORECASE | re.DOTALL)
_DISPLAY_TEMPLATE_CALL_RE = re.compile(
r"\bdisplay_template\s*\(\s*"
r"([A-Za-z_]\w*(?:\s*(?:\.\s*[A-Za-z_]\w*|\[[^\]\r\n]+\]))*)"
)
_CSS_RULE_RE = re.compile(r"(?s)([^{}]+)\{([^{}]+)\}")
_HEX_COLOR_RE = re.compile(r"^#([0-9a-f]{3}|[0-9a-f]{6})$", re.IGNORECASE)
_RGB_COLOR_RE = re.compile(r"rgba?\(([^\)]+)\)", re.IGNORECASE)
Expand Down Expand Up @@ -336,6 +340,61 @@ def find_accessibility_findings(
return unique_findings


def find_display_template_subject_findings(
*, parsed_docs: Iterable[Any], input_file: Optional[str] = None
) -> list[AccessibilityFinding]:
"""Warn when a locally defined template displayed in the interview lacks a subject."""
docs = list(parsed_docs)
displayed_template_names = {
template_name
for parsed_doc in docs
for value in _iter_string_values(parsed_doc.doc)
for template_name in _display_template_names(value)
}
if not displayed_template_names:
return []

findings: list[AccessibilityFinding] = []
seen_template_names: set[str] = set()
for parsed_doc in docs:
template_name = str(parsed_doc.doc.get("template") or "").strip()
if (
not template_name
or template_name not in displayed_template_names
or template_name in seen_template_names
or str(parsed_doc.doc.get("subject") or "").strip()
):
continue
seen_template_names.add(template_name)
line_key = "subject" if "subject" in parsed_doc.doc else "template"
findings.append(
AccessibilityFinding(
message_id=MessageId.ACCESSIBILITY_DISPLAY_TEMPLATE_MISSING_SUBJECT,
file_name=input_file,
line_number=parsed_doc.line_for_key(line_key),
context={"template_name": template_name},
)
)
return findings


def _iter_string_values(value: Any) -> Iterable[str]:
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, item in value.items():
if key != "__line__":
yield from _iter_string_values(item)
elif isinstance(value, list):
for item in value:
yield from _iter_string_values(item)


def _display_template_names(value: str) -> Iterable[str]:
for match in _DISPLAY_TEMPLATE_CALL_RE.finditer(value):
yield re.sub(r"\s+", "", match.group(1))


def _check_combobox_usage(
doc: dict[str, Any],
source_code: str,
Expand Down
14 changes: 14 additions & 0 deletions src/dayamlchecker/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class MessageId(StrEnum):
"accessibility_generic_validation_message"
)
ACCESSIBILITY_AMBIGUOUS_BUTTON_TEXT = "accessibility_ambiguous_button_text"
ACCESSIBILITY_DISPLAY_TEMPLATE_MISSING_SUBJECT = (
"accessibility_display_template_missing_subject"
)

# Translatability
TRANSLATABILITY_CHOICES_WITHOUT_INVARIANT_VALUES = (
Expand Down Expand Up @@ -874,6 +877,17 @@ class MessageDefinition:
summary="Button text may be too vague",
template="button text may be too vague out of context: {snippet}",
),
MessageId.ACCESSIBILITY_DISPLAY_TEMPLATE_MISSING_SUBJECT: MessageDefinition(
code="WA529",
severity=Severity.WARNING,
finding_class=FindingClass.ACCESSIBILITY,
summary="Displayed template is missing an accessible subject",
template=(
"template `{template_name}` is used with `display_template()` but has "
"no non-empty `subject`; add a descriptive subject to label the "
"displayed content"
),
),
# Translatability
MessageId.TRANSLATABILITY_CHOICES_WITHOUT_INVARIANT_VALUES: MessageDefinition(
code="WT701",
Expand Down
8 changes: 8 additions & 0 deletions src/dayamlchecker/yaml_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from dayamlchecker.accessibility import (
AccessibilityLintOptions,
find_accessibility_findings,
find_display_template_subject_findings,
)
from dayamlchecker.messages import Finding, FindingClass, MessageId, draft, make_finding
from dayamlchecker.style import (
Expand Down Expand Up @@ -2186,6 +2187,13 @@ def find_errors_from_string(
all_errors.extend(
_find_interview_level_findings(parsed_docs, input_file=input_file)
)
if lint_mode == ACCESSIBILITY_LINT_MODE:
all_errors.extend(
find_display_template_subject_findings(
parsed_docs=parsed_docs,
input_file=input_file,
)
)
style_options = runtime_options.style_options()
if style_options.enabled and not has_yaml_parse_errors:
all_errors.extend(
Expand Down
66 changes: 66 additions & 0 deletions tests/test_yaml_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,72 @@ def test_accessibility_mode_disabled_by_default(self):
f"Did not expect accessibility errors in default mode, got: {accessibility_errors}",
)

def test_accessibility_display_template_missing_subject_warns(self):
yaml_content = """template: terms_of_use
content: |
These are the terms.
---
question: Review the terms
subquestion: |
${ display_template(terms_of_use) }
"""
errs = find_errors_from_string(
yaml_content,
input_file="<string_invalid>",
lint_mode="accessibility",
)
warning = next(err for err in errs if err.code == "WA529")
self.assertEqual(warning.line_number, 1)
self.assertEqual(warning.context["template_name"], "terms_of_use")

def test_accessibility_display_template_empty_subject_warns(self):
yaml_content = """question: Review the terms
subquestion: ${ display_template(terms_of_use) }
---
template: terms_of_use
subject: " "
content: These are the terms.
"""
errs = find_errors_from_string(
yaml_content,
input_file="<string_invalid>",
lint_mode="accessibility",
)
warning = next(err for err in errs if err.code == "WA529")
self.assertEqual(warning.line_number, 5)

def test_accessibility_display_template_nonempty_subject_allowed(self):
yaml_content = """question: Review the terms
subquestion: ${ display_template(terms_of_use) }
---
template: terms_of_use
subject: Terms of use
content: These are the terms.
"""
errs = find_errors_from_string(
yaml_content,
input_file="<string_valid>",
lint_mode="accessibility",
)
self.assertFalse(
_has_code(errs, "WA529"),
f"Did not expect a display-template subject warning, got: {errs}",
)

def test_accessibility_unused_template_without_subject_allowed(self):
yaml_content = """template: terms_of_use
content: These are the terms.
---
question: Continue
subquestion: No displayed template here.
"""
errs = find_errors_from_string(
yaml_content,
input_file="<string_valid>",
lint_mode="accessibility",
)
self.assertFalse(_has_code(errs, "WA529"))

def test_accessibility_markdown_image_missing_alt_text(self):
yaml_content = """question: |
![](docassemble.demo:data/static/logo.png)
Expand Down
Loading