diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..8d0877a4c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,56 @@ +--- +branch: feat-api-uplift +repo: docs-v2 +created: 2025-12-02T15:28:32Z +status: in-progress +--- + +# feat-api-uplift + +## Overview + +Replace the current API reference documentation implementation (RapiDoc web components) with Hugo-native templates. + +## Phase 1: Core Infrastructure (completed) + +### Build process + +- `yarn build:api` parses OpenAPI specs into Hugo data +- Generates Hugo pages with frontmatter for Algolia search integration +- Static JSON chunks for faster page loads + +### OpenAPI tag cleanup + +- Removed unused tags from OpenAPI specs +- Updated tags to be consistent and descriptive + +### Hugo-native POC + +- Implemented Hugo-native templates in `layouts/partials/api/hugo-native/` +- Tested with InfluxDB 3 Core product + +## Phase 2: Migration to Hugo-Native (in progress) + +**Plan**: @plans/2026-02-13-hugo-native-api-migration.md + +### Task Order + +1. ✅ **Promote Hugo-native templates** - Move from POC to production +2. ✅ **Remove RapiDoc templates** - Delete templates and partials +3. ✅ **Remove RapiDoc JavaScript** - Delete components +4. ✅ **Remove operation pages** - Delete individual operation page generation +5. ✅ **Update Cypress tests** - Simplify tests for static HTML +6. ✅ **Clean up styles** - Remove RapiDoc CSS and dead auth modal code +7. **Fix generation script cleanup** - Add `--clean` flag (planned) +8. **Apply Cache Data tag split** - Enterprise spec update (planned) +9. **Migrate remaining products** - Apply to all InfluxDB products (planned) + +## Related Files + +- Branch: `feat-api-uplift` +- Plan: `plans/2026-02-13-hugo-native-api-migration.md` + +## Notes + +- Use Chrome devtools and Cypress to debug +- No individual operation pages - operations accessed only via tag pages diff --git a/api-docs/scripts/dist/generate-openapi-articles.js b/api-docs/scripts/dist/generate-openapi-articles.js index 36e9697fd..b882e3720 100644 --- a/api-docs/scripts/dist/generate-openapi-articles.js +++ b/api-docs/scripts/dist/generate-openapi-articles.js @@ -157,7 +157,7 @@ function generateDataFromOpenAPI(specFile, dataOutPath, articleOutPath) { * Generate Hugo content pages from article data * * Creates markdown files with frontmatter from article metadata. - * Each article becomes a page with type: api that renders via RapiDoc. + * Each article becomes a page with type: api that renders via Hugo-native templates. * * @param options - Generation options */ @@ -278,7 +278,7 @@ ${yaml.dump(frontmatter)}--- * 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 RapiDoc. + * Each article becomes a page with type: api that renders via Hugo-native templates. * Includes operation metadata for TOC generation. * * @param options - Generation options @@ -449,165 +449,9 @@ ${yaml.dump(frontmatter)}--- } console.log(`✓ Generated ${data.articles.length} tag-based content pages in ${contentPath}`); // NOTE: Path page generation is disabled - all operations are now displayed - // inline on tag pages using RapiDoc with hash-based navigation for deep linking. - // The tag pages render all operations in a single scrollable view with a - // server-side generated TOC for quick navigation. - // - // Previously this generated individual pages per API path: - // generatePathPages({ articlesPath, contentPath, pathSpecFiles }); -} -/** - * Convert API path to URL-safe slug with normalized version prefix - * - * Transforms an API path to a URL-friendly format: - * - Removes leading "/api" prefix (added by parent directory structure) - * - Ensures all paths have a version prefix (defaults to v1 if none) - * - Removes leading slash - * - Removes curly braces from path parameters (e.g., {db} → db) - * - * Examples: - * - "/write" → "v1/write" - * - "/api/v3/configure/database" → "v3/configure/database" - * - "/api/v3/configure/database/{db}" → "v3/configure/database/db" - * - "/api/v2/write" → "v2/write" - * - "/health" → "v1/health" - * - * @param apiPath - The API path (e.g., "/write", "/api/v3/write_lp") - * @returns URL-safe path slug with version prefix (e.g., "v1/write", "v3/configure/database") - */ -function apiPathToSlug(apiPath) { - // Remove leading "/api" prefix if present - let normalizedPath = apiPath.replace(/^\/api/, ''); - // Remove leading slash - normalizedPath = normalizedPath.replace(/^\//, ''); - // If path doesn't start with version prefix, add v1/ - if (!/^v\d+\//.test(normalizedPath)) { - normalizedPath = `v1/${normalizedPath}`; - } - // Remove curly braces from path parameters (e.g., {db} → db) - // to avoid URL encoding issues in Hugo - normalizedPath = normalizedPath.replace(/[{}]/g, ''); - return normalizedPath; -} -/** Method sort order for consistent display */ -const METHOD_ORDER = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; -/** - * Generate standalone Hugo content pages for each API path - * - * Creates individual pages at path-based URLs like /api/v3/configure/database/ - * for each unique API path. Each page includes all HTTP methods (operations) - * for that path, rendered using RapiDoc with match-type='includes'. - * - * When pathSpecFiles is provided, uses path-specific specs for isolated rendering. - * Falls back to tag-based specs when pathSpecFiles is not available. - * - * @param options - Generation options - */ -function generatePathPages(options) { - const { articlesPath, contentPath, pathSpecFiles } = 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; - } - // Read articles data - const articlesContent = fs.readFileSync(articlesFile, 'utf8'); - const data = yaml.load(articlesContent); - if (!data.articles || !Array.isArray(data.articles)) { - console.warn(`⚠️ No articles found in ${articlesFile}`); - return; - } - // Collect all operations and group by API path - const pathOperations = new Map(); - // Process each article (tag) and collect operations by path - for (const article of data.articles) { - // Skip conceptual articles (they don't have operations) - if (article.fields.isConceptual) { - continue; - } - const operations = article.fields.operations || []; - const tagSpecFile = article.fields.staticFilePath; - const tagName = article.fields.tag || article.fields.name || ''; - for (const op of operations) { - const existing = pathOperations.get(op.path); - if (existing) { - // Add operation to existing path group - existing.operations.push(op); - } - else { - // Create new path group - pathOperations.set(op.path, { - operations: [op], - tagSpecFile, - tagName, - }); - } - } - } - let pathCount = 0; - // Generate a page for each unique API path - for (const [apiPath, pathData] of pathOperations) { - // Build page path: api/{path}/ - // e.g., /api/v3/configure/database -> api/v3/configure/database/ - const pathSlug = apiPathToSlug(apiPath); - // Only add 'api/' prefix if the path doesn't already start with 'api/' - const basePath = pathSlug.startsWith('api/') ? pathSlug : `api/${pathSlug}`; - const pathDir = path.join(contentPath, basePath); - const pathFile = path.join(pathDir, '_index.md'); - // Create directory if needed - if (!fs.existsSync(pathDir)) { - fs.mkdirSync(pathDir, { recursive: true }); - } - // Sort operations by method order - const sortedOperations = [...pathData.operations].sort((a, b) => { - const aIndex = METHOD_ORDER.indexOf(a.method.toUpperCase()); - const bIndex = METHOD_ORDER.indexOf(b.method.toUpperCase()); - return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex); - }); - // Use first operation's summary or construct from methods - const methods = sortedOperations.map((op) => op.method.toUpperCase()); - const title = sortedOperations.length === 1 && sortedOperations[0].summary - ? sortedOperations[0].summary - : `${apiPath}`; - // Determine spec file - use path-specific spec if available - const pathSpecFile = pathSpecFiles?.get(apiPath); - const specFile = pathSpecFile || pathData.tagSpecFile; - const frontmatter = { - title, - description: `API reference for ${apiPath} - ${methods.join(', ')}`, - type: 'api-path', - layout: 'path', - // RapiDoc configuration - specFile, - apiPath, - // Include all operations for TOC generation - operations: sortedOperations.map((op) => ({ - operationId: op.operationId, - method: op.method, - path: op.path, - summary: op.summary, - ...(op.compatVersion && { compatVersion: op.compatVersion }), - })), - tag: pathData.tagName, - }; - // Collect related links from all operations - const relatedLinks = []; - for (const op of sortedOperations) { - if (op.externalDocs?.url && !relatedLinks.includes(op.externalDocs.url)) { - relatedLinks.push(op.externalDocs.url); - } - } - if (relatedLinks.length > 0) { - frontmatter.related = relatedLinks; - } - const pageContent = `--- -${yaml.dump(frontmatter)}--- -`; - fs.writeFileSync(pathFile, pageContent); - pathCount++; - } - console.log(`✓ Generated ${pathCount} path pages in ${contentPath}/api/`); + // inline on tag pages using Hugo-native templates with hash-based navigation + // for deep linking. The tag pages render all operations in a single scrollable + // view with a server-side generated TOC for quick navigation. } /** * Merge article data from multiple specs into a single articles.yml diff --git a/api-docs/scripts/dist/openapi-paths-to-hugo-data/index.js b/api-docs/scripts/dist/openapi-paths-to-hugo-data/index.js index 812555568..c455fb6bf 100644 --- a/api-docs/scripts/dist/openapi-paths-to-hugo-data/index.js +++ b/api-docs/scripts/dist/openapi-paths-to-hugo-data/index.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; /** * OpenAPI to Hugo Data Converter * @@ -7,70 +7,47 @@ * * @module openapi-paths-to-hugo-data */ -var __createBinding = - (this && this.__createBinding) || - (Object.create - ? function (o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if ( - !desc || - ('get' in desc ? !m.__esModule : desc.writable || desc.configurable) - ) { - desc = { - enumerable: true, - get: function () { - return m[k]; - }, - }; - } - Object.defineProperty(o, k2, desc); - } - : function (o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; - }); -var __setModuleDefault = - (this && this.__setModuleDefault) || - (Object.create - ? function (o, v) { - Object.defineProperty(o, 'default', { enumerable: true, value: v }); - } - : function (o, v) { - o['default'] = v; - }); -var __importStar = - (this && this.__importStar) || - (function () { - var ownKeys = function (o) { - ownKeys = - Object.getOwnPropertyNames || - function (o) { - var ar = []; - for (var k in o) - if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; }; - return ownKeys(o); + return ownKeys(o); }; return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) - for (var k = ownKeys(mod), i = 0; i < k.length; i++) - if (k[i] !== 'default') __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; }; - })(); -Object.defineProperty(exports, '__esModule', { value: true }); +})(); +Object.defineProperty(exports, "__esModule", { value: true }); exports.writePathSpecificSpecs = writePathSpecificSpecs; exports.generateHugoDataByTag = generateHugoDataByTag; exports.generateHugoData = generateHugoData; exports.generatePathSpecificSpecs = generatePathSpecificSpecs; -const yaml = __importStar(require('js-yaml')); -const fs = __importStar(require('fs')); -const path = __importStar(require('path')); +const yaml = __importStar(require("js-yaml")); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); /** * Read a YAML file and parse it * @@ -79,8 +56,8 @@ const path = __importStar(require('path')); * @returns Parsed YAML content */ function readFile(filepath, encoding = 'utf8') { - const content = fs.readFileSync(filepath, encoding); - return yaml.load(content); + const content = fs.readFileSync(filepath, encoding); + return yaml.load(content); } /** * Write data to a YAML file @@ -89,7 +66,7 @@ function readFile(filepath, encoding = 'utf8') { * @param outputTo - Output file path */ function writeDataFile(data, outputTo) { - fs.writeFileSync(outputTo, yaml.dump(data)); + fs.writeFileSync(outputTo, yaml.dump(data)); } /** * Write data to a JSON file @@ -98,22 +75,22 @@ function writeDataFile(data, outputTo) { * @param outputTo - Output file path */ function writeJsonFile(data, outputTo) { - fs.writeFileSync(outputTo, JSON.stringify(data, null, 2)); + fs.writeFileSync(outputTo, JSON.stringify(data, null, 2)); } /** * OpenAPI utility functions */ const openapiUtils = { - /** - * Check if a path fragment is a placeholder (e.g., {id}) - * - * @param str - Path fragment to check - * @returns True if the fragment is a placeholder - */ - isPlaceholderFragment(str) { - const placeholderRegex = /^\{.*\}$/; - return placeholderRegex.test(str); - }, + /** + * Check if a path fragment is a placeholder (e.g., {id}) + * + * @param str - Path fragment to check + * @returns True if the fragment is a placeholder + */ + isPlaceholderFragment(str) { + const placeholderRegex = /^\{.*\}$/; + return placeholderRegex.test(str); + }, }; /** * Convert tag name to URL-friendly slug @@ -122,35 +99,35 @@ const openapiUtils = { * @returns URL-friendly slug (e.g., "write-data", "processing-engine") */ function slugifyTag(tagName) { - return tagName - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); + return tagName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); } /** * Menu group mappings for tag-based navigation * Maps OpenAPI tags to sidebar groups */ const TAG_MENU_GROUPS = { - // Concepts group - 'Quick start': 'Concepts', - Authentication: 'Concepts', - 'Headers and parameters': 'Concepts', - 'Response codes': 'Concepts', - // Data Operations group - 'Write data': 'Data Operations', - 'Query data': 'Data Operations', - 'Cache data': 'Data Operations', - // Administration group - Database: 'Administration', - Table: 'Administration', - Token: 'Administration', - // Processing Engine group - 'Processing engine': 'Processing Engine', - // Server group - 'Server information': 'Server', - // Compatibility group - 'Compatibility endpoints': 'Compatibility', + // Concepts group + 'Quick start': 'Concepts', + Authentication: 'Concepts', + 'Headers and parameters': 'Concepts', + 'Response codes': 'Concepts', + // Data Operations group + 'Write data': 'Data Operations', + 'Query data': 'Data Operations', + 'Cache data': 'Data Operations', + // Administration group + Database: 'Administration', + Table: 'Administration', + Token: 'Administration', + // Processing Engine group + 'Processing engine': 'Processing Engine', + // Server group + 'Server information': 'Server', + // Compatibility group + 'Compatibility endpoints': 'Compatibility', }; /** * Get menu group for a tag @@ -159,20 +136,20 @@ const TAG_MENU_GROUPS = { * @returns Menu group name or 'Other' if not mapped */ function getMenuGroupForTag(tagName) { - return TAG_MENU_GROUPS[tagName] || 'Other'; + return TAG_MENU_GROUPS[tagName] || 'Other'; } /** * HTTP methods to check for operations */ const HTTP_METHODS = [ - 'get', - 'post', - 'put', - 'patch', - 'delete', - 'options', - 'head', - 'trace', + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace', ]; /** * Extract all operations from an OpenAPI document grouped by tag @@ -181,47 +158,45 @@ const HTTP_METHODS = [ * @returns Map of tag name to operations with that tag */ function extractOperationsByTag(openapi) { - const tagOperations = new Map(); - Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => { - HTTP_METHODS.forEach((method) => { - const operation = pathItem[method]; - if (operation) { - const opMeta = { - operationId: operation.operationId || `${method}-${pathKey}`, - method: method.toUpperCase(), - path: pathKey, - summary: operation.summary || '', - tags: operation.tags || [], - }; - // Extract compatibility version if present - if (operation['x-compatibility-version']) { - opMeta.compatVersion = operation['x-compatibility-version']; - } - // Extract externalDocs if present - if (operation.externalDocs) { - opMeta.externalDocs = { - description: operation.externalDocs.description || '', - url: operation.externalDocs.url, - }; - } - // Extract x-influxdatadocs-related if present - if ( - operation['x-influxdatadocs-related'] && - Array.isArray(operation['x-influxdatadocs-related']) - ) { - opMeta.related = operation['x-influxdatadocs-related']; - } - // Add operation to each of its tags - (operation.tags || []).forEach((tag) => { - if (!tagOperations.has(tag)) { - tagOperations.set(tag, []); - } - tagOperations.get(tag).push(opMeta); + const tagOperations = new Map(); + Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => { + HTTP_METHODS.forEach((method) => { + const operation = pathItem[method]; + if (operation) { + const opMeta = { + operationId: operation.operationId || `${method}-${pathKey}`, + method: method.toUpperCase(), + path: pathKey, + summary: operation.summary || '', + tags: operation.tags || [], + }; + // Extract compatibility version if present + if (operation['x-compatibility-version']) { + opMeta.compatVersion = operation['x-compatibility-version']; + } + // Extract externalDocs if present + if (operation.externalDocs) { + opMeta.externalDocs = { + description: operation.externalDocs.description || '', + url: operation.externalDocs.url, + }; + } + // Extract x-influxdatadocs-related if present + if (operation['x-influxdatadocs-related'] && + Array.isArray(operation['x-influxdatadocs-related'])) { + opMeta.related = operation['x-influxdatadocs-related']; + } + // Add operation to each of its tags + (operation.tags || []).forEach((tag) => { + if (!tagOperations.has(tag)) { + tagOperations.set(tag, []); + } + tagOperations.get(tag).push(opMeta); + }); + } }); - } }); - }); - return tagOperations; + return tagOperations; } /** * Write OpenAPI specs grouped by tag to separate files @@ -232,85 +207,83 @@ function extractOperationsByTag(openapi) { * @param outPath - Output directory path */ function writeTagOpenapis(openapi, prefix, outPath) { - const tagOperations = extractOperationsByTag(openapi); - // Process each tag - tagOperations.forEach((operations, tagName) => { - // Deep copy openapi - const doc = JSON.parse(JSON.stringify(openapi)); - // Filter paths to only include those with operations for this tag - const filteredPaths = {}; - Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => { - const filteredPathItem = {}; - let hasOperations = false; - HTTP_METHODS.forEach((method) => { - const operation = pathItem[method]; - if (operation?.tags?.includes(tagName)) { - // Clone the operation and restrict tags to only this tag - // This prevents RapiDoc from rendering the operation multiple times - // (once per tag) when an operation belongs to multiple tags - const filteredOperation = { ...operation, tags: [tagName] }; - filteredPathItem[method] = filteredOperation; - hasOperations = true; + const tagOperations = extractOperationsByTag(openapi); + // Process each tag + tagOperations.forEach((operations, tagName) => { + // Deep copy openapi + const doc = JSON.parse(JSON.stringify(openapi)); + // Filter paths to only include those with operations for this tag + const filteredPaths = {}; + Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => { + const filteredPathItem = {}; + let hasOperations = false; + HTTP_METHODS.forEach((method) => { + const operation = pathItem[method]; + if (operation?.tags?.includes(tagName)) { + // Clone the operation and restrict tags to only this tag + // This prevents the operation from being rendered multiple times + // (once per tag) when an operation belongs to multiple tags + const filteredOperation = { ...operation, tags: [tagName] }; + filteredPathItem[method] = filteredOperation; + hasOperations = true; + } + }); + // Include path-level parameters if we have operations + if (hasOperations) { + if (pathItem.parameters) { + filteredPathItem.parameters = pathItem.parameters; + } + filteredPaths[pathKey] = filteredPathItem; + } + }); + doc.paths = filteredPaths; + // Filter tags to only include this tag (and trait tags for context) + if (doc.tags) { + doc.tags = doc.tags.filter((tag) => tag.name === tagName || tag['x-traitTag']); } - }); - // Include path-level parameters if we have operations - if (hasOperations) { - if (pathItem.parameters) { - filteredPathItem.parameters = pathItem.parameters; + // Update info + const tagSlug = slugifyTag(tagName); + doc.info.title = tagName; + doc.info.description = `API reference for ${tagName}`; + doc['x-tagGroup'] = tagName; + try { + if (!fs.existsSync(outPath)) { + fs.mkdirSync(outPath, { recursive: true }); + } + const baseFilename = `${prefix}${tagSlug}`; + const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); + const jsonPath = path.resolve(outPath, `${baseFilename}.json`); + writeDataFile(doc, yamlPath); + writeJsonFile(doc, jsonPath); + console.log(`Generated tag spec: ${baseFilename}.yaml (${Object.keys(filteredPaths).length} paths, ${operations.length} operations)`); + } + catch (err) { + console.error(`Error writing tag group ${tagName}:`, err); + } + }); + // Also create specs for conceptual tags (x-traitTag) without operations + (openapi.tags || []).forEach((tag) => { + if (tag['x-traitTag'] && !tagOperations.has(tag.name)) { + const doc = JSON.parse(JSON.stringify(openapi)); + doc.paths = {}; + doc.tags = [tag]; + doc.info.title = tag.name; + doc.info.description = tag.description || `API reference for ${tag.name}`; + doc['x-tagGroup'] = tag.name; + const tagSlug = slugifyTag(tag.name); + try { + const baseFilename = `${prefix}${tagSlug}`; + const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); + const jsonPath = path.resolve(outPath, `${baseFilename}.json`); + writeDataFile(doc, yamlPath); + writeJsonFile(doc, jsonPath); + console.log(`Generated conceptual tag spec: ${baseFilename}.yaml`); + } + catch (err) { + console.error(`Error writing conceptual tag ${tag.name}:`, err); + } } - filteredPaths[pathKey] = filteredPathItem; - } }); - doc.paths = filteredPaths; - // Filter tags to only include this tag (and trait tags for context) - if (doc.tags) { - doc.tags = doc.tags.filter( - (tag) => tag.name === tagName || tag['x-traitTag'] - ); - } - // Update info - const tagSlug = slugifyTag(tagName); - doc.info.title = tagName; - doc.info.description = `API reference for ${tagName}`; - doc['x-tagGroup'] = tagName; - try { - if (!fs.existsSync(outPath)) { - fs.mkdirSync(outPath, { recursive: true }); - } - const baseFilename = `${prefix}${tagSlug}`; - const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); - const jsonPath = path.resolve(outPath, `${baseFilename}.json`); - writeDataFile(doc, yamlPath); - writeJsonFile(doc, jsonPath); - console.log( - `Generated tag spec: ${baseFilename}.yaml (${Object.keys(filteredPaths).length} paths, ${operations.length} operations)` - ); - } catch (err) { - console.error(`Error writing tag group ${tagName}:`, err); - } - }); - // Also create specs for conceptual tags (x-traitTag) without operations - (openapi.tags || []).forEach((tag) => { - if (tag['x-traitTag'] && !tagOperations.has(tag.name)) { - const doc = JSON.parse(JSON.stringify(openapi)); - doc.paths = {}; - doc.tags = [tag]; - doc.info.title = tag.name; - doc.info.description = tag.description || `API reference for ${tag.name}`; - doc['x-tagGroup'] = tag.name; - const tagSlug = slugifyTag(tag.name); - try { - const baseFilename = `${prefix}${tagSlug}`; - const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); - const jsonPath = path.resolve(outPath, `${baseFilename}.json`); - writeDataFile(doc, yamlPath); - writeJsonFile(doc, jsonPath); - console.log(`Generated conceptual tag spec: ${baseFilename}.yaml`); - } catch (err) { - console.error(`Error writing conceptual tag ${tag.name}:`, err); - } - } - }); } /** * Convert API path to filename-safe slug @@ -319,12 +292,12 @@ function writeTagOpenapis(openapi, prefix, outPath) { * @returns Filename-safe slug (e.g., "api-v3-configure-token-admin") */ function pathToFileSlug(apiPath) { - return apiPath - .replace(/^\//, '') // Remove leading slash - .replace(/\//g, '-') // Replace slashes with dashes - .replace(/[{}]/g, '') // Remove curly braces from path params - .replace(/-+/g, '-') // Collapse multiple dashes - .replace(/-$/, ''); // Remove trailing dash + return apiPath + .replace(/^\//, '') // Remove leading slash + .replace(/\//g, '-') // Replace slashes with dashes + .replace(/[{}]/g, '') // Remove curly braces from path params + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/-$/, ''); // Remove trailing dash } /** * Write path-specific OpenAPI specs (one file per exact API path) @@ -337,65 +310,61 @@ function pathToFileSlug(apiPath) { * @returns Map of API path to spec file path (for use in frontmatter) */ function writePathSpecificSpecs(openapi, outPath) { - const pathSpecFiles = new Map(); - if (!fs.existsSync(outPath)) { - fs.mkdirSync(outPath, { recursive: true }); - } - Object.entries(openapi.paths).forEach(([apiPath, pathItem]) => { - // Deep clone pathItem to avoid mutating original - const clonedPathItem = JSON.parse(JSON.stringify(pathItem)); - // Limit each operation to a single tag to prevent duplicate rendering in RapiDoc - // RapiDoc renders operations once per tag, so multiple tags cause duplicates - const usedTags = new Set(); - HTTP_METHODS.forEach((method) => { - const operation = clonedPathItem[method]; - if (operation?.tags && operation.tags.length > 0) { - // Select the most specific tag to avoid duplicate rendering - // Prefer "Auth token" over "Authentication" for token-related operations - let primaryTag = operation.tags[0]; - if (operation.tags.includes('Auth token')) { - primaryTag = 'Auth token'; - } - operation.tags = [primaryTag]; - usedTags.add(primaryTag); - } - }); - // Create spec with just this path (all its methods) - // Include global security requirements so RapiDoc displays auth correctly - const pathSpec = { - openapi: openapi.openapi, - info: { - ...openapi.info, - title: apiPath, - description: `API reference for ${apiPath}`, - }, - paths: { [apiPath]: clonedPathItem }, - components: openapi.components, // Include for $ref resolution - servers: openapi.servers, - security: openapi.security, // Global security requirements - }; - // Filter spec-level tags to only include those used by operations - if (openapi.tags) { - pathSpec.tags = openapi.tags.filter( - (tag) => usedTags.has(tag.name) && !tag['x-traitTag'] - ); + const pathSpecFiles = new Map(); + if (!fs.existsSync(outPath)) { + fs.mkdirSync(outPath, { recursive: true }); } - // Write files - const slug = pathToFileSlug(apiPath); - const yamlPath = path.resolve(outPath, `${slug}.yaml`); - const jsonPath = path.resolve(outPath, `${slug}.json`); - writeDataFile(pathSpec, yamlPath); - writeJsonFile(pathSpec, jsonPath); - // Store the web-accessible path (without "static/" prefix) - // Hugo serves files from static/ at the root, so we extract the path after 'static/' - const staticMatch = yamlPath.match(/static\/(.+)$/); - const webPath = staticMatch ? `/${staticMatch[1]}` : yamlPath; - pathSpecFiles.set(apiPath, webPath); - }); - console.log( - `Generated ${pathSpecFiles.size} path-specific specs in ${outPath}` - ); - return pathSpecFiles; + Object.entries(openapi.paths).forEach(([apiPath, pathItem]) => { + // Deep clone pathItem to avoid mutating original + const clonedPathItem = JSON.parse(JSON.stringify(pathItem)); + // Limit each operation to a single tag to prevent duplicate rendering + // Operations with multiple tags would be rendered once per tag + const usedTags = new Set(); + HTTP_METHODS.forEach((method) => { + const operation = clonedPathItem[method]; + if (operation?.tags && operation.tags.length > 0) { + // Select the most specific tag to avoid duplicate rendering + // Prefer "Auth token" over "Authentication" for token-related operations + let primaryTag = operation.tags[0]; + if (operation.tags.includes('Auth token')) { + primaryTag = 'Auth token'; + } + operation.tags = [primaryTag]; + usedTags.add(primaryTag); + } + }); + // Create spec with just this path (all its methods) + // Include global security requirements so auth info displays correctly + const pathSpec = { + openapi: openapi.openapi, + info: { + ...openapi.info, + title: apiPath, + description: `API reference for ${apiPath}`, + }, + paths: { [apiPath]: clonedPathItem }, + components: openapi.components, // Include for $ref resolution + servers: openapi.servers, + security: openapi.security, // Global security requirements + }; + // Filter spec-level tags to only include those used by operations + if (openapi.tags) { + pathSpec.tags = openapi.tags.filter((tag) => usedTags.has(tag.name) && !tag['x-traitTag']); + } + // Write files + const slug = pathToFileSlug(apiPath); + const yamlPath = path.resolve(outPath, `${slug}.yaml`); + const jsonPath = path.resolve(outPath, `${slug}.json`); + writeDataFile(pathSpec, yamlPath); + writeJsonFile(pathSpec, jsonPath); + // Store the web-accessible path (without "static/" prefix) + // Hugo serves files from static/ at the root, so we extract the path after 'static/' + const staticMatch = yamlPath.match(/static\/(.+)$/); + const webPath = staticMatch ? `/${staticMatch[1]}` : yamlPath; + pathSpecFiles.set(apiPath, webPath); + }); + console.log(`Generated ${pathSpecFiles.size} path-specific specs in ${outPath}`); + return pathSpecFiles; } /** * Write OpenAPI specs grouped by path to separate files @@ -406,80 +375,79 @@ function writePathSpecificSpecs(openapi, outPath) { * @param outPath - Output directory path */ function writePathOpenapis(openapi, prefix, outPath) { - const pathGroups = {}; - // Group paths by their base path (first 3-4 segments, excluding placeholders) - Object.keys(openapi.paths) - .sort() - .forEach((p) => { - const delimiter = '/'; - let key = p.split(delimiter); - // Check if this is an item path (ends with a placeholder) - let isItemPath = openapiUtils.isPlaceholderFragment(key[key.length - 1]); - if (isItemPath) { - key = key.slice(0, -1); - } - // Take first 4 segments - key = key.slice(0, 4); - // Check if the last segment is still a placeholder - isItemPath = openapiUtils.isPlaceholderFragment(key[key.length - 1]); - if (isItemPath) { - key = key.slice(0, -1); - } - const groupKey = key.join('/'); - pathGroups[groupKey] = pathGroups[groupKey] || {}; - pathGroups[groupKey][p] = openapi.paths[p]; - }); - // Write each path group to separate YAML and JSON files - Object.keys(pathGroups).forEach((pg) => { - // Deep copy openapi - const doc = JSON.parse(JSON.stringify(openapi)); - doc.paths = pathGroups[pg]; - // Collect tags used by operations in this path group - const usedTags = new Set(); - Object.values(doc.paths).forEach((pathItem) => { - const httpMethods = [ - 'get', - 'post', - 'put', - 'patch', - 'delete', - 'options', - 'head', - 'trace', - ]; - httpMethods.forEach((method) => { - const operation = pathItem[method]; - if (operation?.tags) { - operation.tags.forEach((tag) => usedTags.add(tag)); + const pathGroups = {}; + // Group paths by their base path (first 3-4 segments, excluding placeholders) + Object.keys(openapi.paths) + .sort() + .forEach((p) => { + const delimiter = '/'; + let key = p.split(delimiter); + // Check if this is an item path (ends with a placeholder) + let isItemPath = openapiUtils.isPlaceholderFragment(key[key.length - 1]); + if (isItemPath) { + key = key.slice(0, -1); + } + // Take first 4 segments + key = key.slice(0, 4); + // Check if the last segment is still a placeholder + isItemPath = openapiUtils.isPlaceholderFragment(key[key.length - 1]); + if (isItemPath) { + key = key.slice(0, -1); + } + const groupKey = key.join('/'); + pathGroups[groupKey] = pathGroups[groupKey] || {}; + pathGroups[groupKey][p] = openapi.paths[p]; + }); + // Write each path group to separate YAML and JSON files + Object.keys(pathGroups).forEach((pg) => { + // Deep copy openapi + const doc = JSON.parse(JSON.stringify(openapi)); + doc.paths = pathGroups[pg]; + // Collect tags used by operations in this path group + const usedTags = new Set(); + Object.values(doc.paths).forEach((pathItem) => { + const httpMethods = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace', + ]; + httpMethods.forEach((method) => { + const operation = pathItem[method]; + if (operation?.tags) { + operation.tags.forEach((tag) => usedTags.add(tag)); + } + }); + }); + // Filter tags to only include those used by operations in this path group + // Exclude x-traitTag tags (supplementary documentation tags) + if (doc.tags) { + doc.tags = doc.tags.filter((tag) => usedTags.has(tag.name) && !tag['x-traitTag']); + } + // Simplify info for path-specific docs + doc.info.title = pg; + doc.info.description = `API reference for ${pg}`; + doc['x-pathGroup'] = pg; + try { + if (!fs.existsSync(outPath)) { + fs.mkdirSync(outPath, { recursive: true }); + } + const baseFilename = `${prefix}${pg.replaceAll('/', '-').replace(/^-/, '')}`; + const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); + const jsonPath = path.resolve(outPath, `${baseFilename}.json`); + // Write both YAML and JSON versions + writeDataFile(doc, yamlPath); + writeJsonFile(doc, jsonPath); + console.log(`Generated: ${baseFilename}.yaml and ${baseFilename}.json`); + } + catch (err) { + console.error(`Error writing path group ${pg}:`, err); } - }); }); - // Filter tags to only include those used by operations in this path group - // Exclude x-traitTag tags (supplementary documentation tags) - if (doc.tags) { - doc.tags = doc.tags.filter( - (tag) => usedTags.has(tag.name) && !tag['x-traitTag'] - ); - } - // Simplify info for path-specific docs - doc.info.title = pg; - doc.info.description = `API reference for ${pg}`; - doc['x-pathGroup'] = pg; - try { - if (!fs.existsSync(outPath)) { - fs.mkdirSync(outPath, { recursive: true }); - } - const baseFilename = `${prefix}${pg.replaceAll('/', '-').replace(/^-/, '')}`; - const yamlPath = path.resolve(outPath, `${baseFilename}.yaml`); - const jsonPath = path.resolve(outPath, `${baseFilename}.json`); - // Write both YAML and JSON versions - writeDataFile(doc, yamlPath); - writeJsonFile(doc, jsonPath); - console.log(`Generated: ${baseFilename}.yaml and ${baseFilename}.json`); - } catch (err) { - console.error(`Error writing path group ${pg}:`, err); - } - }); } /** * Create article metadata for a path group @@ -488,119 +456,107 @@ function writePathOpenapis(openapi, prefix, outPath) { * @returns Article metadata object */ function createArticleDataForPathGroup(openapi) { - const article = { - path: '', - fields: { - name: openapi['x-pathGroup'] || '', - describes: Object.keys(openapi.paths), - }, - }; - /** - * Convert OpenAPI path to Hugo-friendly article path - * Legacy endpoints (without /api/ prefix) go under api/ directly - * Versioned endpoints (with /api/vN/) keep their structure - * - * @param p - Path to convert (e.g., '/health', '/api/v3/query_sql') - * @returns Path suitable for Hugo content directory (e.g., 'api/health', 'api/v3/query_sql') - */ - const toHugoPath = (p) => { - if (!p) { - return ''; - } - // If path doesn't start with /api/, it's a legacy endpoint - // Place it directly under api/ to avoid collision with /api/v1/* paths - if (!p.startsWith('/api/')) { - // /health -> api/health - // /write -> api/write - return `api${p}`; - } - // /api/v1/health -> api/v1/health - // /api/v2/write -> api/v2/write - // /api/v3/query_sql -> api/v3/query_sql - return p.replace(/^\//, ''); - }; - /** - * Convert path to tag-friendly format (dashes instead of slashes) - * - * @param p - Path to convert - * @returns Tag-friendly path - */ - const toTagPath = (p) => { - if (!p) { - return ''; - } - return p.replace(/^\//, '').replaceAll('/', '-'); - }; - const pathGroup = openapi['x-pathGroup'] || ''; - article.path = toHugoPath(pathGroup); - // Store original path for menu display (shows actual endpoint path) - article.fields.menuName = pathGroup; - article.fields.title = openapi.info?.title; - article.fields.description = openapi.description; - const pathGroupFrags = path.parse(openapi['x-pathGroup'] || ''); - article.fields.tags = [pathGroupFrags?.dir, pathGroupFrags?.name] - .filter(Boolean) - .map((t) => toTagPath(t)); - // Extract x-relatedLinks and OpenAPI tags from path items or operations - const relatedLinks = []; - const apiTags = []; - const httpMethods = [ - 'get', - 'post', - 'put', - 'patch', - 'delete', - 'options', - 'head', - 'trace', - ]; - Object.values(openapi.paths).forEach((pathItem) => { - // Check path-level x-relatedLinks - if ( - pathItem['x-relatedLinks'] && - Array.isArray(pathItem['x-relatedLinks']) - ) { - relatedLinks.push( - ...pathItem['x-relatedLinks'].filter( - (link) => !relatedLinks.includes(link) - ) - ); - } - // Check operation-level x-relatedLinks and tags - httpMethods.forEach((method) => { - const operation = pathItem[method]; - if (operation) { - // Extract x-relatedLinks - if ( - operation['x-relatedLinks'] && - Array.isArray(operation['x-relatedLinks']) - ) { - relatedLinks.push( - ...operation['x-relatedLinks'].filter( - (link) => !relatedLinks.includes(link) - ) - ); + const article = { + path: '', + fields: { + name: openapi['x-pathGroup'] || '', + describes: Object.keys(openapi.paths), + }, + }; + /** + * Convert OpenAPI path to Hugo-friendly article path + * Legacy endpoints (without /api/ prefix) go under api/ directly + * Versioned endpoints (with /api/vN/) keep their structure + * + * @param p - Path to convert (e.g., '/health', '/api/v3/query_sql') + * @returns Path suitable for Hugo content directory (e.g., 'api/health', 'api/v3/query_sql') + */ + const toHugoPath = (p) => { + if (!p) { + return ''; } - // Extract OpenAPI tags from operation - if (operation.tags && Array.isArray(operation.tags)) { - operation.tags.forEach((tag) => { - if (!apiTags.includes(tag)) { - apiTags.push(tag); + // If path doesn't start with /api/, it's a legacy endpoint + // Place it directly under api/ to avoid collision with /api/v1/* paths + if (!p.startsWith('/api/')) { + // /health -> api/health + // /write -> api/write + return `api${p}`; + } + // /api/v1/health -> api/v1/health + // /api/v2/write -> api/v2/write + // /api/v3/query_sql -> api/v3/query_sql + return p.replace(/^\//, ''); + }; + /** + * Convert path to tag-friendly format (dashes instead of slashes) + * + * @param p - Path to convert + * @returns Tag-friendly path + */ + const toTagPath = (p) => { + if (!p) { + return ''; + } + return p.replace(/^\//, '').replaceAll('/', '-'); + }; + const pathGroup = openapi['x-pathGroup'] || ''; + article.path = toHugoPath(pathGroup); + // Store original path for menu display (shows actual endpoint path) + article.fields.menuName = pathGroup; + article.fields.title = openapi.info?.title; + article.fields.description = openapi.description; + const pathGroupFrags = path.parse(openapi['x-pathGroup'] || ''); + article.fields.tags = [pathGroupFrags?.dir, pathGroupFrags?.name] + .filter(Boolean) + .map((t) => toTagPath(t)); + // Extract x-relatedLinks and OpenAPI tags from path items or operations + const relatedLinks = []; + const apiTags = []; + const httpMethods = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace', + ]; + Object.values(openapi.paths).forEach((pathItem) => { + // Check path-level x-relatedLinks + if (pathItem['x-relatedLinks'] && + Array.isArray(pathItem['x-relatedLinks'])) { + relatedLinks.push(...pathItem['x-relatedLinks'].filter((link) => !relatedLinks.includes(link))); + } + // Check operation-level x-relatedLinks and tags + httpMethods.forEach((method) => { + const operation = pathItem[method]; + if (operation) { + // Extract x-relatedLinks + if (operation['x-relatedLinks'] && + Array.isArray(operation['x-relatedLinks'])) { + relatedLinks.push(...operation['x-relatedLinks'].filter((link) => !relatedLinks.includes(link))); + } + // Extract OpenAPI tags from operation + if (operation.tags && Array.isArray(operation.tags)) { + operation.tags.forEach((tag) => { + if (!apiTags.includes(tag)) { + apiTags.push(tag); + } + }); + } } - }); - } - } + }); }); - }); - // Only add related if there are links - if (relatedLinks.length > 0) { - article.fields.related = relatedLinks; - } - // Add OpenAPI tags from operations (for Hugo frontmatter) - if (apiTags.length > 0) { - article.fields.apiTags = apiTags; - } - return article; + // Only add related if there are links + if (relatedLinks.length > 0) { + article.fields.related = relatedLinks; + } + // Add OpenAPI tags from operations (for Hugo frontmatter) + if (apiTags.length > 0) { + article.fields.apiTags = apiTags; + } + return article; } /** * Write OpenAPI article metadata to Hugo data files @@ -611,56 +567,55 @@ function createArticleDataForPathGroup(openapi) { * @param opts - Options including file pattern filter */ function writeOpenapiArticleData(sourcePath, targetPath, opts) { - /** - * Check if path is a file - */ - const isFile = (filePath) => { - return fs.lstatSync(filePath).isFile(); - }; - /** - * Check if filename matches pattern - */ - const matchesPattern = (filePath) => { - return opts.filePattern - ? path.parse(filePath).name.startsWith(opts.filePattern) - : true; - }; - try { - const articles = fs - .readdirSync(sourcePath) - .map((fileName) => path.join(sourcePath, fileName)) - .filter(matchesPattern) - .filter(isFile) - .filter( - (filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml') - ) // Only process YAML files - .map((filePath) => { - const openapi = readFile(filePath); - const article = createArticleDataForPathGroup(openapi); - article.fields.source = filePath; - // Hugo omits "/static" from the URI when serving files stored in "./static" - article.fields.staticFilePath = filePath.replace(/^static\//, '/'); - return article; - }); - if (!fs.existsSync(targetPath)) { - fs.mkdirSync(targetPath, { recursive: true }); + /** + * Check if path is a file + */ + const isFile = (filePath) => { + return fs.lstatSync(filePath).isFile(); + }; + /** + * Check if filename matches pattern + */ + const matchesPattern = (filePath) => { + return opts.filePattern + ? path.parse(filePath).name.startsWith(opts.filePattern) + : true; + }; + try { + const articles = fs + .readdirSync(sourcePath) + .map((fileName) => path.join(sourcePath, fileName)) + .filter(matchesPattern) + .filter(isFile) + .filter((filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml')) // Only process YAML files + .map((filePath) => { + const openapi = readFile(filePath); + const article = createArticleDataForPathGroup(openapi); + article.fields.source = filePath; + // Hugo omits "/static" from the URI when serving files stored in "./static" + article.fields.staticFilePath = filePath.replace(/^static\//, '/'); + return article; + }); + if (!fs.existsSync(targetPath)) { + fs.mkdirSync(targetPath, { recursive: true }); + } + const articleCollection = { articles }; + // Write both YAML and JSON versions + const yamlPath = path.resolve(targetPath, 'articles.yml'); + const jsonPath = path.resolve(targetPath, 'articles.json'); + writeDataFile(articleCollection, yamlPath); + writeJsonFile(articleCollection, jsonPath); + console.log(`Generated ${articles.length} articles in ${targetPath}`); + } + catch (e) { + console.error('Error writing article data:', e); } - const articleCollection = { articles }; - // Write both YAML and JSON versions - const yamlPath = path.resolve(targetPath, 'articles.yml'); - const jsonPath = path.resolve(targetPath, 'articles.json'); - writeDataFile(articleCollection, yamlPath); - writeJsonFile(articleCollection, jsonPath); - console.log(`Generated ${articles.length} articles in ${targetPath}`); - } catch (e) { - console.error('Error writing article data:', e); - } } /** * Sanitize markdown description by removing fragment links and ReDoc directives * * Handles three cases: - * 1. RapiDoc fragment links: [text](#section/...) -> text (removes the link entirely) + * 1. OpenAPI fragment links: [text](#section/...) -> text (removes the link entirely) * 2. Relative links with fragments: [text](/path/#anchor) -> [text](/path/) (keeps link, removes fragment) * 3. ReDoc injection directives: (removes entirely) * @@ -671,37 +626,34 @@ function writeOpenapiArticleData(sourcePath, targetPath, opts) { * @returns Sanitized description suitable for children shortcode rendering */ function sanitizeDescription(description) { - if (!description) { - return ''; - } - let sanitized = description; - // Remove ReDoc injection directives (e.g., ) - sanitized = sanitized.replace(//g, ''); - // Handle markdown links: - // 1. RapiDoc fragment links (#section/..., #operation/..., #tag/...) -> replace with just the text - // 2. Relative links with fragments (/path/#anchor) -> keep link but remove fragment - sanitized = sanitized.replace( - /\[([^\]]+)\]\(([^)]+)\)/g, - (match, text, url) => { - // Case 1: RapiDoc fragment links (starts with #section/, #operation/, #tag/) - if (url.match(/^#(section|operation|tag)\//)) { - return text; // Just return the link text, no markdown link - } - // Case 2: Relative link with fragment (starts with /, contains #) - if (url.startsWith('/') && url.includes('#')) { - const urlWithoutFragment = url.split('#')[0]; - if (urlWithoutFragment === '/' || urlWithoutFragment === '') { - return text; - } - return `[${text}](${urlWithoutFragment})`; - } - // Case 3: Keep other links as-is (external links, non-fragment links) - return match; + if (!description) { + return ''; } - ); - // Clean up extra whitespace left by directive removals - sanitized = sanitized.replace(/\n\n\n+/g, '\n\n').trim(); - return sanitized; + let sanitized = description; + // Remove ReDoc injection directives (e.g., ) + sanitized = sanitized.replace(//g, ''); + // Handle markdown links: + // 1. OpenAPI fragment links (#section/..., #operation/..., #tag/...) -> replace with just the text + // 2. Relative links with fragments (/path/#anchor) -> keep link but remove fragment + sanitized = sanitized.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { + // Case 1: OpenAPI fragment links (starts with #section/, #operation/, #tag/) + if (url.match(/^#(section|operation|tag)\//)) { + return text; // Just return the link text, no markdown link + } + // Case 2: Relative link with fragment (starts with /, contains #) + if (url.startsWith('/') && url.includes('#')) { + const urlWithoutFragment = url.split('#')[0]; + if (urlWithoutFragment === '/' || urlWithoutFragment === '') { + return text; + } + return `[${text}](${urlWithoutFragment})`; + } + // Case 3: Keep other links as-is (external links, non-fragment links) + return match; + }); + // Clean up extra whitespace left by directive removals + sanitized = sanitized.replace(/\n\n\n+/g, '\n\n').trim(); + return sanitized; } /** * Create article data for a tag-based grouping @@ -712,76 +664,74 @@ function sanitizeDescription(description) { * @returns Article metadata object */ function createArticleDataForTag(openapi, operations, tagMeta) { - const tagName = openapi['x-tagGroup'] || ''; - const tagSlug = slugifyTag(tagName); - const isConceptual = tagMeta?.['x-traitTag'] === true; - const article = { - path: `api/${tagSlug}`, - fields: { - name: tagName, - describes: Object.keys(openapi.paths), - title: tagName, - description: sanitizeDescription( - tagMeta?.description || - openapi.info?.description || - `API reference for ${tagName}` - ), - tag: tagName, - isConceptual, - menuGroup: getMenuGroupForTag(tagName), - operations: operations.map((op) => ({ - operationId: op.operationId, - method: op.method, - path: op.path, - summary: op.summary, - tags: op.tags, - ...(op.compatVersion && { compatVersion: op.compatVersion }), - ...(op.externalDocs && { externalDocs: op.externalDocs }), - })), - }, - }; - // Add tag description for conceptual pages (sanitized for children shortcode) - if (tagMeta?.description) { - article.fields.tagDescription = sanitizeDescription(tagMeta.description); - } - // Show security schemes section on Authentication pages - if (tagName === 'Authentication') { - article.fields.showSecuritySchemes = true; - } - // Set custom weight for Quick start to appear first in nav - if (tagName === 'Quick start') { - article.fields.weight = 1; - } - // Set default weight for consistent sorting (articles without explicit weight) - if (article.fields.weight === undefined) { - article.fields.weight = 100; - } - // Aggregate unique related URLs from multiple sources into article-level related - // This populates Hugo frontmatter `related` field for "Related content" links - const relatedUrls = new Set(); - // First check tag-level x-influxdatadocs-related - if (tagMeta?.['x-influxdatadocs-related']) { - tagMeta['x-influxdatadocs-related'].forEach((url) => relatedUrls.add(url)); - } - // Then check tag-level externalDocs (legacy single link) - if (tagMeta?.externalDocs?.url) { - relatedUrls.add(tagMeta.externalDocs.url); - } - // Then aggregate from operations - operations.forEach((op) => { - // Check operation-level x-influxdatadocs-related (via opMeta.related) - if (op.related) { - op.related.forEach((url) => relatedUrls.add(url)); + const tagName = openapi['x-tagGroup'] || ''; + const tagSlug = slugifyTag(tagName); + const isConceptual = tagMeta?.['x-traitTag'] === true; + const article = { + path: `api/${tagSlug}`, + fields: { + name: tagName, + describes: Object.keys(openapi.paths), + title: tagName, + description: sanitizeDescription(tagMeta?.description || + openapi.info?.description || + `API reference for ${tagName}`), + tag: tagName, + isConceptual, + menuGroup: getMenuGroupForTag(tagName), + operations: operations.map((op) => ({ + operationId: op.operationId, + method: op.method, + path: op.path, + summary: op.summary, + tags: op.tags, + ...(op.compatVersion && { compatVersion: op.compatVersion }), + ...(op.externalDocs && { externalDocs: op.externalDocs }), + })), + }, + }; + // Add tag description for conceptual pages (sanitized for children shortcode) + if (tagMeta?.description) { + article.fields.tagDescription = sanitizeDescription(tagMeta.description); } - // Check operation-level externalDocs (legacy single link) - if (op.externalDocs?.url) { - relatedUrls.add(op.externalDocs.url); + // Show security schemes section on Authentication pages + if (tagName === 'Authentication') { + article.fields.showSecuritySchemes = true; } - }); - if (relatedUrls.size > 0) { - article.fields.related = Array.from(relatedUrls); - } - return article; + // Set custom weight for Quick start to appear first in nav + if (tagName === 'Quick start') { + article.fields.weight = 1; + } + // Set default weight for consistent sorting (articles without explicit weight) + if (article.fields.weight === undefined) { + article.fields.weight = 100; + } + // Aggregate unique related URLs from multiple sources into article-level related + // This populates Hugo frontmatter `related` field for "Related content" links + const relatedUrls = new Set(); + // First check tag-level x-influxdatadocs-related + if (tagMeta?.['x-influxdatadocs-related']) { + tagMeta['x-influxdatadocs-related'].forEach((url) => relatedUrls.add(url)); + } + // Then check tag-level externalDocs (legacy single link) + if (tagMeta?.externalDocs?.url) { + relatedUrls.add(tagMeta.externalDocs.url); + } + // Then aggregate from operations + operations.forEach((op) => { + // Check operation-level x-influxdatadocs-related (via opMeta.related) + if (op.related) { + op.related.forEach((url) => relatedUrls.add(url)); + } + // Check operation-level externalDocs (legacy single link) + if (op.externalDocs?.url) { + relatedUrls.add(op.externalDocs.url); + } + }); + if (relatedUrls.size > 0) { + article.fields.related = Array.from(relatedUrls); + } + return article; } /** * Write tag-based OpenAPI article metadata to Hugo data files @@ -793,92 +743,82 @@ function createArticleDataForTag(openapi, operations, tagMeta) { * @param opts - Options including file pattern filter */ function writeOpenapiTagArticleData(sourcePath, targetPath, openapi, opts) { - const isFile = (filePath) => { - return fs.lstatSync(filePath).isFile(); - }; - const matchesPattern = (filePath) => { - return opts.filePattern - ? path.parse(filePath).name.startsWith(opts.filePattern) - : true; - }; - // Create tag metadata lookup - const tagMetaMap = new Map(); - (openapi.tags || []).forEach((tag) => { - tagMetaMap.set(tag.name, tag); - }); - try { - const articles = fs - .readdirSync(sourcePath) - .map((fileName) => path.join(sourcePath, fileName)) - .filter(matchesPattern) - .filter(isFile) - .filter( - (filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml') - ) - .map((filePath) => { - const tagOpenapi = readFile(filePath); - const tagName = - tagOpenapi['x-tagGroup'] || tagOpenapi.info?.title || ''; - const tagMeta = tagMetaMap.get(tagName); - // Extract operations from the tag-filtered spec - const operations = []; - Object.entries(tagOpenapi.paths).forEach(([pathKey, pathItem]) => { - HTTP_METHODS.forEach((method) => { - const operation = pathItem[method]; - if (operation) { - const opMeta = { - operationId: operation.operationId || `${method}-${pathKey}`, - method: method.toUpperCase(), - path: pathKey, - summary: operation.summary || '', - tags: operation.tags || [], - }; - // Extract compatibility version if present - if (operation['x-compatibility-version']) { - opMeta.compatVersion = operation['x-compatibility-version']; - } - // Extract externalDocs if present - if (operation.externalDocs) { - opMeta.externalDocs = { - description: operation.externalDocs.description || '', - url: operation.externalDocs.url, - }; - } - // Extract x-influxdatadocs-related if present - if ( - operation['x-influxdatadocs-related'] && - Array.isArray(operation['x-influxdatadocs-related']) - ) { - opMeta.related = operation['x-influxdatadocs-related']; - } - operations.push(opMeta); - } - }); + const isFile = (filePath) => { + return fs.lstatSync(filePath).isFile(); + }; + const matchesPattern = (filePath) => { + return opts.filePattern + ? path.parse(filePath).name.startsWith(opts.filePattern) + : true; + }; + // Create tag metadata lookup + const tagMetaMap = new Map(); + (openapi.tags || []).forEach((tag) => { + tagMetaMap.set(tag.name, tag); + }); + try { + const articles = fs + .readdirSync(sourcePath) + .map((fileName) => path.join(sourcePath, fileName)) + .filter(matchesPattern) + .filter(isFile) + .filter((filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml')) + .map((filePath) => { + const tagOpenapi = readFile(filePath); + const tagName = tagOpenapi['x-tagGroup'] || tagOpenapi.info?.title || ''; + const tagMeta = tagMetaMap.get(tagName); + // Extract operations from the tag-filtered spec + const operations = []; + Object.entries(tagOpenapi.paths).forEach(([pathKey, pathItem]) => { + HTTP_METHODS.forEach((method) => { + const operation = pathItem[method]; + if (operation) { + const opMeta = { + operationId: operation.operationId || `${method}-${pathKey}`, + method: method.toUpperCase(), + path: pathKey, + summary: operation.summary || '', + tags: operation.tags || [], + }; + // Extract compatibility version if present + if (operation['x-compatibility-version']) { + opMeta.compatVersion = operation['x-compatibility-version']; + } + // Extract externalDocs if present + if (operation.externalDocs) { + opMeta.externalDocs = { + description: operation.externalDocs.description || '', + url: operation.externalDocs.url, + }; + } + // Extract x-influxdatadocs-related if present + if (operation['x-influxdatadocs-related'] && + Array.isArray(operation['x-influxdatadocs-related'])) { + opMeta.related = operation['x-influxdatadocs-related']; + } + operations.push(opMeta); + } + }); + }); + const article = createArticleDataForTag(tagOpenapi, operations, tagMeta); + article.fields.source = filePath; + article.fields.staticFilePath = filePath.replace(/^static\//, '/'); + return article; }); - const article = createArticleDataForTag( - tagOpenapi, - operations, - tagMeta - ); - article.fields.source = filePath; - article.fields.staticFilePath = filePath.replace(/^static\//, '/'); - return article; - }); - if (!fs.existsSync(targetPath)) { - fs.mkdirSync(targetPath, { recursive: true }); + if (!fs.existsSync(targetPath)) { + fs.mkdirSync(targetPath, { recursive: true }); + } + const articleCollection = { articles }; + // Write both YAML and JSON versions + const yamlPath = path.resolve(targetPath, 'articles.yml'); + const jsonPath = path.resolve(targetPath, 'articles.json'); + writeDataFile(articleCollection, yamlPath); + writeJsonFile(articleCollection, jsonPath); + console.log(`Generated ${articles.length} tag-based articles in ${targetPath}`); + } + catch (e) { + console.error('Error writing tag article data:', e); } - const articleCollection = { articles }; - // Write both YAML and JSON versions - const yamlPath = path.resolve(targetPath, 'articles.yml'); - const jsonPath = path.resolve(targetPath, 'articles.json'); - writeDataFile(articleCollection, yamlPath); - writeJsonFile(articleCollection, jsonPath); - console.log( - `Generated ${articles.length} tag-based articles in ${targetPath}` - ); - } catch (e) { - console.error('Error writing tag article data:', e); - } } /** * Generate Hugo data files from an OpenAPI specification grouped by tag @@ -892,28 +832,24 @@ function writeOpenapiTagArticleData(sourcePath, targetPath, openapi, opts) { * @param options - Generation options */ function generateHugoDataByTag(options) { - const filenamePrefix = `${path.parse(options.specFile).name}-`; - const sourceFile = readFile(options.specFile, 'utf8'); - // Optionally generate path-based files for backwards compatibility - if (options.includePaths) { - console.log( - `\nGenerating OpenAPI path files in ${options.dataOutPath}....` - ); - writePathOpenapis(sourceFile, filenamePrefix, options.dataOutPath); - } - // Generate tag-based files - const tagOutPath = options.includePaths - ? path.join(options.dataOutPath, 'tags') - : options.dataOutPath; - console.log(`\nGenerating OpenAPI tag files in ${tagOutPath}....`); - writeTagOpenapis(sourceFile, filenamePrefix, tagOutPath); - console.log( - `\nGenerating OpenAPI tag article data in ${options.articleOutPath}...` - ); - writeOpenapiTagArticleData(tagOutPath, options.articleOutPath, sourceFile, { - filePattern: filenamePrefix, - }); - console.log('\nTag-based generation complete!\n'); + const filenamePrefix = `${path.parse(options.specFile).name}-`; + const sourceFile = readFile(options.specFile, 'utf8'); + // Optionally generate path-based files for backwards compatibility + if (options.includePaths) { + console.log(`\nGenerating OpenAPI path files in ${options.dataOutPath}....`); + writePathOpenapis(sourceFile, filenamePrefix, options.dataOutPath); + } + // Generate tag-based files + const tagOutPath = options.includePaths + ? path.join(options.dataOutPath, 'tags') + : options.dataOutPath; + console.log(`\nGenerating OpenAPI tag files in ${tagOutPath}....`); + writeTagOpenapis(sourceFile, filenamePrefix, tagOutPath); + console.log(`\nGenerating OpenAPI tag article data in ${options.articleOutPath}...`); + writeOpenapiTagArticleData(tagOutPath, options.articleOutPath, sourceFile, { + filePattern: filenamePrefix, + }); + console.log('\nTag-based generation complete!\n'); } /** * Generate Hugo data files from an OpenAPI specification @@ -927,17 +863,15 @@ function generateHugoDataByTag(options) { * @param options - Generation options */ function generateHugoData(options) { - const filenamePrefix = `${path.parse(options.specFile).name}-`; - const sourceFile = readFile(options.specFile, 'utf8'); - console.log(`\nGenerating OpenAPI path files in ${options.dataOutPath}....`); - writePathOpenapis(sourceFile, filenamePrefix, options.dataOutPath); - console.log( - `\nGenerating OpenAPI article data in ${options.articleOutPath}...` - ); - writeOpenapiArticleData(options.dataOutPath, options.articleOutPath, { - filePattern: filenamePrefix, - }); - console.log('\nGeneration complete!\n'); + const filenamePrefix = `${path.parse(options.specFile).name}-`; + const sourceFile = readFile(options.specFile, 'utf8'); + console.log(`\nGenerating OpenAPI path files in ${options.dataOutPath}....`); + writePathOpenapis(sourceFile, filenamePrefix, options.dataOutPath); + console.log(`\nGenerating OpenAPI article data in ${options.articleOutPath}...`); + writeOpenapiArticleData(options.dataOutPath, options.articleOutPath, { + filePattern: filenamePrefix, + }); + console.log('\nGeneration complete!\n'); } /** * Generate path-specific OpenAPI specs from a spec file @@ -949,14 +883,14 @@ function generateHugoData(options) { * @returns Map of API path to spec file web path (for use in frontmatter) */ function generatePathSpecificSpecs(specFile, outPath) { - const openapi = readFile(specFile, 'utf8'); - return writePathSpecificSpecs(openapi, outPath); + const openapi = readFile(specFile, 'utf8'); + return writePathSpecificSpecs(openapi, outPath); } // CommonJS export for backward compatibility module.exports = { - generateHugoData, - generateHugoDataByTag, - generatePathSpecificSpecs, - writePathSpecificSpecs, + generateHugoData, + generateHugoDataByTag, + generatePathSpecificSpecs, + writePathSpecificSpecs, }; -//# sourceMappingURL=index.js.map +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/assets/js/components/api-auth-input.ts b/assets/js/components/api-auth-input.ts deleted file mode 100644 index 7e99fe5e5..000000000 --- a/assets/js/components/api-auth-input.ts +++ /dev/null @@ -1,604 +0,0 @@ -/** - * API Auth Input Component (Popover) - * - * Provides a popover-based credential input for API operations. - * Integrates with RapiDoc's auth system via JavaScript API. - * - * Features: - * - Popover UI triggered by button click - * - Filters auth schemes based on operation requirements - * - Session-only credentials (not persisted to storage) - * - Syncs with RapiDoc's "Try it" feature - * - * Usage: - * - * - */ - -interface ComponentOptions { - component: HTMLElement; -} - -interface AuthCredentials { - bearer?: string; - basic?: { username: string; password: string }; - querystring?: string; -} - -type CleanupFn = () => void; - -// sessionStorage key for credentials -// Persists across page navigations, cleared when tab closes -const CREDENTIALS_KEY = 'influxdata-api-credentials'; - -/** - * Get credentials from sessionStorage - */ -function getCredentials(): AuthCredentials { - try { - const stored = sessionStorage.getItem(CREDENTIALS_KEY); - return stored ? JSON.parse(stored) : {}; - } catch { - return {}; - } -} - -/** - * Set credentials in sessionStorage - */ -function setCredentials(credentials: AuthCredentials): void { - try { - if (Object.keys(credentials).length === 0) { - sessionStorage.removeItem(CREDENTIALS_KEY); - } else { - sessionStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials)); - } - } catch (e) { - console.warn('[API Auth] Failed to store credentials:', e); - } -} - -/** - * Check if any credentials are set - */ -function hasCredentials(): boolean { - const creds = getCredentials(); - return !!(creds.bearer || creds.basic?.password || creds.querystring); -} - -/** - * Try to update the visible auth input in RapiDoc's shadow DOM. - * This provides visual feedback but is not essential for authentication. - */ -function updateRapiDocAuthInput( - rapiDoc: HTMLElement, - token: string, - scheme: 'bearer' | 'token' -): void { - try { - const shadowRoot = rapiDoc.shadowRoot; - if (!shadowRoot) return; - - const headerValue = - scheme === 'bearer' ? `Bearer ${token}` : `Token ${token}`; - - const authInputSelectors = [ - 'input[data-pname="Authorization"]', - 'input[placeholder*="authorization" i]', - 'input[placeholder*="token" i]', - '.request-headers input[type="text"]', - ]; - - for (const selector of authInputSelectors) { - const input = shadowRoot.querySelector(selector); - if (input && !input.value) { - input.value = headerValue; - input.dispatchEvent(new Event('input', { bubbles: true })); - console.log('[API Auth] Updated visible auth input in RapiDoc'); - return; - } - } - } catch (e) { - console.debug('[API Auth] Could not update visible input:', e); - } -} - -/** - * Apply credentials to a RapiDoc element - * Returns true if credentials were successfully applied - */ -function applyCredentialsToRapiDoc( - rapiDoc: HTMLElement, - credentials: AuthCredentials -): boolean { - let applied = false; - - // Clear existing credentials first - if ('removeAllSecurityKeys' in rapiDoc) { - try { - (rapiDoc as any).removeAllSecurityKeys(); - } catch (e) { - console.warn('[API Auth] Failed to clear existing credentials:', e); - } - } - - // Apply bearer/token credentials using setApiKey() only - // Using both HTML attributes AND setApiKey() causes "2 API keys applied" - if (credentials.bearer) { - try { - if ('setApiKey' in rapiDoc) { - (rapiDoc as any).setApiKey('BearerAuthentication', credentials.bearer); - console.log('[API Auth] Applied bearer via setApiKey()'); - applied = true; - } - updateRapiDocAuthInput(rapiDoc, credentials.bearer, 'bearer'); - } catch (e) { - console.error('[API Auth] Failed to set API key:', e); - } - } - - // Apply basic auth credentials - if ('setHttpUserNameAndPassword' in rapiDoc && credentials.basic?.password) { - try { - (rapiDoc as any).setHttpUserNameAndPassword( - 'BasicAuthentication', - credentials.basic.username || '', - credentials.basic.password - ); - applied = true; - console.log('[API Auth] Applied basic auth credentials to RapiDoc'); - } catch (e) { - console.error('[API Auth] Failed to set basic auth:', e); - } - } - - return applied; -} - -/** - * Create auth field HTML for a specific scheme - */ -function createAuthField(scheme: string): string { - switch (scheme) { - case 'bearer': - return ` -
- -
- - -
-
`; - - case 'token': - return ` -
- -
- - -
-
`; - - case 'basic': - return ` -
-

