@eslint/create-config
Version:
Utility to create ESLint config files.
387 lines (311 loc) • 15.6 kB
JavaScript
/**
* @fileoverview to generate config files.
* @author 唯然<weiran.zsd@outlook.com>
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import process from "node:process";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import enquirer from "enquirer";
import semverGreaterThanRange from "semver/ranges/gtr.js";
import semverLessThan from "semver/functions/lt.js";
import { isPackageTypeModule, installSyncSaveDev, fetchPeerDependencies, findPackageJson } from "./utils/npm-utils.js";
import { getShorthandName } from "./utils/naming.js";
import * as log from "./utils/logging.js";
import { langQuestions, jsQuestions, mdQuestions, installationQuestions, addJitiQuestion } from "./questions.js";
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Get the file extensions to lint based on the user's answers.
* @param {Object} answers The answers provided by the user.
* @returns {string[]} The file extensions to lint.
*/
function getExtensions(answers) {
const extensions = ["js", "mjs", "cjs"];
if (answers.useTs) {
extensions.push("ts", "mts", "cts");
}
if (answers.framework === "vue") {
extensions.push("vue");
}
if (answers.framework === "react") {
extensions.push("jsx");
if (answers.useTs) {
extensions.push("tsx");
}
}
return extensions;
}
const helperContent = `import path from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
// mimic CommonJS variables -- not needed if using CommonJS
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({baseDirectory: __dirname, recommendedConfig: js.configs.recommended});
`;
/**
* Adds environment configuration to the ESLint config.
* @param {Object} options The options for adding environment config.
* @param {string[]} options.env The environment types to add.
* @param {string[]} options.devDependencies The dev dependencies array to modify.
* @returns {{config: string, importContent: string}} The environment configuration content and import statement.
*/
function addEnvironmentConfig({ env, devDependencies }) {
if (!env?.length) {
return { config: "", importContent: "" };
}
devDependencies.push("globals");
const envContent = {
browser: "globals: globals.browser",
node: "globals: globals.node",
"browser,node": "globals: {...globals.browser, ...globals.node}"
};
return {
config: `languageOptions: { ${envContent[env.join(",")]} }`,
importContent: "import globals from \"globals\";\n"
};
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Class representing a ConfigGenerator.
*/
export class ConfigGenerator {
/**
* Create a ConfigGenerator.
* @param {Object} options The options for the ConfigGenerator.
* @param {string} options.cwd The current working directory.
* @param {Object} options.answers The answers provided by the user.
* @returns {ConfigGenerator} The ConfigGenerator instance.
*/
constructor(options) {
this.cwd = options.cwd;
this.packageJsonPath = options.packageJsonPath || findPackageJson(this.cwd);
this.answers = options.answers || {};
this.result = {
devDependencies: ["eslint"],
configFilename: "eslint.config.js",
configContent: "",
installFlags: ["-D"]
};
}
/**
* Prompt the user for input.
* @returns {void}
*/
async prompt() {
Object.assign(this.answers, await enquirer.prompt(langQuestions));
if (this.answers.languages.includes("javascript")) {
Object.assign(this.answers, await enquirer.prompt(jsQuestions));
}
if (this.answers.languages.includes("md")) {
Object.assign(this.answers, await enquirer.prompt(mdQuestions));
}
if (this.answers.configFileLanguage === "ts") {
const nodeVersion = process.versions.node;
// Node.js v24.3.0 removed the experimental warning from type stripping.
if (semverLessThan(nodeVersion, "24.3.0")) {
log.info("Jiti is required for Node.js <24.3.0 to read TypeScript configuration files.");
Object.assign(this.answers, await enquirer.prompt(addJitiQuestion));
}
}
}
/**
* Calculate the configuration based on the user's answers.
* @returns {void}
*/
async calc() {
const isESMModule = isPackageTypeModule(this.packageJsonPath);
let configExt = isESMModule ? "js" : "mjs";
if (this.answers.configFileLanguage === "ts") {
configExt = isESMModule ? "ts" : "mts";
}
this.result.configFilename = `eslint.config.${configExt}`;
this.answers.config = typeof this.answers.config === "string"
? { packageName: this.answers.config, type: "flat" }
: this.answers.config;
const extensions = `**/*.{${getExtensions(this.answers)}}`;
const languages = this.answers.languages ?? ["javascript"];
const purpose = this.answers.purpose;
let isDefineConfigExported = false;
let importContent = "";
let exportContent = "";
let needCompatHelper = false;
// language = javascript/typescript
if (languages.includes("javascript")) {
const useTs = this.answers.useTs;
if (purpose === "problems") {
this.result.devDependencies.push("@eslint/js");
importContent += "import js from \"@eslint/js\";\n";
let configContent = ` { files: ["${extensions}"], plugins: { js }, extends: ["js/recommended"]`;
const { config, importContent: envImport } = addEnvironmentConfig({
env: this.answers.env,
devDependencies: this.result.devDependencies
});
importContent += envImport;
if (config) {
configContent += `, ${config}`;
}
exportContent += `${configContent} },\n`;
}
if (this.answers.moduleType === "commonjs" || this.answers.moduleType === "script") {
exportContent += ` { files: ["**/*.js"], languageOptions: { sourceType: "${this.answers.moduleType}" } },\n`;
}
if (this.answers.env?.length > 0 && purpose !== "problems") {
const { config, importContent: envImport } = addEnvironmentConfig({
env: this.answers.env,
devDependencies: this.result.devDependencies
});
importContent += envImport;
exportContent += ` { files: ["${extensions}"], ${config} },\n`;
}
if (useTs) {
this.result.devDependencies.push("typescript-eslint");
importContent += "import tseslint from \"typescript-eslint\";\n";
exportContent += " tseslint.configs.recommended,\n";
}
if (this.answers.framework === "vue") {
this.result.devDependencies.push("eslint-plugin-vue");
importContent += "import pluginVue from \"eslint-plugin-vue\";\n";
exportContent += " pluginVue.configs[\"flat/essential\"],\n";
// https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser
if (useTs) {
exportContent += " { files: [\"**/*.vue\"], languageOptions: { parserOptions: { parser: tseslint.parser } } },\n";
}
}
if (this.answers.framework === "react") {
this.result.devDependencies.push("eslint-plugin-react");
importContent += "import pluginReact from \"eslint-plugin-react\";\n";
exportContent += " pluginReact.configs.flat.recommended,\n";
}
} else {
exportContent += " { ignores: [\"**/*.js\", \"**/*.cjs\", \"**/*.mjs\"] },\n";
}
// language = json/jsonc/json5
if (languages.some(item => item.startsWith("json"))) {
this.result.devDependencies.push("@eslint/json");
importContent += "import json from \"@eslint/json\";\n";
if (languages.includes("json")) {
const config = purpose === "syntax"
? " { files: [\"**/*.json\"], plugins: { json }, language: \"json/json\" },\n"
: " { files: [\"**/*.json\"], plugins: { json }, language: \"json/json\", extends: [\"json/recommended\"] },\n";
exportContent += config;
}
if (languages.includes("jsonc")) {
const config = purpose === "syntax"
? " { files: [\"**/*.jsonc\"], plugins: { json }, language: \"json/jsonc\" },\n"
: " { files: [\"**/*.jsonc\"], plugins: { json }, language: \"json/jsonc\", extends: [\"json/recommended\"] },\n";
exportContent += config;
}
if (languages.includes("json5")) {
const config = purpose === "syntax"
? " { files: [\"**/*.json5\"], plugins: { json }, language: \"json/json5\" },\n"
: " { files: [\"**/*.json5\"], plugins: { json }, language: \"json/json5\", extends: [\"json/recommended\"] },\n";
exportContent += config;
}
}
// language = markdown
if (languages.includes("md")) {
this.result.devDependencies.push("@eslint/markdown");
importContent += "import markdown from \"@eslint/markdown\";\n";
if (purpose === "syntax") {
exportContent += ` { files: ["**/*.md"], plugins: { markdown }, language: "markdown/${this.answers.mdType}" },\n`;
} else if (purpose === "problems") {
exportContent += ` { files: ["**/*.md"], plugins: { markdown }, language: "markdown/${this.answers.mdType}", extends: ["markdown/recommended"] },\n`;
}
}
// language = css
if (languages.includes("css")) {
this.result.devDependencies.push("@eslint/css");
importContent += "import css from \"@eslint/css\";\n";
if (purpose === "syntax") {
exportContent += " { files: [\"**/*.css\"], plugins: { css }, language: \"css/css\" },\n";
} else if (purpose === "problems") {
exportContent += " { files: [\"**/*.css\"], plugins: { css }, language: \"css/css\", extends: [\"css/recommended\"] },\n";
}
}
// passed `--config`
if (this.answers.config) {
const config = this.answers.config;
this.result.devDependencies.push(config.packageName);
// install peer dependencies - it's needed for most eslintrc-style shared configs.
const peers = await fetchPeerDependencies(config.packageName);
if (peers !== null) {
const eslintIndex = peers.findIndex(dep => (dep.startsWith("eslint@")));
if (eslintIndex === -1) {
// eslint is not in the peer dependencies
this.result.devDependencies.push(...peers);
} else {
const versionMatch = peers[eslintIndex].match(/eslint@(.+)/u);
const versionRequirement = versionMatch[1]; // Complete version requirement string
// Check if the version requirement allows for ESLint 9.22.0+
isDefineConfigExported = !semverGreaterThanRange("9.22.0", versionRequirement);
// eslint is in the peer dependencies => overwrite eslint version
this.result.devDependencies[0] = peers[eslintIndex];
peers.splice(eslintIndex, 1);
this.result.devDependencies.push(...peers);
}
}
if (config.type === "flat" || config.type === void 0) {
importContent += `import config from "${config.packageName}";\n`;
exportContent += " config,\n";
} else if (config.type === "eslintrc") {
needCompatHelper = true;
const shorthandName = getShorthandName(config.packageName, "eslint-config");
exportContent += ` compat.extends("${shorthandName}"),\n`;
}
} else {
isDefineConfigExported = true;
}
if (isDefineConfigExported) {
importContent += "import { defineConfig } from \"eslint/config\";\n";
} else {
this.result.devDependencies.push("@eslint/config-helpers");
importContent += "import { defineConfig } from \"@eslint/config-helpers\";\n";
}
if (needCompatHelper) {
this.result.devDependencies.push("@eslint/eslintrc", "@eslint/js");
}
if (this.answers.addJiti) {
this.result.devDependencies.push("jiti");
}
this.result.configContent = `${needCompatHelper ? importContent : importContent.slice(0, -1)}
${needCompatHelper ? helperContent : ""}
export default defineConfig([\n${exportContent || " {}\n"}]);\n`; // defaults to `[{}]` to avoid empty config warning
}
/**
* Output the configuration.
* @returns {void}
*/
async output() {
log.info("The config that you've selected requires the following dependencies:\n");
log.info(this.result.devDependencies.join(", "));
const { executeInstallation, packageManager } = (await enquirer.prompt(installationQuestions));
const configPath = path.join(this.cwd, this.result.configFilename);
if (executeInstallation === true) {
log.info("☕️Installing...");
installSyncSaveDev(this.result.devDependencies, packageManager, this.result.installFlags);
await writeFile(configPath, this.result.configContent);
// import("eslint") won't work in some cases.
// refs: https://github.com/eslint/create-config/issues/8, https://github.com/eslint/create-config/issues/12
const eslintBin = path.join(this.packageJsonPath, "../node_modules/eslint/bin/eslint.js");
const result = spawnSync(process.execPath, [eslintBin, "--fix", "--quiet", configPath], { encoding: "utf8" });
if (result.error || result.status !== 0) {
log.error("A config file was generated, but the config file itself may not follow your linting rules.");
} else {
log.info(`Successfully created ${configPath} file.`);
}
} else {
await writeFile(configPath, this.result.configContent);
log.info(`Successfully created ${configPath} file.`);
log.warn("You will need to install the dependencies yourself.");
}
}
}