@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `webjsui lint` orchestrator: walks the app, reads each module, runs the
|
|
3
|
+
* scanner, dispatches every class site to every enabled rule, and returns the
|
|
4
|
+
* violations. It does the filesystem work so the rules stay pure (the same
|
|
5
|
+
* split `@webjsdev/server`'s check runner uses: collect first, then hand pure
|
|
6
|
+
* rule functions the collected set).
|
|
7
|
+
*
|
|
8
|
+
* Scope (D7): `app/**`, `components/**`, `modules/**` and `lib/**` under the
|
|
9
|
+
* config's cwd, over `.ts .tsx .js .jsx .mts .mjs`; `node_modules`, `.webjs`,
|
|
10
|
+
* `dist` and `public` are never walked. `resolvedPaths.ui` (default
|
|
11
|
+
* `components/ui`) is skipped by default: a copied primitive legitimately owns
|
|
12
|
+
* structural values no variant can express (`ring-[3px]`, `[&_svg]:size-4`)
|
|
13
|
+
* and is what every other file's variants are measured against, so linting it
|
|
14
|
+
* with the app's rules is backwards. An app widens the scope with a negated
|
|
15
|
+
* ignore entry (`"!components/ui/**"`) or narrows it with more globs.
|
|
16
|
+
*
|
|
17
|
+
* @module lint
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
21
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
22
|
+
import { scanClassSites, collectHelperImports } from './scan.js';
|
|
23
|
+
import { readThemeTokens } from './theme-tokens.js';
|
|
24
|
+
import { extractHelperAxes } from '../registry/extract.js';
|
|
25
|
+
import { RULES, RULE_NAMES } from './rules/index.js';
|
|
26
|
+
|
|
27
|
+
const SCAN_DIRS = ['app', 'components', 'modules', 'lib'];
|
|
28
|
+
const EXTENSIONS = /\.(?:ts|tsx|js|jsx|mts|mjs)$/;
|
|
29
|
+
const NEVER_WALK = new Set(['node_modules', '.webjs', 'dist', 'public']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{
|
|
33
|
+
* rule: string, severity: 'warn'|'error', file: string,
|
|
34
|
+
* line: number, column: number, class: string, message: string, fix?: string,
|
|
35
|
+
* }} Violation
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize `config.lint.rules` into `{ name: { severity, allow } }` for the
|
|
40
|
+
* rules that are on. A bare severity string and the object form both land
|
|
41
|
+
* here; an absent rule is off.
|
|
42
|
+
*
|
|
43
|
+
* @param {any} lint the parsed `lint` block, or undefined
|
|
44
|
+
* @returns {Record<string, { severity: 'warn'|'error', allow: string[] }>}
|
|
45
|
+
*/
|
|
46
|
+
export function enabledRules(lint) {
|
|
47
|
+
/** @type {Record<string, { severity: 'warn'|'error', allow: string[] }>} */
|
|
48
|
+
const out = {};
|
|
49
|
+
const rules = lint?.rules ?? {};
|
|
50
|
+
for (const name of RULE_NAMES) {
|
|
51
|
+
const raw = rules[name];
|
|
52
|
+
if (raw === undefined) continue;
|
|
53
|
+
const severity = typeof raw === 'string' ? raw : raw.severity;
|
|
54
|
+
if (severity === 'off') continue;
|
|
55
|
+
out[name] = { severity, allow: typeof raw === 'string' ? [] : (raw.allow ?? []) };
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A minimal glob (`**`, `*`, `?`) to RegExp over a posix relative path. */
|
|
61
|
+
export function globToRegExp(glob) {
|
|
62
|
+
let re = '';
|
|
63
|
+
for (let i = 0; i < glob.length; i++) {
|
|
64
|
+
const c = glob[i];
|
|
65
|
+
if (c === '*') {
|
|
66
|
+
if (glob[i + 1] === '*') {
|
|
67
|
+
i++;
|
|
68
|
+
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
|
|
69
|
+
} else re += '[^/]*';
|
|
70
|
+
} else if (c === '?') re += '[^/]';
|
|
71
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
}
|
|
73
|
+
return new RegExp(`^${re}$`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toPosix(p) {
|
|
77
|
+
return p.split(sep).join('/');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk the D7 scope and return every lintable file, relative to `cwd` (posix).
|
|
82
|
+
* @param {string} cwd
|
|
83
|
+
*/
|
|
84
|
+
function collectFiles(cwd) {
|
|
85
|
+
/** @type {string[]} */
|
|
86
|
+
const files = [];
|
|
87
|
+
const walk = (dir) => {
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
if (NEVER_WALK.has(e.name)) continue;
|
|
92
|
+
const full = join(dir, e.name);
|
|
93
|
+
if (e.isDirectory()) walk(full);
|
|
94
|
+
else if (e.isFile() && EXTENSIONS.test(e.name)) files.push(toPosix(relative(cwd, full)));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const d of SCAN_DIRS) {
|
|
98
|
+
const full = join(cwd, d);
|
|
99
|
+
if (existsSync(full) && statSync(full).isDirectory()) walk(full);
|
|
100
|
+
}
|
|
101
|
+
return files.sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Lint the app at `cwd` against the parsed `components.json` config.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} cwd
|
|
108
|
+
* @param {ReturnType<import('../utils/get-config.js').getConfig>} config
|
|
109
|
+
* @returns {{ violations: Violation[], warnings: string[], configured: boolean }}
|
|
110
|
+
* `warnings` are non-violation notices (an unreadable theme path);
|
|
111
|
+
* `configured` is false when no rule is on, the opt-in guarantee.
|
|
112
|
+
*/
|
|
113
|
+
export function lintApp(cwd, config) {
|
|
114
|
+
const root = resolve(cwd);
|
|
115
|
+
const rules = enabledRules(config.lint);
|
|
116
|
+
/** @type {string[]} */
|
|
117
|
+
const warnings = [];
|
|
118
|
+
if (Object.keys(rules).length === 0) return { violations: [], warnings, configured: false };
|
|
119
|
+
|
|
120
|
+
// Ignore set: the ui dir by default, plus the app's entries. A `!` entry
|
|
121
|
+
// un-ignores whatever it MATCHES, so `!components/ui/**` widens the scope
|
|
122
|
+
// over the whole ui dir and `!components/ui/button.ts` over one file.
|
|
123
|
+
const uiRel = toPosix(relative(root, config.resolvedPaths.ui));
|
|
124
|
+
const ignore = [`${uiRel}/**`];
|
|
125
|
+
const unignore = [];
|
|
126
|
+
for (const entry of config.lint?.ignore ?? []) {
|
|
127
|
+
if (entry.startsWith('!')) unignore.push(entry.slice(1));
|
|
128
|
+
else ignore.push(entry);
|
|
129
|
+
}
|
|
130
|
+
// An entry also covers its own subtree, and a leading `./` or a trailing `/`
|
|
131
|
+
// is dropped, so the directory spellings (`app/legacy`, `app/legacy/`,
|
|
132
|
+
// `./app/legacy/**`) work instead of silently matching no file.
|
|
133
|
+
const toRes = (glob) => {
|
|
134
|
+
const g = glob.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
135
|
+
return [globToRegExp(g), globToRegExp(`${g}/**`)];
|
|
136
|
+
};
|
|
137
|
+
const ignoreRes = ignore.flatMap(toRes);
|
|
138
|
+
const unignoreRes = unignore.flatMap(toRes);
|
|
139
|
+
const isIgnored = (rel) => ignoreRes.some((re) => re.test(rel)) && !unignoreRes.some((re) => re.test(rel));
|
|
140
|
+
|
|
141
|
+
// Theme tokens, read once; no tokens disables no-raw-colors for the run.
|
|
142
|
+
let tokens = [];
|
|
143
|
+
let themePath = toPosix(relative(root, config.resolvedPaths.tailwindCss));
|
|
144
|
+
if (rules['no-raw-colors']) {
|
|
145
|
+
const theme = readThemeTokens(config.resolvedPaths.tailwindCss);
|
|
146
|
+
tokens = theme.tokens;
|
|
147
|
+
if (tokens.length === 0) {
|
|
148
|
+
warnings.push(`no-raw-colors is off for this run: no --color-* tokens found in a @theme block of ${themePath} (tailwind.css in components.json)`);
|
|
149
|
+
delete rules['no-raw-colors'];
|
|
150
|
+
if (Object.keys(rules).length === 0) return { violations: [], warnings, configured: true };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @type {Map<string, Record<string, Record<string, string[]>>>} axes per helper file */
|
|
155
|
+
const axesCache = new Map();
|
|
156
|
+
const axesForFile = (absPath) => {
|
|
157
|
+
const candidates = [absPath, `${absPath}.ts`, `${absPath}.js`];
|
|
158
|
+
for (const p of candidates) {
|
|
159
|
+
if (axesCache.has(p)) return { axes: axesCache.get(p), file: toPosix(relative(root, p)) };
|
|
160
|
+
let src;
|
|
161
|
+
try { src = readFileSync(p, 'utf8'); } catch { continue; }
|
|
162
|
+
const axes = extractHelperAxes(src);
|
|
163
|
+
axesCache.set(p, axes);
|
|
164
|
+
return { axes, file: toPosix(relative(root, p)) };
|
|
165
|
+
}
|
|
166
|
+
return { axes: {}, file: null };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** @type {Violation[]} */
|
|
170
|
+
const violations = [];
|
|
171
|
+
for (const rel of collectFiles(root)) {
|
|
172
|
+
if (isIgnored(rel)) continue;
|
|
173
|
+
const abs = join(root, rel);
|
|
174
|
+
let src;
|
|
175
|
+
try { src = readFileSync(abs, 'utf8'); } catch { continue; }
|
|
176
|
+
const imports = collectHelperImports(src, {
|
|
177
|
+
filePath: abs,
|
|
178
|
+
appRoot: root,
|
|
179
|
+
uiDir: config.resolvedPaths.ui,
|
|
180
|
+
utilsPath: config.resolvedPaths.utils,
|
|
181
|
+
});
|
|
182
|
+
// `cnNames` is passed even when empty ON PURPOSE: the scanner's own default
|
|
183
|
+
// recognizes a bare `cn`, but here `cn` counts only when imported from the
|
|
184
|
+
// configured utils alias, so an unrecognized `cn` never opens a call site.
|
|
185
|
+
const sites = scanClassSites(src, { helpers: imports.helpers, cnNames: imports.cnNames });
|
|
186
|
+
if (!sites.length) continue;
|
|
187
|
+
const axesFor = (helper) => {
|
|
188
|
+
const target = imports.helperFiles[helper];
|
|
189
|
+
if (!target) return { axes: {}, file: null };
|
|
190
|
+
const r = axesForFile(target);
|
|
191
|
+
return { axes: r.axes[imports.helperExports[helper] ?? helper] ?? {}, file: r.file };
|
|
192
|
+
};
|
|
193
|
+
for (const site of sites) {
|
|
194
|
+
for (const [name, conf] of Object.entries(rules)) {
|
|
195
|
+
const ctx = { tokens, themePath, allow: conf.allow, axesFor };
|
|
196
|
+
for (const v of RULES[name](site, ctx)) {
|
|
197
|
+
violations.push({ rule: name, severity: conf.severity, file: rel, ...v });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Two rules can flag one token (an arbitrary colour beside a helper); keep both, ordered.
|
|
203
|
+
violations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule));
|
|
204
|
+
return { violations, warnings, configured: true };
|
|
205
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The rule table `webjsui lint` dispatches over. Each rule is pure: `(site, ctx) => violations`. */
|
|
2
|
+
import { noRawColors } from './no-raw-colors.js';
|
|
3
|
+
import { noArbitraryValues } from './no-arbitrary-values.js';
|
|
4
|
+
import { noRestyle } from './no-restyle.js';
|
|
5
|
+
|
|
6
|
+
export const RULES = {
|
|
7
|
+
'no-raw-colors': noRawColors,
|
|
8
|
+
'no-arbitrary-values': noArbitraryValues,
|
|
9
|
+
'no-restyle': noRestyle,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const RULE_NAMES = Object.keys(RULES);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-arbitrary-values`: a token whose UTILITY carries a bracket (`p-[13px]`,
|
|
3
|
+
* `ring-[3px]`, `[padding:13px]`), which sidesteps the theme's scale. An
|
|
4
|
+
* arbitrary VARIANT (`[&_svg]:size-4`, `has-[>svg]:px-3`) never fires, and the
|
|
5
|
+
* `(--var)` shorthand never fires, per the grammar. `allow` takes category
|
|
6
|
+
* names (`layout`, `spacing`) and class-group ids (`rounded`).
|
|
7
|
+
*
|
|
8
|
+
* @module lint/rules/no-arbitrary-values
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseToken, isAllowed } from '../grammar.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('../scan.js').ClassSite} site
|
|
15
|
+
* @param {{ allow?: string[] }} ctx
|
|
16
|
+
* @returns {Array<{ line: number, column: number, class: string, message: string }>}
|
|
17
|
+
*/
|
|
18
|
+
export function noArbitraryValues(site, ctx) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const token of site.classes) {
|
|
21
|
+
const parsed = parseToken(token.name);
|
|
22
|
+
if (!parsed.arbitraryValue) continue;
|
|
23
|
+
if (isAllowed(parsed, ctx.allow)) continue;
|
|
24
|
+
const category = parsed.category ?? 'layout';
|
|
25
|
+
out.push({
|
|
26
|
+
line: token.line,
|
|
27
|
+
column: token.column,
|
|
28
|
+
class: token.name,
|
|
29
|
+
message: `${parsed.utility} is an arbitrary value (${category}${parsed.group ? `, group ${parsed.group}` : ''}). Use a theme scale step instead, or allow "${category}"${parsed.group && !parsed.group.startsWith('arbitrary..') ? ` or "${parsed.group}"` : ''} in the rule's allow list.`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-raw-colors`: a Tailwind palette utility (`text-red-600`, `bg-sky-500`)
|
|
3
|
+
* in a class site, where the app's theme declares a token that should carry
|
|
4
|
+
* the role instead.
|
|
5
|
+
*
|
|
6
|
+
* The message names ONLY utilities the app's own theme declares (read from the
|
|
7
|
+
* configured Tailwind CSS by `theme-tokens.js`), joined to the offender's own
|
|
8
|
+
* prefix, so `--color-destructive` offers `text-destructive` for a `text-`
|
|
9
|
+
* offender. A nearest-role suggestion is added only where the mapping is
|
|
10
|
+
* unambiguous and the role is actually declared. When the theme yields no
|
|
11
|
+
* tokens the orchestrator never calls this rule, because a message that
|
|
12
|
+
* cannot name an alternative reproduces the prose-guidance failure at higher
|
|
13
|
+
* volume.
|
|
14
|
+
*
|
|
15
|
+
* @module lint/rules/no-raw-colors
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parseToken } from '../grammar.js';
|
|
19
|
+
|
|
20
|
+
const PREFIXES = ['text', 'bg', 'border', 'ring', 'divide', 'outline', 'fill', 'stroke', 'from', 'via', 'to', 'shadow', 'decoration', 'placeholder', 'caret', 'accent'];
|
|
21
|
+
const FAMILIES = ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'slate', 'gray', 'zinc', 'neutral', 'stone'];
|
|
22
|
+
const STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
|
|
23
|
+
|
|
24
|
+
const RAW = new RegExp(`^(${PREFIXES.join('|')})-(${FAMILIES.join('|')})-(${STEPS.join('|')})$`);
|
|
25
|
+
|
|
26
|
+
/** Families whose role match is unambiguous. */
|
|
27
|
+
const ROLE = {
|
|
28
|
+
red: () => 'destructive',
|
|
29
|
+
rose: () => 'destructive',
|
|
30
|
+
slate: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'),
|
|
31
|
+
gray: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'),
|
|
32
|
+
zinc: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'),
|
|
33
|
+
neutral: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'),
|
|
34
|
+
stone: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The tokens worth listing first for a given prefix, in this order, then the rest of the theme. */
|
|
38
|
+
const LEAD = ['destructive', 'muted-foreground', 'primary', 'foreground', 'muted', 'accent', 'secondary', 'background', 'border'];
|
|
39
|
+
const LIST_MAX = 6;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {import('../scan.js').ClassSite} site
|
|
43
|
+
* @param {{ tokens: string[], themePath: string }} ctx
|
|
44
|
+
* @returns {Array<{ line: number, column: number, class: string, message: string, fix?: string }>}
|
|
45
|
+
*/
|
|
46
|
+
export function noRawColors(site, ctx) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const token of site.classes) {
|
|
49
|
+
const parsed = parseToken(token.name);
|
|
50
|
+
const m = RAW.exec(parsed.base);
|
|
51
|
+
if (!m) continue;
|
|
52
|
+
const [, prefix, family] = m;
|
|
53
|
+
const role = ROLE[family]?.(prefix);
|
|
54
|
+
const roleDeclared = role !== undefined && ctx.tokens.includes(role);
|
|
55
|
+
const ordered = [...LEAD.filter((t) => ctx.tokens.includes(t)), ...ctx.tokens.filter((t) => !LEAD.includes(t))];
|
|
56
|
+
const list = ordered.slice(0, LIST_MAX).map((t) => `${prefix}-${t}`);
|
|
57
|
+
let message = `${parsed.base} is a raw palette color. Use a theme token: ${list.join(', ')} (declared in ${ctx.themePath}).`;
|
|
58
|
+
if (roleDeclared) {
|
|
59
|
+
const what = family === 'red' || family === 'rose' ? 'error' : 'muted';
|
|
60
|
+
const noun = prefix === 'bg' ? 'surfaces' : prefix === 'text' ? 'text' : `${prefix} colors`;
|
|
61
|
+
message += ` For ${what} ${noun}, ${prefix}-${role} is the role match`;
|
|
62
|
+
message += role === 'destructive' && prefix === 'text' ? ', or use errorClass() from the kit.' : '.';
|
|
63
|
+
}
|
|
64
|
+
out.push({
|
|
65
|
+
line: token.line,
|
|
66
|
+
column: token.column,
|
|
67
|
+
class: token.name,
|
|
68
|
+
message,
|
|
69
|
+
...(roleDeclared ? { fix: swapBase(token.name, parsed.base, `${prefix}-${role}`) } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `raw` with its base utility swapped and everything else kept, so `fix` is a
|
|
77
|
+
* drop-in replacement for the reported `class`: the variants, the opacity
|
|
78
|
+
* modifier and a `!` all survive (`hover:text-red-600/50` to
|
|
79
|
+
* `hover:text-destructive/50`). The LAST occurrence is the utility, since an
|
|
80
|
+
* arbitrary variant ahead of it can spell the same text.
|
|
81
|
+
*/
|
|
82
|
+
function swapBase(raw, base, replacement) {
|
|
83
|
+
const at = raw.lastIndexOf(base);
|
|
84
|
+
if (at === -1) return replacement;
|
|
85
|
+
return raw.slice(0, at) + replacement + raw.slice(at + base.length);
|
|
86
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `no-restyle`: a class composed NEXT TO a kit helper, in either shape WebJs
|
|
3
|
+
* writes: a `cn()` call whose arguments include both a `*Class()` call and a
|
|
4
|
+
* string literal, or a `class` attribute holding both a `${helperCall()}` hole
|
|
5
|
+
* and static text. The scanner records the helpers a site is composed with,
|
|
6
|
+
* so the rule fires on every class of a site that has at least one.
|
|
7
|
+
*
|
|
8
|
+
* The message names the helper's REAL variant and size values, read from the
|
|
9
|
+
* app's own copied helper source through `extractHelperAxes` (the shared
|
|
10
|
+
* projector in `registry/extract.js`), never from a second parser and never
|
|
11
|
+
* invented: a helper matching neither authored shape yields no value list.
|
|
12
|
+
*
|
|
13
|
+
* `allow` takes category names and class-group ids. The documented starting
|
|
14
|
+
* configuration is `["layout", "rounded"]`, which is what lets the skill's
|
|
15
|
+
* sanctioned one-off (`cn(buttonClass({ size: 'none' }), 'w-9 h-9
|
|
16
|
+
* rounded-full')`) pass: `w-9 h-9` is layout, and `rounded-full` is the plain
|
|
17
|
+
* radius group granted by name without opening the whole `shape` category.
|
|
18
|
+
*
|
|
19
|
+
* @module lint/rules/no-restyle
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parseToken, isAllowed } from '../grammar.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('../scan.js').ClassSite} site
|
|
26
|
+
* @param {{ allow?: string[], axesFor: (helper: string) => { axes: Record<string, string[]>, file: string|null } }} ctx
|
|
27
|
+
* @returns {Array<{ line: number, column: number, class: string, message: string }>}
|
|
28
|
+
*/
|
|
29
|
+
export function noRestyle(site, ctx) {
|
|
30
|
+
if (!site.helpers.length) return [];
|
|
31
|
+
const out = [];
|
|
32
|
+
// Every composed helper is named; the axes come from the LAST one, since a
|
|
33
|
+
// later argument is what `cn` lets win.
|
|
34
|
+
const helper = site.helpers[site.helpers.length - 1];
|
|
35
|
+
const named = site.helpers.length === 1 ? helper : `${site.helpers.slice(0, -1).join(', ')} and ${helper}`;
|
|
36
|
+
const { axes, file } = ctx.axesFor(helper);
|
|
37
|
+
for (const token of site.classes) {
|
|
38
|
+
const parsed = parseToken(token.name);
|
|
39
|
+
if (isAllowed(parsed, ctx.allow)) continue;
|
|
40
|
+
let message = `${parsed.utility} overrides what ${named} already ${site.helpers.length === 1 ? 'sets' : 'set'}.`;
|
|
41
|
+
const axisNames = Object.keys(axes);
|
|
42
|
+
if (axisNames.length) {
|
|
43
|
+
const axis = pickAxis(parsed, axisNames);
|
|
44
|
+
message += ` Use a ${helper} ${axis}: ${axes[axis].join(', ')}`;
|
|
45
|
+
message += file ? ` (declared in ${file}).` : '.';
|
|
46
|
+
} else {
|
|
47
|
+
message += ` Pick a variant ${helper} exposes${file ? ` (declared in ${file})` : ''}, or allow "${parsed.category ?? 'layout'}"${parsed.group ? ` or "${parsed.group}"` : ''} in the rule's allow list.`;
|
|
48
|
+
}
|
|
49
|
+
out.push({ line: token.line, column: token.column, class: token.name, message });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A colour or typography override reads as a variant, and a size-ish one as a
|
|
56
|
+
* size when the helper has one. Font size and line height are the two
|
|
57
|
+
* typography groups a kit SIZE carries (`text-xs` in the button's `xs`), so they
|
|
58
|
+
* read as a size, while a weight or an underline stays a variant.
|
|
59
|
+
*/
|
|
60
|
+
function pickAxis(parsed, axisNames) {
|
|
61
|
+
const cat = parsed.category;
|
|
62
|
+
const sizeLike = cat === null || cat === 'spacing' || cat === 'shape' || parsed.group === 'font-size' || parsed.group === 'leading';
|
|
63
|
+
if (sizeLike && axisNames.includes('size')) return 'size';
|
|
64
|
+
if (axisNames.includes('variant')) return 'variant';
|
|
65
|
+
return axisNames[0];
|
|
66
|
+
}
|