UNPKG

eslint-doc-generator

Version:

Automatic documentation generator for ESLint plugins and rules.

289 lines (288 loc) 14.6 kB
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'); }