diff --git a/README.md b/README.md index f88a13d..efe624d 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/dayamlchecker/accessibility.py b/src/dayamlchecker/accessibility.py index 12cee33..ef43a8a 100644 --- a/src/dayamlchecker/accessibility.py +++ b/src/dayamlchecker/accessibility.py @@ -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") @@ -211,6 +211,10 @@ class TextSection: ) _MARKDOWN_LINK_RE = re.compile(r"(?]*)>(.*?)", 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) @@ -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, diff --git a/src/dayamlchecker/messages.py b/src/dayamlchecker/messages.py index 7bb9431..2014cd1 100644 --- a/src/dayamlchecker/messages.py +++ b/src/dayamlchecker/messages.py @@ -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 = ( @@ -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", diff --git a/src/dayamlchecker/yaml_structure.py b/src/dayamlchecker/yaml_structure.py index 7eb5bd9..a9d1d4f 100644 --- a/src/dayamlchecker/yaml_structure.py +++ b/src/dayamlchecker/yaml_structure.py @@ -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 ( @@ -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( diff --git a/tests/test_yaml_structure.py b/tests/test_yaml_structure.py index 502b072..d083810 100644 --- a/tests/test_yaml_structure.py +++ b/tests/test_yaml_structure.py @@ -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="", + 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="", + 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="", + 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="", + 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)