@josueavalosjim/taste-check 0.11.0 → 0.12.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/README.md +5 -1
- package/package.json +1 -1
- package/src/contrast.mjs +8 -3
- package/src/css.mjs +30 -10
- package/src/files.mjs +24 -5
- package/src/judge.mjs +16 -2
- package/src/runtime.mjs +12 -4
- package/src/tokens.mjs +12 -5
- package/src/treatments.mjs +16 -6
package/README.md
CHANGED
|
@@ -637,12 +637,16 @@ publisher over OIDC, so no npm token is stored in the repository and every
|
|
|
637
637
|
release carries a provenance attestation. For a minor or major bump, run
|
|
638
638
|
`npm version minor` or `npm version major` and `git push --follow-tags`.
|
|
639
639
|
|
|
640
|
-
|
|
640
|
+
183 tests. Most of them plant a violation into a fixture that was passing a
|
|
641
641
|
moment earlier and demand it gets caught: a token darkened below its floor, an
|
|
642
642
|
unapproved class added to a clean file, a class buried in a template literal
|
|
643
643
|
hole, a fabricated token hidden in a ternary, a file pattern pointed at
|
|
644
644
|
nothing.
|
|
645
645
|
|
|
646
|
+
The count is not the point and is easy to inflate. What the suite is actually
|
|
647
|
+
worth is measured by breaking the source on purpose and checking the right
|
|
648
|
+
test goes red, which is where the 0.12.0 fixes came from.
|
|
649
|
+
|
|
646
650
|
## License
|
|
647
651
|
|
|
648
652
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josueavalosjim/taste-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
package/src/contrast.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { readFileSync } from 'node:fs';
|
|
20
20
|
import { contrastRatio, isOpaque, parseColor } from './color.mjs';
|
|
21
21
|
import { lineAt, parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { expandEach, label } from './files.mjs';
|
|
23
23
|
|
|
24
24
|
/** A token name, or a literal colour, resolved to rgba for one theme. */
|
|
25
25
|
function side(spec, table, theme) {
|
|
@@ -40,9 +40,14 @@ export function runContrast(config, cwd) {
|
|
|
40
40
|
const problems = [];
|
|
41
41
|
const { tokens, themes, pairs } = config;
|
|
42
42
|
|
|
43
|
-
const files =
|
|
43
|
+
const { files, empty: deadPatterns } = expandEach(tokens, cwd);
|
|
44
|
+
// Per pattern. A dead one beside a live one leaves the union non-empty, so
|
|
45
|
+
// a renamed token file stops being read without anything saying so.
|
|
46
|
+
for (const p of deadPatterns) problems.push(`no token files matched "${p}"`);
|
|
44
47
|
if (!files.length) {
|
|
45
|
-
|
|
48
|
+
if (!deadPatterns.length) {
|
|
49
|
+
problems.push(`no token files matched ${tokens.map((t) => `"${t}"`).join(', ')}`);
|
|
50
|
+
}
|
|
46
51
|
return { name: 'contrast', samples, problems, summary: '' };
|
|
47
52
|
}
|
|
48
53
|
|
package/src/css.mjs
CHANGED
|
@@ -39,11 +39,6 @@
|
|
|
39
39
|
*/
|
|
40
40
|
const CONDITIONAL = /^@(media|supports|container|scope|document)\b/;
|
|
41
41
|
|
|
42
|
-
/** Blank out comments, keeping every offset and newline so lines stay true. */
|
|
43
|
-
function blankComments(css) {
|
|
44
|
-
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
42
|
const normalize = (selector) => selector.replace(/\s+/g, ' ').replace(/'/g, '"').trim();
|
|
48
43
|
|
|
49
44
|
/**
|
|
@@ -51,16 +46,25 @@ const normalize = (selector) => selector.replace(/\s+/g, ' ').replace(/'/g, '"')
|
|
|
51
46
|
* under and the at-rules it is nested inside.
|
|
52
47
|
*/
|
|
53
48
|
export function parseDeclarations(css) {
|
|
54
|
-
const source =
|
|
49
|
+
const source = css;
|
|
55
50
|
const decls = [];
|
|
56
51
|
const stack = [];
|
|
57
52
|
let buffer = '';
|
|
58
53
|
let bufferStart = 0;
|
|
54
|
+
/* Offset of the first non-whitespace character in the buffer, recorded as
|
|
55
|
+
it is read rather than derived afterwards by trimming. Deriving it worked
|
|
56
|
+
only while comments were blanked to spaces up front: once they are stepped
|
|
57
|
+
over instead, the source still holds the comment text, so trimming stops
|
|
58
|
+
at the "/" and every declaration under a comment reported the comment's
|
|
59
|
+
line as its own. */
|
|
60
|
+
let ink = -1;
|
|
59
61
|
let quote = null;
|
|
60
62
|
|
|
61
|
-
const flush = (
|
|
63
|
+
const flush = () => {
|
|
62
64
|
const text = buffer.trim();
|
|
63
65
|
buffer = '';
|
|
66
|
+
const at = ink;
|
|
67
|
+
ink = -1;
|
|
64
68
|
if (!text) return;
|
|
65
69
|
const colon = text.indexOf(':');
|
|
66
70
|
if (colon === -1) return;
|
|
@@ -77,13 +81,14 @@ export function parseDeclarations(css) {
|
|
|
77
81
|
value,
|
|
78
82
|
selector,
|
|
79
83
|
atRules: stack.filter((s) => s.startsWith('@')),
|
|
80
|
-
index:
|
|
84
|
+
index: at === -1 ? bufferStart : at,
|
|
81
85
|
});
|
|
82
86
|
};
|
|
83
87
|
|
|
84
88
|
for (let i = 0; i < source.length; i += 1) {
|
|
85
89
|
const c = source[i];
|
|
86
90
|
if (quote) {
|
|
91
|
+
if (ink === -1) ink = i;
|
|
87
92
|
buffer += c;
|
|
88
93
|
if (c === '\\') {
|
|
89
94
|
buffer += source[i + 1] ?? '';
|
|
@@ -91,29 +96,44 @@ export function parseDeclarations(css) {
|
|
|
91
96
|
} else if (c === quote) quote = null;
|
|
92
97
|
continue;
|
|
93
98
|
}
|
|
99
|
+
/* Comments are skipped here rather than blanked before the walk.
|
|
100
|
+
Blanking first cannot see quotes, so a "/*" inside one string value and
|
|
101
|
+
a "*\/" inside another erased every declaration between them. A theme
|
|
102
|
+
override lost that way does not fail: resolveScopes falls back to the
|
|
103
|
+
base value and the dark theme gets measured against light numbers, which
|
|
104
|
+
is the exact miss this file exists to catch. Offsets stay true because
|
|
105
|
+
nothing is rewritten, only stepped over. */
|
|
106
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
107
|
+
const end = source.indexOf('*\/', i + 2);
|
|
108
|
+
i = end === -1 ? source.length : end + 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
94
111
|
if (c === '"' || c === "'") {
|
|
95
112
|
quote = c;
|
|
113
|
+
if (ink === -1) ink = i;
|
|
96
114
|
buffer += c;
|
|
97
115
|
continue;
|
|
98
116
|
}
|
|
99
117
|
if (c === '{') {
|
|
100
118
|
stack.push(buffer.trim().replace(/\s+/g, ' '));
|
|
101
119
|
buffer = '';
|
|
120
|
+
ink = -1;
|
|
102
121
|
bufferStart = i + 1;
|
|
103
122
|
continue;
|
|
104
123
|
}
|
|
105
124
|
if (c === '}') {
|
|
106
|
-
flush(
|
|
125
|
+
flush();
|
|
107
126
|
stack.pop();
|
|
108
127
|
bufferStart = i + 1;
|
|
109
128
|
continue;
|
|
110
129
|
}
|
|
111
130
|
if (c === ';') {
|
|
112
|
-
flush(
|
|
131
|
+
flush();
|
|
113
132
|
bufferStart = i + 1;
|
|
114
133
|
continue;
|
|
115
134
|
}
|
|
116
135
|
if (!buffer) bufferStart = i;
|
|
136
|
+
if (ink === -1 && c.trim()) ink = i;
|
|
117
137
|
buffer += c;
|
|
118
138
|
}
|
|
119
139
|
return decls;
|
package/src/files.mjs
CHANGED
|
@@ -56,25 +56,44 @@ function walk(dir, out) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Absolute paths matching the patterns,
|
|
60
|
-
*
|
|
59
|
+
* Absolute paths matching the patterns, plus the patterns that matched
|
|
60
|
+
* nothing.
|
|
61
|
+
*
|
|
62
|
+
* The misses are returned rather than inferred from an empty result, because
|
|
63
|
+
* a list is checked as a union. Two patterns, one naming src and one naming
|
|
64
|
+
* components, with components renamed: the src half still returns every file
|
|
65
|
+
* it matched, so the union is not empty and that whole second tree silently
|
|
66
|
+
* stops being checked. This file's own header promises a typo is reported
|
|
67
|
+
* where it happened, and only a per-pattern answer keeps that promise.
|
|
61
68
|
*/
|
|
62
|
-
export function
|
|
69
|
+
export function expandEach(patterns, cwd) {
|
|
63
70
|
const found = new Set();
|
|
71
|
+
const empty = [];
|
|
64
72
|
for (const pattern of patterns) {
|
|
73
|
+
const before = found.size;
|
|
65
74
|
const absolute = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
|
|
66
75
|
if (!/[*?]/.test(pattern)) {
|
|
67
76
|
if (existsSync(absolute) && statSync(absolute).isFile()) found.add(absolute);
|
|
77
|
+
else empty.push(pattern);
|
|
68
78
|
continue;
|
|
69
79
|
}
|
|
70
80
|
const base = resolve(cwd, baseOf(pattern));
|
|
71
|
-
if (!existsSync(base) || !statSync(base).isDirectory())
|
|
81
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) {
|
|
82
|
+
empty.push(pattern);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
72
85
|
const test = toRegExp(isAbsolute(pattern) ? pattern : resolve(cwd, pattern).split(sep).join('/'));
|
|
73
86
|
for (const file of walk(base, [])) {
|
|
74
87
|
if (test.test(file.split(sep).join('/'))) found.add(file);
|
|
75
88
|
}
|
|
89
|
+
if (found.size === before) empty.push(pattern);
|
|
76
90
|
}
|
|
77
|
-
return [...found].sort();
|
|
91
|
+
return { files: [...found].sort(), empty };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Just the paths, for callers with nothing useful to say about a miss. */
|
|
95
|
+
export function expand(patterns, cwd) {
|
|
96
|
+
return expandEach(patterns, cwd).files;
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
/** A path as you would paste it into an editor. */
|
package/src/judge.mjs
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { execFileSync } from 'node:child_process';
|
|
42
42
|
import { readFileSync } from 'node:fs';
|
|
43
43
|
import { resolve } from 'node:path';
|
|
44
|
-
import {
|
|
44
|
+
import { expandEach, label } from './files.mjs';
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* The instructions wrapped around the user's checklist.
|
|
@@ -182,7 +182,21 @@ export function prepareJudge(config, cwd) {
|
|
|
182
182
|
if (!made.ok) return { ok: false, result: empty([made.reason], failOn) };
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
const images =
|
|
185
|
+
const { files: images, empty: deadShots } = expandEach(shots, cwd);
|
|
186
|
+
/* A dead shot pattern is the same failure as no shots at all, one screen at
|
|
187
|
+
a time: the judge answers about the frames it was given and says nothing
|
|
188
|
+
about the one nobody handed it. */
|
|
189
|
+
if (deadShots.length) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
result: empty(
|
|
193
|
+
deadShots.map(
|
|
194
|
+
(p) => `no screenshots matched "${p}". A screen the judge never saw cannot pass.`,
|
|
195
|
+
),
|
|
196
|
+
failOn,
|
|
197
|
+
),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
186
200
|
if (!images.length) {
|
|
187
201
|
return {
|
|
188
202
|
ok: false,
|
package/src/runtime.mjs
CHANGED
|
@@ -38,11 +38,19 @@ const MEASURE = `(targets) => targets.map(({ selector, prop, againstParent }) =>
|
|
|
38
38
|
if (!el) return { error: 'matched no element' };
|
|
39
39
|
if (!el.getClientRects().length) return { error: 'matched an element that is not rendered' };
|
|
40
40
|
const cs = getComputedStyle(el);
|
|
41
|
-
|
|
41
|
+
// Read either spelling. A config saying border-top-color is the natural
|
|
42
|
+
// thing to write and the validator accepts it, so it must not fall through
|
|
43
|
+
// to an undefined lookup.
|
|
44
|
+
const fg = prop.includes('-') ? cs.getPropertyValue(prop) : cs[prop];
|
|
42
45
|
if (!fg) return { error: 'has no ' + prop };
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
if (/^border/i.test(prop)) {
|
|
47
|
+
/* Both spellings again. Stripping only the camelCase affixes turned
|
|
48
|
+
border-top-color into "-top-color" and then read borderTopWidth as
|
|
49
|
+
border-top-colorWidth, which is undefined, so every dash-case edge
|
|
50
|
+
reported "no width, so no edge is drawn" over a real border. */
|
|
51
|
+
const side = prop.replace(/^border-?/i, '').replace(/-?color$/i, '');
|
|
52
|
+
const dashed = side ? side.replace(/([A-Z])/g, '-$1').replace(/^-/, '').toLowerCase() : 'top';
|
|
53
|
+
const width = parseFloat(cs.getPropertyValue('border-' + dashed + '-width'));
|
|
46
54
|
if (!width) return { error: 'has a ' + prop + ' but no width, so no edge is drawn' };
|
|
47
55
|
}
|
|
48
56
|
const layers = [];
|
package/src/tokens.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { readFileSync } from 'node:fs';
|
|
31
31
|
import { lineAt, parseDeclarations } from './css.mjs';
|
|
32
|
-
import {
|
|
32
|
+
import { expandEach, label } from './files.mjs';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* The name is `[\w-]+` for the same reason `resolveValue` in css.mjs uses it:
|
|
@@ -72,10 +72,17 @@ export function runTokens(config, cwd) {
|
|
|
72
72
|
const { declaredIn, files: patterns, allow = [], allowPrefixes = [] } = config;
|
|
73
73
|
|
|
74
74
|
const quoted = (list) => list.map((p) => `"${p}"`).join(', ');
|
|
75
|
-
const
|
|
76
|
-
const
|
|
75
|
+
const declared_ = expandEach(declaredIn, cwd);
|
|
76
|
+
const scanned_ = expandEach(patterns, cwd);
|
|
77
|
+
const tokenFiles = declared_.files;
|
|
78
|
+
const files = scanned_.files;
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
/* Per pattern, not per list. A list is a union, so one dead pattern beside
|
|
81
|
+
a live one leaves the union non-empty and that tree stops being checked
|
|
82
|
+
in silence. */
|
|
83
|
+
for (const p of declared_.empty) problems.push(`no token files matched "${p}"`);
|
|
84
|
+
for (const p of scanned_.empty) problems.push(`no markup files matched "${p}"`);
|
|
85
|
+
if (!tokenFiles.length && !declared_.empty.length) problems.push(`no token files matched ${quoted(declaredIn)}`);
|
|
79
86
|
const declared = declaredNames(tokenFiles);
|
|
80
87
|
if (tokenFiles.length && !declared.size) {
|
|
81
88
|
problems.push(
|
|
@@ -83,7 +90,7 @@ export function runTokens(config, cwd) {
|
|
|
83
90
|
`but none of them declares a custom property. Every reference would read as undefined.`,
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
|
-
if (!files.length) problems.push(`no markup files matched ${quoted(patterns)}`);
|
|
93
|
+
if (!files.length && !scanned_.empty.length) problems.push(`no markup files matched ${quoted(patterns)}`);
|
|
87
94
|
|
|
88
95
|
// Without a trustworthy declared set there is nothing to compare against,
|
|
89
96
|
// and reporting every reference in the codebase as undefined would bury the
|
package/src/treatments.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
import { lineAt } from './css.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import { expandEach, label } from './files.mjs';
|
|
25
25
|
|
|
26
26
|
/** Literal value shapes worth flagging when they are not on the list. */
|
|
27
27
|
const VALUE_SHAPES = [
|
|
@@ -61,10 +61,18 @@ export function* openTags(source, names) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The value of `attr` when it is a plain string literal, else null.
|
|
66
|
+
*
|
|
67
|
+
* Both quote styles. Matching only double quotes meant class='a b' and
|
|
68
|
+
* style='...' hit neither this nor the expression path, so the element was
|
|
69
|
+
* read as carrying no classes and no values: a file full of unapproved
|
|
70
|
+
* classes reported clean and exited 0. Single quotes are the house style in
|
|
71
|
+
* a lot of HTML, and `files` accepts .html, so this was not a corner.
|
|
72
|
+
*/
|
|
65
73
|
export function literalAttr(attrs, attr) {
|
|
66
|
-
const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*
|
|
67
|
-
return m ? { text: m[
|
|
74
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*(["'])([\\s\\S]*?)\\1`));
|
|
75
|
+
return m ? { text: m[2], at: m.index + m[0].indexOf(m[1]) + 1 } : null;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
/** The braced expression for `attr`, brace-balanced, or null. */
|
|
@@ -190,9 +198,11 @@ export function runTreatments(config, cwd) {
|
|
|
190
198
|
approvedValues = [],
|
|
191
199
|
} = config;
|
|
192
200
|
|
|
193
|
-
const files =
|
|
201
|
+
const { files, empty } = expandEach(patterns, cwd);
|
|
202
|
+
// Per pattern: a dead one beside a live one leaves the union non-empty.
|
|
203
|
+
for (const p of empty) problems.push(`no markup files matched "${p}"`);
|
|
194
204
|
if (!files.length) {
|
|
195
|
-
problems.push(`no markup files matched ${patterns.map((p) => `"${p}"`).join(', ')}`);
|
|
205
|
+
if (!empty.length) problems.push(`no markup files matched ${patterns.map((p) => `"${p}"`).join(', ')}`);
|
|
196
206
|
return { name: 'treatments', failures, problems, summary: '' };
|
|
197
207
|
}
|
|
198
208
|
|