@icjia/contrastcap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/package.json +46 -0
- package/src/browser.js +58 -0
- package/src/cli.js +125 -0
- package/src/config.js +69 -0
- package/src/engine/axeRunner.js +73 -0
- package/src/engine/colorSuggest.js +104 -0
- package/src/engine/contrastCalc.js +63 -0
- package/src/engine/pixelSampler.js +94 -0
- package/src/server.js +190 -0
- package/src/tools/auditPage.js +238 -0
- package/src/tools/checkElementContrast.js +99 -0
- package/src/tools/checkPageContrast.js +26 -0
- package/src/tools/getContrastSummary.js +26 -0
- package/src/utils/formatResults.js +106 -0
- package/src/utils/largeText.js +14 -0
- package/src/utils/sanitizeError.js +37 -0
- package/src/utils/urlValidate.js +35 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
import { validateUrl } from '../utils/urlValidate.js';
|
|
3
|
+
import { withPage } from '../browser.js';
|
|
4
|
+
import { sampleBackgroundColor } from '../engine/pixelSampler.js';
|
|
5
|
+
import {
|
|
6
|
+
contrastRatio,
|
|
7
|
+
requiredRatio,
|
|
8
|
+
parseRgbString,
|
|
9
|
+
rgbToHex,
|
|
10
|
+
} from '../engine/contrastCalc.js';
|
|
11
|
+
import { suggestFix } from '../engine/colorSuggest.js';
|
|
12
|
+
import { isLargeText } from '../utils/largeText.js';
|
|
13
|
+
import { formatElementResult } from '../utils/formatResults.js';
|
|
14
|
+
import { withAuditTimeout } from './auditPage.js';
|
|
15
|
+
|
|
16
|
+
function rgbStringToHex(rgbStr) {
|
|
17
|
+
const { r, g, b } = parseRgbString(rgbStr);
|
|
18
|
+
return rgbToHex(r, g, b);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function checkElementContrast(params) {
|
|
22
|
+
const level = params.level || CONFIG.DEFAULT_LEVEL;
|
|
23
|
+
const selector = params.selector;
|
|
24
|
+
if (typeof selector !== 'string' || selector.length === 0 || selector.length > CONFIG.SELECTOR_MAX_LEN) {
|
|
25
|
+
throw new Error('Invalid selector');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const validated = await validateUrl(params.url);
|
|
29
|
+
|
|
30
|
+
return withAuditTimeout(withPage(async (page) => {
|
|
31
|
+
await page.goto(validated, {
|
|
32
|
+
timeout: CONFIG.NAV_TIMEOUT,
|
|
33
|
+
waitUntil: 'networkidle',
|
|
34
|
+
}).catch(async (err) => {
|
|
35
|
+
if (/Timeout/i.test(err.message || '')) {
|
|
36
|
+
await page.goto(validated, { timeout: CONFIG.NAV_TIMEOUT, waitUntil: 'load' });
|
|
37
|
+
} else {
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await validateUrl(page.url()); // post-redirect re-check
|
|
43
|
+
|
|
44
|
+
const element = await page.$(selector);
|
|
45
|
+
if (!element) throw new Error('Element not found');
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await element.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
|
|
49
|
+
const box = await element.boundingBox();
|
|
50
|
+
if (!box || box.width * box.height < 4) {
|
|
51
|
+
throw new Error('Element has zero size');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta = await element.evaluate((el) => {
|
|
55
|
+
const cs = getComputedStyle(el);
|
|
56
|
+
return {
|
|
57
|
+
color: cs.color,
|
|
58
|
+
fontSize: cs.fontSize,
|
|
59
|
+
fontWeight: cs.fontWeight,
|
|
60
|
+
text: (el.textContent || '').trim(),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const fgPx = parseFloat(meta.fontSize);
|
|
65
|
+
const large = isLargeText(fgPx, meta.fontWeight);
|
|
66
|
+
const required = requiredRatio(large, level);
|
|
67
|
+
|
|
68
|
+
const fgHex = rgbStringToHex(meta.color);
|
|
69
|
+
const bg = await sampleBackgroundColor(page, element, box, meta.color);
|
|
70
|
+
const ratio = contrastRatio(fgHex, bg.hex);
|
|
71
|
+
const pass = ratio >= required;
|
|
72
|
+
|
|
73
|
+
const entry = {
|
|
74
|
+
selector,
|
|
75
|
+
text: meta.text,
|
|
76
|
+
ratio,
|
|
77
|
+
required,
|
|
78
|
+
pass,
|
|
79
|
+
fontSize: meta.fontSize,
|
|
80
|
+
fontWeight: meta.fontWeight,
|
|
81
|
+
isLargeText: large,
|
|
82
|
+
foreground: fgHex,
|
|
83
|
+
background: bg.hex,
|
|
84
|
+
backgroundSource: bg.source,
|
|
85
|
+
};
|
|
86
|
+
if (!pass) entry.suggestion = suggestFix(fgHex, bg.hex, large, level);
|
|
87
|
+
if (pass && (ratio < required + CONFIG.MARGINAL_DELTA)) {
|
|
88
|
+
entry.note = `Ratio within ${CONFIG.MARGINAL_DELTA} of threshold — marginal.`;
|
|
89
|
+
}
|
|
90
|
+
if (bg.highVariance && !entry.note) {
|
|
91
|
+
entry.note = 'Background sampled from gradient or image — may vary at other positions.';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return formatElementResult({ url: page.url(), wcagLevel: level, entry });
|
|
95
|
+
} finally {
|
|
96
|
+
await element.dispose().catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
import { auditPage, withAuditTimeout } from './auditPage.js';
|
|
3
|
+
import { formatPageResult } from '../utils/formatResults.js';
|
|
4
|
+
|
|
5
|
+
export async function checkPageContrast(params) {
|
|
6
|
+
const level = params.level || CONFIG.DEFAULT_LEVEL;
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
finalUrl,
|
|
10
|
+
axePassCount,
|
|
11
|
+
resolvedPassCount,
|
|
12
|
+
failures,
|
|
13
|
+
warnings,
|
|
14
|
+
skippedCount,
|
|
15
|
+
} = await withAuditTimeout(auditPage(params.url, level));
|
|
16
|
+
|
|
17
|
+
return formatPageResult({
|
|
18
|
+
url: finalUrl,
|
|
19
|
+
wcagLevel: level,
|
|
20
|
+
axePassCount,
|
|
21
|
+
resolvedPassCount,
|
|
22
|
+
failures,
|
|
23
|
+
warnings,
|
|
24
|
+
skippedCount,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
import { auditPage, withAuditTimeout } from './auditPage.js';
|
|
3
|
+
import { formatSummaryResult } from '../utils/formatResults.js';
|
|
4
|
+
|
|
5
|
+
export async function getContrastSummary(params) {
|
|
6
|
+
const level = params.level || CONFIG.DEFAULT_LEVEL;
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
finalUrl,
|
|
10
|
+
axePassCount,
|
|
11
|
+
resolvedPassCount,
|
|
12
|
+
failures,
|
|
13
|
+
warnings,
|
|
14
|
+
skippedCount,
|
|
15
|
+
} = await withAuditTimeout(auditPage(params.url, level));
|
|
16
|
+
|
|
17
|
+
return formatSummaryResult({
|
|
18
|
+
url: finalUrl,
|
|
19
|
+
wcagLevel: level,
|
|
20
|
+
axePassCount,
|
|
21
|
+
resolvedPassCount,
|
|
22
|
+
failCount: failures.length,
|
|
23
|
+
warningCount: warnings.length,
|
|
24
|
+
skippedCount,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function truncate(str, max) {
|
|
2
|
+
if (typeof str !== 'string') return str;
|
|
3
|
+
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function round(n, decimals = 2) {
|
|
7
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return n;
|
|
8
|
+
const f = 10 ** decimals;
|
|
9
|
+
return Math.round(n * f) / f;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shapeEntry(item) {
|
|
13
|
+
const entry = {
|
|
14
|
+
selector: truncate(item.selector, 120),
|
|
15
|
+
text: truncate(item.text || '', 40),
|
|
16
|
+
ratio: round(item.ratio, 2),
|
|
17
|
+
required: item.required,
|
|
18
|
+
level: item.level,
|
|
19
|
+
fontSize: item.fontSize,
|
|
20
|
+
fontWeight: item.fontWeight,
|
|
21
|
+
isLargeText: !!item.isLargeText,
|
|
22
|
+
foreground: item.foreground,
|
|
23
|
+
background: item.background,
|
|
24
|
+
backgroundSource: item.backgroundSource || 'computed',
|
|
25
|
+
};
|
|
26
|
+
if (item.suggestion) entry.suggestion = item.suggestion;
|
|
27
|
+
if (item.note) entry.note = item.note;
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatPageResult({
|
|
32
|
+
url,
|
|
33
|
+
wcagLevel,
|
|
34
|
+
axePassCount,
|
|
35
|
+
resolvedPassCount,
|
|
36
|
+
skippedCount,
|
|
37
|
+
failures,
|
|
38
|
+
warnings,
|
|
39
|
+
}) {
|
|
40
|
+
const pass = axePassCount + resolvedPassCount;
|
|
41
|
+
const total = pass + failures.length + warnings.length + skippedCount;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
url,
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
wcag_level: wcagLevel,
|
|
47
|
+
summary: {
|
|
48
|
+
total,
|
|
49
|
+
pass,
|
|
50
|
+
fail: failures.length,
|
|
51
|
+
warning: warnings.length,
|
|
52
|
+
skipped: skippedCount,
|
|
53
|
+
},
|
|
54
|
+
failures: failures.map(shapeEntry),
|
|
55
|
+
warnings: warnings.map(shapeEntry),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatSummaryResult({
|
|
60
|
+
url,
|
|
61
|
+
wcagLevel,
|
|
62
|
+
axePassCount,
|
|
63
|
+
resolvedPassCount,
|
|
64
|
+
failCount,
|
|
65
|
+
warningCount,
|
|
66
|
+
skippedCount,
|
|
67
|
+
}) {
|
|
68
|
+
const pass = axePassCount + resolvedPassCount;
|
|
69
|
+
const total = pass + failCount + warningCount + skippedCount;
|
|
70
|
+
return {
|
|
71
|
+
url,
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
wcag_level: wcagLevel,
|
|
74
|
+
counts: {
|
|
75
|
+
total_elements_checked: total,
|
|
76
|
+
pass,
|
|
77
|
+
fail: failCount,
|
|
78
|
+
warning: warningCount,
|
|
79
|
+
skipped: skippedCount,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatElementResult({ url, wcagLevel, entry }) {
|
|
85
|
+
const out = {
|
|
86
|
+
url,
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
wcag_level: wcagLevel,
|
|
89
|
+
selector: entry.selector,
|
|
90
|
+
text: truncate(entry.text || '', 40),
|
|
91
|
+
ratio: round(entry.ratio, 2),
|
|
92
|
+
required: entry.required,
|
|
93
|
+
pass: entry.pass,
|
|
94
|
+
fontSize: entry.fontSize,
|
|
95
|
+
fontWeight: entry.fontWeight,
|
|
96
|
+
isLargeText: !!entry.isLargeText,
|
|
97
|
+
foreground: entry.foreground,
|
|
98
|
+
background: entry.background,
|
|
99
|
+
backgroundSource: entry.backgroundSource || 'computed',
|
|
100
|
+
};
|
|
101
|
+
if (entry.suggestion) out.suggestion = entry.suggestion;
|
|
102
|
+
if (entry.note) out.note = entry.note;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const _test = { truncate, round, shapeEntry };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Large-text classification per WCAG 2.1.
|
|
3
|
+
*
|
|
4
|
+
* `fontSizePx` MUST be a resolved px value — read via
|
|
5
|
+
* `getComputedStyle(el).fontSize` in the browser. Do not try to parse em/rem/pt
|
|
6
|
+
* server-side: em is relative to the parent's computed size and cannot be
|
|
7
|
+
* resolved without the element context.
|
|
8
|
+
*/
|
|
9
|
+
export function isLargeText(fontSizePx, fontWeight) {
|
|
10
|
+
const px = typeof fontSizePx === 'number' ? fontSizePx : parseFloat(fontSizePx);
|
|
11
|
+
const weight = parseInt(fontWeight, 10) || 400;
|
|
12
|
+
if (!Number.isFinite(px)) return false;
|
|
13
|
+
return px >= 24 || (px >= 18.66 && weight >= 700);
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { log } from '../config.js';
|
|
2
|
+
|
|
3
|
+
const KNOWN = [
|
|
4
|
+
'Blocked URL scheme',
|
|
5
|
+
'Blocked URL',
|
|
6
|
+
'Invalid URL',
|
|
7
|
+
'Element not found',
|
|
8
|
+
'Element has zero size',
|
|
9
|
+
'Audit timed out',
|
|
10
|
+
'Audit queue full',
|
|
11
|
+
'Page navigation timed out',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function sanitizeError(err) {
|
|
15
|
+
const msg = err?.message || 'Unknown error';
|
|
16
|
+
|
|
17
|
+
if (KNOWN.some(k => msg.startsWith(k))) return msg;
|
|
18
|
+
|
|
19
|
+
if (msg.includes('ECONNREFUSED') || msg.includes('ERR_CONNECTION_REFUSED')) {
|
|
20
|
+
return 'Could not connect to URL';
|
|
21
|
+
}
|
|
22
|
+
if (msg.includes('ETIMEDOUT') || msg.includes('ETIMEOUT') || msg.includes('ERR_TIMED_OUT')) {
|
|
23
|
+
return 'Connection timed out';
|
|
24
|
+
}
|
|
25
|
+
if (msg.includes('ERR_NAME_NOT_RESOLVED') || msg.includes('ENOTFOUND')) {
|
|
26
|
+
return 'Could not resolve hostname';
|
|
27
|
+
}
|
|
28
|
+
if (msg.includes('net::ERR_')) {
|
|
29
|
+
return 'Network error';
|
|
30
|
+
}
|
|
31
|
+
if (msg.toLowerCase().startsWith('invalid url')) {
|
|
32
|
+
return 'Invalid URL';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
log('error', `Unhandled error: ${msg}`);
|
|
36
|
+
return 'Audit failed';
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { lookup } from 'dns/promises';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
|
|
4
|
+
async function isBlockedIp(hostname) {
|
|
5
|
+
if (CONFIG.LOCALHOST_HOSTS.includes(hostname)) return false;
|
|
6
|
+
try {
|
|
7
|
+
const { address } = await lookup(hostname);
|
|
8
|
+
const normalized = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
9
|
+
return CONFIG.BLOCKED_IP_PREFIXES.some(p => normalized.startsWith(p));
|
|
10
|
+
} catch {
|
|
11
|
+
return true; // DNS failure → fail closed
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function validateUrl(url) {
|
|
16
|
+
if (typeof url !== 'string' || url.length === 0 || url.length > CONFIG.MAX_URL_LENGTH) {
|
|
17
|
+
throw new Error('Invalid URL');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let parsed;
|
|
21
|
+
try { parsed = new URL(url); } catch { throw new Error('Invalid URL'); }
|
|
22
|
+
|
|
23
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
24
|
+
throw new Error('Blocked URL scheme');
|
|
25
|
+
}
|
|
26
|
+
if (CONFIG.BLOCKED_HOSTNAMES.includes(parsed.hostname)) {
|
|
27
|
+
throw new Error('Blocked URL');
|
|
28
|
+
}
|
|
29
|
+
if (await isBlockedIp(parsed.hostname)) {
|
|
30
|
+
throw new Error('Blocked URL');
|
|
31
|
+
}
|
|
32
|
+
return parsed.href;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const _test = { isBlockedIp };
|