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 21, 2022
1 parent f8bd2da commit db9c998
Show file tree
Hide file tree
Showing 8 changed files with 428 additions and 28 deletions.
33 changes: 31 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` - A function-only option useful for applying custom transformations such as formatting with tools like prettier. See [prettier example](#prettier).
- [`ruleListSplit`](#configuration-options) with a function - This is useful for customizing the grouping of rules into lists.

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

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

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

```js
/** @type {import('eslint-doc-generator').GenerateOptions} */
const config = {
ruleListSplit(rules) {
return [
{
// No header for this list.
rules: rules.filter(([name, rule]) => !rule.meta.someProp),
},
{
title: 'Foo',
rules: rules.filter(([name, rule]) => rule.meta.someProp === 'foo'),
},
{
title: 'Bar',
rules: rules.filter(([name, rule]) => rule.meta.someProp === 'bar'),
},
];
},
};

module.exports = config;
```

### 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
30 changes: 22 additions & 8 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
ruleDocSectionOptions: { type: 'boolean' },
ruleDocTitleFormat: { type: 'string' },
ruleListColumns: schemaStringArray,
ruleListSplit: { anyOf: [{ type: 'string' }, schemaStringArray] },
ruleListSplit:
/* istanbul ignore next -- TODO: haven't tested JavaScript config files yet https://github.com/bmish/eslint-doc-generator/issues/366 */
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
typeof explorerResults.config.ruleListSplit === 'function'
? {
/* Functions are allowed but JSON Schema can't validate them so no-op in this case. */
}
: { anyOf: [{ type: 'string' }, schemaStringArray] },
urlConfigs: { type: 'string' },
urlRuleDoc: { type: 'string' },
};
Expand All @@ -130,23 +137,22 @@ 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.');
}

// 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 +270,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 All @@ -285,6 +291,14 @@ export async function run(

const generateOptions = merge(configFileOptions, options); // Recursive merge.

// Options with both a CLI/config-file variant will lose the function value during the merge, so restore it here.
// TODO: figure out a better way to handle this.
/* istanbul ignore next -- TODO: haven't tested JavaScript config files yet https://github.com/bmish/eslint-doc-generator/issues/366 */
if (typeof configFileOptions.ruleListSplit === 'function') {
// @ts-expect-error -- The array is supposed to be read-only at this point.
generateOptions.ruleListSplit = configFileOptions.ruleListSplit;
}

// Invoke callback.
await cb(path, generateOptions);
})
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
71 changes: 63 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 All @@ -30,6 +35,7 @@ import { capitalizeOnlyFirstLetter } from './string.js';
import { noCase } from 'no-case';
import { getProperty } from 'dot-prop';
import { boolean, isBooleanable } from 'boolean';
import Ajv from 'ajv';

function isBooleanableTrue(value: unknown): boolean {
return isBooleanable(value) && boolean(value);
Expand Down Expand Up @@ -219,7 +225,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 +243,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 +344,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 +378,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 +446,56 @@ 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') {
const userDefinedLists = ruleListSplit(ruleNamesAndRules);

// Schema for the user-defined lists.
const schema = {
// Array of rule lists.
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
rules: {
type: 'array',
items: {
type: 'array',
items: [
{ type: 'string' }, // The rule name.
{ type: 'object' }, // The rule object (won't bother trying to validate deeper than this).
],
minItems: 2,
maxItems: 2,
},
minItems: 1,
uniqueItems: true,
},
},
required: ['rules'],
additionalProperties: false,
},
minItems: 1,
uniqueItems: true,
};

// Validate the user-defined lists.
const ajv = new Ajv();
const validate = ajv.compile(schema);
const valid = validate(userDefinedLists);
if (!valid) {
throw new Error(
validate.errors
? ajv.errorsText(validate.errors, {
dataVar: 'ruleListSplit return value',
})
: /* istanbul ignore next -- this shouldn't happen */
'Invalid ruleListSplit return value'
);
}

rulesAndHeaders.push(...userDefinedLists);
} 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
Loading

0 comments on commit db9c998

Please sign in to comment.