From 11ea9baa7d4179992e40f25c03a344b1003ebf52 Mon Sep 17 00:00:00 2001 From: SimeonC <1085899+SimeonC@users.noreply.github.com> Date: Wed, 1 Nov 2023 11:43:27 +0900 Subject: [PATCH] feat: add more eslint config presets Allows us to use the eslint configs more flexibly. Upgrades the quality generator to allow selecting which preset - required for NX cypress project usage. --- packages/eslint-config/README.stories.mdx | 30 +++++++++ packages/eslint-config/package.json | 30 +++++++++ .../src/overrides/buildBaseTypescript.ts | 63 +++++++++-------- .../src/overrides/documentation.ts | 6 +- .../eslint-config/src/overrides/typescript.ts | 8 +-- .../src/overrides/typescriptDefinitions.ts | 10 +-- packages/eslint-config/src/presets/basic.ts | 67 +++++++++++++++++++ packages/eslint-config/src/presets/cypress.ts | 43 ++++++++++++ .../src/presets/cypressComponent.ts | 15 +++++ packages/eslint-config/src/presets/react.ts | 41 ++++++++++++ packages/eslint-config/src/presets/reactTs.ts | 13 ++++ .../eslint-config/src/presets/typescript.ts | 64 ++++++++++++++++++ .../nx/src/generators/quality/eslintConfig.ts | 44 +++++++++++- .../nx/src/generators/quality/generator.ts | 4 +- .../nx/src/generators/quality/schema.json | 46 ++++++++++++- 15 files changed, 438 insertions(+), 46 deletions(-) create mode 100644 packages/eslint-config/src/presets/basic.ts create mode 100644 packages/eslint-config/src/presets/cypress.ts create mode 100644 packages/eslint-config/src/presets/cypressComponent.ts create mode 100644 packages/eslint-config/src/presets/react.ts create mode 100644 packages/eslint-config/src/presets/reactTs.ts create mode 100644 packages/eslint-config/src/presets/typescript.ts diff --git a/packages/eslint-config/README.stories.mdx b/packages/eslint-config/README.stories.mdx index 0697193b..024bece4 100644 --- a/packages/eslint-config/README.stories.mdx +++ b/packages/eslint-config/README.stories.mdx @@ -5,6 +5,7 @@ import { Meta } from '@storybook/addon-docs/blocks'; ## Introduction This package is our standard rules for eslint in the form of a eslint preset package. +We also export sub presets for more specific use cases. Recommended usage is; @@ -19,3 +20,32 @@ module.exports = { ```shell static eslint --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./ ``` + +### Presets + +- `@tablecheck/eslint-config` - Our standard rules for eslint, used with legacy combined repos + +#### Specific Presets + +- `@tablecheck/eslint-config/basic` - Rules for vanilla javascript only repositories. +- `@tablecheck/eslint-config/typescript` - Rules for vanilla typescript projects +- `@tablecheck/eslint-config/react` - Rules for react javascript project +- `@tablecheck/eslint-config/react-typescript` - Rules for react typescript project + +The following cypress presets should be used with one of the above Specific Presets + +- `@tablecheck/eslint-config/cypress` - Rules for Cypress projects (applies to all files) +- `@tablecheck/eslint-config/cypress-component` - Use this when adding component testing to a project, it adds an override for `*.cypress.*` and `*.cy.*` files + +For example, a react typescript project with cypress component testing should use the following; + +`.eslintrc.js` + +```js static +module.exports = { + extends: [ + '@tablecheck/eslint-config/react-typescript', + '@tablecheck/eslint-config/cypress-component', + ], +}; +``` diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 34e846ae..226b90b8 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -14,6 +14,36 @@ "main": "./dist/index.js", "default": "./dist/index.js" }, + "./basic": { + "types": "./dist/presets/basic.d.ts", + "main": "./dist/presets/basic.js", + "default": "./dist/presets/basic.js" + }, + "./typescript": { + "types": "./dist/presets/typescript.d.ts", + "main": "./dist/presets/typescript.js", + "default": "./dist/presets/typescript.js" + }, + "./cypress": { + "types": "./dist/presets/cypress.d.ts", + "main": "./dist/presets/cypress.js", + "default": "./dist/presets/cypress.js" + }, + "./cypress-component": { + "types": "./dist/presets/cypressComponent.d.ts", + "main": "./dist/presets/cypressComponent.js", + "default": "./dist/presets/cypressComponent.js" + }, + "./react": { + "types": "./dist/presets/react.d.ts", + "main": "./dist/presets/react.js", + "default": "./dist/presets/react.js" + }, + "./react-typescript": { + "types": "./dist/presets/reactTs.d.ts", + "main": "./dist/presets/reactTs.js", + "default": "./dist/presets/reactTs.js" + }, "./package.json": "./package.json" }, "main": "./dist/index.js", diff --git a/packages/eslint-config/src/overrides/buildBaseTypescript.ts b/packages/eslint-config/src/overrides/buildBaseTypescript.ts index 1ba7e801..9fbd5a96 100644 --- a/packages/eslint-config/src/overrides/buildBaseTypescript.ts +++ b/packages/eslint-config/src/overrides/buildBaseTypescript.ts @@ -11,7 +11,7 @@ if (!process.env.NODE_ENV) { * typescript specific overrides for enabled eslint rules. * Make sure to keep the typescript + eslint rules paired and commented. */ -const eslintTypescriptRules: Linter.RulesRecord = { +export const baseTypescriptRules: Linter.RulesRecord = { // unused variables '@typescript-eslint/no-unused-vars': 'error', 'no-void': 'off', @@ -25,21 +25,41 @@ const eslintTypescriptRules: Linter.RulesRecord = { // see https://stackoverflow.com/a/67652059/1413689 'consistent-return': 'off', '@typescript-eslint/no-unsafe-return': 'error', + + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/prefer-nullish-coalescing': [ + 'error', + { + ignoreConditionalTests: true, + ignoreMixedLogicalExpressions: true, + ignorePrimitives: { + string: true, + boolean: true, + }, + }, + ], + '@tablecheck/prefer-shortest-import': 'error', }; -/** - * - * @param files - file globs - * @param rules - here should be the basic rules - * @param forcedRules - this is the place to override any ts rules - * @returns eslint-config - */ -export function buildBaseTypescript( - files: Linter.ConfigOverride['files'], - rules: Linter.RulesRecord, - forcedRules?: Linter.RulesRecord, -): Linter.ConfigOverride | undefined { +export function buildBaseTypescript< + T extends Linter.RulesRecord, + TForced extends Linter.RulesRecord, +>({ + files, + rules, + forcedRules, + ...options +}: { + files: Linter.ConfigOverride['files']; + rules: T; + forcedRules?: TForced; +} & Omit< + Linter.ConfigOverride, + 'parser' | 'extends' | 'plugins' | 'settings' | 'rules' | 'files' +>): Linter.ConfigOverride { return { + ...options, parser: '@typescript-eslint/parser', extends: [ 'airbnb-typescript', @@ -47,7 +67,6 @@ export function buildBaseTypescript( 'plugin:@typescript-eslint/stylistic-type-checked', 'plugin:eslint-comments/recommended', 'prettier', - 'plugin:react-hooks/recommended', ], plugins: [ @@ -68,21 +87,7 @@ export function buildBaseTypescript( }, rules: { ...rules, - ...eslintTypescriptRules, - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unsafe-enum-comparison': 'off', - '@typescript-eslint/prefer-nullish-coalescing': [ - 'error', - { - ignoreConditionalTests: true, - ignoreMixedLogicalExpressions: true, - ignorePrimitives: { - string: true, - boolean: true, - }, - }, - ], - '@tablecheck/prefer-shortest-import': 'error', + ...baseTypescriptRules, ...forcedRules, }, }; diff --git a/packages/eslint-config/src/overrides/documentation.ts b/packages/eslint-config/src/overrides/documentation.ts index ce5ba845..e56b6f5e 100644 --- a/packages/eslint-config/src/overrides/documentation.ts +++ b/packages/eslint-config/src/overrides/documentation.ts @@ -3,9 +3,9 @@ import type { Linter } from 'eslint'; export const documentationOverrides: Linter.ConfigOverride = { files: [ '**/__fixtures__/**/*', - '**/*.fixture.{js,jsx}', - '**/*.{stories,story}.{js,jsx}', - '.storybook/**/*.{js,jsx}', + '**/*.fixture.{js,jsx,cjs,mjs}', + '**/*.{stories,story}.{js,jsx,cjs,mjs}', + '.storybook/**/*.{js,jsx,cjs,mjs}', ], rules: { 'no-console': 'off', diff --git a/packages/eslint-config/src/overrides/typescript.ts b/packages/eslint-config/src/overrides/typescript.ts index 4e961b61..acc0d031 100644 --- a/packages/eslint-config/src/overrides/typescript.ts +++ b/packages/eslint-config/src/overrides/typescript.ts @@ -6,13 +6,13 @@ import { reactRules } from '../rules/react'; import { buildBaseTypescript } from './buildBaseTypescript'; -export const typescriptOverrides = buildBaseTypescript( - ['**/*.ts', '**/*.tsx'], - { +export const typescriptOverrides = buildBaseTypescript({ + files: ['**/*.ts', '**/*.tsx'], + rules: { ...generalRules, ...reactRules, ...promiseRules, ...emotionRules, ...namingRules, }, -); +}); diff --git a/packages/eslint-config/src/overrides/typescriptDefinitions.ts b/packages/eslint-config/src/overrides/typescriptDefinitions.ts index 9c37f773..3a396b3b 100644 --- a/packages/eslint-config/src/overrides/typescriptDefinitions.ts +++ b/packages/eslint-config/src/overrides/typescriptDefinitions.ts @@ -5,15 +5,15 @@ import { reactRules } from '../rules/react'; import { buildBaseTypescript } from './buildBaseTypescript'; -export const typescriptDefinitionOverrides = buildBaseTypescript( - ['**/*.d.ts'], - { +export const typescriptDefinitionOverrides = buildBaseTypescript({ + files: ['**/*.d.ts'], + rules: { ...generalRules, ...reactRules, ...promiseRules, ...emotionRules, }, - { + forcedRules: { 'import/no-default-export': 'off', 'vars-on-top': 'off', 'no-unused-vars': 'off', @@ -22,4 +22,4 @@ export const typescriptDefinitionOverrides = buildBaseTypescript( '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, -); +}); diff --git a/packages/eslint-config/src/presets/basic.ts b/packages/eslint-config/src/presets/basic.ts new file mode 100644 index 00000000..ec38d749 --- /dev/null +++ b/packages/eslint-config/src/presets/basic.ts @@ -0,0 +1,67 @@ +import type { Linter } from 'eslint'; + +import { rootConfigsOverrides } from '../overrides/rootConfigs'; +import { scriptsOverrides } from '../overrides/scripts'; +import { testOverrides } from '../overrides/tests'; +import { emotionRules } from '../rules/emotion'; +import { generalRules } from '../rules/general'; +import { promiseRules } from '../rules/promise'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +module.exports = { + extends: ['airbnb', 'plugin:eslint-comments/recommended', 'prettier'], + + plugins: ['eslint-comments', 'promise', '@tablecheck', '@nx', '@emotion'], + + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + + env: { + node: true, + browser: true, + commonjs: true, + es6: true, + }, + + rules: { + ...generalRules, + ...promiseRules, + ...emotionRules, + }, + + overrides: [ + rootConfigsOverrides, + scriptsOverrides, + testOverrides, + { + files: [ + '**/__fixtures__/**/*', + '**/*.fixture.{ts,tsx,js,jsx,cts,mts,cjs,mjs}', + '**/*.{stories,story}.{ts,tsx,js,jsx}', + '.storybook/**/*.{ts,tsx,js,jsx}', + ], + rules: { + 'no-console': 'off', + 'import/no-default-export': 'off', + 'react-hooks/rules-of-hooks': 'off', + 'react-hooks/exhaustive-deps': 'off', + 'react/function-component-definition': 'off', + 'react/jsx-no-constructed-context-values': 'off', + 'react-refresh/only-export-components': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'consistent-return': 'error', + }, + env: { + node: true, + }, + }, + ], +} satisfies Linter.Config; diff --git a/packages/eslint-config/src/presets/cypress.ts b/packages/eslint-config/src/presets/cypress.ts new file mode 100644 index 00000000..2afde0e2 --- /dev/null +++ b/packages/eslint-config/src/presets/cypress.ts @@ -0,0 +1,43 @@ +import type { Linter } from 'eslint'; + +import { testOverrides as testRules } from '../overrides/tests'; +import { namingRules } from '../rules/namingConvention'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +const testOverrides = Object.keys(testRules.rules ?? {}).reduce( + (result, ruleKey) => ({ ...result, [ruleKey]: 'off' }), + {}, +); + +module.exports = { + env: { + 'cypress/globals': true, + }, + rules: { + ...testOverrides, + 'promise/catch-or-return': 'off', + 'promise/always-return': 'off', + 'import/no-import-module-exports': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/naming-convention': ( + ['error'] as Linter.RuleLevelAndOptions + ).concat( + ( + namingRules[ + '@typescript-eslint/naming-convention' + ] as Linter.RuleLevelAndOptions + ).slice(1), + [ + { + selector: 'memberLike', + format: null, + }, + ], + ) as Linter.RuleLevelAndOptions, + }, +} satisfies Linter.Config; diff --git a/packages/eslint-config/src/presets/cypressComponent.ts b/packages/eslint-config/src/presets/cypressComponent.ts new file mode 100644 index 00000000..4dd1826f --- /dev/null +++ b/packages/eslint-config/src/presets/cypressComponent.ts @@ -0,0 +1,15 @@ +import type { Linter } from 'eslint'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +module.exports = { + overrides: [ + { + files: ['**/cypress/**/*', '**/*.{cy,cypress}.{js,jsx,ts,tsx}'], + extends: ['@tablecheck/eslint-config/preset-cypress'], + }, + ], +} satisfies Linter.Config; diff --git a/packages/eslint-config/src/presets/react.ts b/packages/eslint-config/src/presets/react.ts new file mode 100644 index 00000000..89b6985d --- /dev/null +++ b/packages/eslint-config/src/presets/react.ts @@ -0,0 +1,41 @@ +import * as path from 'path'; + +import type { Linter } from 'eslint'; +import * as fs from 'fs-extra'; +import type { PackageJson } from 'type-fest'; + +import { reactRules } from '../rules/react'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +let reactVersion = '17'; // set to 17 for legacy reasons or to not error if react not present - should be able to detect below +const packageJsonPath = path.resolve(path.join(process.cwd(), 'package.json')); +if (fs.existsSync(packageJsonPath)) { + const pkg = fs.readJsonSync(packageJsonPath) as PackageJson; + if (pkg.dependencies?.react) { + const versionOnly = pkg.dependencies.react + .replace(/^[^0-9]+/gi, '') + .replace(/\..+$/gi, ''); + if (versionOnly === '*') + reactVersion = '18'; // dumb hack, but using '*' is more dumb + else if (!Number.isNaN(parseFloat(versionOnly))) reactVersion = versionOnly; + } +} + +module.exports = { + plugins: ['react-refresh'], + extends: ['plugin:react-hooks/recommended'], + + settings: { + react: { + version: reactVersion, + }, + }, + + rules: { + ...reactRules, + }, +} satisfies Linter.Config; diff --git a/packages/eslint-config/src/presets/reactTs.ts b/packages/eslint-config/src/presets/reactTs.ts new file mode 100644 index 00000000..fdb08943 --- /dev/null +++ b/packages/eslint-config/src/presets/reactTs.ts @@ -0,0 +1,13 @@ +import type { Linter } from 'eslint'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +module.exports = { + extends: [ + '@tablecheck/eslint-config/preset-typescript', + '@tablecheck/eslint-config/preset-react', + ], +} satisfies Linter.Config; diff --git a/packages/eslint-config/src/presets/typescript.ts b/packages/eslint-config/src/presets/typescript.ts new file mode 100644 index 00000000..b4761092 --- /dev/null +++ b/packages/eslint-config/src/presets/typescript.ts @@ -0,0 +1,64 @@ +import type { Linter } from 'eslint'; + +import { buildBaseTypescript } from '../overrides/buildBaseTypescript'; +import { testOverrides } from '../overrides/tests'; +import { emotionRules } from '../rules/emotion'; +import { generalRules } from '../rules/general'; +import { namingRules } from '../rules/namingConvention'; +import { promiseRules } from '../rules/promise'; + +if (!process.env.NODE_ENV) { + // This check allows us to run linters inside IDE's + process.env.NODE_ENV = 'development'; +} + +module.exports = { + extends: ['@tablecheck/eslint-config/preset-basic'], + overrides: [ + buildBaseTypescript({ + files: ['**/*.{ts,tsx,cts,mts}'], + rules: { + ...generalRules, + ...promiseRules, + ...emotionRules, + ...namingRules, + '@typescript-eslint/explicit-module-boundary-types': 'off', + }, + }), + buildBaseTypescript({ + files: ['**/*.d.ts'], + rules: { + 'import/no-default-export': 'off', + 'vars-on-top': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-empty-interface': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }), + buildBaseTypescript({ + files: [ + '**/__mocks__/**/*.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + '**/__setup__/**/*.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + '**/__tests__/**/*.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + '**/__tests__/*.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + '**/*.test.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + '**/*.spec.{ts,tsx,js,jsx,cjs,mjs,cts,mts}', + ], + rules: { + ...testOverrides.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'consistent-return': 'error', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + env: { + node: true, + }, + }), + ], +} satisfies Linter.Config; diff --git a/packages/nx/src/generators/quality/eslintConfig.ts b/packages/nx/src/generators/quality/eslintConfig.ts index fbcb954e..21c2b49f 100644 --- a/packages/nx/src/generators/quality/eslintConfig.ts +++ b/packages/nx/src/generators/quality/eslintConfig.ts @@ -18,7 +18,47 @@ function getConfigs(projectRoot: string) { return defaultConfigs.filter((config) => fs.existsSync(config)); } -export function generateEslintConfig(tree: Tree, projectName: string) { +function getExtends( + eslintType: + | 'basic' + | 'typescript' + | 'react' + | 'reactTs' + | 'cypress' + | 'cypressTs' + | 'component' + | 'componentTs', +) { + switch (eslintType) { + case 'basic': + return "'@tablecheck/eslint-config/basic'"; + case 'typescript': + return "'@tablecheck/eslint-config/typescript'"; + case 'react': + return "'@tablecheck/eslint-config/react'"; + case 'reactTs': + return "'@tablecheck/eslint-config/react-typescript'"; + case 'cypress': + return "'@tablecheck/eslint-config/basic', '@tablecheck/eslint-config/cypress'"; + case 'cypressTs': + return "'@tablecheck/eslint-config/typescript', '@tablecheck/eslint-config/cypress'"; + case 'component': + return "'@tablecheck/eslint-config/react', '@tablecheck/eslint-config/component'"; + case 'componentTs': + return "'@tablecheck/eslint-config/react-typescript', '@tablecheck/eslint-config/component-typescript'"; + default: + return "'@tablecheck/eslint-config'"; + } +} + +export function generateEslintConfig( + tree: Tree, + schema: { + project: string; + eslintType: Parameters[0]; + }, +) { + const projectName = schema.project; const { projectRoot } = getNxProjectRoot(tree, projectName); const projectTsConfigs = getConfigs(projectRoot) @@ -27,7 +67,7 @@ export function generateEslintConfig(tree: Tree, projectName: string) { '/* could not detect tsconfig.json files, manually set them here */'; const fileContent = ` module.exports = { - extends: ['@tablecheck/eslint-config'], + extends: [${getExtends(schema.eslintType)}], parserOptions: { project: [${projectTsConfigs}], }, diff --git a/packages/nx/src/generators/quality/generator.ts b/packages/nx/src/generators/quality/generator.ts index 32f19ab6..fa427d6f 100644 --- a/packages/nx/src/generators/quality/generator.ts +++ b/packages/nx/src/generators/quality/generator.ts @@ -21,7 +21,7 @@ import { updateProjectConfig } from './projectConfig'; export async function qualityGenerator( tree: Tree, - schema: FileTypesGeneratorSchema & { project: string }, + schema: FileTypesGeneratorSchema & Parameters[1], ) { await addDependenciesToPackageJson( tree, @@ -57,7 +57,7 @@ export async function qualityGenerator( path.relative(process.cwd(), tree.root), {}, ); - generateEslintConfig(tree, schema.project); + generateEslintConfig(tree, schema); execSync('npx husky install', { cwd: process.cwd(), stdio: 'inherit', diff --git a/packages/nx/src/generators/quality/schema.json b/packages/nx/src/generators/quality/schema.json index f7eef7b4..253492da 100644 --- a/packages/nx/src/generators/quality/schema.json +++ b/packages/nx/src/generators/quality/schema.json @@ -14,6 +14,50 @@ "x-prompt": "What is the name of the project for the generator?", "x-priority": "important" }, + "eslintType": { + "type": "string", + "description": "The eslint preset to use.", + "alias": "t", + "x-priority": "important", + "x-prompt": { + "message": "What type of eslint preset should we use?", + "type": "list", + "items": [ + { + "value": "basic", + "label": "Vanilla Javascript" + }, + { + "value": "typescript", + "label": "Vanilla Typescript" + }, + { + "value": "react", + "label": "React Javascript" + }, + { + "value": "reactTs", + "label": "React Typescript" + }, + { + "value": "cypress", + "label": "Cypress with Vanilla Javascript" + }, + { + "value": "cypressTs", + "label": "Cypress with Typescript" + }, + { + "value": "component", + "label": "React Javascript with Cypress component testing" + }, + { + "value": "componentTs", + "label": "React Typescript with Cypress component testing" + } + ] + } + }, "svgAsComponent": { "type": "boolean", "description": "Should `import name from 'name.svg'` import a react component (using plugins/transforms) instead of an asset URL?", @@ -21,5 +65,5 @@ "x-priority": "important" } }, - "required": ["project"] + "required": ["project", "eslintType"] }