eslint-doc-generator
Version:
Automatic documentation generator for ESLint plugins and rules.
176 lines (175 loc) • 10.7 kB
JavaScript
import { EOL } from 'node:os';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { getAllNamedOptions, hasOptions } from './rule-options.js';
import { loadPlugin, getPluginPrefix, getPluginRoot, getPathWithExactFileNameCasing, } from './package-json.js';
import { updateRulesList } from './rule-list.js';
import { updateConfigsList } from './config-list.js';
import { generateRuleHeaderLines } from './rule-doc-notices.js';
import { parseRuleDocNoticesOption, parseRuleListColumnsOption, parseConfigEmojiOptions, } from './option-parsers.js';
import { BEGIN_RULE_OPTIONS_LIST_MARKER, END_RULE_HEADER_MARKER, END_RULE_OPTIONS_LIST_MARKER, } from './comment-markers.js';
import { replaceOrCreateHeader, expectContentOrFail, expectSectionHeaderOrFail, } from './markdown.js';
import { resolveConfigsToRules } from './plugin-config-resolution.js';
import { OPTION_DEFAULTS } from './options.js';
import { diff } from 'jest-diff';
import { OPTION_TYPE } from './types.js';
import { replaceRulePlaceholder } from './rule-link.js';
import { updateRuleOptionsList } from './rule-options-list.js';
function stringOrArrayWithFallback(stringOrArray, fallback) {
return stringOrArray && stringOrArray.length > 0 ? stringOrArray : fallback;
}
function stringOrArrayToArrayWithFallback(stringOrArray, fallback) {
const asArray = stringOrArray instanceof Array // eslint-disable-line unicorn/no-instanceof-array -- using Array.isArray() loses type information about the array.
? stringOrArray
: stringOrArray
? [stringOrArray]
: [];
const csvStringItem = asArray.find((item) => item.includes(','));
if (csvStringItem) {
throw new Error(`Provide property as array, not a CSV string: ${csvStringItem}`);
}
return asArray && asArray.length > 0 ? asArray : fallback;
}
// eslint-disable-next-line complexity
export async function generate(path, options) {
const plugin = await loadPlugin(path);
const pluginPrefix = getPluginPrefix(path);
const configsToRules = await resolveConfigsToRules(plugin);
if (!plugin.rules) {
throw new Error('Could not find exported `rules` object in ESLint plugin.');
}
// Options. Add default values as needed.
const check = options?.check ?? OPTION_DEFAULTS[OPTION_TYPE.CHECK];
const configEmojis = parseConfigEmojiOptions(plugin, options?.configEmoji);
const configFormat = options?.configFormat ?? OPTION_DEFAULTS[OPTION_TYPE.CONFIG_FORMAT];
const ignoreConfig = stringOrArrayWithFallback(options?.ignoreConfig, OPTION_DEFAULTS[OPTION_TYPE.IGNORE_CONFIG]);
const ignoreDeprecatedRules = options?.ignoreDeprecatedRules ??
OPTION_DEFAULTS[OPTION_TYPE.IGNORE_DEPRECATED_RULES];
const initRuleDocs = options?.initRuleDocs ?? OPTION_DEFAULTS[OPTION_TYPE.INIT_RULE_DOCS];
const pathRuleDoc = options?.pathRuleDoc ?? OPTION_DEFAULTS[OPTION_TYPE.PATH_RULE_DOC];
const pathRuleList = stringOrArrayToArrayWithFallback(options?.pathRuleList, OPTION_DEFAULTS[OPTION_TYPE.PATH_RULE_LIST]);
const postprocess = options?.postprocess ?? OPTION_DEFAULTS[OPTION_TYPE.POSTPROCESS];
const ruleDocNotices = parseRuleDocNoticesOption(options?.ruleDocNotices);
const ruleDocSectionExclude = stringOrArrayWithFallback(options?.ruleDocSectionExclude, OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_SECTION_EXCLUDE]);
const ruleDocSectionInclude = stringOrArrayWithFallback(options?.ruleDocSectionInclude, OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_SECTION_INCLUDE]);
const ruleDocSectionOptions = options?.ruleDocSectionOptions ??
OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_SECTION_OPTIONS];
const ruleDocTitleFormat = options?.ruleDocTitleFormat ??
OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_TITLE_FORMAT];
const ruleListColumns = parseRuleListColumnsOption(options?.ruleListColumns);
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 = options?.urlRuleDoc ?? OPTION_DEFAULTS[OPTION_TYPE.URL_RULE_DOC];
// Gather normalized list of rules.
const ruleNamesAndRules = Object.entries(plugin.rules)
.map(([name, ruleModule]) => {
// Convert deprecated function-style rules to object-style rules so that we don't have to handle function-style rules everywhere throughout the codebase.
// @ts-expect-error -- this type unfortunately requires us to choose a `meta.type` even though the deprecated function-style rule won't have one.
const ruleModuleAsObject = typeof ruleModule === 'function'
? {
// Deprecated function-style rule don't support most of the properties that object-style rules support, so we'll just use the bare minimum.
meta: {
// @ts-expect-error -- type is missing for this property
schema: ruleModule.schema,
// @ts-expect-error -- type is missing for this property
deprecated: ruleModule.deprecated, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- type is missing for this property
},
create: ruleModule,
}
: ruleModule;
const tuple = [name, ruleModuleAsObject];
return tuple;
})
.filter(
// Filter out deprecated rules from being checked, displayed, or updated if the option is set.
([, rule]) => !ignoreDeprecatedRules || !rule.meta.deprecated)
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Update rule doc for each rule.
let initializedRuleDoc = false;
for (const [name, rule] of ruleNamesAndRules) {
const schema = rule.meta?.schema;
const description = rule.meta?.docs?.description;
const pathToDoc = join(path, replaceRulePlaceholder(pathRuleDoc, name));
const ruleHasOptions = hasOptions(schema);
if (!existsSync(pathToDoc)) {
if (!initRuleDocs) {
throw new Error(`Could not find rule doc (run with --init-rule-docs to create): ${relative(getPluginRoot(path), pathToDoc)}`);
}
// Determine content for fresh rule doc, including any mandatory sections.
// The rule doc header will be added later.
let newRuleDocContents = [
ruleDocSectionInclude.length > 0
? ruleDocSectionInclude
.map((title) => `## ${title}`)
.join(`${EOL}${EOL}`)
: undefined,
ruleHasOptions
? `## Options${EOL}${EOL}${BEGIN_RULE_OPTIONS_LIST_MARKER}${EOL}${END_RULE_OPTIONS_LIST_MARKER}`
: undefined,
]
.filter((section) => section !== undefined)
.join(`${EOL}${EOL}`);
if (newRuleDocContents !== '') {
newRuleDocContents = `${EOL}${newRuleDocContents}${EOL}`;
}
mkdirSync(dirname(pathToDoc), { recursive: true });
writeFileSync(pathToDoc, newRuleDocContents);
initializedRuleDoc = true;
}
// Regenerate the header (title/notices) of each rule doc.
const newHeaderLines = generateRuleHeaderLines(description, name, plugin, configsToRules, pluginPrefix, path, pathRuleDoc, configEmojis, configFormat, ignoreConfig, ruleDocNotices, ruleDocTitleFormat, urlConfigs, urlRuleDoc);
const contentsOld = readFileSync(pathToDoc).toString();
const contentsNew = await postprocess(updateRuleOptionsList(replaceOrCreateHeader(contentsOld, newHeaderLines, END_RULE_HEADER_MARKER), rule), resolve(pathToDoc));
if (check) {
if (contentsNew !== contentsOld) {
console.error(`Please run eslint-doc-generator. A rule doc is out-of-date: ${relative(getPluginRoot(path), pathToDoc)}`);
console.error(diff(contentsNew, contentsOld, { expand: false }));
process.exitCode = 1;
}
}
else {
writeFileSync(pathToDoc, contentsNew);
}
// Check for potential issues with the rule doc.
// Check for required sections.
for (const section of ruleDocSectionInclude) {
expectSectionHeaderOrFail(`\`${name}\` rule doc`, contentsNew, [section], true);
}
// Check for disallowed sections.
for (const section of ruleDocSectionExclude) {
expectSectionHeaderOrFail(`\`${name}\` rule doc`, contentsNew, [section], false);
}
if (ruleDocSectionOptions) {
// Options section.
expectSectionHeaderOrFail(`\`${name}\` rule doc`, contentsNew, ['Options', 'Config'], ruleHasOptions);
for (const { name: namedOption } of getAllNamedOptions(schema)) {
expectContentOrFail(`\`${name}\` rule doc`, 'rule option', contentsNew, namedOption, true); // Each rule option is mentioned.
}
}
}
if (initRuleDocs && !initializedRuleDoc) {
throw new Error('--init-rule-docs was enabled, but no rule doc file needed to be created.');
}
for (const pathRuleListItem of pathRuleList) {
// Find the exact filename.
const pathToFile = getPathWithExactFileNameCasing(join(path, pathRuleListItem));
if (!pathToFile || !existsSync(pathToFile)) {
throw new Error(`Could not find ${String(pathRuleList)} in ESLint plugin.`);
}
// Update the rules list in this file.
const fileContents = readFileSync(pathToFile, 'utf8');
const fileContentsNew = await postprocess(updateConfigsList(updateRulesList(ruleNamesAndRules, fileContents, plugin, configsToRules, pluginPrefix, pathRuleDoc, pathToFile, path, configEmojis, configFormat, ignoreConfig, ruleListColumns, ruleListSplit, urlConfigs, urlRuleDoc), plugin, configsToRules, pluginPrefix, configEmojis, configFormat, ignoreConfig), resolve(pathToFile));
if (check) {
if (fileContentsNew !== fileContents) {
console.error(`Please run eslint-doc-generator. The rules table in ${relative(getPluginRoot(path), pathToFile)} is out-of-date.`);
console.error(diff(fileContentsNew, fileContents, { expand: false }));
process.exitCode = 1;
}
}
else {
writeFileSync(pathToFile, fileContentsNew, 'utf8');
}
}
}