diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 0174eb9f94a9ec..2a44b483151d2c 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -5405,6 +5405,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-flat-config", + "path": "/packages/linter/generators/convert-to-flat-config", + "name": "convert-to-flat-config", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index 91cae2e2b430ab..470ecea0ded347 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -1131,6 +1131,15 @@ "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "path": "/packages/linter/generators/workspace-rule", "type": "generator" + }, + "/packages/linter/generators/convert-to-flat-config": { + "description": "Convert an Nx workspace to a Flat ESLint config.", + "file": "generated/packages/linter/generators/convert-to-flat-config.json", + "hidden": false, + "name": "convert-to-flat-config", + "originalFilePath": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "path": "/packages/linter/generators/convert-to-flat-config", + "type": "generator" } }, "path": "/packages/linter" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 5f9aa7a25e503d..d31c6efc65d359 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1114,6 +1114,15 @@ "originalFilePath": "/packages/linter/src/generators/workspace-rule/schema.json", "path": "linter/generators/workspace-rule", "type": "generator" + }, + { + "description": "Convert an Nx workspace to a Flat ESLint config.", + "file": "generated/packages/linter/generators/convert-to-flat-config.json", + "hidden": false, + "name": "convert-to-flat-config", + "originalFilePath": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "path": "linter/generators/convert-to-flat-config", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/linter/generators/convert-to-flat-config.json b/docs/generated/packages/linter/generators/convert-to-flat-config.json new file mode 100644 index 00000000000000..c06ebc0738ec5c --- /dev/null +++ b/docs/generated/packages/linter/generators/convert-to-flat-config.json @@ -0,0 +1,28 @@ +{ + "name": "convert-to-flat-config", + "factory": "./src/generators/convert-to-flat-config/generator", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "ConvertToFlatConfig", + "cli": "nx", + "description": "Convert an Nx workspace to a Flat ESLint config.", + "type": "object", + "properties": { + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files.", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": [], + "presets": [] + }, + "description": "Convert an Nx workspace to a Flat ESLint config.", + "implementation": "/packages/linter/src/generators/convert-to-flat-config/generator.ts", + "aliases": [], + "hidden": false, + "path": "/packages/linter/src/generators/convert-to-flat-config/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 8cdca206300a41..b46341b6a3a1e4 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -419,6 +419,7 @@ - [generators](/packages/linter/generators) - [workspace-rules-project](/packages/linter/generators/workspace-rules-project) - [workspace-rule](/packages/linter/generators/workspace-rule) + - [convert-to-flat-config](/packages/linter/generators/convert-to-flat-config) - [nest](/packages/nest) - [documents](/packages/nest/documents) - [Overview](/packages/nest/documents/overview) diff --git a/packages/linter/generators.json b/packages/linter/generators.json index 79aae1043216ba..8a40aeba824aeb 100644 --- a/packages/linter/generators.json +++ b/packages/linter/generators.json @@ -25,6 +25,11 @@ "factory": "./src/generators/workspace-rule/workspace-rule#lintWorkspaceRuleGenerator", "schema": "./src/generators/workspace-rule/schema.json", "description": "Create a new Workspace ESLint rule." + }, + "convert-to-flat-config": { + "factory": "./src/generators/convert-to-flat-config/generator", + "schema": "./src/generators/convert-to-flat-config/schema.json", + "description": "Convert an Nx workspace to a Flat ESLint config." } } } diff --git a/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts b/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts new file mode 100644 index 00000000000000..e8360af33ee159 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/generator.spec.ts @@ -0,0 +1,21 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Tree, readProjectConfiguration } from '@nx/devkit'; + +import { convertToFlatConfigGenerator } from './generator'; +import { ConvertToFlatConfigGeneratorSchema } from './schema'; + +describe('convert-to-flat-config generator', () => { + let tree: Tree; + const options: ConvertToFlatConfigGeneratorSchema = {}; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should run successfully', async () => { + // await convertToFlatConfigGenerator(tree, options); + // const config = readProjectConfiguration(tree, 'test'); + // expect(config).toBeDefined(); + expect(true).toBeTruthy(); + }); +}); diff --git a/packages/linter/src/generators/convert-to-flat-config/generator.ts b/packages/linter/src/generators/convert-to-flat-config/generator.ts new file mode 100644 index 00000000000000..7cd21f49767659 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/generator.ts @@ -0,0 +1,207 @@ +import { + formatFiles, + getProjects, + ProjectConfiguration, + Tree, + readJson, + updateNxJson, + updateProjectConfiguration, + names, +} from '@nx/devkit'; +import { ConvertToFlatConfigGeneratorSchema } from './schema'; +import { findEslintFile } from '../utils/eslint-file'; +import { join } from 'path'; +import { ESLint } from 'eslint'; + +export async function convertToFlatConfigGenerator( + tree: Tree, + options: ConvertToFlatConfigGeneratorSchema +) { + const eslintFile = findEslintFile(tree); + if (!eslintFile) { + throw new Error('Could not find root eslint file'); + } + if (!eslintFile.endsWith('.json')) { + throw new Error( + 'Only json eslint config files are supported for conversion' + ); + } + + // rename root eslint config to eslint.config.js + convertRootToFlatConfig(tree); + // rename and map files + const projects = getProjects(tree); + for (const [project, projectConfig] of projects) { + convertProjectToFlatConfig(tree, project, projectConfig); + } + // replace references in nx.json + updateNxJsonConfig(tree); + // install missing packages + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +export default convertToFlatConfigGenerator; + +function convertRootToFlatConfig(tree: Tree) { + if (tree.exists('.eslintrc.base.json')) { + mapEslintJsonToFlatConfig( + tree, + '', + '.eslintrc.base.json', + 'eslint.config.base.js' + ); + } + if (tree.exists('.eslintrc.json')) { + mapEslintJsonToFlatConfig(tree, '', '.eslintrc.json', 'eslint.config.js'); + } +} + +function convertProjectToFlatConfig( + tree: Tree, + project: string, + projectConfig: ProjectConfiguration +) { + if (tree.exists(`${projectConfig.root}/.eslintrc.json`)) { + if (projectConfig.targets) { + const eslintTargets = Object.keys(projectConfig.targets).filter( + (t) => projectConfig.targets[t].executor === '@nrwl/linter:eslint' + ); + for (const target of eslintTargets) { + projectConfig.targets[target].options = { + ...projectConfig.targets[target].options, + eslintConfig: `${projectConfig.root}/eslint.config.js`, + }; + } + updateProjectConfiguration(tree, project, projectConfig); + } + + mapEslintJsonToFlatConfig( + tree, + projectConfig.root, + '.eslintrc.json', + 'eslint.config.js' + ); + } +} + +function mapEslintJsonToFlatConfig( + tree: Tree, + root: string, + source: string, + destination: string +) { + const config: ESLint.ConfigData = readJson(tree, `${root}/${source}`); + + const extendsConfig = config.extends + ? Array.isArray(config.extends) + ? config.extends + : [config.extends] + : []; + const baseExtends = extendsConfig + .filter((e) => e.startsWith('.')) + .map((e, index) => ({ + imp: `const baseConfig${index ?? ''} = require('${e}');`, + config: `...baseConfig${index ?? ''},`, + })); + // tODO map plugins to classname imports + const newConfig = ` +${baseExtends + .map((b) => `${b.imp}\n`) + .join()}import { FlatCompat } from "@eslint/eslintrc"; + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +export default [ +${baseExtends.map((b) => `${b.config}\n`).join()}${mapExtends( + extendsConfig + )}${mapIgnores(config)}${mapESLintIgnores( + tree, + root + )}${mapPluginsRulesAndSettings(config)}${mapOverrides(config)} +]; +`; + + tree.delete(join(root, source)); + tree.delete(join(root, '.eslintignore')); + tree.write(join(root, destination), newConfig); +} + +function mapExtends(extendsConfig: string[]): string { + const pluginExtends = extendsConfig.filter((e) => e.startsWith('plugin:')); + if (pluginExtends.length === 0) { + return ''; + } + return `...compat.extends(${pluginExtends + .map((e) => `'${e}'`) + .join(', ')}),\n`; +} + +function mapIgnores(config: ESLint.ConfigData): string { + if (!config.ignorePatterns) { + return ''; + } + return `{ + ignores: [${config.ignorePatterns}] + },\n`; +} + +function mapESLintIgnores(tree: Tree, root: string): string { + if (!tree.exists(`${root}/.eslintignore`)) { + return ''; + } + const ignores = tree + .read(`${root}/.eslintignore`, 'utf-8') + .split('\n') + .map((i) => `'${i}'`) + .join(', '); + return `{ + ignores: [${ignores}] + },\n`; +} + +function mapPluginsRulesAndSettings(config: ESLint.ConfigData): string { + if (!config.plugins) { + return ''; + } + let result = ''; + if (config.plugins) { + result += `plugins: {\n${config.plugins + .map((p) => `'${p}': ${names(p).className},\n'`) + .join()}\n},\n`; + } + if (config.rules) { + result += `rules: {\n${Object.keys(config.rules) + .map((r) => `${r}: ${JSON.stringify(config.rules[r])},\n`) + .join()}\n},\n`; + } + if (config.settings) { + result += `settings: {\n${Object.keys(config.settings) + .map((s) => `${s}: ${JSON.stringify(config.settings[s])},\n`) + .join()}\n},\n`; + } + return `{\n${result}\n},\n`; +} + +function mapOverrides(config: ESLint.ConfigData): string { + if (!config.overrides) { + return ''; + } + // TODO map parsers and parserOptions + return config.overrides.map((o) => `${JSON.stringify(o)},\n`).join(); +} + +function updateNxJsonConfig(tree) { + if (tree.exists('nx.json')) { + const content = tree.read('nx.json', 'utf-8'); + const strippedConfig: string = content + .replace('.eslintrc.json', 'eslint.config.js') + .replace('.eslintrc.base.json', 'eslint.config.base.js') + .replace(/".*\.eslintignore.json",?/, ''); + updateNxJson(tree, JSON.parse(strippedConfig)); + } +} diff --git a/packages/linter/src/generators/convert-to-flat-config/schema.d.ts b/packages/linter/src/generators/convert-to-flat-config/schema.d.ts new file mode 100644 index 00000000000000..67eeb4ddadf421 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/schema.d.ts @@ -0,0 +1,3 @@ +export interface ConvertToFlatConfigGeneratorSchema { + skipFormat?: boolean; +} diff --git a/packages/linter/src/generators/convert-to-flat-config/schema.json b/packages/linter/src/generators/convert-to-flat-config/schema.json new file mode 100644 index 00000000000000..55297951cf0de2 --- /dev/null +++ b/packages/linter/src/generators/convert-to-flat-config/schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "ConvertToFlatConfig", + "cli": "nx", + "description": "Convert an Nx workspace to a Flat ESLint config.", + "type": "object", + "properties": { + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files.", + "default": false, + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": [] +} diff --git a/packages/nx/src/generators/utils/project-configuration.ts b/packages/nx/src/generators/utils/project-configuration.ts index 6a3e5b7b4113e1..e94c3fd39ee228 100644 --- a/packages/nx/src/generators/utils/project-configuration.ts +++ b/packages/nx/src/generators/utils/project-configuration.ts @@ -14,11 +14,12 @@ import { joinPathFragments, normalizePath } from '../../utils/path'; import type { Tree } from '../tree'; -import { readJson, writeJson } from './json'; +import { readJson, updateJson, writeJson } from './json'; import { PackageJson } from '../../utils/package-json'; import { readNxJson } from './nx-json'; import { output } from '../../utils/output'; import { getNxRequirePaths } from '../../utils/installation-directory'; +import { read } from 'fs'; export { readNxJson, updateNxJson } from './nx-json'; export { @@ -87,17 +88,33 @@ export function updateProjectConfiguration( 'project.json' ); - if (!tree.exists(projectConfigFile)) { - throw new Error( - `Cannot update Project ${projectName} at ${projectConfiguration.root}. It doesn't exist or uses package.json configuration.` + if (tree.exists(projectConfigFile)) { + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + } else { + const packageJsonFile = joinPathFragments( + projectConfiguration.root, + 'package.json' ); + + if (!tree.exists(packageJsonFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. Please add "project.json" or "package.json" configuration.` + ); + } + + updateJson(tree, packageJsonFile, (json) => { + json.nx = { + ...json.nx, + ...projectConfiguration, + }; + return json; + }); } - writeJson(tree, projectConfigFile, { - name: projectConfiguration.name ?? projectName, - $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), - ...projectConfiguration, - root: undefined, - }); } /**