Basic Authentication (v1 compatibility)

-
- - -
-
- -
- - -
-
-
`; - - case 'querystring': - return ` -
- -
- - -
-
`; - - default: - return ''; - } -} - -/** - * Create the popover content HTML - */ -function createPopoverContent(schemes: string[]): string { - // If both bearer and token are supported, show combined field - const hasBearerAndToken = - schemes.includes('bearer') && schemes.includes('token'); - const displaySchemes = hasBearerAndToken - ? schemes.filter((s) => s !== 'token') - : schemes; - - const fields = displaySchemes.map((s) => createAuthField(s)).join(''); - - // Adjust label if both bearer and token are supported - const bearerLabel = hasBearerAndToken - ? '(Bearer / Token auth)' - : '(Bearer auth)'; - - return ` -
-
-

API Credentials

- -
-

- Enter credentials for "Try it" requests. -

- ${fields.replace('(Bearer auth)', bearerLabel)} -
- - -
- -
- `; -} - -/** - * Show feedback message - */ -function showFeedback( - container: HTMLElement, - message: string, - type: 'success' | 'error' -): void { - const feedback = container.querySelector('.auth-feedback'); - if (feedback) { - feedback.textContent = message; - feedback.className = `auth-feedback auth-feedback--${type}`; - feedback.hidden = false; - - setTimeout(() => { - feedback.hidden = true; - }, 3000); - } -} - -/** - * Update the status indicator on the trigger button - */ -function updateStatusIndicator(trigger: HTMLElement): void { - const indicator = trigger.querySelector( - '.auth-status-indicator' - ); - const hasCreds = hasCredentials(); - - if (indicator) { - indicator.hidden = !hasCreds; - } - - trigger.classList.toggle('has-credentials', hasCreds); -} - -/** - * Initialize the auth input popover component - */ -export default function ApiAuthInput({ - component, -}: ComponentOptions): CleanupFn | void { - // Component is the banner container, find the trigger button inside it - const triggerEl = - component.querySelector('.api-auth-trigger'); - const statusEl = component.querySelector('.api-auth-status'); - - if (!triggerEl) { - console.error('[API Auth] Trigger button not found in banner'); - return; - } - - // Find the popover element (it's a sibling before the banner) - const popoverEl = document.querySelector('.api-auth-popover'); - - if (!popoverEl) { - console.error('[API Auth] Popover container not found'); - return; - } - - // Reassign to new consts so TypeScript knows they're not null in closures - const trigger = triggerEl; - const popover = popoverEl; - - // Get schemes from the component (banner) dataset - const schemesAttr = component.dataset.schemes || 'bearer'; - const schemes = schemesAttr.split(',').map((s) => s.trim().toLowerCase()); - - // Render popover content - popover.innerHTML = createPopoverContent(schemes); - - // Element references - const bearerInput = popover.querySelector('#auth-bearer'); - const tokenInput = popover.querySelector('#auth-token'); - const usernameInput = - popover.querySelector('#auth-username'); - const passwordInput = - popover.querySelector('#auth-password'); - const querystringInput = - popover.querySelector('#auth-querystring'); - const applyBtn = popover.querySelector('.auth-apply'); - const clearBtn = popover.querySelector('.auth-clear'); - const closeBtn = popover.querySelector('.popover-close'); - - // Backdrop element for modal overlay - const backdrop = document.querySelector('.api-auth-backdrop'); - - // Restore saved credentials from sessionStorage - const savedCredentials = getCredentials(); - if (savedCredentials.bearer && bearerInput) { - bearerInput.value = savedCredentials.bearer; - } - if (savedCredentials.basic) { - if (usernameInput) usernameInput.value = savedCredentials.basic.username; - if (passwordInput) passwordInput.value = savedCredentials.basic.password; - } - if (savedCredentials.querystring && querystringInput) { - querystringInput.value = savedCredentials.querystring; - } - - // Update status indicator based on saved credentials - if (hasCredentials() && statusEl) { - statusEl.textContent = 'Credentials set for this session.'; - trigger.textContent = 'Update credentials'; - } - - /** - * Toggle popover visibility - */ - function togglePopover(show?: boolean): void { - const shouldShow = show ?? popover.hidden; - popover.hidden = !shouldShow; - if (backdrop) backdrop.hidden = !shouldShow; - trigger.setAttribute('aria-expanded', String(shouldShow)); - - if (shouldShow) { - // Focus first input when opening - const firstInput = popover.querySelector( - 'input:not([type="hidden"])' - ); - firstInput?.focus(); - } - } - - /** - * Close popover - */ - function closePopover(): void { - togglePopover(false); - trigger.focus(); - } - - // Trigger button click - trigger.addEventListener('click', (e) => { - e.stopPropagation(); - togglePopover(); - }); - - // Close button - closeBtn?.addEventListener('click', closePopover); - - // Close on backdrop click - backdrop?.addEventListener('click', closePopover); - - // Close on outside click - function handleOutsideClick(e: MouseEvent): void { - if ( - !popover.hidden && - !popover.contains(e.target as Node) && - !trigger.contains(e.target as Node) - ) { - closePopover(); - } - } - document.addEventListener('click', handleOutsideClick); - - // Close on Escape - function handleEscape(e: KeyboardEvent): void { - if (e.key === 'Escape' && !popover.hidden) { - closePopover(); - } - } - document.addEventListener('keydown', handleEscape); - - // Show/hide toggle for password fields - const showToggles = - popover.querySelectorAll('.auth-show-toggle'); - showToggles.forEach((btn) => { - btn.addEventListener('click', () => { - const targetId = btn.dataset.target; - const input = popover.querySelector(`#${targetId}`); - if (input) { - const isPassword = input.type === 'password'; - input.type = isPassword ? 'text' : 'password'; - btn.classList.toggle('showing', !isPassword); - } - }); - }); - - /** - * Apply credentials - */ - function applyCredentials(): void { - const newCredentials: AuthCredentials = {}; - - // Get token from bearer or token input (they're combined for UX) - const tokenValue = bearerInput?.value || tokenInput?.value; - if (tokenValue) { - newCredentials.bearer = tokenValue; - } - - if (usernameInput?.value || passwordInput?.value) { - newCredentials.basic = { - username: usernameInput?.value || '', - password: passwordInput?.value || '', - }; - } - - if (querystringInput?.value) { - newCredentials.querystring = querystringInput.value; - } - - setCredentials(newCredentials); - updateStatusIndicator(trigger); - - // Update status text and button - if (hasCredentials()) { - if (statusEl) statusEl.textContent = 'Credentials set for this session.'; - trigger.textContent = 'Update credentials'; - } - - // Apply to RapiDoc - const rapiDoc = document.querySelector('rapi-doc') as HTMLElement | null; - if (rapiDoc && 'setApiKey' in rapiDoc) { - const applied = applyCredentialsToRapiDoc(rapiDoc, newCredentials); - if (applied) { - showFeedback(popover, 'Credentials saved for this session', 'success'); - } else { - showFeedback(popover, 'No credentials to apply', 'error'); - } - } else { - showFeedback(popover, 'Credentials saved for this session', 'success'); - } - } - - /** - * Clear credentials - */ - function clearCredentials(): void { - if (bearerInput) bearerInput.value = ''; - if (tokenInput) tokenInput.value = ''; - if (usernameInput) usernameInput.value = ''; - if (passwordInput) passwordInput.value = ''; - if (querystringInput) querystringInput.value = ''; - - setCredentials({}); - updateStatusIndicator(trigger); - - // Reset status text and button - if (statusEl) - statusEl.textContent = 'This endpoint requires authentication.'; - trigger.textContent = 'Set credentials'; - - // Clear from RapiDoc using removeAllSecurityKeys() - const rapiDoc = document.querySelector('rapi-doc') as HTMLElement | null; - if (rapiDoc && 'removeAllSecurityKeys' in rapiDoc) { - try { - (rapiDoc as any).removeAllSecurityKeys(); - } catch (e) { - console.debug('[API Auth] Failed to clear RapiDoc credentials:', e); - } - } - - showFeedback(popover, 'Credentials cleared', 'success'); - } - - // Button handlers - applyBtn?.addEventListener('click', applyCredentials); - clearBtn?.addEventListener('click', clearCredentials); - - // Listen for RapiDoc spec-loaded event to apply stored credentials - function handleSpecLoaded(event: Event): void { - const rapiDoc = event.target as HTMLElement; - const storedCredentials = getCredentials(); - if ( - storedCredentials.bearer || - storedCredentials.basic?.password || - storedCredentials.querystring - ) { - setTimeout(() => { - applyCredentialsToRapiDoc(rapiDoc, storedCredentials); - }, 100); - } - } - - // Watch for RapiDoc elements - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node instanceof HTMLElement && node.tagName === 'RAPI-DOC') { - node.addEventListener('spec-loaded', handleSpecLoaded); - } - } - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - // Check if RapiDoc already exists - const existingRapiDoc = document.querySelector('rapi-doc'); - if (existingRapiDoc) { - existingRapiDoc.addEventListener('spec-loaded', handleSpecLoaded); - } - - // Initialize status indicator - updateStatusIndicator(trigger); - - // Cleanup function - return (): void => { - observer.disconnect(); - document.removeEventListener('click', handleOutsideClick); - document.removeEventListener('keydown', handleEscape); - existingRapiDoc?.removeEventListener('spec-loaded', handleSpecLoaded); - }; -} diff --git a/assets/js/components/api-toc.ts b/assets/js/components/api-toc.ts index 2fc37799b..beb6ff5cc 100644 --- a/assets/js/components/api-toc.ts +++ b/assets/js/components/api-toc.ts @@ -3,7 +3,7 @@ * * Generates "ON THIS PAGE" navigation from content headings or operations data. * Features: - * - Builds TOC from h2 headings by default (avoids RapiDoc h3 fragment collisions) + * - Builds TOC from h2 headings by default * - Builds TOC from operations data passed via data-operations attribute (tag-based) * - Highlights current section on scroll (intersection observer) * - Smooth scroll to anchors @@ -44,11 +44,7 @@ interface OperationMeta { /** * Get headings from the currently visible content * - * For API pages, only h2 headings are collected to avoid fragment collisions - * from repetitive h3 headings like "Syntax", "Parameters", "Responses" that - * RapiDoc generates for each operation. - * - * @param maxLevel - Maximum heading level to include (default: 2 for API pages) + * @param maxLevel - Maximum heading level to include (default: 2) */ function getVisibleHeadings(maxLevel: number = 2): TocEntry[] { // Find the active tab panel or main content area @@ -147,7 +143,7 @@ function buildOperationsTocHtml(operations: OperationMeta[]): string { let html = '
    '; operations.forEach((op) => { - // Generate anchor ID from operationId (RapiDoc uses operationId for anchors) + // Generate anchor ID from operationId const anchorId = op.operationId; const methodClass = getMethodClass(op.method); @@ -269,44 +265,6 @@ function setupScrollHighlighting( return observer; } -/** - * Set up RapiDoc navigation for TOC links (for tag pages) - * Uses RapiDoc's scrollToPath method instead of native scroll - */ -function setupRapiDocNavigation(container: HTMLElement): void { - container.addEventListener('click', (event) => { - const target = event.target as HTMLElement; - const link = target.closest('.api-toc-link'); - - if (!link) { - return; - } - - const href = link.getAttribute('href'); - if (!href?.startsWith('#')) { - return; - } - - event.preventDefault(); - - // Get the path from the hash (e.g., "post-/api/v3/configure/distinct_cache") - const path = href.slice(1); - - // Find RapiDoc element and call scrollToPath - const rapiDoc = document.querySelector('rapi-doc') as HTMLElement & { - // eslint-disable-next-line no-unused-vars - scrollToPath?: (path: string) => void; - }; - - if (rapiDoc && typeof rapiDoc.scrollToPath === 'function') { - rapiDoc.scrollToPath(path); - } - - // Update URL hash - history.pushState(null, '', href); - }); -} - /** * Set up smooth scroll for TOC links */ @@ -348,7 +306,6 @@ function setupSmoothScroll(container: HTMLElement): void { /** * Update TOC visibility based on active tab - * Hide TOC for Operations tab (RapiDoc has built-in navigation) */ function updateTocVisibility(container: HTMLElement): void { const operationsPanel = document.querySelector( @@ -417,21 +374,12 @@ export default function ApiToc({ component }: ComponentOptions): void { } // Check if TOC was pre-rendered server-side (has existing links) - // For tag pages with RapiDoc, the TOC is rendered by Hugo from operations frontmatter const hasServerRenderedToc = nav.querySelectorAll('.api-toc-link').length > 0; if (hasServerRenderedToc) { // Server-side TOC exists - just show it and set up navigation component.classList.remove('is-hidden'); - - // For tag pages with RapiDoc, use RapiDoc's scrollToPath for navigation - // instead of smooth scrolling (which can't access shadow DOM elements) - const rapiDocWrapper = document.querySelector('[data-tag-page="true"]'); - if (rapiDocWrapper) { - setupRapiDocNavigation(component); - } else { - setupSmoothScroll(component); - } + setupSmoothScroll(component); return; } @@ -439,7 +387,7 @@ export default function ApiToc({ component }: ComponentOptions): void { const operations = parseOperationsData(component); let observer: IntersectionObserver | null = null; - // Get max heading level from data attribute (default: 2 to avoid RapiDoc h3 collisions) + // Get max heading level from data attribute (default: 2) // Use data-toc-depth="3" to include h3 headings if needed const maxHeadingLevel = parseInt( component.getAttribute('data-toc-depth') || '2', @@ -467,7 +415,6 @@ export default function ApiToc({ component }: ComponentOptions): void { } // Otherwise, fall back to heading-based TOC - // Use configured max heading level to avoid fragment collisions from RapiDoc h3s const entries = getVisibleHeadings(maxHeadingLevel); if (nav) { nav.innerHTML = buildTocHtml(entries); diff --git a/assets/js/main.js b/assets/js/main.js index e8cba67f3..bc9d6c89e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -46,7 +46,6 @@ import SidebarSearch from './components/sidebar-search.js'; import { SidebarToggle } from './sidebar-toggle.js'; import Theme from './theme.js'; import ThemeSwitch from './theme-switch.js'; -import ApiAuthInput from './components/api-auth-input.ts'; import ApiToc from './components/api-toc.ts'; /** @@ -79,7 +78,6 @@ const componentRegistry = { 'sidebar-toggle': SidebarToggle, theme: Theme, 'theme-switch': ThemeSwitch, - 'api-auth-input': ApiAuthInput, 'api-toc': ApiToc, }; diff --git a/assets/styles/layouts/_api-layout.scss b/assets/styles/layouts/_api-layout.scss index e48beade9..3d998ecca 100644 --- a/assets/styles/layouts/_api-layout.scss +++ b/assets/styles/layouts/_api-layout.scss @@ -35,7 +35,7 @@ padding: 1rem; border-left: 1px solid $nav-border; - // Hidden state (used when Operations/RapiDoc tab is active) + // Hidden state (used when a tab panel hides the TOC) &.is-hidden { display: none; } @@ -589,43 +589,6 @@ .tab-content:not(:first-of-type) { display: none; } - - // RapiDoc container styling - rapi-doc { - display: block; - width: 100%; - min-height: 400px; - } -} - -//////////////////////////////////////////////////////////////////////////////// -////////////////////////////// RapiDoc Overrides /////////////////////////////// -//////////////////////////////////////////////////////////////////////////////// - -// Hide RapiDoc's internal navigation (we provide our own) -rapi-doc::part(section-navbar) { - display: none !important; -} - -// Hide RapiDoc's internal tag headers/titles (we use custom tabs for navigation) -// label-tag-title is the "PROCESSING ENGINE" header with auth badges shown in tag groups -rapi-doc::part(label-tag-title) { - display: none !important; -} - -// Hide RapiDoc's authentication section (we have separate Auth tab) -rapi-doc::part(section-auth) { - display: none !important; -} - -// Ensure RapiDoc content fills available space -rapi-doc::part(section-main-content) { - padding: 0; -} - -// Match RapiDoc's operation section styling to our theme -rapi-doc::part(section-operations) { - padding: 0; } //////////////////////////////////////////////////////////////////////////////// diff --git a/assets/styles/layouts/_api-hugo-native.scss b/assets/styles/layouts/_api-operations.scss similarity index 99% rename from assets/styles/layouts/_api-hugo-native.scss rename to assets/styles/layouts/_api-operations.scss index 2b26bf16a..9b7f38506 100644 --- a/assets/styles/layouts/_api-hugo-native.scss +++ b/assets/styles/layouts/_api-operations.scss @@ -1,5 +1,5 @@ -// Hugo-Native API Documentation Styles -// Styled after docusaurus-openapi aesthetic with clean, readable layouts +// API Operations Styles +// Renders OpenAPI operations, parameters, schemas, and responses // Variables $api-border-radius: 6px; diff --git a/assets/styles/layouts/_api-overrides.scss b/assets/styles/layouts/_api-overrides.scss index f108911e2..00a7b1286 100644 --- a/assets/styles/layouts/_api-overrides.scss +++ b/assets/styles/layouts/_api-overrides.scss @@ -1,9 +1,8 @@ //////////////////////////////////////////////////////////////////////////////// // API Documentation Style Overrides // -// Provides loading spinner and reusable API-related styles. -// Note: Legacy Redoc-specific overrides have been removed in favor of -// Scalar/RapiDoc renderers which use CSS custom properties for theming. +// Provides loading spinner and reusable HTTP method badge colors. +// Used by Hugo-native API templates for consistent styling. //////////////////////////////////////////////////////////////////////////////// @import "tools/color-palette"; diff --git a/assets/styles/layouts/_api-security-schemes.scss b/assets/styles/layouts/_api-security-schemes.scss index e8fdabee3..3723786b0 100644 --- a/assets/styles/layouts/_api-security-schemes.scss +++ b/assets/styles/layouts/_api-security-schemes.scss @@ -3,7 +3,7 @@ // // Styles for security schemes sections displayed on conceptual API pages // (like Authentication). These sections are rendered from OpenAPI spec -// securitySchemes using Hugo templates, not RapiDoc. +// securitySchemes using Hugo templates. //////////////////////////////////////////////////////////////////////////////// .api-security-schemes { @@ -90,296 +90,3 @@ html:has(link[title="dark-theme"]:not([disabled])) { } } -//////////////////////////////////////////////////////////////////////////////// -// API Auth Modal - Credential input for operation pages -// -// Modal UI triggered by "Set credentials" button in auth info banner. -// Integrates with RapiDoc "Try it" via JavaScript API. -//////////////////////////////////////////////////////////////////////////////// - -// Backdrop overlay -.api-auth-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - z-index: 1000; - - &[hidden] { - display: none; - } -} - -// Credentials modal -.api-auth-popover { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1001; - min-width: 320px; - max-width: 400px; - background: $g20-white; - border: 1px solid $g5-pepper; - border-radius: 8px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); - - &[hidden] { - display: none; - } -} - -.api-auth-popover-content { - padding: 1rem; -} - -.popover-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; - - h4 { - margin: 0; - font-size: 0.95rem; - font-weight: 600; - color: $article-heading; - } -} - -.popover-close { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - background: none; - border: none; - border-radius: 4px; - cursor: pointer; - color: $g9-mountain; - transition: all 0.15s; - - &:hover { - background: rgba(0, 0, 0, 0.05); - color: $article-text; - } -} - -.auth-description { - margin: 0 0 1rem 0; - font-size: 0.85rem; - color: $g9-mountain; -} - -.auth-field { - margin-bottom: 0.75rem; - - label { - display: block; - margin-bottom: 0.25rem; - font-weight: 600; - font-size: 0.85rem; - color: $article-text; - } - - .auth-label-hint { - font-weight: 400; - color: $g9-mountain; - } - - input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid $g5-pepper; - border-radius: 3px; - font-family: inherit; - font-size: 0.9rem; - background: $g20-white; - color: $article-text; - - &:focus { - outline: none; - border-color: $b-pool; - box-shadow: 0 0 0 2px rgba($b-pool, 0.2); - } - - &::placeholder { - color: $g9-mountain; - } - } -} - -.auth-input-group { - position: relative; - display: flex; - align-items: center; - - input { - padding-right: 2.5rem; - } -} - -.auth-show-toggle { - position: absolute; - right: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - padding: 0.25rem; - background: none; - border: none; - cursor: pointer; - color: $g9-mountain; - opacity: 0.6; - transition: opacity 0.15s; - - &:hover { - opacity: 1; - } - - &.showing { - color: $b-pool; - opacity: 1; - } -} - -.auth-actions { - display: flex; - gap: 0.5rem; - margin-top: 1rem; - - .auth-apply, - .auth-clear { - padding: 0.4rem 0.75rem; - font-size: 0.85rem; - font-weight: 500; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - text-decoration: none; - } - - .auth-apply { - background: $b-pool; - color: $g20-white; - border: 1px solid $b-pool; - - &:hover { - background: darken($b-pool, 8%); - border-color: darken($b-pool, 8%); - } - } - - .auth-clear { - background: transparent; - color: $article-text; - border: 1px solid $g5-pepper; - - &:hover { - background: rgba(0, 0, 0, 0.05); - border-color: $g9-mountain; - } - } -} - -.auth-feedback { - margin: 0.75rem 0 0 0; - padding: 0.5rem 0.75rem; - font-size: 0.85rem; - border-radius: 3px; - - &.auth-feedback--success { - background: rgba($gr-viridian, 0.1); - color: $gr-viridian; - } - - &.auth-feedback--error { - background: rgba($r-fire, 0.1); - color: $r-fire; - } -} - -// Dark theme for modal -[data-theme="dark"], -html:has(link[title="dark-theme"]:not([disabled])) { - .api-auth-popover { - background: $grey15; - border-color: $grey25; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - } - - .popover-header h4 { - color: $g20-white; - } - - .popover-close { - color: $g15-platinum; - - &:hover { - background: $grey20; - color: $g20-white; - } - } - - .auth-description { - color: $g15-platinum; - } - - .auth-field { - label { - color: $g20-white; - } - - .auth-label-hint { - color: $g15-platinum; - } - - input { - background: $grey20; - border-color: $grey25; - color: $g20-white; - - &:focus { - border-color: $b-pool; - } - - &::placeholder { - color: $g9-mountain; - } - } - } - - .auth-show-toggle { - color: $g15-platinum; - - &.showing { - color: $b-pool; - } - } - - .auth-feedback { - &.auth-feedback--success { - background: rgba($gr-viridian, 0.15); - color: $gr-emerald; - } - - &.auth-feedback--error { - background: rgba($r-fire, 0.15); - color: $r-tungsten; - } - } - - .auth-actions { - .auth-clear { - color: $g15-platinum; - border-color: $grey25; - - &:hover { - background: $grey20; - border-color: $g15-platinum; - color: $g20-white; - } - } - } -} diff --git a/assets/styles/styles-default.scss b/assets/styles/styles-default.scss index 589160164..bf982f015 100644 --- a/assets/styles/styles-default.scss +++ b/assets/styles/styles-default.scss @@ -35,7 +35,7 @@ "layouts/v3-wayfinding", "layouts/api-layout", "layouts/api-security-schemes", - "layouts/api-hugo-native"; + "layouts/api-operations"; // Import Components @import "components/influxdb-version-detector", diff --git a/plans/2026-02-13-hugo-native-api-migration.md b/plans/2026-02-13-hugo-native-api-migration.md index b781b92ec..232441217 100644 --- a/plans/2026-02-13-hugo-native-api-migration.md +++ b/plans/2026-02-13-hugo-native-api-migration.md @@ -142,24 +142,37 @@ Simplified Cypress tests now that we use standard HTML instead of shadow DOM. *** -### Task 6: Clean up styles +### Task 6: Clean up styles ✅ COMPLETED -**Priority:** Medium +**Priority:** Medium | **Status:** Completed 2026-02-13 -Remove RapiDoc-specific styles and consolidate Hugo-native styles. +Remove RapiDoc-specific styles, JavaScript, and references from the codebase. -**Files to review:** +**Files modified:** -- `assets/styles/layouts/_api-layout.scss` -- `assets/styles/layouts/_api-overrides.scss` -- `assets/styles/layouts/_api-hugo-native.scss` +- `assets/styles/layouts/_api-layout.scss` - Removed \~40 lines of `rapi-doc::part()` CSS selectors +- `assets/styles/layouts/_api-overrides.scss` - Updated comment header +- `assets/styles/layouts/_api-security-schemes.scss` - Removed \~290 lines of dead auth modal styles +- `assets/js/main.js` - Removed dead `api-auth-input` import and registration +- `assets/js/components/api-toc.ts` - Removed RapiDoc-specific code and updated comments + +**Files deleted:** + +- `static/css/rapidoc-custom.css` - Unused static CSS file **Changes:** -1. Remove RapiDoc-specific CSS variables and selectors -2. Merge `_api-hugo-native.scss` into `_api-layout.scss` -3. Remove `_api-overrides.scss` if only contains RapiDoc overrides -4. Update SCSS imports in the main stylesheet +1. ✅ Removed `rapi-doc` container styling and `::part()` selectors from `_api-layout.scss` +2. ✅ Removed dead auth modal section from `_api-security-schemes.scss` (was for RapiDoc "Try it" integration) +3. ✅ Removed `api-auth-input` dead import from `main.js` (component file was already deleted) +4. ✅ Removed `setupRapiDocNavigation()` dead function and references from `api-toc.ts` +5. ✅ Updated comments throughout to remove RapiDoc mentions +6. ✅ Rebuilt `api-docs/scripts/dist/` to update compiled JavaScript + +**Architecture decision:** Kept operation styles separate from layout styles for cleaner separation of concerns: + +- `_api-layout.scss` handles page structure and navigation +- `_api-operations.scss` handles operation/schema component rendering (renamed from `_api-hugo-native.scss`) *** diff --git a/static/css/rapidoc-custom.css b/static/css/rapidoc-custom.css deleted file mode 100644 index f1d9979f6..000000000 --- a/static/css/rapidoc-custom.css +++ /dev/null @@ -1,18 +0,0 @@ -/* Custom RapiDoc overrides */ - -/* Reduce parameter table indentation - target the expand column */ -.m-table tr > td:first-child { - width: 0 !important; - min-width: 0 !important; - padding: 0 !important; -} - -/* Reduce param-name cell indentation */ -.param-name { - text-align: left !important; -} - -/* Make auth section scrollable on narrow viewports */ -.security-info-button { - flex-wrap: wrap; -}