@josueavalosjim/taste-check 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 +235 -0
- package/bin/taste-check.mjs +90 -0
- package/package.json +49 -0
- package/schema/config.schema.json +118 -0
- package/src/color.mjs +140 -0
- package/src/config.mjs +175 -0
- package/src/contrast.mjs +110 -0
- package/src/css.mjs +149 -0
- package/src/files.mjs +81 -0
- package/src/index.mjs +29 -0
- package/src/report.mjs +80 -0
- package/src/treatments.mjs +229 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading and validation.
|
|
3
|
+
*
|
|
4
|
+
* Validation is strict on purpose, including rejecting keys it does not know.
|
|
5
|
+
* A misspelled `pairs` key leaves the contrast check with nothing to measure
|
|
6
|
+
* while the run still goes green, and the config still looks correct on the
|
|
7
|
+
* page. The same goes for a pair naming a theme that does not exist: it would
|
|
8
|
+
* match no themes and never be measured.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
+
|
|
15
|
+
function stringArray(value, where, errors, { required = true } = {}) {
|
|
16
|
+
if (value === undefined) {
|
|
17
|
+
if (required) errors.push(`${where} is required`);
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
21
|
+
errors.push(`${where} must be an array of strings`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
if (required && !value.length) {
|
|
25
|
+
errors.push(`${where} must not be empty. An empty list narrows the run to nothing.`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rejectUnknown(object, allowed, where, errors) {
|
|
31
|
+
for (const key of Object.keys(object)) {
|
|
32
|
+
if (!allowed.includes(key)) {
|
|
33
|
+
errors.push(`${where} has an unknown key "${key}". Allowed: ${allowed.join(', ')}.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateContrast(contrast, errors) {
|
|
39
|
+
rejectUnknown(contrast, ['tokens', 'themes', 'pairs'], 'contrast', errors);
|
|
40
|
+
stringArray(contrast.tokens, 'contrast.tokens', errors);
|
|
41
|
+
|
|
42
|
+
const names = new Set();
|
|
43
|
+
if (!Array.isArray(contrast.themes) || !contrast.themes.length) {
|
|
44
|
+
errors.push('contrast.themes must be a non-empty array');
|
|
45
|
+
} else {
|
|
46
|
+
contrast.themes.forEach((theme, i) => {
|
|
47
|
+
const where = `contrast.themes[${i}]`;
|
|
48
|
+
if (!isPlainObject(theme)) {
|
|
49
|
+
errors.push(`${where} must be an object`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
rejectUnknown(theme, ['name', 'scopes'], where, errors);
|
|
53
|
+
if (typeof theme.name !== 'string' || !theme.name) errors.push(`${where}.name must be a string`);
|
|
54
|
+
else if (names.has(theme.name)) errors.push(`${where}.name "${theme.name}" is used twice`);
|
|
55
|
+
else names.add(theme.name);
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(theme.scopes) || !theme.scopes.length) {
|
|
58
|
+
errors.push(`${where}.scopes must be a non-empty array`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
theme.scopes.forEach((scope, j) => {
|
|
62
|
+
if (typeof scope === 'string') return;
|
|
63
|
+
if (!isPlainObject(scope)) {
|
|
64
|
+
errors.push(`${where}.scopes[${j}] must be a selector string or an object`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
rejectUnknown(scope, ['selector', 'atRule'], `${where}.scopes[${j}]`, errors);
|
|
68
|
+
if (typeof scope.selector !== 'string' || !scope.selector) {
|
|
69
|
+
errors.push(`${where}.scopes[${j}].selector must be a string`);
|
|
70
|
+
}
|
|
71
|
+
if (scope.atRule !== undefined && typeof scope.atRule !== 'string') {
|
|
72
|
+
errors.push(`${where}.scopes[${j}].atRule must be a string`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Array.isArray(contrast.pairs) || !contrast.pairs.length) {
|
|
79
|
+
errors.push('contrast.pairs must be a non-empty array. With no pairs there is nothing to measure.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
contrast.pairs.forEach((pair, i) => {
|
|
83
|
+
const where = `contrast.pairs[${i}]`;
|
|
84
|
+
if (!isPlainObject(pair)) {
|
|
85
|
+
errors.push(`${where} must be an object`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
rejectUnknown(pair, ['fg', 'bg', 'min', 'label', 'themes'], where, errors);
|
|
89
|
+
for (const key of ['fg', 'bg']) {
|
|
90
|
+
if (typeof pair[key] !== 'string' || !pair[key]) {
|
|
91
|
+
errors.push(`${where}.${key} must be a token name or a colour`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (typeof pair.min !== 'number' || !Number.isFinite(pair.min) || pair.min <= 0) {
|
|
95
|
+
errors.push(
|
|
96
|
+
`${where}.min must be a positive number. There is no default: WCAG's 4.5 and 3.0 are ` +
|
|
97
|
+
`documented in the README, never assumed here.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (pair.label !== undefined && typeof pair.label !== 'string') {
|
|
101
|
+
errors.push(`${where}.label must be a string`);
|
|
102
|
+
}
|
|
103
|
+
if (pair.themes !== undefined) {
|
|
104
|
+
const listed = stringArray(pair.themes, `${where}.themes`, errors);
|
|
105
|
+
for (const name of listed) {
|
|
106
|
+
if (names.size && !names.has(name)) {
|
|
107
|
+
errors.push(
|
|
108
|
+
`${where}.themes names "${name}", which is not a theme in contrast.themes. ` +
|
|
109
|
+
`A pair scoped to a theme that does not exist is never measured.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateTreatments(treatments, errors) {
|
|
118
|
+
rejectUnknown(
|
|
119
|
+
treatments,
|
|
120
|
+
['files', 'elements', 'approvedClasses', 'allowPrefixes', 'approvedValues'],
|
|
121
|
+
'treatments',
|
|
122
|
+
errors,
|
|
123
|
+
);
|
|
124
|
+
stringArray(treatments.files, 'treatments.files', errors);
|
|
125
|
+
stringArray(treatments.elements, 'treatments.elements', errors, { required: false });
|
|
126
|
+
stringArray(treatments.approvedClasses, 'treatments.approvedClasses', errors, { required: false });
|
|
127
|
+
stringArray(treatments.allowPrefixes, 'treatments.allowPrefixes', errors, { required: false });
|
|
128
|
+
stringArray(treatments.approvedValues, 'treatments.approvedValues', errors, { required: false });
|
|
129
|
+
if (treatments.elements !== undefined && Array.isArray(treatments.elements) && !treatments.elements.length) {
|
|
130
|
+
errors.push('treatments.elements must not be empty. Omit it to scan every element.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Validate a parsed config, returning a list of human-readable problems. */
|
|
135
|
+
export function validate(config) {
|
|
136
|
+
const errors = [];
|
|
137
|
+
if (!isPlainObject(config)) return ['the config must be a JSON object'];
|
|
138
|
+
rejectUnknown(config, ['$schema', 'contrast', 'treatments'], 'the config', errors);
|
|
139
|
+
|
|
140
|
+
if (config.contrast === undefined && config.treatments === undefined) {
|
|
141
|
+
errors.push('the config must define "contrast", "treatments", or both');
|
|
142
|
+
}
|
|
143
|
+
if (config.contrast !== undefined) {
|
|
144
|
+
if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
|
|
145
|
+
else errors.push('contrast must be an object');
|
|
146
|
+
}
|
|
147
|
+
if (config.treatments !== undefined) {
|
|
148
|
+
if (isPlainObject(config.treatments)) validateTreatments(config.treatments, errors);
|
|
149
|
+
else errors.push('treatments must be an object');
|
|
150
|
+
}
|
|
151
|
+
return errors;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read and validate a config file. Paths inside it resolve against the file's
|
|
156
|
+
* own directory, so a config is portable and can be run from anywhere.
|
|
157
|
+
*/
|
|
158
|
+
export function load(path) {
|
|
159
|
+
const file = resolve(path);
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = readFileSync(file, 'utf8');
|
|
163
|
+
} catch {
|
|
164
|
+
return { ok: false, errors: [`cannot read ${path}`] };
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { ok: false, errors: [`${path} is not valid JSON: ${error.message}`] };
|
|
171
|
+
}
|
|
172
|
+
const errors = validate(parsed);
|
|
173
|
+
if (errors.length) return { ok: false, errors };
|
|
174
|
+
return { ok: true, config: parsed, dir: dirname(file), file };
|
|
175
|
+
}
|
package/src/contrast.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG contrast, measured over your own tokens.
|
|
3
|
+
*
|
|
4
|
+
* The technique this ports was originally a Playwright test that read colours
|
|
5
|
+
* off a running page with getComputedStyle, for a good reason: a token file
|
|
6
|
+
* cannot tell you what is actually painted behind an element. That check
|
|
7
|
+
* existed because a comment in a stylesheet claimed a grey "clears 4.5:1 on
|
|
8
|
+
* white" while the page shipped on an off-white, and nothing re-checked it.
|
|
9
|
+
* The value passed by a fifth of a point, by memory, with no test to say so
|
|
10
|
+
* when the background moved.
|
|
11
|
+
*
|
|
12
|
+
* This module is the static half of that idea: it re-derives the ratio from
|
|
13
|
+
* the tokens, which is exactly what the original warned against, and is the
|
|
14
|
+
* honest trade for a tool that runs anywhere with no browser. What it keeps is
|
|
15
|
+
* the arithmetic, the alpha compositing, and the rule that a pair naming a
|
|
16
|
+
* token which does not exist is a failure rather than a skip. See "What this
|
|
17
|
+
* does not do" in the README before trusting a pass.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { contrastRatio, isOpaque, parseColor } from './color.mjs';
|
|
21
|
+
import { parseDeclarations, resolveScopes, resolveValue } from './css.mjs';
|
|
22
|
+
import { expand, label } from './files.mjs';
|
|
23
|
+
|
|
24
|
+
/** A token name, or a literal colour, resolved to rgba for one theme. */
|
|
25
|
+
function side(spec, table, theme) {
|
|
26
|
+
if (spec.startsWith('--')) {
|
|
27
|
+
const resolved = resolveValue(table, spec);
|
|
28
|
+
if (!resolved.ok) return { ok: false, reason: `theme "${theme}": ${resolved.reason}` };
|
|
29
|
+
const color = parseColor(resolved.value);
|
|
30
|
+
if (!color.ok) return { ok: false, reason: `theme "${theme}": ${spec} is ${color.reason}` };
|
|
31
|
+
return { ok: true, rgba: color.rgba };
|
|
32
|
+
}
|
|
33
|
+
const color = parseColor(spec);
|
|
34
|
+
if (!color.ok) return { ok: false, reason: `theme "${theme}": ${color.reason}` };
|
|
35
|
+
return { ok: true, rgba: color.rgba };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runContrast(config, cwd) {
|
|
39
|
+
const samples = [];
|
|
40
|
+
const problems = [];
|
|
41
|
+
const { tokens, themes, pairs } = config;
|
|
42
|
+
|
|
43
|
+
const files = expand(tokens, cwd);
|
|
44
|
+
if (!files.length) {
|
|
45
|
+
problems.push(`no token files matched ${tokens.map((t) => `"${t}"`).join(', ')}`);
|
|
46
|
+
return { name: 'contrast', samples, problems, summary: '' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One declaration list across every token file, in the order they were
|
|
50
|
+
// listed, so a later file overriding an earlier one behaves like a later
|
|
51
|
+
// @import would.
|
|
52
|
+
const decls = files.flatMap((file) => parseDeclarations(readFileSync(file, 'utf8')));
|
|
53
|
+
|
|
54
|
+
for (const theme of themes) {
|
|
55
|
+
const table = resolveScopes(decls, theme.scopes);
|
|
56
|
+
if (!table.size) {
|
|
57
|
+
problems.push(
|
|
58
|
+
`theme "${theme.name}" resolved zero tokens. Check its scopes (${theme.scopes
|
|
59
|
+
.map((s) => (typeof s === 'string' ? s : s.selector))
|
|
60
|
+
.join(', ')}) against ${files.map((f) => label(f, cwd)).join(', ')}.`,
|
|
61
|
+
);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const pair of pairs) {
|
|
66
|
+
if (pair.themes && !pair.themes.includes(theme.name)) continue;
|
|
67
|
+
const where = `${pair.fg} on ${pair.bg}`;
|
|
68
|
+
|
|
69
|
+
const bg = side(pair.bg, table, theme.name);
|
|
70
|
+
if (!bg.ok) {
|
|
71
|
+
problems.push(`${where}: ${bg.reason}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// A ground with alpha is not a ground. The original walked up the DOM to
|
|
75
|
+
// the first opaque ancestor; there is no DOM here, so the config has to
|
|
76
|
+
// name a surface that is actually opaque.
|
|
77
|
+
if (!isOpaque(bg.rgba)) {
|
|
78
|
+
problems.push(
|
|
79
|
+
`${where}: theme "${theme.name}": ${pair.bg} is translucent, so there is nothing ` +
|
|
80
|
+
`definite to measure against. Name the opaque surface behind it instead.`,
|
|
81
|
+
);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fg = side(pair.fg, table, theme.name);
|
|
86
|
+
if (!fg.ok) {
|
|
87
|
+
problems.push(`${where}: ${fg.reason}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ratio = contrastRatio(fg.rgba, bg.rgba);
|
|
92
|
+
samples.push({
|
|
93
|
+
theme: theme.name,
|
|
94
|
+
fg: pair.fg,
|
|
95
|
+
bg: pair.bg,
|
|
96
|
+
note: pair.label ?? '',
|
|
97
|
+
ratio,
|
|
98
|
+
min: pair.min,
|
|
99
|
+
pass: ratio >= pair.min,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: 'contrast',
|
|
106
|
+
samples,
|
|
107
|
+
problems,
|
|
108
|
+
summary: `${samples.length} pairs across ${themes.length} ${themes.length === 1 ? 'theme' : 'themes'}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/css.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-property extraction from a stylesheet, and theme resolution.
|
|
3
|
+
*
|
|
4
|
+
* This is not a CSS parser. It is a walker that tracks brace depth and quote
|
|
5
|
+
* state well enough to answer one question: for a given theme, what is the
|
|
6
|
+
* final value of each `--token`? A regex over a stylesheet works right up
|
|
7
|
+
* until a value contains a brace or a selector contains a quote, at which
|
|
8
|
+
* point it stops seeing declarations and reports the file as fine.
|
|
9
|
+
*
|
|
10
|
+
* The cascade model is deliberately small and written down rather than
|
|
11
|
+
* implied. Scopes are applied in the order the config lists them and the last
|
|
12
|
+
* declaration wins. There is no specificity resolution: if your token file
|
|
13
|
+
* relies on `.a.b` beating `.b`, list the scopes in the order you want them
|
|
14
|
+
* applied and the result is the one you asked for.
|
|
15
|
+
*
|
|
16
|
+
* Declarations inside an at-rule are ignored unless a scope opts into that
|
|
17
|
+
* at-rule by name. Without that rule, a `@media (prefers-color-scheme: dark)`
|
|
18
|
+
* block containing `:root` would silently overwrite the light theme, and the
|
|
19
|
+
* light theme would be checked against colours it never paints.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Blank out comments, keeping every offset and newline so lines stay true. */
|
|
23
|
+
function blankComments(css) {
|
|
24
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const normalize = (selector) => selector.replace(/\s+/g, ' ').replace(/'/g, '"').trim();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every custom-property declaration in the source, with the selector it sits
|
|
31
|
+
* under and the at-rules it is nested inside.
|
|
32
|
+
*/
|
|
33
|
+
export function parseDeclarations(css) {
|
|
34
|
+
const source = blankComments(css);
|
|
35
|
+
const decls = [];
|
|
36
|
+
const stack = [];
|
|
37
|
+
let buffer = '';
|
|
38
|
+
let bufferStart = 0;
|
|
39
|
+
let quote = null;
|
|
40
|
+
|
|
41
|
+
const flush = (end) => {
|
|
42
|
+
const text = buffer.trim();
|
|
43
|
+
buffer = '';
|
|
44
|
+
if (!text) return;
|
|
45
|
+
const colon = text.indexOf(':');
|
|
46
|
+
if (colon === -1) return;
|
|
47
|
+
const prop = text.slice(0, colon).trim();
|
|
48
|
+
if (!prop.startsWith('--')) return;
|
|
49
|
+
const selector = [...stack].reverse().find((s) => !s.startsWith('@')) ?? '';
|
|
50
|
+
decls.push({
|
|
51
|
+
prop,
|
|
52
|
+
value: text.slice(colon + 1).trim(),
|
|
53
|
+
selector,
|
|
54
|
+
atRules: stack.filter((s) => s.startsWith('@')),
|
|
55
|
+
index: bufferStart + (source.slice(bufferStart, end).length - source.slice(bufferStart, end).trimStart().length),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
60
|
+
const c = source[i];
|
|
61
|
+
if (quote) {
|
|
62
|
+
buffer += c;
|
|
63
|
+
if (c === '\\') {
|
|
64
|
+
buffer += source[i + 1] ?? '';
|
|
65
|
+
i += 1;
|
|
66
|
+
} else if (c === quote) quote = null;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (c === '"' || c === "'") {
|
|
70
|
+
quote = c;
|
|
71
|
+
buffer += c;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === '{') {
|
|
75
|
+
stack.push(buffer.trim().replace(/\s+/g, ' '));
|
|
76
|
+
buffer = '';
|
|
77
|
+
bufferStart = i + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === '}') {
|
|
81
|
+
flush(i);
|
|
82
|
+
stack.pop();
|
|
83
|
+
bufferStart = i + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (c === ';') {
|
|
87
|
+
flush(i);
|
|
88
|
+
bufferStart = i + 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!buffer) bufferStart = i;
|
|
92
|
+
buffer += c;
|
|
93
|
+
}
|
|
94
|
+
return decls;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Does a declaration's selector list contain this exact scope selector? */
|
|
98
|
+
function selectorMatches(list, scope) {
|
|
99
|
+
const want = normalize(scope);
|
|
100
|
+
return list.split(',').some((part) => normalize(part) === want);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The token table for one theme: a Map of `--name` to { value, selector }.
|
|
105
|
+
* Scopes are applied in order, later winning.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveScopes(decls, scopes) {
|
|
108
|
+
const table = new Map();
|
|
109
|
+
for (const scope of scopes) {
|
|
110
|
+
const selector = typeof scope === 'string' ? scope : scope.selector;
|
|
111
|
+
const atRule = typeof scope === 'string' ? null : (scope.atRule ?? null);
|
|
112
|
+
for (const d of decls) {
|
|
113
|
+
if (!selectorMatches(d.selector, selector)) continue;
|
|
114
|
+
if (atRule === null) {
|
|
115
|
+
if (d.atRules.length) continue;
|
|
116
|
+
} else if (!d.atRules.some((a) => a.includes(atRule))) continue;
|
|
117
|
+
table.set(d.prop, d);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return table;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Follow `var(--other)` indirection to a concrete value.
|
|
125
|
+
*
|
|
126
|
+
* A cycle, or a var() pointing at a token this theme does not define, returns
|
|
127
|
+
* an error rather than the unresolved text: an unresolved value would reach
|
|
128
|
+
* the colour parser and fail there with a worse message.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveValue(table, name, seen = new Set()) {
|
|
131
|
+
if (seen.has(name)) {
|
|
132
|
+
return { ok: false, reason: `${name} resolves in a circle (${[...seen, name].join(' -> ')})` };
|
|
133
|
+
}
|
|
134
|
+
const decl = table.get(name);
|
|
135
|
+
if (!decl) return { ok: false, reason: `${name} is not defined in this theme` };
|
|
136
|
+
|
|
137
|
+
const match = decl.value.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/);
|
|
138
|
+
if (!match) return { ok: true, value: decl.value, decl };
|
|
139
|
+
|
|
140
|
+
const target = match[1];
|
|
141
|
+
const fallback = match[2]?.trim();
|
|
142
|
+
const next = resolveValue(table, target, new Set([...seen, name]));
|
|
143
|
+
if (next.ok) return next;
|
|
144
|
+
if (fallback) return { ok: true, value: fallback, decl };
|
|
145
|
+
return { ok: false, reason: `${name} points at ${target}, which ${next.reason.replace(/^.*? /, '')}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 1-indexed line of a source offset, for error messages. */
|
|
149
|
+
export const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small glob, so the tool keeps zero dependencies and behaves the same on
|
|
3
|
+
* every Node it claims to support.
|
|
4
|
+
*
|
|
5
|
+
* Supports the shapes a config actually uses: a literal path, a single-star
|
|
6
|
+
* pattern like `dir/*.ext`, and a double-star pattern matching any depth.
|
|
7
|
+
* Brace expansion is not supported. List the patterns separately.
|
|
8
|
+
*
|
|
9
|
+
* A pattern that matches nothing returns an empty list. Every caller treats
|
|
10
|
+
* that as a failure rather than a clean run, so a typo in a path is reported
|
|
11
|
+
* where it happened instead of showing up as zero findings.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const ESCAPE = /[.+^${}()|[\]\\]/g;
|
|
17
|
+
|
|
18
|
+
function toRegExp(pattern) {
|
|
19
|
+
let out = '';
|
|
20
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
21
|
+
const c = pattern[i];
|
|
22
|
+
if (c === '*') {
|
|
23
|
+
if (pattern.slice(i, i + 3) === '**/') {
|
|
24
|
+
out += '(?:[^/]*/)*';
|
|
25
|
+
i += 2;
|
|
26
|
+
} else if (pattern.slice(i, i + 2) === '**') {
|
|
27
|
+
out += '.*';
|
|
28
|
+
i += 1;
|
|
29
|
+
} else out += '[^/]*';
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (c === '?') {
|
|
33
|
+
out += '[^/]';
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out += c.replace(ESCAPE, '\\$&');
|
|
37
|
+
}
|
|
38
|
+
return new RegExp(`^${out}$`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The longest wildcard-free directory prefix, so we only walk what we must. */
|
|
42
|
+
function baseOf(pattern) {
|
|
43
|
+
const parts = pattern.split('/');
|
|
44
|
+
const stop = parts.findIndex((p) => p.includes('*') || p.includes('?'));
|
|
45
|
+
return (stop === -1 ? parts.slice(0, -1) : parts.slice(0, stop)).join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walk(dir, out) {
|
|
49
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
51
|
+
const path = join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) walk(path, out);
|
|
53
|
+
else out.push(path);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Absolute paths matching the patterns, relative to `cwd`, sorted and
|
|
60
|
+
* de-duplicated so a run is reproducible and a report is diffable.
|
|
61
|
+
*/
|
|
62
|
+
export function expand(patterns, cwd) {
|
|
63
|
+
const found = new Set();
|
|
64
|
+
for (const pattern of patterns) {
|
|
65
|
+
const absolute = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
|
|
66
|
+
if (!/[*?]/.test(pattern)) {
|
|
67
|
+
if (existsSync(absolute) && statSync(absolute).isFile()) found.add(absolute);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const base = resolve(cwd, baseOf(pattern));
|
|
71
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) continue;
|
|
72
|
+
const test = toRegExp(isAbsolute(pattern) ? pattern : resolve(cwd, pattern).split(sep).join('/'));
|
|
73
|
+
for (const file of walk(base, [])) {
|
|
74
|
+
if (test.test(file.split(sep).join('/'))) found.add(file);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...found].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A path as you would paste it into an editor. */
|
|
81
|
+
export const label = (path, cwd) => relative(cwd, path).split(sep).join('/') || path;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The programmatic entry point, for wiring the checks into a test runner or a
|
|
3
|
+
* CI script without going through the CLI.
|
|
4
|
+
*/
|
|
5
|
+
export { runContrast } from './contrast.mjs';
|
|
6
|
+
export { runTreatments } from './treatments.mjs';
|
|
7
|
+
export { load, validate } from './config.mjs';
|
|
8
|
+
export { toText, toJson, failed } from './report.mjs';
|
|
9
|
+
export { parseColor, contrastRatio, composite, luminance } from './color.mjs';
|
|
10
|
+
export { parseDeclarations, resolveScopes, resolveValue } from './css.mjs';
|
|
11
|
+
export { openTags, classesOf } from './treatments.mjs';
|
|
12
|
+
|
|
13
|
+
import { runContrast } from './contrast.mjs';
|
|
14
|
+
import { runTreatments } from './treatments.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run the checks a config asks for. `only` narrows to one check by name.
|
|
18
|
+
* Returns the raw results; formatting and exit codes are the caller's.
|
|
19
|
+
*/
|
|
20
|
+
export function run(config, cwd, { only = null } = {}) {
|
|
21
|
+
const results = [];
|
|
22
|
+
if (config.contrast && (!only || only === 'contrast')) {
|
|
23
|
+
results.push(runContrast(config.contrast, cwd));
|
|
24
|
+
}
|
|
25
|
+
if (config.treatments && (!only || only === 'treatments')) {
|
|
26
|
+
results.push(runTreatments(config.treatments, cwd));
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting.
|
|
3
|
+
*
|
|
4
|
+
* Passing samples are printed too, with their ratios. Knowing a pair cleared
|
|
5
|
+
* 4.52 against a floor of 4.5 is worth more than knowing it cleared, because
|
|
6
|
+
* the next change to the background takes it under.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const pad = (text, width) => String(text).padStart(width);
|
|
10
|
+
|
|
11
|
+
// Always show a decimal, so a floor of 3 reads as 3.0 next to a 4.5 and the
|
|
12
|
+
// column stays scannable. Never round: 4.55 must not print as 4.6.
|
|
13
|
+
const showMin = (min) => (Number.isInteger(min) ? min.toFixed(1) : String(min));
|
|
14
|
+
|
|
15
|
+
function contrastLines(result) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
const widest = Math.max(0, ...result.samples.map((s) => s.ratio.toFixed(2).length));
|
|
18
|
+
for (const s of result.samples) {
|
|
19
|
+
const note = s.note ? ` ${s.note}` : '';
|
|
20
|
+
lines.push({
|
|
21
|
+
level: s.pass ? 'ok' : 'fail',
|
|
22
|
+
text: `${pad(s.ratio.toFixed(2), widest)}:1 needs ${showMin(s.min)} ${s.theme} ${s.fg} on ${s.bg}${note}`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
for (const p of result.problems) lines.push({ level: 'error', text: p });
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function treatmentLines(result) {
|
|
30
|
+
return [
|
|
31
|
+
...result.failures.map((text) => ({ level: 'fail', text })),
|
|
32
|
+
...result.problems.map((text) => ({ level: 'error', text })),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const linesFor = (result) =>
|
|
37
|
+
result.name === 'contrast' ? contrastLines(result) : treatmentLines(result);
|
|
38
|
+
|
|
39
|
+
const MARK = { ok: ' ok ', ' fail': 'FAIL ', fail: 'FAIL ', error: 'ERROR ' };
|
|
40
|
+
|
|
41
|
+
export function toText(results) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const result of results) {
|
|
44
|
+
const lines = linesFor(result);
|
|
45
|
+
const bad = lines.filter((l) => l.level !== 'ok');
|
|
46
|
+
if (!bad.length) {
|
|
47
|
+
out.push(`${result.name} ok, ${result.summary}`);
|
|
48
|
+
// A clean contrast run still shows its margins. Nothing else in the
|
|
49
|
+
// report tells you which pair is one nudge away from failing.
|
|
50
|
+
if (result.name === 'contrast') for (const l of lines) out.push(` ${MARK.ok}${l.text}`);
|
|
51
|
+
out.push('');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.push(`${result.name} FAILED`);
|
|
55
|
+
for (const l of lines) out.push(` ${MARK[l.level]}${l.text}`);
|
|
56
|
+
out.push('');
|
|
57
|
+
}
|
|
58
|
+
return out.join('\n').trimEnd();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const failed = (results) =>
|
|
62
|
+
results.some((r) => linesFor(r).some((l) => l.level !== 'ok'));
|
|
63
|
+
|
|
64
|
+
export function toJson(results) {
|
|
65
|
+
return JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
ok: !failed(results),
|
|
68
|
+
checks: results.map((r) => ({
|
|
69
|
+
name: r.name,
|
|
70
|
+
summary: r.summary,
|
|
71
|
+
ok: !linesFor(r).some((l) => l.level !== 'ok'),
|
|
72
|
+
samples: r.samples ?? [],
|
|
73
|
+
failures: r.failures ?? [],
|
|
74
|
+
problems: r.problems ?? [],
|
|
75
|
+
})),
|
|
76
|
+
},
|
|
77
|
+
null,
|
|
78
|
+
2,
|
|
79
|
+
);
|
|
80
|
+
}
|