From 22a0754767de48205978ab0b34e500fb0b03568b Mon Sep 17 00:00:00 2001 From: Bryan Mishkin <698306+bmish@users.noreply.github.com> Date: Sat, 7 Oct 2023 17:57:16 -0400 Subject: [PATCH] Support auto-generated config lists (#480) --- README.md | 13 + docs/examples/eslint-plugin-test/README.md | 10 +- lib/comment-markers.ts | 6 + lib/config-list.ts | 98 ++++++ lib/generator.ts | 31 +- .../__snapshots__/configs-list-test.ts.snap | 126 +++++++ test/lib/generate/configs-list-test.ts | 327 ++++++++++++++++++ 7 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 lib/config-list.ts create mode 100644 test/lib/generate/__snapshots__/configs-list-test.ts.snap create mode 100644 test/lib/generate/configs-list-test.ts diff --git a/README.md b/README.md index 571b6cef..03b508a5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Automatic documentation generator for [ESLint](https://eslint.org/) plugins and Generates the following documentation covering a [wide variety](#column-and-notice-types) of rule metadata: - `README.md` rules table +- `README.md` configs table - Rule doc titles and notices Also performs [configurable](#configuration-options) section consistency checks on rule docs: @@ -20,6 +21,7 @@ Also performs [configurable](#configuration-options) section consistency checks - [Usage](#usage) - [Examples](#examples) - [Rules list table](#rules-list-table) + - [Configs list table](#configs-list-table) - [Rule doc notices](#rule-doc-notices) - [Users](#users) - [Configuration options](#configuration-options) @@ -75,6 +77,13 @@ Delete any old rules list from your `README.md`. A new one will be automatically ``` +Optionally, add these marker comments to your `README.md` where you would like the configs list to go (uses the `description` property exported by each config if available): + +```md + + +``` + Delete any old recommended/fixable/etc. notices from your rule docs. A new title and notices will be automatically added to the top of each rule doc (along with a marker comment if it doesn't already exist). ```md @@ -102,6 +111,10 @@ For examples, see our [users](#users) or the in-house examples below. Note that See the generated rules table and legend in our example [`README.md`](./docs/examples/eslint-plugin-test/README.md#rules). +### Configs list table + +See the generated configs table in our example [`README.md`](./docs/examples/eslint-plugin-test/README.md#configs). + ### Rule doc notices See the generated rule doc title and notices in our example rule docs [`no-foo.md`](./docs/examples/eslint-plugin-test/docs/rules/no-foo.md), [`prefer-bar.md`](./docs/examples/eslint-plugin-test/docs/rules/prefer-bar.md), [`require-baz.md`](./docs/examples/eslint-plugin-test/docs/rules/require-baz.md). diff --git a/docs/examples/eslint-plugin-test/README.md b/docs/examples/eslint-plugin-test/README.md index a81e491f..7e09f731 100644 --- a/docs/examples/eslint-plugin-test/README.md +++ b/docs/examples/eslint-plugin-test/README.md @@ -4,7 +4,15 @@ This plugin is for x purpose. ## Configs -Configs section would normally go here. + + +| | Name | Description | +| :- | :------------ | :----------------------------------------------- | +| ✅ | `recommended` | These rules are recommended for everyone. | +| 🎨 | `stylistic` | These rules are more about code style than bugs. | +| ⌨️ | `typescript` | These are good rules to use with TypeScript. | + + ## Rules diff --git a/lib/comment-markers.ts b/lib/comment-markers.ts index df14e331..1d23b547 100644 --- a/lib/comment-markers.ts +++ b/lib/comment-markers.ts @@ -5,3 +5,9 @@ export const END_RULE_LIST_MARKER = ''; // Marker so that rule doc header (title/notices) can be automatically updated. export const END_RULE_HEADER_MARKER = ''; + +// Markers so that the configs table list can be automatically updated. +export const BEGIN_CONFIG_LIST_MARKER = + ''; +export const END_CONFIG_LIST_MARKER = + ''; diff --git a/lib/config-list.ts b/lib/config-list.ts new file mode 100644 index 00000000..6a08119c --- /dev/null +++ b/lib/config-list.ts @@ -0,0 +1,98 @@ +import { + BEGIN_CONFIG_LIST_MARKER, + END_CONFIG_LIST_MARKER, +} from './comment-markers.js'; +import { markdownTable } from 'markdown-table'; +import type { ConfigsToRules, ConfigEmojis, Plugin } from './types.js'; +import { ConfigFormat, configNameToDisplay } from './config-format.js'; + +function generateConfigListMarkdown( + plugin: Plugin, + configsToRules: ConfigsToRules, + pluginPrefix: string, + configEmojis: ConfigEmojis, + configFormat: ConfigFormat, + ignoreConfig: readonly string[] +): string { + /* istanbul ignore next -- configs are sure to exist at this point */ + const configs = Object.values(plugin.configs || {}); + const hasDescription = configs.some( + // @ts-expect-error -- description is not an official config property. + (config) => config.description + ); + const listHeaderRow = ['', 'Name']; + if (hasDescription) { + listHeaderRow.push('Description'); + } + + return markdownTable( + [ + listHeaderRow, + ...Object.keys(configsToRules) + .filter((configName) => !ignoreConfig.includes(configName)) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + .map((configName) => { + return [ + configEmojis.find((obj) => obj.config === configName)?.emoji || '', + `\`${configNameToDisplay( + configName, + configFormat, + pluginPrefix + )}\``, + hasDescription + ? // @ts-expect-error -- description is not an official config property. + (plugin.configs?.[configName]?.description as + | string + | undefined) || '' + : undefined, + ].filter((col) => col !== undefined); + }), + ], + { align: 'l' } // Left-align headers. + ); +} + +export function updateConfigsList( + markdown: string, + plugin: Plugin, + configsToRules: ConfigsToRules, + pluginPrefix: string, + configEmojis: ConfigEmojis, + configFormat: ConfigFormat, + ignoreConfig: readonly string[] +): string { + const listStartIndex = markdown.indexOf(BEGIN_CONFIG_LIST_MARKER); + let listEndIndex = markdown.indexOf(END_CONFIG_LIST_MARKER); + + if (listStartIndex === -1 || listEndIndex === -1) { + // No config list found. + return markdown; + } + + if ( + Object.keys(configsToRules).filter( + (configName) => !ignoreConfig.includes(configName) + ).length === 0 + ) { + // No non-ignored configs found. + return markdown; + } + + // Account for length of pre-existing marker. + listEndIndex += END_CONFIG_LIST_MARKER.length; + + const preList = markdown.slice(0, Math.max(0, listStartIndex)); + const postList = markdown.slice(Math.max(0, listEndIndex)); + + // New config list. + const list = generateConfigListMarkdown( + plugin, + configsToRules, + pluginPrefix, + configEmojis, + configFormat, + ignoreConfig + ); + + return `${preList}${BEGIN_CONFIG_LIST_MARKER}\n\n${list}\n\n${END_CONFIG_LIST_MARKER}${postList}`; +} diff --git a/lib/generator.ts b/lib/generator.ts index 513b6b1a..4a235997 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -8,6 +8,7 @@ import { getPathWithExactFileNameCasing, } from './package-json.js'; import { updateRulesList } from './rule-list.js'; +import { updateConfigsList } from './config-list.js'; import { generateRuleHeaderLines } from './rule-doc-notices.js'; import { parseRuleDocNoticesOption, @@ -260,22 +261,30 @@ export async function generate(path: string, options?: GenerateOptions) { // Update the rules list in this file. const fileContents = readFileSync(pathToFile, 'utf8'); const fileContentsNew = await postprocess( - updateRulesList( - ruleNamesAndRules, - fileContents, + updateConfigsList( + updateRulesList( + ruleNamesAndRules, + fileContents, + plugin, + configsToRules, + pluginPrefix, + pathRuleDoc, + pathToFile, + path, + configEmojis, + configFormat, + ignoreConfig, + ruleListColumns, + ruleListSplit, + urlConfigs, + urlRuleDoc + ), plugin, configsToRules, pluginPrefix, - pathRuleDoc, - pathToFile, - path, configEmojis, configFormat, - ignoreConfig, - ruleListColumns, - ruleListSplit, - urlConfigs, - urlRuleDoc + ignoreConfig ), resolve(pathToFile) ); diff --git a/test/lib/generate/__snapshots__/configs-list-test.ts.snap b/test/lib/generate/__snapshots__/configs-list-test.ts.snap new file mode 100644 index 00000000..5bb717c6 --- /dev/null +++ b/test/lib/generate/__snapshots__/configs-list-test.ts.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generate (configs list) basic generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + + +| | Name | +| :- | :------------ | +| ✅ | \`recommended\` | + +" +`; + +exports[`generate (configs list) when a config exports a description generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + + +| | Name | Description | +| :- | :------------ | :--------------------------------------- | +| | \`foo\` | | +| ✅ | \`recommended\` | This config has the recommended rules... | + +" +`; + +exports[`generate (configs list) when all configs are ignored generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + +" +`; + +exports[`generate (configs list) when there are no configs generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + +" +`; + +exports[`generate (configs list) with --config-format generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + + +| | Name | +| :- | :----------------- | +| ✅ | \`test/recommended\` | + +" +`; + +exports[`generate (configs list) with --ignore-config generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + + +| | Name | +| :- | :------------ | +| ✅ | \`recommended\` | + +" +`; + +exports[`generate (configs list) with configs not defined in alphabetical order generates the documentation 1`] = ` +"## Rules + + +| Name | Description | +| :----------------------------- | :--------------------- | +| [no-foo](docs/rules/no-foo.md) | Description of no-foo. | + + +## Configs + + +| | Name | +| :- | :------------ | +| | \`foo\` | +| ✅ | \`recommended\` | + +" +`; diff --git a/test/lib/generate/configs-list-test.ts b/test/lib/generate/configs-list-test.ts new file mode 100644 index 00000000..bb9efc35 --- /dev/null +++ b/test/lib/generate/configs-list-test.ts @@ -0,0 +1,327 @@ +import { generate } from '../../../lib/generator.js'; +import mockFs from 'mock-fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { jest } from '@jest/globals'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); + +describe('generate (configs list)', function () { + describe('basic', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + recommended: {}, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('with --ignore-config', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + foo: {}, + recommended: {}, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.', { ignoreConfig: ['foo'] }); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('with --config-format', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + recommended: {}, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.', { configFormat: 'prefix-name' }); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('with configs not defined in alphabetical order', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + recommended: {}, + foo: {}, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('when a config exports a description', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + foo: {}, + recommended: { description: 'This config has the recommended rules...' }, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('when there are no configs', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('when all configs are ignored', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { + meta: { docs: { description: 'Description of no-foo.' }, }, + create(context) {} + }, + }, + configs: { + recommended: {}, + } + };`, + + 'README.md': `## Rules +## Configs + +`, + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.', { ignoreConfig: ['recommended'] }); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); +});