diff --git a/README.md b/README.md index 56131d0c..be8a21a8 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ There's also a `postprocess` option that's only available via a [config file](#c | `--rule-doc-section-options` | Whether to require an "Options" or "Config" rule doc section and mention of any named options for rules with options. Default: `true`. | | `--rule-doc-title-format` | The format to use for rule doc titles. Defaults to `desc-parens-prefix-name`. See choices in below [table](#--rule-doc-title-format). | | `--rule-list-columns` | Ordered, comma-separated list of columns to display in rule list. Empty columns will be hidden. See choices in below [table](#column-and-notice-types). Default: `name,description,configsError,configsWarn,configsOff,fixable,hasSuggestions,requiresTypeChecking,deprecated`. | -| `--rule-list-split` | Rule property to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. | +| `--rule-list-split` | Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. | | `--url-configs` | Link to documentation about the ESLint configurations exported by the plugin. | | `--url-rule-doc` | Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name. | diff --git a/lib/cli.ts b/lib/cli.ts index 7afcd4f9..09f170a8 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -105,7 +105,7 @@ async function loadConfigFileOptions(): Promise { ruleDocSectionOptions: { type: 'boolean' }, ruleDocTitleFormat: { type: 'string' }, ruleListColumns: schemaStringArray, - ruleListSplit: { type: 'string' }, + ruleListSplit: { anyOf: [{ type: 'string' }, schemaStringArray] }, urlConfigs: { type: 'string' }, urlRuleDoc: { type: 'string' }, }; @@ -140,6 +140,10 @@ async function loadConfigFileOptions(): Promise { if (typeof config.pathRuleList === 'string') { config.pathRuleList = [config.pathRuleList]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (typeof config.ruleListSplit === 'string') { + config.ruleListSplit = [config.ruleListSplit]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + } return explorerResults.config as GenerateOptions; } @@ -260,7 +264,9 @@ export async function run( ) .option( '--rule-list-split ', - '(optional) Rule property to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`.' + '(optional) Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`.', + collectCSV, + [] ) .option( '--url-configs ', diff --git a/lib/generator.ts b/lib/generator.ts index 66a6176e..46489ef4 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -156,8 +156,10 @@ export async function generate(path: string, options?: GenerateOptions) { options?.ruleDocTitleFormat ?? OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_TITLE_FORMAT]; const ruleListColumns = parseRuleListColumnsOption(options?.ruleListColumns); - const ruleListSplit = - options?.ruleListSplit ?? OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT]; + const ruleListSplit = stringOrArrayToArrayWithFallback( + options?.ruleListSplit, + OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT] + ); const urlConfigs = options?.urlConfigs ?? OPTION_DEFAULTS[OPTION_TYPE.URL_CONFIGS]; const urlRuleDoc = diff --git a/lib/options.ts b/lib/options.ts index a27c101b..2986e341 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -63,7 +63,7 @@ export const OPTION_DEFAULTS = { ) .filter(([_col, enabled]) => enabled) .map(([col]) => col), - [OPTION_TYPE.RULE_LIST_SPLIT]: undefined, + [OPTION_TYPE.RULE_LIST_SPLIT]: [], [OPTION_TYPE.URL_CONFIGS]: undefined, [OPTION_TYPE.URL_RULE_DOC]: undefined, } satisfies Record; // Satisfies is used to ensure all options are included, but without losing type information. diff --git a/lib/rule-list.ts b/lib/rule-list.ts index f61b2d09..a416ef78 100644 --- a/lib/rule-list.ts +++ b/lib/rule-list.ts @@ -220,10 +220,7 @@ function generateRulesListMarkdown( } type RulesAndHeaders = { header?: string; rules: RuleNamesAndRules }[]; -type RulesAndHeadersReadOnly = readonly { - header?: string; - rules: RuleNamesAndRules; -}[]; +type RulesAndHeadersReadOnly = Readonly; function generateRuleListMarkdownForRulesAndHeaders( rulesAndHeaders: RulesAndHeadersReadOnly, @@ -269,66 +266,74 @@ function generateRuleListMarkdownForRulesAndHeaders( function getRulesAndHeadersForSplit( ruleNamesAndRules: RuleNamesAndRules, plugin: Plugin, - ruleListSplit: string + ruleListSplit: readonly string[] ): RulesAndHeadersReadOnly { const rulesAndHeaders: RulesAndHeaders = []; - const values = new Set( - ruleNamesAndRules.map(([name]) => - getPropertyFromRule(plugin, name, ruleListSplit) - ) - ); - const valuesAll = [...values.values()]; + // Initially, all rules are unused. + let unusedRules: [name: string, rule: RuleModule][] = [...ruleNamesAndRules]; // TODO: use type but readonly - if (values.size === 1 && isConsideredFalse(valuesAll[0])) { - throw new Error( - `No rules found with --rule-list-split property "${ruleListSplit}".` + for (const ruleListSplitItem of ruleListSplit) { + const rulesAndHeadersForThisSplit: RulesAndHeaders = []; + + // Check what possible values this split property can have. + const values = new Set( + unusedRules.map(([name]) => + getPropertyFromRule(plugin, name, ruleListSplitItem) + ) ); - } + const valuesAll = [...values.values()]; - // Show any rules that don't have a value for this rule-list-split property first, or for which the boolean property is off. - if (valuesAll.some((val) => isConsideredFalse(val))) { - const rulesForThisValue = ruleNamesAndRules.filter(([name]) => - isConsideredFalse(getPropertyFromRule(plugin, name, ruleListSplit)) + if (values.size === 1 && isConsideredFalse(valuesAll[0])) { + // TODO: this check needs to be performed on all rules + throw new Error( + `No rules found with --rule-list-split property "${ruleListSplitItem}".` + ); + } + // For each possible non-disabled value, show a header and list of corresponding rules. + const valuesNotFalseAndNotTrue = valuesAll.filter( + (val) => !isConsideredFalse(val) && !isBooleanableTrue(val) ); + const valuesTrue = valuesAll.filter((val) => isBooleanableTrue(val)); + const valuesNew = [ + ...valuesNotFalseAndNotTrue, + ...(valuesTrue.length > 0 ? [true] : []), // If there are multiple true values, combine them all into one. + ]; + for (const value of valuesNew.sort((a, b) => + String(a).toLowerCase().localeCompare(String(b).toLowerCase()) + )) { + const rulesForThisValue = unusedRules.filter(([name]) => { + const property = getPropertyFromRule(plugin, name, ruleListSplitItem); + return ( + property === value || (value === true && isBooleanableTrue(property)) + ); + }); + + // Turn ruleListSplit into a title. + // E.g. meta.docs.requiresTypeChecking to "Requires Type Checking". + const ruleListSplitParts = ruleListSplitItem.split('.'); + const ruleListSplitFinalPart = + ruleListSplitParts[ruleListSplitParts.length - 1]; + const ruleListSplitTitle = noCase(ruleListSplitFinalPart, { + transform: (str) => capitalizeOnlyFirstLetter(str), + }); + + rulesAndHeadersForThisSplit.push({ + header: String(isBooleanableTrue(value) ? ruleListSplitTitle : value), + rules: rulesForThisValue, + }); + + // Remove these rules from the unused rules. + unusedRules = unusedRules.filter( + (rule) => !rulesForThisValue.includes(rule) + ); + } - rulesAndHeaders.push({ - rules: rulesForThisValue, - }); + rulesAndHeaders.unshift(...rulesAndHeadersForThisSplit); } - // For each possible non-disabled value, show a header and list of corresponding rules. - const valuesNotFalseAndNotTrue = valuesAll.filter( - (val) => !isConsideredFalse(val) && !isBooleanableTrue(val) - ); - const valuesTrue = valuesAll.filter((val) => isBooleanableTrue(val)); - const valuesNew = [ - ...valuesNotFalseAndNotTrue, - ...(valuesTrue.length > 0 ? [true] : []), // If there are multiple true values, combine them all into one. - ]; - for (const value of valuesNew.sort((a, b) => - String(a).toLowerCase().localeCompare(String(b).toLowerCase()) - )) { - const rulesForThisValue = ruleNamesAndRules.filter(([name]) => { - const property = getPropertyFromRule(plugin, name, ruleListSplit); - return ( - property === value || (value === true && isBooleanableTrue(property)) - ); - }); - - // Turn ruleListSplit into a title. - // E.g. meta.docs.requiresTypeChecking to "Requires Type Checking". - const ruleListSplitParts = ruleListSplit.split('.'); - const ruleListSplitFinalPart = - ruleListSplitParts[ruleListSplitParts.length - 1]; - const ruleListSplitTitle = noCase(ruleListSplitFinalPart, { - transform: (str) => capitalizeOnlyFirstLetter(str), - }); - - rulesAndHeaders.push({ - header: String(isBooleanableTrue(value) ? ruleListSplitTitle : value), - rules: rulesForThisValue, - }); + if (unusedRules.length > 0) { + rulesAndHeaders.unshift({ rules: unusedRules }); } return rulesAndHeaders; @@ -346,7 +351,7 @@ export function updateRulesList( configEmojis: ConfigEmojis, ignoreConfig: readonly string[], ruleListColumns: readonly COLUMN_TYPE[], - ruleListSplit?: string, + ruleListSplit: readonly string[], urlConfigs?: string, urlRuleDoc?: string ): string { @@ -414,7 +419,7 @@ export function updateRulesList( // Determine the pairs of rules and headers based on any split property. const rulesAndHeaders: RulesAndHeaders = []; - if (ruleListSplit) { + if (ruleListSplit.length > 0) { rulesAndHeaders.push( ...getRulesAndHeadersForSplit(ruleNamesAndRules, plugin, ruleListSplit) ); diff --git a/lib/types.ts b/lib/types.ts index 07fe1fd3..c78b0f54 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -162,7 +162,7 @@ export type GenerateOptions = { * A separate list and header will be created for each value. * Example: `meta.type`. */ - readonly ruleListSplit?: string; + readonly ruleListSplit?: string | readonly string[]; /** Link to documentation about the ESLint configurations exported by the plugin. */ readonly urlConfigs?: string; /** diff --git a/test/lib/__snapshots__/cli-test.ts.snap b/test/lib/__snapshots__/cli-test.ts.snap index 59923d7a..86053c52 100644 --- a/test/lib/__snapshots__/cli-test.ts.snap +++ b/test/lib/__snapshots__/cli-test.ts.snap @@ -51,7 +51,10 @@ exports[`cli all CLI options and all config files options merges correctly, with "hasSuggestions", "type", ], - "ruleListSplit": "meta.docs.foo-from-cli", + "ruleListSplit": [ + "meta.docs.foo-from-config-file", + "meta.docs.foo-from-cli", + ], "urlConfigs": "https://example.com/configs-url-from-cli", "urlRuleDoc": "https://example.com/rule-doc-url-from-cli", }, @@ -95,7 +98,9 @@ exports[`cli all CLI options, no config file options is called correctly 1`] = ` "ruleListColumns": [ "type", ], - "ruleListSplit": "meta.docs.foo-from-cli", + "ruleListSplit": [ + "meta.docs.foo-from-cli", + ], "urlConfigs": "https://example.com/configs-url-from-cli", "urlRuleDoc": "https://example.com/rule-doc-url-from-cli", }, @@ -140,7 +145,9 @@ exports[`cli all config files options, no CLI options is called correctly 1`] = "fixable", "hasSuggestions", ], - "ruleListSplit": "meta.docs.foo-from-config-file", + "ruleListSplit": [ + "meta.docs.foo-from-config-file", + ], "urlConfigs": "https://example.com/configs-url-from-config-file", "urlRuleDoc": "https://example.com/rule-doc-url-from-config-file", }, @@ -159,6 +166,7 @@ exports[`cli boolean option - false (explicit) is called correctly 1`] = ` "ruleDocSectionExclude": [], "ruleDocSectionInclude": [], "ruleListColumns": [], + "ruleListSplit": [], }, ] `; @@ -175,6 +183,7 @@ exports[`cli boolean option - true (explicit) is called correctly 1`] = ` "ruleDocSectionExclude": [], "ruleDocSectionInclude": [], "ruleListColumns": [], + "ruleListSplit": [], }, ] `; @@ -191,6 +200,7 @@ exports[`cli boolean option - true (implicit) is called correctly 1`] = ` "ruleDocSectionExclude": [], "ruleDocSectionInclude": [], "ruleListColumns": [], + "ruleListSplit": [], }, ] `; @@ -206,6 +216,7 @@ exports[`cli no options is called correctly 1`] = ` "ruleDocSectionExclude": [], "ruleDocSectionInclude": [], "ruleListColumns": [], + "ruleListSplit": [], }, ] `; @@ -226,6 +237,7 @@ exports[`cli pathRuleList as array in config file and CLI merges correctly 1`] = "ruleDocSectionExclude": [], "ruleDocSectionInclude": [], "ruleListColumns": [], + "ruleListSplit": [], }, ] `; diff --git a/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap b/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap index f62113bc..07ef4df3 100644 --- a/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap +++ b/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap @@ -75,6 +75,37 @@ exports[`generate (--rule-list-split) ignores case when sorting headers splits t " `; +exports[`generate (--rule-list-split) multiple properties splits the list by multiple properties 1`] = ` +"## Rules + + +💼 Configurations enabled in.\\ +✅ Set in the \`recommended\` configuration.\\ +❌ Deprecated. + +### Hello + +| Name | 💼 | ❌ | +| :----------------------------- | :- | :- | +| [no-foo](docs/rules/no-foo.md) | ✅ | | + +### World + +| Name | 💼 | ❌ | +| :----------------------------- | :- | :- | +| [no-biz](docs/rules/no-biz.md) | | | + +### Deprecated + +| Name | 💼 | ❌ | +| :----------------------------- | :- | :- | +| [no-bar](docs/rules/no-bar.md) | | ❌ | +| [no-baz](docs/rules/no-baz.md) | | ❌ | + + +" +`; + exports[`generate (--rule-list-split) with boolean (CONSTANT_CASE) splits the list with the right header 1`] = ` "## Rules diff --git a/test/lib/generate/option-rule-list-split-test.ts b/test/lib/generate/option-rule-list-split-test.ts index 78f9335a..ecda7d71 100644 --- a/test/lib/generate/option-rule-list-split-test.ts +++ b/test/lib/generate/option-rule-list-split-test.ts @@ -573,4 +573,51 @@ describe('generate (--rule-list-split)', function () { expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); }); }); + + describe('multiple properties', 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: { deprecated: false, docs: { category: 'Hello' } }, create(context) {} }, + 'no-bar': { meta: { deprecated: true, docs: { category: 'Should Not Show Since Deprecated' } }, create(context) {} }, + 'no-baz': { meta: { deprecated: true, docs: { category: 'Should Not Show Since Deprecated' } }, create(context) {} }, + 'no-biz': { meta: { deprecated: false, docs: { category: 'World' } }, create(context) {} }, + }, + configs: { + recommended: { rules: { 'test/no-foo': 'error' } }, + } + };`, + + 'README.md': '## Rules\n', + + 'docs/rules/no-foo.md': '', + 'docs/rules/no-bar.md': '', + 'docs/rules/no-baz.md': '', + 'docs/rules/no-biz.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('splits the list by multiple properties', async function () { + await generate('.', { + ruleListSplit: ['meta.deprecated', 'meta.docs.category'], + }); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); });