eslint-doc-generator
Version:
Automatic documentation generator for ESLint plugins and rules.
289 lines (288 loc) • 14.6 kB
JavaScript
import { join } from 'node:path';
import { END_RULE_HEADER_MARKER, formatComment } from './comment-markers.js';
import { EMOJI_DEPRECATED, EMOJI_DESCRIPTION, EMOJI_FIXABLE, EMOJI_HAS_SUGGESTIONS, EMOJI_REQUIRES_TYPE_CHECKING, EMOJI_CONFIG_FROM_SEVERITY, EMOJI_OPTIONS, } from './emojis.js';
import { findConfigEmoji, getConfigsForRule } from './plugin-configs.js';
import { getPluginRoot } from './package-json.js';
import { SEVERITY_TYPE, NOTICE_TYPE } from './types.js';
import { RULE_TYPE_MESSAGES_NOTICES } from './rule-type.js';
import { hasOptions } from './rule-options.js';
import { getLinkToRule, getMarkdownLink, replaceRulePlaceholder, } from './rule-link.js';
import { toSentenceCase, addTrailingPeriod } from './string.js';
import { configNameToDisplay } from './config-format.js';
import { makeRuleDocTitle } from './rule-doc-title.js';
function severityToTerminology(severity) {
switch (severity) {
case SEVERITY_TYPE.error: {
return 'is enabled';
}
case SEVERITY_TYPE.warn: {
return '_warns_';
}
case SEVERITY_TYPE.off: {
return 'is _disabled_';
}
/* istanbul ignore next -- this shouldn't happen */
default: {
throw new Error(`Unknown severity: ${String(severity)}`);
}
}
}
function configsToNoticeSentence(context, configs, severity, configsLinkOrWord, configLinkOrWord) {
// Create CSV list of configs with their emojis.
const csv = configs
.map((config) => {
const emoji = findConfigEmoji(context, config);
return `${emoji ? `${emoji} ` : ''}\`${configNameToDisplay(context, config)}\``;
})
.join(', ');
const term = severityToTerminology(severity);
const sentence = configs.length > 1
? `This rule ${term} in the following ${configsLinkOrWord}: ${csv}.`
: configs.length === 1
? `This rule ${term} in the ${csv} ${configLinkOrWord}.`
: undefined;
return sentence;
}
/**
* Build the "It was replaced by ..." sentence from DeprecatedInfo.replacedBy entries.
* Entries without a rule name are silently skipped.
*/
function replacedByToNoticeSentence(context, deprecated, pathCurrentPage) {
if (!deprecated.replacedBy || deprecated.replacedBy.length === 0) {
return undefined;
}
function formatReplacedByEntry(info) {
if (!info.rule?.name) {
return undefined;
}
// Prefer an explicit URL from the plugin maintainer; fall back to auto-generated link.
const replacementRule = info.rule.url
? getMarkdownLink(info.rule.name, true, info.rule.url)
: getLinkToRule(context, info.rule.name, pathCurrentPage, true, true);
// Only show "from <plugin>" for external plugins (not ESLint core).
const externalPlugin = info.plugin?.name && info.plugin.name !== 'eslint'
? ` from ${getMarkdownLink(info.plugin.name, false, info.plugin.url)}`
: '';
return `${replacementRule}${externalPlugin}${info.message ? ` (${info.message})` : ''}${info.url ? ` (${getMarkdownLink('read more', false, info.url)})` : ''}`;
}
// Filter first, then apply conjunction, so "and" targets the correct item.
const parts = deprecated.replacedBy
.map((item) => formatReplacedByEntry(item))
.filter((part) => part !== undefined);
if (parts.length === 0) {
return undefined;
}
if (parts.length > 1) {
const lastIndex = parts.length - 1;
// Safe: lastIndex is guaranteed valid since parts.length > 1.
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
parts[lastIndex] = `and ${parts[lastIndex]}`;
}
return `It was replaced by ${parts.join(', ')}.`;
}
// A few individual notices declared here just so they can be reused in multiple notices.
const NOTICE_FIXABLE = `${EMOJI_FIXABLE} This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).`;
const NOTICE_HAS_SUGGESTIONS = `${EMOJI_HAS_SUGGESTIONS} This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).`;
/**
* An object containing the text for each notice type (as a string or function to generate the
* string).
*/
const RULE_NOTICES = {
// Configs notice varies based on whether the rule is configured in one or more configs.
[NOTICE_TYPE.CONFIGS]: ({ context, configsError, configsWarn, configsOff, }) => {
const { options } = context;
const { urlConfigs } = options;
// Add link to configs documentation if provided.
const configsLinkOrWord = urlConfigs
? `[configs](${urlConfigs})`
: 'configs';
const configLinkOrWord = urlConfigs ? `[config](${urlConfigs})` : 'config';
/* istanbul ignore next -- this shouldn't happen */
if (configsError.length === 0 &&
configsWarn.length === 0 &&
configsOff.length === 0) {
throw new Error('Should not be trying to display config notice for rule not configured in any configs.');
}
// Use the emoji(s) for the severity levels this rule is set to in various configs.
const emojis = [];
if (configsError.length > 0) {
emojis.push(EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.error]);
}
if (configsWarn.length > 0) {
emojis.push(EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.warn]);
}
if (configsOff.length > 0) {
emojis.push(EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.off]);
}
const sentences = [
configsToNoticeSentence(context, configsError, SEVERITY_TYPE.error, configsLinkOrWord, configLinkOrWord),
configsToNoticeSentence(context, configsWarn, SEVERITY_TYPE.warn, configsLinkOrWord, configLinkOrWord),
configsToNoticeSentence(context, configsOff, SEVERITY_TYPE.off, configsLinkOrWord, configLinkOrWord),
]
.filter(Boolean)
.join(' ');
return `${emojis.join('')} ${sentences}`;
},
[NOTICE_TYPE.DEPRECATED]: ({ context, deprecated, replacedBy, ruleName }) => {
const { options, path } = context;
const { pathRuleDoc } = options;
// Derive the path of the *deprecated* rule's doc page (not the replacement's) so that
// relative() in getLinkToRule produces correct links from this page to replacements.
const pathCurrentPage = join(getPluginRoot(path), replaceRulePlaceholder(pathRuleDoc, ruleName));
// New DeprecatedInfo object format (ESLint >=9.21.0) vs legacy boolean format.
if (typeof deprecated === 'object') {
function formatVersion(version) {
return version.startsWith('v') ? version : `v${version}`;
}
const sentenceDeprecated = `${EMOJI_DEPRECATED} This rule ${deprecated.deprecatedSince ? 'has been' : 'is'} ${deprecated.url ? `[deprecated](${deprecated.url})` : 'deprecated'}${deprecated.deprecatedSince
? ` since ${formatVersion(deprecated.deprecatedSince)}`
: ''}${deprecated.availableUntil
? ` and will be available until ${formatVersion(deprecated.availableUntil)}`
: ''}.`;
const sentenceReplacedBy = replacedByToNoticeSentence(context, deprecated, pathCurrentPage);
const deprecatedMessage = deprecated.message
? addTrailingPeriod(deprecated.message)
: undefined;
return [sentenceDeprecated, sentenceReplacedBy, deprecatedMessage]
.filter(Boolean)
.join(' ');
}
const replacementRuleList = (replacedBy ?? []).map((replacementRuleName) => {
return getLinkToRule(context, replacementRuleName, pathCurrentPage, true, true);
});
return `${EMOJI_DEPRECATED} This rule is deprecated.${replacedBy && replacedBy.length > 0
? ` It was replaced by ${replacementRuleList.join(', ')}.`
: ''}`;
},
[NOTICE_TYPE.DESCRIPTION]: ({ description }) => {
/* istanbul ignore next -- this shouldn't happen */
if (!description) {
throw new Error('Should not be trying to display description notice for rule with no description.');
}
// Return the description like a normal body sentence.
return `${EMOJI_DESCRIPTION} ${addTrailingPeriod(toSentenceCase(description))}`;
},
[NOTICE_TYPE.TYPE]: ({ type }) => {
/* istanbul ignore next -- this shouldn't happen */
if (!type) {
throw new Error('Should not be trying to display type notice for rule with no type.');
}
return RULE_TYPE_MESSAGES_NOTICES[type];
},
// Fixable/suggestions.
[NOTICE_TYPE.FIXABLE]: NOTICE_FIXABLE,
[NOTICE_TYPE.FIXABLE_AND_HAS_SUGGESTIONS]: ({ fixable, hasSuggestions }) => {
if (fixable && hasSuggestions) {
return `${EMOJI_FIXABLE}${EMOJI_HAS_SUGGESTIONS} This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).`;
}
if (fixable) {
return NOTICE_FIXABLE;
}
/* istanbul ignore else -- V8 branch coverage doesn't detect this branch is tested */
if (hasSuggestions) {
return NOTICE_HAS_SUGGESTIONS;
}
/* istanbul ignore next -- this shouldn't happen */
throw new Error('Should not be trying to display fixable and has suggestions column when neither apply.');
},
[NOTICE_TYPE.HAS_SUGGESTIONS]: NOTICE_HAS_SUGGESTIONS,
[NOTICE_TYPE.OPTIONS]: `${EMOJI_OPTIONS} This rule is configurable.`,
[NOTICE_TYPE.REQUIRES_TYPE_CHECKING]: `${EMOJI_REQUIRES_TYPE_CHECKING} This rule requires [type information](https://typescript-eslint.io/linting/typed-linting).`,
};
/**
* Determine which notices should and should not be included at the top of a rule doc.
*/
function getNoticesForRule(context, rule, configsError, configsWarn, configsOff) {
const { options } = context;
const { ruleDocNotices } = options;
const notices = {
// Alphabetical order.
[NOTICE_TYPE.CONFIGS]: configsError.length > 0 ||
configsWarn.length > 0 ||
configsOff.length > 0,
[NOTICE_TYPE.DEPRECATED]: Boolean(rule.meta?.deprecated),
[NOTICE_TYPE.DESCRIPTION]: Boolean(rule.meta?.docs?.description) || false,
// Fixable/suggestions.
[NOTICE_TYPE.FIXABLE]: Boolean(rule.meta?.fixable),
[NOTICE_TYPE.FIXABLE_AND_HAS_SUGGESTIONS]: Boolean(rule.meta?.fixable) || Boolean(rule.meta?.hasSuggestions),
[NOTICE_TYPE.HAS_SUGGESTIONS]: Boolean(rule.meta?.hasSuggestions),
[NOTICE_TYPE.OPTIONS]: hasOptions(rule.meta?.schema),
[NOTICE_TYPE.REQUIRES_TYPE_CHECKING]:
// @ts-expect-error -- TODO: requiresTypeChecking type not present
rule.meta?.docs?.requiresTypeChecking || false,
[NOTICE_TYPE.TYPE]: Boolean(rule.meta?.type),
};
// Recreate object using the ordering and presence of columns specified in ruleDocNotices.
return Object.fromEntries(ruleDocNotices.map((type) => [type, notices[type]]));
}
/**
* Get the lines for the notice section at the top of a rule doc.
*/
function getRuleNoticeLines(context, ruleName) {
const { path, options, plugin } = context;
const { ignoreConfig } = options;
const lines = [];
const rule = plugin.rules?.[ruleName];
/* istanbul ignore next */
if (!rule) {
// This is only to please TypeScript. We should always have a rule when this function is called.
throw new Error('Rule not found');
}
if (typeof rule !== 'object') {
// We don't support the deprecated, function-style rule format as there's not much information we can extract from it.
// https://eslint.org/docs/latest/developer-guide/working-with-rules-deprecated
return [];
}
const configsError = getConfigsForRule(context, ruleName, SEVERITY_TYPE.error).filter((configName) => !ignoreConfig.includes(configName));
const configsWarn = getConfigsForRule(context, ruleName, SEVERITY_TYPE.warn).filter((configName) => !ignoreConfig.includes(configName));
const configsOff = getConfigsForRule(context, ruleName, SEVERITY_TYPE.off).filter((configName) => !ignoreConfig.includes(configName));
const notices = getNoticesForRule(context, rule, configsError, configsWarn, configsOff);
let noticeType;
for (noticeType in notices) {
const expected = notices[noticeType];
if (!expected) {
// This notice should not be included.
continue;
}
lines.push(''); // Blank line first.
const ruleNoticeStrOrFn = RULE_NOTICES[noticeType];
/* istanbul ignore next -- this won't happen since we would have already bailed out earlier. */
if (!ruleNoticeStrOrFn) {
// No notice for this column.
continue;
}
const description = rule.meta?.docs?.description;
lines.push(typeof ruleNoticeStrOrFn === 'function'
? ruleNoticeStrOrFn({
context,
ruleName,
configsError,
configsWarn,
configsOff,
...(description !== undefined && { description }),
fixable: Boolean(rule.meta?.fixable),
hasSuggestions: Boolean(rule.meta?.hasSuggestions),
deprecated: rule.meta?.deprecated,
// eslint-disable-next-line @typescript-eslint/no-deprecated
replacedBy: rule.meta?.replacedBy,
path,
type: rule.meta?.type,
})
: ruleNoticeStrOrFn);
}
return lines;
}
/**
* Generate a rule doc header for a particular rule.
* @returns new header including marker
*/
export function generateRuleHeaderLines(context, description, name, isMdx) {
const { options: { framework }, } = context;
const title = makeRuleDocTitle(context, name, description);
return [
...(framework === 'starlight' ? [] : [`# ${title}`]),
...getRuleNoticeLines(context, name),
'',
formatComment(END_RULE_HEADER_MARKER, isMdx),
].join('\n');
}