@mcptoolshop/accessibility-suite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/.github/workflows/ci.yml +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -0
  4. package/docs/prov-spec/.github/workflows/ci.yml +68 -0
  5. package/docs/prov-spec/CHANGELOG.md +69 -0
  6. package/docs/prov-spec/CODE_OF_CONDUCT.md +129 -0
  7. package/docs/prov-spec/CONFORMANCE_LEVELS.md +223 -0
  8. package/docs/prov-spec/CONTRIBUTING.md +145 -0
  9. package/docs/prov-spec/IMPLEMENTER_CHECKLIST.md +137 -0
  10. package/docs/prov-spec/LICENSE +21 -0
  11. package/docs/prov-spec/PRESS_RELEASE.md +74 -0
  12. package/docs/prov-spec/README.md +182 -0
  13. package/docs/prov-spec/SETUP.md +135 -0
  14. package/docs/prov-spec/WHY.md +86 -0
  15. package/docs/prov-spec/examples/artifact.example.json +14 -0
  16. package/docs/prov-spec/examples/artifact.ref.example.json +9 -0
  17. package/docs/prov-spec/examples/evidence.example.json +6 -0
  18. package/docs/prov-spec/examples/mcp.envelope.example.json +97 -0
  19. package/docs/prov-spec/examples/mcp.request.example.json +28 -0
  20. package/docs/prov-spec/examples/prov.record.example.json +35 -0
  21. package/docs/prov-spec/interop/PROOF_NODE_ENGINE.md +114 -0
  22. package/docs/prov-spec/spec/MCP_COMPATIBILITY.md +241 -0
  23. package/docs/prov-spec/spec/PROV_METHODS_CATALOG.md +142 -0
  24. package/docs/prov-spec/spec/PROV_METHODS_SPEC.md +397 -0
  25. package/docs/prov-spec/spec/methods.json +213 -0
  26. package/docs/prov-spec/spec/schemas/artifact.ref.schema.v0.1.json +58 -0
  27. package/docs/prov-spec/spec/schemas/artifact.schema.v0.1.json +61 -0
  28. package/docs/prov-spec/spec/schemas/assist.request.schema.v0.1.json +52 -0
  29. package/docs/prov-spec/spec/schemas/assist.response.schema.v0.1.json +70 -0
  30. package/docs/prov-spec/spec/schemas/cli.error.schema.v0.1.json +78 -0
  31. package/docs/prov-spec/spec/schemas/evidence.schema.v0.1.json +37 -0
  32. package/docs/prov-spec/spec/schemas/mcp.envelope.schema.v0.1.json +141 -0
  33. package/docs/prov-spec/spec/schemas/mcp.request.schema.v0.1.json +79 -0
  34. package/docs/prov-spec/spec/schemas/methods.schema.json +93 -0
  35. package/docs/prov-spec/spec/schemas/prov-capabilities.schema.json +122 -0
  36. package/docs/prov-spec/spec/schemas/prov.record.schema.v0.1.json +133 -0
  37. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/expected.json +4 -0
  38. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/input.json +1 -0
  39. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/double_wrapped.json +14 -0
  40. package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/wrong_schema_version.json +11 -0
  41. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/expected.json +24 -0
  42. package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/input.json +8 -0
  43. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/expected.json +7 -0
  44. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/input.json +1 -0
  45. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/non_hex_chars.json +16 -0
  46. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/uppercase_hex.json +16 -0
  47. package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/wrong_length.json +16 -0
  48. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/hyphen_separator.json +8 -0
  49. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/reserved_namespace.json +8 -0
  50. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/starts_with_digit.json +8 -0
  51. package/docs/prov-spec/spec/vectors/method_id_syntax/negative/uppercase.json +8 -0
  52. package/docs/prov-spec/spec/vectors/method_id_syntax/positive/valid_ids.json +18 -0
  53. package/docs/prov-spec/tools/python/prov_validator.py +428 -0
  54. package/examples/a11y-demo-site/.github/workflows/a11y-artifacts.yml +81 -0
  55. package/examples/a11y-demo-site/.github/workflows/a11y.yml +34 -0
  56. package/examples/a11y-demo-site/CODE_OF_CONDUCT.md +129 -0
  57. package/examples/a11y-demo-site/CONTRIBUTING.md +83 -0
  58. package/examples/a11y-demo-site/LICENSE +21 -0
  59. package/examples/a11y-demo-site/README.md +155 -0
  60. package/examples/a11y-demo-site/html/contact.html +15 -0
  61. package/examples/a11y-demo-site/html/index.html +20 -0
  62. package/examples/a11y-demo-site/scripts/a11y.sh +20 -0
  63. package/package.json +26 -0
  64. package/src/a11y-assist/.github/workflows/publish.yml +52 -0
  65. package/src/a11y-assist/.github/workflows/test.yml +30 -0
  66. package/src/a11y-assist/A11Y_ASSIST_TEST_COVERAGE_REQUIREMENTS.md +104 -0
  67. package/src/a11y-assist/CODE_OF_CONDUCT.md +129 -0
  68. package/src/a11y-assist/CONTRIBUTING.md +98 -0
  69. package/src/a11y-assist/ENGINE.md +363 -0
  70. package/src/a11y-assist/LICENSE +21 -0
  71. package/src/a11y-assist/PRESS_RELEASE.md +71 -0
  72. package/src/a11y-assist/QUICKSTART.md +101 -0
  73. package/src/a11y-assist/README.md +192 -0
  74. package/src/a11y-assist/RELEASE_NOTES.md +319 -0
  75. package/src/a11y-assist/a11y_assist/__init__.py +3 -0
  76. package/src/a11y-assist/a11y_assist/cli.py +599 -0
  77. package/src/a11y-assist/a11y_assist/from_cli_error.py +149 -0
  78. package/src/a11y-assist/a11y_assist/guard.py +444 -0
  79. package/src/a11y-assist/a11y_assist/ingest.py +407 -0
  80. package/src/a11y-assist/a11y_assist/methods.py +137 -0
  81. package/src/a11y-assist/a11y_assist/parse_raw.py +71 -0
  82. package/src/a11y-assist/a11y_assist/profiles/__init__.py +29 -0
  83. package/src/a11y-assist/a11y_assist/profiles/cognitive_load.py +245 -0
  84. package/src/a11y-assist/a11y_assist/profiles/cognitive_load_render.py +86 -0
  85. package/src/a11y-assist/a11y_assist/profiles/dyslexia.py +144 -0
  86. package/src/a11y-assist/a11y_assist/profiles/dyslexia_render.py +77 -0
  87. package/src/a11y-assist/a11y_assist/profiles/plain_language.py +119 -0
  88. package/src/a11y-assist/a11y_assist/profiles/plain_language_render.py +66 -0
  89. package/src/a11y-assist/a11y_assist/profiles/screen_reader.py +348 -0
  90. package/src/a11y-assist/a11y_assist/profiles/screen_reader_render.py +89 -0
  91. package/src/a11y-assist/a11y_assist/render.py +95 -0
  92. package/src/a11y-assist/a11y_assist/schemas/assist.request.schema.v0.1.json +52 -0
  93. package/src/a11y-assist/a11y_assist/schemas/assist.response.schema.v0.1.json +70 -0
  94. package/src/a11y-assist/a11y_assist/schemas/cli.error.schema.v0.1.json +78 -0
  95. package/src/a11y-assist/a11y_assist/storage.py +31 -0
  96. package/src/a11y-assist/pyproject.toml +60 -0
  97. package/src/a11y-assist/tests/__init__.py +1 -0
  98. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_high.json +18 -0
  99. package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_medium.json +16 -0
  100. package/src/a11y-assist/tests/fixtures/base_inputs/raw_text_low.txt +3 -0
  101. package/src/a11y-assist/tests/fixtures/cli_error_good.json +9 -0
  102. package/src/a11y-assist/tests/fixtures/cli_error_missing_id.json +7 -0
  103. package/src/a11y-assist/tests/fixtures/cli_error_string_format.json +7 -0
  104. package/src/a11y-assist/tests/fixtures/expected/cognitive_load_high.txt +20 -0
  105. package/src/a11y-assist/tests/fixtures/expected/dyslexia_high.txt +20 -0
  106. package/src/a11y-assist/tests/fixtures/expected/lowvision_high.txt +18 -0
  107. package/src/a11y-assist/tests/fixtures/expected/plain_language_high.txt +14 -0
  108. package/src/a11y-assist/tests/fixtures/expected/screen_reader_high.txt +19 -0
  109. package/src/a11y-assist/tests/fixtures/golden_screen_reader_cli_error.txt +16 -0
  110. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_no_id.txt +14 -0
  111. package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_with_id.txt +14 -0
  112. package/src/a11y-assist/tests/fixtures/raw_good.txt +11 -0
  113. package/src/a11y-assist/tests/fixtures/raw_no_id.txt +2 -0
  114. package/src/a11y-assist/tests/test_cognitive_load.py +469 -0
  115. package/src/a11y-assist/tests/test_dyslexia.py +337 -0
  116. package/src/a11y-assist/tests/test_explain.py +74 -0
  117. package/src/a11y-assist/tests/test_golden.py +127 -0
  118. package/src/a11y-assist/tests/test_guard.py +819 -0
  119. package/src/a11y-assist/tests/test_guard_integration.py +457 -0
  120. package/src/a11y-assist/tests/test_ingest.py +311 -0
  121. package/src/a11y-assist/tests/test_methods_metadata.py +236 -0
  122. package/src/a11y-assist/tests/test_plain_language.py +348 -0
  123. package/src/a11y-assist/tests/test_render.py +117 -0
  124. package/src/a11y-assist/tests/test_screen_reader.py +703 -0
  125. package/src/a11y-assist/tests/test_storage_last.py +61 -0
  126. package/src/a11y-assist/tests/test_triage.py +86 -0
  127. package/src/a11y-ci/.github/workflows/ci.yml +43 -0
  128. package/src/a11y-ci/.github/workflows/test.yml +30 -0
  129. package/src/a11y-ci/A11Y_CI_TEST_COVERAGE_REQUIREMENTS.md +94 -0
  130. package/src/a11y-ci/CODE_OF_CONDUCT.md +129 -0
  131. package/src/a11y-ci/CONTRIBUTING.md +142 -0
  132. package/src/a11y-ci/LICENSE +21 -0
  133. package/src/a11y-ci/README.md +105 -0
  134. package/src/a11y-ci/a11y_ci/__init__.py +3 -0
  135. package/src/a11y-ci/a11y_ci/allowlist.py +83 -0
  136. package/src/a11y-ci/a11y_ci/cli.py +145 -0
  137. package/src/a11y-ci/a11y_ci/gate.py +131 -0
  138. package/src/a11y-ci/a11y_ci/render.py +48 -0
  139. package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +24 -0
  140. package/src/a11y-ci/a11y_ci/scorecard.py +99 -0
  141. package/src/a11y-ci/npm/package.json +35 -0
  142. package/src/a11y-ci/pyproject.toml +64 -0
  143. package/src/a11y-ci/tests/__init__.py +1 -0
  144. package/src/a11y-ci/tests/fixtures/allowlist_expired.json +10 -0
  145. package/src/a11y-ci/tests/fixtures/allowlist_ok.json +10 -0
  146. package/src/a11y-ci/tests/fixtures/baseline_ok.json +7 -0
  147. package/src/a11y-ci/tests/fixtures/current_fail.json +6 -0
  148. package/src/a11y-ci/tests/fixtures/current_ok.json +6 -0
  149. package/src/a11y-ci/tests/fixtures/current_regresses.json +7 -0
  150. package/src/a11y-ci/tests/test_gate.py +134 -0
  151. package/src/a11y-evidence-engine/.github/workflows/ci.yml +53 -0
  152. package/src/a11y-evidence-engine/CODE_OF_CONDUCT.md +129 -0
  153. package/src/a11y-evidence-engine/CONTRIBUTING.md +128 -0
  154. package/src/a11y-evidence-engine/LICENSE +21 -0
  155. package/src/a11y-evidence-engine/README.md +71 -0
  156. package/src/a11y-evidence-engine/bin/a11y-engine.js +11 -0
  157. package/src/a11y-evidence-engine/fixtures/bad/button-no-name.html +30 -0
  158. package/src/a11y-evidence-engine/fixtures/bad/img-missing-alt.html +19 -0
  159. package/src/a11y-evidence-engine/fixtures/bad/input-missing-label.html +26 -0
  160. package/src/a11y-evidence-engine/fixtures/bad/missing-lang.html +11 -0
  161. package/src/a11y-evidence-engine/fixtures/good/index.html +29 -0
  162. package/src/a11y-evidence-engine/package-lock.json +109 -0
  163. package/src/a11y-evidence-engine/package.json +45 -0
  164. package/src/a11y-evidence-engine/src/cli.js +74 -0
  165. package/src/a11y-evidence-engine/src/evidence/canonicalize.js +52 -0
  166. package/src/a11y-evidence-engine/src/evidence/json_pointer.js +34 -0
  167. package/src/a11y-evidence-engine/src/evidence/prov_emit.js +153 -0
  168. package/src/a11y-evidence-engine/src/fswalk.js +56 -0
  169. package/src/a11y-evidence-engine/src/html_parse.js +117 -0
  170. package/src/a11y-evidence-engine/src/ids.js +53 -0
  171. package/src/a11y-evidence-engine/src/rules/document_missing_lang.js +50 -0
  172. package/src/a11y-evidence-engine/src/rules/form_control_missing_label.js +105 -0
  173. package/src/a11y-evidence-engine/src/rules/img_missing_alt.js +77 -0
  174. package/src/a11y-evidence-engine/src/rules/index.js +37 -0
  175. package/src/a11y-evidence-engine/src/rules/interactive_missing_name.js +129 -0
  176. package/src/a11y-evidence-engine/src/scan.js +128 -0
  177. package/src/a11y-evidence-engine/test/scan.test.js +149 -0
  178. package/src/a11y-evidence-engine/test/vectors.test.js +200 -0
  179. package/src/a11y-lint/.github/workflows/ci.yml +46 -0
  180. package/src/a11y-lint/.github/workflows/test.yml +34 -0
  181. package/src/a11y-lint/CODE_OF_CONDUCT.md +129 -0
  182. package/src/a11y-lint/CONTRIBUTING.md +70 -0
  183. package/src/a11y-lint/GOVERNANCE.md +57 -0
  184. package/src/a11y-lint/LICENSE +21 -0
  185. package/src/a11y-lint/PRESS_RELEASE.md +50 -0
  186. package/src/a11y-lint/README.md +276 -0
  187. package/src/a11y-lint/RELEASE_NOTES.md +57 -0
  188. package/src/a11y-lint/RELEASING.md +57 -0
  189. package/src/a11y-lint/a11y_lint/__init__.py +64 -0
  190. package/src/a11y-lint/a11y_lint/cli.py +319 -0
  191. package/src/a11y-lint/a11y_lint/errors.py +252 -0
  192. package/src/a11y-lint/a11y_lint/render.py +293 -0
  193. package/src/a11y-lint/a11y_lint/report_md.py +289 -0
  194. package/src/a11y-lint/a11y_lint/scan_cli_text.py +434 -0
  195. package/src/a11y-lint/a11y_lint/schemas/cli.error.schema.v0.1.json +83 -0
  196. package/src/a11y-lint/a11y_lint/scorecard.py +244 -0
  197. package/src/a11y-lint/a11y_lint/validate.py +225 -0
  198. package/src/a11y-lint/pyproject.toml +75 -0
  199. package/src/a11y-lint/tests/__init__.py +1 -0
  200. package/src/a11y-lint/tests/test_cli.py +200 -0
  201. package/src/a11y-lint/tests/test_errors.py +188 -0
  202. package/src/a11y-lint/tests/test_render.py +202 -0
  203. package/src/a11y-lint/tests/test_report_md.py +188 -0
  204. package/src/a11y-lint/tests/test_scan_cli_text.py +290 -0
  205. package/src/a11y-lint/tests/test_scorecard.py +195 -0
  206. package/src/a11y-lint/tests/test_validate.py +257 -0
  207. package/src/a11y-mcp-tools/.github/workflows/ci.yml +53 -0
  208. package/src/a11y-mcp-tools/CODE_OF_CONDUCT.md +129 -0
  209. package/src/a11y-mcp-tools/CONTRIBUTING.md +136 -0
  210. package/src/a11y-mcp-tools/LICENSE +21 -0
  211. package/src/a11y-mcp-tools/PROV_METHODS_CATALOG.md +104 -0
  212. package/src/a11y-mcp-tools/README.md +168 -0
  213. package/src/a11y-mcp-tools/bin/cli.js +452 -0
  214. package/src/a11y-mcp-tools/bin/server.js +244 -0
  215. package/src/a11y-mcp-tools/fixtures/requests/a11y.diagnose.ok.json +27 -0
  216. package/src/a11y-mcp-tools/fixtures/requests/a11y.evidence.ok.json +25 -0
  217. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.ok.json +139 -0
  218. package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.provenance_fail.json +13 -0
  219. package/src/a11y-mcp-tools/fixtures/responses/a11y.evidence.ok.json +88 -0
  220. package/src/a11y-mcp-tools/package-lock.json +189 -0
  221. package/src/a11y-mcp-tools/package.json +49 -0
  222. package/src/a11y-mcp-tools/src/envelope.js +197 -0
  223. package/src/a11y-mcp-tools/src/index.js +9 -0
  224. package/src/a11y-mcp-tools/src/schemas/artifact.js +85 -0
  225. package/src/a11y-mcp-tools/src/schemas/diagnosis.schema.v0.1.json +137 -0
  226. package/src/a11y-mcp-tools/src/schemas/envelope.schema.v0.1.json +108 -0
  227. package/src/a11y-mcp-tools/src/schemas/evidence.bundle.schema.v0.1.json +129 -0
  228. package/src/a11y-mcp-tools/src/schemas/evidence.js +97 -0
  229. package/src/a11y-mcp-tools/src/schemas/index.js +11 -0
  230. package/src/a11y-mcp-tools/src/schemas/provenance.js +140 -0
  231. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.request.schema.v0.1.json +77 -0
  232. package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.response.schema.v0.1.json +50 -0
  233. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.request.schema.v0.1.json +120 -0
  234. package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.response.schema.v0.1.json +50 -0
  235. package/src/a11y-mcp-tools/src/tools/diagnose.js +597 -0
  236. package/src/a11y-mcp-tools/src/tools/evidence.js +481 -0
  237. package/src/a11y-mcp-tools/src/tools/index.js +10 -0
  238. package/src/a11y-mcp-tools/test/contract.test.mjs +154 -0
  239. package/src/a11y-mcp-tools/test/diagnose.test.js +485 -0
  240. package/src/a11y-mcp-tools/test/evidence.test.js +183 -0
  241. package/src/a11y-mcp-tools/test/schema.test.js +327 -0
