@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
656 lines (573 loc) • 19 kB
JavaScript
const Ajv2020 = require('ajv/dist/2020')
const fs = require('fs')
const path = require('path')
/**
* Valid admonition locations for notes, warnings, tips, cautions, importants
*/
const VALID_ADMONITION_LOCATIONS = [
'after_header',
'after_description',
'after_usage',
'after_aliases',
'after_flags',
'after_examples',
'before_see_also',
'end'
]
/**
* Valid platforms
*/
const VALID_PLATFORMS = ['linux', 'darwin', 'windows']
/**
* Valid include/preservation locations
*/
const VALID_INCLUDE_LOCATIONS = [
'after_header',
'after_description',
'after_usage',
'after_aliases',
'after_flags',
'after_modifiers',
'after_examples',
'before_see_also',
'end'
]
/**
* Valid custom section positions
*/
const VALID_SECTION_POSITIONS = [
'after_description',
'after_usage',
'after_flags',
'after_examples',
'before_see_also',
'end'
]
/**
* Validation result object
*/
class ValidationResult {
constructor() {
this.valid = true
this.errors = []
this.warnings = []
}
addError(message, context = null) {
this.valid = false
this.errors.push({ message, context })
}
addWarning(message, context = null) {
this.warnings.push({ message, context })
}
merge(other) {
if (!other.valid) this.valid = false
this.errors.push(...other.errors)
this.warnings.push(...other.warnings)
}
format() {
const lines = []
if (this.errors.length > 0) {
lines.push('ERRORS:')
for (const err of this.errors) {
lines.push(` ✗ ${err.message}`)
if (err.context) lines.push(` at: ${err.context}`)
}
}
if (this.warnings.length > 0) {
lines.push('WARNINGS:')
for (const warn of this.warnings) {
lines.push(` ⚠ ${warn.message}`)
if (warn.context) lines.push(` at: ${warn.context}`)
}
}
return lines.join('\n')
}
}
/**
* Normalize a command path (trim whitespace, collapse multiple spaces)
* @param {string} cmdPath - Command path to normalize
* @returns {string} Normalized command path
*/
function normalizeCommandPath(cmdPath) {
if (!cmdPath || typeof cmdPath !== 'string') return ''
return cmdPath.trim().replace(/\s+/g, ' ')
}
/**
* Load and compile the JSON Schema for validation
* @param {string} [overridesPath] - Path to the overrides file; the schema is
* looked up as a sibling (same directory) before falling back to the
* package-relative path.
* @returns {Function} Compiled schema validator
*/
function loadSchemaValidator(overridesPath) {
// Primary: look for the schema next to the overrides file (docs repo's docs-data/).
if (overridesPath) {
const siblingPath = path.join(path.dirname(path.resolve(overridesPath)), 'rpk-overrides.schema.json')
if (fs.existsSync(siblingPath)) {
const schema = JSON.parse(fs.readFileSync(siblingPath, 'utf8'))
const ajv = new Ajv2020({ allErrors: true, verbose: true })
return ajv.compile(schema)
}
}
// Fallback: package-relative path (legacy / monorepo usage).
const schemaPath = path.resolve(__dirname, '../../docs-data/rpk-overrides.schema.json')
if (!fs.existsSync(schemaPath)) {
throw new Error(`Schema file not found: ${schemaPath}`)
}
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
const ajv = new Ajv2020({ allErrors: true, verbose: true })
return ajv.compile(schema)
}
/**
* Validate overrides against JSON Schema
* @param {Object} overrides - Overrides object to validate
* @param {string} [overridesPath] - Path to the overrides file (used to locate schema)
* @returns {ValidationResult}
*/
function validateSchema(overrides, overridesPath) {
const result = new ValidationResult()
try {
const validate = loadSchemaValidator(overridesPath)
const valid = validate(overrides)
if (!valid) {
for (const error of validate.errors || []) {
const path = error.instancePath || '/'
const message = `${error.message}`
result.addError(message, path)
}
}
} catch (err) {
result.addError(`Schema validation failed: ${err.message}`)
}
return result
}
/**
* Validate command paths in overrides against actual command tree
* @param {Object} overrides - Overrides object (resolved)
* @param {Object} commandTree - rpk command tree
* @returns {ValidationResult}
*/
function validateCommandPaths(overrides, commandTree) {
const result = new ValidationResult()
if (!overrides || !overrides.commands) return result
if (!commandTree) {
result.addWarning('No command tree provided, skipping command path validation')
return result
}
// Flatten command tree to get all valid paths
const validPaths = new Set()
const flattenTree = (node, parentPath = '') => {
const currentPath = parentPath ? `${parentPath} ${node.name}` : node.name
validPaths.add(currentPath)
if (node.commands && Array.isArray(node.commands)) {
for (const child of node.commands) {
flattenTree(child, currentPath)
}
}
}
flattenTree(commandTree)
// Check each override command path
for (const cmdPath of Object.keys(overrides.commands)) {
const normalized = normalizeCommandPath(cmdPath)
// Check for normalization issues
if (normalized !== cmdPath) {
result.addWarning(
`Command path has irregular whitespace: "${cmdPath}"`,
`commands["${cmdPath}"]`
)
}
// Check if path exists in tree
if (!validPaths.has(normalized)) {
// Find similar paths for suggestion
const similar = [...validPaths]
.filter(p => {
const parts = normalized.split(' ')
const pParts = p.split(' ')
return parts.some(part => pParts.includes(part))
})
.slice(0, 3)
let message = `Unknown command path: "${cmdPath}"`
if (similar.length > 0) {
message += `\n Did you mean one of: ${similar.join(', ')}?`
}
result.addError(message, `commands["${cmdPath}"]`)
}
}
return result
}
/**
* Unescape RFC 6901 JSON Pointer encoding
* @param {string} segment - Path segment to unescape
* @returns {string} Unescaped segment
*/
function unescapeRfc6901(segment) {
// RFC 6901: ~1 → /, ~0 → ~ (order matters: ~1 first)
return segment.replace(/~1/g, '/').replace(/~0/g, '~')
}
/**
* Validate $ref and $refs references in overrides
* @param {Object} overrides - Raw overrides object (before resolution)
* @returns {ValidationResult}
*/
function validateReferences(overrides) {
const result = new ValidationResult()
if (!overrides) return result
// Track visited refs for cycle detection
const checkRef = (ref, context, visited = new Set()) => {
if (!ref || typeof ref !== 'string') {
result.addError(`Invalid $ref value: ${ref}`, context)
return
}
// Validate ref format
if (!ref.startsWith('#/')) {
result.addError(
`Invalid $ref format: "${ref}" (must start with #/)`,
context
)
return
}
// Check for cycles
if (visited.has(ref)) {
result.addError(
`Circular reference detected: "${ref}"`,
context
)
return
}
// Resolve the reference with RFC 6901 unescaping
const refPath = ref.replace(/^#\//, '').split('/').map(unescapeRfc6901)
let resolved = overrides
for (const part of refPath) {
if (resolved && typeof resolved === 'object') {
resolved = resolved[part]
} else {
resolved = undefined
break
}
}
if (resolved === undefined) {
result.addError(
`Cannot resolve $ref: "${ref}" - path does not exist`,
context
)
return
}
// Check for nested references (cycle detection)
const newVisited = new Set(visited)
newVisited.add(ref)
checkObjectForRefs(resolved, `${context} -> ${ref}`, newVisited)
}
const checkRefs = (refs, context, visited = new Set()) => {
if (!Array.isArray(refs)) {
result.addError(`$refs must be an array`, context)
return
}
for (let i = 0; i < refs.length; i++) {
// Each array item gets a fresh copy of the visited set
// This allows the same definition to be referenced multiple times
// without being incorrectly flagged as a cycle
checkRef(refs[i], `${context}.$refs[${i}]`, new Set(visited))
}
}
// Check object for $ref/$refs with cycle tracking
const checkObjectForRefs = (obj, context = '', visited = new Set()) => {
if (!obj || typeof obj !== 'object') return
if (Array.isArray(obj)) {
obj.forEach((item, i) => checkObjectForRefs(item, `${context}[${i}]`, visited))
return
}
if (obj.$ref) {
checkRef(obj.$ref, context, visited)
}
if (obj.$refs) {
checkRefs(obj.$refs, context, visited)
}
for (const [key, value] of Object.entries(obj)) {
if (key !== '$ref' && key !== '$refs') {
checkObjectForRefs(value, context ? `${context}.${key}` : key, visited)
}
}
}
checkObjectForRefs(overrides)
return result
}
/**
* Validate admonition locations
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validateAdmonitionLocations(commandOverride, context) {
const result = new ValidationResult()
const admonitionTypes = ['notes', 'warnings', 'tips', 'cautions', 'importants']
for (const type of admonitionTypes) {
if (commandOverride[type]) {
for (const location of Object.keys(commandOverride[type])) {
if (!VALID_ADMONITION_LOCATIONS.includes(location)) {
result.addError(
`Invalid ${type} location: "${location}"`,
`${context}.${type}.${location}`
)
result.addWarning(
`Valid locations are: ${VALID_ADMONITION_LOCATIONS.join(', ')}`
)
}
}
}
}
return result
}
/**
* Validate include locations
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validateIncludeLocations(commandOverride, context) {
const result = new ValidationResult()
if (commandOverride.includes) {
for (const location of Object.keys(commandOverride.includes)) {
if (!VALID_INCLUDE_LOCATIONS.includes(location)) {
result.addError(
`Invalid include location: "${location}"`,
`${context}.includes.${location}`
)
result.addWarning(
`Valid locations are: ${VALID_INCLUDE_LOCATIONS.join(', ')}`
)
}
}
}
// Check cloudContent and selfHostedContent
for (const contentType of ['cloudContent', 'selfHostedContent']) {
if (commandOverride[contentType]) {
for (const location of Object.keys(commandOverride[contentType])) {
if (!VALID_INCLUDE_LOCATIONS.includes(location)) {
result.addError(
`Invalid ${contentType} location: "${location}"`,
`${context}.${contentType}.${location}`
)
}
}
}
}
return result
}
/**
* Validate custom section positions
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validateCustomSectionPositions(commandOverride, context) {
const result = new ValidationResult()
if (commandOverride.customSections) {
for (const [name, section] of Object.entries(commandOverride.customSections)) {
if (section.position && !VALID_SECTION_POSITIONS.includes(section.position)) {
result.addError(
`Invalid custom section position: "${section.position}"`,
`${context}.customSections.${name}.position`
)
result.addWarning(
`Valid positions are: ${VALID_SECTION_POSITIONS.join(', ')}`
)
}
}
}
return result
}
/**
* Validate platform values
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validatePlatforms(commandOverride, context) {
const result = new ValidationResult()
if (commandOverride.platforms) {
for (const platform of commandOverride.platforms) {
if (!VALID_PLATFORMS.includes(platform)) {
result.addError(
`Invalid platform: "${platform}"`,
`${context}.platforms`
)
result.addWarning(
`Valid platforms are: ${VALID_PLATFORMS.join(', ')}`
)
}
}
}
return result
}
/**
* Validate flag overrides
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validateFlags(commandOverride, context) {
const result = new ValidationResult()
// cloudOnly and selfHostedOnly are mutually exclusive: setting both wraps
// the row in an ifdef::env-cloud + ifndef::env-cloud pair that no build
// can satisfy, so the row silently vanishes from BOTH sites.
if (commandOverride.cloudOnly && commandOverride.selfHostedOnly) {
result.addError(
'cloudOnly and selfHostedOnly are both set: the command row is unsatisfiable and disappears from every build. Set at most one.',
context
)
}
if (commandOverride.flags) {
for (const [flagName, flagOverride] of Object.entries(commandOverride.flags)) {
if (flagOverride.cloudOnly && flagOverride.selfHostedOnly) {
result.addError(
`cloudOnly and selfHostedOnly are both set on flag "${flagName}": the flag row is unsatisfiable and disappears from every build. Set at most one.`,
`${context}.flags.${flagName}`
)
}
// Validate flag name format
if (!flagName.match(/^[a-zA-Z][-a-zA-Z0-9]*$/)) {
result.addWarning(
`Unusual flag name format: "${flagName}"`,
`${context}.flags.${flagName}`
)
}
// Validate type if specified
if (flagOverride.type) {
const validTypes = ['bool', 'string', 'int', 'int32', 'int64', 'uint', 'uint32',
'uint64', 'float', 'float32', 'float64', 'duration',
'stringSlice', 'stringArray', 'intSlice', '-']
if (!validTypes.includes(flagOverride.type)) {
result.addWarning(
`Unusual flag type: "${flagOverride.type}" for flag "${flagName}"`,
`${context}.flags.${flagName}.type`
)
}
}
}
}
return result
}
/**
* Validate description content (check for common issues)
* @param {Object} commandOverride - Single command override object
* @param {string} context - Context for error messages
* @returns {ValidationResult}
*/
function validateDescriptions(commandOverride, context) {
const result = new ValidationResult()
const checkDescription = (desc, descContext) => {
if (!desc || typeof desc !== 'string') return
// Check for HTML entities that should have been decoded
if (desc.match(/&#x[0-9a-fA-F]+;|&#\d+;|&[a-z]+;/)) {
result.addWarning(
`Description may contain unescaped HTML entities`,
descContext
)
}
// Check for common AsciiDoc syntax issues
if (desc.includes('[') && !desc.includes(']')) {
result.addWarning(
`Possible unclosed bracket in description`,
descContext
)
}
// Check for very long single-line descriptions (might need wrapping)
if (desc.length > 500 && !desc.includes('\n')) {
result.addWarning(
`Very long description (${desc.length} chars) without line breaks`,
descContext
)
}
}
if (commandOverride.description) {
checkDescription(commandOverride.description, `${context}.description`)
}
if (commandOverride.appendToDescription) {
checkDescription(commandOverride.appendToDescription, `${context}.appendToDescription`)
}
return result
}
/**
* Run all validations on an overrides object
* @param {Object} overrides - Overrides object to validate
* @param {Object} [commandTree] - Optional command tree for path validation
* @returns {ValidationResult}
*/
function validateOverrides(overrides, commandTree = null, overridesPath = null) {
const result = new ValidationResult()
if (!overrides || typeof overrides !== 'object') {
result.addError('Overrides must be a non-null object')
return result
}
// 1. Schema validation
result.merge(validateSchema(overrides, overridesPath))
// 2. Reference validation
result.merge(validateReferences(overrides))
// 3. Command path validation (if tree provided)
if (commandTree) {
result.merge(validateCommandPaths(overrides, commandTree))
}
// 4. Per-command validations
if (overrides.commands) {
for (const [cmdPath, cmdOverride] of Object.entries(overrides.commands)) {
const context = `commands["${cmdPath}"]`
result.merge(validateAdmonitionLocations(cmdOverride, context))
result.merge(validateIncludeLocations(cmdOverride, context))
result.merge(validateCustomSectionPositions(cmdOverride, context))
result.merge(validatePlatforms(cmdOverride, context))
result.merge(validateFlags(cmdOverride, context))
result.merge(validateDescriptions(cmdOverride, context))
}
}
return result
}
/**
* Load and validate overrides file
* @param {string} overridesPath - Path to overrides JSON file
* @param {Object} [commandTree] - Optional command tree for path validation
* @returns {{ overrides: Object|null, validation: ValidationResult }}
*/
function loadAndValidateOverrides(overridesPath, commandTree = null) {
const validation = new ValidationResult()
if (!overridesPath) {
return { overrides: null, validation }
}
if (!fs.existsSync(overridesPath)) {
validation.addWarning(`Overrides file not found: ${overridesPath}`)
return { overrides: null, validation }
}
// Parse JSON
let overrides
try {
const content = fs.readFileSync(overridesPath, 'utf8')
overrides = JSON.parse(content)
} catch (err) {
validation.addError(`Failed to parse overrides JSON: ${err.message}`, overridesPath)
return { overrides: null, validation }
}
// Run validations
validation.merge(validateOverrides(overrides, commandTree, overridesPath))
return { overrides, validation }
}
module.exports = {
ValidationResult,
normalizeCommandPath,
loadSchemaValidator,
validateSchema,
validateCommandPaths,
validateReferences,
validateAdmonitionLocations,
validateIncludeLocations,
validateCustomSectionPositions,
validatePlatforms,
validateFlags,
validateDescriptions,
validateOverrides,
loadAndValidateOverrides,
unescapeRfc6901,
VALID_ADMONITION_LOCATIONS,
VALID_PLATFORMS,
VALID_INCLUDE_LOCATIONS,
VALID_SECTION_POSITIONS
}