@nxds/eslint-plugin 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +54 -2
- package/package.json +53 -3
- package/src/canonical-step-set.json +10 -0
- package/src/config.js +50 -0
- package/src/index.js +25 -0
- package/src/rules/canonical-spacing-steps.js +74 -0
- package/src/rules/no-jsdoc-data-tags.js +54 -0
- package/src/rules/no-multi-statement-jsx-handler.js +121 -0
- package/src/rules/no-render-prop-types.js +136 -0
- package/src/rules/nx-class-conventions.js +332 -0
- package/src/tailwind.js +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nexus Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nxds/eslint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Custom ESLint rules for the nxds design system. They split into two groups: **token-layer** rules that backfill what the deleted `--nx-size-*` primitive layer used to enforce, and **component-authoring** rules that machine-enforce conventions documented in `.claude/rules/` (so they fail `pnpm lint` and the pre-commit hook for every contributor, not just code review).
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
### `@nxds/canonical-spacing-steps`
|
|
8
|
+
|
|
9
|
+
Flags any px value in `packages/core/tokens/semantic/spacing-*.json` that is not in the canonical step set. The canonical set is the union of every px value currently shipped across the 7 mode files; see `src/canonical-step-set.json`. Refresh that file when a mode file legitimately introduces a new value.
|
|
10
|
+
|
|
11
|
+
Requires `jsonc-eslint-parser`. Wired in the root `eslint.config.js` to target spacing mode files only.
|
|
12
|
+
|
|
13
|
+
### `@nxds/nx-class-conventions`
|
|
14
|
+
|
|
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
|
+
|
|
17
|
+
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
|
+
|
|
19
|
+
**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
|
+
|
|
21
|
+
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
|
+
|
|
23
|
+
### `@nxds/no-render-prop-types`
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
### `@nxds/no-multi-statement-jsx-handler`
|
|
28
|
+
|
|
29
|
+
Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler props (`onClick`, `onChange`, …) with 3+ statements, or containing a nested callback-object argument, must be extracted to a named function above `return`. One- and two-statement handlers stay inline.
|
|
30
|
+
|
|
31
|
+
### `@nxds/no-jsdoc-data-tags`
|
|
32
|
+
|
|
33
|
+
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
|
+
|
|
35
|
+
## Source of truth
|
|
36
|
+
|
|
37
|
+
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__/`.
|
|
38
|
+
|
|
39
|
+
## Shareable config
|
|
40
|
+
|
|
41
|
+
`@nxds/eslint-plugin/config` exports small flat-config helpers for consumers
|
|
42
|
+
that want the nxds rules without copying this monorepo's full lint setup:
|
|
43
|
+
|
|
44
|
+
- `nxdsComponentConfig({ files })` wires the component-authoring rules.
|
|
45
|
+
- `nxdsSpacingTokenConfig({ files, parser })` wires the spacing-token rule; pass
|
|
46
|
+
`jsonc-eslint-parser` when linting JSON token files.
|
|
47
|
+
|
|
48
|
+
`@nxds/eslint-plugin/tailwind` exports `nxdsTailwindClassesConfig({ files,
|
|
49
|
+
entryPoint })`, which wires `eslint-plugin-better-tailwindcss`'s
|
|
50
|
+
`no-unknown-classes` against the stylesheet that builds `files`. Under
|
|
51
|
+
`prefix(nx)` it reports a bare utility, a typo, and any class the theme no
|
|
52
|
+
longer emits. `eslint-plugin-better-tailwindcss` is an optional peer dependency:
|
|
53
|
+
install it only if you import this subpath. `entryPoint` must be an absolute
|
|
54
|
+
path — tools such as `next build` lint from their own directory — and a
|
|
55
|
+
relative one throws.
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxds/eslint-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ESLint rules that enforce nxds conventions in copied components.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"nxds",
|
|
8
|
+
"design-system",
|
|
9
|
+
"eslint",
|
|
10
|
+
"eslintplugin",
|
|
11
|
+
"eslint-plugin",
|
|
12
|
+
"tailwindcss"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://nxds.dev/getting-started/install#optional-add-lint-rules",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/nexuslabs-ai/nxds/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.19.0"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/nexuslabs-ai/nxds.git",
|
|
24
|
+
"directory": "packages/eslint-plugin"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"main": "./src/index.js",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.js",
|
|
32
|
+
"./config": "./src/config.js",
|
|
33
|
+
"./tailwind": "./src/tailwind.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"jsonc-eslint-parser": "^3.1.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"eslint-plugin-better-tailwindcss": "^4.7.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"eslint-plugin-better-tailwindcss": "^4.7.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"eslint-plugin-better-tailwindcss": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"scripts": {
|
|
54
|
+
"refresh:canonical-set": "node scripts/refresh-canonical-set.js"
|
|
55
|
+
}
|
|
6
56
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
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
|
+
"values": [
|
|
4
|
+
0, 2, 4, 6, 8, 9, 10, 11, 12, 14, 15, 16, 18, 20, 22, 24, 26, 28, 30, 32,
|
|
5
|
+
34, 36, 38, 40, 42, 44, 46, 48, 52, 56, 60, 64, 68, 76, 80, 84, 92, 96, 100,
|
|
6
|
+
108, 112, 116, 120, 124, 128, 132, 140, 144, 148, 156, 160, 164, 172, 176,
|
|
7
|
+
180, 188, 192, 196, 204, 208, 210, 212, 220, 224, 228, 236, 240, 244, 252,
|
|
8
|
+
256, 260, 284, 288, 292, 316, 320, 324, 380, 384, 388
|
|
9
|
+
]
|
|
10
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import plugin from './index.js';
|
|
2
|
+
|
|
3
|
+
export const componentRuleSet = {
|
|
4
|
+
'@nxds/nx-class-conventions': 'error',
|
|
5
|
+
'@nxds/no-render-prop-types': 'error',
|
|
6
|
+
'@nxds/no-multi-statement-jsx-handler': 'error',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const spacingTokenRuleSet = {
|
|
10
|
+
'@nxds/canonical-spacing-steps': 'error',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function nxdsComponentConfig(options = {}) {
|
|
14
|
+
const { files } = options;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...(files ? { files } : {}),
|
|
18
|
+
plugins: {
|
|
19
|
+
'@nxds': plugin,
|
|
20
|
+
},
|
|
21
|
+
rules: componentRuleSet,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nxdsSpacingTokenConfig(options = {}) {
|
|
26
|
+
const { files, parser } = options;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...(files ? { files } : {}),
|
|
30
|
+
...(parser
|
|
31
|
+
? {
|
|
32
|
+
languageOptions: {
|
|
33
|
+
parser,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
plugins: {
|
|
38
|
+
'@nxds': plugin,
|
|
39
|
+
},
|
|
40
|
+
rules: spacingTokenRuleSet,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
plugin,
|
|
46
|
+
componentRuleSet,
|
|
47
|
+
spacingTokenRuleSet,
|
|
48
|
+
nxdsComponentConfig,
|
|
49
|
+
nxdsSpacingTokenConfig,
|
|
50
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
import canonicalSpacingSteps from './rules/canonical-spacing-steps.js';
|
|
4
|
+
import noJsdocDataTags from './rules/no-jsdoc-data-tags.js';
|
|
5
|
+
import noMultiStatementJsxHandler from './rules/no-multi-statement-jsx-handler.js';
|
|
6
|
+
import noRenderPropTypes from './rules/no-render-prop-types.js';
|
|
7
|
+
import nxClassConventions from './rules/nx-class-conventions.js';
|
|
8
|
+
|
|
9
|
+
// Derive from package.json so ESLint's --cache / --print-config, which key off
|
|
10
|
+
// meta.version, don't desync when changesets bumps the published version.
|
|
11
|
+
const { name, version } = createRequire(import.meta.url)('../package.json');
|
|
12
|
+
|
|
13
|
+
const plugin = {
|
|
14
|
+
meta: { name, version },
|
|
15
|
+
rules: {
|
|
16
|
+
'canonical-spacing-steps': canonicalSpacingSteps,
|
|
17
|
+
'nx-class-conventions': nxClassConventions,
|
|
18
|
+
'no-render-prop-types': noRenderPropTypes,
|
|
19
|
+
'no-multi-statement-jsx-handler': noMultiStatementJsxHandler,
|
|
20
|
+
'no-jsdoc-data-tags': noJsdocDataTags,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default plugin;
|
|
25
|
+
export const { rules } = plugin;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const canonical = JSON.parse(
|
|
7
|
+
readFileSync(join(__dirname, '..', 'canonical-step-set.json'), 'utf8')
|
|
8
|
+
);
|
|
9
|
+
const allowed = new Set(canonical.values);
|
|
10
|
+
|
|
11
|
+
function summarizeAllowed(set) {
|
|
12
|
+
const arr = [...set].sort((a, b) => a - b);
|
|
13
|
+
if (arr.length <= 10) return arr.join(', ');
|
|
14
|
+
return `${arr.slice(0, 6).join(', ')}, … ${arr[arr.length - 1]} (${arr.length} total)`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readNumericLiteral(node) {
|
|
18
|
+
if (!node) return null;
|
|
19
|
+
if (node.type === 'JSONLiteral' && typeof node.value === 'number') {
|
|
20
|
+
return node.value;
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
node.type === 'JSONUnaryExpression' &&
|
|
24
|
+
node.operator === '-' &&
|
|
25
|
+
node.argument?.type === 'JSONLiteral' &&
|
|
26
|
+
typeof node.argument.value === 'number'
|
|
27
|
+
) {
|
|
28
|
+
return -node.argument.value;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
meta: {
|
|
35
|
+
type: 'problem',
|
|
36
|
+
docs: {
|
|
37
|
+
description:
|
|
38
|
+
'Disallow px values in spacing mode files outside the canonical step set',
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
messages: {
|
|
42
|
+
offGrid:
|
|
43
|
+
'{{value}}px is not in the canonical spacing step set. Allowed: {{allowed}}. Refresh src/canonical-step-set.json via `pnpm --filter @nxds/eslint-plugin refresh:canonical-set` if a mode file legitimately introduces a new value.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
return {
|
|
48
|
+
'JSONProperty[key.value="$value"] > JSONObjectExpression'(node) {
|
|
49
|
+
let valueProp, unitProp;
|
|
50
|
+
for (const prop of node.properties) {
|
|
51
|
+
if (prop.type !== 'JSONProperty') continue;
|
|
52
|
+
const key = prop.key?.value;
|
|
53
|
+
if (key === 'value') valueProp = prop.value;
|
|
54
|
+
else if (key === 'unit') unitProp = prop.value;
|
|
55
|
+
}
|
|
56
|
+
if (!unitProp || unitProp.type !== 'JSONLiteral') return;
|
|
57
|
+
if (unitProp.value !== 'px') return;
|
|
58
|
+
|
|
59
|
+
const n = readNumericLiteral(valueProp);
|
|
60
|
+
if (n === null) return;
|
|
61
|
+
if (allowed.has(n)) return;
|
|
62
|
+
|
|
63
|
+
context.report({
|
|
64
|
+
node: valueProp,
|
|
65
|
+
messageId: 'offGrid',
|
|
66
|
+
data: {
|
|
67
|
+
value: String(n),
|
|
68
|
+
allowed: summarizeAllowed(allowed),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Enforces `.claude/rules/code-comments.md`: comments are prose, never data.
|
|
2
|
+
// The docs read prop defaults from code and usage examples from `docs`
|
|
3
|
+
// stories, so a `@default` or `@example` tag is a second copy that can drift.
|
|
4
|
+
|
|
5
|
+
// TypeScript reads a tag at the start of a line, after its `*`, or after
|
|
6
|
+
// whitespace mid-line.
|
|
7
|
+
const TAG = /(^\s*\*?\s*|\s)@(default|example)\b/;
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
'Disallow `@default` and `@example` JSDoc tags; defaults come from code and examples from docs stories.',
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
default:
|
|
19
|
+
'`@default` duplicates a default the code sets. Delete it, and say so in the description if a dependency sets the default.',
|
|
20
|
+
example:
|
|
21
|
+
'`@example` is an untested copy of usage. Delete it and show the usage in a `docs` story.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
Program() {
|
|
27
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
28
|
+
if (comment.type !== 'Block' || !comment.value.startsWith('*')) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
comment.value.split('\n').forEach((text, index) => {
|
|
33
|
+
const match = TAG.exec(text);
|
|
34
|
+
if (!match) return;
|
|
35
|
+
|
|
36
|
+
const line = comment.loc.start.line + index;
|
|
37
|
+
// The first line starts after the comment's own `/*`.
|
|
38
|
+
const column =
|
|
39
|
+
(index === 0 ? comment.loc.start.column + 2 : 0) +
|
|
40
|
+
match.index +
|
|
41
|
+
match[1].length;
|
|
42
|
+
context.report({
|
|
43
|
+
loc: {
|
|
44
|
+
start: { line, column },
|
|
45
|
+
end: { line, column: column + match[2].length + 1 },
|
|
46
|
+
},
|
|
47
|
+
messageId: match[2],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler
|
|
2
|
+
// props with 3+ statements, or containing a callback-object argument (e.g.,
|
|
3
|
+
// `mutation.mutate(input, { onSuccess, onError })`), must be extracted to a
|
|
4
|
+
// named function above `return`. One- and two-statement handlers stay inline.
|
|
5
|
+
|
|
6
|
+
const HANDLER_NAME = /^on[A-Z]/;
|
|
7
|
+
|
|
8
|
+
function countStatements(body) {
|
|
9
|
+
if (body.type !== 'BlockStatement') {
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
return body.body.length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function callExpressionHasCallbackObject(expr) {
|
|
16
|
+
if (!expr || expr.type !== 'CallExpression') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
for (const arg of expr.arguments) {
|
|
20
|
+
if (arg.type !== 'ObjectExpression') {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
for (const prop of arg.properties) {
|
|
24
|
+
if (
|
|
25
|
+
prop.type === 'Property' &&
|
|
26
|
+
prop.key.type === 'Identifier' &&
|
|
27
|
+
HANDLER_NAME.test(prop.key.name)
|
|
28
|
+
) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasCallbackObjectArgument(body) {
|
|
37
|
+
// Expression-body arrow: `() => mutate(x, { onSuccess, onError })`.
|
|
38
|
+
if (body.type !== 'BlockStatement') {
|
|
39
|
+
return callExpressionHasCallbackObject(body);
|
|
40
|
+
}
|
|
41
|
+
for (const stmt of body.body) {
|
|
42
|
+
const expr =
|
|
43
|
+
stmt.type === 'ExpressionStatement'
|
|
44
|
+
? stmt.expression
|
|
45
|
+
: stmt.type === 'ReturnStatement'
|
|
46
|
+
? stmt.argument
|
|
47
|
+
: null;
|
|
48
|
+
if (callExpressionHasCallbackObject(expr)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'problem',
|
|
58
|
+
docs: {
|
|
59
|
+
description:
|
|
60
|
+
'Disallow multi-statement or callback-nesting inline arrow functions in JSX handler props; extract to a named function above `return`.',
|
|
61
|
+
},
|
|
62
|
+
schema: [
|
|
63
|
+
{
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
maxStatements: { type: 'integer', minimum: 1 },
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
messages: {
|
|
72
|
+
tooManyStatements:
|
|
73
|
+
'Inline JSX handler "{{name}}" has {{count}} statements — extract a named function above `return`.',
|
|
74
|
+
nestedCallback:
|
|
75
|
+
'Inline JSX handler "{{name}}" contains nested callback object(s) — extract a named function above `return`.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
const options = context.options[0] ?? {};
|
|
80
|
+
const maxStatements = options.maxStatements ?? 2;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
JSXAttribute(node) {
|
|
84
|
+
if (!node.name || node.name.type !== 'JSXIdentifier') {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const name = node.name.name;
|
|
88
|
+
if (!HANDLER_NAME.test(name)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const value = node.value;
|
|
92
|
+
if (!value || value.type !== 'JSXExpressionContainer') {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const expr = value.expression;
|
|
96
|
+
if (
|
|
97
|
+
expr.type !== 'ArrowFunctionExpression' &&
|
|
98
|
+
expr.type !== 'FunctionExpression'
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const count = countStatements(expr.body);
|
|
103
|
+
if (count > maxStatements) {
|
|
104
|
+
context.report({
|
|
105
|
+
node,
|
|
106
|
+
messageId: 'tooManyStatements',
|
|
107
|
+
data: { name, count },
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (hasCallbackObjectArgument(expr.body)) {
|
|
112
|
+
context.report({
|
|
113
|
+
node,
|
|
114
|
+
messageId: 'nestedCallback',
|
|
115
|
+
data: { name },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Enforces `.claude/rules/composition-over-render-props.md`: component props
|
|
2
|
+
// should not be typed as render callbacks (`(...) => ReactNode`), component
|
|
3
|
+
// references (`ComponentType<...>`, `FC<...>`), or unions thereof. Event
|
|
4
|
+
// handler props (`onClick`, `onSubmit`, ...) return data, not JSX, and are
|
|
5
|
+
// exempt.
|
|
6
|
+
//
|
|
7
|
+
// Known scope gaps — these patterns sneak through the AST visitors below and
|
|
8
|
+
// must be caught by code review:
|
|
9
|
+
// - Render-shaped props inherited via `Pick<Props, 'renderItem'>`.
|
|
10
|
+
// - Prop shapes derived from `z.infer<typeof schema>` (no TSPropertySignature
|
|
11
|
+
// in the source AST — the shape comes from a type expression).
|
|
12
|
+
// - Destructured arrow-fn parameters typed inline (no enclosing
|
|
13
|
+
// `TSInterfaceBody` / `TSTypeLiteral` — checker walks property signatures
|
|
14
|
+
// only).
|
|
15
|
+
|
|
16
|
+
const EVENT_HANDLER = /^on[A-Z]/;
|
|
17
|
+
|
|
18
|
+
const REACT_NODE_NAMES = new Set([
|
|
19
|
+
'ReactNode',
|
|
20
|
+
'ReactElement',
|
|
21
|
+
'ReactChild',
|
|
22
|
+
'ReactFragment',
|
|
23
|
+
'JSX.Element',
|
|
24
|
+
'React.ReactNode',
|
|
25
|
+
'React.ReactElement',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const COMPONENT_TYPE_NAMES = new Set([
|
|
29
|
+
'ComponentType',
|
|
30
|
+
'FC',
|
|
31
|
+
'FunctionComponent',
|
|
32
|
+
'ElementType',
|
|
33
|
+
'React.ComponentType',
|
|
34
|
+
'React.FC',
|
|
35
|
+
'React.FunctionComponent',
|
|
36
|
+
'React.ElementType',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function typeReferenceName(node) {
|
|
40
|
+
if (node.type !== 'TSTypeReference') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const t = node.typeName;
|
|
44
|
+
if (t.type === 'Identifier') {
|
|
45
|
+
return t.name;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
t.type === 'TSQualifiedName' &&
|
|
49
|
+
t.left.type === 'Identifier' &&
|
|
50
|
+
t.right.type === 'Identifier'
|
|
51
|
+
) {
|
|
52
|
+
return `${t.left.name}.${t.right.name}`;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function returnsReactNode(fnType) {
|
|
58
|
+
if (fnType.type !== 'TSFunctionType') {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const ret = fnType.returnType?.typeAnnotation;
|
|
62
|
+
if (!ret || ret.type !== 'TSTypeReference') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const name = typeReferenceName(ret);
|
|
66
|
+
return name !== null && REACT_NODE_NAMES.has(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isComponentTypeRef(node) {
|
|
70
|
+
const name = typeReferenceName(node);
|
|
71
|
+
return name !== null && COMPONENT_TYPE_NAMES.has(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findViolation(typeNode) {
|
|
75
|
+
if (typeNode.type === 'TSFunctionType' && returnsReactNode(typeNode)) {
|
|
76
|
+
return { kind: 'renderCallback' };
|
|
77
|
+
}
|
|
78
|
+
if (typeNode.type === 'TSTypeReference' && isComponentTypeRef(typeNode)) {
|
|
79
|
+
return { kind: 'componentAsProp', typeName: typeReferenceName(typeNode) };
|
|
80
|
+
}
|
|
81
|
+
if (typeNode.type === 'TSUnionType') {
|
|
82
|
+
for (const member of typeNode.types) {
|
|
83
|
+
const v = findViolation(member);
|
|
84
|
+
if (v) {
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
meta: {
|
|
94
|
+
type: 'problem',
|
|
95
|
+
docs: {
|
|
96
|
+
description:
|
|
97
|
+
'Disallow render-callback props and component-as-prop patterns; use `children` composition or per-mode component split.',
|
|
98
|
+
},
|
|
99
|
+
schema: [],
|
|
100
|
+
messages: {
|
|
101
|
+
renderCallback:
|
|
102
|
+
'Prop "{{name}}" returns ReactNode/JSX.Element — use `children` (or named ReactNode slots) instead of a render callback.',
|
|
103
|
+
componentAsProp:
|
|
104
|
+
'Prop "{{name}}" is typed as a component ({{typeName}}) — use `children` composition or split into per-mode components instead.',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
create(context) {
|
|
108
|
+
function checkPropertySignature(node) {
|
|
109
|
+
if (!node.key || node.key.type !== 'Identifier') {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const name = node.key.name;
|
|
113
|
+
if (EVENT_HANDLER.test(name)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const ann = node.typeAnnotation?.typeAnnotation;
|
|
117
|
+
if (!ann) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const v = findViolation(ann);
|
|
121
|
+
if (!v) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
context.report({
|
|
125
|
+
node,
|
|
126
|
+
messageId: v.kind,
|
|
127
|
+
data: { name, typeName: v.typeName ?? '' },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
'TSInterfaceBody > TSPropertySignature': checkPropertySignature,
|
|
133
|
+
'TSTypeLiteral > TSPropertySignature': checkPropertySignature,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Enforces the nx: Tailwind class conventions from
|
|
2
|
+
// `.claude/rules/shadcn-divergences.md` on
|
|
3
|
+
// class strings (string literals and template literals). Ported from the
|
|
4
|
+
// former `.claude/hooks/lint-nx-prefix.mjs` Claude hook so the checks run in
|
|
5
|
+
// `pnpm lint` and the pre-commit hook for every contributor — not only on
|
|
6
|
+
// in-session Claude edits.
|
|
7
|
+
|
|
8
|
+
export const RAW_FONT_WEIGHTS = [
|
|
9
|
+
'thin',
|
|
10
|
+
'extralight',
|
|
11
|
+
'light',
|
|
12
|
+
'normal',
|
|
13
|
+
'medium',
|
|
14
|
+
'semibold',
|
|
15
|
+
'bold',
|
|
16
|
+
'extrabold',
|
|
17
|
+
'black',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const RAW_LINE_HEIGHTS = [
|
|
21
|
+
'none',
|
|
22
|
+
'tight',
|
|
23
|
+
'snug',
|
|
24
|
+
'normal',
|
|
25
|
+
'relaxed',
|
|
26
|
+
'loose',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const RAW_LETTER_SPACINGS = [
|
|
30
|
+
'tighter',
|
|
31
|
+
'tight',
|
|
32
|
+
'normal',
|
|
33
|
+
'wide',
|
|
34
|
+
'wider',
|
|
35
|
+
'widest',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const NAMED_VARIANT = String.raw`@?[\w-]+(?:\[[^\s]+?\])?(?:\/[\w-]+)?:`;
|
|
39
|
+
const ARBITRARY_VARIANT = String.raw`\[[^\s]+?\]:`;
|
|
40
|
+
const CHILD_VARIANT = String.raw`\*{1,2}:`;
|
|
41
|
+
const NX_MODIFIER_CHAIN = String.raw`(?:${NAMED_VARIANT}|${ARBITRARY_VARIANT}|${CHILD_VARIANT})*`;
|
|
42
|
+
|
|
43
|
+
const CHECKS = [
|
|
44
|
+
{
|
|
45
|
+
messageId: 'prefixOrder',
|
|
46
|
+
re: /[a-z][a-z0-9_-]*:nx:|\]:nx:/,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
messageId: 'bannedAccent',
|
|
50
|
+
re: new RegExp(`nx:${NX_MODIFIER_CHAIN}(?:bg|text)-accent\\b`),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
messageId: 'incompletePath',
|
|
54
|
+
re: new RegExp(
|
|
55
|
+
`nx:${NX_MODIFIER_CHAIN}(?:bg|text|border)-(?:primary|secondary|error|success|warning|information|destructive)(?![\\w-])`
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
messageId: 'rawPrimitive',
|
|
60
|
+
re: new RegExp(
|
|
61
|
+
`nx:${NX_MODIFIER_CHAIN}(?:bg|text|border)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d{2,3}`
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
messageId: 'rawFontSize',
|
|
66
|
+
re: new RegExp(
|
|
67
|
+
`nx:${NX_MODIFIER_CHAIN}text-(?:xs|sm|base|lg|xl|[2-9]xl)\\b`
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
messageId: 'rawFontWeight',
|
|
72
|
+
re: new RegExp(
|
|
73
|
+
`nx:${NX_MODIFIER_CHAIN}font-(?:${RAW_FONT_WEIGHTS.join('|')})\\b`
|
|
74
|
+
),
|
|
75
|
+
runtimeOnly: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
messageId: 'rawLineHeight',
|
|
79
|
+
re: new RegExp(
|
|
80
|
+
`nx:${NX_MODIFIER_CHAIN}leading-(?:${RAW_LINE_HEIGHTS.join('|')}|\\d+|\\[)`
|
|
81
|
+
),
|
|
82
|
+
runtimeOnly: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
messageId: 'rawLetterSpacing',
|
|
86
|
+
re: new RegExp(
|
|
87
|
+
`nx:${NX_MODIFIER_CHAIN}tracking-(?:${RAW_LETTER_SPACINGS.join('|')}|\\[)`
|
|
88
|
+
),
|
|
89
|
+
runtimeOnly: true,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// Only a width or a colour paints a ring. `outline-none` / `outline-hidden`
|
|
94
|
+
// suppress one, `outline-0` is a zero width, `outline-offset-*` shifts a ring
|
|
95
|
+
// it does not paint, and the style keywords set `outline-style` alone.
|
|
96
|
+
const RING_PAINTING_OUTLINE =
|
|
97
|
+
/focus-visible:outline-(?!none\b|hidden\b|0\b|offset-|solid\b|dashed\b|dotted\b|double\b)/;
|
|
98
|
+
|
|
99
|
+
// `nx:transition-colors` expands to a property list that includes
|
|
100
|
+
// `outline-color`, so a surface that also paints a focus ring fades the ring in
|
|
101
|
+
// over the duration instead of landing it with the keypress.
|
|
102
|
+
function fadesItsFocusRing(scope) {
|
|
103
|
+
return (
|
|
104
|
+
/\bnx:transition-colors\b/.test(scope) && RING_PAINTING_OUTLINE.test(scope)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isStoryFile(filename) {
|
|
109
|
+
return /\.stories\.[jt]sx?$/.test(filename);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isDocsFile(filename) {
|
|
113
|
+
return /(?:^|[/\\])apps[/\\]docs[/\\]/.test(filename);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What the runtime-only checks skip: stories and the docs app quote utilities
|
|
117
|
+
// as specimens and prose, and the docs app styles its own chrome off-system.
|
|
118
|
+
function isExampleFile(filename) {
|
|
119
|
+
return isStoryFile(filename) || isDocsFile(filename);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The ring check skips less: only the docs pages, which render a pairing as
|
|
123
|
+
// the subject of the prose around it. The rest of the docs app is its own
|
|
124
|
+
// shipped chrome, and a story's class strings are the component's real ones.
|
|
125
|
+
function isDocsSpecimenPage(filename) {
|
|
126
|
+
return /(?:^|[/\\])apps[/\\]docs[/\\]app[/\\]_pages[/\\]/.test(filename);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const RAW_TYPOGRAPHY_EXCEPTIONS = [
|
|
130
|
+
{
|
|
131
|
+
filename:
|
|
132
|
+
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
|
|
133
|
+
className:
|
|
134
|
+
'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
|
+
messageIds: new Set(['rawFontWeight', 'rawLineHeight']),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
filename:
|
|
139
|
+
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
|
|
140
|
+
className: 'nx:text-foreground nx:font-mono nx:font-medium nx:tabular-nums',
|
|
141
|
+
messageIds: new Set(['rawFontWeight']),
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function normalizeClassName(raw) {
|
|
146
|
+
return raw.trim().replace(/\s+/g, ' ');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isRawTypographyException(check, filename, raw) {
|
|
150
|
+
const normalized = normalizeClassName(raw);
|
|
151
|
+
return RAW_TYPOGRAPHY_EXCEPTIONS.some(
|
|
152
|
+
(exception) =>
|
|
153
|
+
exception.filename.test(filename) &&
|
|
154
|
+
exception.className === normalized &&
|
|
155
|
+
exception.messageIds.has(check.messageId)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function shouldRunCheck(check, filename, raw) {
|
|
160
|
+
if (!check.runtimeOnly) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (isExampleFile(filename)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return !isRawTypographyException(check, filename, raw);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function matchedMessageIds(raw, filename) {
|
|
170
|
+
const matched = new Set();
|
|
171
|
+
for (const check of CHECKS) {
|
|
172
|
+
const { messageId, re } = check;
|
|
173
|
+
if (!shouldRunCheck(check, filename, raw)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (re.test(raw)) {
|
|
177
|
+
matched.add(messageId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return matched;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A class composer joins every string it is handed — base, array element,
|
|
184
|
+
// `variants` value — so the whole call is one scope. The name has to be
|
|
185
|
+
// written plainly; an aliased or member-expression composer is invisible.
|
|
186
|
+
const CLASS_COMPOSING_CALLEES = new Set(['cva', 'cn', 'clsx', 'cx']);
|
|
187
|
+
|
|
188
|
+
// `[…].join(' ')` collapses the array literal into one class attribute.
|
|
189
|
+
function isJoinedArrayLiteral(node) {
|
|
190
|
+
const { callee } = node;
|
|
191
|
+
return (
|
|
192
|
+
callee.type === 'MemberExpression' &&
|
|
193
|
+
!callee.computed &&
|
|
194
|
+
callee.object.type === 'ArrayExpression' &&
|
|
195
|
+
callee.property.type === 'Identifier' &&
|
|
196
|
+
callee.property.name === 'join'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isClassComposingCall(node) {
|
|
201
|
+
if (node.type !== 'CallExpression') {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
if (node.callee.type === 'Identifier') {
|
|
205
|
+
return CLASS_COMPOSING_CALLEES.has(node.callee.name);
|
|
206
|
+
}
|
|
207
|
+
return isJoinedArrayLiteral(node);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A string handed to some other function reaches the class attribute only
|
|
211
|
+
// through that call's return value, so it leaves the scope.
|
|
212
|
+
function breaksClassScope(node) {
|
|
213
|
+
return node.type === 'CallExpression' && !isClassComposingCall(node);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isInsideClassCall(node) {
|
|
217
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
218
|
+
if (isClassComposingCall(current)) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (breaksClassScope(current)) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Every class string a node can contribute: its own value, a template
|
|
229
|
+
// literal's quasis, and the same for anything nested inside it that still
|
|
230
|
+
// reaches the same class attribute.
|
|
231
|
+
function collectClassStrings(node, visitorKeys, collected) {
|
|
232
|
+
if (node.type === 'Literal') {
|
|
233
|
+
if (typeof node.value === 'string') {
|
|
234
|
+
collected.push(node.value);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (node.type === 'TemplateLiteral') {
|
|
239
|
+
for (const quasi of node.quasis) {
|
|
240
|
+
collected.push(quasi.value.cooked ?? quasi.value.raw);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
244
|
+
const child = node[key];
|
|
245
|
+
for (const value of Array.isArray(child) ? child : [child]) {
|
|
246
|
+
if (value?.type && !breaksClassScope(value)) {
|
|
247
|
+
collectClassStrings(value, visitorKeys, collected);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default {
|
|
254
|
+
meta: {
|
|
255
|
+
type: 'problem',
|
|
256
|
+
docs: {
|
|
257
|
+
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, and no ring-fading `transition-colors` on a focus-ring surface.',
|
|
259
|
+
},
|
|
260
|
+
schema: [],
|
|
261
|
+
messages: {
|
|
262
|
+
prefixOrder:
|
|
263
|
+
'Wrong nx: prefix order — `nx:` must come BEFORE all modifiers (e.g. `nx:hover:bg-*`, not `hover:nx:bg-*`).',
|
|
264
|
+
bannedAccent:
|
|
265
|
+
'Banned `accent` token — nxds has no `accent`; use `background-hover` / `container-hover` / `popover-hover` (see shadcn-divergences.md).',
|
|
266
|
+
incompletePath:
|
|
267
|
+
'Incomplete semantic token path — add a `-background`, `-foreground`, or `-subtle` suffix (e.g. `nx:bg-primary-background`).',
|
|
268
|
+
rawPrimitive:
|
|
269
|
+
'Raw Tailwind primitive color — use a semantic token instead (e.g. `nx:bg-primary-background`, not `nx:bg-blue-500`).',
|
|
270
|
+
rawFontSize:
|
|
271
|
+
'Raw Tailwind font-size utility — use a typography composite instead (e.g. `nx:typography-body-default`, not `nx:text-sm`).',
|
|
272
|
+
rawFontWeight:
|
|
273
|
+
'Raw Tailwind font-weight utility — use a typography composite instead (e.g. `nx:typography-label-default`, not `nx:font-medium`).',
|
|
274
|
+
rawLineHeight:
|
|
275
|
+
'Raw Tailwind line-height utility — let a typography composite own line-height instead of `nx:leading-*`.',
|
|
276
|
+
rawLetterSpacing:
|
|
277
|
+
'Raw Tailwind letter-spacing utility — use a typography composite such as `nx:typography-label-caps` instead of `nx:tracking-*`.',
|
|
278
|
+
ringFadingTransition:
|
|
279
|
+
'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).',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
create(context) {
|
|
283
|
+
const filename = context.filename ?? '';
|
|
284
|
+
const { visitorKeys } = context.sourceCode;
|
|
285
|
+
|
|
286
|
+
function report(node, raw) {
|
|
287
|
+
for (const messageId of matchedMessageIds(raw, filename)) {
|
|
288
|
+
context.report({ node, messageId });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function reportScope(node) {
|
|
293
|
+
if (isDocsSpecimenPage(filename) || isInsideClassCall(node)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const collected = [];
|
|
297
|
+
collectClassStrings(node, visitorKeys, collected);
|
|
298
|
+
if (fadesItsFocusRing(collected.join(' '))) {
|
|
299
|
+
context.report({ node, messageId: 'ringFadingTransition' });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
Literal(node) {
|
|
305
|
+
if (typeof node.value !== 'string') {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
report(node, node.value);
|
|
309
|
+
reportScope(node);
|
|
310
|
+
},
|
|
311
|
+
TemplateLiteral(node) {
|
|
312
|
+
const matched = new Set();
|
|
313
|
+
for (const quasi of node.quasis) {
|
|
314
|
+
const raw = quasi.value.cooked ?? quasi.value.raw;
|
|
315
|
+
for (const id of matchedMessageIds(raw, filename)) {
|
|
316
|
+
matched.add(id);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const messageId of matched) {
|
|
320
|
+
context.report({ node, messageId });
|
|
321
|
+
}
|
|
322
|
+
reportScope(node);
|
|
323
|
+
},
|
|
324
|
+
CallExpression(node) {
|
|
325
|
+
if (!isClassComposingCall(node)) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
reportScope(node);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
};
|
package/src/tailwind.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import betterTailwindcss from 'eslint-plugin-better-tailwindcss';
|
|
2
|
+
import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// `matchesName` requires a full match, hence the leading `.*`.
|
|
6
|
+
const tailwindClassSelectors = [
|
|
7
|
+
...getDefaultSelectors(),
|
|
8
|
+
{
|
|
9
|
+
kind: 'attribute',
|
|
10
|
+
name: '.*ClassName',
|
|
11
|
+
match: [{ type: 'strings' }],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
kind: 'variable',
|
|
15
|
+
name: '.*(?:ClassName|Class|Classes)',
|
|
16
|
+
match: [{ type: 'strings' }],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
kind: 'variable',
|
|
20
|
+
name: '.*CLASSES',
|
|
21
|
+
match: [{ type: 'objectValues' }],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
kind: 'variable',
|
|
25
|
+
name: '.*',
|
|
26
|
+
match: [{ type: 'objectValues', path: '(?:^|\\.)className$' }],
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function nxdsTailwindClassesConfig(options) {
|
|
31
|
+
const { files, entryPoint } = options;
|
|
32
|
+
if (typeof entryPoint !== 'string' || !path.isAbsolute(entryPoint)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`nxdsTailwindClassesConfig: \`entryPoint\` must be an absolute path, got '${entryPoint}'.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...(files ? { files } : {}),
|
|
40
|
+
plugins: { 'better-tailwindcss': betterTailwindcss },
|
|
41
|
+
settings: {
|
|
42
|
+
'better-tailwindcss': { entryPoint, selectors: tailwindClassSelectors },
|
|
43
|
+
},
|
|
44
|
+
rules: {
|
|
45
|
+
// `dark` is the hook for the `@custom-variant dark` in nxds.css.
|
|
46
|
+
'better-tailwindcss/no-unknown-classes': [
|
|
47
|
+
'error',
|
|
48
|
+
{ ignore: ['^dark$'] },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|