@nxds/eslint-plugin 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/canonical-step-set.json +9 -5
- package/src/rules/nx-class-conventions.js +154 -15
package/README.md
CHANGED
|
@@ -14,12 +14,16 @@ Requires `jsonc-eslint-parser`. Wired in the root `eslint.config.js` to target s
|
|
|
14
14
|
|
|
15
15
|
Enforces the `nx:` Tailwind-class conventions from `.claude/rules/shadcn-divergences.md` on class strings (string + single-quasi template literals): correct prefix order (`nx:` before every modifier, not `hover:nx:…`), no banned `accent` token, complete semantic token paths (`-background` / `-foreground` / `-subtle`), and no raw primitive colors (`nx:bg-blue-500`). Ported from the former `.claude/hooks/lint-nx-prefix.mjs` so the checks run in `pnpm lint` and the pre-commit hook. Wired for `packages/react/src/**` and `apps/**` `.tsx`.
|
|
16
16
|
|
|
17
|
+
Icons keep one size at every density, so the rule reports an icon sized from spacing. That is a numeric spacing step (`size-4`, `h-3`, `w-3`) or a value that reads a spacing variable, such as `size-[var(--nx-spacing-8)]`. It checks a variant whose subject is the `svg`, such as `[&_svg]:size-4` or `[&>svg:not(…)]:h-3`, and the `className` of an `Icon*` or `*Icon` JSX element. `has-[>svg]:size-8` and `[&:has(svg)]:size-9` size the box around the icon, so they pass. Use `size-icon-glyph-xs`, `-sm`, `-default`, `-lg` or `-xl`. They follow the UI scale. Stories are checked, because the docs pages copy their code. The docs app is not checked, and neither are Vue template `class` attributes.
|
|
18
|
+
|
|
17
19
|
One check reads a whole class-string scope rather than a single literal: `nx:transition-colors` beside a ring-painting `focus-visible:outline-*` class fades the focus ring in, because Tailwind expands `transition-colors` to a list carrying `outline-color`. Only a width or a colour paints a ring, so `outline-none`, `outline-hidden`, `outline-0`, `outline-offset-*` and the style keywords (`outline-solid` / `dashed` / `dotted` / `double`) are exempt. Use `nx:transition-control` / `nx:transition-field` instead — see `/foundations/focus`.
|
|
18
20
|
|
|
19
21
|
**What counts as one scope.** One class string; one call to `cva`, `cn`, `clsx` or `cx` — base, array elements and `variants` values together; or one `[…].join(' ')`, which collapses an array literal into a single attribute. That **over-approximates**: two sibling `variants` values of the same key are mutually exclusive and never share an element, and the rule reports the pair anyway. Over-reporting is the safe direction here, and the shape every field surface is written in — a base string plus a ring in a later array element — needs the call-wide scope to be caught at all.
|
|
20
22
|
|
|
21
23
|
Two things fall outside a scope and pass: an array that is never joined, whose elements are per-item class strings; and a string that only meets the rest at a call site — a shared `const`, an argument handed to another function, or a callback body the enclosing call takes with it. The four composer names must be written plainly, and `join` is the one member expression the walk reads, on an array literal receiver — so an aliased or chained composer (`twMerge`, `utils.cn`, a renamed import, `[…].filter(Boolean).join(' ')`) is invisible to it. Specimen surfaces are exempt from this check: the docs pages under `apps/docs/app/_pages`, where a pairing is the subject of the prose. Stories and the rest of the docs app are checked.
|
|
22
24
|
|
|
25
|
+
A second scope check covers icon line width. A class list that sizes `svg` children, such as `[&_svg]:size-…` or `[&>svg]:h-…`, must also include `nx:icon-stroke`, so every stroked icon draws the line for its size, from `--nx-icon-stroke-base` and `--nx-icon-stroke-ratio`. It must size them with a `size-icon-glyph-*` class, because `size-*`, `h-*` and `w-*` leave `--nx-icon-size` unset. Stories and the docs app are not checked.
|
|
26
|
+
|
|
23
27
|
### `@nxds/no-render-prop-types`
|
|
24
28
|
|
|
25
29
|
Enforces `.claude/rules/composition-over-render-props.md`: component props must not be typed as render callbacks (`(...) => ReactNode`) or component references (`ComponentType` / `FC` / `ElementType`). Event-handler-named props (`on*`) are exempt. Use `children` / named `ReactNode` slots or per-mode components instead. Third-party-mandated shapes (e.g. recharts) opt out with a scoped `eslint-disable` + reason.
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Union of every px value shipped across packages/core/tokens/semantic/spacing-{vega,lyra,maia,mira,nova,luma,sera}.json. Regenerate via `pnpm --filter @nxds/eslint-plugin refresh:canonical-set`. The shipped union may exceed the originally-intended scale; reconciliation tracked separately.",
|
|
3
3
|
"values": [
|
|
4
|
-
0, 2, 4, 6, 8,
|
|
5
|
-
34, 36, 38, 40, 42, 44, 46, 48,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
0, 2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 25,
|
|
5
|
+
26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62,
|
|
6
|
+
64, 66, 68, 70, 76, 78, 80, 82, 84, 86, 92, 94, 96, 98, 100, 102, 108, 110,
|
|
7
|
+
112, 114, 116, 118, 124, 126, 128, 130, 132, 134, 140, 142, 144, 146, 148,
|
|
8
|
+
150, 156, 158, 160, 162, 164, 166, 172, 174, 176, 178, 180, 182, 188, 190,
|
|
9
|
+
192, 194, 196, 198, 204, 206, 208, 210, 212, 214, 220, 222, 224, 226, 228,
|
|
10
|
+
230, 236, 238, 240, 242, 244, 246, 252, 254, 256, 258, 260, 262, 284, 286,
|
|
11
|
+
288, 290, 292, 294, 316, 318, 320, 322, 324, 326, 380, 382, 384, 386, 388,
|
|
12
|
+
390
|
|
9
13
|
]
|
|
10
14
|
}
|
|
@@ -35,6 +35,88 @@ export const RAW_LETTER_SPACINGS = [
|
|
|
35
35
|
'widest',
|
|
36
36
|
];
|
|
37
37
|
|
|
38
|
+
// A size from spacing follows density: a numeric spacing step (`size-4`), or an
|
|
39
|
+
// arbitrary value that reads a spacing variable
|
|
40
|
+
// (`size-[var(--nx-spacing-8)]`, `size-(--nx-spacing-8)`). Glyph tokens
|
|
41
|
+
// (`size-icon-glyph-sm`) and other arbitrary values (`size-[24px]`) do not.
|
|
42
|
+
// A leading `!` is Tailwind's important modifier.
|
|
43
|
+
const SPACING_SIZE = /^!?(?:size|h|w)-(?:\d|[[(].*--nx-spacing-)/;
|
|
44
|
+
|
|
45
|
+
// Any size utility: a spacing step, a glyph token or an arbitrary value.
|
|
46
|
+
const ANY_SIZE = /^!?(?:size|h|w)-/;
|
|
47
|
+
|
|
48
|
+
// A fixed size that leaves `--nx-icon-size` unset, such as `size-[24px]`. A size
|
|
49
|
+
// from spacing reports `densityIconSize` instead.
|
|
50
|
+
const PLAIN_SIZE = /^!?(?:size|h|w)-(?!icon-glyph-|\d|[[(].*--nx-spacing-)/;
|
|
51
|
+
|
|
52
|
+
// An `Icon*` or `*Icon` JSX element, such as `IconX` or `CheckIcon`.
|
|
53
|
+
const ICON_ELEMENT_NAME = /^Icon(?![a-z])|Icon$/;
|
|
54
|
+
|
|
55
|
+
/** The `nx:` classes in a class string. */
|
|
56
|
+
function nxClasses(raw) {
|
|
57
|
+
return raw.split(/\s+/).filter((token) => token.startsWith('nx:'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Splits a class at the `:` separators outside `[…]` and `(…)`. */
|
|
61
|
+
function splitVariants(className) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let start = 0;
|
|
65
|
+
for (let i = 0; i < className.length; i++) {
|
|
66
|
+
const char = className[i];
|
|
67
|
+
if (char === '[' || char === '(') depth++;
|
|
68
|
+
else if (char === ']' || char === ')') depth--;
|
|
69
|
+
else if (char === ':' && depth === 0) {
|
|
70
|
+
parts.push(className.slice(start, i));
|
|
71
|
+
start = i + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
parts.push(className.slice(start));
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A selector without its `[…]` and `(…)` groups. */
|
|
79
|
+
function withoutGroups(selector) {
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let kept = '';
|
|
82
|
+
for (const char of selector) {
|
|
83
|
+
if (char === '[' || char === '(') depth++;
|
|
84
|
+
else if (char === ']' || char === ')') depth--;
|
|
85
|
+
else if (depth === 0) kept += char;
|
|
86
|
+
}
|
|
87
|
+
return kept;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// An arbitrary variant whose subject is the `svg`, such as `[&_svg]` or
|
|
91
|
+
// `[&>svg:not(…)]`. In `has-[>svg]` and `[&:has(svg)]` the subject is the box
|
|
92
|
+
// around the icon.
|
|
93
|
+
function selectsSvg(variant) {
|
|
94
|
+
if (!variant.startsWith('[') || !variant.endsWith(']')) return false;
|
|
95
|
+
const compounds = withoutGroups(variant.slice(1, -1)).split(/[_>+~]/);
|
|
96
|
+
return /^svg(?![\w-])/.test(compounds.at(-1));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether an `nx:` class sizes an `svg` with a utility that `size` matches. */
|
|
100
|
+
function sizesSvg(token, size) {
|
|
101
|
+
const variants = splitVariants(token.slice('nx:'.length));
|
|
102
|
+
const utility = variants.pop();
|
|
103
|
+
return size.test(utility) && variants.some(selectsSvg);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sizesSvgFromSpacing(raw) {
|
|
107
|
+
return nxClasses(raw).some((token) => sizesSvg(token, SPACING_SIZE));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sizesFromSpacing(raw) {
|
|
111
|
+
return nxClasses(raw).some((token) =>
|
|
112
|
+
SPACING_SIZE.test(splitVariants(token).at(-1))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isIconElement(name) {
|
|
117
|
+
return name.type === 'JSXIdentifier' && ICON_ELEMENT_NAME.test(name.name);
|
|
118
|
+
}
|
|
119
|
+
|
|
38
120
|
const NAMED_VARIANT = String.raw`@?[\w-]+(?:\[[^\s]+?\])?(?:\/[\w-]+)?:`;
|
|
39
121
|
const ARBITRARY_VARIANT = String.raw`\[[^\s]+?\]:`;
|
|
40
122
|
const CHILD_VARIANT = String.raw`\*{1,2}:`;
|
|
@@ -72,21 +154,27 @@ const CHECKS = [
|
|
|
72
154
|
re: new RegExp(
|
|
73
155
|
`nx:${NX_MODIFIER_CHAIN}font-(?:${RAW_FONT_WEIGHTS.join('|')})\\b`
|
|
74
156
|
),
|
|
75
|
-
|
|
157
|
+
skipFile: isExampleFile,
|
|
76
158
|
},
|
|
77
159
|
{
|
|
78
160
|
messageId: 'rawLineHeight',
|
|
79
161
|
re: new RegExp(
|
|
80
162
|
`nx:${NX_MODIFIER_CHAIN}leading-(?:${RAW_LINE_HEIGHTS.join('|')}|\\d+|\\[)`
|
|
81
163
|
),
|
|
82
|
-
|
|
164
|
+
skipFile: isExampleFile,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
messageId: 'densityIconSize',
|
|
168
|
+
test: sizesSvgFromSpacing,
|
|
169
|
+
// Stories are checked: the docs pages copy their code into apps.
|
|
170
|
+
skipFile: isDocsFile,
|
|
83
171
|
},
|
|
84
172
|
{
|
|
85
173
|
messageId: 'rawLetterSpacing',
|
|
86
174
|
re: new RegExp(
|
|
87
175
|
`nx:${NX_MODIFIER_CHAIN}tracking-(?:${RAW_LETTER_SPACINGS.join('|')}|\\[)`
|
|
88
176
|
),
|
|
89
|
-
|
|
177
|
+
skipFile: isExampleFile,
|
|
90
178
|
},
|
|
91
179
|
];
|
|
92
180
|
|
|
@@ -105,6 +193,27 @@ function fadesItsFocusRing(scope) {
|
|
|
105
193
|
);
|
|
106
194
|
}
|
|
107
195
|
|
|
196
|
+
// A class list that sizes its `svg` children draws the icon, so it sets the
|
|
197
|
+
// icon's line with `nx:icon-stroke` too.
|
|
198
|
+
function leavesIconUnstroked(scope) {
|
|
199
|
+
const classes = nxClasses(scope);
|
|
200
|
+
if (classes.includes('nx:icon-stroke')) return false;
|
|
201
|
+
return classes.some((token) => sizesSvg(token, ANY_SIZE));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `size-icon-glyph-*` sets `--nx-icon-size`, the size the line is drawn for. Any
|
|
205
|
+
// other size must set the variable itself, under the same variants.
|
|
206
|
+
function sizesIconWithoutLine(scope) {
|
|
207
|
+
const classes = nxClasses(scope);
|
|
208
|
+
return classes.some((token) => {
|
|
209
|
+
if (!sizesSvg(token, PLAIN_SIZE)) return false;
|
|
210
|
+
const variants = splitVariants(token).slice(0, -1).join(':');
|
|
211
|
+
return !classes.some((other) =>
|
|
212
|
+
other.startsWith(`${variants}:[--nx-icon-size:`)
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
108
217
|
function isStoryFile(filename) {
|
|
109
218
|
return /\.stories\.[jt]sx?$/.test(filename);
|
|
110
219
|
}
|
|
@@ -113,8 +222,9 @@ function isDocsFile(filename) {
|
|
|
113
222
|
return /(?:^|[/\\])apps[/\\]docs[/\\]/.test(filename);
|
|
114
223
|
}
|
|
115
224
|
|
|
116
|
-
// What the
|
|
117
|
-
// as specimens and prose, and the docs app styles its own
|
|
225
|
+
// What the raw typography and stroke checks skip: stories and the docs app
|
|
226
|
+
// quote utilities as specimens and prose, and the docs app styles its own
|
|
227
|
+
// chrome off-system.
|
|
118
228
|
function isExampleFile(filename) {
|
|
119
229
|
return isStoryFile(filename) || isDocsFile(filename);
|
|
120
230
|
}
|
|
@@ -157,10 +267,7 @@ function isRawTypographyException(check, filename, raw) {
|
|
|
157
267
|
}
|
|
158
268
|
|
|
159
269
|
function shouldRunCheck(check, filename, raw) {
|
|
160
|
-
if (
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
if (isExampleFile(filename)) {
|
|
270
|
+
if (check.skipFile?.(filename)) {
|
|
164
271
|
return false;
|
|
165
272
|
}
|
|
166
273
|
return !isRawTypographyException(check, filename, raw);
|
|
@@ -169,12 +276,12 @@ function shouldRunCheck(check, filename, raw) {
|
|
|
169
276
|
function matchedMessageIds(raw, filename) {
|
|
170
277
|
const matched = new Set();
|
|
171
278
|
for (const check of CHECKS) {
|
|
172
|
-
const { messageId, re } = check;
|
|
173
279
|
if (!shouldRunCheck(check, filename, raw)) {
|
|
174
280
|
continue;
|
|
175
281
|
}
|
|
176
|
-
|
|
177
|
-
|
|
282
|
+
const matches = check.test ? check.test(raw) : check.re.test(raw);
|
|
283
|
+
if (matches) {
|
|
284
|
+
matched.add(check.messageId);
|
|
178
285
|
}
|
|
179
286
|
}
|
|
180
287
|
return matched;
|
|
@@ -255,7 +362,7 @@ export default {
|
|
|
255
362
|
type: 'problem',
|
|
256
363
|
docs: {
|
|
257
364
|
description:
|
|
258
|
-
'Enforce nx: Tailwind class conventions — correct prefix order, no banned `accent` token, complete semantic token paths, no raw primitive colors, no raw named font-size utilities,
|
|
365
|
+
'Enforce nx: Tailwind class conventions — correct prefix order, no banned `accent` token, complete semantic token paths, no raw primitive colors, no raw named font-size utilities, no icon sized from spacing, no ring-fading `transition-colors` on a focus-ring surface, and no sized `svg` children without `nx:icon-stroke`.',
|
|
259
366
|
},
|
|
260
367
|
schema: [],
|
|
261
368
|
messages: {
|
|
@@ -275,8 +382,14 @@ export default {
|
|
|
275
382
|
'Raw Tailwind line-height utility — let a typography composite own line-height instead of `nx:leading-*`.',
|
|
276
383
|
rawLetterSpacing:
|
|
277
384
|
'Raw Tailwind letter-spacing utility — use a typography composite such as `nx:typography-label-caps` instead of `nx:tracking-*`.',
|
|
385
|
+
densityIconSize:
|
|
386
|
+
'Icon size from spacing — a numeric `size-*`, `h-*` or `w-*`, or one that reads a `--nx-spacing-*` variable, on an `svg` selector (`[&_svg]:size-4`) or on an `Icon*` / `*Icon` element follows density. Use `size-icon-glyph-xs`, `-sm`, `-default`, `-lg` or `-xl` (12, 14, 16, 24 or 32px at the Default UI scale).',
|
|
278
387
|
ringFadingTransition:
|
|
279
388
|
'Ring-fading transition — `nx:transition-colors` includes `outline-color`, so this focus ring fades in instead of landing with the keypress. Use `nx:transition-control` (or `nx:transition-field` on a field surface).',
|
|
389
|
+
unstrokedIcon:
|
|
390
|
+
'Unstroked icon — this class list sizes its `svg` children (`[&_svg]:size-icon-glyph-default`), so add `nx:icon-stroke` to draw stroked icons at the icon line width.',
|
|
391
|
+
plainIconSize:
|
|
392
|
+
'Plain icon size — size `svg` children with a `size-icon-glyph-*` class, so the line matches the icon. `size-*`, `h-*` and `w-*` leave `--nx-icon-size` unset, so set it on the same selector for a size off the scale.',
|
|
280
393
|
},
|
|
281
394
|
},
|
|
282
395
|
create(context) {
|
|
@@ -290,14 +403,22 @@ export default {
|
|
|
290
403
|
}
|
|
291
404
|
|
|
292
405
|
function reportScope(node) {
|
|
293
|
-
if (
|
|
406
|
+
if (isInsideClassCall(node)) {
|
|
294
407
|
return;
|
|
295
408
|
}
|
|
296
409
|
const collected = [];
|
|
297
410
|
collectClassStrings(node, visitorKeys, collected);
|
|
298
|
-
|
|
411
|
+
const scope = collected.join(' ');
|
|
412
|
+
if (!isDocsSpecimenPage(filename) && fadesItsFocusRing(scope)) {
|
|
299
413
|
context.report({ node, messageId: 'ringFadingTransition' });
|
|
300
414
|
}
|
|
415
|
+
if (isExampleFile(filename)) return;
|
|
416
|
+
if (leavesIconUnstroked(scope)) {
|
|
417
|
+
context.report({ node, messageId: 'unstrokedIcon' });
|
|
418
|
+
}
|
|
419
|
+
if (sizesIconWithoutLine(scope)) {
|
|
420
|
+
context.report({ node, messageId: 'plainIconSize' });
|
|
421
|
+
}
|
|
301
422
|
}
|
|
302
423
|
|
|
303
424
|
return {
|
|
@@ -327,6 +448,24 @@ export default {
|
|
|
327
448
|
}
|
|
328
449
|
reportScope(node);
|
|
329
450
|
},
|
|
451
|
+
JSXOpeningElement(node) {
|
|
452
|
+
if (isDocsFile(filename) || !isIconElement(node.name)) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const className = node.attributes.find(
|
|
456
|
+
(attribute) =>
|
|
457
|
+
attribute.type === 'JSXAttribute' &&
|
|
458
|
+
attribute.name.name === 'className'
|
|
459
|
+
);
|
|
460
|
+
if (!className?.value) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const collected = [];
|
|
464
|
+
collectClassStrings(className.value, visitorKeys, collected);
|
|
465
|
+
if (collected.some(sizesFromSpacing)) {
|
|
466
|
+
context.report({ node: className, messageId: 'densityIconSize' });
|
|
467
|
+
}
|
|
468
|
+
},
|
|
330
469
|
};
|
|
331
470
|
},
|
|
332
471
|
};
|