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