Skip to content

Commit

Permalink
feat: support functions in --rule-list-split option
Browse files Browse the repository at this point in the history
  • Loading branch information
bmish committed Dec 20, 2022
1 parent f8bd2da commit 77acee1
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 28 deletions.
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(s) 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`. A function can also be provided for this option via a [config file](#configuration-file). |
| `--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. |

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

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

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)).
Some options are exclusive to a JavaScript-based config file:

- `postprocess` - This is useful for applying custom transformations such as formatting with tools like prettier. See [prettier example](#prettier).
- `ruleListSplit` - This is useful for customizing the grouping of rules into lists.

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

Expand All @@ -200,6 +203,24 @@ const config = {
module.exports = config;
```

Example `.eslint-doc-generatorrc.js` with `ruleListSplit` function:

```js
const config = {
ruleListSplit(rules) {
const list1 = {
title: 'Foo',
rules: rules.filter(([name, rule]) => rule.meta.foo),
};
const list2 = {
title: 'Bar',
rules: rules.filter(([name, rule]) => rule.meta.bar),
};
return [list1, list2];
},
};
```

### Badges

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`.
Expand Down
33 changes: 25 additions & 8 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
ruleDocSectionOptions: { type: 'boolean' },
ruleDocTitleFormat: { type: 'string' },
ruleListColumns: schemaStringArray,
ruleListSplit: { anyOf: [{ type: 'string' }, schemaStringArray] },
ruleListSplit: {
/* JSON Schema can't validate functions so check this later */
},
urlConfigs: { type: 'string' },
urlRuleDoc: { type: 'string' },
};
Expand All @@ -130,23 +132,38 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
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.

// Additional validation that couldn't be handled by ajv.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
/* eslint-disable @typescript-eslint/no-unsafe-member-access -- disabled for same reason above */
if (config.postprocess && typeof config.postprocess !== 'function') {
throw new Error('postprocess must be a function');
throw new Error('postprocess must be a function.');
}
if (
config.ruleListSplit &&
!(
typeof config.ruleListSplit === 'string' ||
(Array.isArray(config.ruleListSplit) &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- disabled for same reason above
config.ruleListSplit.every(
(item: unknown) => typeof item === 'string'
)) ||
typeof config.ruleListSplit === 'function'
)
) {
throw new Error(
'ruleListSplit must be a string, string array, or function.'
);
}

// Perform any normalization.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (typeof config.pathRuleList === 'string') {
config.pathRuleList = [config.pathRuleList]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
config.pathRuleList = [config.pathRuleList];
}
// 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
config.ruleListSplit = [config.ruleListSplit];
}

return explorerResults.config as GenerateOptions;
}
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
return {};
}

Expand Down Expand Up @@ -264,7 +281,7 @@ export async function run(
)
.option(
'--rule-list-split <property>',
'(optional) Rule property(s) 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`. To specify a function, use a JavaScript-based config file.',
collectCSV,
[]
)
Expand Down
11 changes: 7 additions & 4 deletions lib/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,13 @@ 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 = stringOrArrayToArrayWithFallback(
options?.ruleListSplit,
OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT]
);
const ruleListSplit =
typeof options?.ruleListSplit === 'function'
? options.ruleListSplit
: stringOrArrayToArrayWithFallback(
options?.ruleListSplit,
OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT]
);
const urlConfigs =
options?.urlConfigs ?? OPTION_DEFAULTS[OPTION_TYPE.URL_CONFIGS];
const urlRuleDoc =
Expand Down
23 changes: 15 additions & 8 deletions lib/rule-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import { findSectionHeader, findFinalHeaderLevel } from './markdown.js';
import { getPluginRoot } from './package-json.js';
import { generateLegend } from './rule-list-legend.js';
import { relative } from 'node:path';
import { COLUMN_TYPE, RuleModule, SEVERITY_TYPE } from './types.js';
import {
COLUMN_TYPE,
RuleListSplitFunction,
RuleModule,
SEVERITY_TYPE,
} from './types.js';
import { markdownTable } from 'markdown-table';
import type {
Plugin,
Expand Down Expand Up @@ -219,7 +224,7 @@ function generateRulesListMarkdown(
);
}

type RulesAndHeaders = { header?: string; rules: RuleNamesAndRules }[];
type RulesAndHeaders = { title?: string; rules: RuleNamesAndRules }[];
type RulesAndHeadersReadOnly = Readonly<RulesAndHeaders>;

