UNPKG

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

Version:

Antora extensions and macros developed for Redpanda documentation.

1,048 lines (926 loc) 37.8 kB
#!/usr/bin/env node const { execSync, spawnSync } = require('child_process'); const os = require('os'); const { Command } = require('commander'); const path = require('path'); const yaml = require('yaml'); const fs = require('fs'); const handlebars = require('handlebars'); const { determineDocsBranch } = require('../cli-utils/self-managed-docs-branch.js'); const fetchFromGithub = require('../tools/fetch-from-github.js'); const { urlToXref } = require('../cli-utils/convert-doc-links.js'); const { generateRpcnConnectorDocs } = require('../tools/redpanda-connect/generate-rpcn-connector-docs.js'); const parseCSVConnectors = require('../tools/redpanda-connect/parse-csv-connectors.js'); const { getAntoraValue, setAntoraValue } = require('../cli-utils/antora-utils'); const { getRpkConnectVersion, printDeltaReport } = require('../tools/redpanda-connect/report-delta'); /** * Searches upward from a starting directory to locate the repository root. * * Traverses parent directories from the specified start path, returning the first directory containing either a `.git` folder or a `package.json` file. Exits the process with an error if no such directory is found. * * @param {string} [start] - The directory to begin the search from. Defaults to the current working directory. * @returns {string} The absolute path to the repository root directory. */ function findRepoRoot(start = process.cwd()) { let dir = start; while (dir !== path.parse(dir).root) { if ( fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'package.json')) ) { return dir; } dir = path.dirname(dir); } console.error('❌ Could not find repo root (no .git or package.json in any parent)'); process.exit(1); } // -------------------------------------------------------------------- // Dependency check functions /** * Prints an error message to stderr and exits the process with a non-zero status. * * @param {string} msg - The error message to display before exiting. */ function fail(msg) { console.error(`❌ ${msg}`); process.exit(1); } /** * Ensures that a specified command-line tool is installed and operational. * * Attempts to execute the tool with a version flag to verify its presence. If the tool is missing or fails to run, the process exits with an error message and optional installation hint. * * @param {string} cmd - The name of the tool to check (e.g., 'docker', 'helm-docs'). * @param {object} [opts] - Optional settings. * @param {string} [opts.versionFlag='--version'] - The flag used to test the tool's execution. * @param {string} [opts.help] - An optional hint or installation instruction shown on failure. */ function requireTool(cmd, { versionFlag = '--version', help = '' } = {}) { try { execSync(`${cmd} ${versionFlag}`, { stdio: 'ignore' }); } catch { const hint = help ? `\n→ ${help}` : ''; fail(`'${cmd}' is required but not found.${hint}`); } } /** * Ensures that a command-line tool is installed by checking if it responds to a specified flag. * * @param {string} cmd - The name of the command-line tool to check. * @param {string} [help] - Optional help text to display if the tool is not found. * @param {string} [versionFlag='--version'] - The flag to use when checking if the tool is installed. * * @throws {Error} If the specified command is not found or does not respond to the specified flag. */ function requireCmd(cmd, help, versionFlag = '--version') { requireTool(cmd, { versionFlag, help }); } // -------------------------------------------------------------------- // Special validators /** * Ensures that Python with a minimum required version is installed and available in the system PATH. * * Checks for either `python3` or `python` executables and verifies that the version is at least the specified minimum (default: 3.10). Exits the process with an error message if the requirement is not met. * * @param {number} [minMajor=3] - Minimum required major version of Python. * @param {number} [minMinor=10] - Minimum required minor version of Python. */ function requirePython(minMajor = 3, minMinor = 10) { const candidates = ['python3', 'python']; for (const p of candidates) { try { const out = execSync(`${p} --version`, { encoding: 'utf8' }).trim(); const [maj, min] = out.split(' ')[1].split('.').map(Number); if (maj > minMajor || (maj === minMajor && min >= minMinor)) { return; // success } } catch { /* ignore & try next */ } } fail( `Python ${minMajor}.${minMinor}+ not found or too old. → Install from your package manager or https://python.org` ); } /** * Ensures that the Docker CLI is installed and the Docker daemon is running. * * @throws {Error} If Docker is not installed or the Docker daemon is not running. */ function requireDockerDaemon() { requireTool('docker', { help: 'https://docs.docker.com/get-docker/' }); try { execSync('docker info', { stdio: 'ignore' }); } catch { fail('Docker daemon does not appear to be running. Please start Docker.'); } } // -------------------------------------------------------------------- // Grouped checks /** * Ensures that required dependencies for generating CRD documentation are installed. * * Verifies the presence of the {@link git} and {@link crd-ref-docs} command-line tools, exiting the process with an error message if either is missing. */ function verifyCrdDependencies() { requireCmd('git', 'Install Git: https://git-scm.com/downloads'); requireCmd( 'crd-ref-docs', ` The 'crd-ref-docs' command is required but was not found. To install it, follow these steps (for macOS): 1. Determine your architecture: Run: \`uname -m\` 2. Download and install: - For Apple Silicon (M1/M2/M3): curl -fLO https://github.com/elastic/crd-ref-docs/releases/download/v0.1.0/crd-ref-docs_0.1.0_Darwin_arm64.tar.gz tar -xzf crd-ref-docs_0.1.0_Darwin_arm64.tar.gz chmod +x crd-ref-docs sudo mv crd-ref-docs /usr/local/bin/ - For Intel (x86_64): curl -fLO https://github.com/elastic/crd-ref-docs/releases/download/v0.1.0/crd-ref-docs_0.1.0_Darwin_x86_64.tar.gz tar -xzf crd-ref-docs_0.1.0_Darwin_x86_64.tar.gz chmod +x crd-ref-docs sudo mv crd-ref-docs /usr/local/bin/ For more details, visit: https://github.com/elastic/crd-ref-docs `.trim() ); requireCmd( 'go', ` The 'go' command (Golang) is required but was not found. To install it on macOS: Option 1: Install via Homebrew (recommended): brew install go Option 2: Download directly from the official site: 1. Visit: https://go.dev/dl/ 2. Download the appropriate installer for macOS. 3. Run the installer and follow the instructions. After installation, verify it works: go version For more details, see: https://go.dev/doc/install `.trim(), 'version' ); } /** * Ensures that all required tools for Helm documentation generation are installed. * * Checks for the presence of `helm-docs`, `pandoc`, and `git`, exiting the process with an error if any are missing. */ function verifyHelmDependencies() { requireCmd( 'helm-docs', ` The 'helm-docs' command is required but was not found. To install it, follow these steps (for macOS): 1. Determine your architecture: Run: \`uname -m\` 2. Download and install: - For Apple Silicon (M1/M2/M3): curl -fLO https://github.com/norwoodj/helm-docs/releases/download/v1.11.0/helm-docs_1.11.0_Darwin_arm64.tar.gz tar -xzf helm-docs_1.11.0_Darwin_arm64.tar.gz chmod +x helm-docs sudo mv helm-docs /usr/local/bin/ - For Intel (x86_64): curl -fLO https://github.com/norwoodj/helm-docs/releases/download/v1.11.0/helm-docs_1.11.0_Darwin_x86_64.tar.gz tar -xzf helm-docs_1.11.0_Darwin_x86_64.tar.gz chmod +x helm-docs sudo mv helm-docs /usr/local/bin/ Alternatively, if you use Homebrew: brew install norwoodj/tap/helm-docs For more details, visit: https://github.com/norwoodj/helm-docs `.trim() ); requireCmd('pandoc', 'brew install pandoc or https://pandoc.org'); requireCmd('git', 'Install Git: https://git-scm.com/downloads'); } /** * Ensures all dependencies required for generating property documentation are installed. * * Checks for the presence of `make`, Python 3.10 or newer, and at least one C++ compiler (`gcc` or `clang`). Exits the process with an error message if any dependency is missing. */ function verifyPropertyDependencies() { requireCmd('make', 'Your OS package manager'); requirePython(); try { execSync('gcc --version', { stdio: 'ignore' }); } catch { try { execSync('clang --version', { stdio: 'ignore' }); } catch { fail('A C++ compiler (gcc or clang) is required.'); } } } /** * Ensures all required dependencies for generating Redpanda metrics documentation are installed. * * Verifies that Python 3.10+, `curl`, and `tar` are available, and that the Docker daemon is running. * * @throws {Error} If any required dependency is missing or the Docker daemon is not running. */ function verifyMetricsDependencies() { requirePython(); requireCmd('curl'); requireCmd('tar'); requireDockerDaemon(); } // -------------------------------------------------------------------- // Main CLI Definition // -------------------------------------------------------------------- const programCli = new Command(); const pkg = require('../package.json'); programCli .name('doc-tools') .description('Redpanda Document Automation CLI') .version(pkg.version); // Top-level commands. programCli .command('install-test-dependencies') .description('Install packages for doc test workflows') .action(() => { const scriptPath = path.join(__dirname, '../cli-utils/install-test-dependencies.sh'); const result = spawnSync(scriptPath, { stdio: 'inherit', shell: true }); process.exit(result.status); }); programCli .command('get-redpanda-version') .description('Print the latest Redpanda version') .option('--beta', 'Return the latest RC (beta) version if available') .option('--from-antora', 'Read prerelease flag from local antora.yml') .action(async (options) => { try { await require('../tools/get-redpanda-version.js')(options); } catch (err) { console.error(`❌ ${err.message}`); process.exit(1); } }); programCli .command('get-console-version') .description('Print the latest Console version') .option('--beta', 'Return the latest beta version if available') .option('--from-antora', 'Read prerelease flag from local antora.yml') .action(async (options) => { try { await require('../tools/get-console-version.js')(options); } catch (err) { console.error(`❌ ${err.message}`); process.exit(1); } }); programCli .command('link-readme') .description('Symlink a README.adoc into docs/modules/<module>/pages/') .requiredOption('-s, --subdir <subdir>', 'Relative path to the lab project subdirectory') .requiredOption('-t, --target <filename>', 'Name of the target AsciiDoc file in pages/') .action((options) => { const repoRoot = findRepoRoot(); const normalized = options.subdir.replace(/\/+$/, ''); const moduleName = normalized.split('/')[0]; const projectDir = path.join(repoRoot, normalized); const pagesDir = path.join(repoRoot, 'docs', 'modules', moduleName, 'pages'); const sourceFile = path.join(projectDir, 'README.adoc'); const destLink = path.join(pagesDir, options.target); if (!fs.existsSync(projectDir)) { console.error(`❌ Project directory not found: ${projectDir}`); process.exit(1); } if (!fs.existsSync(sourceFile)) { console.error(`❌ README.adoc not found in ${projectDir}`); process.exit(1); } fs.mkdirSync(pagesDir, { recursive: true }); const relPath = path.relative(pagesDir, sourceFile); try { if (fs.existsSync(destLink)) { const stat = fs.lstatSync(destLink); if (stat.isSymbolicLink()) fs.unlinkSync(destLink); else fail(`Destination already exists and is not a symlink: ${destLink}`); } fs.symlinkSync(relPath, destLink); console.log(`✅ Linked ${relPath}${destLink}`); } catch (err) { fail(`Failed to create symlink: ${err.message}`); } }); programCli .command('fetch') .description('Fetch a file or directory from GitHub and save it locally') .requiredOption('-o, --owner <owner>', 'GitHub repo owner or org') .requiredOption('-r, --repo <repo>', 'GitHub repo name') .requiredOption('-p, --remote-path <path>', 'Path in the repo to fetch') .requiredOption('-d, --save-dir <dir>', 'Local directory to save into') .option('-f, --filename <name>', 'Custom filename to save as') .action(async (options) => { try { await fetchFromGithub( options.owner, options.repo, options.remotePath, options.saveDir, options.filename ); console.log(`✅ Fetched to ${options.saveDir}`); } catch (err) { console.error(`❌ ${err.message}`); process.exit(1); } }); // Create an "automation" subcommand group. const automation = new Command('generate').description('Run docs automations'); // -------------------------------------------------------------------- // Automation subcommands // -------------------------------------------------------------------- // Common options for both automation tasks. const commonOptions = { dockerRepo: 'redpanda', consoleTag: 'latest', consoleDockerRepo: 'console', }; function runClusterDocs(mode, tag, options) { const script = path.join(__dirname, '../cli-utils/generate-cluster-docs.sh'); const args = [mode, tag, options.dockerRepo, options.consoleTag, options.consoleDockerRepo]; console.log(`⏳ Running ${script} with arguments: ${args.join(' ')}`); const r = spawnSync('bash', [script, ...args], { stdio: 'inherit', shell: true }); if (r.status !== 0) process.exit(r.status); } // helper to diff two autogenerated directories function diffDirs(kind, oldTag, newTag) { const oldDir = path.join('autogenerated', oldTag, kind); const newDir = path.join('autogenerated', newTag, kind); const diffDir = path.join('autogenerated', 'diffs', kind, `${oldTag}_to_${newTag}`); const patch = path.join(diffDir, 'changes.patch'); if (!fs.existsSync(oldDir)) { console.error(`❌ Cannot diff: missing ${oldDir}`); process.exit(1); } if (!fs.existsSync(newDir)) { console.error(`❌ Cannot diff: missing ${newDir}`); process.exit(1); } fs.mkdirSync(diffDir, { recursive: true }); const cmd = `diff -ru "${oldDir}" "${newDir}" > "${patch}" || true`; const res = spawnSync(cmd, { stdio: 'inherit', shell: true }); if (res.error) { console.error(`❌ diff failed: ${res.error.message}`); process.exit(1); } console.log(`✅ Wrote patch: ${patch}`); } automation .command('metrics-docs') .description('Generate JSON and AsciiDoc documentation for Redpanda metrics') .requiredOption('-t, --tag <tag>', 'Redpanda version to use when starting Redpanda in Docker') .option( '--docker-repo <repo>', 'Docker repository to use when starting Redpanda in Docker', commonOptions.dockerRepo ) .option( '--console-tag <tag>', 'Redpanda Console version to use when starting Redpanda Console in Docker', commonOptions.consoleTag ) .option( '--console-docker-repo <repo>', 'Docker repository to use when starting Redpanda Console in Docker', commonOptions.consoleDockerRepo ) .option('--diff <oldTag>', 'Also diff autogenerated metrics from <oldTag> → <tag>') .action((options) => { verifyMetricsDependencies(); const newTag = options.tag; const oldTag = options.diff; if (oldTag) { const oldDir = path.join('autogenerated', oldTag, 'metrics'); if (!fs.existsSync(oldDir)) { console.log(`⏳ Generating metrics docs for old tag ${oldTag}…`); runClusterDocs('metrics', oldTag, options); } } console.log(`⏳ Generating metrics docs for new tag ${newTag}…`); runClusterDocs('metrics', newTag, options); if (oldTag) { diffDirs('metrics', oldTag, newTag); } process.exit(0); }); automation .command('rpcn-connector-docs') .description('Generate RPCN connector docs and diff changes since the last version') .option('-d, --data-dir <path>', 'Directory where versioned connect JSON files live', path.resolve(process.cwd(), 'docs-data')) .option('--old-data <path>', 'Optional override for old data file (for diff)') .option('-f, --fetch-connectors', 'Fetch latest connector data using rpk') .option('-m, --draft-missing', 'Generate full-doc drafts for connectors missing in output') .option('--csv <path>', 'Path to connector metadata CSV file', 'internal/plugins/info.csv') .option('--template-main <path>', 'Main Handlebars template', path.resolve(__dirname, '../tools/redpanda-connect/templates/connector.hbs')) .option('--template-intro <path>', 'Intro section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/intro.hbs')) .option('--template-fields <path>', 'Fields section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/fields-partials.hbs')) .option('--template-examples <path>', 'Examples section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/examples-partials.hbs')) .option('--overrides <path>', 'Optional JSON file with overrides') .action(async (options) => { let success = true; const dataDir = path.resolve(process.cwd(), options.dataDir); fs.mkdirSync(dataDir, { recursive: true }); const timestamp = new Date().toISOString(); let newVersion; let dataFile; if (options.fetchConnectors) { try { execSync('rpk --version', { stdio: 'ignore' }); newVersion = getRpkConnectVersion(); const tmpFile = path.join(dataDir, `connect-${newVersion}.tmp.json`); const finalFile = path.join(dataDir, `connect-${newVersion}.json`); const fd = fs.openSync(tmpFile, 'w'); const r = spawnSync('rpk', ['connect', 'list', '--format', 'json-full'], { stdio: ['ignore', fd, 'inherit'] }); fs.closeSync(fd); const rawJson = fs.readFileSync(tmpFile, 'utf8'); const parsed = JSON.parse(rawJson); fs.writeFileSync(finalFile, JSON.stringify(parsed, null, 2)); fs.unlinkSync(tmpFile); dataFile = finalFile; console.log(`✅ Fetched and saved: ${finalFile}`); } catch (err) { console.error(`❌ Failed to fetch connectors: ${err.message}`); success = false; } } else { const candidates = fs.readdirSync(dataDir).filter(f => /^connect-\d+\.\d+\.\d+\.json$/.test(f)); if (candidates.length === 0) { console.error('❌ No connect-<version>.json found. Use --fetch-connectors.'); process.exit(1); } candidates.sort(); dataFile = path.join(dataDir, candidates[candidates.length - 1]); newVersion = candidates[candidates.length - 1].match(/connect-(\d+\.\d+\.\d+)\.json/)[1]; } console.log('⏳ Generating connector partials...'); let partialsWritten, partialFiles, draftsWritten, draftFiles; try { const result = await generateRpcnConnectorDocs({ data: dataFile, overrides: options.overrides, template: options.templateMain, templateIntro: options.templateIntro, templateFields: options.templateFields, templateExamples: options.templateExamples, writeFullDrafts: false }); partialsWritten = result.partialsWritten; partialFiles = result.partialFiles; } catch (err) { console.error(`❌ Failed to generate partials: ${err.message}`); success = false; } if (options.draftMissing) { console.log('⏳ Drafting missing connectors…'); try { const connectorList = await parseCSVConnectors(options.csv, console); const validConnectors = connectorList.filter(r => r.name && r.type); const roots = { pages: path.resolve(process.cwd(), 'modules/components/pages'), partials:path.resolve(process.cwd(), 'modules/components/partials/components'), }; // find any connector that has NO .adoc under pages/TYPEs or partials/TYPEs const allMissing = validConnectors.filter(({ name, type }) => { const relPath = path.join(`${type}s`, `${name}.adoc`); const existsInAny = Object.values(roots).some(root => fs.existsSync(path.join(root, relPath)) ); return !existsInAny; }); // still skip sql_driver const missingConnectors = allMissing.filter(c => !c.name.includes('sql_driver')); if (missingConnectors.length === 0) { console.log('✅ All connectors (excluding sql_drivers) already have docs—nothing to draft.'); } else { console.log(`⏳ Docs missing for ${missingConnectors.length} connectors:`); missingConnectors.forEach(({ name, type }) => { console.log(` • ${type}/${name}`); }); console.log(''); // build your filtered JSON as before… const rawData = fs.readFileSync(dataFile, 'utf8'); const dataObj = JSON.parse(rawData); const filteredDataObj = {}; for (const [key, arr] of Object.entries(dataObj)) { if (!Array.isArray(arr)) { filteredDataObj[key] = arr; continue; } filteredDataObj[key] = arr.filter(component => missingConnectors.some( m => m.name === component.name && `${m.type}s` === key ) ); } const tempDataPath = path.join(dataDir, '._filtered_connect_data.json'); fs.writeFileSync(tempDataPath, JSON.stringify(filteredDataObj, null, 2), 'utf8'); const draftResult = await generateRpcnConnectorDocs({ data: tempDataPath, overrides: options.overrides, template: options.templateMain, templateFields: options.templateFields, templateExamples:options.templateExamples, templateIntro: options.templateIntro, writeFullDrafts: true }); fs.unlinkSync(tempDataPath); draftsWritten = draftResult.draftsWritten; draftFiles = draftResult.draftFiles; } } catch (err) { console.error(`❌ Could not draft missing: ${err.message}`); success = false; } } let oldIndex = {}; if (options.oldData && fs.existsSync(options.oldData)) { oldIndex = JSON.parse(fs.readFileSync(options.oldData, 'utf8')); } else { const oldVersion = getAntoraValue('asciidoc.attributes.latest-connect-version'); if (oldVersion) { const oldPath = path.join(dataDir, `connect-${oldVersion}.json`); if (fs.existsSync(oldPath)) { oldIndex = JSON.parse(fs.readFileSync(oldPath, 'utf8')); } } } const newIndex = JSON.parse(fs.readFileSync(dataFile, 'utf8')); printDeltaReport(oldIndex, newIndex); function logCollapsed(label, filesArray, maxToShow = 10) { console.log(` • ${label}: ${filesArray.length} total`); const sample = filesArray.slice(0, maxToShow); sample.forEach(fp => console.log(` – ${fp}`)); const remaining = filesArray.length - sample.length; if (remaining > 0) { console.log(` … plus ${remaining} more`); } console.log(''); } const wrote = setAntoraValue('asciidoc.attributes.latest-connect-version', newVersion); if (wrote) { console.log(`✅ Updated Antora version: ${newVersion}`); } console.log('📊 Generation Report:'); console.log(` • Partial files: ${partialsWritten}`); // Split “partials” into fields vs examples by checking the path substring. const fieldsPartials = partialFiles.filter(fp => fp.includes('/fields/')); const examplesPartials = partialFiles.filter(fp => fp.includes('/examples/')); // Show only up to 10 of each logCollapsed('Fields partials', fieldsPartials, 10); logCollapsed('Examples partials', examplesPartials, 10); if (options.draftMissing) { console.log(` • Full drafts: ${draftsWritten}`); logCollapsed('Draft files', draftFiles, 5); } console.log('\n📄 Summary:'); console.log(` • Run time: ${timestamp}`); console.log(` • Version used: ${newVersion}`); process.exit(success ? 0 : 1); }); automation .command('property-docs') .description('Generate JSON and AsciiDoc documentation for Redpanda configuration properties') .option('--tag <tag>', 'Git tag or branch to extract from', 'dev') .option('--diff <oldTag>', 'Also diff autogenerated properties from <oldTag> → <tag>') .action((options) => { verifyPropertyDependencies(); const newTag = options.tag; const oldTag = options.diff; const cwd = path.resolve(__dirname, '../tools/property-extractor'); const make = (tag) => { console.log(`⏳ Building property docs for ${tag}…`); const r = spawnSync('make', ['build', `TAG=${tag}`], { cwd, stdio: 'inherit' }); if (r.error) { console.error(`❌ ${r.error.message}`); process.exit(1); } if (r.status !== 0) process.exit(r.status); }; if (oldTag) { const oldDir = path.join('autogenerated', oldTag, 'properties'); if (!fs.existsSync(oldDir)) make(oldTag); } make(newTag); if (oldTag) { diffDirs('properties', oldTag, newTag); } process.exit(0); }); automation .command('rpk-docs') .description('Generate AsciiDoc documentation for rpk CLI commands') .requiredOption('-t, --tag <tag>', 'Redpanda version to use when starting Redpanda in Docker') .option( '--docker-repo <repo>', 'Docker repository to use when starting Redpanda in Docker', commonOptions.dockerRepo ) .option( '--console-tag <tag>', 'Redpanda Console version to use when starting Redpanda Console in Docker', commonOptions.consoleTag ) .option( '--console-docker-repo <repo>', 'Docker repository to use when starting Redpanda Console in Docker', commonOptions.consoleDockerRepo ) .option('--diff <oldTag>', 'Also diff autogenerated rpk docs from <oldTag> → <tag>') .action((options) => { verifyMetricsDependencies(); const newTag = options.tag; const oldTag = options.diff; if (oldTag) { const oldDir = path.join('autogenerated', oldTag, 'rpk'); if (!fs.existsSync(oldDir)) { console.log(`⏳ Generating rpk docs for old tag ${oldTag}…`); runClusterDocs('rpk', oldTag, options); } } console.log(`⏳ Generating rpk docs for new tag ${newTag}…`); runClusterDocs('rpk', newTag, options); if (oldTag) { diffDirs('rpk', oldTag, newTag); } process.exit(0); }); automation .command('helm-spec') .description( `Generate AsciiDoc documentation for one or more Helm charts (supports local dirs or GitHub URLs)` ) .option( '--chart-dir <dir|url>', 'Chart directory (contains Chart.yaml) or a root containing multiple charts, or a GitHub URL', 'https://github.com/redpanda-data/redpanda-operator/charts' ) .requiredOption('-t, --tag <tag>', 'Branch or tag to clone when using a GitHub URL for the chart-dir') .option('--readme <file>', 'Relative README.md path inside each chart dir', 'README.md') .option('--output-dir <dir>', 'Where to write all generated AsciiDoc files', 'modules/reference/pages') .option('--output-suffix <suffix>', 'Suffix to append to each chart name (including extension)', '-helm-spec.adoc') .action((opts) => { verifyHelmDependencies(); // Prepare chart-root (local or GitHub) let root = opts.chartDir; let tmpClone = null; if (/^https?:\/\/github\.com\//.test(root)) { if (!opts.tag) { console.error('❌ When using a GitHub URL you must pass --tag'); process.exit(1); } const u = new URL(root); const parts = u.pathname.replace(/\.git$/, '').split('/').filter(Boolean); if (parts.length < 2) { console.error(`❌ Invalid GitHub URL: ${root}`); process.exit(1); } const [owner, repo, ...sub] = parts; const repoUrl = `https://${u.host}/${owner}/${repo}.git`; const ref = opts.tag; console.log(`⏳ Verifying ${repoUrl}@${ref}…`); const ok = spawnSync( 'git', ['ls-remote', '--exit-code', repoUrl, `refs/heads/${ref}`, `refs/tags/${ref}`], { stdio: 'ignore' } ).status === 0; if (!ok) { console.error(`❌ ${ref} not found on ${repoUrl}`); process.exit(1); } tmpClone = fs.mkdtempSync(path.join(os.tmpdir(), 'helm-')); console.log(`⏳ Cloning ${repoUrl}@${ref}${tmpClone}`); if ( spawnSync('git', ['clone', '--depth', '1', '--branch', ref, repoUrl, tmpClone], { stdio: 'inherit', }).status !== 0 ) { console.error('❌ git clone failed'); process.exit(1); } root = sub.length ? path.join(tmpClone, sub.join('/')) : tmpClone; } // Discover charts if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { console.error(`❌ Chart root not found: ${root}`); process.exit(1); } let charts = []; if (fs.existsSync(path.join(root, 'Chart.yaml'))) { charts = [root]; } else { charts = fs .readdirSync(root) .map((n) => path.join(root, n)) .filter((p) => fs.existsSync(path.join(p, 'Chart.yaml'))); } if (charts.length === 0) { console.error(`❌ No charts found under: ${root}`); process.exit(1); } // Ensure output-dir exists const outDir = path.resolve(opts.outputDir); fs.mkdirSync(outDir, { recursive: true }); // Process each chart for (const chartPath of charts) { const name = path.basename(chartPath); console.log(`⏳ Processing chart "${name}"…`); // Regenerate README.md console.log(`⏳ helm-docs in ${chartPath}`); let r = spawnSync('helm-docs', { cwd: chartPath, stdio: 'inherit' }); if (r.status !== 0) process.exit(r.status); // Convert Markdown → AsciiDoc const md = path.join(chartPath, opts.readme); if (!fs.existsSync(md)) { console.error(`❌ README not found: ${md}`); process.exit(1); } const outFile = path.join(outDir, `k-${name}${opts.outputSuffix}`); console.log(`⏳ pandoc ${md}${outFile}`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); r = spawnSync('pandoc', [md, '-t', 'asciidoc', '-o', outFile], { stdio: 'inherit' }); if (r.status !== 0) process.exit(r.status); // Post-process tweaks let doc = fs.readFileSync(outFile, 'utf8'); const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g; doc = doc .replace(/(\[\d+\])\]\./g, '$1\\].') .replace(/^== # (.*)$/gm, '= $1') .replace(/^== description: (.*)$/gm, ':description: $1') .replace(xrefRe, (match) => { let urlPart = match; let bracketPart = ''; const m = match.match(/^([^\[]+)(\[[^\]]*\])$/); if (m) { urlPart = m[1]; bracketPart = m[2]; } if (urlPart.endsWith('#')) { return match; } try { const xref = urlToXref(urlPart); return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`; } catch (err) { console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`); return match; } }); fs.writeFileSync(outFile, doc, 'utf8'); console.log(`✅ Wrote ${outFile}`); } // Cleanup if (tmpClone) fs.rmSync(tmpClone, { recursive: true, force: true }); }); automation .command('crd-spec') .description('Generate Asciidoc documentation for Kubernetes CRD references') .requiredOption('-t, --tag <operatorTag>', 'Operator release tag or branch, such as operator/v25.1.2') .option( '-s, --source-path <src>', 'CRD Go types dir or GitHub URL', 'https://github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2' ) .option('-d, --depth <n>', 'How many levels deep', '10') .option('--templates-dir <dir>', 'Asciidoctor templates dir', '.github/crd-config/templates/asciidoctor/operator') .option('--output <file>', 'Where to write the generated AsciiDoc file', 'modules/reference/pages/k-crd.adoc') .action(async (opts) => { verifyCrdDependencies(); // Fetch upstream config const configTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'crd-config-')); console.log(`⏳ Fetching crd-ref-docs-config.yaml from redpanda-operator@main…`); await fetchFromGithub( 'redpanda-data', 'redpanda-operator', 'operator/crd-ref-docs-config.yaml', configTmp, 'crd-ref-docs-config.yaml' ); const configPath = path.join(configTmp, 'crd-ref-docs-config.yaml'); // Detect docs repo context const repoRoot = findRepoRoot(); const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); const inDocs = pkg.name === 'redpanda-docs-playbook' || (pkg.repository && pkg.repository.url.includes('redpanda-data/docs')); let docsBranch = null; if (!inDocs) { console.warn('⚠️ Not inside redpanda-data/docs; skipping branch suggestion.'); } else { try { docsBranch = await determineDocsBranch(opts.tag); console.log(`✅ Detected docs repo; you should commit to branch '${docsBranch}'.`); } catch (err) { console.error(`❌ Unable to determine docs branch: ${err.message}`); process.exit(1); } } // Validate templates if (!fs.existsSync(opts.templatesDir)) { console.error(`❌ Templates directory not found: ${opts.templatesDir}`); process.exit(1); } // Prepare source (local folder or GitHub URL) let localSrc = opts.sourcePath; let tmpSrc; if (/^https?:\/\/github\.com\//.test(opts.sourcePath)) { const u = new URL(opts.sourcePath); const parts = u.pathname.split('/').filter(Boolean); if (parts.length < 2) { console.error(`❌ Invalid GitHub URL: ${opts.sourcePath}`); process.exit(1); } const [owner, repo, ...subpathParts] = parts; const repoUrl = `https://${u.host}/${owner}/${repo}`; const subpath = subpathParts.join('/'); console.log(`⏳ Verifying "${opts.tag}" in ${repoUrl}…`); const ok = spawnSync('git', ['ls-remote', '--exit-code', repoUrl, `refs/tags/${opts.tag}`, `refs/heads/${opts.tag}`], { stdio: 'ignore', }).status === 0; if (!ok) { console.error(`❌ Tag or branch "${opts.tag}" not found on ${repoUrl}`); process.exit(1); } tmpSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'crd-src-')); console.log(`⏳ Cloning ${repoUrl}@${opts.tag}${tmpSrc}`); if ( spawnSync('git', ['clone', '--depth', '1', '--branch', opts.tag, repoUrl, tmpSrc], { stdio: 'inherit', }).status !== 0 ) { console.error(`❌ git clone failed`); process.exit(1); } localSrc = subpath ? path.join(tmpSrc, subpath) : tmpSrc; if (!fs.existsSync(localSrc)) { console.error(`❌ Subdirectory not found in repo: ${subpath}`); process.exit(1); } } // Ensure output directory exists const outputDir = path.dirname(opts.output); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // Run crd-ref-docs const args = [ '--source-path', localSrc, '--max-depth', opts.depth, '--templates-dir', opts.templatesDir, '--config', configPath, '--renderer', 'asciidoctor', '--output-path', opts.output, ]; console.log(`⏳ Running crd-ref-docs ${args.join(' ')}`); if (spawnSync('crd-ref-docs', args, { stdio: 'inherit' }).status !== 0) { console.error(`❌ crd-ref-docs failed`); process.exit(1); } let doc = fs.readFileSync(opts.output, 'utf8'); const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g; doc = doc.replace(xrefRe, (match) => { let urlPart = match; let bracketPart = ''; const m = match.match(/^([^\[]+)(\[[^\]]*\])$/); if (m) { urlPart = m[1]; bracketPart = m[2]; } if (urlPart.endsWith('#')) { return match; } try { const xref = urlToXref(urlPart); return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`; } catch (err) { console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`); return match; } }); fs.writeFileSync(opts.output, doc, 'utf8'); // Cleanup if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true }); fs.rmSync(configTmp, { recursive: true, force: true }); console.log(`✅ CRD docs generated at ${opts.output}`); if (inDocs) { console.log(`ℹ️ Don't forget to commit your changes on branch '${docsBranch}'.`); } }); programCli.addCommand(automation); programCli.parse(process.argv);