UNPKG

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

Version:

Antora extensions and macros developed for Redpanda documentation.

1,432 lines (1,264 loc) 125 kB
'use strict' const fs = require('fs') const path = require('path') const handlebars = require('handlebars') const helpers = require('./helpers') // Register Handlebars helpers Object.entries(helpers).forEach(([name, fn]) => { if (typeof fn === 'function') { handlebars.registerHelper(name, fn) } }) // Template paths const TEMPLATES_DIR = path.resolve(__dirname, './templates') // Full command paths from the tree being generated (e.g. "rpk ai run codex"). // Registered by generateRpkDocs so formatDescription can wrap real multi-word // command paths as a unit instead of heuristically wrapping `rpk` alone. let knownCommandPaths = new Set() /** * Register the set of real command paths for the current generation run. * @param {string[]} paths - Full command paths (e.g. "rpk ai run codex") */ function registerKnownCommandPaths(paths) { knownCommandPaths = new Set(paths) } /** * Register a Handlebars partial from file * @param {string} name - Partial name * @param {string} filePath - Path to template file */ function registerPartial(name, filePath) { const resolved = path.resolve(filePath) try { const source = fs.readFileSync(resolved, 'utf8') handlebars.registerPartial(name, source) } catch (err) { console.warn(`Warning: Could not load partial "${name}" from ${resolved}`) } } /** * Load and compile a Handlebars template * @param {string} templatePath - Path to template file * @returns {Function} Compiled template function */ function loadTemplate(templatePath) { const source = fs.readFileSync(templatePath, 'utf8') return handlebars.compile(source) } /** * Resolve $ref references in overrides object with cycle detection * @param {Object} obj - Object to resolve * @param {Object} root - Root object containing definitions * @param {string} [context] - Context for error messages (e.g., command path) * @param {Set} [visited] - Set of visited refs for cycle detection * @param {number} [depth] - Current recursion depth * @returns {Object} Resolved object */ function resolveReferences(obj, root, context = '', visited = new Set(), depth = 0) { // Prevent infinite recursion const MAX_DEPTH = 50 if (depth > MAX_DEPTH) { console.error(`ERROR: Maximum reference resolution depth exceeded (${MAX_DEPTH})`) console.error(` Context: ${context || 'root'}`) console.error(` This may indicate circular references in your overrides.`) return obj } if (!obj || typeof obj !== 'object') return obj if (Array.isArray(obj)) { return obj.map((item, i) => resolveReferences(item, root, `${context}[${i}]`, visited, depth + 1) ) } // Handle $ref if (obj.$ref && typeof obj.$ref === 'string') { const ref = obj.$ref // Check for cycles if (visited.has(ref)) { console.error(`ERROR: Circular reference detected: ${ref}`) console.error(` Context: ${context || 'root'}`) console.error(` Reference chain would create infinite loop.`) const { $ref, ...rest } = obj return rest // Return without the reference to prevent infinite loop } // Validate ref format if (!ref.startsWith('#/')) { console.error(`ERROR: Invalid $ref format: "${ref}"`) console.error(` Context: ${context || 'root'}`) console.error(` References must start with #/ (e.g., #/definitions/my-flags)`) return obj } const refPath = ref.replace(/^#\//, '').split('/') let resolved = root for (const part of refPath) { if (resolved && typeof resolved === 'object') { resolved = resolved[part] } else { resolved = undefined break } } if (resolved !== undefined) { const newVisited = new Set(visited) newVisited.add(ref) const { $ref: _, ...rest } = obj return { ...resolveReferences(resolved, root, `${context}.$ref(${ref})`, newVisited, depth + 1), ...rest } } // Reference not found - provide helpful error console.error(`ERROR: Cannot resolve $ref: "${ref}"`) console.error(` Context: ${context || 'root'}`) // Suggest similar paths const findPaths = (obj, prefix = '#') => { const paths = [] if (obj && typeof obj === 'object' && !Array.isArray(obj)) { for (const key of Object.keys(obj)) { const path = `${prefix}/${key}` paths.push(path) paths.push(...findPaths(obj[key], path)) } } return paths } const availablePaths = findPaths(root) const refBase = ref.split('/').slice(0, -1).join('/') const similar = availablePaths .filter(p => p.startsWith(refBase) || p.includes(refPath[refPath.length - 1])) .slice(0, 3) if (similar.length > 0) { console.error(` Did you mean: ${similar.join(', ')}?`) } // Return object without the unresolved $ref const { $ref: _, ...rest } = obj return rest } // Handle $refs array (merge multiple references) // Order: $refs provide defaults, explicit properties override if (obj.$refs && Array.isArray(obj.$refs)) { const { $refs, ...rest } = obj let merged = {} // First, merge all $refs in order (later $refs override earlier ones) for (let i = 0; i < $refs.length; i++) { const ref = $refs[i] // Check for cycles if (visited.has(ref)) { console.error(`ERROR: Circular reference detected in $refs: ${ref}`) console.error(` Context: ${context || 'root'}.$refs[${i}]`) continue } if (!ref.startsWith('#/')) { console.error(`ERROR: Invalid $refs entry: "${ref}" (must start with #/)`) console.error(` Context: ${context || 'root'}.$refs[${i}]`) continue } const refPath = ref.replace(/^#\//, '').split('/') let resolved = root for (const part of refPath) { if (resolved && typeof resolved === 'object') { resolved = resolved[part] } else { resolved = undefined break } } if (resolved !== undefined) { const newVisited = new Set(visited) newVisited.add(ref) merged = deepMerge( merged, resolveReferences(resolved, root, `${context}.$refs[${i}](${ref})`, newVisited, depth + 1) ) } else { console.error(`ERROR: Cannot resolve $refs entry: "${ref}"`) console.error(` Context: ${context || 'root'}.$refs[${i}]`) } } // Then overlay explicit properties (they take precedence over $refs) return deepMerge(merged, rest) } // Recursively resolve nested objects const result = {} for (const [key, value] of Object.entries(obj)) { result[key] = resolveReferences(value, root, `${context}.${key}`, visited, depth + 1) } return result } /** * Deep merge two objects with array deduplication * @param {Object} target - Target object * @param {Object} source - Source object * @returns {Object} Merged object */ function deepMerge(target, source) { if (!source || typeof source !== 'object') return target if (!target || typeof target !== 'object') return source const result = { ...target } for (const [key, value] of Object.entries(source)) { if (Array.isArray(value) && Array.isArray(result[key])) { const targetArray = result[key] // Check if this is a string-only array (prerequisites, seeAlso, etc.) const isStringArray = targetArray.every(item => typeof item === 'string') && value.every(item => typeof item === 'string') if (isStringArray) { // Deduplicate string arrays const combined = [...targetArray, ...value] result[key] = [...new Set(combined)] } else { // Merge arrays by name if items have name property const merged = [...targetArray] for (const sourceItem of value) { if (sourceItem && typeof sourceItem === 'object' && sourceItem.name) { const targetIdx = merged.findIndex(t => t && t.name === sourceItem.name) if (targetIdx >= 0) { merged[targetIdx] = deepMerge(merged[targetIdx], sourceItem) } else { merged.push(sourceItem) } } else if (sourceItem && typeof sourceItem === 'object') { // Object without name - check for duplicates by deep equality const isDuplicate = merged.some(t => JSON.stringify(t) === JSON.stringify(sourceItem) ) if (!isDuplicate) { merged.push(sourceItem) } } else { // Primitive - add if not already present if (!merged.includes(sourceItem)) { merged.push(sourceItem) } } } result[key] = merged } } else if (typeof value === 'object' && typeof result[key] === 'object' && !Array.isArray(value)) { result[key] = deepMerge(result[key], value) } else { result[key] = value } } return result } /** * Check if a command should be excluded from documentation * @param {Object} overrides - Overrides object (resolved) * @param {string} commandPath - Full command path (e.g., "rpk topic create") * @returns {boolean} True if command should be excluded */ function shouldExcludeCommand(overrides, commandPath) { if (!overrides || !overrides.commands) return false const commandOverride = overrides.commands[commandPath] return commandOverride?.exclude === true } /** * Check if a command should be written to the partials directory (cloudSecretDir). * Walks up the command path so setting asPartial: true on "rpk ai" applies to all children. * @param {Object} overrides - Resolved overrides object * @param {string} commandPath - Full command path (e.g., "rpk ai agent list") * @returns {boolean} */ function shouldUsePartialDir(overrides, commandPath) { if (!overrides?.commands) return false const parts = commandPath.split(' ') for (let i = parts.length; i >= 1; i--) { const ancestor = parts.slice(0, i).join(' ') if (overrides.commands[ancestor]?.asPartial === true) return true } return false } /** * Check if a command belongs to the rpk cloud or rpk security secret families. * Their pages are routed to the cloud partials directory (cloudSecretDir) and * published by cloud-docs, so links to them from regular pages must cross to * the cloud component. * @param {string} commandPath - Full command path (e.g., "rpk cloud login") * @returns {boolean} */ function isCloudSecretCommand(commandPath) { return commandPath === 'rpk cloud' || commandPath.startsWith('rpk cloud ') || commandPath === 'rpk security secret' || commandPath.startsWith('rpk security secret ') } /** * Antora component that publishes the rpk cloud and rpk security secret pages. */ const CLOUD_DOCS_COMPONENT = 'cloud-data-platform' /** * Get command metadata from overrides * @param {Object} overrides - Overrides object (resolved) * @param {string} commandPath - Full command path (e.g., "rpk topic create") * @returns {Object} Command metadata */ function getCommandMetadata(overrides, commandPath) { if (!overrides || !overrides.commands) return {} return overrides.commands[commandPath] || {} } /** * Valid content positions */ const VALID_CONTENT_POSITIONS = new Set([ 'after_header', 'after_description', 'after_usage', 'after_aliases', 'after_flags', 'after_modifiers', 'after_examples', 'before_see_also', 'end' ]) /** * Check if admonition content needs block format (complex) vs simple format * @param {string} content - Admonition content * @returns {boolean} True if complex format needed */ function isComplexAdmonition(content) { if (!content) return false // Check for includes, multiple paragraphs, code blocks, tables return content.includes('\n\n') || content.includes('include::') || content.includes('[,') || content.includes('----') || content.includes('|===') } /** * Wrap admonition content in appropriate format * @param {string} type - Admonition type (note, warning, etc.) * @param {string} content - Content to wrap * @returns {string} Wrapped admonition */ function wrapAdmonition(type, content) { const upperType = type.toUpperCase() // Check if already manually wrapped (backward compatibility) if (content.trim().startsWith(`[${upperType}]`)) { return content } // Detect complexity and wrap accordingly if (isComplexAdmonition(content)) { // Block format for complex content return `[${upperType}]\n====\n${content}\n====` } else { // Simple format for single paragraph return `${upperType}: ${content}` } } /** * Process unified content array into position-grouped content * @param {Array} contentArray - Array of content items from overrides * @param {string} [context] - Context for error messages (e.g., command path) * @returns {Object} Content grouped by position, with rendered AsciiDoc */ function processContentArray(contentArray, context = '') { if (!contentArray || !Array.isArray(contentArray)) { return { sections: {}, admonitions: {}, cloudContent: {}, selfHostedContent: {}, includes: {} } } const result = { sections: {}, // position -> array of {id, title, content} admonitions: {}, // position -> rendered admonition string cloudContent: {}, // position -> content string selfHostedContent: {}, // position -> content string includes: {} // position -> array of paths } // Initialize arrays/objects for each position for (const pos of VALID_CONTENT_POSITIONS) { result.sections[pos] = [] result.admonitions[pos] = [] result.cloudContent[pos] = [] result.selfHostedContent[pos] = [] result.includes[pos] = [] } for (const item of contentArray) { const { type, position, content, id, title, path, paths, exclude } = item // Skip excluded items if (exclude === true) { continue } // Validate position if (!VALID_CONTENT_POSITIONS.has(position)) { console.warn(`WARNING: Invalid content position "${position}" for type "${type}"`) if (context) console.warn(` Context: ${context}`) console.warn(` Valid positions: ${[...VALID_CONTENT_POSITIONS].join(', ')}`) continue } switch (type) { case 'section': { // Escape {word} (no hyphens) in table cell lines — CLI format specifiers like {hex} // are not AsciiDoc attribute refs and must be escaped to prevent substitution. const escapedContent = typeof content === 'string' ? content.replace(/^\|(.*)$/gm, (m, cell) => /\{[a-z_][a-z0-9_]*\}/.test(cell) ? '|' + cell.replace(/\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}') : m) : content // Pass through all section properties (subsections, parent, headingLevel, exclude) result.sections[position].push({ type: 'section', id, title, content: escapedContent, subsections: item.subsections, parent: item.parent, headingLevel: item.headingLevel, exclude }) // Deprecation warning for manual "See also" sections if (content && content.includes('== See also')) { console.warn(`⚠️ Deprecated: Manual "See also" section in ${context || 'unknown'}`) console.warn(` Use seeAlso key instead`) } break } case 'example': // Structured single example result.sections[position].push({ type: 'example', description: item.description, code: item.code, language: item.language || 'bash', attributes: item.attributes }) break case 'examples': // Structured multiple examples result.sections[position].push({ type: 'examples', title: item.title || 'Examples', items: item.items }) break case 'note': // Use wrapAdmonition for smart formatting result.admonitions[position].push(wrapAdmonition('note', content)) break case 'warning': result.admonitions[position].push(wrapAdmonition('warning', content)) break case 'tip': result.admonitions[position].push(wrapAdmonition('tip', content)) break case 'caution': result.admonitions[position].push(wrapAdmonition('caution', content)) break case 'important': result.admonitions[position].push(wrapAdmonition('important', content)) break case 'cloud-only': result.cloudContent[position].push(content) break case 'self-hosted': result.selfHostedContent[position].push(content) break case 'include': if (path) { result.includes[position].push(path) } if (paths && Array.isArray(paths)) { result.includes[position].push(...paths) } break default: console.warn(`WARNING: Unknown content type "${type}"`) if (context) console.warn(` Context: ${context}`) } } // Convert arrays to rendered strings where appropriate const rendered = { sections: result.sections, admonitions: {}, cloudContent: {}, selfHostedContent: {}, includes: {} } for (const pos of VALID_CONTENT_POSITIONS) { // Join admonitions with double newlines rendered.admonitions[pos] = result.admonitions[pos].join('\n\n') // Join cloud content rendered.cloudContent[pos] = result.cloudContent[pos].join('\n\n') // Join self-hosted content rendered.selfHostedContent[pos] = result.selfHostedContent[pos].join('\n\n') // Keep includes as array rendered.includes[pos] = result.includes[pos] } return rendered } /** * Merge overrides into command data * @param {Object} command - Command object * @param {Object} overrides - Overrides object (resolved) * @param {string} commandPath - Full command path (e.g., "rpk topic create") * @returns {Object} Merged command */ function mergeCommandOverrides(command, overrides, commandPath) { if (!overrides || !overrides.commands) return command const commandOverride = overrides.commands[commandPath] if (!commandOverride) return command const result = { ...command } // Override description while preserving sections from original if (commandOverride.description) { // Parse sections from original description const originalParsed = parseDescriptionSections(command.description || '') // Parse sections from override description (in case override includes sections) const overrideParsed = parseDescriptionSections(commandOverride.description) // If override only has main description (no sections), preserve original sections // UNLESS the override has content items (which replace the sections) const hasOverrideSections = Object.keys(overrideParsed.sections).length > 0 const hasContentItems = commandOverride.content && commandOverride.content.length > 0 if (!hasOverrideSections && !hasContentItems && Object.keys(originalParsed.sections).length > 0) { // Rebuild description with override main text + original sections let rebuiltDescription = overrideParsed.mainDescription for (const [sectionName, sectionContent] of Object.entries(originalParsed.sections)) { rebuiltDescription += `\n\n${sectionName}\n\n${sectionContent}` } result.description = rebuiltDescription } else { // Override has sections, content items, or original has none - use override as-is result.description = commandOverride.description } } // Append to description if specified if (commandOverride.appendToDescription) { result.description = (result.description || '') + '\n\n' + commandOverride.appendToDescription } // Copy description scope (for conditional rendering) if (commandOverride.descriptionScope) { result.descriptionScope = commandOverride.descriptionScope } // Override flags if (result.flags) { // First, filter out excluded flags if (commandOverride.excludeFlags) { const excludeSet = new Set(commandOverride.excludeFlags) result.flags = result.flags.filter(flag => !excludeSet.has(flag.name)) } // Then apply flag overrides if (commandOverride.flags) { result.flags = result.flags.map(flag => { const flagOverride = commandOverride.flags[flag.name] if (flagOverride) { return { ...flag, ...flagOverride } } return flag }) } } // Add introduced version if specified if (commandOverride.introducedInVersion) { result.introducedInVersion = commandOverride.introducedInVersion } // Copy deprecation info if (commandOverride.deprecated) { result.deprecated = true result.deprecatedMessage = commandOverride.deprecatedMessage result.deprecatedInVersion = commandOverride.deprecatedInVersion result.removedInVersion = commandOverride.removedInVersion result.replacement = commandOverride.replacement } // Copy minVersion if (commandOverride.minVersion) { result.minVersion = commandOverride.minVersion } // Copy platforms from override (explicit override takes precedence) if (commandOverride.platforms) { result.platforms = commandOverride.platforms } // Copy prerequisites if (commandOverride.prerequisites) { result.prerequisites = commandOverride.prerequisites } // Copy seeAlso if (commandOverride.seeAlso) { result.seeAlso = commandOverride.seeAlso } // Copy pageAliases if (commandOverride.pageAliases) { result.pageAliases = commandOverride.pageAliases } // Copy aliases override if (commandOverride.aliases) { result.aliases = commandOverride.aliases } // Merge unified content array if (command.content || commandOverride.content) { const baseContent = command.content || [] const overrideContent = commandOverride.content || [] // Build a map of override items by id for quick lookup const overrideById = new Map() for (const item of overrideContent) { if (item.id) { overrideById.set(item.id, item) } } // Merge: start with base content, applying overrides by id const mergedContent = [] for (const item of baseContent) { if (item.id && overrideById.has(item.id)) { const override = overrideById.get(item.id) // If override has exclude: true, skip this item entirely if (override.exclude === true) { continue } // Otherwise, merge the override with the base item mergedContent.push({ ...item, ...override }) } else { mergedContent.push(item) } } // Add any override items that don't have an id match in base content for (const item of overrideContent) { if (!item.id || !baseContent.some(baseItem => baseItem.id === item.id)) { // Include all items, even those with exclude: true // (exclude directives may be used to remove sections from description) mergedContent.push(item) } } result.content = mergedContent } // Copy cloud/self-hosted only flags if (commandOverride.cloudOnly) { result.cloudOnly = true } if (commandOverride.selfHostedOnly) { result.selfHostedOnly = true } // Copy excludeExamples if (commandOverride.excludeExamples) { result.excludeExamples = commandOverride.excludeExamples } return result } /** * Deep clone an object (simple JSON-safe clone) * @param {Object} obj - Object to clone * @returns {Object} Deep copy of the object */ function deepClone(obj) { return JSON.parse(JSON.stringify(obj)) } /** * Recursively apply overrides to an entire command tree * Creates an enhanced tree with all overrides merged in, suitable for saving as canonical JSON * IMPORTANT: This function deep clones the tree to avoid mutating the original * @param {Object} tree - The rpk command tree * @param {Object} overrides - Resolved overrides object * @param {string} [parentPath=''] - Parent command path for recursion * @param {boolean} [isRoot=true] - Whether this is the root call (triggers deep clone) * @returns {Object} Enhanced tree with overrides applied (new object, original unchanged) */ function applyOverridesToTree(tree, overrides, parentPath = '', isRoot = true) { if (!tree) return tree if (!overrides) return isRoot ? deepClone(tree) : tree // Deep clone at root level to avoid mutating the original tree const workingTree = isRoot ? deepClone(tree) : tree const commandPath = parentPath ? `${parentPath} ${workingTree.name}` : workingTree.name const enhanced = mergeCommandOverrides(workingTree, overrides, commandPath) // Recursively process subcommands (not root, already cloned) if (enhanced.commands && enhanced.commands.length > 0) { enhanced.commands = enhanced.commands.map(subCmd => applyOverridesToTree(subCmd, overrides, commandPath, false) ) } return enhanced } /** * Decode HTML entities in text * @param {string} text - Text with HTML entities * @returns {string} Decoded text */ function decodeHtmlEntities(text) { if (!text) return text return text .replace(/&#x3D;/g, '=') .replace(/&#x27;/g, "'") .replace(/&#x60;/g, '`') .replace(/&quot;/g, '"') .replace(/&amp;/g, '&') .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec)) .replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16))) } /** * Convert indented code examples and YAML/config blocks to protected placeholders * Detects patterns like: * --flag value --other "arg" (command examples) * - job_name: test (YAML lists) * key: value (YAML nested content) * * Returns placeholders that must be restored after other transformations. * * @param {string} text - Text potentially containing indented code * @param {Array} codeBlockStore - Array to store extracted code blocks * @returns {string} Text with code blocks replaced by placeholders */ function convertIndentedCodeBlocksToAsciiDoc(text, codeBlockStore = []) { if (!text) return text const lines = text.split('\n') const result = [] let i = 0 while (i < lines.length) { const line = lines[i] // Unindented shell example: "$ rpk ..." at column 0 followed by its // sample output on the contiguous lines below. Rendered as a command // block plus a no-copy output block so the output's =-underlined titles // and aligned columns are never parsed as prose or headings. if (/^\$\s/.test(line)) { const command = line.replace(/^\$\s+/, '') let j = i + 1 const outputLines = [] while (j < lines.length && lines[j].trim() !== '' && !/^\$\s/.test(lines[j])) { outputLines.push(lines[j]) j++ } let block = `\n[,bash]\n----\n${command}\n----\n` if (outputLines.length > 0) { block += `\n[.no-copy]\n----\n${outputLines.join('\n')}\n----\n` } const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__` codeBlockStore.push(block) result.push('') result.push(placeholder) result.push('') i = j continue } // Colon-introduced code sample: prose ending with ":" followed by an // indented block that is not a list (Cedar policies, config snippets). // Deeply indented (4+ space) blocks are literals by help-text convention // even without a colon introducer (path templates like // " kafka/{topic}/{partition}_{revision}/" would otherwise render as // prose whose braces Asciidoctor eats as attribute references). // Captured verbatim (dedented) so inline-code transforms never touch it. const prevNonBlank = [...result].reverse().find(l => l.trim() !== '') // Only a chunk that starts after a blank line is a standalone literal; // a deeply indented line mid-chunk is a wrapped continuation of a table // row or list item and belongs to the converters below. const atChunkStart = result.length === 0 || result[result.length - 1].trim() === '' if ( ( (prevNonBlank && /:\s*$/.test(prevNonBlank) && /^[ ]{2,}\S/.test(line)) || (atChunkStart && /^[ ]{4,}\S/.test(line)) ) && !/^[ ]{2,}(-|\*|\d+[.)])\s/.test(line) && !/^[ ]{2,}(--|rpk\s|\$\s)/.test(line) ) { const blockLines = [] let j = i while (j < lines.length && (/^[ ]{2,}\S/.test(lines[j]) || lines[j].trim() === '')) { if (lines[j].trim() === '' && (j + 1 >= lines.length || !/^[ ]{2,}\S/.test(lines[j + 1]))) break blockLines.push(lines[j]) j++ } // Column-aligned layouts (two or more lines with a run of spaces // separating columns) are definition tables, not code: leave them for // the indented-table/YAML converters below. const alignedLines = blockLines.filter(l => /\S\s{2,}\S/.test(l.trim())).length if (alignedLines < 2) { const indent = Math.min(...blockLines.filter(l => l.trim() !== '').map(l => l.search(/\S/))) const dedented = blockLines.map(l => l.slice(indent)).join('\n') const codeBlock = `\n[,text]\n----\n${dedented}\n----\n` const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__` codeBlockStore.push(codeBlock) result.push('') result.push(placeholder) result.push('') i = j continue } } // Check for indented command example: 2+ spaces then --, rpk, or $ (shell prompt) // e.g. " --job-name test --labels ..." // e.g. " rpk cluster info" // e.g. " $ echo 'command'" if (/^[ ]{2,}(--|rpk\s|\$\s)/.test(line)) { // Collect all consecutive indented lines that look like command continuation // Strip trailing backslashes as we'll add them during join // Also strip leading "$ " shell prompt if present const codeLines = [line.trim().replace(/^\$\s+/, '').replace(/\s*\\$/, '')] let j = i + 1 while (j < lines.length && /^[ ]{2,}\S/.test(lines[j]) && !/^[ ]{2,}-\s+[A-Z]/.test(lines[j])) { // Continue if indented and not a markdown list item (dash followed by capital letter = prose) codeLines.push(lines[j].trim().replace(/\s*\\$/, '')) j++ } // Create code block - join with backslash continuation if multi-line const joinedCode = codeLines.length > 1 ? codeLines.join(' \\\n') : codeLines[0] const codeBlock = `\n[,bash]\n----\n${joinedCode}\n----\n` const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__` codeBlockStore.push(codeBlock) result.push('') result.push(placeholder) result.push('') i = j continue } // Check for indented YAML block: 2+ spaces, starts with "- key:" or "key:" // and is followed by more indented content with ":" patterns // IMPORTANT: Exclude prose definition lists like " - Term: Long description text" // YAML has short values or values on next line, prose has long text after colon const isYamlStart = ( // " - key:" with no text or short value after colon (YAML style) /^[ ]{2,}-\s+\w+:(?:\s*$|\s+\S{1,20}\s*$)/.test(line) || // " key:" at end of line (YAML block start) /^[ ]{2,}\w+:\s*$/.test(line) ) // Exclude if it looks like a prose definition list (colon followed by long text) const isProse = /^[ ]{2,}-\s+\w+:\s+\w+\s+\w+\s+\w+/.test(line) if (isYamlStart && !isProse) { // Look ahead to see if this is a YAML block (multiple lines with : patterns) let j = i const potentialYamlLines = [] let hasMultipleYamlLines = false while (j < lines.length) { const currentLine = lines[j] // YAML patterns: indented with "- key:", "key:", or just indented continuation if (/^[ ]{2,}/.test(currentLine) && ( /^[ ]*-?\s*\w+:/.test(currentLine) || // key: or - key: /^[ ]*-?\s*\[/.test(currentLine) || // - [array] /^[ ]{4,}\w+:/.test(currentLine) || // deeply indented key: (potentialYamlLines.length > 0 && /^[ ]{4,}\S/.test(currentLine)) // continuation )) { potentialYamlLines.push(currentLine) if (potentialYamlLines.length > 1) hasMultipleYamlLines = true j++ } else if (/^[ ]*$/.test(currentLine) && potentialYamlLines.length > 0) { // Blank line - check if YAML continues after if (j + 1 < lines.length && /^[ ]{2,}-?\s*\w+:/.test(lines[j + 1])) { potentialYamlLines.push(currentLine) j++ } else { break } } else { break } } // If we have a multi-line YAML block, convert to code block if (hasMultipleYamlLines && potentialYamlLines.length >= 2) { // Find minimum indentation to normalize const minIndent = Math.min(...potentialYamlLines .filter(l => l.trim()) .map(l => l.match(/^([ ]*)/)[1].length)) const yamlContent = potentialYamlLines.map(l => l.slice(minIndent)).join('\n') const codeBlock = `\n[,yaml]\n----\n${yamlContent}\n----\n` const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__` codeBlockStore.push(codeBlock) result.push('') result.push(placeholder) result.push('') i = j continue } } // Not a code block pattern, keep line as-is result.push(line) i++ } return result.join('\n') } /** * Convert markdown-style lists to AsciiDoc format * Handles: "text:\n - item1\n - item2" -> "text:\n\n* item1\n* item2" * @param {string} text - Text with potential markdown lists * @returns {string} Text with AsciiDoc lists */ function convertMarkdownLists(text) { if (!text) return text // Find and convert markdown-style indented lists // Pattern: text followed by newline + spaces + dash + space (first item) // then more items with same pattern // // We use a single regex that: // 1. Matches the text before the list (group 1) // 2. Captures all the list items together // 3. Converts dashes to asterisks and removes indentation // Process the text to find list blocks const lines = text.split('\n') const result = [] let i = 0 while (i < lines.length) { const line = lines[i] // Check if this line starts a markdown-style list item (indented dash) if (/^[ \t]+-[ \t]+/.test(line)) { // We found a list item - check if we need a blank line before it // (need blank line if previous line is not empty and not a list item) if (result.length > 0) { const prevLine = result[result.length - 1] if (prevLine !== '' && !/^\* /.test(prevLine)) { result.push('') // Add blank line before list } } // Convert this item: remove leading whitespace, change - to * const converted = line.replace(/^[ \t]+-[ \t]+/, '* ') result.push(converted) } else { result.push(line) } i++ } return result.join('\n') } /** * Convert numbered lists to AsciiDoc list format * Detects patterns like: * 1. First item * 2. Second item * 3. Third item * Converts to: * . First item * . Second item * . Third item * * @param {string} text - Text potentially containing numbered lists * @param {Object} [options] - Conversion options * @param {boolean} [options.skip] - If true, skip list conversion entirely * @returns {string} Text with lists converted to AsciiDoc format */ function convertNumberedListsToAsciiDoc(text, options = {}) { if (!text || options.skip) return text const lines = text.split('\n') const result = [] let i = 0 while (i < lines.length) { const line = lines[i] // Check if this line starts a numbered list (indented number followed by period and space) // Pattern: spaces, then 1-3 digits, period, space, content if (/^[ \t]+(\d{1,3})\.\s+.+/.test(line)) { // Found potential list start - collect consecutive numbered items const listLines = [line] let j = i + 1 let expectedNum = parseInt(line.match(/^[ \t]+(\d{1,3})\./)[1]) + 1 while (j < lines.length) { const nextLine = lines[j] // Check if it's the next numbered item const nextMatch = nextLine.match(/^[ \t]+(\d{1,3})\.\s+.+/) if (nextMatch && parseInt(nextMatch[1]) === expectedNum) { listLines.push(nextLine) expectedNum++ j++ } // Allow blank lines within the list else if (/^[ \t]*$/.test(nextLine) && j + 1 < lines.length) { const afterBlank = lines[j + 1] const afterMatch = afterBlank.match(/^[ \t]+(\d{1,3})\.\s+.+/) if (afterMatch && parseInt(afterMatch[1]) === expectedNum) { listLines.push(nextLine) // Include blank line j++ continue } else { break } } else { break } } // Convert to AsciiDoc list format if we have at least 2 items if (listLines.length >= 2) { // Add blank line before list if previous line isn't blank if (result.length > 0 && result[result.length - 1].trim() !== '') { result.push('') } // Convert each numbered item to AsciiDoc format (. instead of 1., 2., etc.) for (const listLine of listLines) { if (/^[ \t]*$/.test(listLine)) { result.push(listLine) // Keep blank lines } else { // Remove indentation and number, replace with '.' const content = listLine.replace(/^[ \t]+\d{1,3}\.\s+/, '') result.push(`. ${content}`) } } i = j continue } } // Not a list, keep the line as-is result.push(line) i++ } return result.join('\n') } /** * Convert indented bulleted definition lists to AsciiDoc-compliant format * Detects patterns like: * * Term: description text that may * continue on next line * * Another: more description * * Converts to proper AsciiDoc bullet list with bold terms: * * *Term*: description text * * *Another*: more description * * @param {string} text - Text potentially containing definition lists * @param {Object} [options] - Conversion options * @param {boolean} [options.skip] - If true, skip conversion entirely * @returns {string} Text with definition lists formatted as AsciiDoc */ function convertBulletedDefinitionListsToAsciiDoc(text, options = {}) { if (!text || options.skip) return text const lines = text.split('\n') const result = [] let i = 0 while (i < lines.length) { const line = lines[i] // Check for indented bullet with term:description pattern // Pattern: spaces, bullet (* or -), optional space, word(s), colon, description const bulletMatch = line.match(/^(\s{2,})([*-])\s+([A-Za-z][A-Za-z0-9_]*(?:\s+[A-Za-z][A-Za-z0-9_]*)*):\s+(.*)$/) if (bulletMatch) { const indent = bulletMatch[1] const bullet = bulletMatch[2] // Collect all consecutive bullets at same level with term: pattern const definitionItems = [] let j = i while (j < lines.length) { const currentLine = lines[j] const currentMatch = currentLine.match(/^(\s{2,})([*-])\s+([A-Za-z][A-Za-z0-9_]*(?:\s+[A-Za-z][A-Za-z0-9_]*)*):\s+(.*)$/) if (currentMatch && currentMatch[1] === indent && currentMatch[2] === bullet) { // Start new definition item definitionItems.push({ term: currentMatch[3], description: currentMatch[4] }) j++ // Check for continuation lines (more indented than the bullet) while (j < lines.length) { const nextLine = lines[j] // Continuation: starts with more indentation than the bullet, no bullet char if (nextLine.match(/^\s+/) && !nextLine.match(/^\s*[*-]\s/) && nextLine.trim()) { const continuationIndent = nextLine.match(/^(\s*)/)[1].length if (continuationIndent > indent.length) { // Append to description definitionItems[definitionItems.length - 1].description += ' ' + nextLine.trim() j++ } else { break } } else if (/^\s*$/.test(nextLine)) { // Blank line - might be between items, peek ahead if (j + 1 < lines.length && lines[j + 1].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[*-]\\s+[A-Za-z]`))) { j++ // Skip blank line between definition items } else { break } } else { break } } } else { break } } // Only convert if we have 2+ items (makes sense as a list) if (definitionItems.length >= 2) { result.push('') for (const item of definitionItems) { // Format as AsciiDoc bullet with backticked term result.push(`* \`${item.term}\`: ${item.description}`) } result.push('') i = j continue } } // Not a definition list, keep line as-is result.push(line) i++ } return result.join('\n') } /** * Convert indented columnar text to AsciiDoc tables * Detects patterns like: * value1 description of value1 * value2 description of value2 * * @param {string} text - Text potentially containing indented tables * @param {Object} [options] - Conversion options * @param {boolean} [options.skip] - If true, skip table conversion entirely * @returns {string} Text with tables converted to AsciiDoc format */ function convertIndentedTablesToAsciiDoc(text, options = {}) { if (!text || options.skip) return text // Skip conversion if text contains code patterns (struct definitions, type declarations) if (/\b(type|struct|interface|class)\s+\w+\s*(struct\s*)?\{/.test(text)) { return text } const lines = text.split('\n') const result = [] let i = 0 while (i < lines.length) { const line = lines[i] // Check if this line starts a potential table (indented, has content) // Pattern: starts with spaces, has non-space content, then 2+ spaces, then more content if (/^[ \t]{2,}[^ \t].* /.test(line)) { // Found potential table start - look ahead for consecutive similar lines const tableLines = [line] let j = i + 1 while (j < lines.length) { const nextLine = lines[j] // Continue if line is indented and has columnar structure if (/^[ \t]{2,}[^ \t].* /.test(nextLine)) { tableLines.push(nextLine) j++ } // Or if it's a continuation line (indented more deeply, no column separator) else if (/^[ \t]{4,}[^ \t]/.test(nextLine) && !/ /.test(nextLine.trim())) { tableLines.push(nextLine) j++ } // Or blank line followed by another table row else if (/^[ \t]*$/.test(nextLine) && j + 1 < lines.length && /^[ \t]{2,}[^ \t].* /.test(lines[j + 1])) { tableLines.push(nextLine) j++ } else { break } } // Only convert if we have 2+ rows (actual table) if (tableLines.length >= 2) { // Parse the table rows const parsedRows = [] let currentRow = null for (const tableLine of tableLines) { if (/^[ \t]*$/.test(tableLine)) continue // Skip blank lines const trimmed = tableLine.trim() // Check if this is a continuation line (no column separator) if (currentRow && !/ /.test(trimmed)) { // Append to the last column of the current row const lastColIndex = currentRow.length - 1 currentRow[lastColIndex] += ' ' + trimmed } else { // Extract columns: split on 2+ spaces const columns = trimmed.split(/ +/) if (columns.length >= 2) { currentRow = columns parsedRows.push(currentRow) } } } if (parsedRows.length >= 2) { // Convert to AsciiDoc table result.push('') result.push('[cols="1m,1a"]') result.push('|===') result.push('|Value |Description') result.push('') for (const [value, ...descParts] of parsedRows) { // Remove trailing colon from value (e.g., "Organization:" -> "Organization") const cleanValue = value.replace(/:$/, '') const desc = descParts.join(' ') // Escape {word} (no hyphens) in table cells — CLI format specifiers like {hex}, {json} // are not AsciiDoc attribute refs and must be escaped to prevent substitution. const escapeAttrs = s => s.replace(/\{([a-z_][a-z0-9_]*)\}/g, '\\{$1}') result.push(`|${escapeAttrs(cleanValue)} |${escapeAttrs(desc)}`) } result.push('|===') result.push('') i = j continue } } } // Not a table, keep the line as-is result.push(line) i++ } return result.join('\n') } /** * Apply only the text replacements from textTransformations to a raw string. * Used for examples content where we want string substitutions (e.g. rpai → rpk ai) * but NOT AsciiDoc structural formatting or inlineCode wrapping. * @param {string} text * @param {Object|null} customTransformations * @returns {string} */ function applyTextTransformations(text, customTransformations, options = {}) { if (!text || !customTransformations?.replacements) return text let result = text for (const rule of customTransformations.replacements) { // Code blocks are verbatim: only rules explicitly marked applyToCode // (like the rpai -> rpk ai binary-name rewrite) may touch them. if (options.code && !rule.applyToCode) continue try { const flags = rule.flags || 'g' result = result.replace(new RegExp(rule.pattern, flags), rule.replacement) } catch (err) { console.warn(`⚠ Invalid replacement pattern: ${rule.pattern}`) } } return result } /** * Apply text transformations to examples content line by line. Indented * lines are verbatim commands: only rules flagged applyToCode may touch * them (a caption rule once rewrote '{"quotas":...}' inside a command to * '{`quotas`:...}'). Caption lines get the full rule set. * @param {string} text * @param {Object|null} customTransformations * @returns {string} */ function applyTextTransformationsToExamples(text, customTransformations) { if (!text || !customTransformations?.replacements) return text return text.split('\n').map(line => /^[ ]{2,}\S/.test(line) ? applyTextTransformations(line, customTransformations, { code: true }) : applyTextTransformations(line, customTransformations) ).join('\n') } /** * Format description by adding backticks around flags and code * @param {string} desc - Description text * @param {Object} [customTransformations] - Optional custom text transformations from overrides * @param {Object} [options] - Formatting options * @param {boolean} [options.skipTableConversion] - If true, skip automatic table conversion * @param {boolean} [options.skipListConversion] - If true, skip automatic list conversion * @returns {string} Formatted description */ function formatDescription(desc, customTransformations = null, options = {}) { if (!desc) return '' if (typeof desc !== 'string') return String(desc) let preProcessed = desc // === STEP 1: Apply merge patterns for broken inline code spans === // These must run BEFORE protecting inline code, so they can fix patterns like: // `rpk command `-flag` rest` -> `rpk command -flag rest` if (customTransformations?.replacements) { const mergePatterns = customTransformations.replacements.filter(rule => rule.description && rule.description.toLowerCase().includes('merge') && rule.description.toLowerCase().includes('inline code') ) for (const rule of mergePatterns) { try { const flags = rule.flags || 'g' const regex = new RegExp(rule.pattern, flags) preProcessed = preProcessed.replace(regex, rule.replacement) } catch (err) { console.warn(`⚠ Invalid merge pattern: ${rule.pattern}`) if (rule.description) console.warn(` Description: ${rule.description}`) console.warn(` Error: ${err.message}`) } } } // === STEP 1b: Detect and protect multi-line code/YAML blocks VERY EARLY === // This must happen BEFORE single-line rpk conversion (STEP 1c) so multi-line // command examples are detected as blocks, not broken into pieces const earlyCodeBlocks = [] preProcessed = convertIndentedCodeBlocksToAsciiDoc(preProcessed, earlyCodeBlocks) // === STEP 1c: Convert SINGLE-LINE indented commands to backticked inline code === // Only applies to commands NOT already in code blocks (protected by STEP 1b) // This must happen BEFORE text transformations so paths inside commands don't get // individually backticked, creating nested placeholders that fail to restore. // Source often uses indentation for example commands: // rpk cluster partitions balancer-status // rpk cluster license set --path /home/file.license // Convert to: // `rpk cluster partitions balancer-status` // `rpk cluster license set --path /home/file.license` preProcessed = preProcessed.replace(/^[ \t]+(rpk\