UNPKG

eslint-doc-generator

Version:

Automatic documentation generator for ESLint plugins and rules.

268 lines (267 loc) 14.5 kB
import { EOL } from 'node:os'; import { BEGIN_RULE_LIST_MARKER, END_RULE_LIST_MARKER, } from './comment-markers.js'; import { EMOJI_DEPRECATED, EMOJI_FIXABLE, EMOJI_HAS_SUGGESTIONS, EMOJI_OPTIONS, EMOJI_REQUIRES_TYPE_CHECKING, } from './emojis.js'; import { getEmojisForConfigsSettingRuleToSeverity } from './plugin-configs.js'; import { getColumns, COLUMN_HEADER } from './rule-list-columns.js'; 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, SEVERITY_TYPE, } from './types.js'; import { markdownTable } from 'markdown-table'; import { EMOJIS_TYPE } from './rule-type.js'; import { hasOptions } from './rule-options.js'; import { getLinkToRule } from './rule-link.js'; import { capitalizeOnlyFirstLetter, sanitizeMarkdownTable } 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) { return isBooleanable(value) && boolean(value); } function isBooleanableFalse(value) { return isBooleanable(value) && !boolean(value); } function isConsideredFalse(value) { return (value === undefined || value === null || value === '' || isBooleanableFalse(value)); } function isBadge(emojiOrBadge) { return emojiOrBadge.startsWith('![badge-'); } function getPropertyFromRule(plugin, ruleName, property) { /* istanbul ignore next -- this shouldn't happen */ if (!plugin.rules) { throw new Error('Should not be attempting to get a property from a rule when there are no rules.'); } const rule = plugin.rules[ruleName]; return getProperty(rule, property); // TODO: Incorrectly typed as undefined. This could be any type, not just undefined (https://github.com/sindresorhus/dot-prop/issues/95). } function getConfigurationColumnValueForRule(ruleName, configsToRules, pluginPrefix, configEmojis, ignoreConfig, severityType) { const configsToRulesWithoutIgnored = Object.fromEntries(Object.entries(configsToRules).filter(([configName]) => !ignoreConfig?.includes(configName))); // Collect the emojis/badges for the configs that set the rule to this severity level. const emojisAndBadges = getEmojisForConfigsSettingRuleToSeverity(ruleName, configsToRulesWithoutIgnored, pluginPrefix, configEmojis, severityType); const emojis = emojisAndBadges.filter((emojiOrBadge) => !isBadge(emojiOrBadge)); const badges = emojisAndBadges.filter((emojiOrBadge) => isBadge(emojiOrBadge)); // Sort emojis before badges for aesthetics. return [...emojis, ...badges].join(' '); } function buildRuleRow(ruleName, rule, columnsEnabled, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc) { const columns = { // Alphabetical order. [COLUMN_TYPE.CONFIGS_ERROR]: getConfigurationColumnValueForRule(ruleName, configsToRules, pluginPrefix, configEmojis, ignoreConfig, SEVERITY_TYPE.error), [COLUMN_TYPE.CONFIGS_OFF]: getConfigurationColumnValueForRule(ruleName, configsToRules, pluginPrefix, configEmojis, ignoreConfig, SEVERITY_TYPE.off), [COLUMN_TYPE.CONFIGS_WARN]: getConfigurationColumnValueForRule(ruleName, configsToRules, pluginPrefix, configEmojis, ignoreConfig, SEVERITY_TYPE.warn), [COLUMN_TYPE.DEPRECATED]: rule.meta?.deprecated ? EMOJI_DEPRECATED : '', [COLUMN_TYPE.DESCRIPTION]: rule.meta?.docs?.description || '', [COLUMN_TYPE.FIXABLE]: rule.meta?.fixable ? EMOJI_FIXABLE : '', [COLUMN_TYPE.FIXABLE_AND_HAS_SUGGESTIONS]: `${rule.meta?.fixable ? EMOJI_FIXABLE : ''}${rule.meta?.hasSuggestions ? EMOJI_HAS_SUGGESTIONS : ''}`, [COLUMN_TYPE.HAS_SUGGESTIONS]: rule.meta?.hasSuggestions ? EMOJI_HAS_SUGGESTIONS : '', [COLUMN_TYPE.NAME]() { return getLinkToRule(ruleName, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, false, false, urlRuleDoc); }, [COLUMN_TYPE.OPTIONS]: hasOptions(rule.meta?.schema) ? EMOJI_OPTIONS : '', [COLUMN_TYPE.REQUIRES_TYPE_CHECKING]: rule.meta?.docs?.requiresTypeChecking ? EMOJI_REQUIRES_TYPE_CHECKING : '', [COLUMN_TYPE.TYPE]: rule.meta?.type ? EMOJIS_TYPE[rule.meta?.type] || '' : '', }; // List columns using the ordering and presence of columns specified in columnsEnabled. return Object.keys(columnsEnabled).flatMap((column) => { const columnValueOrFn = columns[column]; return columnsEnabled[column] ? [ typeof columnValueOrFn === 'function' ? columnValueOrFn() : columnValueOrFn, ] : []; }); } function generateRulesListMarkdown(ruleNamesAndRules, columns, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc) { const listHeaderRow = Object.entries(columns).flatMap(([columnType, enabled]) => { if (!enabled) { return []; } const headerStrOrFn = COLUMN_HEADER[columnType]; return [ typeof headerStrOrFn === 'function' ? headerStrOrFn({ ruleNamesAndRules }) : headerStrOrFn, ]; }); return markdownTable(sanitizeMarkdownTable([ listHeaderRow, ...ruleNamesAndRules.map(([name, rule]) => buildRuleRow(name, rule, columns, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc)), ]), { align: 'l' } // Left-align headers. ); } function generateRuleListMarkdownForRulesAndHeaders(rulesAndHeaders, headerLevel, columns, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc) { const parts = []; for (const { title, rules } of rulesAndHeaders) { if (title) { parts.push(`${'#'.repeat(headerLevel)} ${title}`); } parts.push(generateRulesListMarkdown(rules, columns, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc)); } return parts.join(`${EOL}${EOL}`); } /** * Get the pairs of rules and headers for a given split property. */ function getRulesAndHeadersForSplit(ruleNamesAndRules, plugin, ruleListSplit) { const rulesAndHeaders = []; // Initially, all rules are unused. let unusedRules = ruleNamesAndRules; // Loop through each split property. for (const ruleListSplitItem of ruleListSplit) { // Store the rules and headers for this split property. const rulesAndHeadersForThisSplit = []; // Check what possible values this split property can have. const valuesForThisPropertyFromUnusedRules = [ ...new Set(unusedRules.map(([name]) => getPropertyFromRule(plugin, name, ruleListSplitItem))).values(), ]; const valuesForThisPropertyFromAllRules = [ ...new Set(ruleNamesAndRules.map(([name]) => getPropertyFromRule(plugin, name, ruleListSplitItem))).values(), ]; // Throw an exception if there are no possible rules with this split property. if (valuesForThisPropertyFromAllRules.length === 1 && isConsideredFalse(valuesForThisPropertyFromAllRules[0])) { throw new Error(`No rules found with --rule-list-split property "${ruleListSplitItem}".`); } // For each possible non-disabled value, show a header and list of corresponding rules. const valuesNotFalseAndNotTrue = valuesForThisPropertyFromUnusedRules.filter((val) => !isConsideredFalse(val) && !isBooleanableTrue(val)); const valuesTrue = valuesForThisPropertyFromUnusedRules.filter((val) => isBooleanableTrue(val)); const valuesNew = [ ...valuesNotFalseAndNotTrue, ...(valuesTrue.length > 0 ? [true] : []), // If there are multiple true values, combine them all into one. ]; for (const value of valuesNew.sort((a, b) => String(a).toLowerCase().localeCompare(String(b).toLowerCase()))) { // Rules with the property set to this value. const rulesForThisValue = unusedRules.filter(([name]) => { const property = getPropertyFromRule(plugin, name, ruleListSplitItem); return (property === value || (value === true && isBooleanableTrue(property))); }); // Turn ruleListSplit into a title. // E.g. meta.docs.requiresTypeChecking to "Requires Type Checking". const ruleListSplitParts = ruleListSplitItem.split('.'); const ruleListSplitFinalPart = ruleListSplitParts[ruleListSplitParts.length - 1]; const ruleListSplitTitle = noCase(ruleListSplitFinalPart, { transform: (str) => capitalizeOnlyFirstLetter(str), }); // Add a list for the rules with property set to this value. rulesAndHeadersForThisSplit.push({ title: String(isBooleanableTrue(value) ? ruleListSplitTitle : value), rules: rulesForThisValue, }); // Remove these rules from the unused rules. unusedRules = unusedRules.filter((rule) => !rulesForThisValue.includes(rule)); } // Add the rules and headers for this split property to the beginning of the list of all rules and headers. rulesAndHeaders.unshift(...rulesAndHeadersForThisSplit); } // All remaining unused rules go at the beginning. if (unusedRules.length > 0) { rulesAndHeaders.unshift({ rules: unusedRules }); } return rulesAndHeaders; } export function updateRulesList(ruleNamesAndRules, markdown, plugin, configsToRules, pluginPrefix, pathRuleDoc, pathRuleList, pathPlugin, configEmojis, configFormat, ignoreConfig, ruleListColumns, ruleListSplit, urlConfigs, urlRuleDoc) { let listStartIndex = markdown.indexOf(BEGIN_RULE_LIST_MARKER); let listEndIndex = markdown.indexOf(END_RULE_LIST_MARKER); // Find the best possible section to insert the rules list into if the markers are missing. const rulesSectionHeader = findSectionHeader(markdown, 'rules'); const rulesSectionIndex = rulesSectionHeader ? markdown.indexOf(rulesSectionHeader) : -1; if (listStartIndex === -1 && listEndIndex === -1 && rulesSectionHeader && rulesSectionIndex !== -1) { // If the markers are missing, we'll try to find the rules section and insert the list there. listStartIndex = rulesSectionIndex + rulesSectionHeader.length; listEndIndex = rulesSectionIndex + rulesSectionHeader.length - 1; } else { // Account for length of pre-existing marker. listEndIndex += END_RULE_LIST_MARKER.length; } if (listStartIndex === -1 || listEndIndex === -1) { throw new Error(`${relative(getPluginRoot(pathPlugin), pathRuleList)} is missing rules list markers: ${BEGIN_RULE_LIST_MARKER}${END_RULE_LIST_MARKER}`); } const preList = markdown.slice(0, Math.max(0, listStartIndex)); const postList = markdown.slice(Math.max(0, listEndIndex)); // Determine what header level to use for sub-lists based on the last seen header level. const preListFinalHeaderLevel = findFinalHeaderLevel(preList); const ruleListSplitHeaderLevel = preListFinalHeaderLevel ? preListFinalHeaderLevel + 1 : 1; // Determine columns to include in the rules list. const columns = getColumns(plugin, ruleNamesAndRules, configsToRules, ruleListColumns, pluginPrefix, ignoreConfig); // New legend. const legend = generateLegend(columns, plugin, configsToRules, configEmojis, configFormat, pluginPrefix, ignoreConfig, urlConfigs); // Determine the pairs of rules and headers based on any split property. const rulesAndHeaders = []; 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' }, { 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)); } else { rulesAndHeaders.push({ rules: ruleNamesAndRules }); } // New rule list. const list = generateRuleListMarkdownForRulesAndHeaders(rulesAndHeaders, ruleListSplitHeaderLevel, columns, configsToRules, plugin, pluginPrefix, pathPlugin, pathRuleDoc, pathRuleList, configEmojis, ignoreConfig, urlRuleDoc); const newContent = `${legend ? `${legend}${EOL}${EOL}` : ''}${list}`; return `${preList}${BEGIN_RULE_LIST_MARKER}${EOL}${EOL}${newContent}${EOL}${EOL}${END_RULE_LIST_MARKER}${postList}`; }