@webjsdev/ui 0.3.14 → 0.3.16
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 +8 -0
- package/package.json +1 -1
- package/src/commands/add.js +19 -6
- package/src/commands/lint.js +93 -0
- package/src/index.js +3 -1
- package/src/lint/grammar.js +839 -0
- package/src/lint/index.js +205 -0
- package/src/lint/rules/index.js +12 -0
- package/src/lint/rules/no-arbitrary-values.js +33 -0
- package/src/lint/rules/no-raw-colors.js +86 -0
- package/src/lint/rules/no-restyle.js +66 -0
- package/src/lint/scan.js +446 -0
- package/src/lint/theme-tokens.js +75 -0
- package/src/registry/extract.js +166 -0
- package/src/registry/schema.js +38 -0
- package/src/utils/package-manager.js +92 -0
package/src/lint/scan.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The class-site scanner behind `webjsui lint`: where in a module the linter
|
|
3
|
+
* reads Tailwind classes from. Pure over `(source, { helpers, cnNames })`, no
|
|
4
|
+
* filesystem, so every rule test is a string in and an array out.
|
|
5
|
+
*
|
|
6
|
+
* A CLASS SITE is one of three shapes, and they are the complete set:
|
|
7
|
+
*
|
|
8
|
+
* 1. Template attribute site. A `class=` attribute inside an OPEN TAG inside
|
|
9
|
+
* an `html` tagged template. Nested `html` templates inside holes are
|
|
10
|
+
* recursed into (the blog's positives all sit inside
|
|
11
|
+
* `${cond ? html\`...\` : ''}`).
|
|
12
|
+
* 2. Helper argument site. Every string literal (a plain template literal
|
|
13
|
+
* included, split at its holes) lexically inside a call to a recognized
|
|
14
|
+
* `cn(` (the app's utils alias) anywhere in the module. A
|
|
15
|
+
* recognized `*Class(` helper call contributes its NAME (the class beside
|
|
16
|
+
* it is composed with that helper) and its own arguments are never read
|
|
17
|
+
* as classes, since `buttonClass({ variant: 'secondary' })` carries option
|
|
18
|
+
* values, not classes.
|
|
19
|
+
* 3. Hole site. Every string literal inside a `class=${...}` hole.
|
|
20
|
+
*
|
|
21
|
+
* Sites 2 and 3 overlap (`class=${cn(buttonClass(), 'w-9')}`) and a `cn` call
|
|
22
|
+
* inside a class hole feeds the hole's site rather than opening a second one,
|
|
23
|
+
* so one string is never reported twice.
|
|
24
|
+
*
|
|
25
|
+
* The TAG-REGION requirement is what makes an escaped code sample inert: a
|
|
26
|
+
* `class=` is a site only when a literal `<` followed by a tag-name character
|
|
27
|
+
* opened a tag that a `>` has not yet closed. In a docs page the markup is
|
|
28
|
+
* written `<p class="...">`, so no tag is ever open and nothing is read.
|
|
29
|
+
* This is a structural rule, not a "does this look like a docs page" guess.
|
|
30
|
+
*
|
|
31
|
+
* A class string spanning a hole is split at the hole boundary. Each static
|
|
32
|
+
* run is tokenized on whitespace, and a token touching a hole with no
|
|
33
|
+
* intervening whitespace is DROPPED as a fragment (`class="text-${size} p-2"`
|
|
34
|
+
* yields only `p-2`). Nothing is reconstructed across a hole.
|
|
35
|
+
*
|
|
36
|
+
* The lexer is hand-rolled, borrowing the regex-versus-division and nested
|
|
37
|
+
* `${...}` handling of `@webjsdev/server`'s `js-scan.js`. It is NOT imported:
|
|
38
|
+
* this package must not depend on `@webjsdev/server`, and that module blanks
|
|
39
|
+
* template bodies while this one must read them.
|
|
40
|
+
*
|
|
41
|
+
* @module lint/scan
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {{ name: string, offset: number, line: number, column: number }} ClassToken
|
|
48
|
+
* @typedef {{
|
|
49
|
+
* kind: 'attribute'|'hole'|'call',
|
|
50
|
+
* offset: number, line: number, column: number,
|
|
51
|
+
* classes: ClassToken[],
|
|
52
|
+
* helpers: string[],
|
|
53
|
+
* }} ClassSite
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
57
|
+
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
|
|
58
|
+
'do', 'else', 'case', 'yield', 'await', 'throw',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Scan one module's source for class sites.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} src
|
|
65
|
+
* @param {{ helpers?: Iterable<string>, cnNames?: Iterable<string> }} [opts]
|
|
66
|
+
* @returns {ClassSite[]}
|
|
67
|
+
*/
|
|
68
|
+
export function scanClassSites(src, opts = {}) {
|
|
69
|
+
const helpers = new Set(opts.helpers ?? []);
|
|
70
|
+
const cnNames = new Set(opts.cnNames ?? ['cn']);
|
|
71
|
+
const n = src.length;
|
|
72
|
+
/** @type {ClassSite[]} */
|
|
73
|
+
const sites = [];
|
|
74
|
+
const lineStarts = [0];
|
|
75
|
+
for (let k = 0; k < n; k++) if (src[k] === '\n') lineStarts.push(k + 1);
|
|
76
|
+
const pos = (offset) => {
|
|
77
|
+
let lo = 0, hi = lineStarts.length - 1;
|
|
78
|
+
while (lo < hi) {
|
|
79
|
+
const mid = (lo + hi + 1) >> 1;
|
|
80
|
+
if (lineStarts[mid] <= offset) lo = mid; else hi = mid - 1;
|
|
81
|
+
}
|
|
82
|
+
return { line: lo + 1, column: offset - lineStarts[lo] + 1 };
|
|
83
|
+
};
|
|
84
|
+
const newSite = (kind, offset) => ({ kind, offset, ...pos(offset), classes: [], helpers: [] });
|
|
85
|
+
// A site is dropped while muted (inside a helper's own arguments, or inside
|
|
86
|
+
// a hole that sits in commented-out markup).
|
|
87
|
+
const emit = (site) => { if (site.classes.length && mute === 0) sites.push(site); };
|
|
88
|
+
|
|
89
|
+
/** @type {ClassSite[]} the active collector stack (innermost last) */
|
|
90
|
+
const collectors = [];
|
|
91
|
+
let mute = 0;
|
|
92
|
+
const active = () => (collectors.length ? collectors[collectors.length - 1] : null);
|
|
93
|
+
const pushTokens = (site, text, base) => {
|
|
94
|
+
const re = /\S+/g;
|
|
95
|
+
let m;
|
|
96
|
+
while ((m = re.exec(text)) !== null) {
|
|
97
|
+
site.classes.push({ name: m[0], offset: base + m.index, ...pos(base + m.index) });
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// One static run of a class string that holes may interrupt. A token touching
|
|
102
|
+
// a hole with no whitespace between is a fragment and is dropped, on either
|
|
103
|
+
// side, so nothing is ever reconstructed across a hole.
|
|
104
|
+
const pushRun = (site, text, base, holeBefore, holeAfter) => {
|
|
105
|
+
if (!text.length) return;
|
|
106
|
+
if (holeBefore && !/^\s/.test(text)) {
|
|
107
|
+
const m = /^\S+/.exec(text);
|
|
108
|
+
base += m[0].length; text = text.slice(m[0].length);
|
|
109
|
+
}
|
|
110
|
+
if (holeAfter && !/\s$/.test(text)) text = text.replace(/\S+$/, '');
|
|
111
|
+
pushTokens(site, text, base);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let i = 0;
|
|
115
|
+
let lastSig = '';
|
|
116
|
+
let lastWord = '';
|
|
117
|
+
let lastWordIsProp = false;
|
|
118
|
+
let lastWasIncDec = false;
|
|
119
|
+
const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; };
|
|
120
|
+
const isRegex = () => {
|
|
121
|
+
if (lastSig === '') return true;
|
|
122
|
+
if (lastSig === ')' || lastSig === ']') return false;
|
|
123
|
+
if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false;
|
|
124
|
+
if (lastWasIncDec) return false;
|
|
125
|
+
if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const scanLineComment = () => { i += 2; while (i < n && src[i] !== '\n') i++; };
|
|
130
|
+
const scanBlockComment = () => {
|
|
131
|
+
i += 2;
|
|
132
|
+
while (i < n) { if (src[i] === '*' && src[i + 1] === '/') { i += 2; return; } i++; }
|
|
133
|
+
};
|
|
134
|
+
const scanRegex = () => {
|
|
135
|
+
i++;
|
|
136
|
+
let inClass = false;
|
|
137
|
+
while (i < n) {
|
|
138
|
+
const d = src[i];
|
|
139
|
+
if (d === '\\' && i + 1 < n) { i += 2; continue; }
|
|
140
|
+
if (d === '\n') break;
|
|
141
|
+
if (d === '[') inClass = true;
|
|
142
|
+
else if (d === ']') inClass = false;
|
|
143
|
+
else if (d === '/' && !inClass) { i++; break; }
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
markValue();
|
|
147
|
+
};
|
|
148
|
+
// A literal that is a comparison operand (`kind === 'primary'`), or a
|
|
149
|
+
// `case 'x':` label, is a value the code compares, not a class, so it is not
|
|
150
|
+
// collected. Any other literal in a class hole or cn() call is read as a
|
|
151
|
+
// class; an unknown token falls to the layout category, so a stray one is
|
|
152
|
+
// admitted by the recommended config rather than reported.
|
|
153
|
+
const isComparisonOperand = (before, after) => {
|
|
154
|
+
if (lastWord === 'case') return true;
|
|
155
|
+
let j = before - 1;
|
|
156
|
+
while (j >= 0 && /\s/.test(src[j])) j--;
|
|
157
|
+
if (j >= 1 && src[j] === '=' && (src[j - 1] === '=' || src[j - 1] === '!')) return true;
|
|
158
|
+
let k = after;
|
|
159
|
+
while (k < n && /\s/.test(src[k])) k++;
|
|
160
|
+
return (src[k] === '=' && src[k + 1] === '=') || (src[k] === '!' && src[k + 1] === '=');
|
|
161
|
+
};
|
|
162
|
+
const scanString = (q) => {
|
|
163
|
+
const quoteAt = i;
|
|
164
|
+
const start = i + 1;
|
|
165
|
+
i++;
|
|
166
|
+
let body = '';
|
|
167
|
+
while (i < n) {
|
|
168
|
+
if (src[i] === '\\' && i + 1 < n) { body += src[i] + src[i + 1]; i += 2; continue; }
|
|
169
|
+
if (src[i] === q) { i++; break; }
|
|
170
|
+
if (src[i] === '\n') { i++; break; }
|
|
171
|
+
body += src[i]; i++;
|
|
172
|
+
}
|
|
173
|
+
const site = active();
|
|
174
|
+
if (site && mute === 0 && !isComparisonOperand(quoteAt, i)) pushTokens(site, body, start);
|
|
175
|
+
markValue();
|
|
176
|
+
};
|
|
177
|
+
// A plain template literal is a string literal too, so inside a collecting
|
|
178
|
+
// site its static text is read like one (`cn(buttonClass(), \`bg-pink-500\`)`),
|
|
179
|
+
// split at its holes by the same fragment rule an attribute site uses.
|
|
180
|
+
const scanPlainTemplate = () => {
|
|
181
|
+
const quoteAt = i;
|
|
182
|
+
const site = mute === 0 ? active() : null;
|
|
183
|
+
/** @type {Array<{ text: string, base: number, holeBefore: boolean, holeAfter: boolean }>} */
|
|
184
|
+
const runs = [];
|
|
185
|
+
let run = '';
|
|
186
|
+
let runStart = -1;
|
|
187
|
+
let holeBefore = false;
|
|
188
|
+
const endRun = (holeAfter) => {
|
|
189
|
+
if (run.length) runs.push({ text: run, base: runStart, holeBefore, holeAfter });
|
|
190
|
+
run = ''; runStart = -1;
|
|
191
|
+
};
|
|
192
|
+
i++;
|
|
193
|
+
while (i < n) {
|
|
194
|
+
const c = src[i];
|
|
195
|
+
if (c === '\\' && i + 1 < n) {
|
|
196
|
+
if (runStart === -1) runStart = i;
|
|
197
|
+
run += c + src[i + 1];
|
|
198
|
+
i += 2;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === '`') { i++; break; }
|
|
202
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
203
|
+
endRun(true);
|
|
204
|
+
holeBefore = true;
|
|
205
|
+
i += 2;
|
|
206
|
+
scanCode('hole');
|
|
207
|
+
if (i < n && src[i] === '}') i++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (runStart === -1) runStart = i;
|
|
211
|
+
run += c;
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
endRun(false);
|
|
215
|
+
if (site && !isComparisonOperand(quoteAt, i)) {
|
|
216
|
+
for (const r of runs) pushRun(site, r.text, r.base, r.holeBefore, r.holeAfter);
|
|
217
|
+
}
|
|
218
|
+
markValue();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// An `html` tagged template: read the TEXT for open tags and `class=`
|
|
222
|
+
// attributes, recurse into holes as code.
|
|
223
|
+
const scanHtmlTemplate = () => {
|
|
224
|
+
i++;
|
|
225
|
+
let inTag = false;
|
|
226
|
+
/** @type {{ site: ClassSite|null, quote: string|null, run: string, runStart: number, holeBefore: boolean }|null} */
|
|
227
|
+
let attr = null;
|
|
228
|
+
let pendingClassHole = false;
|
|
229
|
+
let inComment = false;
|
|
230
|
+
const flushRun = (touchesHoleRight) => {
|
|
231
|
+
if (!attr || !attr.site) return;
|
|
232
|
+
pushRun(attr.site, attr.run, attr.runStart, attr.holeBefore, touchesHoleRight);
|
|
233
|
+
attr.run = ''; attr.runStart = -1; attr.holeBefore = false;
|
|
234
|
+
};
|
|
235
|
+
const closeAttr = () => { flushRun(false); if (attr.site) emit(attr.site); attr = null; };
|
|
236
|
+
while (i < n) {
|
|
237
|
+
const c = src[i];
|
|
238
|
+
if (c === '\\' && i + 1 < n) {
|
|
239
|
+
if (attr && attr.site) { if (attr.runStart === -1) attr.runStart = i; attr.run += src[i] + src[i + 1]; }
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (c === '`') { i++; break; }
|
|
244
|
+
// Commented-out markup (`<!-- <div class="..."> -->`) opens no tag. A hole
|
|
245
|
+
// inside the comment is still lexed as code so the template's real end is
|
|
246
|
+
// found, but it is muted: its output lands inside the comment, so nothing
|
|
247
|
+
// in it is collected.
|
|
248
|
+
if (inComment) {
|
|
249
|
+
if (src.startsWith('-->', i)) { inComment = false; i += 3; continue; }
|
|
250
|
+
if (c === '$' && src[i + 1] === '{') { i += 2; mute++; scanCode('hole'); mute--; if (i < n && src[i] === '}') i++; continue; }
|
|
251
|
+
i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!inTag && !attr && src.startsWith('<!--', i)) { inComment = true; i += 4; continue; }
|
|
255
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
256
|
+
const at = i;
|
|
257
|
+
i += 2;
|
|
258
|
+
if (attr && attr.site) {
|
|
259
|
+
flushRun(true);
|
|
260
|
+
attr.holeBefore = true;
|
|
261
|
+
collectors.push(attr.site);
|
|
262
|
+
scanCode('hole');
|
|
263
|
+
collectors.pop();
|
|
264
|
+
} else if (pendingClassHole) {
|
|
265
|
+
pendingClassHole = false;
|
|
266
|
+
const site = newSite('hole', at);
|
|
267
|
+
collectors.push(site);
|
|
268
|
+
scanCode('hole');
|
|
269
|
+
collectors.pop();
|
|
270
|
+
emit(site);
|
|
271
|
+
} else {
|
|
272
|
+
scanCode('hole');
|
|
273
|
+
}
|
|
274
|
+
if (i < n && src[i] === '}') i++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (attr) {
|
|
278
|
+
if (attr.quote ? c === attr.quote : (/\s/.test(c) || c === '>')) {
|
|
279
|
+
const consume = attr.quote !== null;
|
|
280
|
+
closeAttr();
|
|
281
|
+
if (consume) i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (attr.site) { if (attr.runStart === -1) attr.runStart = i; attr.run += c; }
|
|
285
|
+
i++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!inTag) {
|
|
289
|
+
if (c === '<' && /[A-Za-z]/.test(src[i + 1] || '')) { inTag = true; i += 2; continue; }
|
|
290
|
+
i++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (c === '>') { inTag = false; pendingClassHole = false; i++; continue; }
|
|
294
|
+
if (c === '"' || c === "'") {
|
|
295
|
+
// Another attribute's quoted value: skip it, still scanning holes as code.
|
|
296
|
+
attr = { site: null, quote: c, run: '', runStart: -1, holeBefore: false };
|
|
297
|
+
i++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (src.startsWith('class=', i) && /[\s]/.test(src[i - 1] || '')) {
|
|
301
|
+
i += 6;
|
|
302
|
+
const q = src[i];
|
|
303
|
+
if (q === '"' || q === "'") {
|
|
304
|
+
attr = { site: newSite('attribute', i), quote: q, run: '', runStart: -1, holeBefore: false };
|
|
305
|
+
i++;
|
|
306
|
+
} else if (q === '$' && src[i + 1] === '{') {
|
|
307
|
+
pendingClassHole = true;
|
|
308
|
+
} else {
|
|
309
|
+
attr = { site: newSite('attribute', i), quote: null, run: '', runStart: -1, holeBefore: false };
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
if (attr) closeAttr();
|
|
316
|
+
markValue();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// A recognized call: `cn(` opens a call site unless one is already
|
|
320
|
+
// collecting; a `*Class(` helper contributes its name and mutes its args.
|
|
321
|
+
const scanCall = (word, wordAt) => {
|
|
322
|
+
// i sits at `(`
|
|
323
|
+
i++;
|
|
324
|
+
if (helpers.has(word)) {
|
|
325
|
+
const site = active();
|
|
326
|
+
if (site && mute === 0 && !site.helpers.includes(word)) site.helpers.push(word);
|
|
327
|
+
mute++;
|
|
328
|
+
scanCode('paren');
|
|
329
|
+
mute--;
|
|
330
|
+
} else if (active() || mute > 0) {
|
|
331
|
+
scanCode('paren');
|
|
332
|
+
} else {
|
|
333
|
+
const site = newSite('call', wordAt);
|
|
334
|
+
collectors.push(site);
|
|
335
|
+
scanCode('paren');
|
|
336
|
+
collectors.pop();
|
|
337
|
+
emit(site);
|
|
338
|
+
}
|
|
339
|
+
if (i < n && src[i] === ')') i++;
|
|
340
|
+
lastSig = ')'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** @param {'hole'|'paren'|null} stop */
|
|
344
|
+
function scanCode(stop) {
|
|
345
|
+
let brace = 0;
|
|
346
|
+
let paren = 0;
|
|
347
|
+
// A hole or a call's arguments start a fresh expression. Without this the
|
|
348
|
+
// lexer still remembers the `html` tag that opened the enclosing template,
|
|
349
|
+
// so a plain template (or a regex) leading a hole is misread.
|
|
350
|
+
if (stop !== null) { lastSig = stop === 'hole' ? '{' : '('; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; }
|
|
351
|
+
while (i < n) {
|
|
352
|
+
const c = src[i], next = src[i + 1];
|
|
353
|
+
if (stop === 'hole' && c === '}' && brace === 0) return;
|
|
354
|
+
if (stop === 'paren' && c === ')' && paren === 0) return;
|
|
355
|
+
if (c === '/' && next === '/') { scanLineComment(); continue; }
|
|
356
|
+
if (c === '/' && next === '*') { scanBlockComment(); continue; }
|
|
357
|
+
if (c === '/' && isRegex()) { scanRegex(); continue; }
|
|
358
|
+
if (c === "'" || c === '"') { scanString(c); continue; }
|
|
359
|
+
if (c === '`') {
|
|
360
|
+
if (lastWord === 'html' && /[\w$]/.test(lastSig) && !lastWordIsProp) scanHtmlTemplate();
|
|
361
|
+
else scanPlainTemplate();
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; i++; continue; }
|
|
365
|
+
if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; i++; continue; }
|
|
366
|
+
if (c === '(') { paren++; lastSig = '('; lastWord = ''; lastWasIncDec = false; i++; continue; }
|
|
367
|
+
if (c === ')') { paren--; lastSig = ')'; lastWord = ''; lastWasIncDec = false; i++; continue; }
|
|
368
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
369
|
+
const prop = lastSig === '.';
|
|
370
|
+
const at = i;
|
|
371
|
+
let w = '';
|
|
372
|
+
while (i < n && /[\w$]/.test(src[i])) { w += src[i]; i++; }
|
|
373
|
+
lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false;
|
|
374
|
+
if (!prop && (cnNames.has(w) || helpers.has(w))) {
|
|
375
|
+
let j = i;
|
|
376
|
+
while (j < n && /[ \t]/.test(src[j])) j++;
|
|
377
|
+
if (src[j] === '(') { i = j; scanCall(w, at); }
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
382
|
+
lastWasIncDec = (c === '+' || c === '-') && c === lastSig;
|
|
383
|
+
lastSig = c; lastWord = ''; i++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
scanCode(null);
|
|
388
|
+
return sites;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The recognized helper and `cn` identifiers of one module, read from its
|
|
393
|
+
* imports. An identifier ending in `Class` is a kit helper only when imported
|
|
394
|
+
* from a path resolving inside `uiDir`; `cn` only when imported from
|
|
395
|
+
* `utilsPath` (the config's `aliases.utils`). A `#` specifier has its sigil
|
|
396
|
+
* stripped and resolves against `appRoot`; a relative one against the file.
|
|
397
|
+
* An unrelated local `fooClass()` is therefore never mistaken for a helper.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} src
|
|
400
|
+
* @param {{ filePath: string, appRoot: string, uiDir: string, utilsPath: string }} paths
|
|
401
|
+
* @returns {{ helpers: string[], cnNames: string[], helperFiles: Record<string, string>, helperExports: Record<string, string> }}
|
|
402
|
+
* `helperFiles` maps each helper to the absolute path its import resolved to
|
|
403
|
+
* (extension as written), so the orchestrator can read the APP's copy.
|
|
404
|
+
* `helperExports` maps each helper's LOCAL name to the name it is exported
|
|
405
|
+
* under, which differs only for an aliased import.
|
|
406
|
+
*/
|
|
407
|
+
export function collectHelperImports(src, { filePath, appRoot, uiDir, utilsPath }) {
|
|
408
|
+
/** @type {string[]} */
|
|
409
|
+
const helpers = [];
|
|
410
|
+
/** @type {string[]} */
|
|
411
|
+
const cnNames = [];
|
|
412
|
+
/** @type {Record<string, string>} */
|
|
413
|
+
const helperFiles = {};
|
|
414
|
+
/** @type {Record<string, string>} */
|
|
415
|
+
const helperExports = {};
|
|
416
|
+
const stripExt = (p) => p.replace(/\.(?:ts|tsx|js|jsx|mts|mjs)$/, '');
|
|
417
|
+
const ui = resolve(uiDir);
|
|
418
|
+
const utils = stripExt(resolve(utilsPath));
|
|
419
|
+
// The optional group admits a default binding ahead of the named list
|
|
420
|
+
// (`import Def, { cn } from ...`), which is otherwise not matched at all.
|
|
421
|
+
const re = /\bimport\s*(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
422
|
+
let m;
|
|
423
|
+
while ((m = re.exec(src)) !== null) {
|
|
424
|
+
const spec = m[2];
|
|
425
|
+
let target;
|
|
426
|
+
if (spec.startsWith('#')) target = resolve(appRoot, spec.slice(1));
|
|
427
|
+
else if (spec.startsWith('.')) target = resolve(dirname(filePath), spec);
|
|
428
|
+
else continue;
|
|
429
|
+
const inUi = target === ui || target.startsWith(ui + sep);
|
|
430
|
+
const isUtils = stripExt(target) === utils;
|
|
431
|
+
if (!inUi && !isUtils) continue;
|
|
432
|
+
// A comment inside a multi-line import list is not a binding.
|
|
433
|
+
const bindings = m[1].replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
|
|
434
|
+
for (const part of bindings.split(',')) {
|
|
435
|
+
const piece = part.trim().replace(/^type\s+/, '');
|
|
436
|
+
if (!piece) continue;
|
|
437
|
+
const [imported, local = imported] = piece.split(/\s+as\s+/).map((s) => s.trim());
|
|
438
|
+
// Recognition keys on the EXPORTED name, so `buttonClass as bc` is still a
|
|
439
|
+
// kit helper. The scanner matches the local, and `helperExports` maps it
|
|
440
|
+
// back to the name `extractHelperAxes` keys its result on.
|
|
441
|
+
if (inUi && /Class$/.test(imported)) { helpers.push(local); helperFiles[local] = target; helperExports[local] = imported; }
|
|
442
|
+
if (isUtils && imported === 'cn') cnNames.push(local);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { helpers, cnNames, helperFiles, helperExports };
|
|
446
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme-token READER behind `webjsui lint`: the `--color-<name>` tokens an
|
|
3
|
+
* app's configured Tailwind CSS file declares, which is what a `no-raw-colors`
|
|
4
|
+
* message names as the alternative to a raw palette utility.
|
|
5
|
+
*
|
|
6
|
+
* Distinct from `utils/theme.js`, which WRITES the token block for `init` and
|
|
7
|
+
* `add`. The two are not merged and this one does not look for the
|
|
8
|
+
* `THEME_MARKER`, because an app's theme file is its own and need not carry
|
|
9
|
+
* the marker (the blog's `public/input.css` opens a plain `@theme {` and
|
|
10
|
+
* declares tokens the kit does not ship).
|
|
11
|
+
*
|
|
12
|
+
* Both `@theme {` and `@theme inline {` are read: the kit's `themes/index.css`
|
|
13
|
+
* uses the inline form, the blog the plain one.
|
|
14
|
+
*
|
|
15
|
+
* @module lint/theme-tokens
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse the `--color-<name>` declarations out of CSS text, inside `@theme` and
|
|
22
|
+
* `@theme inline` blocks only. Pure over the text so it is testable without a
|
|
23
|
+
* file.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} css
|
|
26
|
+
* @returns {string[]} token names without the `--color-` prefix, in declaration order, deduplicated
|
|
27
|
+
*/
|
|
28
|
+
export function parseThemeTokens(css) {
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const tokens = [];
|
|
31
|
+
// Comments go first. A commented-out `--color-old` is not a token Tailwind
|
|
32
|
+
// generates, so naming it would break the rule's promise, and a `}` inside a
|
|
33
|
+
// comment would otherwise close the block early and lose what follows it.
|
|
34
|
+
css = css.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
35
|
+
const re = /@theme\b[^{;]*\{/g;
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(css)) !== null) {
|
|
38
|
+
const open = m.index + m[0].length - 1;
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let close = -1;
|
|
41
|
+
for (let i = open; i < css.length; i++) {
|
|
42
|
+
if (css[i] === '{') depth++;
|
|
43
|
+
else if (css[i] === '}') { depth--; if (depth === 0) { close = i; break; } }
|
|
44
|
+
}
|
|
45
|
+
if (close === -1) break;
|
|
46
|
+
const body = css.slice(open + 1, close);
|
|
47
|
+
const declRe = /--color-([A-Za-z0-9_-]+)\s*:/g;
|
|
48
|
+
let d;
|
|
49
|
+
while ((d = declRe.exec(body)) !== null) {
|
|
50
|
+
if (!tokens.includes(d[1])) tokens.push(d[1]);
|
|
51
|
+
}
|
|
52
|
+
re.lastIndex = close;
|
|
53
|
+
}
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the theme tokens from a CSS file. A missing or unreadable file, or one
|
|
59
|
+
* with no `--color-*` declaration inside a `@theme` block, yields an empty
|
|
60
|
+
* `tokens` array rather than throwing; the caller then disables `no-raw-colors`
|
|
61
|
+
* for the run and names the path, since a message that cannot offer an
|
|
62
|
+
* alternative has no value.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} cssPath
|
|
65
|
+
* @returns {{ tokens: string[], path: string }}
|
|
66
|
+
*/
|
|
67
|
+
export function readThemeTokens(cssPath) {
|
|
68
|
+
let css = '';
|
|
69
|
+
try {
|
|
70
|
+
css = readFileSync(cssPath, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return { tokens: [], path: cssPath };
|
|
73
|
+
}
|
|
74
|
+
return { tokens: parseThemeTokens(css), path: cssPath };
|
|
75
|
+
}
|
package/src/registry/extract.js
CHANGED
|
@@ -48,6 +48,172 @@ export function extractHelperSignatures(src) {
|
|
|
48
48
|
return out;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The option AXES a Tier-1 class helper exposes, e.g.
|
|
53
|
+
* `{ buttonClass: { variant: ['default', ...], size: ['default', 'xs', ...] } }`.
|
|
54
|
+
*
|
|
55
|
+
* `extractHelperSignatures` returns signature TEXT, which is right for `view`
|
|
56
|
+
* and the MCP `ui` tool but cannot answer "which sizes exist", the question a
|
|
57
|
+
* `webjsui lint` no-restyle message has to answer. Same lexical, parser-free
|
|
58
|
+
* approach, same module, so the two cannot drift. Pure over SOURCE TEXT so the
|
|
59
|
+
* linter can feed it the APP's copied `components/ui/*.ts` (which may have
|
|
60
|
+
* added or removed a variant) rather than the packaged registry.
|
|
61
|
+
*
|
|
62
|
+
* Resolves the two shapes the registry actually writes:
|
|
63
|
+
* const size = opts.size ?? 'default'; ... SIZES[size] (button.ts)
|
|
64
|
+
* VARIANTS[opts.variant ?? 'default'] (badge.ts)
|
|
65
|
+
* Two objects feeding one axis are unioned (switch.ts: TRACK_SIZES[size] and
|
|
66
|
+
* THUMB_SIZES[size]). A helper matching neither shape yields no axes, and the
|
|
67
|
+
* caller then omits the value list rather than inventing one.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} src
|
|
70
|
+
* @returns {Record<string, Record<string, string[]>>}
|
|
71
|
+
*/
|
|
72
|
+
export function extractHelperAxes(src) {
|
|
73
|
+
// Comments are blanked first (string-aware), so an apostrophe or a brace in
|
|
74
|
+
// a comment inside the app's variant map cannot swallow the rest of the
|
|
75
|
+
// object; positions are not reported here, so blanking to spaces is enough.
|
|
76
|
+
src = blankComments(src);
|
|
77
|
+
// 1. Every `const NAME(: T)? = { ... }` object literal and its top-level keys.
|
|
78
|
+
/** @type {Map<string, string[]>} */
|
|
79
|
+
const objects = new Map();
|
|
80
|
+
const objRe = /\bconst\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=\s*\{/g;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = objRe.exec(src)) !== null) {
|
|
83
|
+
const open = m.index + m[0].length - 1;
|
|
84
|
+
const close = matchBrace(src, open);
|
|
85
|
+
if (close === -1) continue;
|
|
86
|
+
objects.set(m[1], topLevelKeys(src.slice(open + 1, close)));
|
|
87
|
+
objRe.lastIndex = close;
|
|
88
|
+
}
|
|
89
|
+
if (objects.size === 0) return {};
|
|
90
|
+
|
|
91
|
+
// 2. Every exported helper, by start offset, so a read below is attributed to
|
|
92
|
+
// the nearest preceding declaration (helpers are sequential in a module).
|
|
93
|
+
/** @type {{ name: string, at: number }[]} */
|
|
94
|
+
const helpers = [];
|
|
95
|
+
const fnRe =
|
|
96
|
+
/export\s+(?:const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=]+?)?=>|function\s+([A-Za-z_$][\w$]*)\s*\()/g;
|
|
97
|
+
while ((m = fnRe.exec(src)) !== null) helpers.push({ name: m[1] || m[2], at: m.index });
|
|
98
|
+
const helperAt = (/** @type {number} */ offset) => {
|
|
99
|
+
let h = null;
|
|
100
|
+
for (const c of helpers) if (c.at <= offset) h = c.name;
|
|
101
|
+
return h;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// 3. `const <local> = <param>.<axis> ?? ...` bindings, scoped to their helper.
|
|
105
|
+
/** @type {Map<string, Map<string, string>>} */
|
|
106
|
+
const bindings = new Map();
|
|
107
|
+
const bindRe = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)\s*\?\?/g;
|
|
108
|
+
while ((m = bindRe.exec(src)) !== null) {
|
|
109
|
+
const h = helperAt(m.index);
|
|
110
|
+
if (!h) continue;
|
|
111
|
+
if (!bindings.has(h)) bindings.set(h, new Map());
|
|
112
|
+
bindings.get(h).set(m[1], m[2]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 4. Every `OBJ[<index>]` read: the axis is `<param>.<axis>` in the index, or
|
|
116
|
+
// the axis the bare local was bound to. Union per helper + axis, first-seen
|
|
117
|
+
// order, so the message lists `default` first as the source does.
|
|
118
|
+
/** @type {Record<string, Record<string, string[]>>} */
|
|
119
|
+
const out = {};
|
|
120
|
+
const readRe = /\b([A-Za-z_$][\w$]*)\[([^\]]+)\]/g;
|
|
121
|
+
while ((m = readRe.exec(src)) !== null) {
|
|
122
|
+
const keys = objects.get(m[1]);
|
|
123
|
+
if (!keys) continue;
|
|
124
|
+
const h = helperAt(m.index);
|
|
125
|
+
if (!h) continue;
|
|
126
|
+
const index = m[2];
|
|
127
|
+
let axis = null;
|
|
128
|
+
const member = /[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)/.exec(index);
|
|
129
|
+
if (member) axis = member[1];
|
|
130
|
+
else {
|
|
131
|
+
const local = index.trim();
|
|
132
|
+
axis = bindings.get(h)?.get(local) ?? null;
|
|
133
|
+
}
|
|
134
|
+
if (!axis) continue;
|
|
135
|
+
const forHelper = (out[h] ??= {});
|
|
136
|
+
const values = (forHelper[axis] ??= []);
|
|
137
|
+
for (const k of keys) if (!values.includes(k)) values.push(k);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `src` with every line and block comment blanked to spaces, strings and templates left intact. */
|
|
143
|
+
function blankComments(src) {
|
|
144
|
+
let out = '';
|
|
145
|
+
for (let i = 0; i < src.length; i++) {
|
|
146
|
+
const c = src[i];
|
|
147
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
148
|
+
out += c; i++;
|
|
149
|
+
while (i < src.length && src[i] !== c) { if (src[i] === '\\') { out += src[i]; i++; } out += src[i]; i++; }
|
|
150
|
+
out += src[i] ?? '';
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
154
|
+
while (i < src.length && src[i] !== '\n') { out += ' '; i++; }
|
|
155
|
+
out += '\n';
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
159
|
+
const end = src.indexOf('*/', i + 2);
|
|
160
|
+
const stop = end === -1 ? src.length : end + 2;
|
|
161
|
+
out += src.slice(i, stop).replace(/[^\n]/g, ' ');
|
|
162
|
+
i = stop - 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
out += c;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Index of the `}` matching the `{` at `open`, string-aware. -1 when unbalanced. */
|
|
171
|
+
function matchBrace(s, open) {
|
|
172
|
+
let depth = 0;
|
|
173
|
+
for (let i = open; i < s.length; i++) {
|
|
174
|
+
const c = s[i];
|
|
175
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
176
|
+
i++;
|
|
177
|
+
while (i < s.length && s[i] !== c) { if (s[i] === '\\') i++; i++; }
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (c === '{') depth++;
|
|
181
|
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
|
182
|
+
}
|
|
183
|
+
return -1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The top-level keys of an object-literal body (bare identifiers and quoted strings). */
|
|
187
|
+
function topLevelKeys(body) {
|
|
188
|
+
/** @type {string[]} */
|
|
189
|
+
const keys = [];
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let atKey = true;
|
|
192
|
+
for (let i = 0; i < body.length; i++) {
|
|
193
|
+
const c = body[i];
|
|
194
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
195
|
+
let j = i + 1;
|
|
196
|
+
let text = '';
|
|
197
|
+
while (j < body.length && body[j] !== c) { if (body[j] === '\\') j++; text += body[j]; j++; }
|
|
198
|
+
if (depth === 0 && atKey) { keys.push(text); atKey = false; }
|
|
199
|
+
i = j;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (c === '{' || c === '[' || c === '(') { depth++; continue; }
|
|
203
|
+
if (c === '}' || c === ']' || c === ')') { depth--; continue; }
|
|
204
|
+
if (depth === 0 && c === ',') { atKey = true; continue; }
|
|
205
|
+
if (depth === 0 && atKey && /[A-Za-z_$]/.test(c)) {
|
|
206
|
+
let j = i;
|
|
207
|
+
let word = '';
|
|
208
|
+
while (j < body.length && /[\w$]/.test(body[j])) word += body[j++];
|
|
209
|
+
keys.push(word);
|
|
210
|
+
atKey = false;
|
|
211
|
+
i = j - 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return keys;
|
|
215
|
+
}
|
|
216
|
+
|
|
51
217
|
/**
|
|
52
218
|
* The JSDoc header text (description + a11y obligations + token notes), with the
|
|
53
219
|
* `@example` block and the `@module`/`@param`-style tags dropped. This is the
|