Skip to content

Commit 77acee1

Browse files
committed
feat: support functions in --rule-list-split option
1 parent f8bd2da commit 77acee1

File tree

8 files changed

+234
-28
lines changed

8 files changed

+234
-28
lines changed

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ There's also a `postprocess` option that's only available via a [config file](#c
142142
| `--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`. |
143143
| `--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). |
144144
| `--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`. |
145-
| `--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`. |
145+
| `--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`. A function can also be provided for this option via a [config file](#configuration-file). |
146146
| `--url-configs` | Link to documentation about the ESLint configurations exported by the plugin. |
147147
| `--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. |
148148

@@ -187,7 +187,10 @@ There are a few ways to create a config file (as an alternative to passing the o
187187

188188
Config files support all the [CLI options](#configuration-options) but in camelCase.
189189

190-
Using a JavaScript-based config file also allows you to provide a `postprocess` function to be called with the generated content and file path for each processed file. This is useful for applying custom transformations such as formatting with tools like prettier (see [prettier example](#prettier)).
190+
Some options are exclusive to a JavaScript-based config file:
191+
192+
- `postprocess` - This is useful for applying custom transformations such as formatting with tools like prettier. See [prettier example](#prettier).
193+
- `ruleListSplit` - This is useful for customizing the grouping of rules into lists.
191194

192195
Example `.eslint-doc-generatorrc.js`:
193196

@@ -200,6 +203,24 @@ const config = {
200203
module.exports = config;
201204
```
202205

206+
Example `.eslint-doc-generatorrc.js` with `ruleListSplit` function:
207+
208+
```js
209+
const config = {
210+
ruleListSplit(rules) {
211+
const list1 = {
212+
title: 'Foo',
213+
rules: rules.filter(([name, rule]) => rule.meta.foo),
214+
};
215+
const list2 = {
216+
title: 'Bar',
217+
rules: rules.filter(([name, rule]) => rule.meta.bar),
218+
};
219+
return [list1, list2];
220+
},
221+
};
222+
```
223+
203224
### Badges
204225

205226
While config emojis are the recommended representations of configs that a rule belongs to (see [`--config-emoji`](#configuration-options)), you can alternatively define badges for configs at the bottom of your `README.md`.

lib/cli.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
105105
ruleDocSectionOptions: { type: 'boolean' },
106106
ruleDocTitleFormat: { type: 'string' },
107107
ruleListColumns: schemaStringArray,
108-
ruleListSplit: { anyOf: [{ type: 'string' }, schemaStringArray] },
108+
ruleListSplit: {
109+
/* JSON Schema can't validate functions so check this later */
110+
},
109111
urlConfigs: { type: 'string' },
110112
urlRuleDoc: { type: 'string' },
111113
};
@@ -130,23 +132,38 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
130132
const config = explorerResults.config; // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- Rules are disabled because we haven't applied the GenerateOptions type until after we finish validating/normalizing.
131133

132134
// Additional validation that couldn't be handled by ajv.
133-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
135+
/* eslint-disable @typescript-eslint/no-unsafe-member-access -- disabled for same reason above */
134136
if (config.postprocess && typeof config.postprocess !== 'function') {
135-
throw new Error('postprocess must be a function');
137+
throw new Error('postprocess must be a function.');
138+
}
139+
if (
140+
config.ruleListSplit &&
141+
!(
142+
typeof config.ruleListSplit === 'string' ||
143+
(Array.isArray(config.ruleListSplit) &&
144+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- disabled for same reason above
145+
config.ruleListSplit.every(
146+
(item: unknown) => typeof item === 'string'
147+
)) ||
148+
typeof config.ruleListSplit === 'function'
149+
)
150+
) {
151+
throw new Error(
152+
'ruleListSplit must be a string, string array, or function.'
153+
);
136154
}
137155

138156
// Perform any normalization.
139-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
140157
if (typeof config.pathRuleList === 'string') {
141-
config.pathRuleList = [config.pathRuleList]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
158+
config.pathRuleList = [config.pathRuleList];
142159
}
143-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
144160
if (typeof config.ruleListSplit === 'string') {
145-
config.ruleListSplit = [config.ruleListSplit]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
161+
config.ruleListSplit = [config.ruleListSplit];
146162
}
147163

148164
return explorerResults.config as GenerateOptions;
149165
}
166+
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
150167
return {};
151168
}
152169

@@ -264,7 +281,7 @@ export async function run(
264281
)
265282
.option(
266283
'--rule-list-split <property>',
267-
'(optional) Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`.',
284+
'(optional) Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. To specify a function, use a JavaScript-based config file.',
268285
collectCSV,
269286
[]
270287
)

lib/generator.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,13 @@ export async function generate(path: string, options?: GenerateOptions) {
156156
options?.ruleDocTitleFormat ??
157157
OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_TITLE_FORMAT];
158158
const ruleListColumns = parseRuleListColumnsOption(options?.ruleListColumns);
159-
const ruleListSplit = stringOrArrayToArrayWithFallback(
160-
options?.ruleListSplit,
161-
OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT]
162-
);
159+
const ruleListSplit =
160+
typeof options?.ruleListSplit === 'function'
161+
? options.ruleListSplit
162+
: stringOrArrayToArrayWithFallback(
163+
options?.ruleListSplit,
164+
OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT]
165+
);
163166
const urlConfigs =
164167
options?.urlConfigs ?? OPTION_DEFAULTS[OPTION_TYPE.URL_CONFIGS];
165168
const urlRuleDoc =

lib/rule-list.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import { findSectionHeader, findFinalHeaderLevel } from './markdown.js';
1515
import { getPluginRoot } from './package-json.js';
1616
import { generateLegend } from './rule-list-legend.js';
1717
import { relative } from 'node:path';
18-
import { COLUMN_TYPE, RuleModule, SEVERITY_TYPE } from './types.js';
18+
import {
19+
COLUMN_TYPE,
20+
RuleListSplitFunction,
21+
RuleModule,
22+
SEVERITY_TYPE,
23+
} from './types.js';
1924
import { markdownTable } from 'markdown-table';
2025
import type {
2126
Plugin,
@@ -219,7 +224,7 @@ function generateRulesListMarkdown(
219224
);
220225
}
221226

222-
type RulesAndHeaders = { header?: string; rules: RuleNamesAndRules }[];
227+
type RulesAndHeaders = { title?: string; rules: RuleNamesAndRules }[];
223228
type RulesAndHeadersReadOnly = Readonly<RulesAndHeaders>;
224229

225230
function generateRuleListMarkdownForRulesAndHeaders(
@@ -237,9 +242,9 @@ function generateRuleListMarkdownForRulesAndHeaders(
237242
): string {
238243
const parts: string[] = [];
239244

240-
for (const { header, rules } of rulesAndHeaders) {
241-
if (header) {
242-
parts.push(`${'#'.repeat(headerLevel)} ${header}`);
245+
for (const { title, rules } of rulesAndHeaders) {
246+
if (title) {
247+
parts.push(`${'#'.repeat(headerLevel)} ${title}`);
243248
}
244249
parts.push(
245250
generateRulesListMarkdown(
@@ -338,7 +343,7 @@ function getRulesAndHeadersForSplit(
338343

339344
// Add a list for the rules with property set to this value.
340345
rulesAndHeadersForThisSplit.push({
341-
header: String(isBooleanableTrue(value) ? ruleListSplitTitle : value),
346+
title: String(isBooleanableTrue(value) ? ruleListSplitTitle : value),
342347
rules: rulesForThisValue,
343348
});
344349

@@ -372,7 +377,7 @@ export function updateRulesList(
372377
configEmojis: ConfigEmojis,
373378
ignoreConfig: readonly string[],
374379
ruleListColumns: readonly COLUMN_TYPE[],
375-
ruleListSplit: readonly string[],
380+
ruleListSplit: readonly string[] | RuleListSplitFunction,
376381
urlConfigs?: string,
377382
urlRuleDoc?: string
378383
): string {
@@ -440,7 +445,9 @@ export function updateRulesList(
440445

441446
// Determine the pairs of rules and headers based on any split property.
442447
const rulesAndHeaders: RulesAndHeaders = [];
443-
if (ruleListSplit.length > 0) {
448+
if (typeof ruleListSplit === 'function') {
449+
rulesAndHeaders.push(...ruleListSplit(ruleNamesAndRules));
450+
} else if (ruleListSplit.length > 0) {
444451
rulesAndHeaders.push(
445452
...getRulesAndHeadersForSplit(ruleNamesAndRules, plugin, ruleListSplit)
446453
);

lib/types.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ export const SEVERITY_TYPE_TO_SET: {
3636
export type ConfigsToRules = Record<string, Rules>;
3737

3838
/**
39-
* Convenient way to pass around a list of rules (as tuples).
39+
* List of rules in the form of tuples (rule name and the actual rule).
4040
*/
41-
export type RuleNamesAndRules = readonly [name: string, rule: RuleModule][];
41+
export type RuleNamesAndRules = readonly (readonly [
42+
name: string,
43+
rule: RuleModule
44+
])[];
4245

4346
/**
4447
* The emoji for each config that has one after option parsing and defaults have been applied.
@@ -101,6 +104,17 @@ export enum OPTION_TYPE {
101104
URL_RULE_DOC = 'urlRuleDoc',
102105
}
103106

107+
/**
108+
* Function for splitting the rule list into multiple sections.
109+
* Can be provided via a JavaScript-based config file using the `ruleListSplit` option.
110+
* @param rules all rules from the plugin
111+
* @returns an array of sections, each with a title (optional) and list of rules
112+
*/
113+
export type RuleListSplitFunction = (rules: RuleNamesAndRules) => readonly {
114+
title?: string;
115+
rules: RuleNamesAndRules;
116+
}[];
117+
104118
// JSDocs for options should be kept in sync with README.md and the CLI runner in cli.ts.
105119
/** The type for the config file (e.g. `.eslint-doc-generatorrc.js`) and internal `generate()` function. */
106120
export type GenerateOptions = {
@@ -129,7 +143,7 @@ export type GenerateOptions = {
129143
/**
130144
* Function to be called with the generated content and file path for each processed file.
131145
* Useful for applying custom transformations such as formatting with tools like prettier.
132-
* Only available via a JavaScript config file.
146+
* Only available via a JavaScript-based config file.
133147
*/
134148
readonly postprocess?: (
135149
content: string,
@@ -158,11 +172,12 @@ export type GenerateOptions = {
158172
*/
159173
readonly ruleListColumns?: readonly `${COLUMN_TYPE}`[];
160174
/**
161-
* Rule property to split the rules list by.
175+
* Rule property(s) or function to split the rules list by.
162176
* A separate list and header will be created for each value.
163177
* Example: `meta.type`.
164178
*/
165-
readonly ruleListSplit?: string | readonly string[];
179+
readonly ruleListSplit?: string | readonly string[] | RuleListSplitFunction;
180+
166181
/** Link to documentation about the ESLint configurations exported by the plugin. */
167182
readonly urlConfigs?: string;
168183
/**

test/lib/cli-test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,65 @@ describe('cli', function () {
377377
],
378378
stub
379379
)
380-
).rejects.toThrow('postprocess must be a function');
380+
).rejects.toThrow('postprocess must be a function.');
381+
});
382+
383+
it('ruleListSplit is the wrong primitive type', async function () {
384+
mockFs({
385+
'package.json': JSON.stringify({
386+
name: 'eslint-plugin-test',
387+
main: 'index.js',
388+
type: 'module',
389+
version: '1.0.0',
390+
}),
391+
392+
'.eslint-doc-generatorrc.json': JSON.stringify({
393+
// Doesn't match schema.
394+
ruleListSplit: 123,
395+
}),
396+
});
397+
398+
const stub = sinon.stub().resolves();
399+
await expect(
400+
run(
401+
[
402+
'node', // Path to node.
403+
'eslint-doc-generator.js', // Path to this binary.
404+
],
405+
stub
406+
)
407+
).rejects.toThrow(
408+
'ruleListSplit must be a string, string array, or function'
409+
);
410+
});
411+
412+
it('ruleListSplit is the wrong array type', async function () {
413+
mockFs({
414+
'package.json': JSON.stringify({
415+
name: 'eslint-plugin-test',
416+
main: 'index.js',
417+
type: 'module',
418+
version: '1.0.0',
419+
}),
420+
421+
'.eslint-doc-generatorrc.json': JSON.stringify({
422+
// Doesn't match schema.
423+
ruleListSplit: [123],
424+
}),
425+
});
426+
427+
const stub = sinon.stub().resolves();
428+
await expect(
429+
run(
430+
[
431+
'node', // Path to node.
432+
'eslint-doc-generator.js', // Path to this binary.
433+
],
434+
stub
435+
)
436+
).rejects.toThrow(
437+
'ruleListSplit must be a string, string array, or function.'
438+
);
381439
});
382440
});
383441
});

test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`generate (--rule-list-split) as a function generates the documentation 1`] = `
4+
"## Rules
5+
<!-- begin auto-generated rules list -->
6+
7+
❌ Deprecated.
8+
9+
### Not Deprecated
10+
11+
| Name | ❌ |
12+
| :----------------------------- | :- |
13+
| [no-bar](docs/rules/no-bar.md) | |
14+
| [no-baz](docs/rules/no-baz.md) | |
15+
16+
### Deprecated
17+
18+
| Name | ❌ |
19+
| :----------------------------- | :- |
20+
| [no-foo](docs/rules/no-foo.md) | ❌ |
21+
22+
### Name = "no-baz"
23+
24+
| Name | ❌ |
25+
| :----------------------------- | :- |
26+
| [no-baz](docs/rules/no-baz.md) | |
27+
28+
<!-- end auto-generated rules list -->
29+
"
30+
`;
31+
332
exports[`generate (--rule-list-split) by nested property meta.docs.category splits the list 1`] = `
433
"## Rules
534
<!-- begin auto-generated rules list -->

0 commit comments

Comments
 (0)