#!/usr/bin/env node /** * Generate OpenAPI Articles Script * * Generates Hugo data files and content pages from OpenAPI specifications * for all InfluxDB products. * * Products are auto-discovered by scanning api-docs/ for .config.yml files. * Hugo paths, menu keys, and static file names are derived from directory * structure and existing Hugo frontmatter. * * This script: * 1. Discovers products from .config.yml files * 2. Cleans output directories (unless --no-clean) * 3. Transforms documentation links in specs * 4. Copies specs to static directory for download * 5. Generates tag-based data fragments (YAML and JSON) * 6. Generates Hugo content pages from article data * * Usage: * node generate-openapi-articles.js # Clean and generate all products * node generate-openapi-articles.js influxdb3-core # Clean and generate single product * node generate-openapi-articles.js --no-clean # Generate without cleaning * node generate-openapi-articles.js --dry-run # Preview what would be cleaned * node generate-openapi-articles.js --skip-fetch # Skip getswagger.sh fetch step * node generate-openapi-articles.js --validate-links # Validate documentation links * * @module generate-openapi-articles */ import { execSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; // Import the OpenAPI to Hugo converter const openapiPathsToHugo = require('./openapi-paths-to-hugo-data/index.js'); // --------------------------------------------------------------------------- // Interfaces // --------------------------------------------------------------------------- /** Operation metadata structure from tag-based articles */ interface OperationMeta { operationId: string; method: string; path: string; summary: string; tags: string[]; compatVersion?: string; externalDocs?: { description: string; url: string; }; } /** Article data structure from articles.yml */ interface ArticleData { articles: Array<{ path: string; fields: { name?: string; title?: string; description?: string; tag?: string; isConceptual?: boolean; showSecuritySchemes?: boolean; tagDescription?: string; menuGroup?: string; staticFilePath?: string; operations?: OperationMeta[]; related?: (string | { title: string; href: string })[]; source?: string; weight?: number; }; }>; } /** Single API entry from .config.yml */ interface ApiConfigEntry { root: string; 'x-influxdata-docs-aliases'?: string[]; } /** Parsed .config.yml file */ interface DotConfig { 'x-influxdata-product-name'?: string; apis?: Record; } /** A resolved API section within a product */ interface DiscoveredApi { /** API key from .config.yml (e.g., 'v3', 'management', 'data') */ apiKey: string; /** Version number from the @-suffix (e.g., '3', '0', '2') */ version: string; /** Resolved full path to the spec file */ specFile: string; /** Hugo section slug: 'api' or 'management-api' */ sectionSlug: string; } /** A fully resolved product discovered from .config.yml */ interface DiscoveredProduct { /** Directory containing .config.yml */ configDir: string; /** Product directory relative to api-docs/ (e.g., 'influxdb3/core') */ productDir: string; /** Human-readable name from x-influxdata-product-name */ productName: string; /** Hugo content directory (e.g., 'content/influxdb3/core') */ pagesDir: string; /** Hugo menu key from cascade.product (e.g., 'influxdb3_core') */ menuKey: string; /** True if hand-maintained api/_index.md has its own menu entry */ skipParentMenu: boolean; /** Static file directory name (e.g., 'influxdb3-core') */ staticDirName: string; /** Resolved API sections */ apis: DiscoveredApi[]; } /** Product data from products.yml with api_path */ interface ProductData { name: string; api_path?: string; alt_link_key?: string; } // --------------------------------------------------------------------------- // Constants and CLI flags // --------------------------------------------------------------------------- const DOCS_ROOT = '.'; const API_DOCS_ROOT = 'api-docs'; const validateLinks = process.argv.includes('--validate-links'); const skipFetch = process.argv.includes('--skip-fetch'); const noClean = process.argv.includes('--no-clean'); const dryRun = process.argv.includes('--dry-run'); // --------------------------------------------------------------------------- // Utility functions // --------------------------------------------------------------------------- /** * Load products with API paths from data/products.yml. * Returns a map of alt_link_key to API path for alt_links generation. */ function loadApiProducts(): Map { const yaml = require('js-yaml'); const productsFile = path.join(DOCS_ROOT, 'data/products.yml'); if (!fs.existsSync(productsFile)) { console.warn('⚠️ products.yml not found, skipping alt_links generation'); return new Map(); } const productsContent = fs.readFileSync(productsFile, 'utf8'); const products = yaml.load(productsContent) as Record; const apiProducts = new Map(); for (const [, product] of Object.entries(products)) { if (product.api_path && product.alt_link_key) { apiProducts.set(product.alt_link_key, product.api_path); } } return apiProducts; } const apiProductsMap = loadApiProducts(); /** Execute a shell command and handle errors */ function execCommand(command: string, description?: string): void { try { if (description) { console.log(`\n${description}...`); } console.log(`Executing: ${command}\n`); execSync(command, { stdio: 'inherit' }); } catch (error) { console.error(`\n❌ Error executing command: ${command}`); if (error instanceof Error) { console.error(error.message); } process.exit(1); } } // --------------------------------------------------------------------------- // Auto-discovery functions // --------------------------------------------------------------------------- /** * Recursively find all .config.yml files under api-docs/. * Excludes the root api-docs/.config.yml and internal directories. */ function findConfigFiles(rootDir: string): string[] { const configs: string[] = []; const skipDirs = new Set([ 'node_modules', 'dist', '_build', 'scripts', 'openapi', ]); function scanDir(dir: string, depth: number): void { if (depth > 5) return; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (skipDirs.has(entry.name)) continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { scanDir(fullPath, depth + 1); } else if (entry.name === '.config.yml' && dir !== rootDir) { configs.push(fullPath); } } } scanDir(rootDir, 0); return configs.sort(); } /** * Parse an API entry key like 'v3@3' into apiKey and version. */ function parseApiEntry(entry: string): { apiKey: string; version: string } { const atIdx = entry.indexOf('@'); if (atIdx === -1) { return { apiKey: entry, version: '0' }; } return { apiKey: entry.substring(0, atIdx), version: entry.substring(atIdx + 1), }; } /** * Determine Hugo section slug from API key. * 'management' → 'management-api', everything else → 'api'. */ function getSectionSlug(apiKey: string): string { if (apiKey === 'management') return 'management-api'; return 'api'; } /** * Derive a clean static directory name from a product directory path. * Replaces path separators and underscores with hyphens. * * @example 'influxdb3/core' → 'influxdb3-core' * @example 'enterprise_influxdb/v1' → 'enterprise-influxdb-v1' */ function deriveStaticDirName(productDir: string): string { return productDir.replace(/[/_]/g, '-'); } /** * Read the cascade.product field from a product's _index.md frontmatter. * This value serves as the Hugo menu key. */ function readMenuKey(pagesDir: string): string { const yaml = require('js-yaml'); const indexFile = path.join(pagesDir, '_index.md'); if (!fs.existsSync(indexFile)) { console.warn(`⚠️ Product index not found: ${indexFile}`); return ''; } const content = fs.readFileSync(indexFile, 'utf8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return ''; try { const fm = yaml.load(fmMatch[1]) as Record; const cascade = fm.cascade as Record | undefined; if (cascade?.product) return cascade.product; // Fallback: first key of the menu map if (fm.menu && typeof fm.menu === 'object') { const keys = Object.keys(fm.menu as Record); if (keys.length > 0) return keys[0]; } } catch { console.warn(`⚠️ Could not parse frontmatter in ${indexFile}`); } return ''; } /** * Check whether a hand-maintained api/_index.md already has a menu entry. * If so, the generator should skip adding its own parent menu entry. * * Only detects genuinely hand-maintained files — files previously generated * by this script (which have articleDataKey in frontmatter) are ignored, * since they'll be regenerated during this run. */ function hasExistingApiMenu(pagesDir: string): boolean { const yaml = require('js-yaml'); const apiIndex = path.join(pagesDir, 'api', '_index.md'); if (!fs.existsSync(apiIndex)) return false; const content = fs.readFileSync(apiIndex, 'utf8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return false; try { const fm = yaml.load(fmMatch[1]) as Record; // Skip files generated by this script (they have articleDataKey) if (fm.articleDataKey) return false; return !!fm.menu; } catch { return false; } } /** * Discover all products by scanning api-docs/ for .config.yml files. * Derives Hugo paths from directory structure and existing frontmatter. */ function discoverProducts(): DiscoveredProduct[] { const yaml = require('js-yaml'); const products: DiscoveredProduct[] = []; const configFiles = findConfigFiles(API_DOCS_ROOT); for (const configPath of configFiles) { const configDir = path.dirname(configPath); const productDir = path.relative(API_DOCS_ROOT, configDir); let config: DotConfig; try { const raw = fs.readFileSync(configPath, 'utf8'); config = yaml.load(raw) as DotConfig; } catch (err) { console.warn(`⚠️ Could not parse ${configPath}: ${err}`); continue; } if (!config.apis || Object.keys(config.apis).length === 0) { continue; } const pagesDir = path.join(DOCS_ROOT, 'content', productDir); const staticDirName = deriveStaticDirName(productDir); const menuKey = readMenuKey(pagesDir); const skipParentMenu = hasExistingApiMenu(pagesDir); // Parse API entries, skipping compatibility specs const apis: DiscoveredApi[] = []; for (const [entryKey, entry] of Object.entries(config.apis)) { const { apiKey, version } = parseApiEntry(entryKey); // Skip v1-compatibility entries (being removed in pipeline restructure) if (apiKey.includes('compatibility')) continue; // Prefer post-processed spec from _build/ (has overlays and tag configs), // fall back to source spec for standalone usage const sourceSpec = path.join(configDir, entry.root); const buildSpec = path.join(API_DOCS_ROOT, '_build', productDir, entry.root); const specFile = fs.existsSync(buildSpec) ? buildSpec : sourceSpec; const sectionSlug = getSectionSlug(apiKey); apis.push({ apiKey, version, specFile, sectionSlug }); } if (apis.length === 0) continue; products.push({ configDir, productDir, productName: config['x-influxdata-product-name'] || productDir, pagesDir, menuKey, skipParentMenu, staticDirName, apis, }); } return products; } // --------------------------------------------------------------------------- // Cleanup functions // --------------------------------------------------------------------------- /** * Get all paths that would be cleaned for a product. * * @param product - The product to clean * @param allStaticDirNames - Names of all products (to avoid prefix collisions) */ function getCleanupPaths( product: DiscoveredProduct, allStaticDirNames: string[] ): { directories: string[]; files: string[]; } { const staticPath = path.join(DOCS_ROOT, 'static/openapi'); const directories: string[] = []; const files: string[] = []; // Tag specs directory: static/openapi/{staticDirName}/ const tagSpecsDir = path.join(staticPath, product.staticDirName); if (fs.existsSync(tagSpecsDir)) { directories.push(tagSpecsDir); } // Article data directory: data/article_data/influxdb/{staticDirName}/ const articleDataDir = path.join( DOCS_ROOT, `data/article_data/influxdb/${product.staticDirName}` ); if (fs.existsSync(articleDataDir)) { directories.push(articleDataDir); } // Content pages: content/{pagesDir}/{sectionSlug}/ for each API for (const api of product.apis) { const contentDir = path.join(product.pagesDir, api.sectionSlug); if (fs.existsSync(contentDir)) { directories.push(contentDir); } } // Root spec files: static/openapi/{staticDirName}*.yml and .json // Avoid matching files that belong to products with longer names // (e.g., 'influxdb-cloud' should not match 'influxdb-cloud-dedicated-*.yml') const longerPrefixes = allStaticDirNames.filter( (n) => n !== product.staticDirName && n.startsWith(product.staticDirName + '-') ); if (fs.existsSync(staticPath)) { const staticFiles = fs.readdirSync(staticPath); staticFiles .filter((f) => { if (!f.startsWith(product.staticDirName)) return false; // Exclude files belonging to a longer-named product for (const longer of longerPrefixes) { if (f.startsWith(longer)) return false; } return f.endsWith('.yml') || f.endsWith('.json'); }) .forEach((f) => { files.push(path.join(staticPath, f)); }); } return { directories, files }; } /** Clean output directories for a product before regeneration. */ function cleanProductOutputs( product: DiscoveredProduct, allStaticDirNames: string[] ): void { const { directories, files } = getCleanupPaths(product, allStaticDirNames); for (const dir of directories) { console.log(`🧹 Removing directory: ${dir}`); fs.rmSync(dir, { recursive: true, force: true }); } for (const file of files) { console.log(`🧹 Removing file: ${file}`); fs.unlinkSync(file); } const total = directories.length + files.length; if (total > 0) { console.log( `✓ Cleaned ${directories.length} directories, ${files.length} files for ${product.staticDirName}` ); } } /** Display dry-run preview of what would be cleaned. */ function showDryRunPreview( product: DiscoveredProduct, allStaticDirNames: string[] ): void { const { directories, files } = getCleanupPaths(product, allStaticDirNames); console.log( `\nDRY RUN: Would clean the following for ${product.staticDirName}:\n` ); if (directories.length > 0) { console.log('Directories to remove:'); directories.forEach((dir) => console.log(` - ${dir}`)); } if (files.length > 0) { console.log('\nFiles to remove:'); files.forEach((file) => console.log(` - ${file}`)); } if (directories.length === 0 && files.length === 0) { console.log(' (no files to clean)'); } console.log( `\nSummary: ${directories.length} directories, ${files.length} files would be removed` ); } // --------------------------------------------------------------------------- // Link transformation // --------------------------------------------------------------------------- /** Fields that can contain markdown with links */ const MARKDOWN_FIELDS = new Set(['description', 'summary']); /** Link placeholder pattern */ const LINK_PATTERN = /\/influxdb\/version\//g; /** * Transform documentation links in OpenAPI spec markdown fields. * Replaces `/influxdb/version/` with the actual product path. */ function transformDocLinks( spec: Record, productPath: string ): Record { function transformValue(value: unknown): unknown { if (typeof value === 'string') { return value.replace(LINK_PATTERN, `${productPath}/`); } if (Array.isArray(value)) { return value.map(transformValue); } if (value !== null && typeof value === 'object') { return transformObject(value as Record); } return value; } function transformObject( obj: Record ): Record { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (MARKDOWN_FIELDS.has(key) && typeof value === 'string') { result[key] = value.replace(LINK_PATTERN, `${productPath}/`); } else if (value !== null && typeof value === 'object') { result[key] = transformValue(value); } else { result[key] = value; } } return result; } return transformObject(spec); } /** * Resolve a URL path to a content file path. * * @example '/influxdb3/core/api/auth/' → 'content/influxdb3/core/api/auth/_index.md' */ function resolveContentPath(urlPath: string, contentDir: string): string { const normalized = urlPath.replace(/\/$/, ''); const indexPath = path.join(contentDir, normalized, '_index.md'); const directPath = path.join(contentDir, normalized + '.md'); if (fs.existsSync(indexPath)) return indexPath; if (fs.existsSync(directPath)) return directPath; return indexPath; } /** * Validate that transformed links point to existing content. */ function validateDocLinks( spec: Record, contentDir: string ): string[] { const errors: string[] = []; const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g; function extractLinks(value: unknown, jsonPath: string): void { if (typeof value === 'string') { let match; while ((match = linkPattern.exec(value)) !== null) { const [, linkText, linkUrl] = match; if (linkUrl.startsWith('/') && !linkUrl.startsWith('//')) { const contentPath = resolveContentPath(linkUrl, contentDir); if (!fs.existsSync(contentPath)) { errors.push( `Broken link at ${jsonPath}: [${linkText}](${linkUrl})` ); } } } linkPattern.lastIndex = 0; } else if (Array.isArray(value)) { value.forEach((item, index) => extractLinks(item, `${jsonPath}[${index}]`) ); } else if (value !== null && typeof value === 'object') { for (const [key, val] of Object.entries( value as Record )) { extractLinks(val, `${jsonPath}.${key}`); } } } extractLinks(spec, 'spec'); return errors; } // --------------------------------------------------------------------------- // Page generation // --------------------------------------------------------------------------- /** * Options for generating tag-based pages from article data */ interface GenerateTagPagesOptions { articlesPath: string; contentPath: string; sectionSlug: string; menuKey?: string; menuParent?: string; productDescription?: string; skipParentMenu?: boolean; specDownloadPath: string; articleDataKey: string; articleSection: string; pathSpecFiles?: Map; } /** * Generate Hugo content pages from tag-based article data. * * Creates markdown files with frontmatter from article metadata. * Each article becomes a page with type: api that renders via Hugo-native * templates. Includes operation metadata for TOC generation. */ function generateTagPagesFromArticleData( options: GenerateTagPagesOptions ): void { const { articlesPath, contentPath, sectionSlug, menuKey, menuParent, productDescription, skipParentMenu, specDownloadPath, articleDataKey, articleSection, } = options; const yaml = require('js-yaml'); const articlesFile = path.join(articlesPath, 'articles.yml'); if (!fs.existsSync(articlesFile)) { console.warn(`⚠️ Articles file not found: ${articlesFile}`); return; } const articlesContent = fs.readFileSync(articlesFile, 'utf8'); const data = yaml.load(articlesContent) as ArticleData; if (!data.articles || !Array.isArray(data.articles)) { console.warn(`⚠️ No articles found in ${articlesFile}`); return; } if (!fs.existsSync(contentPath)) { fs.mkdirSync(contentPath, { recursive: true }); } // Generate parent _index.md for the section const sectionDir = path.join(contentPath, sectionSlug); const parentIndexFile = path.join(sectionDir, '_index.md'); if (!fs.existsSync(sectionDir)) { fs.mkdirSync(sectionDir, { recursive: true }); } if (!fs.existsSync(parentIndexFile)) { const apiDescription = productDescription || `Use the InfluxDB HTTP API to write data, query data, and manage databases, tables, and tokens.`; const parentFrontmatter: Record = { title: menuParent || 'InfluxDB HTTP API', description: apiDescription, weight: 104, type: 'api', articleDataKey, articleSection, }; if (menuKey && !skipParentMenu) { parentFrontmatter.menu = { [menuKey]: { name: menuParent || 'InfluxDB HTTP API', identifier: `api-reference-${articleDataKey}-${sectionSlug}`, parent: 'Reference', }, }; } if (apiProductsMap.size > 0) { const altLinks: Record = {}; apiProductsMap.forEach((apiPath, productName) => { altLinks[productName] = apiPath; }); parentFrontmatter.alt_links = altLinks; } const introText = apiDescription.replace( 'InfluxDB', '{{% product-name %}}' ); const parentContent = `--- ${yaml.dump(parentFrontmatter)}--- ${introText} {{< children >}} `; fs.writeFileSync(parentIndexFile, parentContent); console.log(`✓ Generated parent index at ${parentIndexFile}`); } // Generate "All endpoints" page const allEndpointsDir = path.join(sectionDir, 'all-endpoints'); const allEndpointsFile = path.join(allEndpointsDir, '_index.md'); if (!fs.existsSync(allEndpointsDir)) { fs.mkdirSync(allEndpointsDir, { recursive: true }); } const allEndpointsFrontmatter: Record = { title: 'All endpoints', description: `View all API endpoints sorted by path.`, type: 'api', layout: 'all-endpoints', weight: 999, isAllEndpoints: true, articleDataKey, articleSection, }; if (menuKey) { allEndpointsFrontmatter.menu = { [menuKey]: { name: 'All endpoints', identifier: `all-endpoints-${articleDataKey}-${sectionSlug}`, parent: menuParent || 'InfluxDB HTTP API', }, }; } if (apiProductsMap.size > 0) { const altLinks: Record = {}; apiProductsMap.forEach((apiPath, productName) => { altLinks[productName] = apiPath; }); allEndpointsFrontmatter.alt_links = altLinks; } const allEndpointsContent = `--- ${yaml.dump(allEndpointsFrontmatter)}--- All {{% product-name %}} API endpoints, sorted by path. `; fs.writeFileSync(allEndpointsFile, allEndpointsContent); console.log(`✓ Generated all-endpoints page at ${allEndpointsFile}`); // Generate a page for each article (tag) for (const article of data.articles) { const pagePath = path.join(contentPath, article.path); const pageFile = path.join(pagePath, '_index.md'); if (!fs.existsSync(pagePath)) { fs.mkdirSync(pagePath, { recursive: true }); } const title = article.fields.title || article.fields.name || article.path; const isConceptual = article.fields.isConceptual === true; const weight = article.fields.weight ?? 100; const frontmatter: Record = { title, description: article.fields.description || `API reference for ${title}`, type: 'api', layout: isConceptual ? 'single' : 'list', staticFilePath: article.fields.staticFilePath, weight, tag: article.fields.tag, isConceptual, menuGroup: article.fields.menuGroup, specDownloadPath, articleDataKey, articleSection, }; if ( !isConceptual && article.fields.operations && article.fields.operations.length > 0 ) { frontmatter.operations = article.fields.operations; } if (isConceptual && article.fields.tagDescription) { frontmatter.tagDescription = article.fields.tagDescription; } if (article.fields.showSecuritySchemes) { frontmatter.showSecuritySchemes = true; } // Add related links if present if ( article.fields.related && Array.isArray(article.fields.related) && article.fields.related.length > 0 ) { frontmatter.related = article.fields.related; } // Add client library related link for InfluxDB 3 products if (contentPath.includes('influxdb3/') && !isConceptual) { const influxdb3Match = contentPath.match(/influxdb3\/([^/]+)/); if (influxdb3Match) { const productSegment = influxdb3Match[1]; const clientLibLink = { title: 'InfluxDB 3 API client libraries', href: `/influxdb3/${productSegment}/reference/client-libraries/v3/`, }; const existing = (frontmatter.related as Array<{ title: string; href: string }>) || []; const alreadyHas = existing.some( (r) => typeof r === 'object' && r.href === clientLibLink.href ); if (!alreadyHas) { frontmatter.related = [...existing, clientLibLink]; } } } if (apiProductsMap.size > 0) { const altLinks: Record = {}; apiProductsMap.forEach((apiPath, productName) => { altLinks[productName] = apiPath; }); frontmatter.alt_links = altLinks; } const pageContent = `--- ${yaml.dump(frontmatter)}--- `; fs.writeFileSync(pageFile, pageContent); } console.log( `✓ Generated ${data.articles.length} tag-based content pages in ${contentPath}` ); } // --------------------------------------------------------------------------- // Spec processing // --------------------------------------------------------------------------- /** * Process a single API section: transform links, write static spec, * generate tag data, and create Hugo content pages. */ function processApiSection( product: DiscoveredProduct, api: DiscoveredApi, staticBasePath: string ): void { const yaml = require('js-yaml'); const isDualApi = product.apis.length > 1; console.log(`\n📄 Processing ${api.sectionSlug} section (${api.apiKey})`); // --- 1. Determine paths --- // Root spec download: single → {dir}.yml, dual → {dir}-{section}.yml const specSuffix = isDualApi ? `-${api.sectionSlug}` : ''; const staticSpecPath = path.join( staticBasePath, `${product.staticDirName}${specSuffix}.yml` ); const staticJsonSpecPath = staticSpecPath.replace('.yml', '.json'); // Tag specs directory const tagSpecsBase = isDualApi ? path.join(staticBasePath, product.staticDirName, api.sectionSlug) : path.join(staticBasePath, product.staticDirName); // Article data const articlesPath = path.join( DOCS_ROOT, 'data/article_data/influxdb', product.staticDirName, api.sectionSlug ); // Download path for frontmatter const specDownloadPath = `/openapi/${product.staticDirName}${specSuffix}.yml`; // Path spec files for per-operation rendering const pathSpecsDir = isDualApi ? path.join(staticBasePath, product.staticDirName, api.sectionSlug, 'paths') : path.join(staticBasePath, product.staticDirName, 'paths'); // --- 2. Read and transform spec --- if (!fs.existsSync(api.specFile)) { console.warn(`⚠️ Spec file not found: ${api.specFile}`); return; } const specContent = fs.readFileSync(api.specFile, 'utf8'); const specObject = yaml.load(specContent) as Record; const productPath = `/${product.productDir}`; const transformedSpec = transformDocLinks(specObject, productPath); console.log( `✓ Transformed documentation links for ${api.apiKey} to ${productPath}` ); // Validate links if enabled if (validateLinks) { const contentDir = path.join(DOCS_ROOT, 'content'); const linkErrors = validateDocLinks(transformedSpec, contentDir); if (linkErrors.length > 0) { console.warn(`\n⚠️ Link validation warnings for ${api.specFile}:`); linkErrors.forEach((err) => console.warn(` ${err}`)); } } // --- 3. Write transformed spec to static folder --- if (!fs.existsSync(staticBasePath)) { fs.mkdirSync(staticBasePath, { recursive: true }); } fs.writeFileSync(staticSpecPath, yaml.dump(transformedSpec)); console.log(`✓ Wrote transformed spec to ${staticSpecPath}`); fs.writeFileSync( staticJsonSpecPath, JSON.stringify(transformedSpec, null, 2) ); console.log(`✓ Generated JSON spec at ${staticJsonSpecPath}`); // --- 4. Generate tag-based data --- console.log( `\n📋 Generating tag-based data for ${api.apiKey} in ${tagSpecsBase}...` ); openapiPathsToHugo.generateHugoDataByTag({ specFile: staticSpecPath, dataOutPath: tagSpecsBase, articleOutPath: articlesPath, includePaths: true, }); // Generate path-specific specs openapiPathsToHugo.generatePathSpecificSpecs(staticSpecPath, pathSpecsDir); // --- 5. Generate Hugo content pages --- generateTagPagesFromArticleData({ articlesPath, contentPath: product.pagesDir, sectionSlug: api.sectionSlug, menuKey: product.menuKey, menuParent: 'InfluxDB HTTP API', skipParentMenu: product.skipParentMenu, specDownloadPath, articleDataKey: product.staticDirName, articleSection: api.sectionSlug, }); } /** * Process a single product: clean outputs and process each API section. */ function processProduct( product: DiscoveredProduct, allStaticDirNames: string[] ): void { console.log('\n' + '='.repeat(80)); console.log(`Processing ${product.productName}`); console.log('='.repeat(80)); // Clean output directories before regeneration if (!noClean && !dryRun) { cleanProductOutputs(product, allStaticDirNames); } const staticBasePath = path.join(DOCS_ROOT, 'static/openapi'); // Fetch specs if needed if (!skipFetch) { const getswaggerScript = path.join(API_DOCS_ROOT, 'getswagger.sh'); if (fs.existsSync(getswaggerScript)) { // The build function in generate-api-docs.sh handles per-product // fetching. When called standalone, use product directory name. execCommand( `cd ${API_DOCS_ROOT} && ./getswagger.sh ${product.productDir} -B`, `Fetching OpenAPI spec for ${product.productName}` ); } else { console.log(`⚠️ getswagger.sh not found, skipping fetch step`); } } else { console.log(`⏭️ Skipping getswagger.sh (--skip-fetch flag set)`); } // Process each API section independently for (const api of product.apis) { processApiSection(product, api, staticBasePath); } console.log(`\n✅ Successfully processed ${product.productName}\n`); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main(): void { const args = process.argv.slice(2).filter((arg) => !arg.startsWith('--')); // Discover all products from .config.yml files const allProducts = discoverProducts(); if (allProducts.length === 0) { console.error( '❌ No products discovered. Ensure .config.yml files exist under api-docs/.' ); process.exit(1); } // Determine which products to process let productsToProcess: DiscoveredProduct[]; if (args.length === 0) { productsToProcess = allProducts; console.log( `\n📋 Discovered ${allProducts.length} products, processing all...\n` ); } else { // Match by staticDirName or productDir productsToProcess = []; const invalid: string[] = []; for (const arg of args) { const found = allProducts.find( (p) => p.staticDirName === arg || p.productDir === arg || p.productDir.replace(/\//g, '-') === arg ); if (found) { productsToProcess.push(found); } else { invalid.push(arg); } } if (invalid.length > 0) { console.error( `\n❌ Unknown product identifier(s): ${invalid.join(', ')}` ); console.error('\nDiscovered products:'); allProducts.forEach((p) => { console.error( ` - ${p.staticDirName} (${p.productName}) [${p.productDir}]` ); }); process.exit(1); } console.log( `\n📋 Processing specified products: ${productsToProcess.map((p) => p.staticDirName).join(', ')}\n` ); } // Collect all staticDirNames for prefix-safe cleanup const allStaticDirNames = allProducts.map((p) => p.staticDirName); // Handle dry-run mode if (dryRun) { console.log('\n📋 DRY RUN MODE - No files will be modified\n'); productsToProcess.forEach((p) => showDryRunPreview(p, allStaticDirNames)); console.log('\nDry run complete. No files were modified.'); return; } // Process each product productsToProcess.forEach((product) => { processProduct(product, allStaticDirNames); }); console.log('\n' + '='.repeat(80)); console.log('✅ All products processed successfully!'); console.log('='.repeat(80) + '\n'); } // Execute if run directly if (require.main === module) { main(); } // Export for use as a module export { discoverProducts, processProduct, processApiSection, transformDocLinks, validateDocLinks, resolveContentPath, deriveStaticDirName, getSectionSlug, parseApiEntry, readMenuKey, MARKDOWN_FIELDS, LINK_PATTERN, };