diff --git a/cspell.json b/cspell.json index 5bb799f96..2b6087df5 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,7 @@ "attw", "boop", "dbaeumer", + "eslint-doc-generatorrc.js", "infile", "joshuakgoldberg", "markdownlintignore", diff --git a/docs/Blocks.md b/docs/Blocks.md index 9cc52546d..e08bc5c4a 100644 --- a/docs/Blocks.md +++ b/docs/Blocks.md @@ -23,6 +23,7 @@ This table summarizes each block and which base levels they're included in: | ESLint Node Plugin | `--add-eslint-node-plugin`, `--exclude-eslint-node-plugin` | | | 💯 | | ESLint package.json Plugin | `--add-eslint-package-json-plugin`, `--exclude-eslint-package-json-plugin` | | | 💯 | | ESLint Perfectionist Plugin | `--add-eslint-perfectionist-plugin`, `--exclude-eslint-perfectionist-plugin` | | | 💯 | +| ESLint Plugin | `--add-eslint-plugin`, `--exclude-eslint-plugin` | | | | | ESLint Regexp Plugin | `--add-eslint-regexp-plugin`, `--exclude-eslint-regexp-plugin` | | | 💯 | | ESLint YML Plugin | `--add-eslint-yml-plugin`, `--exclude-eslint-yml-plugin` | | | 💯 | | Funding | `--add-funding`, `--exclude-funding` | | ✅ | 💯 | diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts index 56d277d0d..f7d6948ee 100644 --- a/src/blocks/blockESLint.test.ts +++ b/src/blocks/blockESLint.test.ts @@ -465,6 +465,10 @@ describe("blockESLint", () => { imports: [ { source: "eslint-plugin-markdown", specifier: "a", types: true }, { source: "eslint-plugin-regexp", specifier: "b" }, + { + source: { packageName: "eslint-plugin-unknown", version: "1.2.3" }, + specifier: "c", + }, ], rules: { "a/b": "error", @@ -536,6 +540,7 @@ describe("blockESLint", () => { "eslint": "9.22.0", "eslint-plugin-markdown": "5.1.0", "eslint-plugin-regexp": "2.7.0", + "eslint-plugin-unknown": "1.2.3", "typescript-eslint": "8.26.1", }, "scripts": { @@ -586,6 +591,7 @@ describe("blockESLint", () => { import eslint from "@eslint/js"; import a from "eslint-plugin-markdown" import b from "eslint-plugin-regexp" + import c from "eslint-plugin-unknown" import tseslint from "typescript-eslint"; export default tseslint.config( diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts index 757c92483..1d6391d43 100644 --- a/src/blocks/blockESLint.ts +++ b/src/blocks/blockESLint.ts @@ -49,7 +49,10 @@ const zExtension = z.object({ }); const zPackageImport = z.object({ - source: z.string(), + source: z.union([ + z.string(), + z.object({ packageName: z.string(), version: z.string() }), + ]), specifier: z.string(), types: z.boolean().optional(), }); @@ -88,7 +91,7 @@ export const blockESLint = base.createBlock({ 'import tseslint from "typescript-eslint";', ...imports.map( (packageImport) => - `import ${packageImport.specifier} from "${packageImport.source}"`, + `import ${packageImport.specifier} from "${typeof packageImport.source === "string" ? packageImport.source : packageImport.source.packageName}"`, ), ].sort((a, b) => a.replace(/.+from/, "").localeCompare(b.replace(/.+from/, "")), @@ -183,17 +186,36 @@ Each should be shown in VS Code, and can be run manually on the command-line: }), blockPackageJson({ properties: { - devDependencies: getPackageDependencies( - "@eslint/js", - "@types/node", - "eslint", - "typescript-eslint", - ...imports.flatMap(({ source, types }) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- https://github.com/egoist/parse-package-name/issues/30 - const { name } = parsePackageName(source) as { name: string }; - return types ? [name, `@types/${name}`] : [name]; - }), - ), + devDependencies: { + ...getPackageDependencies( + "@eslint/js", + "@types/node", + "eslint", + "typescript-eslint", + ...imports + .filter((imported) => typeof imported.source === "string") + .flatMap(({ source, types }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- https://github.com/egoist/parse-package-name/issues/30 + const { name } = parsePackageName(source) as { + name: string; + }; + return types ? [name, `@types/${name}`] : [name]; + }), + ), + ...Object.fromEntries( + imports + .filter( + ( + imported, + ): imported is typeof imported & { source: object } => + typeof imported.source === "object", + ) + .map((imported) => [ + imported.source.packageName, + imported.source.version, + ]), + ), + }, scripts: { lint: "eslint . --max-warnings 0", }, diff --git a/src/blocks/blockESLintPlugin.test.ts b/src/blocks/blockESLintPlugin.test.ts new file mode 100644 index 000000000..da3bd9f1b --- /dev/null +++ b/src/blocks/blockESLintPlugin.test.ts @@ -0,0 +1,386 @@ +import { testBlock } from "bingo-stratum-testers"; +import { describe, expect, test } from "vitest"; + +import { blockESLintPlugin } from "./blockESLintPlugin.js"; +import { optionsBase } from "./options.fakes.js"; + +describe("blockESLintPlugin", () => { + test("without mode", () => { + const creation = testBlock(blockESLintPlugin, { + options: optionsBase, + }); + + expect(creation).toMatchInlineSnapshot(` + { + "addons": [ + { + "addons": { + "words": [ + "eslint-doc-generatorrc.js", + ], + }, + "block": [Function], + }, + { + "addons": { + "sections": { + "Building": { + "innerSections": [ + { + "contents": " + Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. + + \`\`\`shell + pnpm build:docs + \`\`\` + ", + "heading": "Building Docs", + }, + ], + }, + "Linting": { + "contents": { + "items": [ + "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", + ], + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "ignores": [ + ".eslint-doc-generatorrc.js", + "docs/rules/*/*.ts", + ], + "imports": [ + { + "source": { + "packageName": "eslint-plugin-eslint-plugin", + "version": "6.4.0", + }, + "specifier": "eslintPlugin", + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint Docs", + "steps": [ + { + "run": "pnpm build || exit 0", + }, + { + "run": "pnpm lint:docs", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "devDependencies": { + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + "peerDependencies": { + "@typescript-eslint/parser": ">=8", + "eslint": ">=9", + "typescript": ">=5", + }, + "scripts": { + "build:docs": "eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "coverage": { + "exclude": [ + "src/index.ts", + "src/rules/index.ts", + ], + }, + }, + "block": [Function], + }, + ], + "files": { + ".eslint-doc-generatorrc.js": "import prettier from "prettier"; + + /** @type {import('eslint-doc-generator').GenerateOptions} */ + const config = { + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "prefix-name", + }; + + export default config; + ", + }, + } + `); + }); + + test("setup mode", () => { + const creation = testBlock(blockESLintPlugin, { + mode: "setup", + options: optionsBase, + }); + + expect(creation).toMatchInlineSnapshot(` + { + "addons": [ + { + "addons": { + "words": [ + "eslint-doc-generatorrc.js", + ], + }, + "block": [Function], + }, + { + "addons": { + "sections": { + "Building": { + "innerSections": [ + { + "contents": " + Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. + + \`\`\`shell + pnpm build:docs + \`\`\` + ", + "heading": "Building Docs", + }, + ], + }, + "Linting": { + "contents": { + "items": [ + "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", + ], + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "ignores": [ + ".eslint-doc-generatorrc.js", + "docs/rules/*/*.ts", + ], + "imports": [ + { + "source": { + "packageName": "eslint-plugin-eslint-plugin", + "version": "6.4.0", + }, + "specifier": "eslintPlugin", + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint Docs", + "steps": [ + { + "run": "pnpm build || exit 0", + }, + { + "run": "pnpm lint:docs", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "devDependencies": { + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + "peerDependencies": { + "@typescript-eslint/parser": ">=8", + "eslint": ">=9", + "typescript": ">=5", + }, + "scripts": { + "build:docs": "eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "coverage": { + "exclude": [ + "src/index.ts", + "src/rules/index.ts", + ], + }, + }, + "block": [Function], + }, + ], + "files": { + ".eslint-doc-generatorrc.js": "import prettier from "prettier"; + + /** @type {import('eslint-doc-generator').GenerateOptions} */ + const config = { + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "prefix-name", + }; + + export default config; + ", + "src": { + "index.ts": "import Module from "node:module"; + + import { rules } from "./rules/index.js"; + + const require = Module.createRequire(import.meta.url); + + const { name, version } = + // \`import\`ing here would bypass the TSConfig's \`"rootDir": "src"\` + require("../package.json") as typeof import("../package.json"); + + export const plugin = { + configs: { + get recommended() { + return recommended; + }, + }, + meta: { name, version }, + rules, + }; + + const recommended = { + plugins: { + "test-repository": plugin, + }, + rules: Object.fromEntries( + Object.keys(rules).map((rule) => [\`test-repository/\${rule}\`, "error"]), + ), + }; + + export { rules }; + + export default plugin; + ", + "rules": { + "example.test.ts": "import { rule } from "./enums.js"; + import { ruleTester } from "./ruleTester.js"; + + ruleTester.run("enums", rule, { + invalid: [ + { + code: \`enum Values {}\`, + errors: [ + { + column: 1, + endColumn: 15, + endLine: 1, + line: 1, + messageId: "enum", + }, + ], + }, + ], + valid: [\`const Values = {};\`, \`const Values = {} as const;\`], + }); + ", + "example.ts": "import { createRule } from "../utils.js"; + + export const rule = createRule({ + create(context) { + return { + TSEnumDeclaration(node) { + context.report({ + messageId: "enum", + node, + }); + }, + }; + }, + defaultOptions: [], + meta: { + docs: { + description: "Avoid using TypeScript's enums.", + }, + messages: { + enum: "This enum will not be allowed under TypeScript's --erasableSyntaxOnly.", + }, + schema: [], + type: "problem", + }, + name: "enums", + }); + ", + "index.ts": "import { rule as example } from "./example.js"; + + export const rules = { + example, + }; + ", + "ruleTester.ts": "import { RuleTester } from "@typescript-eslint/rule-tester"; + import * as vitest from "vitest"; + + RuleTester.afterAll = vitest.afterAll; + RuleTester.it = vitest.it; + RuleTester.itOnly = vitest.it.only; + RuleTester.describe = vitest.describe; + + export const ruleTester = new RuleTester(); + ", + }, + "utils.ts": "import { ESLintUtils } from "@typescript-eslint/utils"; + + export const createRule = ESLintUtils.RuleCreator( + (name) => + \`https://github.com/test-owner/test-repository/blob/main/docs/rules/\${name}.md\`, + ); + ", + }, + }, + } + `); + }); +}); diff --git a/src/blocks/blockESLintPlugin.ts b/src/blocks/blockESLintPlugin.ts new file mode 100644 index 000000000..045403bee --- /dev/null +++ b/src/blocks/blockESLintPlugin.ts @@ -0,0 +1,225 @@ +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockESLint } from "./blockESLint.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVitest } from "./blockVitest.js"; + +export const blockESLintPlugin = base.createBlock({ + about: { + name: "ESLint Plugin", + }, + produce() { + return { + addons: [ + blockCSpell({ + words: ["eslint-doc-generatorrc.js"], + }), + blockDevelopmentDocs({ + sections: { + Building: { + innerSections: [ + { + contents: ` +Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. + +\`\`\`shell +pnpm build:docs +\`\`\` + `, + heading: "Building Docs", + }, + ], + }, + Linting: { + contents: { + items: [ + `- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules`, + ], + }, + }, + }, + }), + blockESLint({ + extensions: ['eslintPlugin.configs["flat/recommended"]'], + ignores: [".eslint-doc-generatorrc.js", "docs/rules/*/*.ts"], + imports: [ + { + source: { + packageName: "eslint-plugin-eslint-plugin", + version: "6.4.0", + }, + specifier: "eslintPlugin", + }, + ], + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint Docs", + steps: [ + { run: "pnpm build || exit 0" }, + { run: "pnpm lint:docs" }, + ], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: { + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + peerDependencies: { + "@typescript-eslint/parser": ">=8", + eslint: ">=9", + typescript: ">=5", + }, + scripts: { + "build:docs": "eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }), + blockVitest({ + coverage: { + exclude: ["src/index.ts", "src/rules/index.ts"], + }, + }), + ], + files: { + ".eslint-doc-generatorrc.js": `import prettier from "prettier"; + +/** @type {import('eslint-doc-generator').GenerateOptions} */ +const config = { + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "prefix-name", +}; + +export default config; +`, + }, + }; + }, + setup({ options }) { + const pluginName = options.repository.replace("eslint-plugin-", ""); + + return { + files: { + src: { + "index.ts": `import Module from "node:module"; + +import { rules } from "./rules/index.js"; + +const require = Module.createRequire(import.meta.url); + +const { name, version } = + // \`import\`ing here would bypass the TSConfig's \`"rootDir": "src"\` + require("../package.json") as typeof import("../package.json"); + +export const plugin = { + configs: { + get recommended() { + return recommended; + }, + }, + meta: { name, version }, + rules, +}; + +const recommended = { + plugins: { + "${pluginName}": plugin, + }, + rules: Object.fromEntries( + Object.keys(rules).map((rule) => [\`${pluginName}/\${rule}\`, "error"]), + ), +}; + +export { rules }; + +export default plugin; +`, + rules: { + "example.test.ts": `import { rule } from "./enums.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.run("enums", rule, { + invalid: [ + { + code: \`enum Values {}\`, + errors: [ + { + column: 1, + endColumn: 15, + endLine: 1, + line: 1, + messageId: "enum", + }, + ], + }, + ], + valid: [\`const Values = {};\`, \`const Values = {} as const;\`], +}); +`, + "example.ts": `import { createRule } from "../utils.js"; + +export const rule = createRule({ + create(context) { + return { + TSEnumDeclaration(node) { + context.report({ + messageId: "enum", + node, + }); + }, + }; + }, + defaultOptions: [], + meta: { + docs: { + description: "Avoid using TypeScript's enums.", + }, + messages: { + enum: "This enum will not be allowed under TypeScript's --erasableSyntaxOnly.", + }, + schema: [], + type: "problem", + }, + name: "enums", +}); +`, + "index.ts": `import { rule as example } from "./example.js"; + +export const rules = { + example, +}; +`, + "ruleTester.ts": `import { RuleTester } from "@typescript-eslint/rule-tester"; +import * as vitest from "vitest"; + +RuleTester.afterAll = vitest.afterAll; +RuleTester.it = vitest.it; +RuleTester.itOnly = vitest.it.only; +RuleTester.describe = vitest.describe; + +export const ruleTester = new RuleTester(); +`, + }, + "utils.ts": `import { ESLintUtils } from "@typescript-eslint/utils"; + +export const createRule = ESLintUtils.RuleCreator( + (name) => + \`https://github.com/${options.owner}/${options.repository}/blob/main/docs/rules/\${name}.md\`, +); +`, + }, + }, + }; + }, +}); diff --git a/src/blocks/index.ts b/src/blocks/index.ts index b2b8db005..9e705fd48 100644 --- a/src/blocks/index.ts +++ b/src/blocks/index.ts @@ -14,6 +14,7 @@ import { blockESLintMoreStyling } from "./blockESLintMoreStyling.js"; import { blockESLintNode } from "./blockESLintNode.js"; import { blockESLintPackageJson } from "./blockESLintPackageJson.js"; import { blockESLintPerfectionist } from "./blockESLintPerfectionist.js"; +import { blockESLintPlugin } from "./blockESLintPlugin.js"; import { blockESLintRegexp } from "./blockESLintRegexp.js"; import { blockESLintYML } from "./blockESLintYML.js"; import { blockFunding } from "./blockFunding.js"; @@ -61,6 +62,7 @@ export const blocks = { blockESLintNode, blockESLintPackageJson, blockESLintPerfectionist, + blockESLintPlugin, blockESLintRegexp, blockESLintYML, blockFunding, @@ -109,6 +111,7 @@ export { blockESLintMoreStyling } from "./blockESLintMoreStyling.js"; export { blockESLintNode } from "./blockESLintNode.js"; export { blockESLintPackageJson } from "./blockESLintPackageJson.js"; export { blockESLintPerfectionist } from "./blockESLintPerfectionist.js"; +export { blockESLintPlugin } from "./blockESLintPlugin.js"; export { blockESLintRegexp } from "./blockESLintRegexp.js"; export { blockESLintYML } from "./blockESLintYML.js"; export { blockFunding } from "./blockFunding.js"; diff --git a/src/template.ts b/src/template.ts index 26c3e1f80..9c7e5e44e 100644 --- a/src/template.ts +++ b/src/template.ts @@ -1,6 +1,7 @@ import { base } from "./base.js"; import { blockAreTheTypesWrong } from "./blocks/blockAreTheTypesWrong.js"; import { blockCTATransitions } from "./blocks/blockCTATransitions.js"; +import { blockESLintPlugin } from "./blocks/blockESLintPlugin.js"; import { blockNcc } from "./blocks/blockNcc.js"; import { blockRemoveDependencies } from "./blocks/blockRemoveDependencies.js"; import { blockRemoveFiles } from "./blocks/blockRemoveFiles.js"; @@ -19,6 +20,7 @@ export const template = base.createStratumTemplate({ blocks: [ blockAreTheTypesWrong, blockCTATransitions, + blockESLintPlugin, blockNcc, blockRemoveDependencies, blockRemoveFiles,