@shalii/galaxy-a11y 0.0.0-stage → 0.1.4

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shalii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,283 @@
1
- # Temporary Holding Version
1
+ # @shalii/galaxy-a11y
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Static **WCAG 2.2 / ADA accessibility scanner for Galaxy CMS themes** (html, scss, js).
4
+ It understands Galaxy syntax (`#$variable|x$#`, `{if="…"}`, `[[id]]`, `__@@text@@__`), so every finding points to the real line in your real file and comes with the exact fix.
5
+
6
+ > Static scanning finds roughly a third to a half of WCAG problems. See [Limits](#6-limits) and the manual checklist at the end of every report.
7
+
8
+ ---
9
+
10
+ ## 1. Install and run
11
+
12
+ ### In a project (recommended)
13
+ ```bash
14
+ cd your-theme # folder that contains block/ list/ header/ sass/ src/ package.json
15
+ npm i -D @shalii/galaxy-a11y
16
+ npx galaxy-a11y .
17
+ ```
18
+
19
+ Add a script to the theme's `package.json` so anyone can run it:
20
+ ```json
21
+ "scripts": { "a11y": "galaxy-a11y . --html a11y-report.html" }
22
+ ```
23
+ ```bash
24
+ npm run a11y
25
+ ```
26
+
27
+ ### Globally (once per computer)
28
+ ```bash
29
+ npm i -g @shalii/galaxy-a11y
30
+ cd any-theme-folder
31
+ galaxy-a11y .
32
+ ```
33
+
34
+ > `npm install` only installs the tool. It does **not** scan anything until you run the command.
35
+
36
+ ### Run from source (without installing from npm)
37
+ ```bash
38
+ cd galaxy-a11y # this repository
39
+ npm install
40
+ node bin/galaxy-a11y.js "C:\path\to\your-theme" --fail-on none
41
+ ```
42
+ Use quotes around paths that contain spaces.
43
+
44
+ ---
45
+
46
+ ## 2. Options
47
+
48
+ | Option | What it does |
49
+ |---|---|
50
+ | `galaxy-a11y [folder]` | Folder or single file to scan (default: current folder) |
51
+ | `--html <file>` | Filterable HTML report. Use a path that exists, e.g. `a11y-report.html` |
52
+ | `--json [file]` | Machine-readable output (stdout if no file) |
53
+ | `--all` | Also show low-priority notes |
54
+ | `--disable A,B` | Turn rules off, e.g. `--disable SENSORY,ARIA-ROLE` |
55
+ | `--fail-on <level>` | Exit code 1 on `error` (default), `warning`, or `none` |
56
+ | `--limit <n>` | Max findings printed per file |
57
+ | `--no-fix` | Hide fix text |
58
+ | `-h`, `--help` | Help |
59
+
60
+ Severity levels: **error** (definite failure), **warning** (likely, verify), **manual** (tool cannot decide), **note** (`--all`).
61
+
62
+ ### Silence one finding
63
+ ```html
64
+ <!-- a11y-ignore: ALT -->
65
+ <img src="logo.svg" alt="">
66
+ ```
67
+ ```js
68
+ // a11y-ignore: TRAP
69
+ ```
70
+ Put the comment on the line above or the same line. Without a rule name it silences everything on that line.
71
+
72
+ ### Project config
73
+ Create `.galaxy-a11y.json` in the scanned folder:
74
+ ```json
75
+ { "ignoreDirs": ["legacy"], "ignore": ["/vendor/"], "disable": ["SENSORY"] }
76
+ ```
77
+ Skipped by default: `node_modules`, `public`, `dist`, `build`, `logs`, `vendors`.
78
+
79
+ ### Use in CI or pre-commit
80
+ ```bash
81
+ galaxy-a11y . --fail-on error
82
+ ```
83
+ A non-zero exit code blocks the build or commit when errors exist.
84
+
85
+ ---
86
+
87
+ ## 3. What it checks
88
+
89
+ **48 rules** (46 automatic + 2 manual-only), made up of about **100 individual checks**, each mapped to a WCAG 2.2 success criterion. Every finding shows the rule name, the WCAG number, the line and column, and the fix.
90
+
91
+ | Area | Rule | What it catches | WCAG |
92
+ |---|---|---|---|
93
+ | **Images and media** | `ALT` | `<img>` with no `alt`, or an `alt` that is only whitespace (suggests the Galaxy variable when the editor controls the alt) | 1.1.1 |
94
+ | | `MAP-AREA` | `<area>` in an image map with no alt text | 1.1.1 / 2.4.4 |
95
+ | | `SVG` | Inline `<svg>` exposed to screen readers without a name (decorative ones should be `aria-hidden`) | 1.1.1 / 4.1.2 |
96
+ | | `MEDIA` | Embedded or `<video>` that autoplays with sound, has no controls, or has no text alternative | 1.4.2 / 2.2.2 |
97
+ | | `AUDIO` | `<audio>` that autoplays | 1.4.2 |
98
+ | | `AUDIO-CAPTIONS` | Embedded video: reminds you to confirm captions / transcript exist | 1.2.2 / 1.2.3 / 1.2.5 |
99
+ | **Motion and timing** | `ANIMATION` | Infinite animations with no `prefers-reduced-motion` override; autoplay video with no pause | 2.2.2 / 2.3.3 |
100
+ | | `FLASH` | Animations repeating faster than 3 times per second | 2.3.1 |
101
+ | | `REFRESH` | Meta refresh and timed redirects | 2.2.1 / 2.2.4 / 3.2.5 |
102
+ | **Page structure** | `HTML_LANG` / `LANG` | Missing or invalid `lang` attribute | 3.1.1 / 3.1.2 |
103
+ | | `TITLE` | Empty or missing `<title>` | 2.4.2 |
104
+ | | `PARSING` | Duplicate `id`s (breaks labels, ARIA and skip links) | 4.1.2 |
105
+ | | `HEADING` | Empty headings, skipped levels (h2 → h4), more than one `<h1>`, and elements styled as headings but not real heading tags | 1.3.1 / 2.4.6 |
106
+ | | `NAV` | No `<main>`, several unlabelled `<nav>` landmarks, links opening a new window without warning | 1.3.1 / 2.4.1 / 3.2.5 |
107
+ | | `SKIP-LINK` | No skip-to-content link in the header, or a skip button with no JS handler | 2.4.1 |
108
+ | | `SEMANTIC` | Interactive elements nested inside each other (`<a>` in `<button>`) | 4.1.2 |
109
+ | **Links and buttons** | `HREF` | `<a>` with no `href` (not keyboard-focusable) | 2.1.1 / 4.1.2 |
110
+ | | `ERR-LINK` | Dead links (`#`, `javascript:`), unclear link text ("click here"), and empty hidden links that screen readers still announce | 2.1.1 / 2.4.4 / 4.1.2 |
111
+ | | `LABEL` | Buttons and inputs with no accessible name, empty `<label>`, `<fieldset>` with no `<legend>`, placeholder-only fields | 1.3.1 / 3.3.2 / 4.1.2 |
112
+ | | `LABEL-IN-NAME` | `aria-label` that does not contain the visible text | 2.5.3 |
113
+ | **Forms** | `INPUT-PURPOSE` | Personal-data fields (name, email, phone…) with no `autocomplete` | 1.3.5 |
114
+ | | `AUTOFILL` | `autocomplete="off"` on personal-data fields | 1.3.5 / 3.3.7 |
115
+ | | `AUTH` | Password fields that block password managers; paste blocked | 3.3.8 |
116
+ | | `REDUNDANT-ENTRY` | "Confirm / repeat" fields that make users re-type information | 3.3.7 |
117
+ | | `ERROR-SUGGESTION` | Error text that only says "required" without telling how to fix it | 3.3.1 / 3.3.3 |
118
+ | | `STATUS-LIVE` | Error / success message containers screen readers will not announce | 4.1.3 |
119
+ | **Keyboard and focus** | `TABINDEX` | Positive `tabindex`; `tabindex="-1"` on visible controls; `tabindex` on non-interactive elements | 2.4.3 |
120
+ | | `KEYBOARD` | Click handlers on `<div>` / `<span>` that a keyboard cannot use | 2.1.1 |
121
+ | | `FOCUS` | Focus outline removed with no replacement | 2.4.7 / 2.4.13 |
122
+ | | `FOCUS-ELEMENT` | Overlays / menus opened without moving focus (`.focus()` never called) | 2.4.3 |
123
+ | | `FOCUS-STRATEGY` | Sticky or fixed headers that can cover the focused element, no `scroll-padding-top` | 2.4.11 |
124
+ | | `TRAP` | Dialogs without `aria-modal`, overlays with no Escape-key handling | 2.1.2 / 2.4.3 |
125
+ | | `SHORTCUT` / `CHAR-KEYS` | `accesskey` and single-character keyboard shortcuts | 2.1.4 |
126
+ | | `MODALITY` | Content shown on hover only, `onmouseover` without a keyboard equivalent | 2.1.1 / 1.4.13 |
127
+ | **Touch and pointer** | `GESTURE` | Swipe / drag actions with no single-tap alternative | 2.5.1 / 2.5.7 |
128
+ | | `PTR-GESTURE` | Actions that fire on press (`mousedown` / `touchstart`) instead of release | 2.5.2 |
129
+ | **ARIA** | `ARIA` | Dialogs with no name, misspelled `aria-*` attributes, `aria-hidden` containers that hold focusable items | 4.1.2 |
130
+ | | `ARIA-ROLE` | Unknown or redundant ARIA roles, and roles missing the attributes they require | 4.1.2 |
131
+ | **Visual design (SCSS)** | `CONTRAST` | Text / background pairs under 4.5:1 (3:1 for large text); suggests the closest passing colour | 1.4.3 / 1.4.11 |
132
+ | | `COLOR` | Links told apart from text by colour only (underline removed) | 1.4.1 |
133
+ | | `SIZE` | Text smaller than 12px | 1.4.4 / 1.4.12 |
134
+ | | `SIZE-2.2` | Click / tap targets smaller than 24×24 px | 2.5.8 / 2.5.5 |
135
+ | | `ZOOM` | Text sized only in `vw`; viewport tag that blocks pinch-zoom | 1.4.4 |
136
+ | | `ORIENTATION` | Content hidden or rotated by orientation media queries | 1.3.4 |
137
+ | **Manual only** | `SENSORY` | Reminder to read content for "click the green button / on the right" instructions | 1.3.3 |
138
+ | | `CONSISTENT-HELP` | Reminder: help / contact link must be in the same place on every page | 3.2.6 |
139
+
140
+ The last two rules cannot be decided from source code, so they appear as reminders in the manual checklist at the end of every report.
141
+
142
+ Notes
143
+ - `HREF` = missing or empty href. `ERR-LINK` = dead links and unnamed or generic links.
144
+ - `CONTRAST` only checks colours that appear together in one SCSS rule (literal values or `$variables`). Colours chosen in the CMS cannot be checked statically.
145
+ - Some rules (for example `TRAP` and `FOCUS-ELEMENT`) report as a low-priority **note** when the code appears to use a known library that handles the problem. Use `--all` to see notes.
146
+
147
+ ---
148
+
149
+ ## 4. What to expect
150
+
151
+ ### Output
152
+ This is a real run on two small files (`block/hero.html` and `sass/hero.scss`):
153
+
154
+ ```text
155
+ block/hero.html
156
+ 2:3 error ALT <img> has no alt attribute. WCAG 1.1.1
157
+ fix: Add alt="Describe the image" — or alt="" if purely decorative.
158
+ 3:20 error KEYBOARD <div> has a click handler but is not keyboard-operable. WCAG 2.1.1
159
+ fix: Use <button type="button"> (best). If impossible: role="button" tabindex="0" + Enter/Space key handler.
160
+ 4:6 warning ERR-LINK Link points to "#" (dead link / script action). WCAG 2.1.1 / 4.1.2
161
+ fix: If it navigates, use the real URL. If it runs JS, use <button type="button"> ...
162
+
163
+ sass/hero.scss
164
+ 1:1 error CONTRAST Contrast 2.85:1 for ".hero" (#999 on #fff); needs 4.5:1. WCAG 1.4.3
165
+ fix: Change the text colour to #747474 (closest passing shade), or change the background.
166
+
167
+ (manual checklist)
168
+ 1:1 manual KEYBOARD Tab through every page type once: order, visible focus, no traps ...
169
+
170
+ Scanned 2 files — 5 errors, 5 warnings, 7 manual checks
171
+
172
+ By rule:
173
+ ALT 1 err 0 warn 0 manual
174
+ ...
175
+ ```
176
+
177
+ How to read a line: `line:column severity RULE message WCAG number`, then `fix:` underneath.
178
+
179
+ ### Severity levels
180
+ | Level | Meaning | What to do |
181
+ |---|---|---|
182
+ | **error** | Definite failure of a WCAG criterion | Fix these first |
183
+ | **warning** | Likely a problem, but depends on context | Check each one, fix or silence with `a11y-ignore` |
184
+ | **manual** | The tool cannot decide | Do the check by hand (list at end of report) |
185
+ | **note** | Low priority hint (shown only with `--all`) | Optional |
186
+
187
+ ### Things to know
188
+ - **The first scan on an existing theme often shows many findings.** Many repeat the same cause (for example one SCSS rule used everywhere). Work through the "By rule" table from the top; fixing a shared template or mixin clears many lines at once.
189
+ - **Expect some warnings that do not apply.** The scanner reads source code, so it sometimes cannot tell that a library or the CMS already handles something. Silence those with `a11y-ignore` or `--disable`.
190
+ - **Line numbers point into your real files**, even when the line uses Galaxy syntax (`#$variable|x$#`, `{if="…"}`, `[[id]]`).
191
+ - **Exit code:** `1` if any **error** is found (so CI and pre-commit hooks can block), otherwise `0`. Change with `--fail-on warning` or `--fail-on none`.
192
+ - **`--html a11y-report.html`** writes a report you can filter by errors, warnings and manual checks and share with the team.
193
+ - **Zero findings does not mean "100% accessible".** See [Limits](#6-limits).
194
+
195
+ ---
196
+
197
+ ## 5. What a fix looks like
198
+
199
+ Every finding comes with the exact change to make. Typical examples:
200
+
201
+ **Missing alt text** (`ALT`, WCAG 1.1.1)
202
+ ```html
203
+ <!-- before --> <img src="team.jpg">
204
+ <!-- after --> <img src="team.jpg" alt="The support team at the 2026 summit">
205
+ <!-- decorative image --> <img src="divider.svg" alt="">
206
+ ```
207
+
208
+ **Clickable `<div>`** (`KEYBOARD`, WCAG 2.1.1)
209
+ ```html
210
+ <!-- before --> <div class="btn" onclick="openMenu()">Menu</div>
211
+ <!-- after --> <button type="button" class="btn" onclick="openMenu()">Menu</button>
212
+ ```
213
+
214
+ **Field with placeholder only** (`LABEL`, WCAG 1.3.1 / 3.3.2 / 4.1.2)
215
+ ```html
216
+ <!-- before --> <input type="email" name="email" placeholder="Email">
217
+ <!-- after --> <label for="email">Email</label>
218
+ <input id="email" type="email" name="email" autocomplete="email">
219
+ ```
220
+ (`autocomplete="email"` also fixes `INPUT-PURPOSE`.)
221
+
222
+ **Dead link used as a button** (`ERR-LINK`, WCAG 2.1.1 / 4.1.2)
223
+ ```html
224
+ <!-- before --> <a href="#" class="toggle">Open</a>
225
+ <!-- after --> <button type="button" class="toggle" aria-expanded="false" aria-controls="panel">Open</button>
226
+ <div id="panel" hidden>…</div>
227
+ ```
228
+
229
+ **Low contrast** (`CONTRAST`, WCAG 1.4.3). The scanner calculates the closest passing colour:
230
+ ```scss
231
+ // before
232
+ .hero { color: #999; background: #fff; } // 2.85:1, needs 4.5:1
233
+ // after
234
+ .hero { color: #747474; background: #fff; } // passes
235
+ ```
236
+
237
+ **Removed focus outline** (`FOCUS`, WCAG 2.4.7)
238
+ ```scss
239
+ // before
240
+ a:focus { outline: none; }
241
+ // after
242
+ a:focus-visible { outline: 2px solid $focus-color; outline-offset: 2px; }
243
+ ```
244
+
245
+ **Tiny click target** (`SIZE-2.2`, WCAG 2.5.8)
246
+ ```scss
247
+ // before
248
+ .btn { width: 18px; height: 18px; }
249
+ // after
250
+ .btn { min-width: 24px; min-height: 24px; } // ideally 44×44
251
+ ```
252
+
253
+ **Silence a finding that does not apply**
254
+ ```html
255
+ <!-- a11y-ignore: ALT -->
256
+ <img src="logo.svg" alt="">
257
+ ```
258
+
259
+ ---
260
+
261
+ ## 6. Limits
262
+
263
+ The scanner reads source code only. It cannot see:
264
+ - colours and content that editors choose in the CMS,
265
+ - the `<html lang>` and `<title>` generated by the Galaxy platform,
266
+ - real focus order, screen-reader output, or caption quality.
267
+
268
+ For better coverage also:
269
+ 1. Scan the rendered pages: `npx @axe-core/cli https://your-site/page --tags wcag2a,wcag2aa,wcag22aa`
270
+ 2. Do the manual checklist printed at the end of each report (keyboard pass, screen-reader pass, captions, flashing).
271
+
272
+ No tool can promise "100% ADA". A realistic goal is: zero automated errors, warnings reviewed, manual checklist signed off.
273
+
274
+ ---
275
+
276
+ ## 7. VS Code extension
277
+
278
+ Prefer to see the problems while you edit? Install the **Galaxy A11y** extension from the VS Code Marketplace (publisher `shalii`). It runs this same scanner and shows the findings as squiggles, with the fix in the hover. It is a separate project: `galaxy-a11y-vscode`.
279
+
280
+ ---
281
+
282
+ ## License
283
+ MIT
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const { scan, ORDER } = require('../src');
5
+ const report = require('../src/report');
6
+ const a = process.argv.slice(2);
7
+ const flag = (n) => a.includes(n);
8
+ const val = (n) => { const i = a.indexOf(n); return i > -1 ? a[i + 1] : undefined; };
9
+ if (flag('-h') || flag('--help')) {
10
+ console.log(`galaxy-a11y [folder] [options]
11
+ --all also show low-priority notes
12
+ --json [file] machine-readable output (stdout if no file)
13
+ --html <file> write a filterable HTML report
14
+ --disable A,B turn rules off (e.g. --disable SENSORY,ARIA-ROLE)
15
+ --fail-on <level> exit 1 on error (default) | warning | none
16
+ --limit <n> max findings printed per file
17
+ --no-fix hide fix text
18
+ Silence one finding in source: <!-- a11y-ignore: ALT --> or // a11y-ignore (line above / same line)
19
+ Config: .galaxy-a11y.json { "ignoreDirs": [], "ignore": ["regex"], "disable": ["RULE"] }`);
20
+ process.exit(0);
21
+ }
22
+ const target = a.find((x, i) => !x.startsWith('-') && !['--json', '--html', '--disable', '--fail-on', '--limit'].includes(a[i - 1])) || '.';
23
+ const res = scan(target, { disable: (val('--disable') || '').split(',').filter(Boolean) });
24
+ if (flag('--json')) { const j = JSON.stringify(res, null, 2); const f = val('--json'); if (f && !f.startsWith('-')) fs.writeFileSync(f, j); else console.log(j); }
25
+ else console.log(report.text(res, { all: flag('--all'), color: process.stdout.isTTY, fixes: !flag('--no-fix'), limit: +val('--limit') || 0 }));
26
+ if (val('--html')) {
27
+ const out = require('path').resolve(val('--html'));
28
+ try {
29
+ fs.mkdirSync(require('path').dirname(out), { recursive: true });
30
+ fs.writeFileSync(out, report.html(res));
31
+ console.error('HTML report: ' + out);
32
+ } catch (e) { console.error('Could not write the report to ' + out + ' (' + e.code + '). Try a path that exists, e.g. --html a11y-report.html'); }
33
+ }
34
+ const fail = val('--fail-on') || 'error';
35
+ const bad = fail === 'none' ? 0 : res.findings.filter((f) => ORDER[f.severity] <= (fail === 'warning' ? 1 : 0)).length;
36
+ process.exit(bad ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@shalii/galaxy-a11y",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "description": "Static WCAG 2.2 / ADA accessibility scanner for Galaxy CMS themes (html, scss, js)",
5
+ "keywords": [
6
+ "accessibility",
7
+ "a11y",
8
+ "wcag",
9
+ "ada",
10
+ "galaxy-cms",
11
+ "scss",
12
+ "html-lint"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "commonjs",
16
+ "main": "src/index.js",
17
+ "bin": {
18
+ "galaxy-a11y": "bin/galaxy-a11y.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "scan": "node bin/galaxy-a11y.js ."
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "parse5": "^7.1.2"
37
+ }
38
+ }
package/src/color.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ const NAMED = { white: '#ffffff', black: '#000000', transparent: null };
3
+ function parse(c) {
4
+ if (!c) return null;
5
+ c = String(c).trim().toLowerCase().replace(/\s*!important$/, '');
6
+ if (c in NAMED) return NAMED[c] ? parse(NAMED[c]) : null;
7
+ let m = c.match(/^#([0-9a-f]{3})$/);
8
+ if (m) return m[1].split('').map((h) => parseInt(h + h, 16));
9
+ m = c.match(/^#([0-9a-f]{6})$/);
10
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].substr(i, 2), 16));
11
+ m = c.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)$/);
12
+ if (m) {
13
+ const a = m[4] === undefined ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
14
+ return a < 1 ? null : [+m[1], +m[2], +m[3]]; // semi-transparent: can't judge statically
15
+ }
16
+ return null;
17
+ }
18
+ const lin = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
19
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
20
+ function ratio(a, b) { const l1 = lum(a), l2 = lum(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }
21
+ const hex = (rgb) => '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
22
+ // Nudge fg toward black/white (whichever gives more contrast) until it reaches the target ratio.
23
+ function suggest(fg, bg, target) {
24
+ const towards = lum(bg) > 0.5 ? [0, 0, 0] : [255, 255, 255];
25
+ for (let t = 0; t <= 1.0001; t += 0.02) {
26
+ const c = fg.map((v, i) => v + (towards[i] - v) * t);
27
+ if (ratio(c, bg) >= target) return hex(c);
28
+ }
29
+ return hex(towards);
30
+ }
31
+ module.exports = { parse, ratio, suggest, hex };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+ const { parseScss, walk, collectVars, resolveVal } = require('./scss');
3
+ const C = require('./color');
4
+
5
+ const px = (v) => { const m = /^(-?[\d.]+)px$/.exec((v || '').trim()); return m ? parseFloat(m[1]) : null; };
6
+ const baseOf = (sel) => sel.replace(/:(focus-visible|focus-within|focus)\b/g, '').replace(/\s+/g, ' ').trim();
7
+ const get = (b, ...props) => { for (const p of props) { const d = b.decls.find((x) => x.prop === p); if (d) return d; } return null; };
8
+ const TARGET_RE = /(btn|button|close|toggle|arrow|dot|bullet|pagination|prev|next|checkbox|radio|chevron|burger|hamburger)/i;
9
+
10
+ function analyzeScss(raw, file, vars, shared) {
11
+ const out = [];
12
+ const root = parseScss(raw);
13
+ const add = (rule, severity, line, message, fix, wcag) => out.push({ file, line, col: 1, rule, severity, message, fix, wcag });
14
+ const blocks = [];
15
+ walk(root, (b) => blocks.push(b));
16
+ const hasReduced = /prefers-reduced-motion/.test(raw);
17
+ const focusStyled = new Set();
18
+ blocks.forEach((b) => {
19
+ if (/:focus(-visible|-within)?\b/.test(b.full) && b.decls.some((d) => /^(box-shadow|border|border-color|outline|outline-color|background|background-color|color|text-decoration|filter)$/.test(d.prop) && !/^(none|0)$/.test(d.value))) focusStyled.add(baseOf(b.full));
20
+ });
21
+ shared.focusStyled = shared.focusStyled || new Set(); focusStyled.forEach((x) => shared.focusStyled.add(x));
22
+ shared.scssBlocks.push({ file, blocks });
23
+
24
+ blocks.forEach((b) => {
25
+ if (!b.full) return;
26
+ const isFocus = /:focus(-visible)?\b/.test(b.full);
27
+ // FOCUS: outline removed
28
+ const ol = get(b, 'outline', 'outline-width');
29
+ if (ol && /^(none|0|0px)(\s|!|$)/.test(ol.value) && /:not\(\.(tab\w*|keyboard\w*|using-?keyboard\w*|user-is-tabbing|js-focus\w*)\)/i.test(b.full)) {
30
+ add('FOCUS', 'info', ol.line, `"${b.full}" hides the outline for mouse users and relies on a JS class for keyboard users.`, 'OK while the JS class is always added on Tab and the replacement outline has ≥3:1 contrast. Prefer plain :focus-visible so it works without JS.', 'WCAG 2.4.7');
31
+ } else if (ol && /^(none|0|0px)(\s|!|$)/.test(ol.value)) {
32
+ const indicator = (isFocus && b.decls.some((d) => /^(box-shadow|border|border-color|border-bottom|background|background-color|text-decoration|outline-color|outline-style)$/.test(d.prop) && !/^(none|0)$/.test(d.value))) || b.children.some((c) => /:focus/.test(c.full) && c.decls.length);
33
+ if (isFocus && !indicator) add('FOCUS', 'error', ol.line, `Focus outline removed on "${b.full}" with no replacement style.`, `Replace with a visible indicator, e.g.\n &:focus-visible { outline: 2px solid $focus-color; outline-offset: 2px; } // ≥3:1 contrast against neighbours`, 'WCAG 2.4.7 / 2.4.13');
34
+ else if (!isFocus && !indicator) add('FOCUS', 'warning', ol.line, `outline removed on "${b.full}" (affects :focus too).${shared.focusStyled.has(baseOf(b.full)) ? '' : ' No :focus/:focus-visible style found for it.'}`, `Delete "outline: none" or add:\n &:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }`, 'WCAG 2.4.7');
35
+ }
36
+ // CONTRAST (same rule, literal or $variable colours)
37
+ const fgD = get(b, 'color'), bgD = get(b, 'background-color', 'background');
38
+ if (fgD && bgD) {
39
+ const fg = C.parse(resolveVal(fgD.value, vars)), bg = C.parse(resolveVal(bgD.value.split(/\s+/)[0] === 'none' ? '' : (bgD.prop === 'background' ? (bgD.value.match(/(#[0-9a-f]{3,6}\b|rgba?\([^)]*\)|\$[\w-]+|\b(?:white|black)\b)/i) || [])[0] || '' : bgD.value), vars));
40
+ if (fg && bg) {
41
+ const fs = get(b, 'font-size'), fw = get(b, 'font-weight');
42
+ const size = fs && px(resolveVal(fs.value, vars)), bold = fw && /^(bold|[6-9]00)/.test(fw.value);
43
+ const large = size && (size >= 24 || (size >= 18.66 && bold));
44
+ const need = large ? 3 : 4.5, r = C.ratio(fg, bg);
45
+ if (r < need) add('CONTRAST', 'error', fgD.line, `Contrast ${r.toFixed(2)}:1 for "${b.full}" (${fgD.value} on ${bgD.value}); needs ${need}:1.`, `Change the text colour to ${C.suggest(fg, bg, need)} (closest passing shade), or change the background.`, 'WCAG 1.4.3');
46
+ }
47
+ }
48
+ // ZOOM / SIZE (text)
49
+ const fs = get(b, 'font-size');
50
+ if (fs) {
51
+ const v = resolveVal(fs.value, vars);
52
+ if (/^[\d.]+vw$/.test(v.trim())) add('ZOOM', 'error', fs.line, `font-size ${v} scales only with viewport width, so browser zoom/text-size cannot enlarge it.`, 'Use clamp(1rem, 0.9rem + 1vw, 1.5rem) or rem/em.', 'WCAG 1.4.4');
53
+ const n = px(v);
54
+ if (n !== null && n < 12 && !/(::?before|::?after)/.test(b.full)) add('SIZE', 'warning', fs.line, `font-size ${v} on "${b.full}" is very small.`, 'Use ≥ 1rem for body copy (≥ 0.75rem only for short labels) and rem units so it scales.', 'WCAG 1.4.4 / 1.4.12');
55
+ }
56
+ // SIZE-2.2: target size
57
+ if (TARGET_RE.test(b.full.split(/[\s>+~]/).pop()) && !/(::?before|::?after|svg|path|img)$/.test(b.full)) {
58
+ const w = get(b, 'width', 'min-width'), h = get(b, 'height', 'min-height');
59
+ const wv = w && px(resolveVal(w.value, vars)), hv = h && px(resolveVal(h.value, vars));
60
+ if (wv !== null && hv !== null && (wv < 24 || hv < 24) && !/(progress|fill|track|scrollbar|divider|line)/i.test(b.full)) add('SIZE-2.2', 'warning', (w || h).line, `Click/tap target "${b.full}" is ${wv ?? '?'}×${hv ?? '?'} px (min 24×24).`, 'Make the hit-area ≥ 24×24 CSS px (ideally 44×44): min-width/min-height, or padding / an ::after hit-area, keep the icon small.', 'WCAG 2.5.8 / 2.5.5');
61
+ }
62
+ // ANIMATION / FLASH
63
+ const an = get(b, 'animation', 'animation-name');
64
+ if (an && !hasReduced && /infinite/.test(b.decls.map((d) => d.value).join(' ')) ) add('ANIMATION', 'warning', an.line, `Infinite animation on "${b.full}" with no prefers-reduced-motion override in this file.`, '@media (prefers-reduced-motion: reduce) { & { animation: none !important; transition: none !important; } } — and give a pause control if it runs > 5 s.', 'WCAG 2.2.2 / 2.3.3');
65
+ const dur = get(b, 'animation-duration') || (an && an.prop === 'animation' ? an : null);
66
+ if (dur) { const m = dur.value.match(/([\d.]+)(m?s)/); if (m && /infinite/.test(b.decls.map((d) => d.value).join(' ')) && ((m[2] === 's' && parseFloat(m[1]) < 0.34) || (m[2] === 'ms' && parseFloat(m[1]) < 340))) add('FLASH', 'warning', dur.line, `Animation on "${b.full}" repeats faster than 3×/second.`, 'Slow it down (≥ 0.34 s per cycle) or remove it. Never flash large areas > 3×/s.', 'WCAG 2.3.1'); }
67
+ // MODALITY: hover-only reveal
68
+ if (/:hover\b/.test(b.full) && !/:(focus|focus-visible|focus-within)\b/.test(b.full)) {
69
+ const reveal = b.decls.some((d) => (/^(display|visibility)$/.test(d.prop) && /(block|flex|grid|visible)/.test(d.value)) || (d.prop === 'opacity' && !/^0/.test(d.value)) || (/^(max-)?height$/.test(d.prop) && d.value !== '0'));
70
+ if (reveal && !shared.focusStyled.has(baseOf(b.full)) && !/:focus-within/.test(raw)) add('MODALITY', 'warning', b.line, `Content revealed on :hover only ("${b.full}").`, 'Add the same rule for :focus-visible and :focus-within (e.g. ".menu-item:hover, .menu-item:focus-within { … }"); content must be dismissible (Esc) and hoverable.', 'WCAG 2.1.1 / 1.4.13');
71
+ }
72
+ // COLOR (link only distinguished by colour)
73
+ if (/(^|\s)a$|(^|\s)\.(link|link-text)$/.test(b.full) || /(content|text|body)[\w-]*\s+a$/.test(b.full)) {
74
+ const td = get(b, 'text-decoration');
75
+ if (td && /^none/.test(td.value) && !b.decls.some((d) => /^(border-bottom|box-shadow|background-image|text-decoration-line)$/.test(d.prop)) && !b.children.some((c) => /:hover|:focus|::after|::before/.test(c.full) && c.decls.some((d) => /underline|border|background|width/.test(d.prop + d.value)))) add('COLOR', 'warning', td.line, `Links in "${b.full}" lose their underline; if they sit inside body text, colour alone marks them.`, 'Keep text-decoration: underline for inline links (or add a border-bottom / ≥3:1 colour difference + non-colour cue).', 'WCAG 1.4.1');
76
+ }
77
+ // ORIENTATION
78
+ if (/^@media/.test(b.selector) && /orientation\s*:/.test(b.selector)) {
79
+ const bad = [];
80
+ walk(b, (c) => c.decls.forEach((d) => { if ((d.prop === 'display' && d.value === 'none') || (d.prop === 'visibility' && d.value === 'hidden') || /rotate\(\s*-?90/.test(d.value)) bad.push(d); }));
81
+ if (bad.length) add('ORIENTATION', 'warning', b.line, 'An orientation media query hides content or rotates the page.', 'Do not lock/hide content to one orientation. Adapt layout instead.', 'WCAG 1.3.4');
82
+ }
83
+ });
84
+ // fixed header without scroll-padding is reported once per project
85
+ blocks.forEach((b) => { if (/(header|nav|sticky|fixed)/i.test(b.full) && get(b, 'position') && /^(fixed|sticky)/.test(get(b, 'position').value)) shared.fixedHeaders.push({ file, line: get(b, 'position').line, sel: b.full }); if (get(b, 'scroll-padding-top', 'scroll-padding', 'scroll-margin-top')) shared.hasScrollPadding = true; });
86
+ return out;
87
+ }
88
+ module.exports = { analyzeScss, collectVars, parseScss };
package/src/galaxy.js ADDED
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+ // Turns a Galaxy template into plain HTML WITHOUT changing length, line numbers or columns,
3
+ // so every finding maps back to the exact place in the original file.
4
+ const DYN = '\u2022'; // "•" = value is filled by the CMS at render time
5
+ const blank = (s, ch) => s.replace(/[^\r\n]/g, ch);
6
+
7
+ function preprocess(src) {
8
+ return src
9
+ .replace(/#\$[\s\S]*?\$#/g, (m) => blank(m, DYN)) // #$variable|x$#, #$include|..$#, #$loop..$#
10
+ .replace(/\{(?:else)?if=[\s\S]*?"\s*\}/g, (m) => blank(m, ' ')) // {if="..."} {elseif="..."}
11
+ .replace(/\{(?:else|\/if|\/loop|\/for)\}/g, (m) => blank(m, ' '))
12
+ .replace(/\{\$[^}=]*=[^}]*\}/g, (m) => blank(m, ' ')) // {$x="..."} assignment
13
+ .replace(/\{\$[^}]*\}/g, (m) => blank(m, DYN)) // {$x} usage
14
+ .replace(/\{[a-z_]+\([^}]*\)\}/gi, (m) => blank(m, DYN)) // {format_date(..)}
15
+ .replace(/\{loop_index\}|\[\[[^\]]+\]\]/g, (m) => blank(m, DYN)) // [[id]] [[hide]]
16
+ .replace(/__@@|@@__/g, ' '); // translation markers -> keep the text
17
+ }
18
+
19
+ // How many {if=...} blocks are open at a given offset (used to spot CMS-conditional labels etc.)
20
+ function conditionalDepthAt(raw, offset) {
21
+ const head = raw.slice(0, offset);
22
+ const open = (head.match(/\{if=/g) || []).length;
23
+ const close = (head.match(/\{\/if\}/g) || []).length;
24
+ return open - close;
25
+ }
26
+
27
+ module.exports = { preprocess, conditionalDepthAt, DYN };
@@ -0,0 +1,309 @@
1
+ 'use strict';
2
+ const parse5 = require('parse5');
3
+ const { preprocess, conditionalDepthAt, DYN } = require('./galaxy');
4
+
5
+ const ROLES = new Set('alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem'.split(' '));
6
+ const ARIA_ATTRS = new Set('activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' '));
7
+ const ROLE_REQUIRES = { checkbox: ['aria-checked'], switch: ['aria-checked'], radio: ['aria-checked'], menuitemcheckbox: ['aria-checked'], menuitemradio: ['aria-checked'], combobox: ['aria-expanded'], heading: ['aria-level'], slider: ['aria-valuenow'], scrollbar: ['aria-valuenow'] };
8
+ const WIDGET_ROLES = new Set('button link tab menuitem menuitemcheckbox menuitemradio option checkbox radio switch slider textbox combobox treeitem searchbox spinbutton'.split(' '));
9
+ const IMPLICIT = { nav: 'navigation', button: 'button', ul: 'list', ol: 'list', li: 'listitem', main: 'main', img: 'img', table: 'table', form: 'form', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading' };
10
+ const NON_INTERACTIVE = new Set('div span li p img i b svg td tr section article ul ol h1 h2 h3 h4 h5 h6 figure em strong label'.split(' '));
11
+ const GENERIC_LINK = /^(click here|here|read more|more|learn more|link|discover|view|view more|details|find out more|see more)$/i;
12
+ const AUTOCOMPLETE = [
13
+ [/(first|given|fname)/i, 'given-name'], [/(last|family|surname|lname)/i, 'family-name'], [/e-?mail/i, 'email'],
14
+ [/(phone|mobile|\btel)/i, 'tel'], [/(street|address1|address_1|addressline)/i, 'street-address'], [/(city|town)/i, 'address-level2'],
15
+ [/(zip|postal|postcode)/i, 'postal-code'], [/country/i, 'country-name'], [/(^|[^a-z])name($|[^a-z])/i, 'name'],
16
+ [/(organi[sz]ation|company)/i, 'organization'], [/(birth|dob)/i, 'bday'],
17
+ ];
18
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
19
+ const isDyn = (s) => typeof s === 'string' && s.includes(DYN);
20
+
21
+ function analyzeHtml(raw, file, shared) {
22
+ const out = [];
23
+ const src = preprocess(raw);
24
+ const doc = parse5.parseFragment(src, { sourceCodeLocationInfo: true });
25
+ const ids = new Map(), labelsFor = new Set(), refs = [], inlineScripts = [];
26
+ const headings = [], radios = new Map();
27
+
28
+ const attr = (el, n) => { const a = el.attrs && el.attrs.find((x) => x.name === n); return a ? a.value : undefined; };
29
+ const has = (el, n) => attr(el, n) !== undefined;
30
+ const loc = (el, n) => {
31
+ const l = el.sourceCodeLocation || {};
32
+ return n && l.attrs && l.attrs[n] ? l.attrs[n] : l;
33
+ };
34
+ const rawAttr = (el, n) => { const l = loc(el, n); return l.startOffset !== undefined && n ? raw.slice(l.startOffset, l.endOffset) : ''; };
35
+ const add = (rule, severity, el, message, fix, wcag, n) => {
36
+ const l = loc(el, n);
37
+ out.push({ file, line: l.startLine || 1, col: l.startCol || 1, rule, severity, message, fix: fix || '', wcag: wcag || '' });
38
+ };
39
+ const kids = (el) => (el.tagName === 'template' && el.content ? el.content.childNodes : el.childNodes) || [];
40
+ const hidden = (el) => attr(el, 'aria-hidden') === 'true';
41
+ const text = (el) => kids(el).map((c) => {
42
+ if (c.nodeName === '#text') return c.value;
43
+ if (c.tagName && !['script', 'style'].includes(c.tagName) && !hidden(c)) return text(c);
44
+ return '';
45
+ }).join('');
46
+ const descendants = (el, fn) => kids(el).forEach((c) => { if (c.tagName) { fn(c); descendants(c, fn); } });
47
+
48
+ const isFocusable = (el) => {
49
+ const t = el.tagName, ti = attr(el, 'tabindex');
50
+ if (has(el, 'disabled') || (ti !== undefined && parseInt(ti, 10) < 0)) return false;
51
+ if (ti !== undefined && !isNaN(parseInt(ti, 10))) return true;
52
+ if (t === 'a') return has(el, 'href');
53
+ if (t === 'input') return attr(el, 'type') !== 'hidden';
54
+ return ['button', 'select', 'textarea', 'summary'].includes(t) || ((t === 'audio' || t === 'video') && has(el, 'controls'));
55
+ };
56
+ // Does the element expose an accessible name? returns 'yes' | 'dynamic' | 'no'
57
+ const nameOf = (el) => {
58
+ const al = attr(el, 'aria-label');
59
+ if (has(el, 'aria-labelledby')) return 'yes';
60
+ if (al !== undefined && al.replace(new RegExp(DYN, 'g'), '').trim()) return 'yes';
61
+ if (al !== undefined && isDyn(al)) return 'dynamic';
62
+ const t = text(el);
63
+ if (t.replace(new RegExp(DYN, 'g'), '').trim()) return 'yes';
64
+ let found = 'no';
65
+ descendants(el, (d) => {
66
+ if (hidden(d)) return;
67
+ if (d.tagName === 'img') { const a = attr(d, 'alt'); if (a && a.replace(new RegExp(DYN, 'g'), '').trim()) found = 'yes'; else if (isDyn(a) && found === 'no') found = 'dynamic'; }
68
+ if (d.tagName === 'svg' && (attr(d, 'aria-label') || kids(d).some((k) => k.tagName === 'title'))) found = 'yes';
69
+ if (d.tagName === 'title' && d.parentNode && d.parentNode.tagName === 'svg') found = 'yes';
70
+ });
71
+ if (found !== 'no') return found;
72
+ if (isDyn(t)) return 'dynamic';
73
+ if (attr(el, 'title')) return 'yes';
74
+ return 'no';
75
+ };
76
+
77
+ const visit = (el, anc) => {
78
+ const t = el.tagName, role = (attr(el, 'role') || '').trim().toLowerCase();
79
+ const inAnchor = anc.some((a) => a.tagName === 'a'), inButton = anc.some((a) => a.tagName === 'button');
80
+ const inHeading = anc.some((a) => /^h[1-6]$/.test(a.tagName) || attr(a, 'role') === 'heading');
81
+ const id = attr(el, 'id');
82
+ if (id !== undefined && !isDyn(id) && id.trim()) {
83
+ if (ids.has(id)) add('PARSING', 'error', el, `Duplicate id "${id}" (first used on line ${ids.get(id)}). Breaks label/aria references and skip links.`, 'Make every id unique, e.g. id="' + id + '-[[id]]" (Galaxy [[id]] gives a unique value per item).', 'WCAG 4.1.2', 'id');
84
+ else ids.set(id, loc(el).startLine);
85
+ }
86
+ for (const r of ['aria-labelledby', 'aria-describedby', 'aria-controls']) {
87
+ const v = attr(el, r);
88
+ if (v && !isDyn(v)) v.split(/\s+/).forEach((x) => refs.push({ el, attr: r, id: x }));
89
+ }
90
+
91
+ // ---- lang / viewport / refresh / title -----------------------------------------------------
92
+ if (t === 'html') {
93
+ const lg = attr(el, 'lang');
94
+ if (!lg || !lg.trim()) add('HTML_LANG', 'error', el, '<html> has no lang attribute.', '<html lang="en"> (or the site language; use the Galaxy language variable if the theme is multilingual).', 'WCAG 3.1.1');
95
+ }
96
+ if (has(el, 'lang') && t !== 'html') {
97
+ const lg = attr(el, 'lang');
98
+ if (lg && !isDyn(lg) && !/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(lg.trim())) add('LANG', 'error', el, `Invalid lang value "${lg}".`, 'Use a valid BCP-47 code such as "en", "fr", "es-MX".', 'WCAG 3.1.2', 'lang');
99
+ }
100
+ if (t === 'meta') {
101
+ const he = (attr(el, 'http-equiv') || '').toLowerCase(), nm = (attr(el, 'name') || '').toLowerCase(), c = attr(el, 'content') || '';
102
+ if (he === 'refresh' && /\d/.test(c) && !/^0\b/.test(c.trim())) add('REFRESH', 'error', el, 'Meta refresh auto-redirects/reloads the page.', 'Remove it. Use a server-side 301 redirect, or give users a link/button to continue.', 'WCAG 2.2.1 / 2.2.4 / 3.2.5', 'http-equiv');
103
+ if (nm === 'viewport' && (/user-scalable\s*=\s*(no|0)/i.test(c) || /maximum-scale\s*=\s*(0?\.\d+|1(\.\d+)?)\b/i.test(c))) add('ZOOM', 'error', el, 'Viewport blocks pinch-zoom (user-scalable=no or maximum-scale below 2).', '<meta name="viewport" content="width=device-width, initial-scale=1">', 'WCAG 1.4.4', 'content');
104
+ }
105
+ if (t === 'title' && !text(el).trim()) add('TITLE', 'error', el, '<title> is empty.', 'Give each page a unique, descriptive title: "Page name | Property name".', 'WCAG 2.4.2');
106
+ if (t === 'iframe') {
107
+ const ti = attr(el, 'title');
108
+ if (!(ti && ti.trim()) && !attr(el, 'aria-label') && !hidden(el)) add('TITLE', 'error', el, '<iframe> has no title.', 'Add title="Describe the embedded content", e.g. title="Video: hotel tour" or title="Map of the property".', 'WCAG 4.1.2 / 2.4.1');
109
+ const s = attr(el, 'src') || attr(el, 'data-src') || '';
110
+ if (/[?&]autoplay=1/.test(s) && !/[?&](muted=1|background=1|mute=1)/.test(s)) add('MEDIA', 'error', el, 'Embedded video autoplays with sound.', 'Remove autoplay=1, or add muted=1 and provide a visible pause/stop control.', 'WCAG 1.4.2 / 2.2.2', 'src');
111
+ if (/(youtube|vimeo)/.test(s)) add('AUDIO-CAPTIONS', 'manual', el, 'Embedded video: confirm captions (and a transcript / audio description) exist on the platform.', 'Upload .vtt/.srt captions to YouTube/Vimeo; link a transcript near the player.', 'WCAG 1.2.2 / 1.2.3 / 1.2.5', 'src');
112
+ }
113
+
114
+ // ---- images, svg, maps ---------------------------------------------------------------------
115
+ if (t === 'img') {
116
+ const alt = attr(el, 'alt');
117
+ if (alt === undefined && role !== 'presentation' && role !== 'none') {
118
+ const v = (rawAttr(el, 'src').match(/#\$variable\|([^|$]+)\$#/) || rawAttr(el, 'data-src').match(/#\$variable\|([^|$]+)\$#/) || [])[1];
119
+ add('ALT', 'error', el, '<img> has no alt attribute.', v ? `Add alt="#$variable|${v}.alt$#" (editor-controlled alt) — or alt="" if purely decorative.` : 'Add alt="Describe the image" — or alt="" if purely decorative.', 'WCAG 1.1.1');
120
+ } else if (alt !== undefined && !isDyn(alt)) {
121
+ if (alt.length && !alt.trim()) add('ALT', 'warning', el, 'alt contains only whitespace.', 'Use alt="" for decorative images or real text.', 'WCAG 1.1.1', 'alt');
122
+ else if (/\.(jpe?g|png|gif|svg|webp|avif)$/i.test(alt.trim()) || /^(image|photo|picture|img|banner|icon|graphic)$/i.test(alt.trim())) add('ALT', 'warning', el, `alt "${alt}" is a file name / generic word.`, 'Describe what the image conveys (or alt="" if decorative).', 'WCAG 1.1.1', 'alt');
123
+ else if (alt.length > 150) add('ALT', 'warning', el, 'alt is very long (>150 chars).', 'Keep alt short; put long descriptions in visible text or aria-describedby.', 'WCAG 1.1.1', 'alt');
124
+ }
125
+ if (isDyn(alt)) add('ALT', 'info', el, 'alt comes from a CMS field — make sure editors are told to fill it.', 'In the .var/.data field description: "Describe the image for screen-reader users; leave empty only if decorative."', 'WCAG 1.1.1', 'alt');
126
+ }
127
+ if (t === 'input' && attr(el, 'type') === 'image' && !attr(el, 'alt')) add('ALT', 'error', el, '<input type="image"> has no alt.', 'alt="Search" (describe the button action).', 'WCAG 1.1.1 / 4.1.2');
128
+ if (t === 'area') {
129
+ if (!attr(el, 'alt') && !attr(el, 'aria-label')) add('MAP-AREA', 'error', el, '<area> in an image map has no alt text.', '<area ... alt="Destination name">', 'WCAG 1.1.1 / 2.4.4');
130
+ }
131
+ if (t === 'svg' && !hidden(el) && role !== 'presentation' && role !== 'none') {
132
+ const named = attr(el, 'aria-label') || attr(el, 'aria-labelledby') || kids(el).some((k) => k.tagName === 'title');
133
+ if (!named) add('SVG', 'warning', el, 'Inline <svg> is exposed to assistive tech without a name.', 'Decorative: <svg aria-hidden="true" focusable="false" …>. Meaningful: <svg role="img" aria-label="…"> or add <title>. Best fix point is the shared partial/icons file.', 'WCAG 1.1.1 / 4.1.2');
134
+ else if (role !== 'img' && !attr(el, 'aria-label')) add('SVG', 'info', el, 'Named <svg> without role="img".', 'Add role="img" so the name is announced consistently.', 'WCAG 1.1.1');
135
+ }
136
+ if ((t === 'object' || t === 'embed') && !attr(el, 'title') && !attr(el, 'aria-label')) add('MEDIA', 'warning', el, `<${t}> has no text alternative.`, 'Add title="…" and provide a text alternative.', 'WCAG 1.1.1');
137
+
138
+ // ---- audio / video / animation -------------------------------------------------------------
139
+ if (t === 'video') {
140
+ const trackOk = kids(el).some((k) => k.tagName === 'track' && /captions|subtitles/.test(attr(k, 'kind') || ''));
141
+ if (has(el, 'autoplay') && !has(el, 'muted')) add('MEDIA', 'error', el, 'Video autoplays with sound.', 'Add muted (and playsinline) or remove autoplay.', 'WCAG 1.4.2', 'autoplay');
142
+ if (has(el, 'autoplay') && !has(el, 'controls')) add('ANIMATION', 'warning', el, 'Auto-playing video has no controls to pause/stop (it runs >5 s).', 'Add controls, or a visible Pause/Play button wired to video.pause()/play(). Honour prefers-reduced-motion.', 'WCAG 2.2.2', 'autoplay');
143
+ if (!trackOk && !has(el, 'autoplay')) add('AUDIO-CAPTIONS', 'warning', el, '<video> has no captions track.', '<track kind="captions" src="captions.vtt" srclang="en" label="English" default>', 'WCAG 1.2.2');
144
+ if (!has(el, 'autoplay') && !has(el, 'controls')) add('MEDIA', 'warning', el, '<video> has no controls.', 'Add the controls attribute.', 'WCAG 2.1.1 / 1.4.2');
145
+ }
146
+ if (t === 'audio') {
147
+ if (has(el, 'autoplay')) add('AUDIO', 'error', el, '<audio> autoplays.', 'Remove autoplay; give the user a play control.', 'WCAG 1.4.2', 'autoplay');
148
+ if (!has(el, 'controls')) add('AUDIO', 'error', el, '<audio> has no controls.', 'Add the controls attribute.', 'WCAG 1.4.2 / 2.1.1');
149
+ add('AUDIO', 'manual', el, 'Audio-only content needs a transcript.', 'Add a visible "Transcript" link/section next to the player.', 'WCAG 1.2.1');
150
+ }
151
+ if (t === 'marquee' || t === 'blink') add('ANIMATION', 'error', el, `<${t}> is obsolete and cannot be paused.`, 'Replace with static text, or a CSS animation that respects prefers-reduced-motion and can be paused.', 'WCAG 2.2.2');
152
+
153
+ // ---- links / buttons -----------------------------------------------------------------------
154
+ if (t === 'a') {
155
+ const href = attr(el, 'href');
156
+ if (href === undefined && !role) add('HREF', 'warning', el, '<a> has no href, so it is not keyboard-focusable and not a link.', 'Give it a real href, or use <button type="button"> if it triggers an action.', 'WCAG 2.1.1 / 4.1.2');
157
+ else if (href !== undefined && /^\s*(#|javascript:)/i.test(href) && href.trim().length <= 1 + 'avascript:void(0)'.length && !isDyn(href) && (href.trim() === '#' || /javascript:/i.test(href))) add('ERR-LINK', 'warning', el, `Link points to "${href.trim()}" (dead link / script action).`, 'If it navigates, use the real URL. If it runs JS (open/close/toggle), use <button type="button"> with aria-expanded/aria-controls where relevant.', 'WCAG 2.1.1 / 4.1.2', 'href');
158
+ else if (href !== undefined && !href.trim()) add('HREF', 'error', el, 'href is empty.', 'Provide a destination URL (or remove the link).', 'WCAG 2.4.4', 'href');
159
+ if (href !== undefined) {
160
+ const n = nameOf(el);
161
+ if (n === 'no' && !hidden(el) && attr(el, 'tabindex') === '-1' && /sr-only|visually-hidden/.test(attr(el, 'class') || '')) add('ERR-LINK', 'warning', el, 'Empty, visually-hidden link (used as a JS data carrier). Screen-reader users still meet an unnamed link.', 'Add aria-hidden="true", or better, move the data to a data-* attribute / <script type="application/json"> instead of an <a>.', 'WCAG 2.4.4 / 4.1.2');
162
+ else if (n === 'no' && !hidden(el)) add('ERR-LINK', 'error', el, 'Link has no accessible name (empty or icon-only).', 'Add visible text, or aria-label="Describe destination" on the <a>, or an <img alt>/<svg><title>.', 'WCAG 2.4.4 / 4.1.2');
163
+ const tx = text(el).trim();
164
+ if (GENERIC_LINK.test(tx) && !attr(el, 'aria-label') && !has(el, 'aria-labelledby')) add('ERR-LINK', 'warning', el, `Link text "${tx}" is not meaningful out of context.`, `Make it specific, or: aria-label="${tx} – <item title>" (keep the visible words at the start of the label).`, 'WCAG 2.4.4');
165
+ }
166
+ if (inButton) add('SEMANTIC', 'error', el, '<a> nested inside <button> (interactive in interactive).', 'Use one element only: either a link or a button.', 'WCAG 4.1.2');
167
+ }
168
+ if (t === 'button') {
169
+ if (nameOf(el) === 'no' && !hidden(el)) add('LABEL', 'error', el, '<button> has no accessible name.', 'Add visible text or aria-label="Close", "Open menu", etc. (wrap translatable text as __@@Close@@__).', 'WCAG 4.1.2');
170
+ if (inAnchor) add('SEMANTIC', 'error', el, '<button> nested inside <a> (interactive in interactive).', 'Use one element only.', 'WCAG 4.1.2');
171
+ if (/skip/i.test(attr(el, 'class') || '')) shared.skipButtons.push({ file, line: loc(el).startLine, cls: attr(el, 'class') });
172
+ }
173
+ if (t === 'a' && /skip/i.test(attr(el, 'class') || '') ) shared.skipLinks.push({ file, line: loc(el).startLine, href: attr(el, 'href') });
174
+ if (t === 'a' || t === 'button') {
175
+ const al = attr(el, 'aria-label');
176
+ if (al && !isDyn(al)) {
177
+ const vis = norm(text(el));
178
+ if (vis && !isDyn(text(el)) && !norm(al).includes(vis)) add('LABEL-IN-NAME', 'warning', el, `aria-label "${al}" does not contain the visible text "${text(el).trim().replace(/\s+/g, ' ')}".`, `Start the label with the visible words, e.g. aria-label="${text(el).trim().replace(/\s+/g, ' ')} – …", or remove aria-label.`, 'WCAG 2.5.3', 'aria-label');
179
+ }
180
+ }
181
+
182
+ // ---- forms ---------------------------------------------------------------------------------
183
+ if (t === 'label') { const f = attr(el, 'for'); if (f) labelsFor.add(f); if (!text(el).trim() && !isDyn(text(el)) ) add('LABEL', 'warning', el, '<label> is empty.', 'Put the field name inside the label.', 'WCAG 3.3.2 / 1.3.1'); }
184
+ if (['input', 'select', 'textarea'].includes(t)) {
185
+ const ty = (attr(el, 'type') || 'text').toLowerCase();
186
+ if (!['hidden', 'submit', 'button', 'reset', 'image'].includes(ty)) {
187
+ const idv = attr(el, 'id');
188
+ const wrapped = anc.some((a) => a.tagName === 'label');
189
+ const named = attr(el, 'aria-label') || has(el, 'aria-labelledby') || attr(el, 'title') || wrapped;
190
+ const lab = idv && (labelsFor.has(idv) || null);
191
+ shared._pending = shared._pending || [];
192
+ out.push({ __pendingLabel: true, el, idv, named: !!named, ty, file, line: loc(el).startLine || 1, col: loc(el).startCol || 1 });
193
+ const nm = (attr(el, 'name') || '') + ' ' + (idv && !isDyn(idv) ? idv : '');
194
+ const ac = attr(el, 'autocomplete');
195
+ if (['text', 'email', 'tel', 'url', 'search'].includes(ty) && ac === undefined && ty !== 'search') {
196
+ const hit = AUTOCOMPLETE.find(([re]) => re.test(nm));
197
+ const exp = ty === 'email' ? 'email' : ty === 'tel' ? 'tel' : hit && hit[1];
198
+ if (exp) add('INPUT-PURPOSE', 'error', el, `Field collects personal data (${exp}) but has no autocomplete.`, `Add autocomplete="${exp}".`, 'WCAG 1.3.5');
199
+ }
200
+ if (ac === 'off' && AUTOCOMPLETE.some(([re]) => re.test(nm))) add('AUTOFILL', 'warning', el, 'autocomplete="off" on a personal-data field blocks autofill.', 'Remove autocomplete="off" or use the correct token (given-name, email, tel…).', 'WCAG 1.3.5 / 3.3.7', 'autocomplete');
201
+ if (ty === 'password') {
202
+ if (ac === undefined || ac === 'off') add('AUTH', 'warning', el, 'Password field blocks/omits password-manager support.', 'autocomplete="current-password" (login) or "new-password" (registration). Allow paste.', 'WCAG 3.3.8');
203
+ }
204
+ if (has(el, 'onpaste') || has(el, 'ondrop')) add('AUTH', 'error', el, 'Paste is blocked on a field.', 'Remove the onpaste/ondrop handler so password managers and paste work.', 'WCAG 3.3.8', 'onpaste');
205
+ if (/(confirm|repeat|retype|re-?enter|verify)/i.test(nm)) add('REDUNDANT-ENTRY', 'manual', el, 'Looks like a "confirm/repeat" field — users must not re-type info already given in the same process.', 'Remove the duplicate field, or offer autofill / a "same as above" option.', 'WCAG 3.3.7');
206
+ if (ty === 'radio') { const n = attr(el, 'name') || ''; radios.set(n, (radios.get(n) || []).concat([{ el, group: anc.some((a) => a.tagName === 'fieldset' || ['group', 'radiogroup'].includes(attr(a, 'role'))) }])); }
207
+ }
208
+ }
209
+ if (t === 'fieldset' && !kids(el).some((k) => k.tagName === 'legend')) add('LABEL', 'warning', el, '<fieldset> has no <legend>.', 'Add <legend>Group question</legend> as the first child.', 'WCAG 1.3.1 / 3.3.2');
210
+ const cls = attr(el, 'class') || '';
211
+ if (/(^|[\s_-])(form-)?(error|success)[-_]?(message|msg)([\s_-]|$)|form-(error|success)\b|\balert\b/.test(cls) && !has(el, 'role') && !has(el, 'aria-live')) add('STATUS-LIVE', 'warning', el, 'Message container is not announced by screen readers when it appears.', 'Errors: role="alert". Success/info: role="status" (or aria-live="polite"). The container must exist in the DOM before text is injected.', 'WCAG 4.1.3');
212
+ const dem = attr(el, 'data-errormsg');
213
+ if (dem && !isDyn(dem) && /^(this field is )?required\.?$/i.test(dem.trim())) add('ERROR-SUGGESTION', 'warning', el, `Error text "${dem}" only says the field is required.`, 'Say what to do, per field: "Enter your email, e.g. name@example.com". (3.3.1 identify the field + 3.3.3 suggest a fix.)', 'WCAG 3.3.1 / 3.3.3', 'data-errormsg');
214
+
215
+ // ---- headings ------------------------------------------------------------------------------
216
+ if (/^h[1-6]$/.test(t)) {
217
+ headings.push({ el, lvl: +t[1] });
218
+ if (!text(el).replace(new RegExp(DYN, 'g'), '').trim() && !isDyn(text(el)) && nameOf(el) === 'no') add('HEADING', 'error', el, `<${t}> is empty.`, 'Remove the empty heading or give it text.', 'WCAG 1.3.1 / 2.4.6');
219
+ } else if (['span', 'div', 'p'].includes(t) && !role && !inHeading && /(^|\s)(h[1-6]-title|heading-title-[1-6]|h[1-6])(\s|$)/.test(cls) && text(el).trim()) {
220
+ const lv = (cls.match(/(?:h|heading-title-)([1-6])/) || [])[1] || '2';
221
+ add('HEADING', 'warning', el, `<${t}> styled as a heading (class "${cls.trim()}") is not a real heading.`, `Use <h${lv} class="${cls.trim()}"> (keep the classes — only the tag changes), or role="heading" aria-level="${lv}" if the tag cannot change.`, 'WCAG 1.3.1 / 2.4.6');
222
+ }
223
+
224
+ // ---- tabindex / keyboard / aria ------------------------------------------------------------
225
+ const ti = attr(el, 'tabindex');
226
+ if (ti !== undefined && !isDyn(ti)) {
227
+ const n = parseInt(ti, 10);
228
+ if (n > 0) add('TABINDEX', 'error', el, `tabindex="${ti}" forces a custom tab order.`, 'Use tabindex="0" or none; fix the DOM order instead.', 'WCAG 2.4.3', 'tabindex');
229
+ else if (n === -1 && ((t === 'a' && has(el, 'href')) || ['button', 'input', 'select', 'textarea'].includes(t)) && !hidden(el) && !anc.some(hidden)) add('TABINDEX', 'warning', el, `tabindex="-1" on a visible ${t}${['checkbox', 'radio'].includes(attr(el, 'type')) ? ' (native checkbox/radio replaced by a custom look)' : ''}: keyboard users cannot reach it.`, ['checkbox', 'radio'].includes(attr(el, 'type')) ? 'Keep the native input focusable: remove tabindex="-1" and hide it visually with the sr-only technique (position:absolute; opacity:0; width/height:1px — NOT display:none), then style the custom box with input:focus-visible + .box. Otherwise the JS replacement must be focusable, role="checkbox", aria-checked, Space-key operable.' : 'Remove tabindex="-1" unless the control is hidden/inactive (then also hide it with aria-hidden/hidden/inert). Toggle it from JS when a menu/slide opens.', 'WCAG 2.1.1 / 2.4.3', 'tabindex');
230
+ else if (n === 0 && NON_INTERACTIVE.has(t) && !role && !has(el, 'onclick')) add('TABINDEX', 'warning', el, `tabindex="0" on non-interactive <${t}> without a role.`, 'Remove it, or give a role + accessible name + key handling. Scrollable regions: role="region" aria-label="…".', 'WCAG 2.4.3 / 4.1.2', 'tabindex');
231
+ }
232
+ if (has(el, 'accesskey')) add('SHORTCUT', 'warning', el, 'accesskey creates a single-key shortcut that can clash with assistive tech.', 'Remove accesskey, or provide a way to turn off/remap it.', 'WCAG 2.1.4', 'accesskey');
233
+ if (has(el, 'onclick') || has(el, 'ng-click')) {
234
+ if (NON_INTERACTIVE.has(t) && !isFocusable(el) && !WIDGET_ROLES.has(role)) add('KEYBOARD', 'error', el, `<${t}> has a click handler but is not keyboard-operable.`, 'Use <button type="button"> (best). If impossible: role="button" tabindex="0" + Enter/Space key handler.', 'WCAG 2.1.1', 'onclick');
235
+ }
236
+ if (role && !['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(t) && WIDGET_ROLES.has(role) && !has(el, 'tabindex') && !has(el, 'disabled') && !hidden(el) && !['option', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem', 'tab'].includes(role)) add('KEYBOARD', 'error', el, `role="${role}" on <${t}> is not focusable.`, `Prefer a native element. Otherwise add tabindex="0" and Enter/Space handling for role="${role}".`, 'WCAG 2.1.1');
237
+ if (role) {
238
+ const toks = role.split(/\s+/);
239
+ if (!toks.some((r) => ROLES.has(r) || /^(doc|graphics)-/.test(r))) add('ARIA-ROLE', 'error', el, `Unknown ARIA role "${role}".`, 'Use a valid WAI-ARIA role (or remove it).', 'WCAG 4.1.2', 'role');
240
+ else {
241
+ const r = toks.find((x) => ROLES.has(x));
242
+ if (IMPLICIT[t] === r && !(t === 'li' && false)) add('ARIA-ROLE', 'info', el, `role="${r}" is redundant on <${t}>.`, 'Remove the role (native semantics already apply).', 'WCAG 4.1.2', 'role');
243
+ (ROLE_REQUIRES[r] || []).forEach((q) => { if (!has(el, q) && !['input', 'select'].includes(t) && !(r === 'heading' && /^h[1-6]$/.test(t))) add('ARIA-ROLE', 'error', el, `role="${r}" requires ${q}.`, `Add ${q}="…" and keep it in sync from JS.`, 'WCAG 4.1.2', 'role'); });
244
+ if (r === 'dialog' || r === 'alertdialog') {
245
+ if (!attr(el, 'aria-label') && !has(el, 'aria-labelledby')) add('ARIA', 'warning', el, 'Dialog has no accessible name.', 'aria-labelledby="<id of the dialog heading>".', 'WCAG 4.1.2');
246
+ if (attr(el, 'aria-modal') !== 'true') add('TRAP', 'info', el, 'role="dialog" without aria-modal="true".', 'Add aria-modal="true", move focus inside on open, trap Tab, close on Esc, return focus to the opener.', 'WCAG 2.4.3 / 2.1.2');
247
+ }
248
+ }
249
+ }
250
+ for (const a of el.attrs || []) {
251
+ if (a.name.startsWith('aria-') && !ARIA_ATTRS.has(a.name.slice(5))) add('ARIA', 'error', el, `"${a.name}" is not a valid ARIA attribute.`, 'Check the spelling against WAI-ARIA 1.2 (e.g. aria-labelledby, aria-expanded).', 'WCAG 4.1.2', a.name);
252
+ }
253
+ if (hidden(el) && !has(el, 'hidden') && !has(el, 'inert') && !/display\s*:\s*none/i.test(attr(el, 'style') || '')) {
254
+ let bad = isFocusable(el);
255
+ if (!bad) descendants(el, (d) => { if (isFocusable(d)) bad = true; });
256
+ if (bad) add('ARIA', 'warning', el, 'aria-hidden="true" contains focusable elements. If the container is only hidden by CSS class (not display:none/visibility:hidden/inert) keyboard users can still tab into it.', 'Hide it for everyone while closed (display:none / visibility:hidden / the inert attribute) — or set tabindex="-1" on every focusable child. Toggle aria-hidden, inert and focusability together in JS when it opens.', 'WCAG 4.1.2 / 2.4.3', 'aria-hidden');
257
+ }
258
+ if (attr(el, 'aria-label') && !role && ['div', 'span', 'p', 'i', 'b', 'strong', 'em'].includes(t)) add('ARIA', 'warning', el, `aria-label on a generic <${t}> is ignored by many screen readers.`, 'Add a role (e.g. role="group"/"region") or move the text into the element.', 'WCAG 4.1.2', 'aria-label');
259
+ if (t === 'nav') shared.navs.push({ file, line: loc(el).startLine, col: loc(el).startCol, labeled: !!(attr(el, 'aria-label') || has(el, 'aria-labelledby')) });
260
+ if (t === 'main') shared.mains.push({ file, id: attr(el, 'id') });
261
+ if (t === 'table') {
262
+ let th = false; descendants(el, (d) => { if (d.tagName === 'th') th = true; });
263
+ if (!th && role !== 'presentation' && role !== 'none') add('SEMANTIC', 'warning', el, '<table> has no <th> header cells.', 'Use <th scope="col|row"> for headers; for layout tables use role="presentation" (better: CSS grid/flex).', 'WCAG 1.3.1');
264
+ }
265
+ for (const a of ['onmouseover', 'onmouseenter', 'onmouseout']) if (has(el, a)) add('MODALITY', 'warning', el, `${a} works with a mouse only.`, `Mirror it with onfocus/onblur (and keep content dismissible/hoverable).`, 'WCAG 2.1.1 / 1.4.13', a);
266
+ if (t === 'script') {
267
+ const s = kids(el).find((c) => c.nodeName === '#text');
268
+ if (s && !attr(el, 'src') && !/(json|template)/.test(attr(el, 'type') || '')) inlineScripts.push({ code: raw.slice(s.sourceCodeLocation.startOffset, s.sourceCodeLocation.endOffset), line: s.sourceCodeLocation.startLine });
269
+ }
270
+ // sensory text
271
+ if (!['script', 'style'].includes(t)) kids(el).forEach((c) => {
272
+ if (c.nodeName === '#text' && /\b(click|press|tap|use)\b[^.]{0,40}\b(red|green|blue|round|square|circular)\b|\b(on the|to the)\s+(left|right)\b|\b(above|below)\s+(button|link|image)\b/i.test(c.value)) {
273
+ const l = c.sourceCodeLocation || {}; out.push({ file, line: l.startLine || 1, col: l.startCol || 1, rule: 'SENSORY', severity: 'manual', message: 'Instruction relies on colour/shape/position.', fix: 'Also name the control (e.g. “Select the Book now button”).', wcag: 'WCAG 1.3.3 / 1.4.1' });
274
+ }
275
+ });
276
+ kids(el).forEach((c) => c.tagName && visit(c, anc.concat([el])));
277
+ };
278
+ kids(doc).forEach((c) => c.tagName && visit(c, []));
279
+
280
+ // ---- resolve file-level things -------------------------------------------------------------
281
+ for (let k = out.length - 1; k >= 0; k--) {
282
+ const p = out[k];
283
+ if (!p.__pendingLabel) continue;
284
+ out.splice(k, 1);
285
+ const hasLabel = p.named || (p.idv && labelsFor.has(p.idv));
286
+ if (!hasLabel) {
287
+ const ph = attr(p.el, 'placeholder');
288
+ out.push({ file, line: p.line, col: p.col, rule: 'LABEL', severity: ph ? 'warning' : 'error', message: ph ? 'Field relies on placeholder only (disappears while typing; weak accessible name).' : 'Form control has no label.', fix: 'Add <label for="ID">Field name</label> (id must match) or aria-label="Field name".', wcag: 'WCAG 1.3.1 / 3.3.2 / 4.1.2' });
289
+ } else if (p.idv && labelsFor.has(p.idv) && !p.named) {
290
+ // label exists – but is it only rendered when a CMS switch is on?
291
+ const lab = findLabel(doc, p.idv);
292
+ if (lab && conditionalDepthAt(raw, lab.sourceCodeLocation.startOffset) > 0 && !attr(p.el, 'aria-label')) {
293
+ out.push({ file, line: p.line, col: p.col, rule: 'LABEL', severity: 'warning', message: 'Visible <label> is wrapped in a CMS {if} (e.g. enable-label). When the editor turns it off the field has no name.', fix: 'Add a fallback: aria-label="{if=\\"…enable-label…\\"}{else}#$variable|label$#{/if}" — or always render the label and hide it visually (.sr-only).', wcag: 'WCAG 3.3.2 / 4.1.2' });
294
+ }
295
+ }
296
+ }
297
+ function findLabel(node, forId) {
298
+ let r = null;
299
+ const w = (n) => { (n.tagName === 'template' && n.content ? n.content.childNodes : n.childNodes || []).forEach((c) => { if (c.tagName === 'label' && attr(c, 'for') === forId) r = r || c; if (c.tagName) w(c); }); };
300
+ w(node); return r;
301
+ }
302
+ refs.forEach((r) => { if (!ids.has(r.id)) add('ARIA', 'warning', r.el, `${r.attr}="${r.id}" points to an id that is not in this file.`, 'Check the id exists in the rendered page (it may live in another block). If not, fix the id or remove the attribute.', 'WCAG 4.1.2', r.attr); });
303
+ let prev = 0;
304
+ headings.forEach((h) => { if (prev && h.lvl > prev + 1) add('HEADING', 'warning', h.el, `Heading level jumps from h${prev} to h${h.lvl}.`, `Use h${prev + 1} (style size with CSS classes, not heading level).`, 'WCAG 1.3.1 / 2.4.6'); prev = h.lvl; });
305
+ if (headings.filter((h) => h.lvl === 1).length > 1) add('HEADING', 'warning', headings.filter((h) => h.lvl === 1)[1].el, 'More than one <h1> in this file.', 'One <h1> per page; use h2 for other sections.', 'WCAG 1.3.1');
306
+ radios.forEach((arr, name) => { if (arr.length > 1 && !arr.some((r) => r.group)) add('LABEL', 'warning', arr[0].el, `Radio group "${name}" is not wrapped in <fieldset><legend> / role="radiogroup".`, '<fieldset><legend>Question</legend> …radios… </fieldset>', 'WCAG 1.3.1 / 3.3.2'); });
307
+ return { findings: out, inlineScripts };
308
+ }
309
+ module.exports = { analyzeHtml };
package/src/index.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { analyzeHtml } = require('./html-rules');
5
+ const { analyzeScss, collectVars, parseScss } = require('./css-rules');
6
+ const { analyzeJs } = require('./js-rules');
7
+
8
+ const DEFAULT_IGNORE_DIRS = ['node_modules', '.git', 'public', 'dist', 'build', 'logs', 'vendors', 'vendor', 'coverage'];
9
+ const ORDER = { error: 0, warning: 1, manual: 2, info: 3 };
10
+
11
+ function walkDir(dir, cfg, out = []) {
12
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
13
+ const p = path.join(dir, e.name);
14
+ if (e.isDirectory()) { if (!cfg.ignoreDirs.includes(e.name)) walkDir(p, cfg, out); }
15
+ else if (/\.(html|scss|js)$/.test(e.name) && !/\.min\.js$/.test(e.name) && !cfg.ignore.some((re) => re.test(p.replace(/\\/g, '/')))) out.push(p);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function loadConfig(root, opts) {
21
+ let user = {};
22
+ const f = path.join(root, '.galaxy-a11y.json');
23
+ if (fs.existsSync(f)) user = JSON.parse(fs.readFileSync(f, 'utf8'));
24
+ return {
25
+ ignoreDirs: DEFAULT_IGNORE_DIRS.concat(user.ignoreDirs || []),
26
+ ignore: (user.ignore || []).map((s) => new RegExp(s)),
27
+ disable: new Set([...(user.disable || []), ...(opts.disable || [])].map((s) => s.toUpperCase())),
28
+ };
29
+ }
30
+
31
+ function scan(root, opts = {}) {
32
+ root = path.resolve(root);
33
+ const cfg = loadConfig(root, opts);
34
+ const files = fs.statSync(root).isDirectory() ? walkDir(root, cfg) : [root];
35
+ const base = fs.statSync(root).isDirectory() ? root : path.dirname(root);
36
+ const rel = (f) => path.relative(base, f).replace(/\\/g, '/');
37
+ const shared = { navs: [], mains: [], skipButtons: [], skipLinks: [], scssBlocks: [], fixedHeaders: [], focusStyled: new Set(), hasScrollPadding: false };
38
+ const vars = {};
39
+ const scssFiles = files.filter((f) => f.endsWith('.scss'));
40
+ const text = new Map(files.map((f) => [f, fs.readFileSync(f, 'utf8')]));
41
+ scssFiles.forEach((f) => collectVars(parseScss(text.get(f)), vars));
42
+ let findings = [], allJs = '';
43
+ for (const f of files) {
44
+ const raw = text.get(f), r = rel(f);
45
+ try {
46
+ if (f.endsWith('.html')) {
47
+ const { findings: fs_, inlineScripts } = analyzeHtml(raw, r, shared);
48
+ findings.push(...fs_);
49
+ inlineScripts.forEach((s) => { allJs += s.code; findings.push(...analyzeJs(s.code, r, s.line - 1)); });
50
+ } else if (f.endsWith('.scss')) findings.push(...analyzeScss(raw, r, vars, shared));
51
+ else { allJs += raw; findings.push(...analyzeJs(raw, r)); }
52
+ } catch (e) { findings.push({ file: r, line: 1, col: 1, rule: 'SCANNER', severity: 'info', message: 'Could not analyse file: ' + e.message, fix: '', wcag: '' }); }
53
+ }
54
+ // ---- project-wide rules ---------------------------------------------------------------------
55
+ const P = (rule, severity, file, line, message, fix, wcag) => findings.push({ file, line, col: 1, rule, severity, message, fix, wcag });
56
+ const hasSkipHandler = /(ada-skip|btn-ada-skip|skip-?link|skip-to)/i.test(allJs);
57
+ const isDir = fs.statSync(root).isDirectory();
58
+ const hasHeader = files.some((f) => /[\\/]header[\\/][^\\/]+\.html$/.test(f));
59
+ if (isDir && hasHeader && !shared.skipButtons.length && !shared.skipLinks.length) P('SKIP-LINK', 'error', '(project)', 1, 'No skip-to-content control found in any header template.', 'First focusable element in the header: <a class="skip-link" href="#main">Skip to main content</a>, visible on :focus, and <main id="main" tabindex="-1">.', 'WCAG 2.4.1');
60
+ shared.skipButtons.forEach((s) => { if (!hasSkipHandler) P('SKIP-LINK', 'error', s.file, s.line, `Skip control <button class="${s.cls}"> has no JS handler anywhere in the scanned code.`, 'Wire it: document.querySelector(".ada-skip").addEventListener("click", () => { const m = document.getElementById("main"); m.setAttribute("tabindex","-1"); m.focus(); }); — or replace it with <a href="#main">.', 'WCAG 2.4.1');
61
+ else P('SKIP-LINK', 'info', s.file, s.line, 'Skip control is a <button>, not a link. OK if the JS moves focus to <main>; an <a href="#main"> works without JS.', 'Prefer <a class="ada-skip" href="#main">.', 'WCAG 2.4.1'); });
62
+ shared.skipLinks.forEach((s) => { const id = (s.href || '').replace(/^#/, ''); if (id && !isNaN(1) && s.href.startsWith('#') && !shared.mains.some((m) => m.id === id) && !/\$|\u2022/.test(s.href)) P('SKIP-LINK', 'error', s.file, s.line, `Skip link target "${s.href}" does not exist (<main> has no id="${id}" in scanned templates).`, `Add id="${id}" to <main>.`, 'WCAG 2.4.1'); });
63
+ const unl = shared.navs.filter((n) => !n.labeled);
64
+ if (shared.navs.length > 1) unl.forEach((n) => P('NAV', 'warning', n.file, n.line, '<nav> without a label while the site has several <nav> landmarks.', 'aria-label="Main" / "Footer" / "Breadcrumb" (translatable: aria-label="__@@Main navigation@@__").', 'WCAG 1.3.1 / 2.4.1'));
65
+ if (shared.fixedHeaders.length && !shared.hasScrollPadding) { const h = shared.fixedHeaders[0]; P('FOCUS-STRATEGY', 'warning', h.file, h.line, `Fixed/sticky element "${h.sel}" can cover the focused element, and no scroll-padding-top exists anywhere.`, 'html { scroll-padding-top: <header height>; } and test Tab through the page: the focused control must stay visible.', 'WCAG 2.4.11 (2.2)'); }
66
+ const mainFiles = files.filter((f) => /[\\/]page[\\/][^\\/]+\.html$/.test(f));
67
+ if (mainFiles.length && !shared.mains.length) P('NAV', 'error', '(project)', 1, 'No <main> landmark found in any page template.', '<main id="main" tabindex="-1">…</main>', 'WCAG 1.3.1 / 2.4.1');
68
+ if (isDir) [
69
+ ['CONSISTENT-HELP', 'Help/contact link (phone, chat, contact page) must be in the same relative place on every page.', 'WCAG 3.2.6'],
70
+ ['SENSORY', 'Read content for “click the green button / on the right” style instructions.', 'WCAG 1.3.3'],
71
+ ['FLASH', 'Review hero videos/animations: nothing may flash more than 3×/second.', 'WCAG 2.3.1'],
72
+ ['AUDIO-CAPTIONS', 'Check captions/transcripts for every video (incl. YouTube/Vimeo CMS fields).', 'WCAG 1.2.x'],
73
+ ['CONTRAST', 'CMS-chosen colours (custom-bg-color / custom-text-color fields) cannot be checked statically — test real pages.', 'WCAG 1.4.3 / 1.4.11'],
74
+ ['HTML_LANG', 'The <html lang> and <title> are generated by the Galaxy platform shell, not by this theme — check the rendered page source.', 'WCAG 3.1.1 / 2.4.2'],
75
+ ['KEYBOARD', 'Tab through every page type once: order, visible focus, no traps, menus/sliders/forms usable with keys only.', 'WCAG 2.1.1 / 2.4.3 / 2.4.7'],
76
+ ].forEach(([r, m, w]) => P(r, 'manual', '(manual checklist)', 1, m, '', w));
77
+ // ---- ignore comments, disabled rules, dedupe ------------------------------------------------
78
+ const byFile = new Map();
79
+ files.forEach((f) => byFile.set(rel(f), text.get(f).split('\n')));
80
+ const seen = new Set();
81
+ findings = findings.filter((f) => {
82
+ if (cfgDisabled(cfg, f)) return false;
83
+ const L = byFile.get(f.file);
84
+ if (L) for (const ln of [f.line - 2, f.line - 1]) { const m = L[ln] && L[ln].match(/a11y-ignore(?::?\s*([\w,\s_-]+))?/i); if (m && (!m[1] || !m[1].trim() || m[1].toUpperCase().split(/[\s,]+/).includes(f.rule))) return false; }
85
+ const k = [f.file, f.line, f.col, f.rule, f.message].join('|');
86
+ if (seen.has(k)) return false; seen.add(k); return true;
87
+ });
88
+ findings.sort((a, b) => ORDER[a.severity] - ORDER[b.severity] || a.file.localeCompare(b.file) || a.line - b.line);
89
+ return { root, filesScanned: files.length, findings };
90
+ }
91
+ const cfgDisabled = (cfg, f) => cfg.disable.has(f.rule);
92
+ module.exports = { scan, ORDER };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ // Regex-level checks for behaviour that cannot be seen in markup. Comments are blanked first.
3
+ const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).replace(/(^|[^:'"\\])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length));
4
+ function analyzeJs(raw, file, lineOffset = 0) {
5
+ const out = [], code = strip(raw), lines = code.split('\n');
6
+ const add = (rule, severity, idx, message, fix, wcag) => out.push({ file, line: lineOffset + code.slice(0, idx).split('\n').length, col: 1, rule, severity, message, fix, wcag });
7
+ const each = (re, fn) => { let m; const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); while ((m = r.exec(code))) fn(m); };
8
+ const mentions = (re) => re.test(code);
9
+ const overlay = /(modal|popup|lightbox|dialog|drawer|offcanvas|fancybox|burger|mobile-?menu)/i;
10
+ const libHandles = /fancybox/i.test(code); // Fancybox 3 ships Esc + focus handling
11
+
12
+ each(/\.(keyCode|which)\b|\bevent\.keyCode\b/, (m) => add('KEYBOARD', 'info', m.index, 'Deprecated keyCode/which used for key handling.', "Use event.key ('Enter', ' ', 'Escape', 'ArrowDown'…).", 'WCAG 2.1.1'));
13
+ each(/(mouseenter|mouseover|mouseleave|mouseout)\b/, (m) => { if (!/(focusin|focusout|\bfocus\b|\bblur\b)/.test(code)) add('MODALITY', 'warning', m.index, `Hover handler ("${m[1]}") with no matching focus handler in this file.`, "Listen to 'focusin'/'focusout' alongside mouseenter/mouseleave and toggle the same state; make popups dismissible with Esc.", 'WCAG 2.1.1 / 1.4.13'); });
14
+ each(/['"](touchstart|touchmove|touchend)['"]|\bswipe\b|\bDraggable\b|\bdraggable\s*:\s*true|\ballowTouchMove\s*:\s*true/i, (m) => add('GESTURE', 'warning', m.index, 'Touch/drag/swipe gesture. Make sure the same action works with a single tap/click (prev/next buttons, pagination).', 'Provide visible, focusable buttons (aria-label="Previous slide"/"Next slide") for every swipe/drag action.', 'WCAG 2.5.1 / 2.5.7'));
15
+ each(/['"](mousedown|touchstart|pointerdown)['"]/, (m) => add('PTR-GESTURE', 'warning', m.index, `Action fires on "${m[1]}" (down-event) — users cannot abort it.`, "Trigger on 'click' / 'pointerup', or allow cancel by moving away before release.", 'WCAG 2.5.2'));
16
+ each(/(?:key(?:\s*===?\s*|\.toLowerCase\(\)\s*===?\s*)['"][a-z0-9]['"]|keyCode\s*===?\s*(?:6[5-9]|[7-8]\d|90|4[89]|5[0-7]))/i, (m) => { const near = code.slice(Math.max(0, m.index - 200), m.index + 120); if (!/(ctrlKey|altKey|metaKey)/.test(near)) add('CHAR-KEYS', 'error', m.index, 'Single-character keyboard shortcut.', 'Require a modifier (Ctrl/Alt) or let users turn it off/remap it. Do NOT use plain letters/digits.', 'WCAG 2.1.4'); });
17
+ each(/\b(autoplay\s*:|\.autoplay\b|setInterval\s*\()/, (m) => { if (!/(pauseOnMouseEnter|\.autoplay\.stop|\.pause\(|pause-?button|btn-?pause|stopAutoplay|autoplay\s*:\s*false)/i.test(code)) add('ANIMATION', 'warning', m.index, 'Auto-advancing content with no pause/stop control found in this file.', 'Add a visible Pause/Play button (swiper.autoplay.stop()/start()), stop on focus/hover (pauseOnMouseEnter + focusin), and disable autoplay when matchMedia("(prefers-reduced-motion: reduce)").matches.', 'WCAG 2.2.2'); });
18
+ each(/\b(location(?:\.href|\.replace|\.assign)?\s*=|window\.location)/, (m) => { const near = code.slice(Math.max(0, m.index - 120), m.index); if (/setTimeout|setInterval/.test(near)) add('REFRESH', 'warning', m.index, 'Timed redirect.', 'Avoid auto-redirects; if required, warn the user and let them extend/stop it.', 'WCAG 2.2.1 / 3.2.5'); });
19
+ each(/(addEventListener\(\s*['"]paste['"]|\.on\(\s*['"]paste['"]|onpaste)/, (m) => { const near = code.slice(m.index, m.index + 200); if (/preventDefault|return false/.test(near)) add('AUTH', 'error', m.index, 'Paste is blocked.', 'Remove the paste blocker (password managers & assistive tech need paste).', 'WCAG 3.3.8'); });
20
+ if (overlay.test(file) || (overlay.test(code) && /(\.open|openModal|\.show\(|addClass\(['"][^'"]*(open|active|show|visible)|classList\.(add|toggle)\(['"][^'"]*(open|active|show|visible))/i.test(code))) {
21
+ const first = code.search(overlay);
22
+ if (!/\.focus\(/.test(code)) add('FOCUS-ELEMENT', libHandles ? 'info' : 'warning', Math.max(0, first), 'Opens an overlay/menu/dialog but this file never calls .focus().', 'On open: move focus into it (first focusable or the heading with tabindex="-1"). On close: return focus to the trigger element.', 'WCAG 2.4.3 / 3.2.1');
23
+ if (!/(Escape|['"]Esc['"]|keyCode\s*===?\s*27|which\s*===?\s*27)/.test(code)) add('TRAP', libHandles ? 'info' : 'warning', Math.max(0, first), 'Overlay/menu code has no Escape-key handling.', "document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); }); and trap Tab inside modal dialogs (cycle first↔last focusable).", 'WCAG 2.1.2 / 2.4.3');
24
+ if (!/(aria-expanded|aria-hidden|aria-modal|inert|\.attr\(\s*['"]aria-|setAttribute\(\s*['"]aria-)/.test(code)) add('ARIA', 'warning', Math.max(0, first), 'Overlay/menu toggles visually but never updates ARIA state.', "Toggle aria-expanded on the trigger and aria-hidden (or inert) on the panel.", 'WCAG 4.1.2');
25
+ }
26
+ if (/\.validate\s*\(/.test(code) && !/(aria-live|role\s*[=:]\s*['"]alert|aria-invalid|aria-describedby|errorElement)/.test(code)) { const m = code.search(/\.validate\s*\(/); add('ERROR-SUGGESTION', 'warning', m, 'jQuery-validate form with no accessible error wiring.', "Configure: errorElement:'span', errorClass:'error', highlight/unhighlight to set aria-invalid, errorPlacement to set role=\"alert\" and aria-describedby, invalidHandler to focus the first invalid field.", 'WCAG 3.3.1 / 3.3.3 / 4.1.3'); }
27
+ each(/window\.open\s*\(/, (m) => add('NAV', 'info', m.index, 'window.open() opens a new window/tab without warning.', 'Warn users (visually and in the accessible name) or use a normal link.', 'WCAG 3.2.5'));
28
+ each(/\.scrollTo\(|scroll-behavior|behavior\s*:\s*['"]smooth/, (m) => { if (!/prefers-reduced-motion/.test(code)) add('ANIMATION', 'info', m.index, 'Smooth scrolling without a prefers-reduced-motion check.', "const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; behavior: reduce ? 'auto' : 'smooth'", 'WCAG 2.3.3'); });
29
+ each(/\.on\(\s*['"]click['"]\s*,\s*['"]?(?:div|span|li|img|\.[\w-]+)['"]?/, () => {});
30
+ return out;
31
+ }
32
+ module.exports = { analyzeJs };
package/src/report.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ const { ORDER } = require('./index');
3
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
4
+ const col = (on) => (code, s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
5
+
6
+ function text(res, { all = false, color = false, fixes = true, limit = 0 } = {}) {
7
+ const c = col(color), lvl = all ? 3 : 2, list = res.findings.filter((f) => ORDER[f.severity] <= lvl);
8
+ const sev = { error: (s) => c(31, s), warning: (s) => c(33, s), manual: (s) => c(36, s), info: (s) => c(90, s) };
9
+ const out = [];
10
+ const byFile = new Map();
11
+ list.forEach((f) => { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); });
12
+ for (const [file, fl] of byFile) {
13
+ out.push('\n' + c(1, file));
14
+ (limit ? fl.slice(0, limit) : fl).forEach((f) => {
15
+ out.push(` ${String(f.line).padStart(4)}:${String(f.col).padEnd(3)} ${sev[f.severity](f.severity.padEnd(7))} ${c(1, f.rule.padEnd(15))} ${f.message} ${c(90, f.wcag)}`);
16
+ if (fixes && f.fix) out.push(...f.fix.split('\n').map((l, i) => `${' '.repeat(34)}${i ? ' ' : c(32, 'fix: ')}${l}`));
17
+ });
18
+ }
19
+ out.push('\n' + summary(res, list, c));
20
+ return out.join('\n');
21
+ }
22
+ function summary(res, list, c = (a, s) => s) {
23
+ const n = (s) => list.filter((f) => f.severity === s).length;
24
+ const rules = {};
25
+ list.forEach((f) => { rules[f.rule] = rules[f.rule] || { error: 0, warning: 0, manual: 0, info: 0 }; rules[f.rule][f.severity]++; });
26
+ const rows = Object.entries(rules).sort((a, b) => (b[1].error * 1000 + b[1].warning) - (a[1].error * 1000 + a[1].warning)).map(([r, v]) => ` ${r.padEnd(16)} ${String(v.error).padStart(5)} err ${String(v.warning).padStart(5)} warn ${String(v.manual).padStart(3)} manual`);
27
+ return [c(1, `Scanned ${res.filesScanned} files — ${n('error')} errors, ${n('warning')} warnings, ${n('manual')} manual checks${n('info') ? `, ${n('info')} notes` : ''}`), '', c(1, 'By rule:'), ...rows].join('\n');
28
+ }
29
+ function html(res) {
30
+ const rows = res.findings.map((f) => `<tr class="${f.severity}"><td>${esc(f.file)}:${f.line}</td><td>${f.severity}</td><td>${esc(f.rule)}</td><td>${esc(f.message)}<br><small>${esc(f.wcag)}</small></td><td><pre>${esc(f.fix)}</pre></td></tr>`).join('\n');
31
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Galaxy accessibility report</title><meta name="viewport" content="width=device-width,initial-scale=1">
32
+ <style>body{font:14px/1.4 system-ui,sans-serif;margin:24px;color:#111}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:6px;vertical-align:top;text-align:left}pre{margin:0;white-space:pre-wrap;font:12px ui-monospace,monospace}
33
+ tr.error td:nth-child(2){color:#b00020;font-weight:700}tr.warning td:nth-child(2){color:#8a5a00;font-weight:700}tr.manual td:nth-child(2){color:#00606f}tr.info{color:#555}.f button{margin-right:6px;padding:4px 10px}</style>
34
+ <h1>Galaxy accessibility report</h1><p>${res.filesScanned} files scanned · ${res.findings.length} findings</p>
35
+ <p class="f"><button onclick="f('')">All</button><button onclick="f('error')">Errors</button><button onclick="f('warning')">Warnings</button><button onclick="f('manual')">Manual</button><button onclick="f('info')">Notes</button></p>
36
+ <table><thead><tr><th>Where</th><th>Level</th><th>Rule</th><th>Problem</th><th>Exact fix</th></tr></thead><tbody>${rows}</tbody></table>
37
+ <script>function f(s){document.querySelectorAll('tbody tr').forEach(function(r){r.hidden=s&&!r.classList.contains(s)})}</script></html>`;
38
+ }
39
+ module.exports = { text, html, summary };
package/src/scss.js ADDED
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ // Tiny SCSS reader: nested blocks with selector, declarations and line numbers. Not a full compiler.
3
+ function parseScss(src) {
4
+ const root = { selector: '', full: '', decls: [], children: [], line: 1, parent: null };
5
+ let cur = root, buf = '', bufLine = 1, line = 1, i = 0, interp = 0;
6
+ const flushDecl = () => {
7
+ const t = buf.trim();
8
+ buf = '';
9
+ if (!t || t.startsWith('@include') && !t.includes(':') || t.startsWith('@import') || t.startsWith('@use')) return;
10
+ const m = t.match(/^(\$?[\w-]+)\s*:\s*([\s\S]+)$/);
11
+ if (m) cur.decls.push({ prop: m[1], value: m[2].trim(), line: bufLine });
12
+ };
13
+ const resolve = (parent, sel) => {
14
+ const first = sel.split(',')[0].trim();
15
+ if (!parent || sel.startsWith('@')) return sel.startsWith('@') ? sel : first;
16
+ return first.includes('&') ? first.replace(/&/g, parent) : (parent + ' ' + first).trim();
17
+ };
18
+ while (i < src.length) {
19
+ const ch = src[i], nx = src[i + 1];
20
+ if (ch === '\n') line++;
21
+ if (ch === '/' && nx === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
22
+ if (ch === '/' && nx === '*') { i += 2; while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') line++; i++; } i += 2; continue; }
23
+ if (ch === '"' || ch === "'") { const q = ch; buf += ch; i++; while (i < src.length && src[i] !== q) { buf += src[i]; if (src[i] === '\n') line++; i++; } buf += q; i++; continue; }
24
+ if (ch === '#' && nx === '{') { interp++; buf += '#{'; i += 2; continue; }
25
+ if (interp && ch === '}') { interp--; buf += '}'; i++; continue; }
26
+ if (!buf.trim()) bufLine = line;
27
+ if (ch === '{') {
28
+ const sel = buf.trim().replace(/\s+/g, ' ');
29
+ const full = resolve(cur.full, sel);
30
+ const blk = { selector: sel, full, decls: [], children: [], line: bufLine, parent: cur };
31
+ cur.children.push(blk); cur = blk; buf = ''; i++; continue;
32
+ }
33
+ if (ch === '}') { flushDecl(); cur = cur.parent || root; i++; continue; }
34
+ if (ch === ';') { flushDecl(); i++; continue; }
35
+ buf += ch; i++;
36
+ }
37
+ return root;
38
+ }
39
+ function walk(block, fn) { fn(block); block.children.forEach((c) => walk(c, fn)); }
40
+ function collectVars(root, into) {
41
+ walk(root, (b) => b.decls.forEach((d) => { if (d.prop.startsWith('$')) into[d.prop] = d.value.replace(/\s*!default\s*$/, ''); }));
42
+ }
43
+ function resolveVal(v, vars, depth = 0) {
44
+ if (!v) return v;
45
+ v = v.trim();
46
+ if (v.startsWith('$') && vars[v] && depth < 6) return resolveVal(vars[v], vars, depth + 1);
47
+ return v;
48
+ }
49
+ module.exports = { parseScss, walk, collectVars, resolveVal };