UNPKG

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

Version:

Antora extensions and macros developed for Redpanda documentation.

442 lines (393 loc) 16.2 kB
'use strict'; const fs = require('fs'); const path = require('path'); const handlebars = require('handlebars'); const yaml = require('yaml'); const helpers = require('./helpers'); // Register each helper under handlebars, verifying that it’s a function Object.entries(helpers).forEach(([name, fn]) => { if (typeof fn !== 'function') { console.error(`❌ Helper "${name}" is not a function`); process.exit(1); } handlebars.registerHelper(name, fn); }); // Default “main” template (connector.hbs) which invokes partials {{> intro}}, {{> fields}}, {{> examples}} const DEFAULT_TEMPLATE = path.resolve(__dirname, './templates/connector.hbs'); /** * Reads a file at `filePath` and registers it as a Handlebars partial called `name`. * Throws if the file cannot be read. */ function registerPartial(name, filePath) { const resolved = path.resolve(filePath); let source; try { source = fs.readFileSync(resolved, 'utf8'); } catch (err) { throw new Error(`Unable to read "${name}" template at ${resolved}: ${err.message}`); } handlebars.registerPartial(name, source); } /** * Deep-merge `overrides` into `target`. Only 'description', 'type', * 'annotated_field', 'examples', and known nested fields are overridden. */ function mergeOverrides(target, overrides) { if (!overrides || typeof overrides !== 'object') return target; if (!target || typeof target !== 'object') { throw new Error('Target must be a valid object'); } const scalarKeys = ['description', 'type', 'annotated_field', 'version']; for (const key in overrides) { // === Handle annotated_options === if (key === 'annotated_options' && Array.isArray(overrides[key]) && Array.isArray(target[key])) { const overrideMap = new Map(overrides[key].map(([name, desc]) => [name, desc])); target[key] = target[key].map(([name, desc]) => { if (overrideMap.has(name)) { return [name, overrideMap.get(name)]; } return [name, desc]; }); const existingNames = new Set(target[key].map(([name]) => name)); for (const [name, desc] of overrides[key]) { if (!existingNames.has(name)) { target[key].push([name, desc]); } } continue; } // === Handle examples === if (key === 'examples' && Array.isArray(overrides[key])) { // If target[key] is not an array, initialize it if (!Array.isArray(target[key])) { target[key] = []; } const overrideMap = new Map(overrides[key].map(o => [o.title, o])); target[key] = target[key].map(example => { const override = overrideMap.get(example.title); if (override) { return { ...example, ...(override.summary && { summary: override.summary }), ...(override.config && { config: override.config }), }; } return example; }); const existingTitles = new Set(target[key].map(e => e.title)); for (const example of overrides[key]) { if (!existingTitles.has(example.title)) { target[key].push(example); } } continue; } // === Merge arrays of objects with .name === if (Array.isArray(target[key]) && Array.isArray(overrides[key])) { target[key] = target[key].map(item => { const overrideItem = overrides[key].find(o => o.name === item.name); if (overrideItem) { scalarKeys.forEach(field => { if (Object.hasOwn(overrideItem, field)) { item[field] = overrideItem[field]; } }); if (Object.hasOwn(overrideItem, 'selfManagedOnly')) { item.selfManagedOnly = overrideItem.selfManagedOnly; } return mergeOverrides(item, overrideItem); } return item; }); continue; } // === Merge nested objects === if ( typeof target[key] === 'object' && typeof overrides[key] === 'object' && !Array.isArray(target[key]) && !Array.isArray(overrides[key]) ) { target[key] = mergeOverrides(target[key], overrides[key]); continue; } // === Overwrite scalar keys === if (scalarKeys.includes(key) && Object.hasOwn(overrides, key)) { target[key] = overrides[key]; } } return target; } /** * Resolves $ref references in an object by replacing them with their definitions. * Supports JSON Pointer style references like "#/definitions/client_certs". * * @param {Object} obj - The object to resolve references in * @param {Object} root - The root object containing definitions * @returns {Object} The object with references resolved */ function resolveReferences(obj, root) { if (!obj || typeof obj !== 'object') { return obj; } if (Array.isArray(obj)) { return obj.map(item => resolveReferences(item, root)); } const result = {}; for (const [key, value] of Object.entries(obj)) { if (key === '$ref' && typeof value === 'string') { // Handle JSON Pointer style references if (value.startsWith('#/')) { const path = value.substring(2).split('/'); let resolved = root; try { for (const segment of path) { resolved = resolved[segment]; } if (resolved === undefined) { throw new Error(`Reference path not found: ${value}`); } // Merge the resolved object, but don't process $ref in the resolved object // to avoid infinite recursion Object.assign(result, resolved); } catch (err) { throw new Error(`Failed to resolve reference "${value}": ${err.message}`); } } else { throw new Error(`Unsupported reference format: ${value}. Only JSON Pointer references starting with '#/' are supported.`); } } else { // Recursively resolve references in nested objects result[key] = resolveReferences(value, root); } } return result; } /** * Generates documentation files for RPCN connectors using Handlebars templates. * * Depending on the {@link writeFullDrafts} flag, generates either partial documentation files for connector fields and examples, or full draft documentation for each connector component. Supports merging override data with $ref references and skips draft generation for components marked as deprecated. * * @param {Object} options - Configuration options for documentation generation. * @param {string} options.data - Path to the connector data file (JSON or YAML). * @param {string} [options.overrides] - Optional path to a JSON file with override data. Supports $ref references in JSON Pointer format (e.g., "#/definitions/client_certs"). * @param {string} options.template - Path to the main Handlebars template. * @param {string} [options.templateIntro] - Path to the intro partial template (used in full draft mode). * @param {string} [options.templateFields] - Path to the fields partial template. * @param {string} [options.templateExamples] - Path to the examples partial template. * @param {boolean} options.writeFullDrafts - If true, generates full draft documentation; otherwise, generates partials. * @returns {Promise<Object>} An object summarizing the number and paths of generated partials and drafts. * * @throws {Error} If reading or parsing input files fails, if template rendering fails for a component, or if $ref references cannot be resolved. * * @remark * When generating full drafts, components with a `status` of `'deprecated'` are skipped. */ async function generateRpcnConnectorDocs(options) { // Types and output folders for bloblang function/method partials const bloblangTypes = [ { key: 'bloblang-functions', folder: 'bloblang-functions' }, { key: 'bloblang-methods', folder: 'bloblang-methods' } ]; // Recursively mark is_beta on any field/component with description starting with BETA: function markBeta(obj) { if (!obj || typeof obj !== 'object') return; if (Array.isArray(obj)) { obj.forEach(markBeta); return; } // Mark as beta if description starts with 'BETA:' (case-insensitive, trims leading whitespace) if (typeof obj.description === 'string' && /^\s*BETA:\s*/i.test(obj.description)) { obj.is_beta = true; } // Recurse into children/config/fields if (Array.isArray(obj.children)) obj.children.forEach(markBeta); if (obj.config && Array.isArray(obj.config.children)) obj.config.children.forEach(markBeta); // For connector/component arrays for (const key of Object.keys(obj)) { if (Array.isArray(obj[key])) obj[key].forEach(markBeta); } } const { data, overrides, template, // main Handlebars template (for full-draft mode) templateIntro, templateFields, templateExamples, templateBloblang, writeFullDrafts } = options; // Read connector index (JSON or YAML) const raw = fs.readFileSync(data, 'utf8'); const ext = path.extname(data).toLowerCase(); const dataObj = ext === '.json' ? JSON.parse(raw) : yaml.parse(raw); // Mark beta fields/components before overrides markBeta(dataObj); // Apply overrides if provided if (overrides) { const ovRaw = fs.readFileSync(overrides, 'utf8'); const ovObj = JSON.parse(ovRaw); // Resolve any $ref references in the overrides const resolvedOverrides = resolveReferences(ovObj, ovObj); mergeOverrides(dataObj, resolvedOverrides); // Special: merge bloblang_methods and bloblang_functions from overrides into main data for (const [overrideKey, mainKey] of [ ['bloblang_methods', 'bloblang-methods'], ['bloblang_functions', 'bloblang-functions'] ]) { if (Array.isArray(resolvedOverrides[overrideKey])) { if (!Array.isArray(dataObj[mainKey])) dataObj[mainKey] = []; // Merge by name const mainArr = dataObj[mainKey]; const overrideArr = resolvedOverrides[overrideKey]; for (const overrideItem of overrideArr) { if (!overrideItem.name) continue; const idx = mainArr.findIndex(i => i.name === overrideItem.name); if (idx !== -1) { mainArr[idx] = { ...mainArr[idx], ...overrideItem }; } else { mainArr.push(overrideItem); } } } } } // Compile the “main” template (used when writeFullDrafts = true) const compiledTemplate = handlebars.compile(fs.readFileSync(template, 'utf8')); // Determine which templates to use for “fields” and “examples” // If templateFields is not provided, fall back to the single `template`. // If templateExamples is not provided, skip examples entirely. const fieldsTemplatePath = templateFields || template; const examplesTemplatePath = templateExamples || null; // Register partials if (!writeFullDrafts) { if (fieldsTemplatePath) { registerPartial('fields', fieldsTemplatePath); } if (examplesTemplatePath) { registerPartial('examples', examplesTemplatePath); } } else { registerPartial('intro', templateIntro); } const outputRoot = path.resolve(process.cwd(), 'modules/components/partials'); const fieldsOutRoot = path.join(outputRoot, 'fields'); const examplesOutRoot = path.join(outputRoot, 'examples'); const draftsRoot = path.join(outputRoot, 'drafts'); const configExamplesRoot = path.resolve(process.cwd(), 'modules/components/examples'); if (!writeFullDrafts) { fs.mkdirSync(fieldsOutRoot, { recursive: true }); fs.mkdirSync(examplesOutRoot, { recursive: true }); } let partialsWritten = 0; let draftsWritten = 0; const partialFiles = []; const draftFiles = []; for (const [type, items] of Object.entries(dataObj)) { if (!Array.isArray(items)) continue; for (const item of items) { if (!item.name) continue; const name = item.name; if (!writeFullDrafts) { // Render fields using the registered “fields” partial const fieldsOut = handlebars .compile('{{> fields children=config.children}}')(item); // Render examples only if an examples template was provided let examplesOut = ''; if (examplesTemplatePath) { examplesOut = handlebars .compile('{{> examples examples=examples}}')(item); } if (fieldsOut.trim()) { const fPath = path.join(fieldsOutRoot, type, `${name}.adoc`); fs.mkdirSync(path.dirname(fPath), { recursive: true }); fs.writeFileSync(fPath, fieldsOut); partialsWritten++; partialFiles.push(path.relative(process.cwd(), fPath)); } if (examplesOut.trim() && type !== 'bloblang-functions' && type !== 'bloblang-methods') { const ePath = path.join(examplesOutRoot, type, `${name}.adoc`); fs.mkdirSync(path.dirname(ePath), { recursive: true }); fs.writeFileSync(ePath, examplesOut); partialsWritten++; partialFiles.push(path.relative(process.cwd(), ePath)); } } if (writeFullDrafts) { if (String(item.status || '').toLowerCase() === 'deprecated') { console.log(`Skipping draft for deprecated component: ${type}/${name}`); continue; } let content; try { content = compiledTemplate(item); } catch (err) { throw new Error(`Template render failed for component "${name}": ${err.message}`); } const draftSubdir = name === 'gateway' ? path.join(draftsRoot, 'cloud-only') : draftsRoot; const destFile = path.join(draftSubdir, `${name}.adoc`); fs.mkdirSync(path.dirname(destFile), { recursive: true }); fs.writeFileSync(destFile, content, 'utf8'); draftsWritten++; draftFiles.push(path.relative(process.cwd(), destFile)); } } } // Bloblang function/method partials (only if includeBloblang is true) if (options.includeBloblang) { for (const { key, folder } of bloblangTypes) { const items = dataObj[key]; if (!Array.isArray(items)) continue; const outRoot = path.join(outputRoot, folder); fs.mkdirSync(outRoot, { recursive: true }); // Use custom or default template const bloblangTemplatePath = templateBloblang || path.resolve(__dirname, './templates/bloblang-function.hbs'); const bloblangTemplate = handlebars.compile(fs.readFileSync(bloblangTemplatePath, 'utf8')); for (const fn of items) { if (!fn.name) continue; const adoc = bloblangTemplate(fn); const outPath = path.join(outRoot, `${fn.name}.adoc`); fs.writeFileSync(outPath, adoc, 'utf8'); partialsWritten++; partialFiles.push(path.relative(process.cwd(), outPath)); } } } // Common/Advanced config snippet YAMLs in modules/components/examples const commonConfig = helpers.commonConfig; const advancedConfig = helpers.advancedConfig; for (const [type, items] of Object.entries(dataObj)) { if (!Array.isArray(items)) continue; for (const item of items) { if (!item.name || !item.config || !Array.isArray(item.config.children)) continue; // Common config const commonYaml = commonConfig(type, item.name, item.config.children); const commonPath = path.join(configExamplesRoot, 'common', type, `${item.name}.yaml`); fs.mkdirSync(path.dirname(commonPath), { recursive: true }); fs.writeFileSync(commonPath, commonYaml.toString(), 'utf8'); partialsWritten++; partialFiles.push(path.relative(process.cwd(), commonPath)); // Advanced config const advYaml = advancedConfig(type, item.name, item.config.children); const advPath = path.join(configExamplesRoot, 'advanced', type, `${item.name}.yaml`); fs.mkdirSync(path.dirname(advPath), { recursive: true }); fs.writeFileSync(advPath, advYaml.toString(), 'utf8'); partialsWritten++; partialFiles.push(path.relative(process.cwd(), advPath)); } } return { partialsWritten, draftsWritten, partialFiles, draftFiles }; } module.exports = { generateRpcnConnectorDocs, mergeOverrides, resolveReferences };