@nxds/eslint-plugin 0.6.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 +20 -0
- package/package.json +1 -1
- package/src/canonical-step-set.json +9 -5
- package/src/index.js +2 -0
- package/src/rules/no-react-19-only.js +280 -0
- package/src/rules/nx-class-conventions.js +156 -17
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.
|
|
@@ -32,6 +36,22 @@ Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler props (`
|
|
|
32
36
|
|
|
33
37
|
Enforces `.claude/rules/code-comments.md`: JSDoc blocks must not carry `@default` or `@example` tags. The docs read prop defaults from code and usage from `docs` stories, so a tag is a second copy that can drift. Line comments and plain block comments are not checked. Wired in the root `eslint.config.js` for `packages/react/src` and `packages/vue/src`.
|
|
34
38
|
|
|
39
|
+
### `@nxds/no-react-19-only`
|
|
40
|
+
|
|
41
|
+
Reports React 19-only APIs in `packages/react`, the source `packages/react-18` is generated from. The generator adds `forwardRef` and changes nothing else, so a React 19-only call would compile there and then break on React 18. It reports:
|
|
42
|
+
|
|
43
|
+
| Pattern | Use instead |
|
|
44
|
+
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
45
|
+
| `use`, `useActionState`, `useOptimistic`, `useEffectEvent`, `Activity`, `captureOwnerStack` from `react` | An API React 18.3 has |
|
|
46
|
+
| `useFormStatus`, `requestFormReset`, `preload*`, `preinit*`, `preconnect`, `prefetchDNS` from `react-dom` | An API React 18.3 has |
|
|
47
|
+
| `<SomeContext value={…}>` | `<SomeContext.Provider value={…}>` |
|
|
48
|
+
| A ref callback that returns a cleanup | The node in state, with setup and teardown in an effect |
|
|
49
|
+
| `useDeferredValue(value, initialValue)` | One argument |
|
|
50
|
+
| A function passed to `action` or `formAction` | `onSubmit` or `onClick` |
|
|
51
|
+
| `<title>` outside an `<svg>`, `<meta>`, `<link>`, `<style precedence>` | Metadata the app's framework renders |
|
|
52
|
+
|
|
53
|
+
Wired in the root `eslint.config.js` for `packages/react/src` only. It is not in `nxdsComponentConfig`, because a React 19 app may use these APIs.
|
|
54
|
+
|
|
35
55
|
## Source of truth
|
|
36
56
|
|
|
37
57
|
The token-layer rules are self-contained: the canonical step set is `src/canonical-step-set.json`. The component rules map 1:1 to the `.claude/rules/*.md` cited above — the rule code plus those docs are the spec. Every rule has RuleTester coverage in `__tests__/`.
|
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
|
}
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import canonicalSpacingSteps from './rules/canonical-spacing-steps.js';
|
|
4
4
|
import noJsdocDataTags from './rules/no-jsdoc-data-tags.js';
|
|
5
5
|
import noMultiStatementJsxHandler from './rules/no-multi-statement-jsx-handler.js';
|
|
6
|
+
import noReact19Only from './rules/no-react-19-only.js';
|
|
6
7
|
import noRenderPropTypes from './rules/no-render-prop-types.js';
|
|
7
8
|
import nxClassConventions from './rules/nx-class-conventions.js';
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ const plugin = {
|
|
|
18
19
|
'no-render-prop-types': noRenderPropTypes,
|
|
19
20
|
'no-multi-statement-jsx-handler': noMultiStatementJsxHandler,
|
|
20
21
|
'no-jsdoc-data-tags': noJsdocDataTags,
|
|
22
|
+
'no-react-19-only': noReact19Only,
|
|
21
23
|
},
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// `packages/react-18` is generated from `packages/react` by wrapping each
|
|
2
|
+
// component in `forwardRef`, and nothing else changes. So `packages/react`
|
|
3
|
+
// must not use anything React 18 lacks: the generated copy would compile and
|
|
4
|
+
// then break at runtime. This rule reports the React 19-only APIs and
|
|
5
|
+
// behaviours an nxds component could reach for.
|
|
6
|
+
|
|
7
|
+
const REACT_ONLY_19 = new Set([
|
|
8
|
+
'use',
|
|
9
|
+
'useActionState',
|
|
10
|
+
'useOptimistic',
|
|
11
|
+
'useEffectEvent',
|
|
12
|
+
'Activity',
|
|
13
|
+
'captureOwnerStack',
|
|
14
|
+
'cache',
|
|
15
|
+
'cacheSignal',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const REACT_DOM_ONLY_19 = new Set([
|
|
19
|
+
'useFormStatus',
|
|
20
|
+
'requestFormReset',
|
|
21
|
+
'preload',
|
|
22
|
+
'preloadModule',
|
|
23
|
+
'preinit',
|
|
24
|
+
'preinitModule',
|
|
25
|
+
'preconnect',
|
|
26
|
+
'prefetchDNS',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const ONLY_19_BY_SOURCE = {
|
|
30
|
+
react: REACT_ONLY_19,
|
|
31
|
+
'react-dom': REACT_DOM_ONLY_19,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FUNCTION_TYPES = new Set([
|
|
35
|
+
'ArrowFunctionExpression',
|
|
36
|
+
'FunctionExpression',
|
|
37
|
+
'FunctionDeclaration',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isFunction(node) {
|
|
41
|
+
return node != null && FUNCTION_TYPES.has(node.type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether `fn` returns a function from its own body, not a nested one. */
|
|
45
|
+
function returnsFunction(fn) {
|
|
46
|
+
if (
|
|
47
|
+
fn.type === 'ArrowFunctionExpression' &&
|
|
48
|
+
fn.body.type !== 'BlockStatement'
|
|
49
|
+
) {
|
|
50
|
+
return isFunction(fn.body);
|
|
51
|
+
}
|
|
52
|
+
let found = false;
|
|
53
|
+
const visit = (node) => {
|
|
54
|
+
if (found || node == null || typeof node.type !== 'string') return;
|
|
55
|
+
if (node.type === 'ReturnStatement') {
|
|
56
|
+
if (isFunction(node.argument)) found = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isFunction(node)) return;
|
|
60
|
+
for (const [key, value] of Object.entries(node)) {
|
|
61
|
+
if (key === 'parent') continue;
|
|
62
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
63
|
+
else if (value && typeof value === 'object') visit(value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
visit(fn.body);
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The name a call's callee ends in: `useCallback` for `React.useCallback`. */
|
|
71
|
+
function calleeName(call) {
|
|
72
|
+
const { callee } = call;
|
|
73
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
74
|
+
if (
|
|
75
|
+
callee.type === 'MemberExpression' &&
|
|
76
|
+
callee.property.type === 'Identifier'
|
|
77
|
+
) {
|
|
78
|
+
return callee.property.name;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The function an identifier names, when it is declared in reach. */
|
|
84
|
+
function functionNamed(context, identifier) {
|
|
85
|
+
const scope = context.sourceCode.getScope(identifier);
|
|
86
|
+
let variable = null;
|
|
87
|
+
for (let current = scope; current && !variable; current = current.upper) {
|
|
88
|
+
variable = current.set.get(identifier.name) ?? null;
|
|
89
|
+
}
|
|
90
|
+
const definition = variable?.defs[0];
|
|
91
|
+
if (!definition) return null;
|
|
92
|
+
if (definition.node.type === 'FunctionDeclaration') return definition.node;
|
|
93
|
+
if (definition.node.type !== 'VariableDeclarator') return null;
|
|
94
|
+
const init = definition.node.init;
|
|
95
|
+
if (isFunction(init)) return init;
|
|
96
|
+
if (init?.type === 'CallExpression' && calleeName(init) === 'useCallback') {
|
|
97
|
+
return isFunction(init.arguments[0]) ? init.arguments[0] : null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expressionFunction(context, expression) {
|
|
103
|
+
if (isFunction(expression)) return expression;
|
|
104
|
+
if (expression.type === 'Identifier')
|
|
105
|
+
return functionNamed(context, expression);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function attributeExpression(attribute) {
|
|
110
|
+
const { value } = attribute;
|
|
111
|
+
return value?.type === 'JSXExpressionContainer' ? value.expression : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasAncestorElement(node, name) {
|
|
115
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
116
|
+
if (
|
|
117
|
+
current.type === 'JSXElement' &&
|
|
118
|
+
current.openingElement.name.type === 'JSXIdentifier' &&
|
|
119
|
+
current.openingElement.name.name === name
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default {
|
|
128
|
+
meta: {
|
|
129
|
+
type: 'problem',
|
|
130
|
+
docs: {
|
|
131
|
+
description:
|
|
132
|
+
'Disallow React 19-only APIs in source that packages/react-18 is generated from.',
|
|
133
|
+
},
|
|
134
|
+
schema: [],
|
|
135
|
+
messages: {
|
|
136
|
+
api: '`{{name}}` is React 19 only, so the generated packages/react-18 breaks. Use an API React 18.3 has.',
|
|
137
|
+
contextProvider:
|
|
138
|
+
'Rendering a context as a provider is React 19 only. Render `<{{name}}.Provider value={…}>`.',
|
|
139
|
+
refCleanup:
|
|
140
|
+
'A ref callback that returns a cleanup is React 18-incompatible: React 18 ignores the cleanup and calls the ref with `null`. Keep the node in state and set up and tear down in an effect.',
|
|
141
|
+
deferredInitialValue:
|
|
142
|
+
"`useDeferredValue`'s initial value is React 19 only. Pass one argument.",
|
|
143
|
+
formAction:
|
|
144
|
+
'A function `{{name}}` is React 19 only. Handle the form in `onSubmit` or the button in `onClick`.',
|
|
145
|
+
documentMetadata:
|
|
146
|
+
'`<{{name}}>` is hoisted into the document head by React 19 only. React 18 renders it in place.',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
create(context) {
|
|
150
|
+
// Local names bound to the `react` / `react-dom` namespace or default import.
|
|
151
|
+
const namespaces = new Map();
|
|
152
|
+
const contexts = new Set();
|
|
153
|
+
|
|
154
|
+
function reportMember(node, source, name) {
|
|
155
|
+
if (ONLY_19_BY_SOURCE[source]?.has(name)) {
|
|
156
|
+
context.report({ node, messageId: 'api', data: { name } });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
ImportDeclaration(node) {
|
|
162
|
+
const source = node.source.value;
|
|
163
|
+
if (!(source in ONLY_19_BY_SOURCE)) return;
|
|
164
|
+
for (const specifier of node.specifiers) {
|
|
165
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
166
|
+
const imported =
|
|
167
|
+
specifier.imported.name ?? specifier.imported.value;
|
|
168
|
+
reportMember(specifier, source, imported);
|
|
169
|
+
} else {
|
|
170
|
+
namespaces.set(specifier.local.name, source);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
MemberExpression(node) {
|
|
176
|
+
if (
|
|
177
|
+
node.object.type !== 'Identifier' ||
|
|
178
|
+
node.property.type !== 'Identifier'
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const source = namespaces.get(node.object.name);
|
|
183
|
+
if (source) reportMember(node.property, source, node.property.name);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
JSXMemberExpression(node) {
|
|
187
|
+
if (node.object.type !== 'JSXIdentifier') return;
|
|
188
|
+
const source = namespaces.get(node.object.name);
|
|
189
|
+
if (source) reportMember(node.property, source, node.property.name);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
VariableDeclarator(node) {
|
|
193
|
+
if (
|
|
194
|
+
node.id.type === 'Identifier' &&
|
|
195
|
+
node.init?.type === 'CallExpression' &&
|
|
196
|
+
calleeName(node.init) === 'createContext'
|
|
197
|
+
) {
|
|
198
|
+
contexts.add(node.id.name);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
CallExpression(node) {
|
|
203
|
+
if (
|
|
204
|
+
calleeName(node) === 'useDeferredValue' &&
|
|
205
|
+
node.arguments.length > 1
|
|
206
|
+
) {
|
|
207
|
+
context.report({ node, messageId: 'deferredInitialValue' });
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
JSXOpeningElement(node) {
|
|
212
|
+
if (node.name.type !== 'JSXIdentifier') return;
|
|
213
|
+
const { name } = node.name;
|
|
214
|
+
const attributes = node.attributes.filter(
|
|
215
|
+
(attribute) => attribute.type === 'JSXAttribute'
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const isContext = contexts.has(name) || /^[A-Z]\w*Context$/.test(name);
|
|
219
|
+
if (
|
|
220
|
+
isContext &&
|
|
221
|
+
attributes.some((attribute) => attribute.name.name === 'value')
|
|
222
|
+
) {
|
|
223
|
+
context.report({
|
|
224
|
+
node,
|
|
225
|
+
messageId: 'contextProvider',
|
|
226
|
+
data: { name },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (name === 'meta' || name === 'link') {
|
|
231
|
+
context.report({
|
|
232
|
+
node,
|
|
233
|
+
messageId: 'documentMetadata',
|
|
234
|
+
data: { name },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (name === 'title' && !hasAncestorElement(node.parent, 'svg')) {
|
|
238
|
+
context.report({
|
|
239
|
+
node,
|
|
240
|
+
messageId: 'documentMetadata',
|
|
241
|
+
data: { name },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
name === 'style' &&
|
|
246
|
+
attributes.some((attribute) => attribute.name.name === 'precedence')
|
|
247
|
+
) {
|
|
248
|
+
context.report({
|
|
249
|
+
node,
|
|
250
|
+
messageId: 'documentMetadata',
|
|
251
|
+
data: { name },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const attribute of attributes) {
|
|
256
|
+
const attributeName = attribute.name.name;
|
|
257
|
+
const expression = attributeExpression(attribute);
|
|
258
|
+
if (!expression) continue;
|
|
259
|
+
|
|
260
|
+
if (attributeName === 'ref') {
|
|
261
|
+
const fn = expressionFunction(context, expression);
|
|
262
|
+
if (fn && returnsFunction(fn)) {
|
|
263
|
+
context.report({ node: attribute, messageId: 'refCleanup' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
(attributeName === 'action' || attributeName === 'formAction') &&
|
|
268
|
+
expressionFunction(context, expression)
|
|
269
|
+
) {
|
|
270
|
+
context.report({
|
|
271
|
+
node: attribute,
|
|
272
|
+
messageId: 'formAction',
|
|
273
|
+
data: { name: attributeName },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
@@ -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
|
}
|
|
@@ -129,14 +239,14 @@ function isDocsSpecimenPage(filename) {
|
|
|
129
239
|
const RAW_TYPOGRAPHY_EXCEPTIONS = [
|
|
130
240
|
{
|
|
131
241
|
filename:
|
|
132
|
-
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
|
|
242
|
+
/(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
|
|
133
243
|
className:
|
|
134
244
|
'nx:flex nx:size-full nx:items-center nx:justify-center nx:rounded-[inherit] nx:bg-muted nx:text-foreground nx:font-medium nx:leading-none',
|
|
135
245
|
messageIds: new Set(['rawFontWeight', 'rawLineHeight']),
|
|
136
246
|
},
|
|
137
247
|
{
|
|
138
248
|
filename:
|
|
139
|
-
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
|
|
249
|
+
/(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
|
|
140
250
|
className: 'nx:text-foreground nx:font-mono nx:font-medium nx:tabular-nums',
|
|
141
251
|
messageIds: new Set(['rawFontWeight']),
|
|
142
252
|
},
|
|
@@ -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
|
};
|