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/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "everyhexcode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Check and fix the colors in your CSS, Tailwind config and design tokens: WCAG 2 contrast of every pair that meets, light and dark mode, color blindness, and the nearest passing colors as a diff. Runs locally and in your own CI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "everyhexcode": "dist/everyhexcode.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "src/",
12
+ "action.yml",
13
+ "README.md",
14
+ "LICENSE.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "license": "SEE LICENSE IN LICENSE.md",
20
+ "homepage": "https://everyhexcode.com/color-checker",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Benjaminsen/everyhexcode-cli.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/Benjaminsen/everyhexcode-cli/issues"
27
+ },
28
+ "keywords": [
29
+ "color",
30
+ "contrast",
31
+ "wcag",
32
+ "accessibility",
33
+ "a11y",
34
+ "design-tokens",
35
+ "tailwind",
36
+ "css",
37
+ "color-blindness",
38
+ "lint",
39
+ "github-action"
40
+ ],
41
+ "author": "Moltke Benjaminsen ApS <desk@everyhexcode.com>"
42
+ }
package/src/cli.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // everyhexcode: check the colors in your CSS, Tailwind config or design tokens against WCAG 2 contrast and color vision,
3
+ // fix them with the nearest passing colors, or scan a running page in your own headless Chrome. Runs on your machine
4
+ // and in your own CI; nothing is sent anywhere. Same engine as https://everyhexcode.com/color-checker.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { applyFix, check, VERSION as ENGINE } from './engine/check.js';
8
+ import { json5 } from './engine/check-input.js';
9
+ import { expand, groups, skipped } from './files.js';
10
+ import * as out from './output.js';
11
+ import { renderScan } from './render.js';
12
+
13
+ const VERSION = '0.1.0';
14
+ const HELP = `everyhexcode ${VERSION}: check and fix the colors in your code
15
+
16
+ Usage
17
+ everyhexcode check [files or folders...] check CSS, SCSS, Less, Tailwind configs and design tokens (default: .)
18
+ everyhexcode fix [files...] --write write the nearest passing colors back into the files
19
+ everyhexcode scan <url> [--dark] load a page in your own headless Chrome and check what it draws
20
+
21
+ Options
22
+ --target AA|AAA WCAG 2 level (default AA)
23
+ --format text|json|sarif|markdown|github (default text; github prints annotations)
24
+ --out <file> write the report to a file instead of stdout
25
+ --sarif <file> also write SARIF (for GitHub code scanning)
26
+ --lock <names> comma-separated names or hex codes that must not change (e.g. --lock=--primary)
27
+ --config <file> settings file (default: colorcheck.json if it exists)
28
+ --fail-on fail|warn|none exit 1 on failing pairs (default), also on look-alike colors, or never
29
+ --write with fix: change the files
30
+ --verbose list passing pairs too
31
+ --chrome <path> Chrome or Chromium to use for scan (default: CHROME_PATH or the usual install)
32
+ --width <px> viewport width for scan (default 1280)
33
+ --timeout <s> give up on a page that stops answering after this long (default 150), keeping what was read
34
+
35
+ Settings file (colorcheck.json)
36
+ { "files": ["src/styles"], "target": "AA", "lock": ["--brand"], "roles": { "--muted": "bg" },
37
+ "pairs": [["--link", "--card", "text"]], "ignore": ["--decorative"], "failOn": "fail" }
38
+
39
+ Exit codes: 0 all pass, 1 failing pairs, 2 usage or read error.
40
+ Licensed under the Functional Source License (FSL-1.1-ALv2): free for your own use, see LICENSE.md.`;
41
+
42
+ function parseArgs(argv) {
43
+ const a = { _: [] };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const x = argv[i];
46
+ if (x === '--') { a._.push(...argv.slice(i + 1)); break; }
47
+ if (x.startsWith('--')) {
48
+ const [k, v] = x.slice(2).split(/=(.*)/s);
49
+ const flag = ['write', 'verbose', 'dark', 'help', 'version', 'light'].includes(k);
50
+ a[k] = v !== undefined ? v : flag ? true : argv[++i];
51
+ } else if (x === '-h') a.help = true;
52
+ else if (x === '-v') a.version = true;
53
+ else a._.push(x);
54
+ }
55
+ return a;
56
+ }
57
+
58
+ function loadConfig(file) {
59
+ const p = file || (fs.existsSync('colorcheck.json') ? 'colorcheck.json' : null);
60
+ if (!p) return {};
61
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { throw new Error(`Could not read ${p}: ${e.message}`); }
62
+ }
63
+
64
+ function emit(results, a, version) {
65
+ const fmt = a.format || 'text';
66
+ const body = fmt === 'json' ? out.json(results) : fmt === 'sarif' ? out.sarif(results, version) : fmt === 'markdown' ? out.markdown(results) : fmt === 'github' ? out.github(results) : out.text(results, a);
67
+ if (a.out) fs.writeFileSync(a.out, body + '\n'); else if (body) process.stdout.write(body + '\n');
68
+ if (a.sarif) fs.writeFileSync(a.sarif, out.sarif(results, version) + '\n');
69
+ if (process.env.GITHUB_STEP_SUMMARY && fmt === 'github') fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, out.markdown(results) + '\n');
70
+ }
71
+
72
+ function exitCode(results, failOn) {
73
+ if (failOn === 'none') return 0;
74
+ const bad = results.some(({ report }) => out.failing(report).length);
75
+ const warn = results.some(({ report }) => out.lookAlike(report).length);
76
+ return bad || (failOn === 'warn' && warn) ? 1 : 0;
77
+ }
78
+
79
+ /** Write the fix back, file by file: each change goes into the file its value came from, in the way it was written. */
80
+ function writeBack(group, report) {
81
+ const written = [];
82
+ if (group.merged) return written; // a combined read of several files: the changes are listed for each token instead
83
+ for (const part of group.parts) {
84
+ const mine = report.fix.changes.filter((c) => c.span && c.span[0] >= part.start && c.span[1] <= part.end).map((c) => ({ ...c, span: [c.span[0] - part.start, c.span[1] - part.start] }));
85
+ if (!mine.length) continue;
86
+ const fixed = applyFix(part.text, mine, path.basename(part.file));
87
+ fs.writeFileSync(part.file, fixed.text);
88
+ written.push(`${path.relative(process.cwd(), part.file) || part.file}: ${mine.map((c) => `${c.name} #${c.from} → #${c.to}`).join(', ')}`);
89
+ }
90
+ return written;
91
+ }
92
+
93
+ async function main() {
94
+ const a = parseArgs(process.argv.slice(2));
95
+ const cmd = ['check', 'fix', 'scan'].includes(a._[0]) ? a._.shift() : 'check';
96
+ if (a.help) { console.log(HELP); return 0; }
97
+ if (a.version) { console.log(`everyhexcode ${VERSION} (engine ${ENGINE})`); return 0; }
98
+ // a value that is not one of the choices is a mistake in the command, not a reason to fall back silently
99
+ const bad = [['target', ['AA', 'AAA', 'aa', 'aaa']], ['format', ['text', 'json', 'sarif', 'markdown', 'github']], ['fail-on', ['fail', 'warn', 'none']]].find(([k, ok]) => a[k] !== undefined && !ok.includes(String(a[k])));
100
+ if (bad) { console.error(`everyhexcode: --${bad[0]} must be one of ${bad[0] === 'target' ? 'AA, AAA' : bad[1].join(', ')}`); return 2; }
101
+ const cfg = loadConfig(a.config);
102
+ const target = String(a.target || cfg.target || 'AA').toUpperCase() === 'AAA' ? 'AAA' : 'AA';
103
+ const lock = [...(cfg.lock || []), ...String(a.lock || '').split(',').filter(Boolean)];
104
+ const roles = { ...(cfg.roles || {}) };
105
+ for (const n of cfg.ignore || []) roles[n] = 'ignore';
106
+ const opts = { target, lock, roles, pairs: cfg.pairs, max: 5_000_000 };
107
+ const failOn = a['fail-on'] || cfg.failOn || 'fail';
108
+
109
+ if (cmd === 'scan') {
110
+ const url = a._[0] || cfg.url;
111
+ if (!url) { console.error('Give a URL: everyhexcode scan http://localhost:3000'); return 2; }
112
+ const modes = a.dark ? ['dark'] : a.light ? ['light'] : ['light', 'dark'];
113
+ const s = await renderScan(url, { chrome: a.chrome, width: Number(a.width) || 1280, timeout: Number(a.timeout) || 150, modes, log: (m) => process.stderr.write(m + '\n') });
114
+ const report = check(s.css, { ...opts, label: 'rendered' });
115
+ report.input.kind = 'rendered page';
116
+ const group = { label: `${s.url} (rendered)`, text: s.css, parts: [{ file: s.url, start: 0, end: s.css.length, text: s.css }] };
117
+ emit([{ group, report }], a, VERSION);
118
+ if ((a.format || 'text') === 'text') process.stderr.write(`Read ${s.elements} text elements (${s.images} over images or gradients) in ${modes.join(' and ')} mode.\n${s.notes.map((n) => `Note: ${n}\n`).join('')}`);
119
+ report.input.warnings.push(...s.notes);
120
+ return exitCode([{ report }], failOn);
121
+ }
122
+
123
+ const files = expand(a._.length ? a._ : cfg.files || ['.']);
124
+ if (!files.length) { console.error('No CSS, SCSS, Less, token or Tailwind config files found.'); return 2; }
125
+ let results = groups(files).map((group) => ({ group, report: check(group.text, { ...opts, label: path.basename(group.label) }) }));
126
+ // token files that point into each other (theme.json → color.json) are read together; fixes are then listed, not written
127
+ const tokenJson = results.filter(({ group }) => /\.json5?$/i.test(group.label));
128
+ if (tokenJson.length > 1 && tokenJson.some(({ report }) => report.input.warnings.some((w) => /another file/.test(w)))) {
129
+ const merge = (a, b) => { for (const [k, v] of Object.entries(b)) a[k] = v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object' ? merge(a[k], v) : v; return a; };
130
+ let all = {};
131
+ for (const { group } of tokenJson) { try { all = merge(all, JSON.parse(group.text)); } catch { try { all = merge(all, JSON.parse(json5(group.text))); } catch { /* not JSON */ } } }
132
+ const text = JSON.stringify(all, null, 2);
133
+ const group = { label: `${tokenJson.length} token files together`, text, parts: [{ file: tokenJson[0].group.parts[0].file, start: 0, end: text.length, text }], merged: true };
134
+ results = [...results.filter((x) => !tokenJson.includes(x)), { group, report: check(text, { ...opts, label: 'tokens' }) }];
135
+ }
136
+ for (const { group, report } of results) if (!report.input.colors && report.input.warnings.length) console.error(`${path.relative(process.cwd(), group.label) || group.label}: ${report.input.warnings.join(' ')}`);
137
+ results = results.filter(({ report }) => report.input.colors);
138
+ for (const x of skipped) console.error(`everyhexcode: skipped ${x}`);
139
+ if (!results.length) { console.error(`No colors found in ${files.length} file(s)${skipped.length ? ` (${skipped.length} skipped)` : ''}: nothing to check.`); return skipped.length ? 2 : 0; }
140
+ if (cmd === 'fix' && a.write) {
141
+ const lines = results.flatMap(({ group, report }) => writeBack(group, report));
142
+ // values with no place in the text (Figma variables, values reached through an alias written as hsl(var()))
143
+ const unwritten = results.flatMap(({ group, report }) => report.fix.changes.filter((c) => !c.span || group.merged).map((c) => `${c.name}${report.input.modes.length > 1 ? ` (${c.mode})` : ''}: #${c.from} → #${c.to}`));
144
+ console.log(lines.length ? `Changed:\n${lines.map((l) => ' ' + l).join('\n')}` : unwritten.length ? 'No file text to change.' : 'Nothing to change.');
145
+ if (unwritten.length) console.log(`Change these where they are defined (they have no value in the file text):\n${unwritten.map((l) => ' ' + l).join('\n')}`);
146
+ const again = groups(files).map((group) => ({ group, report: check(group.text, { ...opts, label: path.basename(group.label) }) }));
147
+ emit(again, a, VERSION);
148
+ return exitCode(again, failOn);
149
+ }
150
+ if (cmd === 'fix' && (a.format || 'text') === 'text') {
151
+ for (const { report } of results) if (report.fix.diff) process.stdout.write(report.fix.diff + '\n');
152
+ console.error('Run with --write to change the files.');
153
+ return exitCode(results, failOn);
154
+ }
155
+ emit(results, a, VERSION);
156
+ return exitCode(results, failOn);
157
+ }
158
+
159
+ process.stdout.on('error', (e) => { if (e.code === 'EPIPE') process.exit(process.exitCode || 0); throw e; });
160
+ main().then((code) => { process.exitCode = code; }, (e) => { console.error(`everyhexcode: ${e.message}`); process.exitCode = 2; });
@@ -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 = () => '';