Skip to content

Commit a61f029

Browse files
committed
feat: support auto-generated rule options lists
1 parent 22a0754 commit a61f029

File tree

9 files changed

+593
-21
lines changed

9 files changed

+593
-21
lines changed

README.md

+29-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Generates the following documentation covering a [wide variety](#column-and-noti
99
- `README.md` rules table
1010
- `README.md` configs table
1111
- Rule doc titles and notices
12+
- Rule doc options lists
1213

1314
Also performs [configurable](#configuration-options) section consistency checks on rule docs:
1415

@@ -18,11 +19,16 @@ Also performs [configurable](#configuration-options) section consistency checks
1819

1920
- [Motivation](#motivation)
2021
- [Setup](#setup)
22+
- [Scripts](#scripts)
23+
- [Update `README.md`](#update-readmemd)
24+
- [Update rule docs](#update-rule-docs)
25+
- [Configure linting](#configure-linting)
2126
- [Usage](#usage)
2227
- [Examples](#examples)
2328
- [Rules list table](#rules-list-table)
2429
- [Configs list table](#configs-list-table)
2530
- [Rule doc notices](#rule-doc-notices)
31+
- [Rule doc options lists](#rule-doc-options-lists)
2632
- [Users](#users)
2733
- [Configuration options](#configuration-options)
2834
- [Column and notice types](#column-and-notice-types)
@@ -52,6 +58,8 @@ Install it:
5258
npm i --save-dev eslint-doc-generator
5359
```
5460

61+
### Scripts
62+
5563
Add scripts to `package.json`:
5664

5765
- Both a lint script to ensure everything is up-to-date in CI and an update script for contributors to run locally
@@ -70,30 +78,46 @@ Add scripts to `package.json`:
7078
}
7179
```
7280

81+
### Update `README.md`
82+
7383
Delete any old rules list from your `README.md`. A new one will be automatically added to your `## Rules` section (along with the following marker comments if they don't already exist):
7484

7585
```md
7686
<!-- begin auto-generated rules list -->
7787
<!-- end auto-generated rules list -->
7888
```
7989

80-
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):
90+
Optionally, add these marker comments to your `README.md` in a `## Configs` section or similar location (uses the `description` property exported by each config if available):
8191

8292
```md
8393
<!-- begin auto-generated configs list -->
8494
<!-- end auto-generated configs list -->
8595
```
8696

97+
### Update rule docs
98+
8799
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).
88100

89101
```md
90102
<!-- end auto-generated rule header -->
91103
```
92104

105+
Optionally, add these marker comments to your rule docs in an `## Options` section or similar location:
106+
107+
```md
108+
<!-- begin auto-generated rule options list -->
109+
<!-- end auto-generated rule options list -->
110+
```
111+
112+
Note that rule option lists are subject-to-change as we add support for more kinds and properties of schemas. To fully take advantage of them, you'll want to ensure your rules have the `meta.schema` property fleshed out with properties like `description`, `type`, `enum`, `default`, `required`, `deprecated`.
113+
114+
### Configure linting
115+
93116
And be sure to enable the `recommended` rules from [eslint-plugin-eslint-plugin](https://github.com/eslint-community/eslint-plugin-eslint-plugin) as well as:
94117

95118
- [eslint-plugin/require-meta-docs-description](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-docs-description.md) to ensure your rules have consistent descriptions for use in the generated docs
96119
- [eslint-plugin/require-meta-docs-url](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-docs-url.md) to ensure your rule docs are linked to by editors on highlighted violations
120+
- [eslint-plugin/require-meta-schema](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-schema.md) to ensure your rules have schemas for use in determining options
97121

98122
## Usage
99123

@@ -119,6 +143,10 @@ See the generated configs table in our example [`README.md`](./docs/examples/esl
119143

120144
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).
121145

146+
### Rule doc options lists
147+
148+
See the generated rule doc options lists in our example rule doc [`no-foo.md`](./docs/examples/eslint-plugin-test/docs/rules/no-foo.md).
149+
122150
### Users
123151

124152
This tool is used by popular ESLint plugins like:

docs/examples/eslint-plugin-test/docs/rules/no-foo.md

+32-1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,35 @@ Examples would normally go here.
2222

2323
## Options
2424

25-
Config options would normally go here.
25+
<!-- begin auto-generated rule options list -->
26+
27+
| Name | Description | Type | Choices | Default | Required | Deprecated |
28+
| :---- | :---------------------------- | :------ | :---------------- | :------- | :------- | :--------- |
29+
| `bar` | Choose how to use the rule. | String | `always`, `never` | `always` | Yes | |
30+
| `foo` | Enable some kind of behavior. | Boolean | | `false` | | Yes |
31+
32+
<!-- end auto-generated rule options list -->
33+
34+
For the purpose of this example, below is the `meta.schema` that would generate the above rule options table:
35+
36+
```json
37+
[{
38+
"type": "object",
39+
"properties": {
40+
"foo": {
41+
"type": "boolean",
42+
"description": "Enable some kind of behavior.",
43+
"deprecated": true,
44+
"default": false
45+
},
46+
"bar": {
47+
"description": "Choose how to use the rule.",
48+
"type": "string",
49+
"enum": ["always", "never"],
50+
"default": "always"
51+
}
52+
},
53+
"required": ["bar"],
54+
"additionalProperties": false
55+
}]
56+
```

lib/comment-markers.ts

+6
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ export const BEGIN_CONFIG_LIST_MARKER =
1111
'<!-- begin auto-generated configs list -->';
1212
export const END_CONFIG_LIST_MARKER =
1313
'<!-- end auto-generated configs list -->';
14+
15+
// Markers so that the rule options table list can be automatically updated.
16+
export const BEGIN_RULE_OPTIONS_LIST_MARKER =
17+
'<!-- begin auto-generated rule options list -->';
18+
export const END_RULE_OPTIONS_LIST_MARKER =
19+
'<!-- end auto-generated rule options list -->';

lib/generator.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { diff } from 'jest-diff';
2727
import type { GenerateOptions } from './types.js';
2828
import { OPTION_TYPE, RuleModule } from './types.js';
2929
import { replaceRulePlaceholder } from './rule-link.js';
30+
import { updateRuleOptionsList } from './rule-options-list.js';
3031

3132
function stringOrArrayWithFallback<T extends string | readonly string[]>(
3233
stringOrArray: undefined | T,
@@ -180,7 +181,10 @@ export async function generate(path: string, options?: GenerateOptions) {
180181

181182
const contents = readFileSync(pathToDoc).toString();
182183
const contentsNew = await postprocess(
183-
replaceOrCreateHeader(contents, newHeaderLines, END_RULE_HEADER_MARKER),
184+
updateRuleOptionsList(
185+
replaceOrCreateHeader(contents, newHeaderLines, END_RULE_HEADER_MARKER),
186+
rule
187+
),
184188
resolve(pathToDoc)
185189
);
186190

@@ -229,7 +233,7 @@ export async function generate(path: string, options?: GenerateOptions) {
229233
['Options', 'Config'],
230234
hasOptions(schema)
231235
);
232-
for (const namedOption of getAllNamedOptions(schema)) {
236+
for (const { name: namedOption } of getAllNamedOptions(schema)) {
233237
expectContentOrFail(
234238
`\`${name}\` rule doc`,
235239
'rule option',

lib/rule-options-list.ts

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {
2+
BEGIN_RULE_OPTIONS_LIST_MARKER,
3+
END_RULE_OPTIONS_LIST_MARKER,
4+
} from './comment-markers.js';
5+
import { markdownTable } from 'markdown-table';
6+
import type { RuleModule } from './types.js';
7+
import { RuleOption, getAllNamedOptions } from './rule-options.js';
8+
import { capitalizeOnlyFirstLetter } from './string.js';
9+
10+
export enum COLUMN_TYPE {
11+
// Alphabetical order.
12+
DEFAULT = 'default',
13+
DEPRECATED = 'deprecated',
14+
DESCRIPTION = 'description',
15+
ENUM = 'enum',
16+
NAME = 'name',
17+
REQUIRED = 'required',
18+
TYPE = 'type',
19+
}
20+
21+
const HEADERS: {
22+
[key in COLUMN_TYPE]: string;
23+
} = {
24+
// Alphabetical order.
25+
[COLUMN_TYPE.DEFAULT]: 'Default',
26+
[COLUMN_TYPE.DEPRECATED]: 'Deprecated',
27+
[COLUMN_TYPE.DESCRIPTION]: 'Description',
28+
[COLUMN_TYPE.ENUM]: 'Choices',
29+
[COLUMN_TYPE.NAME]: 'Name',
30+
[COLUMN_TYPE.REQUIRED]: 'Required',
31+
[COLUMN_TYPE.TYPE]: 'Type',
32+
};
33+
34+
const COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING: {
35+
[key in COLUMN_TYPE]: boolean;
36+
} = {
37+
// Object keys ordered in display order.
38+
// Object values indicate whether the column is displayed by default.
39+
[COLUMN_TYPE.NAME]: true,
40+
[COLUMN_TYPE.DESCRIPTION]: true,
41+
[COLUMN_TYPE.TYPE]: true,
42+
[COLUMN_TYPE.ENUM]: true,
43+
[COLUMN_TYPE.DEFAULT]: true,
44+
[COLUMN_TYPE.REQUIRED]: true,
45+
[COLUMN_TYPE.DEPRECATED]: true,
46+
};
47+
48+
function ruleOptionToColumnValues(ruleOption: RuleOption): {
49+
[key in COLUMN_TYPE]: string | undefined;
50+
} {
51+
const columns: {
52+
[key in COLUMN_TYPE]: string | undefined;
53+
} = {
54+
// Alphabetical order.
55+
[COLUMN_TYPE.DEFAULT]:
56+
ruleOption.default === undefined
57+
? undefined
58+
: `\`${String(ruleOption.default)}\``,
59+
[COLUMN_TYPE.DEPRECATED]: ruleOption.deprecated ? 'Yes' : undefined,
60+
[COLUMN_TYPE.DESCRIPTION]: ruleOption.description,
61+
[COLUMN_TYPE.ENUM]:
62+
ruleOption.enum && ruleOption.enum.length > 0
63+
? `\`${ruleOption.enum.join('`, `')}\``
64+
: undefined,
65+
[COLUMN_TYPE.NAME]: `\`${ruleOption.name}\``,
66+
[COLUMN_TYPE.REQUIRED]: ruleOption.required ? 'Yes' : undefined,
67+
[COLUMN_TYPE.TYPE]: ruleOption.type
68+
? capitalizeOnlyFirstLetter(ruleOption.type)
69+
: undefined,
70+
};
71+
72+
return columns;
73+
}
74+
75+
function ruleOptionsToColumnsToDisplay(ruleOptions: readonly RuleOption[]): {
76+
[key in COLUMN_TYPE]: boolean;
77+
} {
78+
const columnsToDisplay: {
79+
[key in COLUMN_TYPE]: boolean;
80+
} = {
81+
// Alphabetical order.
82+
[COLUMN_TYPE.DEFAULT]: ruleOptions.some((ruleOption) => ruleOption.default),
83+
[COLUMN_TYPE.DEPRECATED]: ruleOptions.some(
84+
(ruleOption) => ruleOption.deprecated
85+
),
86+
[COLUMN_TYPE.DESCRIPTION]: ruleOptions.some(
87+
(ruleOption) => ruleOption.description
88+
),
89+
[COLUMN_TYPE.ENUM]: ruleOptions.some((ruleOption) => ruleOption.enum),
90+
[COLUMN_TYPE.NAME]: true,
91+
[COLUMN_TYPE.REQUIRED]: ruleOptions.some(
92+
(ruleOption) => ruleOption.required
93+
),
94+
[COLUMN_TYPE.TYPE]: ruleOptions.some((ruleOption) => ruleOption.type),
95+
};
96+
return columnsToDisplay;
97+
}
98+
99+
function generateRuleOptionsListMarkdown(rule: RuleModule): string {
100+
const ruleOptions = getAllNamedOptions(rule.meta.schema);
101+
102+
if (ruleOptions.length === 0) {
103+
return '';
104+
}
105+
106+
const columnsToDisplay = ruleOptionsToColumnsToDisplay(ruleOptions);
107+
const listHeaderRow = Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING)
108+
.filter((type) => columnsToDisplay[type as COLUMN_TYPE])
109+
.map((type) => HEADERS[type as COLUMN_TYPE]);
110+
111+
const rows = [...ruleOptions]
112+
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
113+
.map((ruleOption) => {
114+
const ruleOptionColumnValues = ruleOptionToColumnValues(ruleOption);
115+
116+
// Recreate object using correct ordering and presence of columns.
117+
return Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING)
118+
.filter((type) => columnsToDisplay[type as COLUMN_TYPE])
119+
.map((type) => ruleOptionColumnValues[type as COLUMN_TYPE]);
120+
});
121+
122+
return markdownTable(
123+
[listHeaderRow, ...rows],
124+
{ align: 'l' } // Left-align headers.
125+
);
126+
}
127+
128+
export function updateRuleOptionsList(
129+
markdown: string,
130+
rule: RuleModule
131+
): string {
132+
const listStartIndex = markdown.indexOf(BEGIN_RULE_OPTIONS_LIST_MARKER);
133+
let listEndIndex = markdown.indexOf(END_RULE_OPTIONS_LIST_MARKER);
134+
135+
if (listStartIndex === -1 || listEndIndex === -1) {
136+
// No rule options list found.
137+
return markdown;
138+
}
139+
140+
// Account for length of pre-existing marker.
141+
listEndIndex += END_RULE_OPTIONS_LIST_MARKER.length;
142+
143+
const preList = markdown.slice(0, Math.max(0, listStartIndex));
144+
const postList = markdown.slice(Math.max(0, listEndIndex));
145+
146+
// New rule options list.
147+
const list = generateRuleOptionsListMarkdown(rule);
148+
149+
return `${preList}${BEGIN_RULE_OPTIONS_LIST_MARKER}\n\n${list}\n\n${END_RULE_OPTIONS_LIST_MARKER}${postList}`;
150+
}

lib/rule-options.ts

+34-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
import traverse from 'json-schema-traverse';
22
import type { JSONSchema } from '@typescript-eslint/utils';
33

4+
export type RuleOption = {
5+
name: string;
6+
type?: string;
7+
description?: string;
8+
required?: boolean;
9+
enum?: readonly JSONSchema.JSONSchema4Type[];
10+
default?: JSONSchema.JSONSchema4Type;
11+
deprecated?: boolean;
12+
};
13+
414
/**
515
* Gather a list of named options from a rule schema.
616
* @param jsonSchema - the JSON schema to check
717
* @returns - list of named options we could detect from the schema
818
*/
919
export function getAllNamedOptions(
10-
jsonSchema: JSONSchema.JSONSchema4 | undefined | null
11-
): readonly string[] {
20+
jsonSchema:
21+
| JSONSchema.JSONSchema4
22+
| readonly JSONSchema.JSONSchema4[]
23+
| undefined
24+
| null
25+
): readonly RuleOption[] {
1226
if (!jsonSchema) {
1327
return [];
1428
}
@@ -19,10 +33,23 @@ export function getAllNamedOptions(
1933
);
2034
}
2135

22-
const options: string[] = [];
36+
const options: RuleOption[] = [];
2337
traverse(jsonSchema, (js: JSONSchema.JSONSchema4) => {
2438
if (js.properties) {
25-
options.push(...Object.keys(js.properties));
39+
options.push(
40+
...Object.entries(js.properties).map(([key, value]) => ({
41+
name: key,
42+
type: value.type ? value.type.toString() : undefined,
43+
description: value.description,
44+
default: value.default,
45+
enum: value.enum,
46+
required:
47+
typeof value.required === 'boolean'
48+
? value.required
49+
: Array.isArray(js.required) && js.required.includes(key),
50+
deprecated: value.deprecated, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- property exists on future JSONSchema version but we can let it be used anyway.
51+
}))
52+
);
2653
}
2754
});
2855
return options;
@@ -33,7 +60,9 @@ export function getAllNamedOptions(
3360
* @param jsonSchema - the JSON schema to check
3461
* @returns - whether the schema has options
3562
*/
36-
export function hasOptions(jsonSchema: JSONSchema.JSONSchema4): boolean {
63+
export function hasOptions(
64+
jsonSchema: JSONSchema.JSONSchema4 | readonly JSONSchema.JSONSchema4[]
65+
): boolean {
3766
return (
3867
(Array.isArray(jsonSchema) && jsonSchema.length > 0) ||
3968
(typeof jsonSchema === 'object' && Object.keys(jsonSchema).length > 0)

0 commit comments

Comments
 (0)