@@ -0,0 +1,293 @@
1
+ """Deterministic renderer for CLI accessibility messages.
2
+
3
+ Renders messages in the canonical [OK]/[WARN]/[ERROR] + What/Why/Fix format.
4
+ All output is deterministic and suitable for comparison/testing.
5
+
6
+ Color Philosophy:
7
+ - Colors are purely decorative; meaning NEVER depends on color
8
+ - Default: auto (respects NO_COLOR env var and terminal detection)
9
+ - The plain text output is always fully meaningful without colors
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import TextIO
16
+ import sys
17
+
18
+ from .errors import A11yMessage, Level
19
+
20
+
21
+ def should_use_color(stream: TextIO | None = None) -> bool:
22
+ """Determine if color should be used based on environment.
23
+
24
+ Respects:
25
+ - NO_COLOR env var (https://no-color.org/)
26
+ - FORCE_COLOR env var
27
+ - Terminal detection (isatty)
28
+
29
+ Args:
30
+ stream: Output stream to check (default: stdout)
31
+
32
+ Returns:
33
+ True if colors should be used
34
+ """
35
+ # NO_COLOR takes precedence (https://no-color.org/)
36
+ if os.environ.get("NO_COLOR"):
37
+ return False
38
+
39
+ # FORCE_COLOR overrides terminal detection
40
+ if os.environ.get("FORCE_COLOR"):
41
+ return True
42
+
43
+ # Check if stream is a TTY
44
+ stream = stream or sys.stdout
45
+ return hasattr(stream, "isatty") and stream.isatty()
46
+
47
+
48
+ # ANSI color codes for terminal output
49
+ class Colors:
50
+ """ANSI color codes for terminal rendering."""
51
+
52
+ RESET = "\033[0m"
53
+ BOLD = "\033[1m"
54
+ DIM = "\033[2m"
55
+
56
+ # Level colors
57
+ OK = "\033[32m" # Green
58
+ WARN = "\033[33m" # Yellow
59
+ ERROR = "\033[31m" # Red
60
+
61
+ # Component colors
62
+ CODE = "\033[36m" # Cyan
63
+ LOCATION = "\033[90m" # Gray
64
+ LABEL = "\033[1m" # Bold
65
+
66
+
67
+ def get_level_color(level: Level) -> str:
68
+ """Get the ANSI color code for a level."""
69
+ return {
70
+ Level.OK: Colors.OK,
71
+ Level.WARN: Colors.WARN,
72
+ Level.ERROR: Colors.ERROR,
73
+ }[level]
74
+
75
+
76
+ def render_plain(message: A11yMessage, indent: int = 0) -> str:
77
+ """Render a message as plain text (no colors).
78
+
79
+ This is the canonical output format that all messages must follow:
80
+ [LEVEL] CODE: What
81
+ Why: explanation
82
+ Fix: suggestion
83
+
84
+ Args:
85
+ message: The message to render
86
+ indent: Number of spaces to indent
87
+
88
+ Returns:
89
+ Plain text representation
90
+ """
91
+ prefix = " " * indent
92
+ lines = []
93
+
94
+ # Main line: [LEVEL] CODE: What
95
+ lines.append(f"{prefix}[{message.level}] {message.code}: {message.what}")
96
+
97
+ # Location (if present)
98
+ if message.location:
99
+ lines.append(f"{prefix} at {message.location}")
100
+
101
+ # Why (if present)
102
+ if message.why:
103
+ lines.append(f"{prefix} Why: {message.why}")
104
+
105
+ # Fix (if present)
106
+ if message.fix:
107
+ lines.append(f"{prefix} Fix: {message.fix}")
108
+
109
+ return "\n".join(lines)
110
+
111
+
112
+ def render_colored(message: A11yMessage, indent: int = 0) -> str:
113
+ """Render a message with ANSI colors for terminal display.
114
+
115
+ Args:
116
+ message: The message to render
117
+ indent: Number of spaces to indent
118
+
119
+ Returns:
120
+ Colored text representation
121
+ """
122
+ prefix = " " * indent
123
+ lines = []
124
+ level_color = get_level_color(message.level)
125
+
126
+ # Main line: [LEVEL] CODE: What
127
+ level_str = f"{level_color}{Colors.BOLD}[{message.level}]{Colors.RESET}"
128
+ code_str = f"{Colors.CODE}{message.code}{Colors.RESET}"
129
+ lines.append(f"{prefix}{level_str} {code_str}: {message.what}")
130
+
131
+ # Location (if present)
132
+ if message.location:
133
+ loc_str = f"{Colors.LOCATION}at {message.location}{Colors.RESET}"
134
+ lines.append(f"{prefix} {loc_str}")
135
+
136
+ # Why (if present)
137
+ if message.why:
138
+ lines.append(f"{prefix} {Colors.LABEL}Why:{Colors.RESET} {message.why}")
139
+
140
+ # Fix (if present)
141
+ if message.fix:
142
+ lines.append(f"{prefix} {Colors.LABEL}Fix:{Colors.RESET} {message.fix}")
143
+
144
+ return "\n".join(lines)
145
+
146
+
147
+ def render(
148
+ message: A11yMessage,
149
+ *,
150
+ color: bool = False,
151
+ indent: int = 0,
152
+ ) -> str:
153
+ """Render a message to string.
154
+
155
+ Args:
156
+ message: The message to render
157
+ color: Whether to include ANSI colors
158
+ indent: Number of spaces to indent
159
+
160
+ Returns:
161
+ String representation
162
+ """
163
+ if color:
164
+ return render_colored(message, indent)
165
+ return render_plain(message, indent)
166
+
167
+
168
+ def render_batch(
169
+ messages: list[A11yMessage],
170
+ *,
171
+ color: bool = False,
172
+ separator: str = "\n",
173
+ ) -> str:
174
+ """Render multiple messages.
175
+
176
+ Args:
177
+ messages: Messages to render
178
+ color: Whether to include ANSI colors
179
+ separator: String between messages
180
+
181
+ Returns:
182
+ Combined string representation
183
+ """
184
+ return separator.join(render(msg, color=color) for msg in messages)
185
+
186
+
187
+ class Renderer:
188
+ """Configurable message renderer with output stream support."""
189
+
190
+ def __init__(
191
+ self,
192
+ *,
193
+ color: bool | None = None,
194
+ stream: TextIO | None = None,
195
+ indent: int = 0,
196
+ ) -> None:
197
+ """Initialize the renderer.
198
+
199
+ Args:
200
+ color: Enable colors (None = auto-detect from terminal)
201
+ stream: Output stream (default: stdout)
202
+ indent: Base indentation level
203
+ """
204
+ self.stream = stream or sys.stdout
205
+ self.indent = indent
206
+
207
+ # Auto-detect color support (respects NO_COLOR env var)
208
+ if color is None:
209
+ self.color = should_use_color(self.stream)
210
+ else:
211
+ self.color = color
212
+
213
+ self._counts = {Level.OK: 0, Level.WARN: 0, Level.ERROR: 0}
214
+
215
+ def render(self, message: A11yMessage) -> str:
216
+ """Render a message to string."""
217
+ return render(message, color=self.color, indent=self.indent)
218
+
219
+ def write(self, message: A11yMessage) -> None:
220
+ """Render and write a message to the stream."""
221
+ self._counts[message.level] += 1
222
+ self.stream.write(self.render(message) + "\n")
223
+
224
+ def write_batch(self, messages: list[A11yMessage]) -> None:
225
+ """Render and write multiple messages."""
226
+ for msg in messages:
227
+ self.write(msg)
228
+
229
+ @property
230
+ def ok_count(self) -> int:
231
+ """Number of OK messages written."""
232
+ return self._counts[Level.OK]
233
+
234
+ @property
235
+ def warn_count(self) -> int:
236
+ """Number of WARN messages written."""
237
+ return self._counts[Level.WARN]
238
+
239
+ @property
240
+ def error_count(self) -> int:
241
+ """Number of ERROR messages written."""
242
+ return self._counts[Level.ERROR]
243
+
244
+ @property
245
+ def total_count(self) -> int:
246
+ """Total number of messages written."""
247
+ return sum(self._counts.values())
248
+
249
+ def summary_line(self) -> str:
250
+ """Get a summary line of counts."""
251
+ parts = []
252
+ if self.ok_count:
253
+ parts.append(f"{self.ok_count} passed")
254
+ if self.warn_count:
255
+ parts.append(f"{self.warn_count} warnings")
256
+ if self.error_count:
257
+ parts.append(f"{self.error_count} errors")
258
+
259
+ if not parts:
260
+ return "No issues found"
261
+
262
+ return ", ".join(parts)
263
+
264
+ def write_summary(self) -> None:
265
+ """Write a summary line to the stream."""
266
+ summary = self.summary_line()
267
+ if self.color:
268
+ # Color the summary based on worst level
269
+ if self.error_count:
270
+ summary = f"{Colors.ERROR}{summary}{Colors.RESET}"
271
+ elif self.warn_count:
272
+ summary = f"{Colors.WARN}{summary}{Colors.RESET}"
273
+ else:
274
+ summary = f"{Colors.OK}{summary}{Colors.RESET}"
275
+
276
+ self.stream.write(f"\n{summary}\n")
277
+
278
+
279
+ def format_for_file(messages: list[A11yMessage]) -> str:
280
+ """Format messages for writing to a file (no colors, consistent format).
281
+
282
+ Args:
283
+ messages: Messages to format
284
+
285
+ Returns:
286
+ File-ready string
287
+ """
288
+ lines = []
289
+ for msg in messages:
290
+ lines.append(render_plain(msg))
291
+ lines.append("") # Blank line between messages
292
+
293
+ return "\n".join(lines)
@@ -0,0 +1,289 @@
1
+ """Markdown report renderer for accessibility assessments.
2
+
3
+ Generates detailed markdown reports from scan results and scorecards.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+ from typing import TextIO
10
+ import io
11
+
12
+ from .errors import A11yMessage, Level
13
+ from .scorecard import Scorecard
14
+
15
+
16
+ def render_message_md(message: A11yMessage) -> str:
17
+ """Render a single message as markdown.
18
+
19
+ Args:
20
+ message: Message to render
21
+
22
+ Returns:
23
+ Markdown string
24
+ """
25
+ # Choose emoji based on level
26
+ emoji = {"OK": "✅", "WARN": "⚠️", "ERROR": "❌"}[message.level.value]
27
+
28
+ lines = [f"### {emoji} [{message.level}] {message.code}: {message.what}"]
29
+
30
+ if message.location:
31
+ loc_parts = []
32
+ if message.location.file:
33
+ loc_parts.append(f"`{message.location.file}`")
34
+ if message.location.line:
35
+ loc_parts.append(f"line {message.location.line}")
36
+ if message.location.column:
37
+ loc_parts.append(f"col {message.location.column}")
38
+ if loc_parts:
39
+ lines.append(f"\n**Location:** {' : '.join(loc_parts)}")
40
+
41
+ if message.location and message.location.context:
42
+ lines.append(f"\n```\n{message.location.context}\n```")
43
+
44
+ if message.why:
45
+ lines.append(f"\n**Why:** {message.why}")
46
+
47
+ if message.fix:
48
+ lines.append(f"\n**Fix:** {message.fix}")
49
+
50
+ if message.rule:
51
+ lines.append(f"\n*Rule: `{message.rule}`*")
52
+
53
+ return "\n".join(lines)
54
+
55
+
56
+ def render_scorecard_md(scorecard: Scorecard) -> str:
57
+ """Render a scorecard as markdown.
58
+
59
+ Args:
60
+ scorecard: Scorecard to render
61
+
62
+ Returns:
63
+ Markdown string
64
+ """
65
+ lines = [
66
+ f"# {scorecard.name}",
67
+ "",
68
+ f"**Overall Score:** {scorecard.overall_score:.1f}% ({scorecard.overall_grade})",
69
+ "",
70
+ "## Summary",
71
+ "",
72
+ "| Metric | Count |",
73
+ "|--------|-------|",
74
+ f"| Total Checks | {scorecard.total_checks} |",
75
+ f"| Passed | {scorecard.total_passed} |",
76
+ f"| Warnings | {scorecard.total_warnings} |",
77
+ f"| Errors | {scorecard.total_errors} |",
78
+ "",
79
+ ]
80
+
81
+ # Rule breakdown table
82
+ if scorecard.rule_scores:
83
+ lines.extend(
84
+ [
85
+ "## Rules",
86
+ "",
87
+ "| Rule | Score | Grade | Passed | Warnings | Errors |",
88
+ "|------|-------|-------|--------|----------|--------|",
89
+ ]
90
+ )
91
+
92
+ for name, score in sorted(scorecard.rule_scores.items()):
93
+ lines.append(
94
+ f"| `{name}` | {score.score:.1f}% | {score.grade} | "
95
+ f"{score.passed} | {score.warnings} | {score.errors} |"
96
+ )
97
+
98
+ lines.append("")
99
+
100
+ # Issues by level
101
+ errors = [m for m in scorecard.messages if m.level == Level.ERROR]
102
+ warnings = [m for m in scorecard.messages if m.level == Level.WARN]
103
+
104
+ if errors:
105
+ lines.extend(["## Errors", ""])
106
+ for msg in errors:
107
+ lines.append(render_message_md(msg))
108
+ lines.append("")
109
+
110
+ if warnings:
111
+ lines.extend(["## Warnings", ""])
112
+ for msg in warnings:
113
+ lines.append(render_message_md(msg))
114
+ lines.append("")
115
+
116
+ return "\n".join(lines)
117
+
118
+
119
+ def render_report_md(
120
+ messages: list[A11yMessage],
121
+ *,
122
+ title: str = "Accessibility Report",
123
+ include_timestamp: bool = True,
124
+ include_summary: bool = True,
125
+ ) -> str:
126
+ """Render a full report as markdown.
127
+
128
+ Args:
129
+ messages: Messages to include
130
+ title: Report title
131
+ include_timestamp: Whether to include generation timestamp
132
+ include_summary: Whether to include a summary section
133
+
134
+ Returns:
135
+ Markdown string
136
+ """
137
+ lines = [f"# {title}", ""]
138
+
139
+ if include_timestamp:
140
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
141
+ lines.extend([f"*Generated: {now}*", ""])
142
+
143
+ if include_summary:
144
+ error_count = sum(1 for m in messages if m.level == Level.ERROR)
145
+ warn_count = sum(1 for m in messages if m.level == Level.WARN)
146
+ ok_count = sum(1 for m in messages if m.level == Level.OK)
147
+
148
+ status = "✅ Passing" if error_count == 0 else "❌ Failing"
149
+ lines.extend(
150
+ [
151
+ "## Summary",
152
+ "",
153
+ f"**Status:** {status}",
154
+ "",
155
+ f"- Errors: {error_count}",
156
+ f"- Warnings: {warn_count}",
157
+ f"- Passed: {ok_count}",
158
+ "",
159
+ ]
160
+ )
161
+
162
+ # Group messages by level
163
+ errors = [m for m in messages if m.level == Level.ERROR]
164
+ warnings = [m for m in messages if m.level == Level.WARN]
165
+ passed = [m for m in messages if m.level == Level.OK]
166
+
167
+ if errors:
168
+ lines.extend(["## Errors", ""])
169
+ for msg in errors:
170
+ lines.append(render_message_md(msg))
171
+ lines.append("")
172
+
173
+ if warnings:
174
+ lines.extend(["## Warnings", ""])
175
+ for msg in warnings:
176
+ lines.append(render_message_md(msg))
177
+ lines.append("")
178
+
179
+ if passed:
180
+ lines.extend(["## Passed", ""])
181
+ for msg in passed:
182
+ lines.append(f"- ✅ `{msg.code}`: {msg.what}")
183
+ lines.append("")
184
+
185
+ return "\n".join(lines)
186
+
187
+
188
+ class MarkdownReporter:
189
+ """Reporter for generating markdown reports with configuration."""
190
+
191
+ def __init__(
192
+ self,
193
+ *,
194
+ title: str = "Accessibility Report",
195
+ include_timestamp: bool = True,
196
+ include_passed: bool = False,
197
+ ) -> None:
198
+ """Initialize the reporter.
199
+
200
+ Args:
201
+ title: Report title
202
+ include_timestamp: Include generation timestamp
203
+ include_passed: Include passed checks in detail
204
+ """
205
+ self.title = title
206
+ self.include_timestamp = include_timestamp
207
+ self.include_passed = include_passed
208
+
209
+ def render(self, messages: list[A11yMessage]) -> str:
210
+ """Render messages to markdown.
211
+
212
+ Args:
213
+ messages: Messages to render
214
+
215
+ Returns:
216
+ Markdown string
217
+ """
218
+ return render_report_md(
219
+ messages,
220
+ title=self.title,
221
+ include_timestamp=self.include_timestamp,
222
+ include_summary=True,
223
+ )
224
+
225
+ def render_scorecard(self, scorecard: Scorecard) -> str:
226
+ """Render a scorecard to markdown.
227
+
228
+ Args:
229
+ scorecard: Scorecard to render
230
+
231
+ Returns:
232
+ Markdown string
233
+ """
234
+ return render_scorecard_md(scorecard)
235
+
236
+ def write(self, messages: list[A11yMessage], stream: TextIO) -> None:
237
+ """Write markdown report to a stream.
238
+
239
+ Args:
240
+ messages: Messages to render
241
+ stream: Output stream
242
+ """
243
+ stream.write(self.render(messages))
244
+
245
+ def write_file(self, messages: list[A11yMessage], path: str) -> None:
246
+ """Write markdown report to a file.
247
+
248
+ Args:
249
+ messages: Messages to render
250
+ path: Output file path
251
+ """
252
+ with open(path, "w", encoding="utf-8") as f:
253
+ self.write(messages, f)
254
+
255
+
256
+ def generate_badge_md(score: float, label: str = "a11y") -> str:
257
+ """Generate a markdown badge showing the accessibility score.
258
+
259
+ NOTE: Badges are informational only. They do NOT imply WCAG conformance.
260
+ The score reflects policy compliance, not accessibility certification.
261
+
262
+ Args:
263
+ score: Score percentage (0-100)
264
+ label: Badge label
265
+
266
+ Returns:
267
+ Markdown image reference for shields.io badge
268
+ """
269
+ # Choose color based on score
270
+ if score >= 90:
271
+ color = "brightgreen"
272
+ elif score >= 70:
273
+ color = "yellow"
274
+ elif score >= 50:
275
+ color = "orange"
276
+ else:
277
+ color = "red"
278
+
279
+ # URL-encode the label and value
280
+ value = f"{score:.0f}%25" # %25 is URL-encoded %
281
+ return f"![{label}](https://img.shields.io/badge/{label}-{value}-{color})"
282
+
283
+
284
+ # Disclaimer for badges and scores
285
+ SCORE_DISCLAIMER = (
286
+ "**Note:** Scores and badges are informational only. They do NOT imply "
287
+ "WCAG conformance or accessibility certification. This tool checks policy "
288
+ "rules beyond minimum WCAG requirements."
289
+ )