UNPKG

@redpanda-data/docs-extensions-and-macros

Version:

Antora extensions and macros developed for Redpanda documentation.

762 lines (685 loc) 26.2 kB
'use strict' /** * Diff generator for rpk command documentation * Compares two rpk command trees and generates a detailed diff report */ /** * Deep equality check that handles object key ordering * Objects with same keys in different order are considered equal * @param {*} a - First value * @param {*} b - Second value * @returns {boolean} True if values are deeply equal */ function deepEqual(a, b) { // Handle primitives and null/undefined if (a === b) return true if (a == null || b == null) return a === b if (typeof a !== typeof b) return false // Handle arrays if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false return a.every((item, i) => deepEqual(item, b[i])) } // Handle objects if (typeof a === 'object') { const keysA = Object.keys(a).sort() const keysB = Object.keys(b).sort() if (keysA.length !== keysB.length) return false if (!keysA.every((key, i) => key === keysB[i])) return false return keysA.every(key => deepEqual(a[key], b[key])) } // Primitives that aren't strictly equal return false } /** * Flatten command tree into a map of path -> command * @param {Object} node - Tree node * @param {string} parentPath - Parent command path * @returns {Map} Map of command path to command object */ function flattenToMap(node, parentPath = '') { const result = new Map() // Defensive check for missing or invalid node if (!node || typeof node !== 'object') { return result } // Validate node.name exists and is a non-empty string const nodeName = node.name if (typeof nodeName !== 'string' || nodeName.trim() === '') { // Skip nodes without valid names but still process children if (node.commands && Array.isArray(node.commands)) { for (const child of node.commands) { const childMap = flattenToMap(child, parentPath) for (const [path, cmd] of childMap) { result.set(path, cmd) } } } return result } const currentPath = parentPath ? `${parentPath} ${nodeName}` : nodeName result.set(currentPath, node) if (node.commands && Array.isArray(node.commands)) { for (const child of node.commands) { const childMap = flattenToMap(child, currentPath) for (const [path, cmd] of childMap) { result.set(path, cmd) } } } return result } /** * Get flags as a map of name -> flag * @param {Object} command - Command object * @returns {Map} Map of flag name to flag object */ function getFlagsMap(command) { const flags = command.flags || [] return new Map(flags.map(f => [f.name, f])) } /** * Compare two flags and find differences * @param {Object} oldFlag - Old flag object * @param {Object} newFlag - New flag object * @returns {Object|null} Differences or null if identical */ function compareFlags(oldFlag, newFlag) { // Defensive checks for missing flags if (!oldFlag || !newFlag) { return null } const changes = {} if (oldFlag.type !== newFlag.type) { changes.type = { old: oldFlag.type, new: newFlag.type } } if (!deepEqual(oldFlag.default, newFlag.default)) { changes.default = { old: oldFlag.default, new: newFlag.default } } if (oldFlag.description !== newFlag.description) { changes.description = { old: oldFlag.description, new: newFlag.description } } if (oldFlag.required !== newFlag.required) { changes.required = { old: oldFlag.required, new: newFlag.required } } return Object.keys(changes).length > 0 ? changes : null } /** * Generate diff between two rpk command trees * @param {Object} oldTree - Old command tree * @param {Object} newTree - New command tree * @param {Object} options - Options * @returns {Object} Diff report */ function generateRpkDiff(oldTree, newTree, options = {}) { const { oldVersion = 'old', newVersion = 'new', // deprecated_commands maps from the snapshots (path -> metadata), as // produced by scan-deprecated-commands.js oldDeprecatedCommands = {}, newDeprecatedCommands = {} } = options const oldCommands = flattenToMap(oldTree) const newCommands = flattenToMap(newTree) const oldPaths = new Set(oldCommands.keys()) const newPaths = new Set(newCommands.keys()) // Find new commands const newCommandPaths = [...newPaths].filter(p => !oldPaths.has(p)) const newCommandsDetails = newCommandPaths.map(path => { const cmd = newCommands.get(path) return { path, name: cmd.name, description: cmd.description || '', introducedInVersion: newVersion } }) // Newly deprecated commands: in the new snapshot's deprecation map but not // the old one. Detected by source scanning, because deprecated commands // that are also hidden never appear in --print-tree output. const newlyDeprecatedRaw = Object.entries(newDeprecatedCommands) .filter(([path]) => !oldDeprecatedCommands[path]) .map(([path, info]) => ({ path, message: info.deprecatedMessage || '', replacement: info.replacement || '', hidden: info.hidden === true || /Hidden:\s*true/.test(info._note || ''), deprecatedInVersion: newVersion })) // Roll deprecated subcommands up into their nearest deprecated ancestor: // one entry for `rpk redpanda admin` with its subcommands listed reads // better than a dozen sibling entries const newlyDeprecatedDetails = newlyDeprecatedRaw.filter(d => !newlyDeprecatedRaw.some(other => other !== d && d.path.startsWith(other.path + ' '))) for (const child of newlyDeprecatedRaw) { if (newlyDeprecatedDetails.includes(child)) continue const root = newlyDeprecatedDetails.find(r => child.path.startsWith(r.path + ' ')) if (root) { root.affectedSubcommands = root.affectedSubcommands || [] if (!root.affectedSubcommands.includes(child.path)) { root.affectedSubcommands.push(child.path) } } } // Find removed commands, then separate genuine removals from commands that // disappeared from the tree because they (or an ancestor) were deprecated // and hidden — those still work as aliases and should be reported as // deprecations, not removals. const deprecatedRoots = newlyDeprecatedDetails.map(d => d.path) const isUnderNewlyDeprecated = (p) => deprecatedRoots.some(root => p === root || p.startsWith(root + ' ')) const removedCommandPaths = [...oldPaths].filter(p => !newPaths.has(p)) const removedCommandsDetails = [] for (const path of removedCommandPaths) { if (isUnderNewlyDeprecated(path)) { const root = deprecatedRoots.find(r => path === r || path.startsWith(r + ' ')) const entry = newlyDeprecatedDetails.find(d => d.path === root) if (path !== root) { entry.affectedSubcommands = entry.affectedSubcommands || [] if (!entry.affectedSubcommands.includes(path)) { entry.affectedSubcommands.push(path) } } continue } const cmd = oldCommands.get(path) removedCommandsDetails.push({ path, name: cmd.name, description: cmd.description || '', removedInVersion: newVersion }) } for (const dep of newlyDeprecatedDetails) { if (dep.affectedSubcommands) dep.affectedSubcommands.sort() } // Find flag changes in existing commands const newFlags = [] const removedFlags = [] const changedDefaults = [] const changedFlagTypes = [] const changedFlagRequirements = [] const changedFlagDescriptions = [] const descriptionChanges = [] const flagDataBackfilled = [] // Baseline gap detection: plugin subtrees captured before flag extraction // existed record no flags on any of their own commands (the install/ // uninstall/upgrade shims are rpk-native and do carry flags). Flag // "additions" inside such a group are newly captured documentation, not // newly introduced flags, and stamping them "New in <version>" would // mislabel long-standing flags. Core groups have baseline flag data, so a // zero-flag command there that gains a flag is a genuine addition. const groupsWithBaselineFlagData = new Set() for (const [path, cmd] of oldCommands) { const parts = path.split(' ') if (parts.length < 2) continue // "rpk <plugin> install|uninstall|upgrade" shims are rpk-native if (parts.length === 3 && /^(install|uninstall|upgrade)$/.test(parts[2])) continue if ((cmd.flags || []).length > 0) groupsWithBaselineFlagData.add(parts[1]) } for (const path of newPaths) { if (!oldPaths.has(path)) continue // Skip new commands const oldCmd = oldCommands.get(path) const newCmd = newCommands.get(path) const oldFlags = getFlagsMap(oldCmd) const newFlagsMap = getFlagsMap(newCmd) const topLevel = path.split(' ')[1] const isFlagBackfill = topLevel !== undefined && !groupsWithBaselineFlagData.has(topLevel) && oldFlags.size === 0 && newFlagsMap.size > 0 if (isFlagBackfill) { flagDataBackfilled.push({ commandPath: path, flagCount: newFlagsMap.size }) } // Find new flags (skipped entirely for backfilled commands) for (const [flagName, flag] of isFlagBackfill ? [] : newFlagsMap) { if (!oldFlags.has(flagName)) { newFlags.push({ commandPath: path, flagName, type: flag.type, description: flag.description, default: flag.default, introducedInVersion: newVersion }) } } // Find removed flags for (const [flagName, flag] of oldFlags) { if (!newFlagsMap.has(flagName)) { removedFlags.push({ commandPath: path, flagName, type: flag.type, description: flag.description, removedInVersion: newVersion }) } } // Find changed flags for (const [flagName, newFlag] of newFlagsMap) { if (!oldFlags.has(flagName)) continue const oldFlag = oldFlags.get(flagName) const changes = compareFlags(oldFlag, newFlag) if (changes) { if (changes.default) { changedDefaults.push({ commandPath: path, flagName, oldDefault: changes.default.old, newDefault: changes.default.new }) } if (changes.type) { changedFlagTypes.push({ commandPath: path, flagName, oldType: changes.type.old, newType: changes.type.new }) } if (changes.required) { changedFlagRequirements.push({ commandPath: path, flagName, oldRequired: changes.required.old, newRequired: changes.required.new }) } if (changes.description) { changedFlagDescriptions.push({ commandPath: path, flagName, oldDescription: changes.description.old, newDescription: changes.description.new }) } } } // Check command description changes if (oldCmd.description !== newCmd.description) { descriptionChanges.push({ path, type: 'command', oldDescription: oldCmd.description, newDescription: newCmd.description }) } } return { comparison: { oldVersion, newVersion, timestamp: new Date().toISOString() }, summary: { newCommands: newCommandsDetails.length, newlyDeprecatedCommands: newlyDeprecatedDetails.length, removedCommands: removedCommandsDetails.length, newFlags: newFlags.length, removedFlags: removedFlags.length, changedDefaults: changedDefaults.length, changedFlagTypes: changedFlagTypes.length, changedFlagRequirements: changedFlagRequirements.length, changedFlagDescriptions: changedFlagDescriptions.length, descriptionChanges: descriptionChanges.length, flagDataBackfilled: flagDataBackfilled.length }, details: { newCommands: newCommandsDetails, newlyDeprecatedCommands: newlyDeprecatedDetails, removedCommands: removedCommandsDetails, newFlags, removedFlags, changedDefaults, changedFlagTypes, changedFlagRequirements, changedFlagDescriptions, descriptionChanges, flagDataBackfilled } } } /** * Print a human-readable diff report to console * @param {Object} diff - Diff object from generateRpkDiff */ function printDiffReport(diff) { console.log('\n=== rpk Documentation Diff Report ===') console.log(`Comparing ${diff.comparison.oldVersion}${diff.comparison.newVersion}`) console.log(`Generated: ${diff.comparison.timestamp}\n`) console.log('Summary:') console.log(` New commands: ${diff.summary.newCommands}`) console.log(` Deprecated commands: ${diff.summary.newlyDeprecatedCommands || 0}`) console.log(` Removed commands: ${diff.summary.removedCommands}`) console.log(` New flags: ${diff.summary.newFlags}`) if (diff.summary.flagDataBackfilled) { console.log(` Flag documentation backfilled: ${diff.summary.flagDataBackfilled} command(s) (baseline had no flag data; not reported as new flags)`) } console.log(` Removed flags: ${diff.summary.removedFlags}`) console.log(` Changed defaults: ${diff.summary.changedDefaults}`) console.log(` Changed flag types: ${diff.summary.changedFlagTypes || 0}`) console.log(` Changed flag requirements: ${diff.summary.changedFlagRequirements || 0}`) console.log(` Changed flag descriptions: ${diff.summary.changedFlagDescriptions || 0}`) console.log(` Command description changes: ${diff.summary.descriptionChanges}`) if (diff.details.newCommands.length > 0) { console.log('\nNew Commands:') for (const cmd of diff.details.newCommands) { console.log(` + ${cmd.path}`) if (cmd.description) { const shortDesc = cmd.description.split('\n')[0].substring(0, 60) console.log(` ${shortDesc}${cmd.description.length > 60 ? '...' : ''}`) } } } if ((diff.details.newlyDeprecatedCommands || []).length > 0) { console.log('\nDeprecated Commands (still work, hidden or discouraged):') for (const cmd of diff.details.newlyDeprecatedCommands) { console.log(` ⚠ ${cmd.path}${cmd.hidden ? ' (hidden)' : ''}`) if (cmd.message) console.log(` ${cmd.message}`) if ((cmd.affectedSubcommands || []).length > 0) { console.log(` affects ${cmd.affectedSubcommands.length} subcommand(s)`) } } } if (diff.details.removedCommands.length > 0) { console.log('\nRemoved Commands (no longer in command tree):') for (const cmd of diff.details.removedCommands) { console.log(` ⚠ ${cmd.path}`) } } if (diff.details.newFlags.length > 0) { console.log('\nNew Flags:') for (const flag of diff.details.newFlags) { console.log(` + ${flag.commandPath} --${flag.flagName} (${flag.type})`) } } if (diff.details.removedFlags.length > 0) { console.log('\nRemoved Flags (no longer in command tree):') for (const flag of diff.details.removedFlags) { console.log(` ⚠ ${flag.commandPath} --${flag.flagName}`) } } if (diff.details.changedDefaults.length > 0) { console.log('\nChanged Defaults:') for (const change of diff.details.changedDefaults) { console.log(` ~ ${change.commandPath} --${change.flagName}`) console.log(` ${JSON.stringify(change.oldDefault)}${JSON.stringify(change.newDefault)}`) } } if ((diff.details.changedFlagTypes || []).length > 0) { console.log('\nChanged Flag Types:') for (const change of diff.details.changedFlagTypes) { console.log(` ~ ${change.commandPath} --${change.flagName}: ${change.oldType}${change.newType}`) } } if ((diff.details.changedFlagRequirements || []).length > 0) { console.log('\nChanged Flag Requirements:') for (const change of diff.details.changedFlagRequirements) { console.log(` ~ ${change.commandPath} --${change.flagName}: required ${change.oldRequired}${change.newRequired}`) } } console.log('') } /** * Generate markdown summary for PR description * @param {Object} diff - Diff object * @returns {string} Markdown content */ function generateMarkdownSummary(diff) { const lines = [] lines.push(`## rpk Documentation Changes`) lines.push(``) lines.push(`**Version:** ${diff.comparison.oldVersion}${diff.comparison.newVersion}`) lines.push(``) lines.push(`### Summary`) lines.push(``) lines.push(`| Category | Count |`) lines.push(`|----------|-------|`) lines.push(`| New commands | ${diff.summary.newCommands} |`) lines.push(`| Removed commands | ${diff.summary.removedCommands} |`) lines.push(`| New flags | ${diff.summary.newFlags} |`) lines.push(`| Removed flags | ${diff.summary.removedFlags} |`) lines.push(`| Changed defaults | ${diff.summary.changedDefaults} |`) if (diff.summary.flagDataBackfilled) { lines.push(`| Flag docs backfilled (baseline had no flag data) | ${diff.summary.flagDataBackfilled} commands |`) } lines.push(``) if (diff.details.newCommands.length > 0) { lines.push(`### New Commands`) lines.push(``) for (const cmd of diff.details.newCommands) { lines.push(`- \`${cmd.path}\``) } lines.push(``) } if (diff.details.removedCommands.length > 0) { lines.push(`### Removed Commands`) lines.push(``) lines.push(`> Commands no longer in the active command tree.`) lines.push(``) for (const cmd of diff.details.removedCommands) { lines.push(`- ~~\`${cmd.path}\`~~`) } lines.push(``) } if (diff.details.newFlags.length > 0 && diff.details.newFlags.length <= 20) { lines.push(`### New Flags`) lines.push(``) for (const flag of diff.details.newFlags) { lines.push(`- \`${flag.commandPath}\`: \`--${flag.flagName}\` (${flag.type})`) } lines.push(``) } else if (diff.details.newFlags.length > 20) { lines.push(`### New Flags`) lines.push(``) lines.push(`${diff.details.newFlags.length} new flags added. See diff JSON for details.`) lines.push(``) } return lines.join('\n') } /** * Convert command path to xref path * @param {string} commandPath - Command path like "rpk cluster info" * @returns {string} Xref path like "rpk-cluster/rpk-cluster-info.adoc" */ function commandPathToXref(commandPath) { const parts = commandPath.split(' ') if (parts.length === 1) { return `rpk.adoc` } const dashified = commandPath.replace(/ /g, '-') if (parts.length === 2) { return `${dashified}.adoc` } // For deeper commands: rpk cluster info -> rpk-cluster/rpk-cluster-info.adoc const parentDir = parts.slice(0, 2).join('-') return `${parentDir}/${dashified}.adoc` } /** * Generate AsciiDoc content for what's-new file * @param {Object} diff - Diff object from generateRpkDiff * @param {Object} options - Options * @param {string} options.version - Version string to display * @returns {string} AsciiDoc content */ function generateWhatsNewSection(diff, options = {}) { const lines = [] const version = options.version || diff.comparison.newVersion // Plugin subtrees may render as partials with no linkable pages, so plugin // runs disable xrefs and render plain command names instead. const useXrefs = options.xrefs !== false // Commands that render as partials or are excluded have no linkable page const linkable = typeof options.linkable === 'function' ? options.linkable : () => true // Section heading for the page ("== Redpanda CLI" for core rpk changes, // "== rpk plugins" for plugin releases). When blockLabel is set, the block // opens with a "=== <label>" heading and category headings nest one level // deeper, so accumulated blocks never produce colliding section ids. const sectionHeading = options.sectionHeading || '== Redpanda CLI' const blockLabel = options.blockLabel || null const h = blockLabel ? '====' : '===' // Command groups (two-part commands with subcommands) render into their own // directory: rpk check -> rpk-check/rpk-check.adoc, not rpk-check.adoc const hasSubcommands = typeof options.hasSubcommands === 'function' ? options.hasSubcommands : () => false const cmdRef = (commandPath) => { if (!useXrefs || !linkable(commandPath)) return `\`${commandPath}\`` let xrefPath = commandPathToXref(commandPath) if (commandPath.split(' ').length === 2 && hasSubcommands(commandPath)) { const dashified = commandPath.replace(/ /g, '-') xrefPath = `${dashified}/${dashified}.adoc` } return `xref:reference:rpk/${xrefPath}[\`${commandPath}\`]` } // Check if there are any changes worth documenting const hasNewCommands = diff.details.newCommands.length > 0 const hasDeprecatedCommands = (diff.details.newlyDeprecatedCommands || []).length > 0 const hasRemovedCommands = diff.details.removedCommands.length > 0 const hasNewFlags = diff.details.newFlags.length > 0 const hasRemovedFlags = diff.details.removedFlags.length > 0 const hasChangedDefaults = diff.details.changedDefaults.length > 0 const hasChangedFlagTypes = (diff.details.changedFlagTypes || []).length > 0 if (!hasNewCommands && !hasDeprecatedCommands && !hasRemovedCommands && !hasNewFlags && !hasRemovedFlags && !hasChangedDefaults && !hasChangedFlagTypes) { return '' // No changes to document } lines.push(sectionHeading) lines.push(``) if (blockLabel) { lines.push(`=== ${blockLabel}`) lines.push(``) } if (hasNewCommands) { lines.push(`${h} New commands`) lines.push(``) for (const cmd of diff.details.newCommands) { const shortDesc = firstSentence(cmd.description || '') const desc = shortDesc ? ` - ${shortDesc}` : '' lines.push(`* ${cmdRef(cmd.path)}${desc}`) } lines.push(``) } if (hasNewFlags) { lines.push(`${h} New flags`) lines.push(``) // Group flags by command const flagsByCommand = {} for (const flag of diff.details.newFlags) { if (!flagsByCommand[flag.commandPath]) { flagsByCommand[flag.commandPath] = [] } flagsByCommand[flag.commandPath].push(flag) } for (const [cmdPath, flags] of Object.entries(flagsByCommand)) { const flagList = flags.map(f => `\`--${f.flagName}\``).join(', ') lines.push(`* ${cmdRef(cmdPath)}: Added ${flagList}`) } lines.push(``) } if (hasChangedDefaults) { lines.push(`${h} Changed defaults`) lines.push(``) for (const change of diff.details.changedDefaults) { const oldVal = formatFlagValue(change.oldDefault) const newVal = formatFlagValue(change.newDefault) lines.push(`* ${cmdRef(change.commandPath)}: \`--${change.flagName}\` default changed from \`${oldVal}\` to \`${newVal}\``) } lines.push(``) } if (hasChangedFlagTypes) { lines.push(`${h} Changed flag types`) lines.push(``) for (const change of diff.details.changedFlagTypes) { lines.push(`* ${cmdRef(change.commandPath)}: \`--${change.flagName}\` type changed from \`${change.oldType}\` to \`${change.newType}\``) } lines.push(``) } if (hasDeprecatedCommands) { lines.push(`${h} Deprecated commands`) lines.push(``) for (const cmd of diff.details.newlyDeprecatedCommands) { let entry = `* \`${cmd.path}\`` if (cmd.replacement) { entry += `: ${cmd.replacement}` } else if (cmd.message) { entry += `: ${cmd.message}` } lines.push(entry) if ((cmd.affectedSubcommands || []).length > 0) { lines.push(`+`) lines.push(`Includes its subcommands: ${cmd.affectedSubcommands.map(s => `\`${s}\``).join(', ')}.`) } } lines.push(``) } if (hasRemovedCommands) { lines.push(`${h} Removed commands`) lines.push(``) for (const cmd of diff.details.removedCommands) { lines.push(`* \`${cmd.path}\``) } lines.push(``) } if (hasRemovedFlags) { lines.push(`${h} Removed flags`) lines.push(``) const removedByCommand = {} for (const flag of diff.details.removedFlags) { if (!removedByCommand[flag.commandPath]) { removedByCommand[flag.commandPath] = [] } removedByCommand[flag.commandPath].push(flag) } for (const [cmdPath, flags] of Object.entries(removedByCommand)) { const flagList = flags.map(f => `\`--${f.flagName}\``).join(', ') lines.push(`* ${cmdRef(cmdPath)}: Removed ${flagList}`) } lines.push(``) } return lines.join('\n') } /** * Extract the first sentence of a description for a one-line bullet. * Newlines are collapsed first (cobra help wraps mid-sentence), and decimal * points in version numbers do not end a sentence. Falls back to the whole * collapsed text when no sentence boundary exists. * @param {string} str - Command description * @returns {string} */ function firstSentence(str) { if (!str) return '' // A blank line separates the summary from the long text even when the // summary has no terminal period ("Install Redpanda Check\n\nThis // command..."), so cut at the paragraph break before joining the // hard-wrapped lines within it. const firstParagraph = String(str).split(/\n\s*\n/, 1)[0] const singleLine = firstParagraph.replace(/\s*\n+\s*/g, ' ').trim() const protectedText = singleLine.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2') const match = protectedText.match(/^.*?[.!?](?=\s|$)/) const sentence = match ? match[0] : protectedText return sentence.replace(/__DECIMAL__/g, '.').trim() } /** * Render a flag value for display. Objects and arrays are JSON-encoded so * they never render as [object Object]. * @param {*} value - Flag default value * @returns {string} */ function formatFlagValue(value) { if (value === undefined) return 'unset' if (value === null) return 'null' if (typeof value === 'object') return JSON.stringify(value) return String(value) } module.exports = { generateRpkDiff, printDiffReport, generateMarkdownSummary, generateWhatsNewSection, flattenToMap, getFlagsMap, compareFlags, // Exported for testing firstSentence }