UNPKG

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

Version:

Antora extensions and macros developed for Redpanda documentation.

415 lines (378 loc) 12.7 kB
'use strict' const fs = require('fs') const path = require('path') /** * Validation rules for generated rpk documentation * Each rule returns an array of issues found */ const VALIDATION_RULES = [ { name: 'raw-external-urls', description: 'Raw external URLs that should be links or removed', severity: 'warning', // Allow certain external URLs that are intentional allowlist: [ 'https://github.com/redpanda-data/', // GitHub links are OK as links ], check: (content, filePath) => { const issues = [] // Match URLs not inside link:[] or xref:[] const urlPattern = /(?<!link:)(?<!xref:)(https?:\/\/[^\s\[\]]+)/g let match while ((match = urlPattern.exec(content)) !== null) { const url = match[1] // Check if it's a docs.redpanda.com URL (should always be xref) if (url.includes('docs.redpanda.com')) { issues.push({ line: getLineNumber(content, match.index), message: `Raw docs.redpanda.com URL should be an xref: ${url}`, severity: 'error' }) } } return issues } }, { name: 'invalid-xref-syntax', description: 'xref: followed by http URL (invalid)', severity: 'error', check: (content, filePath) => { const issues = [] const pattern = /xref:https?:\/\/[^\[]+/g let match while ((match = pattern.exec(content)) !== null) { issues.push({ line: getLineNumber(content, match.index), message: `Invalid xref syntax (cannot use URLs): ${match[0]}`, severity: 'error' }) } return issues } }, { name: 'markdown-emphasis', description: 'Markdown-style **text** should be AsciiDoc', severity: 'warning', check: (content, filePath) => { const issues = [] // Match **WORD** patterns (markdown bold) const pattern = /\*\*([A-Z]+)\*\*/g let match while ((match = pattern.exec(content)) !== null) { issues.push({ line: getLineNumber(content, match.index), message: `Markdown emphasis should be AsciiDoc: ${match[0]} → use admonition block instead`, severity: 'warning' }) } return issues } }, { name: 'truncated-description', description: 'Description appears truncated or garbled', severity: 'error', check: (content, filePath) => { const issues = [] const descMatch = content.match(/^:description:\s*(.+)$/m) if (descMatch) { const desc = descMatch[1] // Check for signs of truncation if (desc.length < 20 && !desc.match(/^(List|Get|Set|Create|Delete|Update|Show|Print|Run|Start|Stop)\b/)) { // Very short and doesn't start with a verb if (desc.match(/^[a-z]/) || desc.match(/[^.!?]$/) || desc.match(/^\W/)) { issues.push({ line: getLineNumber(content, descMatch.index), message: `Description appears truncated: "${desc}"`, severity: 'error' }) } } // Check for garbled content (starts with punctuation or code, but allow backtick-wrapped words) if ((desc.match(/^[)}\]'"]/) || desc.match(/^\s*[a-z]+\(\)/)) && !desc.match(/^`[a-z]+`/i)) { issues.push({ line: getLineNumber(content, descMatch.index), message: `Description appears garbled: "${desc}"`, severity: 'error' }) } } return issues } }, { name: 'inline-allcaps-warning', description: 'ALLCAPS warnings in middle of paragraph (not standalone admonitions)', severity: 'warning', check: (content, filePath) => { const issues = [] const lines = content.split('\n') let inCodeBlock = false for (let i = 0; i < lines.length; i++) { const line = lines[i] // Track code blocks if (line.startsWith('----') || line.startsWith('....')) { inCodeBlock = !inCodeBlock continue } if (inCodeBlock) continue // Skip proper AsciiDoc admonitions: // - [WARNING] style block markers // - Standalone KEYWORD: lines (valid single-line admonitions) if (line.match(/^\[(WARNING|IMPORTANT|NOTE|CAUTION|TIP)\]$/)) continue // Check for ALLCAPS warnings that are NOT at start of line (inline in text) // e.g., "This is text WARNING: more text" - this is wrong const inlineMatch = line.match(/\S\s+(WARNING|IMPORTANT|CAUTION|DEPRECATED):\s/) if (inlineMatch) { issues.push({ line: i + 1, message: `Inline ${inlineMatch[1]}: found mid-sentence - should be standalone admonition`, severity: 'warning' }) } } return issues } }, { name: 'coming-soon-placeholder', description: 'Coming soon placeholder content', severity: 'info', check: (content, filePath) => { const issues = [] if (content.toLowerCase().includes('coming soon')) { issues.push({ line: 1, message: 'Contains "coming soon" placeholder - may need documentation when feature ships', severity: 'info' }) } return issues } }, { name: 'bang-bang-callout', description: '!!TEXT!! style callouts are not allowed', severity: 'error', check: (content, filePath) => { const issues = [] const pattern = /!![A-Z]+!!/g let match while ((match = pattern.exec(content)) !== null) { issues.push({ line: getLineNumber(content, match.index), message: `!!CALLOUT!! style not allowed: ${match[0]}`, severity: 'error' }) } return issues } }, { name: 'duplicate-content', description: 'Duplicate paragraphs or sections', severity: 'warning', check: (content, filePath) => { const issues = [] // Split into paragraphs (double newline separated) const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 100) const seen = new Map() for (const para of paragraphs) { // Skip table rows, code blocks, and list items if (para.startsWith('|') || para.startsWith('----') || para.startsWith('* ') || para.startsWith('[')) continue const normalized = para.trim().toLowerCase().replace(/\s+/g, ' ') // Only check substantial prose paragraphs (longer threshold) if (normalized.length > 100) { if (seen.has(normalized)) { issues.push({ line: getLineNumber(content, content.indexOf(para)), message: 'Duplicate paragraph detected', severity: 'warning' }) } seen.set(normalized, true) } } return issues } } ] /** * Get line number for a character index */ function getLineNumber(content, index) { return content.substring(0, index).split('\n').length } /** * Validate a single file * @param {string} filePath - Path to the file * @param {Object} options - Validation options * @returns {Object} Validation result */ function validateFile(filePath, options = {}) { const content = fs.readFileSync(filePath, 'utf8') const relativePath = options.basePath ? path.relative(options.basePath, filePath) : filePath const result = { file: relativePath, errors: [], warnings: [], info: [] } for (const rule of VALIDATION_RULES) { // Skip rules if filtered if (options.skipRules && options.skipRules.includes(rule.name)) continue if (options.onlyRules && !options.onlyRules.includes(rule.name)) continue try { const issues = rule.check(content, filePath) for (const issue of issues) { const entry = { rule: rule.name, line: issue.line, message: issue.message } const severity = issue.severity || rule.severity if (severity === 'error') { result.errors.push(entry) } else if (severity === 'warning') { result.warnings.push(entry) } else { result.info.push(entry) } } } catch (err) { result.errors.push({ rule: rule.name, line: 0, message: `Rule execution failed: ${err.message}` }) } } return result } /** * Validate all files in a directory * @param {string} dirPath - Directory to validate * @param {Object} options - Validation options * @returns {Object} Validation summary */ function validateDirectory(dirPath, options = {}) { const results = [] const summary = { totalFiles: 0, filesWithErrors: 0, filesWithWarnings: 0, totalErrors: 0, totalWarnings: 0, totalInfo: 0, byRule: {} } function walkDir(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { walkDir(fullPath) } else if (entry.name.endsWith('.adoc')) { summary.totalFiles++ const result = validateFile(fullPath, { ...options, basePath: dirPath }) if (result.errors.length > 0) summary.filesWithErrors++ if (result.warnings.length > 0) summary.filesWithWarnings++ summary.totalErrors += result.errors.length summary.totalWarnings += result.warnings.length summary.totalInfo += result.info.length // Track by rule for (const err of [...result.errors, ...result.warnings, ...result.info]) { if (!summary.byRule[err.rule]) { summary.byRule[err.rule] = { errors: 0, warnings: 0, info: 0 } } if (result.errors.includes(err)) summary.byRule[err.rule].errors++ else if (result.warnings.includes(err)) summary.byRule[err.rule].warnings++ else summary.byRule[err.rule].info++ } if (result.errors.length > 0 || result.warnings.length > 0 || (options.showInfo && result.info.length > 0)) { results.push(result) } } } } walkDir(dirPath) return { results, summary } } /** * Format validation results for console output */ function formatResults(validationOutput, options = {}) { const { results, summary } = validationOutput const lines = [] lines.push('\n' + '='.repeat(60)) lines.push('RPK DOCUMENTATION VALIDATION REPORT') lines.push('='.repeat(60)) // Summary lines.push(`\nFiles scanned: ${summary.totalFiles}`) lines.push(`Files with errors: ${summary.filesWithErrors}`) lines.push(`Files with warnings: ${summary.filesWithWarnings}`) lines.push(`Total errors: ${summary.totalErrors}`) lines.push(`Total warnings: ${summary.totalWarnings}`) // By rule if (Object.keys(summary.byRule).length > 0) { lines.push('\nIssues by rule:') for (const [rule, counts] of Object.entries(summary.byRule)) { const parts = [] if (counts.errors > 0) parts.push(`${counts.errors} errors`) if (counts.warnings > 0) parts.push(`${counts.warnings} warnings`) if (counts.info > 0) parts.push(`${counts.info} info`) lines.push(` ${rule}: ${parts.join(', ')}`) } } // Details if (results.length > 0 && !options.summaryOnly) { lines.push('\n' + '-'.repeat(60)) lines.push('DETAILS') lines.push('-'.repeat(60)) for (const result of results) { lines.push(`\n${result.file}:`) for (const err of result.errors) { lines.push(` ❌ [${err.rule}] Line ${err.line}: ${err.message}`) } for (const warn of result.warnings) { lines.push(` ⚠️ [${warn.rule}] Line ${warn.line}: ${warn.message}`) } if (options.showInfo) { for (const info of result.info) { lines.push(` ℹ️ [${info.rule}] Line ${info.line}: ${info.message}`) } } } } lines.push('\n' + '='.repeat(60)) return lines.join('\n') } module.exports = { validateFile, validateDirectory, formatResults, VALIDATION_RULES } // CLI usage if (require.main === module) { const args = process.argv.slice(2) const dirPath = args[0] || '.' if (!fs.existsSync(dirPath)) { console.error(`Directory not found: ${dirPath}`) process.exit(1) } const options = { showInfo: args.includes('--info'), summaryOnly: args.includes('--summary') } const output = validateDirectory(dirPath, options) console.log(formatResults(output, options)) // Exit with error if any errors found if (output.summary.totalErrors > 0) { process.exit(1) } }