POC: chore: use Nx workspace lint rules (#3163)
* chore: use Nx workspace lint rules Closes #3162 * Fix lint * Fix lint on BE * Fix tests --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
64
tools/eslint-rules/index.ts
Normal file
64
tools/eslint-rules/index.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
rule as componentPropsNaming,
|
||||
RULE_NAME as componentPropsNamingName,
|
||||
} from './rules/component-props-naming';
|
||||
import {
|
||||
rule as effectComponents,
|
||||
RULE_NAME as effectComponentsName,
|
||||
} from './rules/effect-components';
|
||||
import {
|
||||
rule as matchingStateVariable,
|
||||
RULE_NAME as matchingStateVariableName,
|
||||
} from './rules/matching-state-variable';
|
||||
import {
|
||||
rule as noHardcodedColors,
|
||||
RULE_NAME as noHardcodedColorsName,
|
||||
} from './rules/no-hardcoded-colors';
|
||||
import {
|
||||
rule as noStateUseref,
|
||||
RULE_NAME as noStateUserefName,
|
||||
} from './rules/no-state-useref';
|
||||
import {
|
||||
rule as sortCssPropertiesAlphabetically,
|
||||
RULE_NAME as sortCssPropertiesAlphabeticallyName,
|
||||
} from './rules/sort-css-properties-alphabetically';
|
||||
import {
|
||||
rule as styledComponentsPrefixedWithStyled,
|
||||
RULE_NAME as styledComponentsPrefixedWithStyledName,
|
||||
} from './rules/styled-components-prefixed-with-styled';
|
||||
/**
|
||||
* Import your custom workspace rules at the top of this file.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* import { RULE_NAME as myCustomRuleName, rule as myCustomRule } from './rules/my-custom-rule';
|
||||
*
|
||||
* In order to quickly get started with writing rules you can use the
|
||||
* following generator command and provide your desired rule name:
|
||||
*
|
||||
* ```sh
|
||||
* npx nx g @nx/eslint:workspace-rule {{ NEW_RULE_NAME }}
|
||||
* ```
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Apply the imported custom rules here.
|
||||
*
|
||||
* For example (using the example import above):
|
||||
*
|
||||
* rules: {
|
||||
* [myCustomRuleName]: myCustomRule
|
||||
* }
|
||||
*/
|
||||
rules: {
|
||||
[componentPropsNamingName]: componentPropsNaming,
|
||||
[effectComponentsName]: effectComponents,
|
||||
[matchingStateVariableName]: matchingStateVariable,
|
||||
[noHardcodedColorsName]: noHardcodedColors,
|
||||
[noStateUserefName]: noStateUseref,
|
||||
[sortCssPropertiesAlphabeticallyName]: sortCssPropertiesAlphabetically,
|
||||
[styledComponentsPrefixedWithStyledName]:
|
||||
styledComponentsPrefixedWithStyled,
|
||||
},
|
||||
};
|
||||
10
tools/eslint-rules/jest.config.ts
Normal file
10
tools/eslint-rules/jest.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'eslint-rules',
|
||||
preset: '../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/tools/eslint-rules',
|
||||
};
|
||||
28
tools/eslint-rules/project.json
Normal file
28
tools/eslint-rules/project.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "eslint-rules",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "tools/eslint-rules",
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"tools/eslint-rules/**/*.ts"
|
||||
],
|
||||
"fix": true
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "tools/eslint-rules/jest.config.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
tools/eslint-rules/rules/component-props-naming.spec.ts
Normal file
45
tools/eslint-rules/rules/component-props-naming.spec.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './component-props-naming';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'export const MyComponent= (props: MyComponentProps) => <div>{props.message}</div>;',
|
||||
},
|
||||
{
|
||||
code: 'export const MyComponent = ({ message }: MyComponentProps) => <div>{message}</div>;',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'export const MyComponent = (props: OwnProps) => <div>{props.message}</div>;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPropsTypeName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'export const MyComponent = (props: MyComponentProps) => <div>{props.message}</div>;',
|
||||
},
|
||||
{
|
||||
code: 'export const MyComponent = ({ message }: OwnProps) => <div>{message}</div>;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPropsTypeName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'export const MyComponent = ({ message }: MyComponentProps) => <div>{message}</div>;',
|
||||
},
|
||||
],
|
||||
});
|
||||
78
tools/eslint-rules/rules/component-props-naming.ts
Normal file
78
tools/eslint-rules/rules/component-props-naming.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import {
|
||||
isIdentifier,
|
||||
isVariableDeclarator,
|
||||
} from '@typescript-eslint/utils/ast-utils';
|
||||
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-component-props-naming"
|
||||
export const RULE_NAME = 'component-props-naming';
|
||||
|
||||
const checkPropsTypeName = ({
|
||||
node,
|
||||
context,
|
||||
functionName,
|
||||
}: {
|
||||
node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression;
|
||||
context: Readonly<RuleContext<'invalidPropsTypeName', any[]>>;
|
||||
functionName: string;
|
||||
}) => {
|
||||
const expectedPropTypeName = `${functionName}Props`;
|
||||
|
||||
if (!functionName.match(/^[A-Z]/)) return;
|
||||
|
||||
node.params.forEach((param) => {
|
||||
if (
|
||||
(param.type === TSESTree.AST_NODE_TYPES.ObjectPattern ||
|
||||
isIdentifier(param)) &&
|
||||
param.typeAnnotation?.typeAnnotation.type ===
|
||||
TSESTree.AST_NODE_TYPES.TSTypeReference &&
|
||||
isIdentifier(param.typeAnnotation?.typeAnnotation.typeName)
|
||||
) {
|
||||
const { typeName } = param.typeAnnotation.typeAnnotation;
|
||||
const actualPropTypeName = typeName.name;
|
||||
if (actualPropTypeName !== expectedPropTypeName) {
|
||||
context.report({
|
||||
node: param,
|
||||
messageId: 'invalidPropsTypeName',
|
||||
data: { expectedPropTypeName, actualPropTypeName },
|
||||
fix: (fixer) => fixer.replaceText(typeName, expectedPropTypeName),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure component props follow naming convention',
|
||||
recommended: 'recommended',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
invalidPropsTypeName:
|
||||
"Expected prop type to be '{{ expectedPropTypeName }}' but found '{{ actualPropTypeName }}'",
|
||||
},
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
return {
|
||||
ArrowFunctionExpression: (node) => {
|
||||
if (isVariableDeclarator(node.parent) && isIdentifier(node.parent.id)) {
|
||||
checkPropsTypeName({
|
||||
node,
|
||||
context,
|
||||
functionName: node.parent.id.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
FunctionDeclaration: (node) => {
|
||||
checkPropsTypeName({ node, context, functionName: node.id.name });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
83
tools/eslint-rules/rules/effect-components.spec.ts
Normal file
83
tools/eslint-rules/rules/effect-components.spec.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from '../rules/effect-components';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const TestComponentEffect = () => <></>;`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponent = () => <div></div>;`,
|
||||
},
|
||||
{
|
||||
code: `export const useUpdateEffect = () => null;`,
|
||||
},
|
||||
{
|
||||
code: `export const useUpdateEffect = () => <></>;`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponent = () => <><div></div></>;`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => null;`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const TestComponent = () => <></>;',
|
||||
output: 'const TestComponentEffect = () => <></>;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'addEffectSuffix',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const TestComponentEffect = () => <><div></div></>;',
|
||||
output: 'const TestComponent = () => <><div></div></>;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'removeEffectSuffix',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
115
tools/eslint-rules/rules/effect-components.ts
Normal file
115
tools/eslint-rules/rules/effect-components.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import {
|
||||
isIdentifier,
|
||||
isVariableDeclarator,
|
||||
} from '@typescript-eslint/utils/ast-utils';
|
||||
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-effect-components"
|
||||
export const RULE_NAME = 'effect-components';
|
||||
|
||||
const isPascalCase = (input: string) => !!input.match(/^[A-Z][a-zA-Z0-9_]*/);
|
||||
|
||||
type TargetNode =
|
||||
| TSESTree.ArrowFunctionExpression
|
||||
| TSESTree.FunctionDeclaration
|
||||
| TSESTree.FunctionExpression;
|
||||
|
||||
const isReturningEmptyFragmentOrNull = (node: TargetNode) =>
|
||||
// Direct return of JSX fragment, e.g., () => <></>
|
||||
(node.body.type === 'JSXFragment' && node.body.children.length === 0) ||
|
||||
// Direct return of null, e.g., () => null
|
||||
(node.body.type === 'Literal' && node.body.value === null) ||
|
||||
// Return JSX fragment or null from block
|
||||
(node.body.type === 'BlockStatement' &&
|
||||
node.body.body.some(
|
||||
(statement) =>
|
||||
statement.type === 'ReturnStatement' &&
|
||||
// Empty JSX fragment return, e.g., return <></>;
|
||||
((statement.argument?.type === 'JSXFragment' &&
|
||||
statement.argument.children.length === 0) ||
|
||||
// Empty React.Fragment return, e.g., return <React.Fragment></React.Fragment>;
|
||||
(statement.argument?.type === 'JSXElement' &&
|
||||
statement.argument.openingElement.name.type === 'JSXIdentifier' &&
|
||||
statement.argument.openingElement.name.name === 'React.Fragment' &&
|
||||
statement.argument.children.length === 0) ||
|
||||
// Literal null return, e.g., return null;
|
||||
(statement.argument?.type === 'Literal' &&
|
||||
statement.argument.value === null)),
|
||||
));
|
||||
|
||||
const checkEffectComponent = ({
|
||||
context,
|
||||
identifier,
|
||||
node,
|
||||
}: {
|
||||
context: Readonly<
|
||||
RuleContext<'addEffectSuffix' | 'removeEffectSuffix', any[]>
|
||||
>;
|
||||
identifier: TSESTree.Identifier;
|
||||
node: TargetNode;
|
||||
}) => {
|
||||
const componentName = identifier.name;
|
||||
|
||||
if (!isPascalCase(componentName)) return;
|
||||
|
||||
const isEffectComponent = isReturningEmptyFragmentOrNull(node);
|
||||
const hasEffectSuffix = componentName.endsWith('Effect');
|
||||
|
||||
if (isEffectComponent && !hasEffectSuffix) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'addEffectSuffix',
|
||||
data: { componentName },
|
||||
fix: (fixer) => fixer.replaceText(identifier, componentName + 'Effect'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasEffectSuffix && !isEffectComponent) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'removeEffectSuffix',
|
||||
data: { componentName },
|
||||
fix: (fixer) =>
|
||||
fixer.replaceText(identifier, componentName.replace('Effect', '')),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
docs: {
|
||||
description:
|
||||
'Effect components should end with the Effect suffix. This rule checks only components that are in PascalCase and that return a JSX fragment or null. Any renderProps or camelCase components are ignored.',
|
||||
},
|
||||
messages: {
|
||||
addEffectSuffix:
|
||||
'Effect component {{ componentName }} should end with the Effect suffix.',
|
||||
removeEffectSuffix:
|
||||
"Component {{ componentName }} shouldn't end with the Effect suffix because it doesn't return a JSX fragment or null.",
|
||||
},
|
||||
type: 'suggestion',
|
||||
schema: [],
|
||||
fixable: 'code',
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
const checkFunctionExpressionEffectComponent = (
|
||||
node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
||||
) =>
|
||||
isVariableDeclarator(node.parent) && isIdentifier(node.parent.id)
|
||||
? checkEffectComponent({ context, identifier: node.parent.id, node })
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: checkFunctionExpressionEffectComponent,
|
||||
|
||||
FunctionDeclaration: (node) =>
|
||||
checkEffectComponent({ context, identifier: node.id, node }),
|
||||
|
||||
FunctionExpression: checkFunctionExpressionEffectComponent,
|
||||
};
|
||||
},
|
||||
});
|
||||
178
tools/eslint-rules/rules/matching-state-variable.spec.ts
Normal file
178
tools/eslint-rules/rules/matching-state-variable.spec.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './matching-state-variable';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const variable = useRecoilValue(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const variable = useRecoilScopedValue(variableScopedState);',
|
||||
},
|
||||
{
|
||||
code: 'const [variable, setVariable] = useRecoilState(variableScopedState);',
|
||||
},
|
||||
{
|
||||
code: 'const [variable, setVariable] = useRecoilScopedState(variableScopedState);',
|
||||
},
|
||||
{
|
||||
code: 'const [variable, setVariable] = useRecoilFamilyState(variableScopedState);',
|
||||
},
|
||||
{
|
||||
code: 'const [variable, setVariable] = useRecoilScopedFamilyState(variableScopedState);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const myValue = useRecoilValue(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const variable = useRecoilValue(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const myValue = useRecoilScopedValue(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const variable = useRecoilScopedValue(variableState);',
|
||||
},
|
||||
|
||||
{
|
||||
code: 'const [myValue, setMyValue] = useRecoilState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output: 'const [variable, setVariable] = useRecoilState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [myValue] = useRecoilState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const [variable] = useRecoilState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [, setMyValue] = useRecoilState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output: 'const [, setVariable] = useRecoilState(variableState);',
|
||||
},
|
||||
|
||||
{
|
||||
code: 'const [myValue, setMyValue] = useRecoilScopedState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'const [variable, setVariable] = useRecoilScopedState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [myValue] = useRecoilScopedState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const [variable] = useRecoilScopedState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [, setMyValue] = useRecoilScopedState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output: 'const [, setVariable] = useRecoilScopedState(variableState);',
|
||||
},
|
||||
|
||||
{
|
||||
code: 'const [myValue, setMyValue] = useRecoilFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'const [variable, setVariable] = useRecoilFamilyState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [myValue] = useRecoilFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const [variable] = useRecoilFamilyState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [, setMyValue] = useRecoilFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output: 'const [, setVariable] = useRecoilFamilyState(variableState);',
|
||||
},
|
||||
|
||||
{
|
||||
code: 'const [myValue, setMyValue] = useRecoilScopedFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'const [variable, setVariable] = useRecoilScopedFamilyState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [myValue] = useRecoilScopedFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidVariableName',
|
||||
},
|
||||
],
|
||||
output: 'const [variable] = useRecoilScopedFamilyState(variableState);',
|
||||
},
|
||||
{
|
||||
code: 'const [, setMyValue] = useRecoilScopedFamilyState(variableState);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidSetterName',
|
||||
},
|
||||
],
|
||||
output:
|
||||
'const [, setVariable] = useRecoilScopedFamilyState(variableState);',
|
||||
},
|
||||
],
|
||||
});
|
||||
142
tools/eslint-rules/rules/matching-state-variable.ts
Normal file
142
tools/eslint-rules/rules/matching-state-variable.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import {
|
||||
AST_NODE_TYPES,
|
||||
ESLintUtils,
|
||||
TSESTree,
|
||||
} from '@typescript-eslint/utils';
|
||||
import { isIdentifier } from '@typescript-eslint/utils/ast-utils';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-matching-state-variable"
|
||||
export const RULE_NAME = 'matching-state-variable';
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Ensure recoil value and setter are named after their atom name',
|
||||
recommended: 'recommended',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
invalidVariableName:
|
||||
"Invalid usage of {{ hookName }}: the variable should be named '{{ expectedName }}' but found '{{ actualName }}'.",
|
||||
invalidSetterName:
|
||||
"Invalid usage of {{ hookName }}: Expected setter '{{ expectedName }}' but found '{{ actualName }}'.",
|
||||
},
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
return {
|
||||
VariableDeclarator: (node: TSESTree.VariableDeclarator) => {
|
||||
if (
|
||||
node?.init?.type === AST_NODE_TYPES.CallExpression &&
|
||||
isIdentifier(node.init.callee) &&
|
||||
[
|
||||
'useRecoilState',
|
||||
'useRecoilScopedState',
|
||||
'useRecoilFamilyState',
|
||||
'useRecoilScopedFamilyState',
|
||||
'useRecoilValue',
|
||||
'useRecoilScopedValue',
|
||||
].includes(node.init.callee.name)
|
||||
) {
|
||||
const stateNameBase = isIdentifier(node.init.arguments[0])
|
||||
? node.init.arguments[0].name
|
||||
: undefined;
|
||||
|
||||
if (!stateNameBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedVariableNameBase = stateNameBase.replace(
|
||||
/(State|FamilyState|Selector|ScopedState|ScopedFamilyState|ScopedSelector)$/,
|
||||
'',
|
||||
);
|
||||
|
||||
if (isIdentifier(node.id)) {
|
||||
const actualVariableName = node.id.name;
|
||||
|
||||
if (actualVariableName !== expectedVariableNameBase) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidVariableName',
|
||||
data: {
|
||||
actualName: actualVariableName,
|
||||
expectedName: expectedVariableNameBase,
|
||||
hookName: stateNameBase,
|
||||
callee: node.init.callee.name,
|
||||
},
|
||||
fix: (fixer) => {
|
||||
return fixer.replaceText(node.id, expectedVariableNameBase);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.id.type === AST_NODE_TYPES.ArrayPattern) {
|
||||
const actualVariableName =
|
||||
node.id.elements?.[0]?.type === AST_NODE_TYPES.Identifier
|
||||
? node.id.elements[0].name
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
actualVariableName &&
|
||||
actualVariableName !== expectedVariableNameBase
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidVariableName',
|
||||
data: {
|
||||
actual: actualVariableName,
|
||||
expected: expectedVariableNameBase,
|
||||
callee: node.init.callee.name,
|
||||
},
|
||||
fix: (fixer) => {
|
||||
if (node.id.type === AST_NODE_TYPES.ArrayPattern) {
|
||||
return fixer.replaceText(
|
||||
node.id.elements[0] as TSESTree.Node,
|
||||
expectedVariableNameBase,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isIdentifier(node.id.elements[1])) {
|
||||
const actualSetterName = node.id.elements[1].name;
|
||||
const expectedSetterName = `set${expectedVariableNameBase
|
||||
.charAt(0)
|
||||
.toUpperCase()}${expectedVariableNameBase.slice(1)}`;
|
||||
|
||||
if (actualSetterName !== expectedSetterName) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidSetterName',
|
||||
data: {
|
||||
hookName: stateNameBase,
|
||||
actualName: actualSetterName,
|
||||
expectedName: expectedSetterName,
|
||||
},
|
||||
fix: (fixer) => {
|
||||
if (node.id.type === AST_NODE_TYPES.ArrayPattern) {
|
||||
return fixer.replaceText(
|
||||
node.id.elements[1]!,
|
||||
expectedSetterName,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
55
tools/eslint-rules/rules/no-hardcoded-colors.spec.ts
Normal file
55
tools/eslint-rules/rules/no-hardcoded-colors.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './no-hardcoded-colors';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const color = theme.background.secondary;',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const color = "rgb(154,205,50)";',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const color = { test: "rgb(154,205,50)", test2: "#ADFF2F" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const color = { test: `rgb(${r},${g},${b})`, test2: `#ADFF${test}` }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const color = "#ADFF2F";',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardcodedColor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
61
tools/eslint-rules/rules/no-hardcoded-colors.ts
Normal file
61
tools/eslint-rules/rules/no-hardcoded-colors.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-no-hardcoded-colors"
|
||||
export const RULE_NAME = 'no-hardcoded-colors';
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
docs: {
|
||||
description:
|
||||
'Do not use hardcoded RGBA or Hex colors. Please use a color from the theme file.',
|
||||
},
|
||||
messages: {
|
||||
hardcodedColor:
|
||||
'Hardcoded color {{ color }} found. Please use a color from the theme file.',
|
||||
},
|
||||
type: 'suggestion',
|
||||
schema: [],
|
||||
fixable: 'code',
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
const testHardcodedColor = (
|
||||
literal: TSESTree.Literal | TSESTree.TemplateLiteral,
|
||||
) => {
|
||||
const colorRegex = /(?:rgba?\()|(?:#[0-9a-fA-F]{2,6})/i;
|
||||
|
||||
if (
|
||||
literal.type === TSESTree.AST_NODE_TYPES.Literal &&
|
||||
typeof literal.value === 'string'
|
||||
) {
|
||||
if (colorRegex.test(literal.value)) {
|
||||
context.report({
|
||||
node: literal,
|
||||
messageId: 'hardcodedColor',
|
||||
data: {
|
||||
color: literal.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (literal.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) {
|
||||
const firstStringValue = literal.quasis[0]?.value.raw;
|
||||
|
||||
if (colorRegex.test(firstStringValue)) {
|
||||
context.report({
|
||||
node: literal,
|
||||
messageId: 'hardcodedColor',
|
||||
data: {
|
||||
color: firstStringValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
Literal: testHardcodedColor,
|
||||
TemplateLiteral: testHardcodedColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
44
tools/eslint-rules/rules/no-state-useref.spec.ts
Normal file
44
tools/eslint-rules/rules/no-state-useref.spec.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './no-state-useref';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const scrollableRef = useRef<HTMLDivElement>(null);',
|
||||
},
|
||||
{
|
||||
code: 'const ref = useRef<HTMLInputElement>(null);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const ref = useRef(null);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noStateUseRef',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const ref = useRef<Boolean>(null);',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noStateUseRef',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "const ref = useRef<string>('');",
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noStateUseRef',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
44
tools/eslint-rules/rules/no-state-useref.ts
Normal file
44
tools/eslint-rules/rules/no-state-useref.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { isIdentifier } from '@typescript-eslint/utils/ast-utils';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-no-state-useref"
|
||||
export const RULE_NAME = 'no-state-useref';
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
docs: {
|
||||
description: "Don't use useRef for state management",
|
||||
},
|
||||
messages: {
|
||||
test: 'test',
|
||||
noStateUseRef:
|
||||
"Don't use useRef for state management. See https://docs.twenty.com/developer/frontend/best-practices#do-not-use-useref-to-store-state for more details.",
|
||||
},
|
||||
type: 'suggestion',
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
return {
|
||||
CallExpression: (node) => {
|
||||
if (!isIdentifier(node.callee) || node.callee.name !== 'useRef') return;
|
||||
|
||||
const typeParam = node.typeArguments?.params[0];
|
||||
|
||||
if (
|
||||
!typeParam ||
|
||||
typeParam.type !== 'TSTypeReference' ||
|
||||
!isIdentifier(typeParam.typeName) ||
|
||||
!typeParam.typeName.name.match(/^(HTML.*Element|Element)$/)
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStateUseRef',
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './sort-css-properties-alphabetically';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const style = css`color: red;`;',
|
||||
},
|
||||
{
|
||||
code: 'const style = css`background-color: $bgColor;color: red;`;',
|
||||
},
|
||||
{
|
||||
code: 'const StyledComponent = styled.div`color: red;`;',
|
||||
},
|
||||
{
|
||||
code: 'const StyledComponent = styled.div`background-color: $bgColor;color: red;`;',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const style = css`color: #FF0000;background-color: $bgColor`;',
|
||||
output: 'const style = css`background-color: $bgColorcolor: #FF0000;`;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'sortCssPropertiesAlphabetically',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const StyledComponent = styled.div`color: #FF0000;background-color: $bgColor`;',
|
||||
output:
|
||||
'const StyledComponent = styled.div`background-color: $bgColorcolor: #FF0000;`;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'sortCssPropertiesAlphabetically',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
282
tools/eslint-rules/rules/sort-css-properties-alphabetically.ts
Normal file
282
tools/eslint-rules/rules/sort-css-properties-alphabetically.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
||||
import { isIdentifier } from '@typescript-eslint/utils/ast-utils';
|
||||
import {
|
||||
RuleFix,
|
||||
RuleFixer,
|
||||
SourceCode,
|
||||
} from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import postcss from 'postcss';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-sort-css-properties-alphabetically"
|
||||
export const RULE_NAME = 'sort-css-properties-alphabetically';
|
||||
|
||||
interface Loc {
|
||||
start: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
end: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
}
|
||||
|
||||
const isMemberExpression = (
|
||||
node: TSESTree.Node,
|
||||
): node is TSESTree.MemberExpression =>
|
||||
node.type === TSESTree.AST_NODE_TYPES.MemberExpression;
|
||||
const isCallExpression = (
|
||||
node: TSESTree.Node,
|
||||
): node is TSESTree.CallExpression =>
|
||||
node.type === TSESTree.AST_NODE_TYPES.CallExpression;
|
||||
|
||||
const isStyledTagname = (node: TSESTree.TaggedTemplateExpression): boolean => {
|
||||
if (isIdentifier(node.tag)) {
|
||||
return node.tag.name === 'css';
|
||||
}
|
||||
|
||||
if (isMemberExpression(node.tag) && isIdentifier(node.tag.object)) {
|
||||
return node.tag.object.name === 'styled';
|
||||
}
|
||||
|
||||
if (isCallExpression(node.tag) && isIdentifier(node.tag.callee)) {
|
||||
return node.tag.callee.name === 'styled';
|
||||
}
|
||||
|
||||
if (
|
||||
isCallExpression(node.tag) &&
|
||||
isMemberExpression(node.tag.callee) &&
|
||||
isIdentifier(node.tag.callee.object)
|
||||
) {
|
||||
return node.tag.callee.object.name === 'styled';
|
||||
}
|
||||
|
||||
if (
|
||||
isCallExpression(node.tag) &&
|
||||
isMemberExpression(node.tag.callee) &&
|
||||
isMemberExpression(node.tag.callee.object) &&
|
||||
isIdentifier(node.tag.callee.object.object)
|
||||
) {
|
||||
return node.tag.callee.object.object.name === 'styled';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* An atomic rule is a rule without nested rules.
|
||||
*/
|
||||
const isValidAtomicRule = (
|
||||
rule: postcss.Rule,
|
||||
): { isValid: boolean; loc?: Loc } => {
|
||||
const decls = rule.nodes.filter(
|
||||
(node) => node.type === 'decl',
|
||||
) as unknown as postcss.Declaration[];
|
||||
|
||||
const invalidDeclIndex = decls.findIndex((decl, index) => {
|
||||
if (index === 0) return false;
|
||||
|
||||
const current = decl.prop;
|
||||
const prev = decls[index - 1].prop;
|
||||
|
||||
return current < prev;
|
||||
});
|
||||
|
||||
return invalidDeclIndex > 0
|
||||
? {
|
||||
isValid: false,
|
||||
loc: {
|
||||
start: {
|
||||
line: decls[invalidDeclIndex - 1].source!.start!.line,
|
||||
column: decls[invalidDeclIndex - 1].source!.start!.column - 1,
|
||||
},
|
||||
end: {
|
||||
line: decls[invalidDeclIndex].source!.end!.line,
|
||||
column: decls[invalidDeclIndex].source!.end!.column - 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { isValid: true };
|
||||
};
|
||||
|
||||
const isValidRule = (rule: postcss.Rule): { isValid: boolean; loc?: Loc } => {
|
||||
// check each rule recursively
|
||||
const { isValid, loc } = rule.nodes.reduce<{ isValid: boolean; loc?: Loc }>(
|
||||
(map, node) => {
|
||||
return node.type === 'rule' ? isValidRule(node) : map;
|
||||
},
|
||||
{ isValid: true },
|
||||
);
|
||||
|
||||
// if there is any invalid rule, return result
|
||||
if (!isValid) {
|
||||
return { isValid, loc };
|
||||
}
|
||||
|
||||
// check declarations
|
||||
return isValidAtomicRule(rule);
|
||||
};
|
||||
|
||||
const getNodeStyles = (node: TSESTree.TaggedTemplateExpression): string => {
|
||||
const [firstQuasi, ...quasis] = node.quasi.quasis;
|
||||
// remove line break added to the first quasi
|
||||
const lineBreakCount = node.quasi.loc.start.line - 1;
|
||||
let styles = `${'\n'.repeat(lineBreakCount)}${' '.repeat(
|
||||
node.quasi.loc.start.column + 1,
|
||||
)}${firstQuasi.value.raw}`;
|
||||
|
||||
// replace expression by spaces and line breaks
|
||||
quasis.forEach(({ value, loc }, idx) => {
|
||||
const prevLoc = idx === 0 ? firstQuasi.loc : quasis[idx - 1].loc;
|
||||
const lineBreaksCount = loc.start.line - prevLoc.end.line;
|
||||
const spacesCount =
|
||||
loc.start.line === prevLoc.end.line
|
||||
? loc.start.column - prevLoc.end.column + 2
|
||||
: loc.start.column + 1;
|
||||
styles = `${styles}${' '}${'\n'.repeat(lineBreaksCount)}${' '.repeat(
|
||||
spacesCount,
|
||||
)}${value.raw}`;
|
||||
});
|
||||
|
||||
return styles;
|
||||
};
|
||||
|
||||
const fix = ({
|
||||
rule,
|
||||
fixer,
|
||||
src,
|
||||
}: {
|
||||
rule: postcss.Rule;
|
||||
fixer: RuleFixer;
|
||||
src: SourceCode;
|
||||
}): RuleFix[] => {
|
||||
// concat fixings recursively
|
||||
const fixings = rule.nodes
|
||||
.filter((node): node is postcss.Rule => node.type === 'rule')
|
||||
.flatMap((node) => fix({ rule: node, fixer, src }));
|
||||
|
||||
const declarations = rule.nodes.filter(
|
||||
(node): node is postcss.Declaration => node.type === 'decl',
|
||||
);
|
||||
const sortedDeclarations = sortDeclarations(declarations);
|
||||
|
||||
return [
|
||||
...fixings,
|
||||
...declarations.flatMap((decl, index) => {
|
||||
if (!areSameDeclarations(decl, sortedDeclarations[index])) {
|
||||
try {
|
||||
const range = getDeclRange({ decl, src });
|
||||
const sortedDeclText = getDeclText({
|
||||
decl: sortedDeclarations[index],
|
||||
src,
|
||||
});
|
||||
|
||||
return [
|
||||
fixer.removeRange([range.startIdx, range.endIdx + 1]),
|
||||
fixer.insertTextAfterRange(
|
||||
[range.startIdx, range.startIdx],
|
||||
sortedDeclText,
|
||||
),
|
||||
];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const areSameDeclarations = (
|
||||
a: postcss.ChildNode,
|
||||
b: postcss.ChildNode,
|
||||
): boolean =>
|
||||
a.source!.start!.line === b.source!.start!.line &&
|
||||
a.source!.start!.column === b.source!.start!.column;
|
||||
|
||||
const getDeclRange = ({
|
||||
decl,
|
||||
src,
|
||||
}: {
|
||||
decl: postcss.ChildNode;
|
||||
src: SourceCode;
|
||||
}): { startIdx: number; endIdx: number } => {
|
||||
const loc = {
|
||||
start: {
|
||||
line: decl.source!.start!.line,
|
||||
column: decl.source!.start!.column - 1,
|
||||
},
|
||||
end: {
|
||||
line: decl.source!.end!.line,
|
||||
column: decl.source!.end!.column - 1,
|
||||
},
|
||||
};
|
||||
|
||||
const startIdx = src.getIndexFromLoc(loc.start);
|
||||
const endIdx = src.getIndexFromLoc(loc.end);
|
||||
return { startIdx, endIdx };
|
||||
};
|
||||
|
||||
const getDeclText = ({
|
||||
decl,
|
||||
src,
|
||||
}: {
|
||||
decl: postcss.ChildNode;
|
||||
src: SourceCode;
|
||||
}) => {
|
||||
const { startIdx, endIdx } = getDeclRange({ decl, src });
|
||||
return src.getText().substring(startIdx, endIdx + 1);
|
||||
};
|
||||
|
||||
const sortDeclarations = (declarations: postcss.Declaration[]) =>
|
||||
declarations
|
||||
.slice()
|
||||
.sort((declA, declB) => (declA.prop > declB.prop ? 1 : -1));
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
docs: {
|
||||
description: 'Styles are sorted alphabetically.',
|
||||
recommended: 'recommended',
|
||||
},
|
||||
messages: {
|
||||
sortCssPropertiesAlphabetically:
|
||||
'Declarations should be sorted alphabetically.',
|
||||
},
|
||||
type: 'suggestion',
|
||||
schema: [],
|
||||
fixable: 'code',
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
return {
|
||||
TaggedTemplateExpression: (node) => {
|
||||
if (!isStyledTagname(node)) return;
|
||||
|
||||
try {
|
||||
const root = postcss.parse(
|
||||
getNodeStyles(node),
|
||||
) as unknown as postcss.Rule;
|
||||
|
||||
const { isValid } = isValidRule(root);
|
||||
|
||||
if (!isValid) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'sortCssPropertiesAlphabetically',
|
||||
fix: (fixer) =>
|
||||
fix({
|
||||
rule: root,
|
||||
fixer,
|
||||
src: context.sourceCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { rule, RULE_NAME } from './styled-components-prefixed-with-styled';
|
||||
|
||||
const ruleTester = new TSESLint.RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
});
|
||||
|
||||
ruleTester.run(RULE_NAME, rule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const StyledButton = styled.button``;',
|
||||
},
|
||||
{
|
||||
code: 'const StyledComponent = styled.div``;',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const Button = styled.button``;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noStyledPrefix',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const Component = styled.div``;',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'noStyledPrefix',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { isIdentifier } from '@typescript-eslint/utils/ast-utils';
|
||||
|
||||
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-styled-components-prefixed-with-styled"
|
||||
export const RULE_NAME = 'styled-components-prefixed-with-styled';
|
||||
|
||||
export const rule = ESLintUtils.RuleCreator(() => __filename)({
|
||||
name: RULE_NAME,
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Warn when StyledComponents are not prefixed with Styled',
|
||||
recommended: 'recommended',
|
||||
},
|
||||
messages: {
|
||||
noStyledPrefix:
|
||||
'{{componentName}} is a StyledComponent and is not prefixed with Styled.',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create: (context) => {
|
||||
return {
|
||||
VariableDeclarator: (node) => {
|
||||
const templateExpr = node.init;
|
||||
|
||||
if (templateExpr?.type !== AST_NODE_TYPES.TaggedTemplateExpression)
|
||||
return;
|
||||
|
||||
const tag = templateExpr.tag;
|
||||
const tagged =
|
||||
tag.type === AST_NODE_TYPES.MemberExpression
|
||||
? tag.object
|
||||
: tag.type === AST_NODE_TYPES.CallExpression
|
||||
? tag.callee
|
||||
: null;
|
||||
|
||||
if (
|
||||
isIdentifier(node.id) &&
|
||||
isIdentifier(tagged) &&
|
||||
tagged.name === 'styled'
|
||||
) {
|
||||
const variable = node.id;
|
||||
|
||||
if (variable.name.startsWith('Styled')) return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStyledPrefix',
|
||||
data: {
|
||||
componentName: variable.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
18
tools/eslint-rules/tsconfig.json
Normal file
18
tools/eslint-rules/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node16",
|
||||
"module": "node16"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lint.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
tools/eslint-rules/tsconfig.lint.json
Normal file
9
tools/eslint-rules/tsconfig.lint.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["**/*.spec.ts"],
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
8
tools/eslint-rules/tsconfig.spec.json
Normal file
8
tools/eslint-rules/tsconfig.spec.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user