@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.
@@ -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
+ }