pasika 0.3.1 → 0.3.3
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 +67 -9
- package/dist/cli/index.js +34 -40
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +11 -0
- package/dist/enforcement/coverage.d.ts +5 -1
- package/dist/enforcement/coverage.js +68 -39
- package/dist/enforcement/doctor.d.ts +16 -0
- package/dist/enforcement/doctor.js +249 -0
- package/dist/enforcement/parse-docs.js +5 -1
- package/dist/enforcement/types.d.ts +0 -3
- package/dist/enforcement/types.js +4 -13
- package/dist/eslint/pasika/ast-types.d.ts +122 -0
- package/dist/eslint/pasika/ast-types.js +1 -0
- package/dist/eslint/pasika/index.d.ts +58 -3
- package/dist/eslint/pasika/index.js +61 -1
- package/dist/eslint/pasika/rules/application-structure.d.ts +2 -0
- package/dist/eslint/pasika/rules/application-structure.js +152 -0
- package/dist/eslint/pasika/rules/component-conventions.d.ts +16 -0
- package/dist/eslint/pasika/rules/component-conventions.js +145 -0
- package/dist/eslint/pasika/rules/component-nesting.d.ts +12 -0
- package/dist/eslint/pasika/rules/component-nesting.js +69 -0
- package/dist/eslint/pasika/rules/config-extraction.d.ts +14 -0
- package/dist/eslint/pasika/rules/config-extraction.js +74 -0
- package/dist/eslint/pasika/rules/cross-feature-import.d.ts +2 -0
- package/dist/eslint/pasika/rules/cross-feature-import.js +75 -0
- package/dist/eslint/pasika/rules/css/apply-usage.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/apply-usage.js +45 -0
- package/dist/eslint/pasika/rules/css/base-layer-pair.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/base-layer-pair.js +49 -0
- package/dist/eslint/pasika/rules/css/css-variable-naming.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/css-variable-naming.js +39 -0
- package/dist/eslint/pasika/rules/css/custom-utility-apply.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/custom-utility-apply.js +52 -0
- package/dist/eslint/pasika/rules/css/global-css-location.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/global-css-location.js +42 -0
- package/dist/eslint/pasika/rules/css/helpers.d.ts +29 -0
- package/dist/eslint/pasika/rules/css/helpers.js +93 -0
- package/dist/eslint/pasika/rules/css/index.d.ts +13 -0
- package/dist/eslint/pasika/rules/css/index.js +25 -0
- package/dist/eslint/pasika/rules/css/root-variables.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/root-variables.js +53 -0
- package/dist/eslint/pasika/rules/css/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/rule-tester.js +22 -0
- package/dist/eslint/pasika/rules/css/stylesheet-ordering.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/stylesheet-ordering.js +76 -0
- package/dist/eslint/pasika/rules/css/surface-utility.d.ts +10 -0
- package/dist/eslint/pasika/rules/css/surface-utility.js +41 -0
- package/dist/eslint/pasika/rules/css/theme-reset.d.ts +9 -0
- package/dist/eslint/pasika/rules/css/theme-reset.js +36 -0
- package/dist/eslint/pasika/rules/css/theme-variable-namespace.d.ts +11 -0
- package/dist/eslint/pasika/rules/css/theme-variable-namespace.js +53 -0
- package/dist/eslint/pasika/rules/cva-appearance-props.d.ts +10 -0
- package/dist/eslint/pasika/rules/cva-appearance-props.js +77 -0
- package/dist/eslint/pasika/rules/cva-boolean-variants.d.ts +11 -0
- package/dist/eslint/pasika/rules/cva-boolean-variants.js +97 -0
- package/dist/eslint/pasika/rules/data-testid-case.d.ts +2 -0
- package/dist/eslint/pasika/rules/data-testid-case.js +76 -0
- package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +13 -1
- package/dist/eslint/pasika/rules/enforce-cn-merge.js +46 -44
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +53 -48
- package/dist/eslint/pasika/rules/filename-case.js +28 -5
- package/dist/eslint/pasika/rules/hook-complexity.d.ts +12 -0
- package/dist/eslint/pasika/rules/hook-complexity.js +114 -0
- package/dist/eslint/pasika/rules/hook-extraction.d.ts +11 -0
- package/dist/eslint/pasika/rules/hook-extraction.js +55 -0
- package/dist/eslint/pasika/rules/import-through-index.d.ts +2 -0
- package/dist/eslint/pasika/rules/import-through-index.js +52 -0
- package/dist/eslint/pasika/rules/interactive-component.d.ts +10 -0
- package/dist/eslint/pasika/rules/interactive-component.js +76 -0
- package/dist/eslint/pasika/rules/json/index.d.ts +5 -0
- package/dist/eslint/pasika/rules/json/index.js +9 -0
- package/dist/eslint/pasika/rules/json/no-cache-flag.d.ts +10 -0
- package/dist/eslint/pasika/rules/json/no-cache-flag.js +45 -0
- package/dist/eslint/pasika/rules/json/no-vulyk-dependency.d.ts +10 -0
- package/dist/eslint/pasika/rules/json/no-vulyk-dependency.js +41 -0
- package/dist/eslint/pasika/rules/json/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/json/rule-tester.js +21 -0
- package/dist/eslint/pasika/rules/jsx-hygiene.d.ts +9 -0
- package/dist/eslint/pasika/rules/jsx-hygiene.js +108 -0
- package/dist/eslint/pasika/rules/locale-dotted-path.d.ts +10 -0
- package/dist/eslint/pasika/rules/locale-dotted-path.js +65 -0
- package/dist/eslint/pasika/rules/locale-placement.d.ts +13 -0
- package/dist/eslint/pasika/rules/locale-placement.js +163 -0
- package/dist/eslint/pasika/rules/locales-location.d.ts +11 -0
- package/dist/eslint/pasika/rules/locales-location.js +56 -0
- package/dist/eslint/pasika/rules/md/doc-kind-suffix.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/doc-kind-suffix.js +29 -0
- package/dist/eslint/pasika/rules/md/example-heading-description.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/example-heading-description.js +30 -0
- package/dist/eslint/pasika/rules/md/glossary-term-linking.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/glossary-term-linking.js +99 -0
- package/dist/eslint/pasika/rules/md/guide-folder-entry-point.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/guide-folder-entry-point.js +46 -0
- package/dist/eslint/pasika/rules/md/guide-link-anchors.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-link-anchors.js +50 -0
- package/dist/eslint/pasika/rules/md/guide-overview-no-links.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-overview-no-links.js +51 -0
- package/dist/eslint/pasika/rules/md/guide-states-no-requirement.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-states-no-requirement.js +26 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-link.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-link.js +48 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-sentence.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/guide-step-single-sentence.js +42 -0
- package/dist/eslint/pasika/rules/md/helpers.d.ts +25 -0
- package/dist/eslint/pasika/rules/md/helpers.js +37 -0
- package/dist/eslint/pasika/rules/md/index.d.ts +27 -0
- package/dist/eslint/pasika/rules/md/index.js +53 -0
- package/dist/eslint/pasika/rules/md/no-cross-document-link.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-cross-document-link.js +36 -0
- package/dist/eslint/pasika/rules/md/no-nested-how-to.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-nested-how-to.js +39 -0
- package/dist/eslint/pasika/rules/md/no-template-prompt.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/no-template-prompt.js +22 -0
- package/dist/eslint/pasika/rules/md/overview-length.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/overview-length.js +47 -0
- package/dist/eslint/pasika/rules/md/overview-present.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/overview-present.js +41 -0
- package/dist/eslint/pasika/rules/md/policy-no-examples.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-no-examples.js +28 -0
- package/dist/eslint/pasika/rules/md/policy-single-document.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-single-document.js +34 -0
- package/dist/eslint/pasika/rules/md/policy-subject-headings.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/policy-subject-headings.js +58 -0
- package/dist/eslint/pasika/rules/md/project-index.d.ts +17 -0
- package/dist/eslint/pasika/rules/md/project-index.js +67 -0
- package/dist/eslint/pasika/rules/md/reference-block-headings.d.ts +6 -0
- package/dist/eslint/pasika/rules/md/reference-block-headings.js +31 -0
- package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.d.ts +6 -0
- package/dist/eslint/pasika/rules/md/reference-no-rfc-vocabulary.js +26 -0
- package/dist/eslint/pasika/rules/md/requirement-present.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/requirement-present.js +41 -0
- package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/rfc-only-in-bullets.js +46 -0
- package/dist/eslint/pasika/rules/md/rule-paired-examples.d.ts +5 -0
- package/dist/eslint/pasika/rules/md/rule-paired-examples.js +36 -0
- package/dist/eslint/pasika/rules/md/rule-tester.d.ts +11 -0
- package/dist/eslint/pasika/rules/md/rule-tester.js +21 -0
- package/dist/eslint/pasika/rules/md/support-document-placement.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/support-document-placement.js +36 -0
- package/dist/eslint/pasika/rules/md/title-matches-file-name.d.ts +2 -0
- package/dist/eslint/pasika/rules/md/title-matches-file-name.js +46 -0
- package/dist/eslint/pasika/rules/named-exports.d.ts +2 -0
- package/dist/eslint/pasika/rules/named-exports.js +49 -0
- package/dist/eslint/pasika/rules/no-eslint-disable.d.ts +10 -0
- package/dist/eslint/pasika/rules/no-eslint-disable.js +33 -0
- package/dist/eslint/pasika/rules/no-mixed-concerns.js +9 -22
- package/dist/eslint/pasika/rules/no-util-barrel.d.ts +2 -0
- package/dist/eslint/pasika/rules/no-util-barrel.js +48 -0
- package/dist/eslint/pasika/rules/pure-function-extract.d.ts +9 -0
- package/dist/eslint/pasika/rules/pure-function-extract.js +92 -0
- package/dist/eslint/pasika/rules/shared-style-dedup.d.ts +13 -0
- package/dist/eslint/pasika/rules/shared-style-dedup.js +91 -0
- package/dist/eslint/pasika/rules/stay-flat.d.ts +12 -0
- package/dist/eslint/pasika/rules/stay-flat.js +81 -0
- package/dist/eslint/pasika/rules/support-folder-shape.d.ts +2 -0
- package/dist/eslint/pasika/rules/support-folder-shape.js +59 -0
- package/dist/eslint/pasika/rules/type-extraction.d.ts +13 -0
- package/dist/eslint/pasika/rules/type-extraction.js +85 -0
- package/dist/eslint/pasika/rules/ui-state.d.ts +9 -0
- package/dist/eslint/pasika/rules/ui-state.js +99 -0
- package/dist/eslint/pasika/rules/util-file-name.d.ts +2 -0
- package/dist/eslint/pasika/rules/util-file-name.js +48 -0
- package/dist/eslint/pasika/rules/value-extraction.d.ts +13 -0
- package/dist/eslint/pasika/rules/value-extraction.js +48 -0
- package/package.json +14 -7
- package/dist/enforcement/docs-check.d.ts +0 -17
- package/dist/enforcement/docs-check.js +0 -162
- package/enforcement/registry.json +0 -1139
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/theme-reset
|
|
3
|
+
*
|
|
4
|
+
* The global stylesheet MUST reset Tailwind's default theme with `--*: initial`.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/styling-guide/rules/global-stylesheet-rule.md
|
|
7
|
+
*/
|
|
8
|
+
import { atrulesNamed, blockChildren } from "./helpers.js";
|
|
9
|
+
export const themeResetRule = {
|
|
10
|
+
meta: {
|
|
11
|
+
schema: [],
|
|
12
|
+
type: "problem",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Require Tailwind's default theme reset --*: initial.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
create(context) {
|
|
18
|
+
return {
|
|
19
|
+
"StyleSheet:exit"(node) {
|
|
20
|
+
const resetFound = atrulesNamed(node, "theme").some((theme) => blockChildren(theme).some((child) => {
|
|
21
|
+
if (child.type === "Declaration")
|
|
22
|
+
return child.property.startsWith("--*");
|
|
23
|
+
if (child.type === "Raw")
|
|
24
|
+
return child.value.includes("--*: initial");
|
|
25
|
+
return false;
|
|
26
|
+
}));
|
|
27
|
+
if (!resetFound) {
|
|
28
|
+
context.report({
|
|
29
|
+
node,
|
|
30
|
+
message: "The global stylesheet must reset Tailwind's default theme with --*: initial inside an @theme block.",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/theme-variable-namespace
|
|
3
|
+
*
|
|
4
|
+
* A value that needs at least two utility classes from the same Tailwind
|
|
5
|
+
* theme-variable namespace MUST use that namespace and have the same name in
|
|
6
|
+
* :root.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/styling-guide/rules/theme-and-utility-definition-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import type { CSSRuleDefinition } from "@eslint/css";
|
|
11
|
+
export declare const themeVariableNamespaceRule: CSSRuleDefinition;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/theme-variable-namespace
|
|
3
|
+
*
|
|
4
|
+
* A value that needs at least two utility classes from the same Tailwind
|
|
5
|
+
* theme-variable namespace MUST use that namespace and have the same name in
|
|
6
|
+
* :root.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/styling-guide/rules/theme-and-utility-definition-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import { atrulesNamed, blockChildren, preludeIdentifiers } from "./helpers.js";
|
|
11
|
+
export const themeVariableNamespaceRule = {
|
|
12
|
+
meta: {
|
|
13
|
+
schema: [],
|
|
14
|
+
type: "problem",
|
|
15
|
+
docs: {
|
|
16
|
+
description: "Require utility classes from the same theme namespace to share a prefix.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
return {
|
|
21
|
+
"StyleSheet:exit"(node) {
|
|
22
|
+
// Namespaces are the first segment of each theme variable name, e.g.
|
|
23
|
+
// --color-* defines the `color` namespace.
|
|
24
|
+
const namespaces = new Set();
|
|
25
|
+
for (const theme of atrulesNamed(node, "theme")) {
|
|
26
|
+
for (const child of blockChildren(theme)) {
|
|
27
|
+
if (child.type !== "Declaration")
|
|
28
|
+
continue;
|
|
29
|
+
const segments = child.property.replace(/^--/, "").split("-");
|
|
30
|
+
if (segments[0])
|
|
31
|
+
namespaces.add(segments[0]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const apply of atrulesNamed(node, "apply")) {
|
|
35
|
+
const classes = preludeIdentifiers(apply);
|
|
36
|
+
for (const ns of namespaces) {
|
|
37
|
+
const matching = classes.filter((name) => name.startsWith(`${ns}-`) || name === ns);
|
|
38
|
+
if (matching.length < 2)
|
|
39
|
+
continue;
|
|
40
|
+
// All classes from one namespace must share a deeper prefix.
|
|
41
|
+
const prefixes = matching.map((name) => name.split("-").slice(0, 2).join("-"));
|
|
42
|
+
if (new Set(prefixes).size > 1) {
|
|
43
|
+
context.report({
|
|
44
|
+
node: apply,
|
|
45
|
+
message: `Utility classes ${matching.join(", ")} use namespace "${ns}" but lack a shared prefix; use the theme-variable namespace prefix.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/cva-appearance-props
|
|
3
|
+
*
|
|
4
|
+
* Enforces that components exposing visual option props (size, variant, etc.)
|
|
5
|
+
* define them via a cva() call rather than raw union types.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from "eslint";
|
|
10
|
+
export declare const cvaAppearancePropsRule: Rule.RuleModule;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/cva-appearance-props
|
|
3
|
+
*
|
|
4
|
+
* Enforces that components exposing visual option props (size, variant, etc.)
|
|
5
|
+
* define them via a cva() call rather than raw union types.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
8
|
+
*/
|
|
9
|
+
const VISUAL_OPTION_NAMES = new Set([
|
|
10
|
+
"size",
|
|
11
|
+
"variant",
|
|
12
|
+
"tone",
|
|
13
|
+
"color",
|
|
14
|
+
"appearance",
|
|
15
|
+
"intent",
|
|
16
|
+
"style",
|
|
17
|
+
"scheme",
|
|
18
|
+
"theme",
|
|
19
|
+
]);
|
|
20
|
+
function hasCvaCall(body) {
|
|
21
|
+
for (const stmt of body) {
|
|
22
|
+
if (stmt.type !== "VariableDeclaration")
|
|
23
|
+
continue;
|
|
24
|
+
for (const decl of stmt.declarations) {
|
|
25
|
+
const init = decl.init;
|
|
26
|
+
if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "cva") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
export const cvaAppearancePropsRule = {
|
|
34
|
+
meta: {
|
|
35
|
+
schema: [],
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: {
|
|
38
|
+
description: "Components with visual option props must define them using cva.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
const program = context.sourceCode.ast;
|
|
43
|
+
const hasCva = hasCvaCall(program.body);
|
|
44
|
+
// If cva is used anywhere in the file, all visual option props are assumed
|
|
45
|
+
// to be handled by that definition. This avoids false positives when the
|
|
46
|
+
// cva call and the component are in different scopes.
|
|
47
|
+
if (hasCva)
|
|
48
|
+
return {};
|
|
49
|
+
return {
|
|
50
|
+
TSTypeAliasDeclaration(node) {
|
|
51
|
+
if (node.id?.type !== "Identifier" || !node.id.name?.endsWith("Props"))
|
|
52
|
+
return;
|
|
53
|
+
if (node.typeAnnotation?.type !== "TSTypeLiteral")
|
|
54
|
+
return;
|
|
55
|
+
for (const member of node.typeAnnotation.members ?? []) {
|
|
56
|
+
if (member.type !== "TSPropertySignature")
|
|
57
|
+
continue;
|
|
58
|
+
const keyName = member.key?.type === "Identifier" ? member.key.name : undefined;
|
|
59
|
+
if (!keyName || !VISUAL_OPTION_NAMES.has(keyName))
|
|
60
|
+
continue;
|
|
61
|
+
// Only flag union types (string literal unions like "sm" | "lg")
|
|
62
|
+
const typeAnnotation = member.typeAnnotation?.typeAnnotation ?? member.typeAnnotation;
|
|
63
|
+
if (!typeAnnotation)
|
|
64
|
+
continue;
|
|
65
|
+
// Accept both TSUnionType and TSTypeLiteral
|
|
66
|
+
if (typeAnnotation.type === "TSUnionType" || typeAnnotation.type === "TSTypeLiteral") {
|
|
67
|
+
context.report({
|
|
68
|
+
node,
|
|
69
|
+
message: `Visual option prop "${keyName}" must be defined through a cva() call ` +
|
|
70
|
+
"rather than a manual union type. See docs/styling-guide/rules/component-variant-rule.md",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/cva-boolean-variants
|
|
3
|
+
*
|
|
4
|
+
* Enforces correct placement of boolean appearance props:
|
|
5
|
+
* - If a boolean variant appears in both CVA and cn(), report it —
|
|
6
|
+
* it belongs in exactly one place.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import type { Rule } from "eslint";
|
|
11
|
+
export declare const cvaBooleanVariantsRule: Rule.RuleModule;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/cva-boolean-variants
|
|
3
|
+
*
|
|
4
|
+
* Enforces correct placement of boolean appearance props:
|
|
5
|
+
* - If a boolean variant appears in both CVA and cn(), report it —
|
|
6
|
+
* it belongs in exactly one place.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/styling-guide/rules/component-variant-rule.md
|
|
9
|
+
*/
|
|
10
|
+
function extractCvaInfo(node) {
|
|
11
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "cva") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const variantNames = new Set();
|
|
15
|
+
const compoundVariants = new Set();
|
|
16
|
+
const options = node.arguments[1];
|
|
17
|
+
if (options?.type !== "ObjectExpression")
|
|
18
|
+
return { variantNames, compoundVariants };
|
|
19
|
+
for (const prop of options.properties) {
|
|
20
|
+
if (prop.type !== "Property")
|
|
21
|
+
continue;
|
|
22
|
+
const keyName = prop.key.type === "Identifier" ? prop.key.name : undefined;
|
|
23
|
+
if (keyName === "variants" && prop.value.type === "ObjectExpression") {
|
|
24
|
+
for (const vProp of prop.value.properties) {
|
|
25
|
+
if (vProp.type !== "Property")
|
|
26
|
+
continue;
|
|
27
|
+
const vName = vProp.key.type === "Identifier" ? vProp.key.name : undefined;
|
|
28
|
+
if (vName)
|
|
29
|
+
variantNames.add(vName);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (keyName === "compoundVariants" && prop.value.type === "ArrayExpression") {
|
|
33
|
+
for (const entry of prop.value.elements) {
|
|
34
|
+
if (entry?.type !== "ObjectExpression")
|
|
35
|
+
continue;
|
|
36
|
+
for (const cvProp of entry.properties) {
|
|
37
|
+
if (cvProp.type !== "Property")
|
|
38
|
+
continue;
|
|
39
|
+
const cvName = cvProp.key.type === "Identifier" ? cvProp.key.name : undefined;
|
|
40
|
+
if (cvName && variantNames.has(cvName)) {
|
|
41
|
+
compoundVariants.add(cvName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { variantNames, compoundVariants };
|
|
48
|
+
}
|
|
49
|
+
export const cvaBooleanVariantsRule = {
|
|
50
|
+
meta: {
|
|
51
|
+
schema: [],
|
|
52
|
+
type: "problem",
|
|
53
|
+
docs: {
|
|
54
|
+
description: "Enforce correct boolean variant placement between CVA and cn().",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
const cvaInfos = [];
|
|
59
|
+
return {
|
|
60
|
+
// Collect all cva() calls in the file
|
|
61
|
+
CallExpression(node) {
|
|
62
|
+
const info = extractCvaInfo(node);
|
|
63
|
+
if (info)
|
|
64
|
+
cvaInfos.push(info);
|
|
65
|
+
},
|
|
66
|
+
// Detect boolean && "class" patterns anywhere inside a className attribute
|
|
67
|
+
"JSXAttribute[name.name='className'] LogicalExpression[operator='&&']"(node) {
|
|
68
|
+
if (cvaInfos.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
if (node.type !== "LogicalExpression")
|
|
71
|
+
return;
|
|
72
|
+
const left = node.left;
|
|
73
|
+
const right = node.right;
|
|
74
|
+
// Right side should be a string literal (class name)
|
|
75
|
+
if (right.type !== "Literal" || typeof right.value !== "string") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Left side should be an identifier (boolean prop)
|
|
79
|
+
if (left.type !== "Identifier")
|
|
80
|
+
return;
|
|
81
|
+
const propName = left.name;
|
|
82
|
+
// Check if this prop is also a CVA variant
|
|
83
|
+
for (const info of cvaInfos) {
|
|
84
|
+
if (info.variantNames.has(propName)) {
|
|
85
|
+
context.report({
|
|
86
|
+
node,
|
|
87
|
+
message: `Boolean prop "${propName}" is used as a standalone conditional in cn() ` +
|
|
88
|
+
"but is also defined as a CVA variant. Move it to one place: " +
|
|
89
|
+
"either CVA (with both true/false treatments) or cn() (standalone). " +
|
|
90
|
+
"See docs/styling-guide/rules/component-variant-rule.md",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/data-testid-case
|
|
3
|
+
*
|
|
4
|
+
* Enforces data-testid casing for components whose rendered result has one
|
|
5
|
+
* statically identifiable intrinsic root. Components with multiple or dynamic
|
|
6
|
+
* roots are intentionally left alone, matching the documented exemption.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { findSimpleRoot, getTestId, parseComponentInfo } from "./component-conventions.js";
|
|
12
|
+
const NEXT_ROUTING_FILES = new Set([
|
|
13
|
+
"page",
|
|
14
|
+
"layout",
|
|
15
|
+
"loading",
|
|
16
|
+
"error",
|
|
17
|
+
"not-found",
|
|
18
|
+
"route",
|
|
19
|
+
"template",
|
|
20
|
+
"default",
|
|
21
|
+
"middleware",
|
|
22
|
+
"instrumentation",
|
|
23
|
+
]);
|
|
24
|
+
const isPascalCase = (value) => /^[A-Z][A-Za-z0-9]*$/.test(value);
|
|
25
|
+
const isKebabCase = (value) => /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value);
|
|
26
|
+
export const dataTestIdCaseRule = {
|
|
27
|
+
meta: {
|
|
28
|
+
schema: [],
|
|
29
|
+
type: "problem",
|
|
30
|
+
docs: {
|
|
31
|
+
description: "Enforce data-testid casing for smart and dumb components with a single intrinsic root.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
const filename = path.resolve(context.filename);
|
|
36
|
+
if (!filename.endsWith(".tsx"))
|
|
37
|
+
return {};
|
|
38
|
+
const base = path.basename(filename, path.extname(filename));
|
|
39
|
+
if (NEXT_ROUTING_FILES.has(base))
|
|
40
|
+
return {};
|
|
41
|
+
const text = context.sourceCode.text;
|
|
42
|
+
const components = parseComponentInfo(text, filename);
|
|
43
|
+
return {
|
|
44
|
+
Program(node) {
|
|
45
|
+
for (const component of components) {
|
|
46
|
+
const root = findSimpleRoot(component, text, filename);
|
|
47
|
+
if (!root)
|
|
48
|
+
continue;
|
|
49
|
+
const { value } = getTestId(root);
|
|
50
|
+
const expected = component.smart ? component.name : toKebabCase(component.name);
|
|
51
|
+
if (component.smart && value === undefined) {
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
message: `Smart component "${component.name}" with one outer DOM element must set data-testid="${expected}".`,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (value === undefined || value === expected)
|
|
59
|
+
continue;
|
|
60
|
+
if (component.smart ? !isPascalCase(value) || value !== expected : !isKebabCase(value)) {
|
|
61
|
+
context.report({
|
|
62
|
+
node,
|
|
63
|
+
message: `data-testid for ${component.smart ? "smart" : "dumb"} component "${component.name}" must be ${component.smart ? "PascalCase" : "kebab-case"}: expected "${expected}".`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
function toKebabCase(value) {
|
|
72
|
+
return value
|
|
73
|
+
.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>")
|
|
74
|
+
.replace(/(?<first>[A-Z])(?<rest>[A-Z][a-z])/g, "$<first>-$<rest>")
|
|
75
|
+
.toLowerCase();
|
|
76
|
+
}
|
|
@@ -6,4 +6,16 @@
|
|
|
6
6
|
* @see docs/styling-guide/rules/class-composition-rule.md
|
|
7
7
|
*/
|
|
8
8
|
import type { Rule } from "eslint";
|
|
9
|
-
|
|
9
|
+
import type { JsxAttributeNode } from "../ast-types.js";
|
|
10
|
+
export declare const enforceCnMergeRule: {
|
|
11
|
+
meta: {
|
|
12
|
+
schema: never[];
|
|
13
|
+
type: "problem";
|
|
14
|
+
docs: {
|
|
15
|
+
description: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
create(context: Rule.RuleContext): {
|
|
19
|
+
JSXAttribute(node: JsxAttributeNode): void;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -8,72 +8,75 @@
|
|
|
8
8
|
function classCount(str) {
|
|
9
9
|
return str.split(/\s+/).filter(Boolean).length;
|
|
10
10
|
}
|
|
11
|
+
function isComponentName(name) {
|
|
12
|
+
return name?.type === "JSXIdentifier" && /^[A-Z]/.test(name.name);
|
|
13
|
+
}
|
|
14
|
+
function isOuterLayoutClass(className) {
|
|
15
|
+
const utility = className.split(":").pop() ?? className;
|
|
16
|
+
return /^(?:-?m[trblsexy]?-.+|(?:min-|max-)?[wh]-.+|size-.+|(?:flex-(?:1|auto|initial|none|grow|shrink|basis-.+)|grow(?:-.+)?|shrink(?:-.+)?|basis-.+|order-.+|(?:col|row)-(?:auto|span-.+|start-.+|end-.+)|(?:self|place-self|justify-self)-.+|z-.+|gap-.+|space-[xy]-.+))$/.test(utility);
|
|
17
|
+
}
|
|
18
|
+
function stringArguments(node) {
|
|
19
|
+
if (node?.type === "Literal" && typeof node.value === "string")
|
|
20
|
+
return [node.value];
|
|
21
|
+
if (node?.type === "TemplateLiteral")
|
|
22
|
+
return node.quasis.map((quasi) => quasi.value.raw);
|
|
23
|
+
if (node?.type === "ConditionalExpression")
|
|
24
|
+
return [...stringArguments(node.consequent), ...stringArguments(node.alternate)];
|
|
25
|
+
if (node?.type === "LogicalExpression")
|
|
26
|
+
return [...stringArguments(node.left), ...stringArguments(node.right)];
|
|
27
|
+
if (node?.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "cn") {
|
|
28
|
+
return node.arguments.flatMap((argument) => stringArguments(argument));
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
11
32
|
export const enforceCnMergeRule = {
|
|
12
|
-
meta: {
|
|
13
|
-
schema: [],
|
|
14
|
-
type: "problem",
|
|
15
|
-
docs: {
|
|
16
|
-
description: "Enforce cn() for conditional class merging.",
|
|
17
|
-
},
|
|
18
|
-
},
|
|
33
|
+
meta: { schema: [], type: "problem", docs: { description: "Enforce cn() for conditional class merging." } },
|
|
19
34
|
create(context) {
|
|
20
35
|
return {
|
|
21
36
|
JSXAttribute(node) {
|
|
22
|
-
|
|
37
|
+
const attributeName = node.name?.name ?? "";
|
|
38
|
+
const element = node.parent.parent;
|
|
39
|
+
const isComponentProp = isComponentName(element.openingElement?.name);
|
|
40
|
+
if (isComponentProp && attributeName !== "className" && attributeName.endsWith("ClassName")) {
|
|
41
|
+
context.report({ node, message: "Expose appearance through typed variant props instead of separate internal class-name props. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (attributeName !== "className" && attributeName !== "class")
|
|
23
45
|
return;
|
|
24
46
|
const valueNode = node.value;
|
|
25
47
|
if (!valueNode)
|
|
26
48
|
return;
|
|
49
|
+
if (isComponentProp && attributeName === "className") {
|
|
50
|
+
const expression = valueNode.type === "JSXExpressionContainer" ? valueNode.expression : valueNode;
|
|
51
|
+
const invalidClass = stringArguments(expression).flatMap((value) => value.split(/\s+/).filter(Boolean)).find((className) => !isOuterLayoutClass(className));
|
|
52
|
+
if (invalidClass)
|
|
53
|
+
context.report({ node, message: `Passed className contains non-layout utility "${invalidClass}". Expose appearance through typed variant props. See docs/styling-guide/rules/class-composition-rule.md` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
27
56
|
if (valueNode.type === "Literal" && typeof valueNode.value === "string") {
|
|
28
|
-
if (classCount(valueNode.value) > 5)
|
|
29
|
-
context.report({
|
|
30
|
-
node,
|
|
31
|
-
message: "Static className with more than 5 classes must use cn() with grouped string literals. " +
|
|
32
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
33
|
-
});
|
|
34
|
-
}
|
|
57
|
+
if (classCount(valueNode.value) > 5)
|
|
58
|
+
context.report({ node, message: "Static className with more than 5 classes must use cn() with grouped string literals. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
35
59
|
return;
|
|
36
60
|
}
|
|
37
|
-
if (valueNode.type !== "JSXExpressionContainer"
|
|
61
|
+
if (valueNode.type !== "JSXExpressionContainer")
|
|
38
62
|
return;
|
|
39
63
|
const expr = valueNode.expression;
|
|
40
64
|
if (expr.type === "BinaryExpression" && expr.operator === "+") {
|
|
41
|
-
context.report({
|
|
42
|
-
node,
|
|
43
|
-
message: "Use cn() instead of + operator for className. " +
|
|
44
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
45
|
-
});
|
|
65
|
+
context.report({ node, message: "Use cn() instead of + operator for className. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
46
66
|
return;
|
|
47
67
|
}
|
|
48
68
|
if (expr.type === "TemplateLiteral" && expr.expressions.length > 0) {
|
|
49
|
-
context.report({
|
|
50
|
-
node,
|
|
51
|
-
message: "Use cn() instead of template literals with conditionals for className. " +
|
|
52
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
53
|
-
});
|
|
69
|
+
context.report({ node, message: "Use cn() instead of template literals with conditionals for className. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
56
|
-
if (expr.type === "Literal" && typeof expr.value === "string") {
|
|
57
|
-
|
|
58
|
-
context.report({
|
|
59
|
-
node,
|
|
60
|
-
message: "Static className with more than 5 classes must use cn() with grouped string literals. " +
|
|
61
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
62
|
-
});
|
|
63
|
-
}
|
|
72
|
+
if (expr.type === "Literal" && typeof expr.value === "string" && classCount(expr.value) > 5) {
|
|
73
|
+
context.report({ node, message: "Static className with more than 5 classes must use cn() with grouped string literals. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
64
74
|
return;
|
|
65
75
|
}
|
|
66
76
|
if (expr.type === "CallExpression" && expr.callee.type === "Identifier" && expr.callee.name === "cn") {
|
|
67
77
|
for (const arg of expr.arguments) {
|
|
68
|
-
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
69
|
-
|
|
70
|
-
context.report({
|
|
71
|
-
node: arg,
|
|
72
|
-
message: "Each cn() string argument must contain at most 5 class names. " +
|
|
73
|
-
"Group by styling concern. " +
|
|
74
|
-
"See docs/styling-guide/rules/class-composition-rule.md",
|
|
75
|
-
});
|
|
76
|
-
}
|
|
78
|
+
if (arg.type === "Literal" && typeof arg.value === "string" && classCount(arg.value) > 5) {
|
|
79
|
+
context.report({ node: arg, message: "Each cn() string argument must contain at most 5 class names. Group by styling concern. See docs/styling-guide/rules/class-composition-rule.md" });
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -81,4 +84,3 @@ export const enforceCnMergeRule = {
|
|
|
81
84
|
};
|
|
82
85
|
},
|
|
83
86
|
};
|
|
84
|
-
/* eslint-enable -- re-enable rules disabled for AST access @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
|
|
@@ -17,59 +17,65 @@ export const enforceCvaVariantPropsRule = {
|
|
|
17
17
|
const cvaDefinitions = new Map();
|
|
18
18
|
return {
|
|
19
19
|
VariableDeclarator(node) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
20
|
+
const init = node.init;
|
|
21
|
+
if (init?.type !== "CallExpression" ||
|
|
22
|
+
init.callee.type !== "Identifier" ||
|
|
23
|
+
init.callee.name !== "cva" ||
|
|
24
|
+
node.id.type !== "Identifier") {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const args = init.arguments;
|
|
28
|
+
if (args.length < 2)
|
|
29
|
+
return;
|
|
30
|
+
const options = args[1];
|
|
31
|
+
if (options?.type !== "ObjectExpression")
|
|
32
|
+
return;
|
|
33
|
+
for (const prop of options.properties) {
|
|
34
|
+
if (prop.type !== "Property" ||
|
|
35
|
+
prop.key.type !== "Identifier" ||
|
|
36
|
+
prop.key.name !== "variants" ||
|
|
37
|
+
prop.value.type !== "ObjectExpression") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const variantNames = prop.value.properties
|
|
41
|
+
.filter((p) => p.type === "Property" && p.key.type === "Identifier")
|
|
42
|
+
.map((p) => p.key.name);
|
|
43
|
+
if (variantNames.length > 0) {
|
|
44
|
+
cvaDefinitions.set(node.id.name, variantNames);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
},
|
|
45
48
|
TSTypeAliasDeclaration(node) {
|
|
46
|
-
|
|
49
|
+
const aliasName = node.id?.name;
|
|
50
|
+
if (!aliasName?.endsWith("Props"))
|
|
47
51
|
return;
|
|
48
|
-
if (node.typeAnnotation
|
|
52
|
+
if (node.typeAnnotation?.type !== "TSTypeLiteral")
|
|
49
53
|
return;
|
|
50
|
-
for (const member of node.typeAnnotation.members) {
|
|
51
|
-
if (member.type
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
54
|
+
for (const member of node.typeAnnotation.members ?? []) {
|
|
55
|
+
if (member.type !== "TSPropertySignature")
|
|
56
|
+
continue;
|
|
57
|
+
const keyName = member.key?.type === "Identifier" ? member.key.name : undefined;
|
|
58
|
+
if (!keyName)
|
|
59
|
+
continue;
|
|
60
|
+
const typeAnn = member.typeAnnotation?.typeAnnotation;
|
|
61
|
+
if (typeAnn?.type !== "TSUnionType")
|
|
62
|
+
continue;
|
|
63
|
+
// typescript-eslint emits ESTree `Literal` nodes; other TypeScript
|
|
64
|
+
// parsers emit `StringLiteral`. Accept both so the rule works
|
|
65
|
+
// whichever parser the consuming config installs.
|
|
66
|
+
const allStringLiterals = (typeAnn.types ?? []).every((t) => t.type === "TSLiteralType" &&
|
|
67
|
+
(t.literal?.type === "StringLiteral" ||
|
|
68
|
+
(t.literal?.type === "Literal" && typeof t.literal.value === "string")));
|
|
69
|
+
if (allStringLiterals && (typeAnn.types?.length ?? 0) >= 2) {
|
|
70
|
+
for (const variantNames of cvaDefinitions.values()) {
|
|
71
|
+
if (variantNames.includes(keyName)) {
|
|
72
|
+
context.report({
|
|
73
|
+
node,
|
|
74
|
+
message: `Variant prop "${keyName}" duplicates CVA variant values. ` +
|
|
75
|
+
"Use VariantProps<typeof variants> instead of manually writing union types. " +
|
|
76
|
+
"See docs/styling-guide/rules/component-variant-rule.md",
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -78,4 +84,3 @@ export const enforceCvaVariantPropsRule = {
|
|
|
78
84
|
};
|
|
79
85
|
},
|
|
80
86
|
};
|
|
81
|
-
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any -- re-enable after AST node access block */
|