function generateRuleListMarkdownForRulesAndHeaders(
Expand All @@ -237,9 +242,9 @@ function generateRuleListMarkdownForRulesAndHeaders(
): string {
const parts: string[] = [];

for (const { header, rules } of rulesAndHeaders) {
if (header) {
parts.push(`${'#'.repeat(headerLevel)} ${header}`);
for (const { title, rules } of rulesAndHeaders) {
if (title) {
parts.push(`${'#'.repeat(headerLevel)} ${title}`);
}
parts.push(
generateRulesListMarkdown(
Expand Down Expand Up @@ -338,7 +343,7 @@ function getRulesAndHeadersForSplit(

// Add a list for the rules with property set to this value.
rulesAndHeadersForThisSplit.push({
header: String(isBooleanableTrue(value) ? ruleListSplitTitle : value),
title: String(isBooleanableTrue(value) ? ruleListSplitTitle : value),
rules: rulesForThisValue,
});

Expand Down Expand Up @@ -372,7 +377,7 @@ export function updateRulesList(
configEmojis: ConfigEmojis,
ignoreConfig: readonly string[],
ruleListColumns: readonly COLUMN_TYPE[],
ruleListSplit: readonly string[],
ruleListSplit: readonly string[] | RuleListSplitFunction,
urlConfigs?: string,
urlRuleDoc?: string
): string {
Expand Down Expand Up @@ -440,7 +445,9 @@ export function updateRulesList(

// Determine the pairs of rules and headers based on any split property.
const rulesAndHeaders: RulesAndHeaders = [];
if (ruleListSplit.length > 0) {
if (typeof ruleListSplit === 'function') {
rulesAndHeaders.push(...ruleListSplit(ruleNamesAndRules));
} else if (ruleListSplit.length > 0) {
rulesAndHeaders.push(
...getRulesAndHeadersForSplit(ruleNamesAndRules, plugin, ruleListSplit)
);
Expand Down
25 changes: 20 additions & 5 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ export const SEVERITY_TYPE_TO_SET: {
export type ConfigsToRules = Record<string, Rules>;

/**
* Convenient way to pass around a list of rules (as tuples).
* List of rules in the form of tuples (rule name and the actual rule).
*/
export type RuleNamesAndRules = readonly [name: string, rule: RuleModule][];
export type RuleNamesAndRules = readonly (readonly [
name: string,
rule: RuleModule
])[];

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

/**
* Function for splitting the rule list into multiple sections.
* Can be provided via a JavaScript-based config file using the `ruleListSplit` option.
* @param rules all rules from the plugin
* @returns an array of sections, each with a title (optional) and list of rules
*/
export type RuleListSplitFunction = (rules: RuleNamesAndRules) => readonly {
title?: string;
rules: RuleNamesAndRules;
}[];

// JSDocs for options should be kept in sync with README.md and the CLI runner in cli.ts.
/** The type for the config file (e.g. `.eslint-doc-generatorrc.js`) and internal `generate()` function. */
export type GenerateOptions = {
Expand Down Expand Up @@ -129,7 +143,7 @@ export type GenerateOptions = {
/**
* Function to be called with the generated content and file path for each processed file.
* Useful for applying custom transformations such as formatting with tools like prettier.
* Only available via a JavaScript config file.
* Only available via a JavaScript-based config file.
*/
readonly postprocess?: (
content: string,
Expand Down Expand Up @@ -158,11 +172,12 @@ export type GenerateOptions = {
*/
readonly ruleListColumns?: readonly `${COLUMN_TYPE}`[];
/**
* Rule property to split the rules list by.
* Rule property(s) or function to split the rules list by.
* A separate list and header will be created for each value.
* Example: `meta.type`.
*/
readonly ruleListSplit?: string | readonly string[];
readonly ruleListSplit?: string | readonly string[] | RuleListSplitFunction;

/** Link to documentation about the ESLint configurations exported by the plugin. */
readonly urlConfigs?: string;
/**
Expand Down
60 changes: 59 additions & 1 deletion test/lib/cli-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,65 @@ describe('cli', function () {
],
stub
)
).rejects.toThrow('postprocess must be a function');
).rejects.toThrow('postprocess must be a function.');
});

it('ruleListSplit is the wrong primitive type', async function () {
mockFs({
'package.json': JSON.stringify({
name: 'eslint-plugin-test',
main: 'index.js',
type: 'module',
version: '1.0.0',
}),

'.eslint-doc-generatorrc.json': JSON.stringify({
// Doesn't match schema.
ruleListSplit: 123,
}),
});

const stub = sinon.stub().resolves();
await expect(
run(
[
'node', // Path to node.
'eslint-doc-generator.js', // Path to this binary.
],
stub
)
).rejects.toThrow(
'ruleListSplit must be a string, string array, or function'
);
});

it('ruleListSplit is the wrong array type', async function () {
mockFs({
'package.json': JSON.stringify({
name: 'eslint-plugin-test',
main: 'index.js',
type: 'module',
version: '1.0.0',
}),

'.eslint-doc-generatorrc.json': JSON.stringify({
// Doesn't match schema.
ruleListSplit: [123],
}),
});

const stub = sinon.stub().resolves();
await expect(
run(
[
'node', // Path to node.
'eslint-doc-generator.js', // Path to this binary.
],
stub
)
).rejects.toThrow(
'ruleListSplit must be a string, string array, or function.'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`generate (--rule-list-split) as a function generates the documentation 1`] = `
"## Rules
<!-- begin auto-generated rules list -->
❌ Deprecated.
### Not Deprecated
| Name | ❌ |
| :----------------------------- | :- |
| [no-bar](docs/rules/no-bar.md) | |
| [no-baz](docs/rules/no-baz.md) | |
### Deprecated
| Name | ❌ |
| :----------------------------- | :- |
| [no-foo](docs/rules/no-foo.md) | ❌ |
### Name = "no-baz"
| Name | ❌ |
| :----------------------------- | :- |
| [no-baz](docs/rules/no-baz.md) | |
<!-- end auto-generated rules list -->
"
`;

exports[`generate (--rule-list-split) by nested property meta.docs.category splits the list 1`] = `
"## Rules
<!-- begin auto-generated rules list -->
Expand Down
Loading

0 comments on commit 77acee1

Please sign in to comment.