@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
1,432 lines (1,264 loc) • 125 kB
JavaScript
'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(/=/g, '=')
.replace(/'/g, "'")
.replace(/`/g, '`')
.replace(/"/g, '"')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/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\