@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.
@@ -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 `&lt;p class="..."&gt;`, 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
+ }
@@ -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