@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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-off value linter: class names and literal values, against a list you
|
|
3
|
+
* supply.
|
|
4
|
+
*
|
|
5
|
+
* ── Why the parsing is a scanner and not one regex ──────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The obvious implementation is `/<a\b[^>]*?className="([^"]*)"[^>]*?>/`, and
|
|
8
|
+
* it catches almost nothing while looking like a pass:
|
|
9
|
+
*
|
|
10
|
+
* - `[^>]*` cannot cross an `onClick={() => x}`. It terminates on the
|
|
11
|
+
* arrow's own ">", and the tag is silently skipped.
|
|
12
|
+
* - A className built as an expression is not a quoted string at all, so
|
|
13
|
+
* half the components in a typical codebase are invisible to it.
|
|
14
|
+
* - `title="a > b"` ends the tag early for the same reason as the arrow.
|
|
15
|
+
*
|
|
16
|
+
* So the tag span is found by walking the source with quote and brace depth
|
|
17
|
+
* tracked, and class names are collected from every string literal inside a
|
|
18
|
+
* className expression. A ternary contributes both of its branches on
|
|
19
|
+
* purpose: the question is whether a class can appear at all, and a false
|
|
20
|
+
* positive is a conversation while a false negative is the bug shipping.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { expand, label } from './files.mjs';
|
|
24
|
+
|
|
25
|
+
/** Literal value shapes worth flagging when they are not on the list. */
|
|
26
|
+
const VALUE_SHAPES = [
|
|
27
|
+
/#[0-9a-fA-F]{3,8}\b/g,
|
|
28
|
+
/\b(?:rgba?|hsla?)\([^)]*\)/g,
|
|
29
|
+
/(?<![\w-])\d*\.?\d+(?:px|rem|em|vh|vw|vmin|vmax|pt|ch|ex)\b/g,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const ANY_ELEMENT = '[A-Za-z][A-Za-z0-9.:_-]*';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every opening tag for the named elements, with its attribute text and the
|
|
36
|
+
* offset that text starts at. Walks rather than matches so quotes and braces
|
|
37
|
+
* nest correctly.
|
|
38
|
+
*/
|
|
39
|
+
export function* openTags(source, names) {
|
|
40
|
+
const pattern = names.includes('*') ? ANY_ELEMENT : `(?:${names.join('|')})`;
|
|
41
|
+
const opener = new RegExp(`<(${pattern})(?=[\\s/>])`, 'g');
|
|
42
|
+
for (const m of source.matchAll(opener)) {
|
|
43
|
+
const start = m.index + m[0].length;
|
|
44
|
+
let i = start;
|
|
45
|
+
let depth = 0;
|
|
46
|
+
let quote = null;
|
|
47
|
+
for (; i < source.length; i += 1) {
|
|
48
|
+
const c = source[i];
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (c === '\\') i += 1;
|
|
51
|
+
else if (c === quote) quote = null;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
55
|
+
else if (c === '{') depth += 1;
|
|
56
|
+
else if (c === '}') depth -= 1;
|
|
57
|
+
else if (c === '>' && depth === 0) break;
|
|
58
|
+
}
|
|
59
|
+
yield { name: m[1], attrs: source.slice(start, i), start, index: m.index };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The value of `attr` when it is a plain string literal, else null. */
|
|
64
|
+
export function literalAttr(attrs, attr) {
|
|
65
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*"([^"]*)"`));
|
|
66
|
+
return m ? { text: m[1], at: m.index + m[0].indexOf('"') + 1 } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The braced expression for `attr`, brace-balanced, or null. */
|
|
70
|
+
export function expressionAttr(attrs, attr) {
|
|
71
|
+
const at = attrs.search(new RegExp(`(?:^|\\s)${attr}\\s*=\\s*\\{`));
|
|
72
|
+
if (at === -1) return null;
|
|
73
|
+
let i = attrs.indexOf('{', at);
|
|
74
|
+
const start = i;
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (; i < attrs.length; i += 1) {
|
|
78
|
+
const c = attrs[i];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (c === '\\') i += 1;
|
|
81
|
+
else if (c === quote) quote = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
85
|
+
else if (c === '{') depth += 1;
|
|
86
|
+
else if (c === '}' && --depth === 0) return { text: attrs.slice(start + 1, i), at: start + 1 };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every string literal inside an expression, with its offset in the source.
|
|
93
|
+
*
|
|
94
|
+
* A walker rather than a regex, for the same reason the tag scanner is one.
|
|
95
|
+
* The interesting case is a template literal: the text around a `${...}` hole
|
|
96
|
+
* is literal class names, the hole itself is not, but the hole very often
|
|
97
|
+
* *contains* more literals. Blanking the hole loses them, and a class name
|
|
98
|
+
* that the checker cannot see is a class name that ships unapproved. So the
|
|
99
|
+
* hole is recursed into instead, with brace depth tracked so a nested object
|
|
100
|
+
* or a nested template does not end it early.
|
|
101
|
+
*/
|
|
102
|
+
function scanLiterals(text, base, out) {
|
|
103
|
+
let i = 0;
|
|
104
|
+
while (i < text.length) {
|
|
105
|
+
const c = text[i];
|
|
106
|
+
if (c === "'" || c === '"') {
|
|
107
|
+
let j = i + 1;
|
|
108
|
+
while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1;
|
|
109
|
+
out.push({ raw: text.slice(i + 1, j), at: base + i + 1 });
|
|
110
|
+
i = j + 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === '`') {
|
|
114
|
+
let j = i + 1;
|
|
115
|
+
let segment = j;
|
|
116
|
+
while (j < text.length && text[j] !== '`') {
|
|
117
|
+
if (text[j] === '\\') {
|
|
118
|
+
j += 2;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (text[j] === '$' && text[j + 1] === '{') {
|
|
122
|
+
out.push({ raw: text.slice(segment, j), at: base + segment });
|
|
123
|
+
let depth = 1;
|
|
124
|
+
let k = j + 2;
|
|
125
|
+
for (; k < text.length && depth > 0; k += 1) {
|
|
126
|
+
if (text[k] === '{') depth += 1;
|
|
127
|
+
else if (text[k] === '}') depth -= 1;
|
|
128
|
+
}
|
|
129
|
+
scanLiterals(text.slice(j + 2, k - 1), base + j + 2, out);
|
|
130
|
+
j = k;
|
|
131
|
+
segment = j;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
j += 1;
|
|
135
|
+
}
|
|
136
|
+
out.push({ raw: text.slice(segment, j), at: base + segment });
|
|
137
|
+
i = j + 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
i += 1;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Every class name the element can possibly carry.
|
|
147
|
+
*
|
|
148
|
+
* For an expression we take the union of every string literal inside it
|
|
149
|
+
* rather than trying to evaluate it. A ternary contributes both of its
|
|
150
|
+
* branches on purpose: the question is whether a class can appear at all.
|
|
151
|
+
*/
|
|
152
|
+
export function classesOf(attrs) {
|
|
153
|
+
const found = [];
|
|
154
|
+
const collect = (text, base) => {
|
|
155
|
+
for (const m of text.matchAll(/\S+/g)) found.push({ name: m[0], at: base + m.index });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const literal = literalAttr(attrs, 'className') ?? literalAttr(attrs, 'class');
|
|
159
|
+
if (literal) {
|
|
160
|
+
collect(literal.text, literal.at);
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const expr = expressionAttr(attrs, 'className') ?? expressionAttr(attrs, 'class');
|
|
165
|
+
if (!expr) return found;
|
|
166
|
+
for (const { raw, at } of scanLiterals(expr.text, expr.at, [])) collect(raw, at);
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Literal colours and lengths inside a style attribute. */
|
|
171
|
+
function inlineValues(attrs) {
|
|
172
|
+
const style = literalAttr(attrs, 'style') ?? expressionAttr(attrs, 'style');
|
|
173
|
+
if (!style) return [];
|
|
174
|
+
const found = [];
|
|
175
|
+
for (const shape of VALUE_SHAPES) {
|
|
176
|
+
for (const m of style.text.matchAll(shape)) found.push({ text: m[0], at: style.at + m.index });
|
|
177
|
+
}
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
|
182
|
+
|
|
183
|
+
export function runTreatments(config, cwd) {
|
|
184
|
+
const failures = [];
|
|
185
|
+
const problems = [];
|
|
186
|
+
const {
|
|
187
|
+
files: patterns,
|
|
188
|
+
elements = ['*'],
|
|
189
|
+
approvedClasses = [],
|
|
190
|
+
allowPrefixes = [],
|
|
191
|
+
approvedValues = [],
|
|
192
|
+
} = config;
|
|
193
|
+
|
|
194
|
+
const files = expand(patterns, cwd);
|
|
195
|
+
if (!files.length) {
|
|
196
|
+
problems.push(`no markup files matched ${patterns.map((p) => `"${p}"`).join(', ')}`);
|
|
197
|
+
return { name: 'treatments', failures, problems, summary: '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const approved = new Set(approvedClasses);
|
|
201
|
+
const allowedValues = new Set(approvedValues.map((v) => v.toLowerCase()));
|
|
202
|
+
|
|
203
|
+
for (const file of files) {
|
|
204
|
+
const source = readFileSync(file, 'utf8');
|
|
205
|
+
const where = (at) => `${label(file, cwd)}:${lineOf(source, at)}`;
|
|
206
|
+
|
|
207
|
+
for (const tag of openTags(source, elements)) {
|
|
208
|
+
for (const { name, at } of classesOf(tag.attrs)) {
|
|
209
|
+
if (approved.has(name)) continue;
|
|
210
|
+
if (allowPrefixes.some((p) => name.startsWith(p))) continue;
|
|
211
|
+
failures.push(`${where(tag.start + at)} class "${name}" on <${tag.name}> is not approved`);
|
|
212
|
+
}
|
|
213
|
+
for (const { text, at } of inlineValues(tag.attrs)) {
|
|
214
|
+
if (allowedValues.has(text.toLowerCase())) continue;
|
|
215
|
+
failures.push(
|
|
216
|
+
`${where(tag.start + at)} inline value "${text}" on <${tag.name}> is a one-off. ` +
|
|
217
|
+
`Use a token, or add it to approvedValues.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
name: 'treatments',
|
|
225
|
+
failures,
|
|
226
|
+
problems,
|
|
227
|
+
summary: `${files.length} ${files.length === 1 ? 'file' : 'files'} scanned`,
|
|
228
|
+
};
|
|
229
|
+
}
|