@github/accessibility-scanner-alt-text-plugin 0.0.1 → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +258 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/src/config.d.ts +5 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +45 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/extract.d.ts +4 -0
- package/dist/src/extract.d.ts.map +1 -0
- package/dist/src/extract.js +22 -0
- package/dist/src/extract.js.map +1 -0
- package/dist/src/findings.d.ts +3 -0
- package/dist/src/findings.d.ts.map +1 -0
- package/dist/src/findings.js +16 -0
- package/dist/src/findings.js.map +1 -0
- package/dist/src/rules/filename-alt-text.d.ts +3 -0
- package/dist/src/rules/filename-alt-text.d.ts.map +1 -0
- package/dist/src/rules/filename-alt-text.js +21 -0
- package/dist/src/rules/filename-alt-text.js.map +1 -0
- package/dist/src/rules/index.d.ts +3 -0
- package/dist/src/rules/index.d.ts.map +1 -0
- package/dist/src/rules/index.js +8 -0
- package/dist/src/rules/index.js.map +1 -0
- package/dist/src/rules/missing-alt-text.d.ts +3 -0
- package/dist/src/rules/missing-alt-text.d.ts.map +1 -0
- package/dist/src/rules/missing-alt-text.js +22 -0
- package/dist/src/rules/missing-alt-text.js.map +1 -0
- package/dist/src/rules/placeholder-alt-text.d.ts +3 -0
- package/dist/src/rules/placeholder-alt-text.d.ts.map +1 -0
- package/dist/src/rules/placeholder-alt-text.js +31 -0
- package/dist/src/rules/placeholder-alt-text.js.map +1 -0
- package/dist/src/rules/repeated-alt-text.d.ts +3 -0
- package/dist/src/rules/repeated-alt-text.d.ts.map +1 -0
- package/dist/src/rules/repeated-alt-text.js +67 -0
- package/dist/src/rules/repeated-alt-text.js.map +1 -0
- package/dist/src/rules/vague-alt-text.d.ts +3 -0
- package/dist/src/rules/vague-alt-text.d.ts.map +1 -0
- package/dist/src/rules/vague-alt-text.js +133 -0
- package/dist/src/rules/vague-alt-text.js.map +1 -0
- package/dist/src/types.d.ts +49 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/normalize-alt-text.d.ts +7 -0
- package/dist/src/utils/normalize-alt-text.d.ts.map +1 -0
- package/dist/src/utils/normalize-alt-text.js +15 -0
- package/dist/src/utils/normalize-alt-text.js.map +1 -0
- package/package.json +43 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright GitHub, Inc.
|
|
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,259 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Alt-Text Plugin for the Accessibility Scanner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Alt-Text Plugin is a [plugin](https://github.com/github/accessibility-scanner/blob/main/PLUGINS.md) for the [AI-powered Accessibility Scanner](https://github.com/github/accessibility-scanner) that flags low-quality `alt` text on images. It complements axe-core's built-in `image-alt` rule, helping teams ship images with descriptive, screen-reader-friendly alternative text.
|
|
4
|
+
|
|
5
|
+
The plugin helps teams:
|
|
6
|
+
|
|
7
|
+
- 🖼️ Catch vague, generic, or filler `alt` text (`"image"`, `"photo"`, `"icon"`)
|
|
8
|
+
- 📛 Catch raw filenames used as `alt` text (`"IMG_1234.png"`)
|
|
9
|
+
- 🔁 Catch runs of adjacent images that share the same `alt` text
|
|
10
|
+
- 🚧 Catch boilerplate placeholder `alt` text (`"todo"`, `"tbd"`, `"fixme"`)
|
|
11
|
+
- ♿ Catch images missing an `alt` attribute entirely (without flagging intentional decorative `alt=""`)
|
|
12
|
+
|
|
13
|
+
> ⚠️ **Note:** This plugin is in active development alongside the a11y scanner's public preview. New rules are still being added and end-to-end integration with the scanner's issue-filing workflow is still maturing. Always review filed issues before acting on them.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## [Frequently-Asked Questions (FAQ)](https://github.com/github/accessibility-scanner/blob/main/FAQ.md)
|
|
18
|
+
|
|
19
|
+
The plugin inherits the a11y scanner's general FAQ — see the link above for questions about scanning, caching, GitHub Enterprise, and more. Plugin-specific questions are answered inline below.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Background
|
|
24
|
+
|
|
25
|
+
This plugin catches low-quality `alt` text that axe-core's built-in [`image-alt`](https://dequeuniversity.com/rules/axe/4.10/image-alt) rule cannot — vague single-word `alt`, raw filenames, runs of duplicate `alt`, and never-filled-in placeholders. The scope is intentionally narrow: deterministic, heuristic checks on `<img>` elements only. Non-`<img>` `role="img"` elements, decorative `alt=""`, and hidden subtrees are filtered out before any rule runs.
|
|
26
|
+
|
|
27
|
+
The project is under active development alongside the scanner's public preview. Roadmap and open work live in this repo's [Issues](https://github.com/github/accessibility-scanner-alt-text-plugin/issues). See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to contribute, including local setup, expected checks, and PR conventions.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
To use the Alt-Text Plugin, you'll need:
|
|
34
|
+
|
|
35
|
+
- **The [AI-powered Accessibility Scanner](https://github.com/github/accessibility-scanner)** (v3 or later) wired into a GitHub Actions workflow in your repository
|
|
36
|
+
- **Everything required to run the scanner itself** (Actions enabled, Issues enabled, a `GH_TOKEN` PAT — see the [scanner README](https://github.com/github/accessibility-scanner#requirements) for the full list)
|
|
37
|
+
|
|
38
|
+
The plugin is published to npm as [`@github/accessibility-scanner-alt-text-plugin`](https://www.npmjs.com/package/@github/accessibility-scanner-alt-text-plugin). The scanner installs it for you when running the `Find` sub-action — you don't need to copy any source into your repository or run `npm install` yourself.
|
|
39
|
+
|
|
40
|
+
To develop the plugin locally, you'll also need:
|
|
41
|
+
|
|
42
|
+
- **Node.js** matching the `engines` field in [`package.json`](./package.json) — currently `^22.13.0 || ^24 || ^26`
|
|
43
|
+
- **npm** (ships with Node)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Getting started
|
|
48
|
+
|
|
49
|
+
### 1. Enable the plugin in your workflow
|
|
50
|
+
|
|
51
|
+
The plugin is loaded from its npm package — there's nothing to copy into your repo. Add it to the scanner action's `scans` input as an **object** with `name`, `package`, and (recommended) a pinned `version`. Keep `"axe"` in the list too, since the scanner only runs Axe by default:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
name: Accessibility Scanner
|
|
55
|
+
on: workflow_dispatch
|
|
56
|
+
|
|
57
|
+
jobs:
|
|
58
|
+
accessibility_scanner:
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
steps:
|
|
61
|
+
- uses: github/accessibility-scanner@v3
|
|
62
|
+
with:
|
|
63
|
+
urls: |
|
|
64
|
+
https://example.com
|
|
65
|
+
repository: REPLACE_THIS/REPLACE_THIS
|
|
66
|
+
token: ${{ secrets.GH_TOKEN }}
|
|
67
|
+
cache_key: REPLACE_THIS
|
|
68
|
+
scans: |
|
|
69
|
+
["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> 👉 Update all `REPLACE_THIS` placeholders with your actual values. See the [scanner's Action inputs](https://github.com/github/accessibility-scanner#action-inputs) for the full list, and the scanner's [PLUGINS.md](https://github.com/github/accessibility-scanner/blob/main/PLUGINS.md) for how NPM plugins are loaded.
|
|
73
|
+
|
|
74
|
+
📚 Learn more
|
|
75
|
+
|
|
76
|
+
- [Plugin docs in the scanner repository](https://github.com/github/accessibility-scanner/blob/main/PLUGINS.md)
|
|
77
|
+
- [Scanner getting-started guide](https://github.com/github/accessibility-scanner#getting-started)
|
|
78
|
+
- [Writing workflows](https://docs.github.com/en/actions/how-tos/write-workflows)
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### 2. Run your first scan
|
|
83
|
+
|
|
84
|
+
Trigger your scanner workflow manually or on its configured schedule. The plugin runs on every URL the scanner visits, extracts each image exposed to assistive technology, and emits a finding for every rule violation. The scanner then turns those findings into GitHub issues.
|
|
85
|
+
|
|
86
|
+
📚 Learn more
|
|
87
|
+
|
|
88
|
+
- [View workflow run history](https://docs.github.com/en/actions/how-tos/monitor-workflows/view-workflow-run-history)
|
|
89
|
+
- [Running a workflow manually](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow#running-a-workflow)
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Rules
|
|
94
|
+
|
|
95
|
+
The plugin runs every extracted image through an append-only registry of rules. Each rule returns a finding when an image fails its criteria, and the scanner turns each finding into an issue.
|
|
96
|
+
|
|
97
|
+
| Rule | ID | Fires when | Example (flagged) |
|
|
98
|
+
| ------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
99
|
+
| **Missing alt** | `missing-alt-text` | The `alt` attribute is absent (`null`) or whitespace-only (`" "`). `alt=""` is treated as intentional decorative use and is **not** flagged. | `<img src="cat.png">`<br>`<img src="cat.png" alt=" ">` |
|
|
100
|
+
| **Vague alt** | `vague-alt-text` | The alt text is one of a curated set of generic single words (`image`, `photo`, `icon`, `logo`, `screenshot`, `chart`, `untitled`, etc.) or short filler phrases (`an image of`, `a photo of`). Normalization is applied before matching: case-insensitive, whitespace-collapsed, surrounding punctuation stripped. | `<img alt="image">`<br>`<img alt="An image of">`<br>`<img alt="PHOTO.">` |
|
|
101
|
+
| **Filename as alt** | `filename-alt-text` | The alt text ends in a common image file extension (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.bmp`, `.ico`). | `<img alt="IMG_1234.png">`<br>`<img alt="Screenshot 2024-04-28.jpg">` |
|
|
102
|
+
| **Repeated alt** | `repeated-alt-text` | Two or more adjacent images on the rendered page share the same normalized alt text. Useful for patterns like five star icons all labeled `"3/5 stars"`. | Five consecutive `<img alt="3/5 stars">` elements |
|
|
103
|
+
| **Placeholder alt** | `placeholder-alt-text` | The alt text matches a known boilerplate string that signals it was never written (`todo`, `tbd`, `fixme`, `placeholder`, `alt text`, `insert alt text`, `image alt`). Normalization is applied before matching. | `<img alt="TODO">`<br>`<img alt="insert alt text">` |
|
|
104
|
+
|
|
105
|
+
### Image extraction
|
|
106
|
+
|
|
107
|
+
Before rules run, the plugin extracts images from the page through Playwright's accessibility tree (`page.getByRole('img')`) and narrows the result to actual `<img>` elements. The following are filtered out automatically and never reach the rules:
|
|
108
|
+
|
|
109
|
+
- Non-`<img>` elements with `role="img"` (e.g. `<svg role="img">`, `<div role="img">`) — this plugin's rules only apply to HTML `<img>` tags
|
|
110
|
+
- Images inside `aria-hidden="true"` subtrees
|
|
111
|
+
- Images inside `display: none` or `visibility: hidden` subtrees
|
|
112
|
+
- Decorative images with `alt=""` (implicit `role="presentation"`)
|
|
113
|
+
|
|
114
|
+
### Overlap with Axe
|
|
115
|
+
|
|
116
|
+
The scanner's built-in Axe scan includes a rule called [`image-alt`](https://dequeuniversity.com/rules/axe/4.10/image-alt) that catches missing and whitespace-only `alt` attributes. If you have both `"axe"` and `"alt-text-scan"` enabled, the same image may be flagged by both. The other four rules in this plugin (`vague-alt-text`, `filename-alt-text`, `repeated-alt-text`, `placeholder-alt-text`) are unique to the plugin and don't overlap with Axe.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Output
|
|
121
|
+
|
|
122
|
+
When a rule fires, the plugin emits a finding with the following shape (matching the scanner's [`Finding` type](https://github.com/github/accessibility-scanner/blob/main/.github/actions/find/src/types.d.ts)):
|
|
123
|
+
|
|
124
|
+
- `scannerType` — always `'alt-text-scan'`; identifies which plugin produced the finding
|
|
125
|
+
- `ruleId` — the ID of the rule that fired (e.g. `'vague-alt-text'`)
|
|
126
|
+
- `url` — the page URL where the image was found
|
|
127
|
+
- `html` — the offending `<img>` element's outer HTML
|
|
128
|
+
- `problemShort` — one-sentence description of what's wrong, including the offending alt text where applicable
|
|
129
|
+
- `problemUrl` — link to the relevant WCAG technique or W3C tutorial
|
|
130
|
+
- `solutionShort` — one-sentence description of how to fix it
|
|
131
|
+
- `solutionLong` — optional longer explanation when one sentence isn't enough
|
|
132
|
+
|
|
133
|
+
The scanner uses these fields to file or update a GitHub issue.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
138
|
+
|
|
139
|
+
To override the default enabled state of one or more rules, add a `config.json` file in your scanner repository at `.github/scanner-plugins/alt-text-scan/config.json`:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
.github/scanner-plugins/alt-text-scan/
|
|
143
|
+
└── config.json ← optional
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"rules": {
|
|
149
|
+
"repeated-alt-text": false,
|
|
150
|
+
"placeholder-alt-text": false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- Each key under `rules` is a rule ID from the [Rules](#rules) table above; the value is `true` (run the rule) or `false` (skip it).
|
|
156
|
+
- Rules you don't list keep their default behavior. Today every rule defaults to enabled.
|
|
157
|
+
- Unknown rule IDs and non-boolean values are logged as warnings and ignored (typo guard).
|
|
158
|
+
- A missing or malformed `config.json` causes the plugin to run with all defaults.
|
|
159
|
+
- The plugin reads the config once at startup, not per URL.
|
|
160
|
+
|
|
161
|
+
A JSON Schema is published at [`schema/config.schema.json`](./schema/config.schema.json). Add a `$schema` line at the top of your `config.json` to get autocomplete, hover docs, and inline validation in editors that support JSON Schema (VS Code, JetBrains IDEs, etc.):
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"$schema": "https://raw.githubusercontent.com/github/accessibility-scanner-alt-text-plugin/main/schema/config.schema.json",
|
|
166
|
+
"rules": {
|
|
167
|
+
"repeated-alt-text": false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The `$schema` line is optional and is ignored by the plugin at runtime.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
### Local setup
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
git clone https://github.com/github/accessibility-scanner-alt-text-plugin.git
|
|
182
|
+
cd accessibility-scanner-alt-text-plugin
|
|
183
|
+
npm ci
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Common scripts
|
|
187
|
+
|
|
188
|
+
| Script | What it does |
|
|
189
|
+
| ---------------------- | --------------------------------------------- |
|
|
190
|
+
| `npm run test` | Runs the Vitest unit suite once |
|
|
191
|
+
| `npm run test:watch` | Runs Vitest in watch mode |
|
|
192
|
+
| `npm run typecheck` | Runs `tsc --noEmit` against the whole project |
|
|
193
|
+
| `npm run lint` | Runs ESLint |
|
|
194
|
+
| `npm run format` | Rewrites files with Prettier |
|
|
195
|
+
| `npm run format:check` | Reports formatting violations without writing |
|
|
196
|
+
|
|
197
|
+
Pull requests trigger two CI workflows: [`lint.yml`](./.github/workflows/lint.yml) runs `lint` and `format:check` on Node 24, and [`test.yml`](./.github/workflows/test.yml) runs `typecheck` and `test` across Node 22, 24, and 26.
|
|
198
|
+
|
|
199
|
+
### Project layout
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
index.ts # Plugin entry point: exports `name` and the default scan function
|
|
203
|
+
src/
|
|
204
|
+
config.ts # Loads & validates .github/scanner-plugins/alt-text-scan/config.json
|
|
205
|
+
extract.ts # Pulls visible <img> records from a Playwright page
|
|
206
|
+
findings.ts # Translates each RuleResult into the scanner's Finding shape
|
|
207
|
+
rules/
|
|
208
|
+
index.ts # Append-only rule registry
|
|
209
|
+
missing-alt-text.ts
|
|
210
|
+
vague-alt-text.ts
|
|
211
|
+
filename-alt-text.ts
|
|
212
|
+
placeholder-alt-text.ts
|
|
213
|
+
repeated-alt-text.ts
|
|
214
|
+
utils/
|
|
215
|
+
normalize-alt-text.ts # Lowercase, trim, collapse whitespace, strip punctuation
|
|
216
|
+
types.ts # Rule, RuleContext, RuleResult, ImageRecord, Finding
|
|
217
|
+
tests/
|
|
218
|
+
extract.test.ts # Playwright-driven tests for the image extractor
|
|
219
|
+
example-site.test.ts # Runs the plugin against the example/site-with-errors fixture
|
|
220
|
+
fixtures/ # Static HTML fixtures used by the extractor tests
|
|
221
|
+
unit/
|
|
222
|
+
*.test.ts # One file per rule, plus config.test.ts for the loader
|
|
223
|
+
utils/
|
|
224
|
+
helpers.ts # makeImage() and evaluateAlts() — shared across rule tests
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Adding a new rule
|
|
228
|
+
|
|
229
|
+
1. Create `src/rules/<rule-name>.ts` exporting a `Rule` (see [`src/types.ts`](./src/types.ts) for the shape — `id`, `problemUrl`, and `evaluate(context)`). Filenames under `src/` and `tests/` must be kebab-case; ESLint's `check-file/filename-naming-convention` rule enforces this.
|
|
230
|
+
2. Import the rule in [`src/rules/index.ts`](./src/rules/index.ts) and append it to `allRules`. The registry is append-only — don't reorder existing rules.
|
|
231
|
+
3. Add a `tests/unit/<rule-name>.test.ts` file. Use `evaluateAlts(alts, rule)` and `makeImage(overrides)` from [`tests/utils/helpers.ts`](./tests/utils/helpers.ts) for the common cases; construct an explicit `RuleContext` only when you need control over `src` or other per-image fields.
|
|
232
|
+
4. Run `npm run test && npm run typecheck && npm run lint` locally before opening a PR. CI re-runs them.
|
|
233
|
+
|
|
234
|
+
> [!IMPORTANT]
|
|
235
|
+
> Image extraction happens once per page, before any rule runs, so every rule sees the same filtered list regardless of which rules are enabled. Don't reach into the DOM from a rule — work from the `ImageRecord[]` the rule's context provides.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Feedback
|
|
240
|
+
|
|
241
|
+
💬 We welcome your feedback! To submit feedback or report issues, please open an issue in this repository. For broader feedback on the a11y scanner itself, file it in the [scanner repository](https://github.com/github/accessibility-scanner/issues).
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
📄 This project is licensed under the terms of the MIT open source license. See the [LICENSE](./LICENSE) file for the full terms.
|
|
248
|
+
|
|
249
|
+
## Maintainers
|
|
250
|
+
|
|
251
|
+
🔧 Maintained alongside the [AI-powered Accessibility Scanner](https://github.com/github/accessibility-scanner). See [CODEOWNERS](./.github/CODEOWNERS) for the responsible team.
|
|
252
|
+
|
|
253
|
+
## Support
|
|
254
|
+
|
|
255
|
+
❓ For support, please open an issue in this repository. See [SUPPORT.md](./SUPPORT.md) for support expectations, or the scanner's [SUPPORT](https://github.com/github/accessibility-scanner/blob/main/SUPPORT.md) document for guidance that applies across the project.
|
|
256
|
+
|
|
257
|
+
## Acknowledgement
|
|
258
|
+
|
|
259
|
+
✨ Built on top of [Playwright](https://playwright.dev/), [Vitest](https://vitest.dev/), and the broader open-source accessibility tooling ecosystem. Thank you to everyone contributing rules, tests, and review.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,gBAAgB,CAAA;AAE9C,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAOnC,wBAA8B,WAAW,CAAC,EAAC,IAAI,EAAE,UAAU,EAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { extractImages } from './src/extract.js';
|
|
3
|
+
import { allRules } from './src/rules/index.js';
|
|
4
|
+
import { emitFindings } from './src/findings.js';
|
|
5
|
+
import { loadConfig } from './src/config.js';
|
|
6
|
+
export const name = 'alt-text-scan';
|
|
7
|
+
// Config lives in the consumer's repo at `.github/scanner-plugins/<name>/config.json`.
|
|
8
|
+
const configPath = join(process.cwd(), '.github', 'scanner-plugins', name, 'config.json');
|
|
9
|
+
const knownRuleIds = new Set(allRules.map(r => r.id));
|
|
10
|
+
const configPromise = loadConfig(configPath, knownRuleIds);
|
|
11
|
+
export default async function altTextScan({ page, addFinding }) {
|
|
12
|
+
const url = page.url();
|
|
13
|
+
const { ruleOverrides } = await configPromise;
|
|
14
|
+
const enabledRules = allRules.filter(rule => ruleOverrides.get(rule.id) ?? rule.defaultEnabled ?? true);
|
|
15
|
+
// Extract images from the page.
|
|
16
|
+
let images;
|
|
17
|
+
try {
|
|
18
|
+
images = await extractImages(page);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
console.error(`[alt-text-scan] failed to extract images from ${url}:`, err);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (images.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
const ctx = { url, images };
|
|
27
|
+
// Enforce checks on each image against each rule.
|
|
28
|
+
for (const rule of enabledRules) {
|
|
29
|
+
let results;
|
|
30
|
+
try {
|
|
31
|
+
results = rule.evaluate(ctx);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error(`[alt-text-scan] rule "${rule.id}" threw on ${url}:`, err);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
await emitFindings(rule, results, url, addFinding);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,IAAI,EAAC,MAAM,WAAW,CAAA;AAC9B,OAAO,EAAC,aAAa,EAAC,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAC,QAAQ,EAAC,MAAM,sBAAsB,CAAA;AAC7C,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAA;AAC9C,OAAO,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAA;AAG1C,MAAM,CAAC,MAAM,IAAI,GAAG,eAAe,CAAA;AAEnC,uFAAuF;AACvF,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,iBAAiB,EAAE,IAAI,EAAE,aAAa,CAAC,CAAA;AACzF,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;AACrD,MAAM,aAAa,GAAG,UAAU,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;AAE1D,MAAM,CAAC,OAAO,CAAC,KAAK,UAAU,WAAW,CAAC,EAAC,IAAI,EAAE,UAAU,EAAa;IACtE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IAEtB,MAAM,EAAC,aAAa,EAAC,GAAG,MAAM,aAAa,CAAA;IAC3C,MAAM,YAAY,GAAG,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,cAAc,IAAI,IAAI,CAAC,CAAA;IAEvG,gCAAgC;IAChC,IAAI,MAAM,CAAA;IACV,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAA;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,iDAAiD,GAAG,GAAG,EAAE,GAAG,CAAC,CAAA;QAC3E,OAAM;IACR,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,GAAG,GAAG,EAAC,GAAG,EAAE,MAAM,EAAC,CAAA;IAEzB,kDAAkD;IAClD,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;QAChC,IAAI,OAAO,CAAA;QACX,IAAI,CAAC;YACH,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;QAC9B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,yBAAyB,IAAI,CAAC,EAAE,cAAc,GAAG,GAAG,EAAE,GAAG,CAAC,CAAA;YACxE,SAAQ;QACV,CAAC;QACD,MAAM,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,CAAA;IACpD,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,GAAG;IACzB,aAAa,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC5C,CAAA;AAID,wBAAsB,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAqB7G"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
const emptyConfig = () => ({ ruleOverrides: new Map() });
|
|
3
|
+
export async function loadConfig(configPath, knownRuleIds) {
|
|
4
|
+
let raw;
|
|
5
|
+
try {
|
|
6
|
+
raw = await readFile(configPath, 'utf8');
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
10
|
+
return emptyConfig();
|
|
11
|
+
}
|
|
12
|
+
console.warn(`[alt-text-scan] failed to read ${configPath}; running with default rule settings:`, err);
|
|
13
|
+
return emptyConfig();
|
|
14
|
+
}
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error(`[alt-text-scan] failed to parse ${configPath}; running with default rule settings:`, err);
|
|
21
|
+
return emptyConfig();
|
|
22
|
+
}
|
|
23
|
+
return { ruleOverrides: collectRuleOverrides(parsed, knownRuleIds) };
|
|
24
|
+
}
|
|
25
|
+
function collectRuleOverrides(parsed, knownRuleIds) {
|
|
26
|
+
const result = new Map();
|
|
27
|
+
if (!parsed || typeof parsed !== 'object')
|
|
28
|
+
return result;
|
|
29
|
+
const rules = parsed.rules;
|
|
30
|
+
if (!rules || typeof rules !== 'object' || Array.isArray(rules))
|
|
31
|
+
return result;
|
|
32
|
+
for (const [id, value] of Object.entries(rules)) {
|
|
33
|
+
if (!knownRuleIds.has(id)) {
|
|
34
|
+
console.warn(`[alt-text-scan] unknown rule id "${id}" in config; ignoring. Known ids: ${[...knownRuleIds].join(', ')}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== 'boolean') {
|
|
38
|
+
console.warn(`[alt-text-scan] non-boolean value for rule "${id}" in config (got ${typeof value}); ignoring.`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
result.set(id, value);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,QAAQ,EAAC,MAAM,kBAAkB,CAAA;AAMzC,MAAM,WAAW,GAAG,GAAiB,EAAE,CAAC,CAAC,EAAC,aAAa,EAAE,IAAI,GAAG,EAAE,EAAC,CAAC,CAAA;AAEpE,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,UAAkB,EAAE,YAAiC;IACpF,IAAI,GAAW,CAAA;IACf,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;IAC1C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,YAAY,KAAK,IAAI,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACnE,OAAO,WAAW,EAAE,CAAA;QACtB,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,kCAAkC,UAAU,uCAAuC,EAAE,GAAG,CAAC,CAAA;QACtG,OAAO,WAAW,EAAE,CAAA;IACtB,CAAC;IAED,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAC1B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,mCAAmC,UAAU,uCAAuC,EAAE,GAAG,CAAC,CAAA;QACxG,OAAO,WAAW,EAAE,CAAA;IACtB,CAAC;IAED,OAAO,EAAC,aAAa,EAAE,oBAAoB,CAAC,MAAM,EAAE,YAAY,CAAC,EAAC,CAAA;AACpE,CAAC;AAED,SAAS,oBAAoB,CAAC,MAAe,EAAE,YAAiC;IAC9E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmB,CAAA;IACzC,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAA;IAExD,MAAM,KAAK,GAAI,MAA4B,CAAC,KAAK,CAAA;IACjD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,MAAM,CAAA;IAE9E,KAAK,MAAM,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;QAC3E,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YAC1B,OAAO,CAAC,IAAI,CACV,oCAAoC,EAAE,qCAAqC,CAAC,GAAG,YAAY,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAC1G,CAAA;YACD,SAAQ;QACV,CAAC;QACD,IAAI,OAAO,KAAK,KAAK,SAAS,EAAE,CAAC;YAC/B,OAAO,CAAC,IAAI,CAAC,+CAA+C,EAAE,oBAAoB,OAAO,KAAK,cAAc,CAAC,CAAA;YAC7G,SAAQ;QACV,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,CAAA;IACvB,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../src/extract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,YAAY,CAAA;AACpC,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,YAAY,CAAA;AAI3C,wBAAsB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAqBtE"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Returns one ImageRecord per HTML <img> element that is exposed in the accessibility tree.
|
|
2
|
+
// Using getByRole('img') filters out elements that assistive tech cannot perceive.
|
|
3
|
+
export async function extractImages(page) {
|
|
4
|
+
return page.getByRole('img').evaluateAll(els => els
|
|
5
|
+
// getByRole('img') also matches SVG/div with role="img", so filter those out.
|
|
6
|
+
.filter(el => el.tagName === 'IMG')
|
|
7
|
+
.map(el => {
|
|
8
|
+
const rect = el.getBoundingClientRect();
|
|
9
|
+
const boundingBox = rect.width === 0 && rect.height === 0 ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
10
|
+
return {
|
|
11
|
+
src: el.getAttribute('src'),
|
|
12
|
+
alt: el.getAttribute('alt'),
|
|
13
|
+
role: el.getAttribute('role'),
|
|
14
|
+
ariaHidden: el.getAttribute('aria-hidden') === 'true',
|
|
15
|
+
ariaLabel: el.getAttribute('aria-label'),
|
|
16
|
+
ariaLabelledBy: el.getAttribute('aria-labelledby'),
|
|
17
|
+
outerHTML: el.outerHTML,
|
|
18
|
+
boundingBox,
|
|
19
|
+
};
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=extract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract.js","sourceRoot":"","sources":["../../src/extract.ts"],"names":[],"mappings":"AAGA,4FAA4F;AAC5F,mFAAmF;AACnF,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAU;IAC5C,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,EAAE,CAC7C,GAAG;QACD,8EAA8E;SAC7E,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,KAAK,KAAK,CAAC;SAClC,GAAG,CAAC,EAAE,CAAC,EAAE;QACR,MAAM,IAAI,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAA;QACvC,MAAM,WAAW,GACf,IAAI,CAAC,KAAK,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAC,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAC,CAAA;QAC/G,OAAO;YACL,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC;YAC3B,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC;YAC3B,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC;YAC7B,UAAU,EAAE,EAAE,CAAC,YAAY,CAAC,aAAa,CAAC,KAAK,MAAM;YACrD,SAAS,EAAE,EAAE,CAAC,YAAY,CAAC,YAAY,CAAC;YACxC,cAAc,EAAE,EAAE,CAAC,YAAY,CAAC,iBAAiB,CAAC;YAClD,SAAS,EAAE,EAAE,CAAC,SAAS;YACvB,WAAW;SACZ,CAAA;IACH,CAAC,CAAC,CACL,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"findings.d.ts","sourceRoot":"","sources":["../../src/findings.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,OAAO,EAAE,IAAI,EAAE,UAAU,EAAC,MAAM,YAAY,CAAA;AAGzD,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,UAAU,EAAE,EACrB,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAC9C,OAAO,CAAC,IAAI,CAAC,CAaf"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Translates each RuleResult into the scanner's Finding shape and records it.
|
|
2
|
+
export async function emitFindings(rule, results, url, addFinding) {
|
|
3
|
+
for (const result of results) {
|
|
4
|
+
await addFinding({
|
|
5
|
+
scannerType: 'alt-text-scan',
|
|
6
|
+
ruleId: rule.id,
|
|
7
|
+
url,
|
|
8
|
+
html: result.image.outerHTML,
|
|
9
|
+
problemShort: result.problemShort,
|
|
10
|
+
problemUrl: rule.problemUrl,
|
|
11
|
+
solutionShort: result.solutionShort,
|
|
12
|
+
solutionLong: result.solutionLong,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=findings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"findings.js","sourceRoot":"","sources":["../../src/findings.ts"],"names":[],"mappings":"AAEA,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAU,EACV,OAAqB,EACrB,GAAW,EACX,UAA+C;IAE/C,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,UAAU,CAAC;YACf,WAAW,EAAE,eAAe;YAC5B,MAAM,EAAE,IAAI,CAAC,EAAE;YACf,GAAG;YACH,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,SAAS;YAC5B,YAAY,EAAE,MAAM,CAAC,YAAY;YACjC,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,aAAa,EAAE,MAAM,CAAC,aAAa;YACnC,YAAY,EAAE,MAAM,CAAC,YAAY;SAClC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filename-alt-text.d.ts","sourceRoot":"","sources":["../../../src/rules/filename-alt-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAA0B,MAAM,aAAa,CAAA;AAI9D,eAAO,MAAM,eAAe,EAAE,IAmB7B,CAAA"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const FILENAME_PATTERN = /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i;
|
|
2
|
+
export const filenameAltText = {
|
|
3
|
+
id: 'filename-alt-text',
|
|
4
|
+
problemUrl: 'https://www.w3.org/WAI/tutorials/images/decision-tree/',
|
|
5
|
+
evaluate(context) {
|
|
6
|
+
const results = [];
|
|
7
|
+
for (const image of context.images) {
|
|
8
|
+
if (image.alt === null || image.alt === '')
|
|
9
|
+
continue;
|
|
10
|
+
if (!FILENAME_PATTERN.test(image.alt.trim()))
|
|
11
|
+
continue;
|
|
12
|
+
results.push({
|
|
13
|
+
image,
|
|
14
|
+
problemShort: `Image alt text appears to be a filename: "${image.alt}"`,
|
|
15
|
+
solutionShort: 'Replace the filename with a meaningful description of the image content.',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=filename-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filename-alt-text.js","sourceRoot":"","sources":["../../../src/rules/filename-alt-text.ts"],"names":[],"mappings":"AAEA,MAAM,gBAAgB,GAAG,yCAAyC,CAAA;AAElE,MAAM,CAAC,MAAM,eAAe,GAAS;IACnC,EAAE,EAAE,mBAAmB;IACvB,UAAU,EAAE,wDAAwD;IACpE,QAAQ,CAAC,OAAoB;QAC3B,MAAM,OAAO,GAAiB,EAAE,CAAA;QAEhC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnC,IAAI,KAAK,CAAC,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,GAAG,KAAK,EAAE;gBAAE,SAAQ;YACpD,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;gBAAE,SAAQ;YAEtD,OAAO,CAAC,IAAI,CAAC;gBACX,KAAK;gBACL,YAAY,EAAE,6CAA6C,KAAK,CAAC,GAAG,GAAG;gBACvE,aAAa,EAAE,0EAA0E;aAC1F,CAAC,CAAA;QACJ,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/rules/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,aAAa,CAAA;AAQrC,eAAO,MAAM,QAAQ,EAAE,IAAI,EAAyF,CAAA"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { filenameAltText } from './filename-alt-text.js';
|
|
2
|
+
import { vagueAltText } from './vague-alt-text.js';
|
|
3
|
+
import { missingAltText } from './missing-alt-text.js';
|
|
4
|
+
import { placeholderAltText } from './placeholder-alt-text.js';
|
|
5
|
+
import { repeatedAltText } from './repeated-alt-text.js';
|
|
6
|
+
// Append-only registry. Add a rule by importing it here and pushing it onto the array.
|
|
7
|
+
export const allRules = [filenameAltText, vagueAltText, missingAltText, placeholderAltText, repeatedAltText];
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/rules/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,eAAe,EAAC,MAAM,wBAAwB,CAAA;AACtD,OAAO,EAAC,YAAY,EAAC,MAAM,qBAAqB,CAAA;AAChD,OAAO,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAA;AACpD,OAAO,EAAC,kBAAkB,EAAC,MAAM,2BAA2B,CAAA;AAC5D,OAAO,EAAC,eAAe,EAAC,MAAM,wBAAwB,CAAA;AAEtD,uFAAuF;AACvF,MAAM,CAAC,MAAM,QAAQ,GAAW,CAAC,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,eAAe,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"missing-alt-text.d.ts","sourceRoot":"","sources":["../../../src/rules/missing-alt-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAA0B,MAAM,aAAa,CAAA;AAE9D,eAAO,MAAM,cAAc,EAAE,IAqB5B,CAAA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const missingAltText = {
|
|
2
|
+
id: 'missing-alt-text',
|
|
3
|
+
problemUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
|
4
|
+
evaluate(context) {
|
|
5
|
+
const results = [];
|
|
6
|
+
for (const image of context.images) {
|
|
7
|
+
// alt === '' is intentional to mark an image as decorative
|
|
8
|
+
if (image.alt === '')
|
|
9
|
+
continue;
|
|
10
|
+
// alt text has real content, so skip
|
|
11
|
+
if (image.alt !== null && image.alt.trim() !== '')
|
|
12
|
+
continue;
|
|
13
|
+
results.push({
|
|
14
|
+
image,
|
|
15
|
+
problemShort: 'Image has no usable alt text (attribute is missing or whitespace-only).',
|
|
16
|
+
solutionShort: 'Add an alt attribute describing the image, or alt="" if it is purely decorative.',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return results;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=missing-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"missing-alt-text.js","sourceRoot":"","sources":["../../../src/rules/missing-alt-text.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,cAAc,GAAS;IAClC,EAAE,EAAE,kBAAkB;IACtB,UAAU,EAAE,mEAAmE;IAC/E,QAAQ,CAAC,OAAoB;QAC3B,MAAM,OAAO,GAAiB,EAAE,CAAA;QAEhC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnC,2DAA2D;YAC3D,IAAI,KAAK,CAAC,GAAG,KAAK,EAAE;gBAAE,SAAQ;YAC9B,qCAAqC;YACrC,IAAI,KAAK,CAAC,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;gBAAE,SAAQ;YAE3D,OAAO,CAAC,IAAI,CAAC;gBACX,KAAK;gBACL,YAAY,EAAE,yEAAyE;gBACvF,aAAa,EAAE,kFAAkF;aAClG,CAAC,CAAA;QACJ,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"placeholder-alt-text.d.ts","sourceRoot":"","sources":["../../../src/rules/placeholder-alt-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAA0B,MAAM,aAAa,CAAA;AAc9D,eAAO,MAAM,kBAAkB,EAAE,IAmBhC,CAAA"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { normalizeAltText } from '../utils/normalize-alt-text.js';
|
|
2
|
+
// Known placeholder/boilerplate strings that signal the alt text was never written.
|
|
3
|
+
const PLACEHOLDER_ALT_TEXT = new Set([
|
|
4
|
+
'todo',
|
|
5
|
+
'tbd',
|
|
6
|
+
'placeholder',
|
|
7
|
+
'alt text',
|
|
8
|
+
'insert alt text',
|
|
9
|
+
'image alt',
|
|
10
|
+
'fixme',
|
|
11
|
+
]);
|
|
12
|
+
export const placeholderAltText = {
|
|
13
|
+
id: 'placeholder-alt-text',
|
|
14
|
+
problemUrl: 'https://www.w3.org/WAI/tutorials/images/decision-tree/',
|
|
15
|
+
evaluate(context) {
|
|
16
|
+
const results = [];
|
|
17
|
+
for (const image of context.images) {
|
|
18
|
+
if (image.alt === null || image.alt === '')
|
|
19
|
+
continue;
|
|
20
|
+
if (!PLACEHOLDER_ALT_TEXT.has(normalizeAltText(image.alt)))
|
|
21
|
+
continue;
|
|
22
|
+
results.push({
|
|
23
|
+
image,
|
|
24
|
+
problemShort: `Image alt text is placeholder text: "${image.alt}"`,
|
|
25
|
+
solutionShort: 'Replace the placeholder with a meaningful description of the image content.',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=placeholder-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"placeholder-alt-text.js","sourceRoot":"","sources":["../../../src/rules/placeholder-alt-text.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,gBAAgB,EAAC,MAAM,gCAAgC,CAAA;AAE/D,oFAAoF;AACpF,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACnC,MAAM;IACN,KAAK;IACL,aAAa;IACb,UAAU;IACV,iBAAiB;IACjB,WAAW;IACX,OAAO;CACR,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,kBAAkB,GAAS;IACtC,EAAE,EAAE,sBAAsB;IAC1B,UAAU,EAAE,wDAAwD;IACpE,QAAQ,CAAC,OAAoB;QAC3B,MAAM,OAAO,GAAiB,EAAE,CAAA;QAEhC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnC,IAAI,KAAK,CAAC,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,GAAG,KAAK,EAAE;gBAAE,SAAQ;YACpD,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,gBAAgB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;gBAAE,SAAQ;YAEpE,OAAO,CAAC,IAAI,CAAC;gBACX,KAAK;gBACL,YAAY,EAAE,wCAAwC,KAAK,CAAC,GAAG,GAAG;gBAClE,aAAa,EAAE,6EAA6E;aAC7F,CAAC,CAAA;QACJ,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repeated-alt-text.d.ts","sourceRoot":"","sources":["../../../src/rules/repeated-alt-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,IAAI,EAAa,MAAM,aAAa,CAAA;AA+B9D,eAAO,MAAM,eAAe,EAAE,IA4C7B,CAAA"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { normalizeAltText } from '../utils/normalize-alt-text.js';
|
|
2
|
+
// Minimum number of consecutive images sharing the same alt before the run is flagged.
|
|
3
|
+
const MIN_RUN_LENGTH = 2;
|
|
4
|
+
// Two adjacent images extend a "run" only if the gap between their bounding boxes is at
|
|
5
|
+
// most this multiple of the larger box's longest dimension.
|
|
6
|
+
const GAP_MULTIPLIER = 3;
|
|
7
|
+
// Returns true when two images are far enough apart on screen that a user
|
|
8
|
+
// would not perceive them as part of the same repeating group. Fails open
|
|
9
|
+
// when either image lacks a measurable layout box: with no spatial data we
|
|
10
|
+
// fall back to the pre-spatial behavior rather than silently dropping a run.
|
|
11
|
+
function tooFarApart(a, b) {
|
|
12
|
+
if (!a.boundingBox || !b.boundingBox)
|
|
13
|
+
return false;
|
|
14
|
+
const aRight = a.boundingBox.x + a.boundingBox.width;
|
|
15
|
+
const aBottom = a.boundingBox.y + a.boundingBox.height;
|
|
16
|
+
const bRight = b.boundingBox.x + b.boundingBox.width;
|
|
17
|
+
const bBottom = b.boundingBox.y + b.boundingBox.height;
|
|
18
|
+
const horizontalGap = Math.max(0, Math.max(a.boundingBox.x, b.boundingBox.x) - Math.min(aRight, bRight));
|
|
19
|
+
const verticalGap = Math.max(0, Math.max(a.boundingBox.y, b.boundingBox.y) - Math.min(aBottom, bBottom));
|
|
20
|
+
const gap = Math.max(horizontalGap, verticalGap);
|
|
21
|
+
const largerDim = Math.max(a.boundingBox.width, a.boundingBox.height, b.boundingBox.width, b.boundingBox.height);
|
|
22
|
+
return gap > GAP_MULTIPLIER * largerDim;
|
|
23
|
+
}
|
|
24
|
+
export const repeatedAltText = {
|
|
25
|
+
id: 'repeated-alt-text',
|
|
26
|
+
problemUrl: 'https://www.w3.org/WAI/tutorials/images/groups/',
|
|
27
|
+
evaluate(context) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
const { images } = context;
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < images.length) {
|
|
32
|
+
// Normalize once and don't consider empty alt texts in run length
|
|
33
|
+
const start = images[i].alt;
|
|
34
|
+
const currAlt = start === null ? '' : normalizeAltText(start);
|
|
35
|
+
if (currAlt === '') {
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Detect length of consecutive alt texts
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
while (j < images.length) {
|
|
42
|
+
const next = images[j].alt;
|
|
43
|
+
const nextAlt = next === null ? '' : normalizeAltText(next);
|
|
44
|
+
if (nextAlt !== currAlt)
|
|
45
|
+
break;
|
|
46
|
+
if (tooFarApart(images[j - 1], images[j]))
|
|
47
|
+
break;
|
|
48
|
+
j++;
|
|
49
|
+
}
|
|
50
|
+
const runLength = j - i;
|
|
51
|
+
if (runLength >= MIN_RUN_LENGTH) {
|
|
52
|
+
// Flag every image in the run except the first
|
|
53
|
+
for (let k = i + 1; k < j; k++) {
|
|
54
|
+
const img = images[k];
|
|
55
|
+
findings.push({
|
|
56
|
+
image: img,
|
|
57
|
+
problemShort: `Alt text is repeated across ${runLength} consecutive images:\n"${img.alt}"`,
|
|
58
|
+
solutionShort: 'If these images form one visual group, describe the group once and mark repeated/decorative images with `alt=""`. Otherwise, give each image unique alt text.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
i = j;
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=repeated-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repeated-alt-text.js","sourceRoot":"","sources":["../../../src/rules/repeated-alt-text.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vague-alt-text.d.ts","sourceRoot":"","sources":["../../../src/rules/vague-alt-text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAAa,MAAM,aAAa,CAAA;AA0HjD,eAAO,MAAM,YAAY,EAAE,IAsB1B,CAAA"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { normalizeAltText } from '../utils/normalize-alt-text.js';
|
|
2
|
+
// Set of words that by themselves, are too vague to be useful alt text
|
|
3
|
+
const VAGUE_WORDS = new Set([
|
|
4
|
+
// Generic media terms
|
|
5
|
+
'image',
|
|
6
|
+
'images',
|
|
7
|
+
'img',
|
|
8
|
+
'photo',
|
|
9
|
+
'photos',
|
|
10
|
+
'photograph',
|
|
11
|
+
'photographs',
|
|
12
|
+
'picture',
|
|
13
|
+
'pictures',
|
|
14
|
+
'pic',
|
|
15
|
+
'pics',
|
|
16
|
+
'graphic',
|
|
17
|
+
'graphics',
|
|
18
|
+
'visual',
|
|
19
|
+
'visuals',
|
|
20
|
+
'media',
|
|
21
|
+
'clipart',
|
|
22
|
+
'gif',
|
|
23
|
+
'gifs',
|
|
24
|
+
// Common UI/image labels
|
|
25
|
+
'icon',
|
|
26
|
+
'icons',
|
|
27
|
+
'illustration',
|
|
28
|
+
'illustrations',
|
|
29
|
+
'banner',
|
|
30
|
+
'banners',
|
|
31
|
+
'thumbnail',
|
|
32
|
+
'thumbnails',
|
|
33
|
+
'logo',
|
|
34
|
+
'logos',
|
|
35
|
+
'avatar',
|
|
36
|
+
'avatars',
|
|
37
|
+
// Very generic descriptors
|
|
38
|
+
'art',
|
|
39
|
+
'artwork',
|
|
40
|
+
'drawing',
|
|
41
|
+
'drawings',
|
|
42
|
+
'diagram',
|
|
43
|
+
'diagrams',
|
|
44
|
+
'chart',
|
|
45
|
+
'charts',
|
|
46
|
+
'graph',
|
|
47
|
+
'graphs',
|
|
48
|
+
'screenshot',
|
|
49
|
+
'screenshots',
|
|
50
|
+
'figure',
|
|
51
|
+
'figures',
|
|
52
|
+
'painting',
|
|
53
|
+
'paintings',
|
|
54
|
+
'map',
|
|
55
|
+
'maps',
|
|
56
|
+
// Placeholders
|
|
57
|
+
// Note: todo, tbd, fixme, and placeholder live in placeholder-alt-text.ts.
|
|
58
|
+
'sample',
|
|
59
|
+
'example',
|
|
60
|
+
'test',
|
|
61
|
+
'demo',
|
|
62
|
+
'default',
|
|
63
|
+
'untitled',
|
|
64
|
+
'null',
|
|
65
|
+
'undefined',
|
|
66
|
+
'none',
|
|
67
|
+
// File format / extension names
|
|
68
|
+
'jpg',
|
|
69
|
+
'jpeg',
|
|
70
|
+
'png',
|
|
71
|
+
'svg',
|
|
72
|
+
'webp',
|
|
73
|
+
// Contextless terms
|
|
74
|
+
'this',
|
|
75
|
+
'that',
|
|
76
|
+
'here',
|
|
77
|
+
'there',
|
|
78
|
+
'above',
|
|
79
|
+
'below',
|
|
80
|
+
// Weak file/asset labels
|
|
81
|
+
'asset',
|
|
82
|
+
'assets',
|
|
83
|
+
'file',
|
|
84
|
+
'attachment',
|
|
85
|
+
'upload',
|
|
86
|
+
'uploaded',
|
|
87
|
+
]);
|
|
88
|
+
// Set of multi-word phrases that by themselves, are too vague to be useful alt text
|
|
89
|
+
const VAGUE_PHRASES = new Set([
|
|
90
|
+
'an image',
|
|
91
|
+
'an image of',
|
|
92
|
+
'a photo',
|
|
93
|
+
'a photo of',
|
|
94
|
+
'a picture',
|
|
95
|
+
'a picture of',
|
|
96
|
+
'an icon',
|
|
97
|
+
'an illustration',
|
|
98
|
+
'a graphic',
|
|
99
|
+
'a screenshot',
|
|
100
|
+
'image of',
|
|
101
|
+
'photo of',
|
|
102
|
+
'picture of',
|
|
103
|
+
'graphic of',
|
|
104
|
+
'screenshot of',
|
|
105
|
+
'image goes here',
|
|
106
|
+
'photo goes here',
|
|
107
|
+
'picture goes here',
|
|
108
|
+
'your image here',
|
|
109
|
+
'your photo here',
|
|
110
|
+
'insert image',
|
|
111
|
+
'insert photo',
|
|
112
|
+
]);
|
|
113
|
+
export const vagueAltText = {
|
|
114
|
+
id: 'vague-alt-text',
|
|
115
|
+
problemUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
|
116
|
+
evaluate(context) {
|
|
117
|
+
return (context.images
|
|
118
|
+
// Find images whose alt text is too vague.
|
|
119
|
+
.filter(img => {
|
|
120
|
+
if (img.alt === null || img.alt === '')
|
|
121
|
+
return false;
|
|
122
|
+
const normalizedAltText = normalizeAltText(img.alt);
|
|
123
|
+
return VAGUE_WORDS.has(normalizedAltText) || VAGUE_PHRASES.has(normalizedAltText);
|
|
124
|
+
})
|
|
125
|
+
// Report each one with offending alt text.
|
|
126
|
+
.map(image => ({
|
|
127
|
+
image,
|
|
128
|
+
problemShort: `Alt text is too vague to describe the image:\n"${image.alt}"`,
|
|
129
|
+
solutionShort: 'replace with descriptive alt text, or use `alt=""` if the image is decorative',
|
|
130
|
+
})));
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=vague-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vague-alt-text.js","sourceRoot":"","sources":["../../../src/rules/vague-alt-text.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,gBAAgB,EAAC,MAAM,gCAAgC,CAAA;AAE/D,uEAAuE;AACvE,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC;IAC1B,sBAAsB;IACtB,OAAO;IACP,QAAQ;IACR,KAAK;IACL,OAAO;IACP,QAAQ;IACR,YAAY;IACZ,aAAa;IACb,SAAS;IACT,UAAU;IACV,KAAK;IACL,MAAM;IACN,SAAS;IACT,UAAU;IACV,QAAQ;IACR,SAAS;IACT,OAAO;IACP,SAAS;IACT,KAAK;IACL,MAAM;IAEN,yBAAyB;IACzB,MAAM;IACN,OAAO;IACP,cAAc;IACd,eAAe;IACf,QAAQ;IACR,SAAS;IACT,WAAW;IACX,YAAY;IACZ,MAAM;IACN,OAAO;IACP,QAAQ;IACR,SAAS;IAET,2BAA2B;IAC3B,KAAK;IACL,SAAS;IACT,SAAS;IACT,UAAU;IACV,SAAS;IACT,UAAU;IACV,OAAO;IACP,QAAQ;IACR,OAAO;IACP,QAAQ;IACR,YAAY;IACZ,aAAa;IACb,QAAQ;IACR,SAAS;IACT,UAAU;IACV,WAAW;IACX,KAAK;IACL,MAAM;IAEN,eAAe;IACf,2EAA2E;IAC3E,QAAQ;IACR,SAAS;IACT,MAAM;IACN,MAAM;IACN,SAAS;IACT,UAAU;IACV,MAAM;IACN,WAAW;IACX,MAAM;IAEN,gCAAgC;IAChC,KAAK;IACL,MAAM;IACN,KAAK;IACL,KAAK;IACL,MAAM;IAEN,oBAAoB;IACpB,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,OAAO;IACP,OAAO;IAEP,yBAAyB;IACzB,OAAO;IACP,QAAQ;IACR,MAAM;IACN,YAAY;IACZ,QAAQ;IACR,UAAU;CACX,CAAC,CAAA;AAEF,oFAAoF;AACpF,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC;IAC5B,UAAU;IACV,aAAa;IACb,SAAS;IACT,YAAY;IACZ,WAAW;IACX,cAAc;IACd,SAAS;IACT,iBAAiB;IACjB,WAAW;IACX,cAAc;IACd,UAAU;IACV,UAAU;IACV,YAAY;IACZ,YAAY;IACZ,eAAe;IACf,iBAAiB;IACjB,iBAAiB;IACjB,mBAAmB;IACnB,iBAAiB;IACjB,iBAAiB;IACjB,cAAc;IACd,cAAc;CACf,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,YAAY,GAAS;IAChC,EAAE,EAAE,gBAAgB;IACpB,UAAU,EAAE,mEAAmE;IAC/E,QAAQ,CAAC,OAAO;QACd,OAAO,CACL,OAAO,CAAC,MAAM;YAEZ,2CAA2C;aAC1C,MAAM,CAAC,GAAG,CAAC,EAAE;YACZ,IAAI,GAAG,CAAC,GAAG,KAAK,IAAI,IAAI,GAAG,CAAC,GAAG,KAAK,EAAE;gBAAE,OAAO,KAAK,CAAA;YACpD,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACnD,OAAO,WAAW,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,aAAa,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAA;QACnF,CAAC,CAAC;YAEF,2CAA2C;aAC1C,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YACb,KAAK;YACL,YAAY,EAAE,kDAAkD,KAAK,CAAC,GAAG,GAAG;YAC5E,aAAa,EAAE,+EAA+E;SAC/F,CAAC,CAAC,CACN,CAAA;IACH,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
export type Finding = {
|
|
3
|
+
scannerType: string;
|
|
4
|
+
ruleId?: string;
|
|
5
|
+
url: string;
|
|
6
|
+
html?: string;
|
|
7
|
+
problemShort: string;
|
|
8
|
+
problemUrl: string;
|
|
9
|
+
solutionShort: string;
|
|
10
|
+
solutionLong?: string;
|
|
11
|
+
screenshotId?: string;
|
|
12
|
+
};
|
|
13
|
+
export type PluginArgs = {
|
|
14
|
+
page: Page;
|
|
15
|
+
addFinding: (finding: Finding) => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
export type ImageRecord = {
|
|
18
|
+
src: string | null;
|
|
19
|
+
alt: string | null;
|
|
20
|
+
role: string | null;
|
|
21
|
+
ariaHidden: boolean;
|
|
22
|
+
ariaLabel: string | null;
|
|
23
|
+
ariaLabelledBy: string | null;
|
|
24
|
+
outerHTML: string;
|
|
25
|
+
boundingBox: BoundingBox | null;
|
|
26
|
+
};
|
|
27
|
+
export type BoundingBox = {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
export type RuleContext = {
|
|
34
|
+
url: string;
|
|
35
|
+
images: ImageRecord[];
|
|
36
|
+
};
|
|
37
|
+
export type RuleResult = {
|
|
38
|
+
image: ImageRecord;
|
|
39
|
+
problemShort: string;
|
|
40
|
+
solutionShort: string;
|
|
41
|
+
solutionLong?: string;
|
|
42
|
+
};
|
|
43
|
+
export type Rule = {
|
|
44
|
+
id: string;
|
|
45
|
+
problemUrl: string;
|
|
46
|
+
defaultEnabled?: boolean;
|
|
47
|
+
evaluate(ctx: RuleContext): RuleResult[];
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,YAAY,CAAA;AAIpC,MAAM,MAAM,OAAO,GAAG;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAGD,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,IAAI,CAAA;IACV,UAAU,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAChD,CAAA;AAGD,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,UAAU,EAAE,OAAO,CAAA;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,WAAW,GAAG,IAAI,CAAA;CAChC,CAAA;AAGD,MAAM,MAAM,WAAW,GAAG;IACxB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAGD,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB,CAAA;AAGD,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,WAAW,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAGD,MAAM,MAAM,IAAI,GAAG;IACjB,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,EAAE,MAAM,CAAA;IAGlB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,GAAG,EAAE,WAAW,GAAG,UAAU,EAAE,CAAA;CACzC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes alt text for comparison by trimming, lowercasing, collapsing internal
|
|
3
|
+
* whitespace, and stripping leading and trailing punctuation. Used by rules that
|
|
4
|
+
* need to compare alt text against a set.
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeAltText(alt: string): string;
|
|
7
|
+
//# sourceMappingURL=normalize-alt-text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize-alt-text.d.ts","sourceRoot":"","sources":["../../../src/utils/normalize-alt-text.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQpD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes alt text for comparison by trimming, lowercasing, collapsing internal
|
|
3
|
+
* whitespace, and stripping leading and trailing punctuation. Used by rules that
|
|
4
|
+
* need to compare alt text against a set.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeAltText(alt) {
|
|
7
|
+
return alt
|
|
8
|
+
.trim()
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/\s+/g, ' ')
|
|
11
|
+
.replace(/^[.,!?;:()[\]{}'"“”‘’]+/, '')
|
|
12
|
+
.replace(/[.,!?;:()[\]{}'"“”‘’]+$/, '')
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=normalize-alt-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize-alt-text.js","sourceRoot":"","sources":["../../../src/utils/normalize-alt-text.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,OAAO,GAAG;SACP,IAAI,EAAE;SACN,WAAW,EAAE;SACb,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;SACpB,OAAO,CAAC,yBAAyB,EAAE,EAAE,CAAC;SACtC,OAAO,CAAC,yBAAyB,EAAE,EAAE,CAAC;SACtC,IAAI,EAAE,CAAA;AACX,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@github/accessibility-scanner-alt-text-plugin",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"keywords": [],
|
|
6
|
-
"author": "",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Alt-text validation plugin for github/accessibility-scanner",
|
|
7
5
|
"license": "MIT",
|
|
8
6
|
"repository": {
|
|
9
7
|
"type": "git",
|
|
10
8
|
"url": "git+https://github.com/github/accessibility-scanner-alt-text-plugin.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
|
+
"test": "vitest run --passWithNoTests",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"lint": "eslint .",
|
|
32
|
+
"format": "prettier --write .",
|
|
33
|
+
"format:check": "prettier --check ."
|
|
34
|
+
},
|
|
35
|
+
"prettier": "@github/prettier-config",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": "^22.13.0 || ^24 || ^26"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@github/prettier-config": "^0.0.6",
|
|
41
|
+
"@types/node": "^26.1.0",
|
|
42
|
+
"eslint": "^10.6.0",
|
|
43
|
+
"eslint-config-prettier": "^10.1.8",
|
|
44
|
+
"eslint-plugin-check-file": "^3.3.1",
|
|
45
|
+
"playwright": "^1.61.1",
|
|
46
|
+
"prettier": "^3.9.4",
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"typescript-eslint": "^8.62.1",
|
|
49
|
+
"vitest": "^4.1.9"
|
|
11
50
|
}
|
|
12
51
|
}
|