everyhexcode 0.0.0-stage → 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.md +135 -0
- package/README.md +99 -2
- package/action.yml +91 -0
- package/dist/everyhexcode.js +5397 -0
- package/package.json +40 -4
- package/src/cli.js +160 -0
- package/src/engine/apca.js +10 -0
- package/src/engine/check-input.js +540 -0
- package/src/engine/check.js +777 -0
- package/src/engine/color.js +364 -0
- package/src/engine/parse.js +208 -0
- package/src/engine/plain.js +124 -0
- package/src/files.js +97 -0
- package/src/no-apca.js +10 -0
- package/src/output.js +110 -0
- package/src/render.js +329 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Each finding in plain words, for people who have never heard of WCAG: what it is and where (named the way a person
|
|
2
|
+
// sees it: the search box, the footer line, a link), why it matters, and the fix as a colour and a CSS line. The
|
|
3
|
+
// selector, the variable and the rule number stay as detail. A template, the same words every time, no model.
|
|
4
|
+
import { contrast, family, info } from './color.js';
|
|
5
|
+
|
|
6
|
+
const TAGS = { a: 'link', button: 'button', input: 'text field', textarea: 'text box', select: 'drop-down', label: 'label', h1: 'main heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', p: 'text', li: 'list item', th: 'table heading', td: 'table text', small: 'small print', code: 'code', pre: 'code', blockquote: 'quote', caption: 'caption', figcaption: 'caption', summary: 'toggle', nav: 'menu', footer: 'footer', header: 'header', svg: 'icon', hr: 'divider line', body: 'page text', html: 'page text', span: 'text', div: 'text', strong: 'bold text', b: 'bold text', em: 'text', time: 'date', mark: 'highlight' };
|
|
7
|
+
const PLACES = [[/\bfooter\b|(^|[-_])footer/, 'in the footer'], [/\bheader\b|(^|[-_])(header|masthead|topbar)/, 'in the header'], [/\bnav\b|(^|[-_])(nav|navbar|menu)/, 'in the menu'], [/\baside\b|(^|[-_])sidebar/, 'in the sidebar'], [/(^|[-_.])(modal|dialog|popover)/, 'in pop-ups'], [/(^|[-_.])card/, 'on cards'], [/(^|[-_.])hero/, 'in the top banner'], [/\btable\b|\bt[dh]\b/, 'in tables'], [/(^|[-_.])(alert|toast|banner)/, 'in notices'], [/(^|[-_.])(tooltip)/, 'in tooltips']];
|
|
8
|
+
// class words that say nothing to a reader
|
|
9
|
+
const NOISE = /^(js|is|has|u|c|l|o|wp|el|col|row|d|flex|grid|container|wrapper|wrap|inner|outer|item|block|main|content|text|color|colour|style|active|current|default|base|primary|secondary|lg|md|sm|xs|xl|\d+)$/;
|
|
10
|
+
|
|
11
|
+
const human = (s) => s.replace(/^[-_.#]+/, '').split(/[-_]+|(?<=[a-z])(?=[A-Z])/).map((w) => w.toLowerCase()).filter((w) => w && !NOISE.test(w));
|
|
12
|
+
|
|
13
|
+
/** The selector's last element, read as a person would name it. */
|
|
14
|
+
export function thingOf(selector, part, kind) {
|
|
15
|
+
const first = String(selector || '').split(/,(?![^(]*\))/)[0].trim();
|
|
16
|
+
const steps = first.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
17
|
+
const last = steps[steps.length - 1] || '';
|
|
18
|
+
const before = steps.slice(0, -1).join(' ').toLowerCase();
|
|
19
|
+
const state = /:hover/.test(last) ? 'hover' : /:focus/.test(last) ? 'focus' : /:visited/.test(last) ? 'visited' : /::?placeholder/.test(last) ? 'placeholder' : /:active/.test(last) ? 'pressed' : null;
|
|
20
|
+
const bare = last.replace(/::?[\w-]+(\([^)]*\))?/g, '');
|
|
21
|
+
const tag = (bare.match(/^[a-z][a-z0-9]*/i) || [''])[0].toLowerCase();
|
|
22
|
+
const classes = [...bare.matchAll(/[.#]([\w-]+)/g)].map((m) => m[1]);
|
|
23
|
+
const attr = (bare.match(/\[type=["']?(\w+)/) || [])[1];
|
|
24
|
+
const cls = classes.join(' ').toLowerCase();
|
|
25
|
+
let name = null, shape = kind === 'ui' ? 'field' : 'text';
|
|
26
|
+
if (/search/.test(cls) || attr === 'search') { name = 'search box'; shape = 'field'; }
|
|
27
|
+
else if (attr === 'checkbox' || /checkbox/.test(cls)) { name = 'checkbox'; shape = 'check'; }
|
|
28
|
+
else if (attr === 'radio' || /radio/.test(cls)) { name = 'option button'; shape = 'check'; }
|
|
29
|
+
else if (tag === 'input' || tag === 'textarea' || tag === 'select' || /(^|[-_ ])(input|field|form-control|textarea|select)([-_ ]|$)/.test(cls)) { name = tag === 'select' || /select/.test(cls) ? 'drop-down' : tag === 'textarea' ? 'text box' : 'text field'; shape = 'field'; }
|
|
30
|
+
else if (tag === 'button' || attr === 'submit' || /(^|[-_ ])(btn|button|cta)([-_ ]|$)/.test(cls)) { name = 'button'; shape = 'button'; }
|
|
31
|
+
else if (tag === 'a' || /(^|[-_ ])link([-_ ]|s?$)/.test(cls)) { name = 'link'; shape = 'link'; }
|
|
32
|
+
else if (/^h[1-6]$/.test(tag) || /(^|[-_ ])(heading|title|headline)([-_ ]|$)/.test(cls)) { name = tag === 'h1' ? 'main heading' : 'heading'; shape = 'heading'; }
|
|
33
|
+
else if (/icon|svg/.test(cls) || tag === 'svg') { name = 'icon'; shape = 'icon'; }
|
|
34
|
+
else if (/badge|tag|chip|pill|label/.test(cls)) { name = 'label'; shape = 'badge'; }
|
|
35
|
+
// otherwise the class or id in words: .footer-trademark is the "footer trademark" text
|
|
36
|
+
const PLACE_WORDS = /^(footer|header|masthead|topbar|nav|navbar|menu|sidebar|modal|dialog|popover|card|hero|alert|toast|banner|tooltip)$/;
|
|
37
|
+
const words = human(classes[classes.length - 1] || '').filter((w) => !PLACE_WORDS.test(w));
|
|
38
|
+
if (!name) name = words.length ? `“${words.join(' ')}” ${tag && TAGS[tag] && !/^(text|page text)$/.test(TAGS[tag]) ? TAGS[tag] : 'text'}` : (TAGS[tag] || (kind === 'ui' ? 'control' : 'text'));
|
|
39
|
+
// where it sits, from the elements around it (or its own class), unless its name already says so
|
|
40
|
+
let where = null;
|
|
41
|
+
const around = `${before} ${cls}`;
|
|
42
|
+
for (const [re, w] of PLACES) if (re.test(around) && !name.includes(w.replace(/^(in|on) (the )?/, '').replace(/s$/, ''))) { where = w; break; }
|
|
43
|
+
if (!where) { const id = (before.match(/#([\w-]+)/g) || []).pop(); if (id) { const w = human(id); if (w.length) where = `in the “${w.join(' ')}” section`; } }
|
|
44
|
+
if (state === 'placeholder') name = `placeholder text in the ${name}`;
|
|
45
|
+
const when = state === 'hover' ? 'when the pointer is over it' : state === 'focus' ? 'when it has keyboard focus' : state === 'visited' ? 'once visited' : state === 'pressed' ? 'while pressed' : null;
|
|
46
|
+
const what = part === 'border' ? `border of the ${name}` : part === 'outline' ? `focus ring of the ${name}` : name;
|
|
47
|
+
return { name, what, where, when, shape: part === 'outline' ? 'focus' : shape };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A colour variable's name in words: --muted-foreground is "muted text", --input is "input borders". */
|
|
51
|
+
function tokenThing(name, kind) {
|
|
52
|
+
const w = human(String(name).replace(/^(--|\$|@)/, '')).filter((x) => !/^(fg|foreground|bg|background|on)$/.test(x));
|
|
53
|
+
if (kind === 'ui') return w.some((x) => /ring|focus|outline/.test(x)) ? 'focus ring' : w.some((x) => /input|border|field|control/.test(x)) ? 'text field borders' : `${w.join(' ') || 'interface'} color`;
|
|
54
|
+
return w.length ? `${w.join(' ')} text` : 'body text';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A colour named for a reader: "very light gray", "near-black". */
|
|
58
|
+
export function colorWords(hex) {
|
|
59
|
+
const o = info(hex).oklch || { l: 50, c: 0 }, l = o.l / 100, f = family(hex);
|
|
60
|
+
if (l > 0.95) return /^f{6}$/i.test(hex) ? 'white' : 'off-white';
|
|
61
|
+
if (l < 0.2) return 'near-black';
|
|
62
|
+
if (f === 'white' || f === 'black' || f === 'gray') return l > 0.85 ? 'very light gray' : l > 0.7 ? 'light gray' : l < 0.4 ? 'dark gray' : 'gray';
|
|
63
|
+
return `${l > 0.85 ? 'very light ' : l > 0.7 ? 'light ' : l < 0.4 ? 'dark ' : ''}${f}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lighterOrDarker = (from, to) => ((info(to).oklch?.l ?? 0) > (info(from).oklch?.l ?? 0) ? 'lighter' : 'darker');
|
|
67
|
+
const propOf = (part, side) => (side === 'bg' ? 'background-color' : part === 'border' ? 'border-color' : part === 'outline' ? 'outline-color' : 'color');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The plain finding for one judged pair. `fix` is the nearest passing colour for this pair if there is one:
|
|
71
|
+
* { side: 'fg' | 'bg', name, from, to, ratio }.
|
|
72
|
+
*/
|
|
73
|
+
export function explain(p, fix) {
|
|
74
|
+
const part = /sets (border|outline)\b/.test(p.why || '') ? (p.why.match(/sets (border|outline)/)[1]) : 'color';
|
|
75
|
+
const fromRule = p.selector && !/^(body|html|:root)$/.test(p.selector);
|
|
76
|
+
const t = fromRule ? thingOf(p.selector, part, p.kind) : { what: /default link/.test(p.fg.name) ? 'links' : /default text|browser default/.test(p.fg.name) ? 'page text' : tokenThing(p.fg.source || p.fg.name, p.kind), where: null, when: null, shape: p.kind === 'ui' ? 'field' : 'text' };
|
|
77
|
+
if (/links? no CSS rule colours/.test(p.why || '')) { t.what = /^1 link/.test(p.why) ? 'link' : 'links'; t.shape = 'link'; }
|
|
78
|
+
const plural = /s$/.test(t.what) && !/(text|ss)$/.test(t.what);
|
|
79
|
+
const onPage = p.pageBg && p.bg.shown === p.pageBg;
|
|
80
|
+
const bgWords = onPage ? 'the page background' : `its ${colorWords(p.bg.shown)} background`;
|
|
81
|
+
const subject = `${t.what}${t.where ? ` ${t.where}` : ''}`;
|
|
82
|
+
const Subject = subject.replace(/[a-z]/, (c) => c.toUpperCase());
|
|
83
|
+
const swatches = `${colorWords(p.fg.shown)} #${p.fg.shown} on ${p.bg.shown === p.pageBg ? '' : `${colorWords(p.bg.shown)} `}#${p.bg.shown}`;
|
|
84
|
+
const isUi = p.kind === 'ui';
|
|
85
|
+
const bad = isUi ? 'too faint' : p.ratio < 3 ? 'hard to read' : 'harder to read than it should be';
|
|
86
|
+
const rule = isUi ? 'Borders, icons and focus rings need at least 3:1' : p.kind === 'large' ? 'Large text (24px, or 19px bold) needs at least 3:1' : 'Text needs at least 4.5:1';
|
|
87
|
+
const who = isUi ? (t.shape === 'focus' ? 'Keyboard users may not see where they are on the page.' : /field|search|box|drop-down|check/.test(t.what) ? `People with low vision may not see where the ${t.name || 'field'} is or where to type.` : 'People with low vision may not see it.') : 'People with low vision, or on a phone in sunlight, may not be able to read it.';
|
|
88
|
+
const base = {
|
|
89
|
+
subject: Subject, shape: t.shape,
|
|
90
|
+
title: p.kind === 'decor' ? `${Subject}: decoration, no minimum` : p.pass === false ? `${Subject} ${plural ? 'are' : 'is'} ${bad}` : p.pass ? `${Subject}: ${p.ratio >= p.need + 0.5 ? 'clear' : 'just clear enough'}` : `${Subject}: not judged`,
|
|
91
|
+
where: [t.where, t.when].filter(Boolean).join(', ') || null,
|
|
92
|
+
colors: swatches,
|
|
93
|
+
};
|
|
94
|
+
if (p.pass !== false || p.kind === 'decor') return { ...base, what: null, why: null, fix: null };
|
|
95
|
+
base.what = `The ${subject}${t.when ? ` (${t.when})` : ''} ${plural ? 'are' : 'is'} ${colorWords(p.fg.shown)} (#${p.fg.shown}) on ${bgWords} (#${p.bg.shown}). The two are only ${p.ratio}:1 apart.`;
|
|
96
|
+
base.why = `${who} ${rule}; ${plural ? 'these are' : 'this one is'} ${p.ratio}:1.`;
|
|
97
|
+
if (fix) {
|
|
98
|
+
const prop = propOf(part, fix.side);
|
|
99
|
+
const isVar = /^(--|\$|@)/.test(fix.name);
|
|
100
|
+
const css = isVar ? `${fix.name}: #${fix.to};` : `${fromRule ? p.selector.split(/,(?![^(]*\))/)[0].trim() : fix.side === 'bg' ? 'body' : 'a'} { ${prop}: #${fix.to}; }`;
|
|
101
|
+
base.fix = { side: fix.side, name: fix.name, from: fix.from, to: fix.to, ratio: fix.ratio, css,
|
|
102
|
+
text: `Make the ${fix.side === 'bg' ? 'background' : part === 'color' ? (t.shape === 'link' ? 'link color' : 'text color') : part === 'outline' ? 'focus ring' : 'border'} a little ${lighterOrDarker(fix.from, fix.to)}: #${fix.from} → #${fix.to}, the nearest shade of the same color that passes (${fix.ratio}:1).${isVar ? ` It is set once, as ${fix.name}, so every place that uses it is fixed too.` : ''}` };
|
|
103
|
+
} else base.fix = null;
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The top of a report in plain words: how much was checked, how much fails, the things to fix first. */
|
|
108
|
+
export function plainSummary(r) {
|
|
109
|
+
const judged = r.pairs.filter((p) => p.kind !== 'decor' && !p.optional);
|
|
110
|
+
const fail = judged.filter((p) => p.pass === false).sort((a, b) => a.ratio / a.need - b.ratio / b.need);
|
|
111
|
+
const seen = new Set(), top = [];
|
|
112
|
+
for (const p of fail) { const k = p.plain?.title; if (k && !seen.has(k)) { seen.add(k); top.push(k); } }
|
|
113
|
+
const n = judged.length;
|
|
114
|
+
const text = !n ? 'No color combinations to judge.' : fail.length
|
|
115
|
+
? `We checked ${n} color ${n === 1 ? 'combination' : 'combinations'}. ${fail.length === 1 ? '1 is' : `${fail.length} are`} too low in contrast: people with low vision, or on a phone in bright light, may not be able to see ${fail.length === 1 ? 'it' : 'them'}.`
|
|
116
|
+
: `We checked ${n} color ${n === 1 ? 'combination' : 'combinations'}, and all of them are clear enough to read and see.`;
|
|
117
|
+
return { text, top: top.slice(0, 5) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Contrast in words, for column help. */
|
|
121
|
+
export const CONTRAST_HELP = 'How far apart two colors are in lightness: 1:1 is the same color, 21:1 is black on white.';
|
|
122
|
+
export const RULE_HELP = 'The accessibility standard (WCAG 2, level AA): normal text needs 4.5:1; large text, borders, icons and focus rings need 3:1.';
|
|
123
|
+
export const APCA_HELP = 'APCA is a newer method, still a draft. Lc runs from 0 (invisible) to about 106; body text wants Lc 75 or more. Advisory only: it does not change pass or fail.';
|
|
124
|
+
export { contrast };
|
package/src/files.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Which files to check: paths, folders (walked) and simple globs (*, **), the kinds the checker reads, and how CSS
|
|
2
|
+
// files are joined so a variable set in one file resolves where another file uses it.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const EXT = /\.(css|scss|sass|less|pcss|postcss|json|js|cjs|mjs|ts|txt)$/i;
|
|
7
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit', 'coverage', 'vendor', '.turbo', '.cache']);
|
|
8
|
+
// a JS or TS file is read only when it is a Tailwind config or a theme/tokens file, never as app code
|
|
9
|
+
const CONFIG_JS = /(^|[/\\])(tailwind\.config|theme|tokens?|colors?|palette)[\w.-]*\.(c|m)?(js|ts)$/i;
|
|
10
|
+
|
|
11
|
+
function globToRe(g) {
|
|
12
|
+
let s = '';
|
|
13
|
+
for (let i = 0; i < g.length; i++) {
|
|
14
|
+
const c = g[i];
|
|
15
|
+
if (c === '*' && g[i + 1] === '*') { s += '.*'; i++; if (g[i + 1] === '/') i++; }
|
|
16
|
+
else if (c === '*') s += '[^/]*';
|
|
17
|
+
else if (c === '?') s += '[^/]';
|
|
18
|
+
else s += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
}
|
|
20
|
+
return new RegExp(`^${s}$`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function walk(dir, out, root) {
|
|
24
|
+
let list;
|
|
25
|
+
try { list = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
26
|
+
for (const e of list) {
|
|
27
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
28
|
+
const p = path.join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(p, out, root); }
|
|
30
|
+
else if (EXT.test(e.name)) out.push(p);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The files a list of arguments means, each once, in a stable order. */
|
|
35
|
+
export function expand(args, cwd = process.cwd()) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const named = new Set(); // files given by name are read whatever they are called
|
|
38
|
+
for (const a of args.length ? args : ['.']) {
|
|
39
|
+
if (/[*?]/.test(a)) {
|
|
40
|
+
const base = a.slice(0, a.search(/[*?]/)).replace(/[^/]*$/, '') || '.';
|
|
41
|
+
const re = globToRe(path.posix.normalize(a.replace(/\\/g, '/')).replace(/^\.\//, ''));
|
|
42
|
+
const all = [];
|
|
43
|
+
walk(path.resolve(cwd, base), all, cwd);
|
|
44
|
+
for (const f of all) { const rel = path.relative(cwd, f).replace(/\\/g, '/'); if (re.test(rel)) out.push(f); }
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const p = path.resolve(cwd, a);
|
|
48
|
+
let st;
|
|
49
|
+
try { st = fs.statSync(p); } catch { throw new Error(`No such file or folder: ${a}`); }
|
|
50
|
+
if (st.isDirectory()) walk(p, out, cwd); else { out.push(p); named.add(p); }
|
|
51
|
+
}
|
|
52
|
+
const keep = (f) => !/\.(js|cjs|mjs|ts)$/i.test(f) || CONFIG_JS.test(f);
|
|
53
|
+
const jsonOk = (f) => !/\.json$/i.test(f) || !/(^|[/\\])(package(-lock)?|tsconfig[\w.-]*|jsconfig|composer|manifest|\.?eslintrc[\w.-]*|renovate|turbo|vercel|firebase|app)\.json$/i.test(f);
|
|
54
|
+
return [...new Set(out)].filter((f) => named.has(f) || (keep(f) && jsonOk(f))).sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Groups to check: all stylesheets together (one cascade: a variable set in tokens.css resolves in buttons.css), and
|
|
59
|
+
* each token or config file alone. Each group keeps where every file starts in the joined text, so findings and fixes
|
|
60
|
+
* go back to the right file and line.
|
|
61
|
+
*/
|
|
62
|
+
export const skipped = [];
|
|
63
|
+
export function groups(files, read = (f) => fs.readFileSync(f, 'utf8')) {
|
|
64
|
+
const css = [], other = [];
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const text = read(f);
|
|
67
|
+
if (text.length > 5_000_000) { skipped.push(`${f} (over 5 MB, not read)`); continue; }
|
|
68
|
+
if (/\.(css|scss|sass|less|pcss|postcss)$/i.test(f)) css.push({ file: f, text }); else other.push({ file: f, text });
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
// stylesheets are read together only when one uses a variable another defines (tokens.css and buttons.css): unrelated
|
|
72
|
+
// files stay apart, so a fix in one never comes from another's colours
|
|
73
|
+
const defs = css.map((c) => new Set([...c.text.matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1])));
|
|
74
|
+
const uses = css.map((c) => new Set([...c.text.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1])));
|
|
75
|
+
const parent = css.map((_, i) => i);
|
|
76
|
+
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
|
|
77
|
+
for (let i = 0; i < css.length; i++) for (let j = 0; j < css.length; j++) {
|
|
78
|
+
if (i !== j && [...uses[i]].some((v) => !defs[i].has(v) && defs[j].has(v))) parent[find(i)] = find(j);
|
|
79
|
+
}
|
|
80
|
+
const sets = new Map();
|
|
81
|
+
css.forEach((c, i) => { const r = find(i); sets.set(r, [...(sets.get(r) || []), c]); });
|
|
82
|
+
for (const list of sets.values()) {
|
|
83
|
+
let text = '', parts = [];
|
|
84
|
+
for (const c of list) { parts.push({ file: c.file, start: text.length, end: text.length + c.text.length, text: c.text }); text += c.text + '\n'; }
|
|
85
|
+
out.push({ label: list.length === 1 ? list[0].file : `${list.length} stylesheets that share variables`, text, parts });
|
|
86
|
+
}
|
|
87
|
+
for (const o of other) out.push({ label: o.file, text: o.text, parts: [{ file: o.file, start: 0, end: o.text.length, text: o.text }] });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** File, line and column of an offset in a joined group. */
|
|
92
|
+
export function locate(group, offset) {
|
|
93
|
+
const p = group.parts.find((x) => offset >= x.start && offset <= x.end) || group.parts[0];
|
|
94
|
+
const before = p.text.slice(0, Math.max(0, offset - p.start));
|
|
95
|
+
const line = before.split('\n').length;
|
|
96
|
+
return { file: p.file, line, column: before.length - before.lastIndexOf('\n') };
|
|
97
|
+
}
|
package/src/no-apca.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The command-line build leaves APCA out (its licence is for W3C web-content use; see LICENSE.md): these stand in for
|
|
2
|
+
// shared/apca.js, so nothing in the bundle computes or names an APCA value.
|
|
3
|
+
export const apca = () => null;
|
|
4
|
+
export const apcaUse = () => '';
|
|
5
|
+
export const APCA_LEVELS = [];
|
|
6
|
+
export const APCA_NOTICE = '';
|
|
7
|
+
export const APCA_WHY = '';
|
|
8
|
+
export const APCA_FORUM = '';
|
|
9
|
+
export const apcaLine = () => '';
|
|
10
|
+
export const lcText = () => '';
|
package/src/output.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// How a check is written out: text for a terminal, JSON, SARIF (GitHub code scanning shows it on the pull request),
|
|
2
|
+
// Markdown (a job summary or a PR comment) and GitHub annotations.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { locate } from './files.js';
|
|
5
|
+
|
|
6
|
+
const C = process.stdout.isTTY && !process.env.NO_COLOR ? { r: '\x1b[31m', g: '\x1b[32m', y: '\x1b[33m', d: '\x1b[2m', b: '\x1b[1m', x: '\x1b[0m' } : { r: '', g: '', y: '', d: '', b: '', x: '' };
|
|
7
|
+
const rel = (f) => (/^https?:/.test(f) ? f : path.relative(process.cwd(), f).replace(/\\/g, '/') || f);
|
|
8
|
+
const SITE = 'https://everyhexcode.com';
|
|
9
|
+
|
|
10
|
+
/** Where a pair's text colour is declared (else its background), as file and line. */
|
|
11
|
+
export function whereOf(group, report, pair) {
|
|
12
|
+
for (const side of [pair.fg, pair.bg]) {
|
|
13
|
+
const t = report.tokens.find((x) => x.name === side.name && x.mode === pair.mode && x.at != null) || report.tokens.find((x) => x.name === side.name && x.at != null);
|
|
14
|
+
if (t) return locate(group, t.at);
|
|
15
|
+
}
|
|
16
|
+
return { file: group.parts[0].file, line: 1, column: 1 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const failing = (r) => r.pairs.filter((p) => !p.pass && p.kind !== 'decor' && !p.optional);
|
|
20
|
+
// look-alike pairs once, whichever modes they occur in
|
|
21
|
+
export const lookAlike = (r) => r.collisions.filter((c) => c.common).filter((c, i, l) => l.findIndex((x) => x.a.name === c.a.name && x.b.name === c.b.name) === i);
|
|
22
|
+
const link = (r) => `${SITE}/color-checker`;
|
|
23
|
+
const fixOf = (r, p) => r.fix.changes.filter((c) => c.mode === p.mode && (c.name === p.fg.name || c.name === p.bg.name)).map((c) => `${c.name} → #${c.to}`).filter((x, i, l) => l.indexOf(x) === i).join(', ');
|
|
24
|
+
|
|
25
|
+
export function text(results, opts) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const { group, report: r } of results) {
|
|
28
|
+
out.push(`${C.b}${rel(group.label)}${C.x} ${C.d}(${r.input.kind}, ${r.input.colors} ${r.input.colors === 1 ? 'color' : 'colors'}${r.input.modes.length > 1 ? ', light and dark' : ''})${C.x}`);
|
|
29
|
+
const f = failing(r);
|
|
30
|
+
for (const p of f) {
|
|
31
|
+
const at = whereOf(group, r, p);
|
|
32
|
+
const fx = fixOf(r, p);
|
|
33
|
+
// a rendered page has no file lines: the element is the place to look
|
|
34
|
+
const where = /^https?:/.test(at.file) ? (p.why || '').replace(/ sets .*$/, '') : `${rel(at.file)}:${at.line}`;
|
|
35
|
+
const pl = p.plain || {};
|
|
36
|
+
out.push(` ${C.r}✗${C.x} ${C.b}${pl.title || `${p.fg.name} on ${p.bg.name}`}${C.x}${r.input.modes.length > 1 ? ` [${p.mode}]` : ''}: #${p.fg.shown} on #${p.bg.shown}, ${C.b}${p.ratio}:1${C.x}, needs ${p.need}:1 ${C.d}${where}${C.x}${pl.why ? `\n ${pl.why}` : ''}${pl.fix ? `\n ${C.g}fix:${C.x} ${pl.fix.text}\n ${C.g}${pl.fix.css}${C.x}` : fx ? `\n ${C.g}fix:${C.x} ${fx}` : ''}`);
|
|
37
|
+
}
|
|
38
|
+
for (const c of lookAlike(r)) out.push(` ${C.y}!${C.x} ${c.a.name} and ${c.b.name} look alike with ${c.worst.label.toLowerCase()} (ΔE ${c.worst.d})`);
|
|
39
|
+
if (opts.verbose) for (const p of r.pairs.filter((x) => x.pass)) out.push(` ${C.g}✓${C.x} ${p.fg.name} on ${p.bg.name}${r.input.modes.length > 1 ? ` [${p.mode}]` : ''}: ${p.ratio}:1`);
|
|
40
|
+
out.push(` ${f.length ? C.r : C.g}${r.summary.pass} pass, ${r.summary.fail} fail${C.x} against ${r.standard}${r.input.warnings.length ? ` ${C.y}${r.input.warnings.join(' ')}${C.x}` : ''}`);
|
|
41
|
+
if (r.fix.remaining.length) out.push(` ${C.y}${r.fix.remaining.length} ${r.fix.remaining.length === 1 ? 'pair has' : 'pairs have'} no single-color fix: ${r.fix.remaining.slice(0, 2).map((x) => `${x.fg} on ${x.bg}: ${x.reason}`).join('; ')}.${C.x}`);
|
|
42
|
+
out.push('');
|
|
43
|
+
}
|
|
44
|
+
return out.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function json(results) {
|
|
48
|
+
return JSON.stringify(results.map(({ group, report }) => ({ file: group.parts.length === 1 ? rel(group.parts[0].file) : group.parts.map((p) => rel(p.file)), report })), null, 2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function markdown(results) {
|
|
52
|
+
const out = ['## Color check', ''];
|
|
53
|
+
for (const { group, report: r } of results) {
|
|
54
|
+
const f = failing(r);
|
|
55
|
+
out.push(`### ${rel(group.label)}: ${f.length ? `❌ ${r.summary.fail} failing` : '✅ all pass'} (${r.standard})`, '', r.summary.plain?.text || r.summary.text, '');
|
|
56
|
+
if (f.length) {
|
|
57
|
+
out.push('| What | Colors | Mode | Contrast | Needs | Where | Fix |', '|---|---|---|---|---|---|---|');
|
|
58
|
+
const cell = (x) => String(x).replace(/\|/g, '/');
|
|
59
|
+
for (const p of f) { const at = whereOf(group, r, p); const pl = p.plain || {}; out.push(`| ${cell(pl.title || '–')} | \`#${p.fg.shown}\` on \`#${p.bg.shown}\` ${p.fg.name === '#' + p.fg.shown && p.bg.name === '#' + p.bg.shown ? '' : `(\`${cell(p.fg.name)}\` on \`${cell(p.bg.name)}\`)`} | ${p.mode} | ${p.ratio}:1 | ${p.need}:1 | ${/^https?:/.test(at.file) ? `\`${cell((p.why || '').replace(/ sets .*$/, ''))}\`` : `${rel(at.file)}:${at.line}`} | ${pl.fix ? `\`${cell(pl.fix.css)}\`` : fixOf(r, p) || '–'} |`); }
|
|
60
|
+
out.push('');
|
|
61
|
+
for (const p of f.filter((x) => x.plain?.why)) out.push(`- **${p.plain.title}.** ${p.plain.why}${p.plain.fix ? ` ${p.plain.fix.text}` : ''}`);
|
|
62
|
+
out.push('');
|
|
63
|
+
}
|
|
64
|
+
if (r.fix.diff) out.push('<details><summary>The fix as a diff</summary>', '', '```diff', r.fix.diff.trim(), '```', '', '</details>', '');
|
|
65
|
+
}
|
|
66
|
+
out.push(`Checked with [everyhexcode](${link()}): WCAG 2 contrast, color vision and the nearest passing colors.`);
|
|
67
|
+
return out.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const esc = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
71
|
+
// property values (file, title) also escape : and , (GitHub's workflow command format)
|
|
72
|
+
const escP = (s) => esc(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
73
|
+
export function github(results) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const { group, report: r } of results) {
|
|
76
|
+
for (const p of failing(r)) {
|
|
77
|
+
const at = whereOf(group, r, p), fx = fixOf(r, p);
|
|
78
|
+
const loc = /^https?:/.test(at.file) ? '' : `file=${escP(rel(at.file))},line=${at.line},col=${at.column},`;
|
|
79
|
+
out.push(`::error ${loc}title=${escP(p.plain?.title ? `${p.plain.title}: ${p.ratio}:1, needs ${p.need}:1` : `Contrast ${p.ratio}:1 (needs ${p.need}:1)`)}::${esc(`${p.plain?.why ? `${p.plain.why} ${p.plain.fix ? `Fix: ${p.plain.fix.css} ` : ''}` : ''}${p.fg.name} on ${p.bg.name}${r.input.modes.length > 1 ? ` (${p.mode} mode)` : ''}${loc ? '' : ` at ${(p.why || '').replace(/ sets .*$/, '')}`} is ${p.ratio}:1; ${p.kind === 'ui' ? 'UI parts need 3:1 (WCAG 1.4.11)' : `text needs ${p.need}:1 (WCAG 1.4.3)`}.${fx ? ` Nearest passing: ${fx}.` : ''}`)}`);
|
|
80
|
+
}
|
|
81
|
+
for (const c of lookAlike(r)) out.push(`::warning title=Colors look alike::${esc(`${c.a.name} and ${c.b.name} look alike with ${c.worst.label.toLowerCase()} (ΔE ${c.worst.d}). Do not let color alone tell them apart (WCAG 1.4.1).`)}`);
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sarif(results, version) {
|
|
87
|
+
const rules = [
|
|
88
|
+
{ id: 'contrast-text', name: 'TextContrast', shortDescription: { text: 'Text contrast below WCAG 2 (1.4.3)' }, helpUri: `${SITE}/guides/wcag-color-contrast`, defaultConfiguration: { level: 'error' } },
|
|
89
|
+
{ id: 'contrast-ui', name: 'NonTextContrast', shortDescription: { text: 'UI part contrast below 3:1 (WCAG 1.4.11)' }, helpUri: `${SITE}/guides/wcag-color-contrast`, defaultConfiguration: { level: 'error' } },
|
|
90
|
+
{ id: 'look-alike', name: 'ColorsLookAlike', shortDescription: { text: 'Colors that look alike with color blindness (WCAG 1.4.1)' }, helpUri: `${SITE}/color-blind-palette-checker`, defaultConfiguration: { level: 'warning' } },
|
|
91
|
+
];
|
|
92
|
+
const res = [];
|
|
93
|
+
for (const { group, report: r } of results) {
|
|
94
|
+
for (const p of failing(r)) {
|
|
95
|
+
const at = whereOf(group, r, p), fx = fixOf(r, p);
|
|
96
|
+
res.push({ ruleId: p.kind === 'ui' ? 'contrast-ui' : 'contrast-text', level: 'error',
|
|
97
|
+
message: { text: `${p.plain?.title ? `${p.plain.title}. ${p.plain.why} ${p.plain.fix ? `${p.plain.fix.text} ` : ''}(` : ''}${p.fg.name} on ${p.bg.name}${r.input.modes.length > 1 ? ` (${p.mode} mode)` : ''}: ${p.ratio}:1, needs ${p.need}:1.${fx ? ` Nearest passing: ${fx}.` : ''}${p.plain?.title ? ')' : ''}` },
|
|
98
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: rel(at.file) }, region: { startLine: at.line, startColumn: at.column } } }],
|
|
99
|
+
partialFingerprints: { pair: `${p.fg.name}|${p.bg.name}|${p.mode}` } });
|
|
100
|
+
}
|
|
101
|
+
for (const c of lookAlike(r)) {
|
|
102
|
+
const t = r.tokens.find((x) => x.name === c.a.name && x.at != null);
|
|
103
|
+
const at = t ? locate(group, t.at) : { file: group.parts[0].file, line: 1, column: 1 };
|
|
104
|
+
res.push({ ruleId: 'look-alike', level: 'warning', message: { text: `${c.a.name} and ${c.b.name} look alike with ${c.worst.label.toLowerCase()} (ΔE ${c.worst.d}).` },
|
|
105
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: rel(at.file) }, region: { startLine: at.line } } }] });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify({ $schema: 'https://json.schemastore.org/sarif-2.1.0.json', version: '2.1.0',
|
|
109
|
+
runs: [{ tool: { driver: { name: 'everyhexcode', version, informationUri: SITE + '/color-checker', rules } }, results: res }] }, null, 2);
|
|
110
|
+
}
|