wcag-scanner 1.2.65 → 1.2.67
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/README.md +107 -83
- package/dist/index.d.ts +5 -24
- package/dist/index.js +11 -126
- package/dist/middleware/express.js +42 -18
- package/dist/react/WcagDevOverlay.d.ts +2 -0
- package/dist/react/WcagDevOverlay.js +282 -154
- package/dist/react/browserScanner.d.ts +11 -1
- package/dist/react/browserScanner.js +107 -22
- package/dist/react/gemini.d.ts +12 -0
- package/dist/react/gemini.js +60 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +10 -1
- package/dist/rules/backgroundImages.d.ts +8 -0
- package/dist/rules/backgroundImages.js +116 -0
- package/dist/rules/contrast.js +52 -23
- package/dist/rules/images.js +0 -91
- package/dist/rules/presets.d.ts +8 -0
- package/dist/rules/presets.js +19 -0
- package/dist/scanner.js +14 -12
- package/dist/types/index.d.ts +3 -0
- package/package.json +16 -15
- package/dist/ai/suggestions.d.ts +0 -11
- package/dist/ai/suggestions.js +0 -103
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -160
- package/dist/wasm/index.d.ts +0 -19
- package/dist/wasm/index.js +0 -53
|
@@ -4,15 +4,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.scanBrowserPage = scanBrowserPage;
|
|
7
|
+
exports.getNthChildSelector = getNthChildSelector;
|
|
7
8
|
exports.getElementPath = getElementPath;
|
|
9
|
+
exports.findElement = findElement;
|
|
10
|
+
exports.findBySnippet = findBySnippet;
|
|
8
11
|
const images_1 = __importDefault(require("../rules/images"));
|
|
12
|
+
const backgroundImages_1 = __importDefault(require("../rules/backgroundImages"));
|
|
9
13
|
const contrast_1 = __importDefault(require("../rules/contrast"));
|
|
10
14
|
const forms_1 = __importDefault(require("../rules/forms"));
|
|
11
15
|
const aria_1 = __importDefault(require("../rules/aria"));
|
|
12
16
|
const structure_1 = __importDefault(require("../rules/structure"));
|
|
13
17
|
const keyboard_1 = __importDefault(require("../rules/keyboard"));
|
|
18
|
+
const presets_1 = require("../rules/presets");
|
|
14
19
|
const RULES = {
|
|
15
20
|
images: images_1.default,
|
|
21
|
+
backgroundImages: backgroundImages_1.default,
|
|
16
22
|
contrast: contrast_1.default,
|
|
17
23
|
forms: forms_1.default,
|
|
18
24
|
aria: aria_1.default,
|
|
@@ -20,40 +26,117 @@ const RULES = {
|
|
|
20
26
|
keyboard: keyboard_1.default,
|
|
21
27
|
};
|
|
22
28
|
async function scanBrowserPage(options = {}) {
|
|
29
|
+
var _a, _b;
|
|
23
30
|
const start = performance.now();
|
|
24
|
-
const ruleNames = options
|
|
31
|
+
const ruleNames = (0, presets_1.resolveRuleNames)(options, presets_1.FAST_RULES);
|
|
25
32
|
const violations = [];
|
|
26
33
|
const warnings = [];
|
|
27
34
|
const passes = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
warnings.push(...(res.warnings || []));
|
|
36
|
-
passes.push(...(res.passes || []));
|
|
35
|
+
const overlayRoot = document.querySelector('[data-wcag-overlay-root="true"]');
|
|
36
|
+
const overlayParent = (_a = overlayRoot === null || overlayRoot === void 0 ? void 0 : overlayRoot.parentNode) !== null && _a !== void 0 ? _a : null;
|
|
37
|
+
const overlayNextSibling = (_b = overlayRoot === null || overlayRoot === void 0 ? void 0 : overlayRoot.nextSibling) !== null && _b !== void 0 ? _b : null;
|
|
38
|
+
try {
|
|
39
|
+
// Detach the dev overlay while scanning so the inspector never scans itself.
|
|
40
|
+
if (overlayRoot && overlayParent) {
|
|
41
|
+
overlayParent.removeChild(overlayRoot);
|
|
37
42
|
}
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
for (const name of ruleNames) {
|
|
44
|
+
const rule = RULES[name];
|
|
45
|
+
if (!rule)
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const res = await rule.check(document, window, options);
|
|
49
|
+
violations.push(...(res.violations || []));
|
|
50
|
+
warnings.push(...(res.warnings || []));
|
|
51
|
+
passes.push(...(res.passes || []));
|
|
52
|
+
}
|
|
53
|
+
catch (_c) {
|
|
54
|
+
// rule failed in browser context — skip silently
|
|
55
|
+
}
|
|
40
56
|
}
|
|
41
57
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
finally {
|
|
59
|
+
if (overlayRoot && overlayParent) {
|
|
60
|
+
overlayParent.insertBefore(overlayRoot, overlayNextSibling);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Exclude elements that live inside the WCAG overlay itself
|
|
64
|
+
const overlaySurface = document.querySelector('[data-wcag-overlay="true"]');
|
|
65
|
+
const isInOverlay = (el) => el != null && ((overlaySurface != null && overlaySurface.contains(el)) ||
|
|
66
|
+
(overlayRoot != null && overlayRoot.contains(el)));
|
|
45
67
|
const annotate = (item) => {
|
|
46
68
|
const el = findElement(item, document);
|
|
47
|
-
|
|
69
|
+
if (!el)
|
|
70
|
+
return { ...item };
|
|
71
|
+
return {
|
|
72
|
+
...item,
|
|
73
|
+
domElement: el,
|
|
74
|
+
elementPath: getElementPath(el),
|
|
75
|
+
elementSelector: getNthChildSelector(el),
|
|
76
|
+
};
|
|
48
77
|
};
|
|
49
78
|
return {
|
|
50
|
-
violations: violations.map(annotate).filter(v => { var _a; return !isInOverlay((_a = v.domElement) !== null && _a !== void 0 ? _a : null); }),
|
|
51
|
-
warnings: warnings.map(annotate).filter(w => { var _a; return !isInOverlay((_a = w.domElement) !== null && _a !== void 0 ? _a : null); }),
|
|
52
|
-
passes,
|
|
79
|
+
violations: dedupeIssues(violations.map(annotate)).filter(v => { var _a; return !isInOverlay((_a = v.domElement) !== null && _a !== void 0 ? _a : null); }),
|
|
80
|
+
warnings: dedupeIssues(warnings.map(annotate)).filter(w => { var _a; return !isInOverlay((_a = w.domElement) !== null && _a !== void 0 ? _a : null); }),
|
|
81
|
+
passes: dedupePasses(passes),
|
|
53
82
|
duration: Math.round(performance.now() - start),
|
|
54
83
|
};
|
|
55
84
|
}
|
|
56
|
-
|
|
85
|
+
function dedupeIssues(items) {
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
return items.filter(item => {
|
|
88
|
+
var _a, _b, _c;
|
|
89
|
+
const key = [
|
|
90
|
+
item.rule,
|
|
91
|
+
item.description,
|
|
92
|
+
item.impact,
|
|
93
|
+
item.snippet,
|
|
94
|
+
(_a = item.element) === null || _a === void 0 ? void 0 : _a.tagName,
|
|
95
|
+
(_b = item.element) === null || _b === void 0 ? void 0 : _b.id,
|
|
96
|
+
(_c = item.element) === null || _c === void 0 ? void 0 : _c.className,
|
|
97
|
+
].join('::');
|
|
98
|
+
if (seen.has(key))
|
|
99
|
+
return false;
|
|
100
|
+
seen.add(key);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function dedupePasses(items) {
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
return items.filter(item => {
|
|
107
|
+
const key = [item.rule, item.description].join('::');
|
|
108
|
+
if (seen.has(key))
|
|
109
|
+
return false;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a precise nth-child CSS selector path for an element.
|
|
116
|
+
* This is unambiguous and always finds the exact element.
|
|
117
|
+
*/
|
|
118
|
+
function getNthChildSelector(el) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
let current = el;
|
|
121
|
+
while (current && current.nodeType === 1) {
|
|
122
|
+
const parent = current.parentElement;
|
|
123
|
+
if (!parent)
|
|
124
|
+
break;
|
|
125
|
+
// If element has a unique ID we can stop traversing early
|
|
126
|
+
const id = current.id;
|
|
127
|
+
if (id) {
|
|
128
|
+
parts.unshift(`#${CSS.escape(id)}`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const index = Array.from(parent.children).indexOf(current) + 1;
|
|
132
|
+
parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
|
|
133
|
+
current = parent;
|
|
134
|
+
if (parts.length >= 8)
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
return parts.join(' > ');
|
|
138
|
+
}
|
|
139
|
+
/** Build a human-readable breadcrumb label for an element. */
|
|
57
140
|
function getElementPath(el) {
|
|
58
141
|
const parts = [];
|
|
59
142
|
let current = el;
|
|
@@ -63,7 +146,6 @@ function getElementPath(el) {
|
|
|
63
146
|
part += `#${current.id}`;
|
|
64
147
|
}
|
|
65
148
|
else if (current.className && typeof current.className === 'string') {
|
|
66
|
-
// Skip auto-generated class names (hashed, uuid-like)
|
|
67
149
|
const classes = current.className
|
|
68
150
|
.trim()
|
|
69
151
|
.split(/\s+/)
|
|
@@ -80,16 +162,19 @@ function getElementPath(el) {
|
|
|
80
162
|
function findElement(item, doc) {
|
|
81
163
|
var _a, _b;
|
|
82
164
|
const { element: info, snippet } = item;
|
|
165
|
+
// 1. ID — most reliable
|
|
83
166
|
if (info === null || info === void 0 ? void 0 : info.id) {
|
|
84
167
|
const el = doc.getElementById(info.id);
|
|
85
168
|
if (el)
|
|
86
169
|
return el;
|
|
87
170
|
}
|
|
171
|
+
// 2. Snippet attribute matching
|
|
88
172
|
if (snippet) {
|
|
89
173
|
const el = findBySnippet(snippet, doc);
|
|
90
174
|
if (el)
|
|
91
175
|
return el;
|
|
92
176
|
}
|
|
177
|
+
// 3. tagName + className
|
|
93
178
|
if (info === null || info === void 0 ? void 0 : info.tagName) {
|
|
94
179
|
try {
|
|
95
180
|
const classes = (_b = (_a = info.className) === null || _a === void 0 ? void 0 : _a.trim().split(/\s+/).filter(Boolean)) !== null && _b !== void 0 ? _b : [];
|
|
@@ -99,7 +184,7 @@ function findElement(item, doc) {
|
|
|
99
184
|
return el;
|
|
100
185
|
}
|
|
101
186
|
catch (_c) {
|
|
102
|
-
// invalid selector
|
|
187
|
+
// invalid selector — skip
|
|
103
188
|
}
|
|
104
189
|
}
|
|
105
190
|
return null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface AiSuggestion {
|
|
2
|
+
code: string;
|
|
3
|
+
explanation: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function getAiSuggestion(apiKey: string, violation: {
|
|
6
|
+
rule?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
snippet?: string;
|
|
9
|
+
help?: string;
|
|
10
|
+
}): Promise<AiSuggestion>;
|
|
11
|
+
export declare function getStoredApiKey(): string;
|
|
12
|
+
export declare function setStoredApiKey(key: string): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getAiSuggestion = getAiSuggestion;
|
|
4
|
+
exports.getStoredApiKey = getStoredApiKey;
|
|
5
|
+
exports.setStoredApiKey = setStoredApiKey;
|
|
6
|
+
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
|
7
|
+
async function getAiSuggestion(apiKey, violation) {
|
|
8
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
9
|
+
const prompt = [
|
|
10
|
+
'You are a web accessibility expert. A WCAG violation was found:',
|
|
11
|
+
`Rule: ${(_a = violation.rule) !== null && _a !== void 0 ? _a : ''}`,
|
|
12
|
+
`Issue: ${(_b = violation.description) !== null && _b !== void 0 ? _b : ''}`,
|
|
13
|
+
violation.help ? `Hint: ${violation.help}` : '',
|
|
14
|
+
violation.snippet ? `HTML:\n${violation.snippet}` : '',
|
|
15
|
+
'',
|
|
16
|
+
'Reply with ONLY:',
|
|
17
|
+
'1. The corrected HTML inside a ```html block',
|
|
18
|
+
'2. One sentence explaining the fix',
|
|
19
|
+
]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join('\n');
|
|
22
|
+
const res = await fetch(`${GEMINI_URL}?key=${encodeURIComponent(apiKey)}`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
27
|
+
generationConfig: { maxOutputTokens: 512, temperature: 0.1 },
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const body = await res.text().catch(() => '');
|
|
32
|
+
throw new Error(`Gemini ${res.status}: ${body.slice(0, 120)}`);
|
|
33
|
+
}
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
const text = (_h = (_g = (_f = (_e = (_d = (_c = data.candidates) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.content) === null || _e === void 0 ? void 0 : _e.parts) === null || _f === void 0 ? void 0 : _f[0]) === null || _g === void 0 ? void 0 : _g.text) !== null && _h !== void 0 ? _h : '';
|
|
36
|
+
const codeMatch = text.match(/```(?:html)?\s*([\s\S]*?)```/);
|
|
37
|
+
const code = codeMatch ? codeMatch[1].trim() : '';
|
|
38
|
+
const explanation = (_j = text
|
|
39
|
+
.replace(/```(?:html)?\s*[\s\S]*?```/g, '')
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.slice(-1)[0]) !== null && _j !== void 0 ? _j : 'See corrected code above.';
|
|
44
|
+
return { code, explanation };
|
|
45
|
+
}
|
|
46
|
+
function getStoredApiKey() {
|
|
47
|
+
var _a;
|
|
48
|
+
try {
|
|
49
|
+
return (_a = localStorage.getItem('wcag-gemini-key')) !== null && _a !== void 0 ? _a : '';
|
|
50
|
+
}
|
|
51
|
+
catch (_b) {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function setStoredApiKey(key) {
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem('wcag-gemini-key', key.trim());
|
|
58
|
+
}
|
|
59
|
+
catch ( /* */_a) { /* */ }
|
|
60
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -3,3 +3,6 @@ export type { WcagDevOverlayProps } from './WcagDevOverlay';
|
|
|
3
3
|
export { scanBrowserPage } from './browserScanner';
|
|
4
4
|
export type { BrowserScanResults, AnnotatedViolation, AnnotatedWarning } from './browserScanner';
|
|
5
5
|
export { initWcagOverlay } from './init';
|
|
6
|
+
export { getAiSuggestion, getStoredApiKey, setStoredApiKey } from './gemini';
|
|
7
|
+
export type { AiSuggestion } from './gemini';
|
|
8
|
+
export { FAST_RULES, FULL_RULES, RULE_PRESETS, resolveRuleNames } from '../rules/presets';
|
package/dist/react/index.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.initWcagOverlay = exports.scanBrowserPage = exports.WcagDevOverlay = void 0;
|
|
3
|
+
exports.resolveRuleNames = exports.RULE_PRESETS = exports.FULL_RULES = exports.FAST_RULES = exports.setStoredApiKey = exports.getStoredApiKey = exports.getAiSuggestion = exports.initWcagOverlay = exports.scanBrowserPage = exports.WcagDevOverlay = void 0;
|
|
4
4
|
var WcagDevOverlay_1 = require("./WcagDevOverlay");
|
|
5
5
|
Object.defineProperty(exports, "WcagDevOverlay", { enumerable: true, get: function () { return WcagDevOverlay_1.WcagDevOverlay; } });
|
|
6
6
|
var browserScanner_1 = require("./browserScanner");
|
|
7
7
|
Object.defineProperty(exports, "scanBrowserPage", { enumerable: true, get: function () { return browserScanner_1.scanBrowserPage; } });
|
|
8
8
|
var init_1 = require("./init");
|
|
9
9
|
Object.defineProperty(exports, "initWcagOverlay", { enumerable: true, get: function () { return init_1.initWcagOverlay; } });
|
|
10
|
+
var gemini_1 = require("./gemini");
|
|
11
|
+
Object.defineProperty(exports, "getAiSuggestion", { enumerable: true, get: function () { return gemini_1.getAiSuggestion; } });
|
|
12
|
+
Object.defineProperty(exports, "getStoredApiKey", { enumerable: true, get: function () { return gemini_1.getStoredApiKey; } });
|
|
13
|
+
Object.defineProperty(exports, "setStoredApiKey", { enumerable: true, get: function () { return gemini_1.setStoredApiKey; } });
|
|
14
|
+
var presets_1 = require("../rules/presets");
|
|
15
|
+
Object.defineProperty(exports, "FAST_RULES", { enumerable: true, get: function () { return presets_1.FAST_RULES; } });
|
|
16
|
+
Object.defineProperty(exports, "FULL_RULES", { enumerable: true, get: function () { return presets_1.FULL_RULES; } });
|
|
17
|
+
Object.defineProperty(exports, "RULE_PRESETS", { enumerable: true, get: function () { return presets_1.RULE_PRESETS; } });
|
|
18
|
+
Object.defineProperty(exports, "resolveRuleNames", { enumerable: true, get: function () { return presets_1.resolveRuleNames; } });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ScannerOptions, ScanResults } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Check CSS background images for potentially meaningful content that lacks text alternatives.
|
|
4
|
+
*/
|
|
5
|
+
declare const _default: {
|
|
6
|
+
check(document: Document, window: Window, _options: ScannerOptions): Promise<ScanResults>;
|
|
7
|
+
};
|
|
8
|
+
export default _default;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Check CSS background images for potentially meaningful content that lacks text alternatives.
|
|
5
|
+
*/
|
|
6
|
+
exports.default = {
|
|
7
|
+
async check(document, window, _options) {
|
|
8
|
+
const results = {
|
|
9
|
+
passes: [],
|
|
10
|
+
violations: [],
|
|
11
|
+
warnings: []
|
|
12
|
+
};
|
|
13
|
+
checkBackgroundImages(document, window, results);
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function checkBackgroundImages(document, window, results) {
|
|
18
|
+
var _a, _b, _c, _d;
|
|
19
|
+
const root = (_a = document.body) !== null && _a !== void 0 ? _a : document.documentElement;
|
|
20
|
+
if (!root)
|
|
21
|
+
return;
|
|
22
|
+
const walker = document.createTreeWalker(root, 1);
|
|
23
|
+
let current = walker.currentNode;
|
|
24
|
+
while (current) {
|
|
25
|
+
const element = current;
|
|
26
|
+
if (!shouldInspectBackgroundImage(element)) {
|
|
27
|
+
current = walker.nextNode();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const style = window.getComputedStyle(element);
|
|
31
|
+
const backgroundImage = style.backgroundImage;
|
|
32
|
+
if (!backgroundImage || backgroundImage === 'none') {
|
|
33
|
+
current = walker.nextNode();
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (isElementHidden(element, window) || element.getAttribute('aria-hidden') === 'true') {
|
|
37
|
+
current = walker.nextNode();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const info = {
|
|
41
|
+
tagName: element.tagName.toLowerCase(),
|
|
42
|
+
id: element.id || null,
|
|
43
|
+
className: ((_b = element.className) === null || _b === void 0 ? void 0 : _b.toString()) || null,
|
|
44
|
+
textContent: ((_c = element.textContent) === null || _c === void 0 ? void 0 : _c.substring(0, 50)) || null
|
|
45
|
+
};
|
|
46
|
+
const hasTextContent = ((_d = element.textContent) === null || _d === void 0 ? void 0 : _d.trim()) !== '';
|
|
47
|
+
const hasAriaLabel = element.hasAttribute('aria-label');
|
|
48
|
+
const hasAriaLabelledby = element.hasAttribute('aria-labelledby');
|
|
49
|
+
const hasTitle = element.hasAttribute('title');
|
|
50
|
+
if (!hasTextContent && !hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
|
|
51
|
+
if (backgroundImage.includes('url(') && !isBackgroundLikelyDecorative(element)) {
|
|
52
|
+
results.warnings.push({
|
|
53
|
+
rule: 'background-image',
|
|
54
|
+
element: info,
|
|
55
|
+
impact: 'moderate',
|
|
56
|
+
description: 'Element with background image may need text alternative',
|
|
57
|
+
snippet: element.outerHTML.slice(0, 150) + (element.outerHTML.length > 150 ? '...' : ''),
|
|
58
|
+
wcag: ['1.1.1'],
|
|
59
|
+
help: 'If the background image conveys meaning, add text alternative via aria-label or text content'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
current = walker.nextNode();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function isElementHidden(element, window) {
|
|
67
|
+
if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const style = window.getComputedStyle(element);
|
|
72
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
73
|
+
}
|
|
74
|
+
catch (_a) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function shouldInspectBackgroundImage(element) {
|
|
79
|
+
var _a;
|
|
80
|
+
const tagName = element.tagName.toLowerCase();
|
|
81
|
+
if (['script', 'style', 'link', 'meta', 'head', 'title', 'base', 'noscript'].includes(tagName)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (element.hasAttribute('style')) {
|
|
88
|
+
const inlineStyle = (element.getAttribute('style') || '').toLowerCase();
|
|
89
|
+
if (inlineStyle.includes('background'))
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (element.hasAttribute('title') || element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (element.childElementCount === 0 && ((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim())) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return ['div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav', 'figure', 'a', 'button'].includes(tagName);
|
|
99
|
+
}
|
|
100
|
+
function isBackgroundLikelyDecorative(element) {
|
|
101
|
+
var _a, _b;
|
|
102
|
+
const decorativeElements = ['header', 'footer', 'section', 'article', 'div'];
|
|
103
|
+
const tagName = ((_a = element.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || '';
|
|
104
|
+
if (decorativeElements.includes(tagName)) {
|
|
105
|
+
const className = ((_b = element.className) === null || _b === void 0 ? void 0 : _b.toString()) || '';
|
|
106
|
+
if (className.includes('background') ||
|
|
107
|
+
className.includes('banner') ||
|
|
108
|
+
className.includes('hero') ||
|
|
109
|
+
className.includes('container') ||
|
|
110
|
+
className.includes('wrapper') ||
|
|
111
|
+
className.includes('section')) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
package/dist/rules/contrast.js
CHANGED
|
@@ -18,6 +18,8 @@ exports.default = {
|
|
|
18
18
|
violations: [],
|
|
19
19
|
warnings: []
|
|
20
20
|
};
|
|
21
|
+
const backgroundColorCache = new WeakMap();
|
|
22
|
+
const parsedColorCache = new Map();
|
|
21
23
|
// Minimum contrast requirements by WCAG level
|
|
22
24
|
const contrastRequirements = {
|
|
23
25
|
'A': {
|
|
@@ -39,13 +41,15 @@ exports.default = {
|
|
|
39
41
|
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, div, a, button, label, li, td, th');
|
|
40
42
|
for (const element of textElements) {
|
|
41
43
|
// Skip empty or hidden elements
|
|
42
|
-
if (!((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim())
|
|
44
|
+
if (!((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim())) {
|
|
43
45
|
continue;
|
|
44
46
|
}
|
|
45
|
-
// Get computed styles
|
|
46
47
|
const style = window.getComputedStyle(element);
|
|
48
|
+
if (isElementHidden(element, style)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
47
51
|
const textColor = style.color;
|
|
48
|
-
const bgColor = getBackgroundColor(element, window);
|
|
52
|
+
const bgColor = getBackgroundColor(element, window, backgroundColorCache);
|
|
49
53
|
// Skip if we couldn't determine colors
|
|
50
54
|
if (!textColor || !bgColor) {
|
|
51
55
|
continue;
|
|
@@ -53,11 +57,11 @@ exports.default = {
|
|
|
53
57
|
// Determine if text is large according to WCAG
|
|
54
58
|
const fontSize = parseFloat(style.fontSize);
|
|
55
59
|
const fontWeight = style.fontWeight;
|
|
56
|
-
const isLargeText = fontSize >= 24 || (fontSize >= 18.5 &&
|
|
60
|
+
const isLargeText = fontSize >= 24 || (fontSize >= 18.5 && isBoldWeight(fontWeight));
|
|
57
61
|
// Get required contrast ratio
|
|
58
62
|
const requiredRatio = isLargeText ? requirements.largeText : requirements.normalText;
|
|
59
63
|
// Calculate contrast ratio
|
|
60
|
-
const contrastRatio = calculateContrastRatio(textColor, bgColor);
|
|
64
|
+
const contrastRatio = calculateContrastRatio(textColor, bgColor, parsedColorCache);
|
|
61
65
|
const info = {
|
|
62
66
|
tagName: element.tagName.toLowerCase(),
|
|
63
67
|
id: element.id || null,
|
|
@@ -95,34 +99,45 @@ exports.default = {
|
|
|
95
99
|
* Check if an element is hidden
|
|
96
100
|
* @param element Element to check
|
|
97
101
|
*/
|
|
98
|
-
function isElementHidden(element) {
|
|
102
|
+
function isElementHidden(element, style) {
|
|
99
103
|
if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
|
|
100
104
|
return true;
|
|
101
105
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
|
|
106
|
+
return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
|
|
107
|
+
}
|
|
108
|
+
function isBoldWeight(fontWeight) {
|
|
109
|
+
const numeric = parseInt(fontWeight, 10);
|
|
110
|
+
if (!Number.isNaN(numeric))
|
|
111
|
+
return numeric >= 700;
|
|
112
|
+
return fontWeight === 'bold' || fontWeight === 'bolder';
|
|
109
113
|
}
|
|
110
114
|
/**
|
|
111
115
|
* Get the effective background color of an element
|
|
112
116
|
* @param element Element to check
|
|
113
117
|
* @param window Browser window
|
|
114
118
|
*/
|
|
115
|
-
function getBackgroundColor(element, window) {
|
|
119
|
+
function getBackgroundColor(element, window, cache) {
|
|
120
|
+
const cached = cache.get(element);
|
|
121
|
+
if (cached)
|
|
122
|
+
return cached;
|
|
116
123
|
let current = element;
|
|
117
124
|
while (current !== null && current.nodeType === 1) {
|
|
125
|
+
const cachedCurrent = cache.get(current);
|
|
126
|
+
if (cachedCurrent) {
|
|
127
|
+
cache.set(element, cachedCurrent);
|
|
128
|
+
return cachedCurrent;
|
|
129
|
+
}
|
|
118
130
|
const style = window.getComputedStyle(current);
|
|
119
131
|
const backgroundColor = style.backgroundColor;
|
|
120
132
|
if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
|
133
|
+
cache.set(current, backgroundColor);
|
|
134
|
+
cache.set(element, backgroundColor);
|
|
121
135
|
return backgroundColor;
|
|
122
136
|
}
|
|
123
137
|
current = current.parentElement;
|
|
124
138
|
}
|
|
125
139
|
// If we couldn't find a background color, default to white
|
|
140
|
+
cache.set(element, 'rgb(255, 255, 255)');
|
|
126
141
|
return 'rgb(255, 255, 255)';
|
|
127
142
|
}
|
|
128
143
|
/**
|
|
@@ -130,9 +145,9 @@ function getBackgroundColor(element, window) {
|
|
|
130
145
|
* @param foreground Foreground color
|
|
131
146
|
* @param background Background color
|
|
132
147
|
*/
|
|
133
|
-
function calculateContrastRatio(foreground, background) {
|
|
134
|
-
const fgRgb = parseColor(foreground);
|
|
135
|
-
const bgRgb = parseColor(background);
|
|
148
|
+
function calculateContrastRatio(foreground, background, cache) {
|
|
149
|
+
const fgRgb = parseColor(foreground, cache);
|
|
150
|
+
const bgRgb = parseColor(background, cache);
|
|
136
151
|
if (!fgRgb || !bgRgb) {
|
|
137
152
|
return 0;
|
|
138
153
|
}
|
|
@@ -146,42 +161,53 @@ function calculateContrastRatio(foreground, background) {
|
|
|
146
161
|
* Parse color string to RGB values
|
|
147
162
|
* @param color Color string
|
|
148
163
|
*/
|
|
149
|
-
function parseColor(color) {
|
|
164
|
+
function parseColor(color, cache) {
|
|
165
|
+
const cached = cache.get(color);
|
|
166
|
+
if (cached !== undefined)
|
|
167
|
+
return cached;
|
|
150
168
|
// Handle 'rgb(r, g, b)' format
|
|
151
169
|
let match = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
|
|
152
170
|
if (match) {
|
|
153
|
-
|
|
171
|
+
const parsed = {
|
|
154
172
|
r: parseInt(match[1]),
|
|
155
173
|
g: parseInt(match[2]),
|
|
156
174
|
b: parseInt(match[3])
|
|
157
175
|
};
|
|
176
|
+
cache.set(color, parsed);
|
|
177
|
+
return parsed;
|
|
158
178
|
}
|
|
159
179
|
// Handle 'rgba(r, g, b, a)' format
|
|
160
180
|
match = color.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)/i);
|
|
161
181
|
if (match) {
|
|
162
|
-
|
|
182
|
+
const parsed = {
|
|
163
183
|
r: parseInt(match[1]),
|
|
164
184
|
g: parseInt(match[2]),
|
|
165
185
|
b: parseInt(match[3])
|
|
166
186
|
};
|
|
187
|
+
cache.set(color, parsed);
|
|
188
|
+
return parsed;
|
|
167
189
|
}
|
|
168
190
|
// Handle hex format (#RRGGBB)
|
|
169
191
|
match = color.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
|
|
170
192
|
if (match) {
|
|
171
|
-
|
|
193
|
+
const parsed = {
|
|
172
194
|
r: parseInt(match[1], 16),
|
|
173
195
|
g: parseInt(match[2], 16),
|
|
174
196
|
b: parseInt(match[3], 16)
|
|
175
197
|
};
|
|
198
|
+
cache.set(color, parsed);
|
|
199
|
+
return parsed;
|
|
176
200
|
}
|
|
177
201
|
// Handle shorthand hex format (#RGB)
|
|
178
202
|
match = color.match(/#([0-9a-f])([0-9a-f])([0-9a-f])/i);
|
|
179
203
|
if (match) {
|
|
180
|
-
|
|
204
|
+
const parsed = {
|
|
181
205
|
r: parseInt(match[1] + match[1], 16),
|
|
182
206
|
g: parseInt(match[2] + match[2], 16),
|
|
183
207
|
b: parseInt(match[3] + match[3], 16)
|
|
184
208
|
};
|
|
209
|
+
cache.set(color, parsed);
|
|
210
|
+
return parsed;
|
|
185
211
|
}
|
|
186
212
|
// Handle common color names
|
|
187
213
|
const colorNames = {
|
|
@@ -194,8 +220,11 @@ function parseColor(color) {
|
|
|
194
220
|
gray: { r: 128, g: 128, b: 128 }
|
|
195
221
|
};
|
|
196
222
|
if (colorNames[color.toLowerCase()]) {
|
|
197
|
-
|
|
223
|
+
const parsed = colorNames[color.toLowerCase()];
|
|
224
|
+
cache.set(color, parsed);
|
|
225
|
+
return parsed;
|
|
198
226
|
}
|
|
227
|
+
cache.set(color, null);
|
|
199
228
|
return null;
|
|
200
229
|
}
|
|
201
230
|
/**
|