From df9c9977cf3e20aa4e1b3d5065411643dd4c0983 Mon Sep 17 00:00:00 2001 From: Jason Stirnaman Date: Sat, 7 Mar 2026 23:00:22 -0600 Subject: [PATCH] feat(api): add inline curl examples and Ask AI links to API operations Generate curl examples at Hugo template time from OpenAPI specs, with server URL variable resolution, $ref handling, and JSON body generation from schema properties. Add "Ask AI about this example" links using existing Kapa integration. Add client library related link to all InfluxDB 3 API tag pages. Improve visual separation between operations with distinct badge colors and response body indentation. --- api-docs/influxdb3/core/v3/ref.yml | 19 +- api-docs/influxdb3/enterprise/v3/ref.yml | 10 +- .../scripts/dist/generate-openapi-articles.js | 49 +- .../dist/openapi-paths-to-hugo-data/index.js | 1412 +++++++++-------- api-docs/scripts/generate-openapi-articles.ts | 35 +- .../openapi-paths-to-hugo-data/index.ts | 98 +- assets/styles/layouts/_api-code-samples.scss | 67 + assets/styles/layouts/_api-operations.scss | 17 +- assets/styles/styles-default.scss | 3 +- .../api/authorizations-api-tokens/_index.md | 3 + .../api/bucket-schemas/_index.md | 3 + .../cloud-serverless/api/buckets/_index.md | 3 + .../cloud-serverless/api/dbrps/_index.md | 3 + .../cloud-serverless/api/delete/_index.md | 3 + .../api/invokable-scripts/_index.md | 3 + .../cloud-serverless/api/limits/_index.md | 3 + .../api/organizations/_index.md | 3 + .../cloud-serverless/api/resources/_index.md | 3 + .../cloud-serverless/api/routes/_index.md | 3 + .../cloud-serverless/api/secrets/_index.md | 3 + .../security-and-access-endpoints/_index.md | 3 + .../system-information-endpoints/_index.md | 3 + .../cloud-serverless/api/tasks/_index.md | 3 + .../cloud-serverless/api/telegrafs/_index.md | 3 + .../cloud-serverless/api/templates/_index.md | 3 + .../cloud-serverless/api/usage/_index.md | 3 + .../cloud-serverless/api/variables/_index.md | 3 + cypress/e2e/content/api-reference.cy.js | 169 +- .../2026-03-07-api-code-samples-design.md | 498 +----- layouts/partials/api/code-sample.html | 233 +++ layouts/partials/api/operation.html | 8 + layouts/partials/api/tag-renderer.html | 19 +- layouts/partials/article/related.html | 39 +- 33 files changed, 1550 insertions(+), 1180 deletions(-) create mode 100644 assets/styles/layouts/_api-code-samples.scss create mode 100644 layouts/partials/api/code-sample.html diff --git a/api-docs/influxdb3/core/v3/ref.yml b/api-docs/influxdb3/core/v3/ref.yml index b5e3e9eb2..b96ccc57f 100644 --- a/api-docs/influxdb3/core/v3/ref.yml +++ b/api-docs/influxdb3/core/v3/ref.yml @@ -29,7 +29,7 @@ info: name: InfluxData url: https://www.influxdata.com email: support@influxdata.com - x-influxdata-related: + x-related: - title: Migrate from InfluxDB v1 or v2 href: /influxdb3/core/get-started/migrate-from-influxdb-v1-v2/ servers: @@ -68,7 +68,7 @@ tags: values to cache, the maximum number of distinct value combinations to cache, and the maximum age of cached values. A DVC is associated with a table, which can have multiple DVCs. - x-influxdata-related: + x-related: - title: Manage the Distinct Value Cache href: /influxdb3/core/admin/distinct-value-cache/ - name: Cache last value @@ -83,7 +83,7 @@ tags: what fields to cache, what tags to use to identify each series, and the number of values to cache for each unique series. An LVC is associated with a table, which can have multiple LVCs. - x-influxdata-related: + x-related: - title: Manage the Last Value Cache href: /influxdb3/core/admin/last-value-cache/ - name: Migrate from InfluxDB v1 or v2 @@ -190,6 +190,11 @@ tags: - name: Auth token description: Manage tokens for authentication and authorization - name: Write data + x-related: + - title: Write data using HTTP APIs + href: /influxdb3/core/write-data/http-api/ + - title: Line protocol reference + href: /influxdb3/core/reference/syntax/line-protocol/ description: | Write data to InfluxDB 3 Core using line protocol format. @@ -331,7 +336,7 @@ paths: - QuerystringAuthentication: [] tags: - Write data - x-influxdata-related: + x-related: - title: Use compatibility APIs to write data href: /influxdb3/core/write-data/http-api/compatibility-apis/ /api/v2/write: @@ -422,7 +427,7 @@ paths: - TokenAuthentication: [] tags: - Write data - x-influxdata-related: + x-related: - title: Use compatibility APIs to write data href: /influxdb3/core/write-data/http-api/compatibility-apis/ /api/v3/write_lp: @@ -816,7 +821,7 @@ paths: - QuerystringAuthentication: [] tags: - Query data - x-influxdata-related: + x-related: - title: Use the InfluxDB v1 HTTP query API and InfluxQL to query data href: /influxdb3/core/query-data/execute-queries/influxdb-v1-api/ post: @@ -938,7 +943,7 @@ paths: - QuerystringAuthentication: [] tags: - Query data - x-influxdata-related: + x-related: - title: Use the InfluxDB v1 HTTP query API and InfluxQL to query data href: /influxdb3/core/query-data/execute-queries/influxdb-v1-api/ /health: diff --git a/api-docs/influxdb3/enterprise/v3/ref.yml b/api-docs/influxdb3/enterprise/v3/ref.yml index d04b3ba08..7262b9e80 100644 --- a/api-docs/influxdb3/enterprise/v3/ref.yml +++ b/api-docs/influxdb3/enterprise/v3/ref.yml @@ -29,7 +29,7 @@ info: name: InfluxData url: https://www.influxdata.com email: support@influxdata.com - x-influxdata-related: + x-related: - title: Migrate from InfluxDB v1 or v2 href: /influxdb3/enterprise/get-started/migrate-from-influxdb-v1-v2/ servers: @@ -321,7 +321,7 @@ paths: tags: - Write data - x-influxdata-related: + x-related: - title: Use compatibility APIs to write data href: /influxdb3/enterprise/write-data/http-api/compatibility-apis/ /api/v2/write: @@ -414,7 +414,7 @@ paths: tags: - Write data - x-influxdata-related: + x-related: - title: Use compatibility APIs to write data href: /influxdb3/enterprise/write-data/http-api/compatibility-apis/ /api/v3/write_lp: @@ -838,7 +838,7 @@ paths: tags: - Query data - x-influxdata-related: + x-related: - title: Use the InfluxDB v1 HTTP query API and InfluxQL to query data href: /influxdb3/enterprise/query-data/execute-queries/influxdb-v1-api/ post: @@ -957,7 +957,7 @@ paths: tags: - Query data - x-influxdata-related: + x-related: - title: Use the InfluxDB v1 HTTP query API and InfluxQL to query data href: /influxdb3/enterprise/query-data/execute-queries/influxdb-v1-api/ /health: diff --git a/api-docs/scripts/dist/generate-openapi-articles.js b/api-docs/scripts/dist/generate-openapi-articles.js index 1277b9778..10a762c1b 100644 --- a/api-docs/scripts/dist/generate-openapi-articles.js +++ b/api-docs/scripts/dist/generate-openapi-articles.js @@ -7,16 +7,20 @@ * for all InfluxDB products. * * This script: - * 1. Runs getswagger.sh to fetch/bundle OpenAPI specs - * 2. Copies specs to static directory for download - * 3. Generates path group fragments (YAML and JSON) - * 4. Creates article metadata (YAML and JSON) - * 5. Generates Hugo content pages from article data + * 1. Cleans output directories (unless --no-clean) + * 2. Runs getswagger.sh to fetch/bundle OpenAPI specs + * 3. Copies specs to static directory for download + * 4. Generates path group fragments (YAML and JSON) + * 5. Creates article metadata (YAML and JSON) + * 6. Generates Hugo content pages from article data * * Usage: - * node generate-openapi-articles.js # Generate all products - * node generate-openapi-articles.js cloud-v2 # Generate single product - * node generate-openapi-articles.js cloud-v2 oss-v2 # Generate multiple products + * node generate-openapi-articles.js # Clean and generate all products + * node generate-openapi-articles.js cloud-v2 # Clean and generate single product + * node generate-openapi-articles.js --no-clean # Generate without cleaning + * node generate-openapi-articles.js --dry-run # Preview what would be cleaned + * node generate-openapi-articles.js --skip-fetch # Skip getswagger.sh fetch step + * node generate-openapi-articles.js --validate-links # Validate documentation links * * @module generate-openapi-articles */ @@ -586,6 +590,25 @@ All {{% product-name %}} API endpoints, sorted by path. ) { frontmatter.related = article.fields.related; } + // Add client library related link for InfluxDB 3 products + if (contentPath.includes('influxdb3/') && !isConceptual) { + // Extract product segment from contentPath (e.g., "core" from ".../influxdb3/core") + const influxdb3Match = contentPath.match(/influxdb3\/([^/]+)/); + if (influxdb3Match) { + const productSegment = influxdb3Match[1]; + const clientLibLink = { + title: 'InfluxDB 3 API client libraries', + href: `/influxdb3/${productSegment}/reference/client-libraries/v3/`, + }; + const existing = frontmatter.related || []; + const alreadyHas = existing.some( + (r) => typeof r === 'object' && r.href === clientLibLink.href + ); + if (!alreadyHas) { + frontmatter.related = [...existing, clientLibLink]; + } + } + } // Add alt_links for cross-product API navigation if (apiProductsMap.size > 0) { const altLinks = {}; @@ -641,12 +664,16 @@ function mergeArticleData(articlesFiles, outputPath) { ...article.fields.operations, ]; } - // Merge related links + // Merge related links (dedup by href for both strings and objects) if (article.fields.related && article.fields.related.length > 0) { const existingRelated = existing.fields.related || []; - const newRelated = article.fields.related.filter( - (r) => !existingRelated.includes(r) + const existingHrefs = new Set( + existingRelated.map((r) => (typeof r === 'string' ? r : r.href)) ); + const newRelated = article.fields.related.filter((r) => { + const href = typeof r === 'string' ? r : r.href; + return !existingHrefs.has(href); + }); existing.fields.related = [...existingRelated, ...newRelated]; } // Keep the longest/most detailed description 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 c455fb6bf..0d33b0e54 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,47 +7,70 @@ * * @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 * @@ -56,8 +79,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 @@ -66,7 +89,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 @@ -75,22 +98,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 @@ -99,35 +122,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 @@ -136,20 +159,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 @@ -158,45 +181,51 @@ 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']; + } + // Extract x-related (title/href objects) if present + if (operation['x-related'] && Array.isArray(operation['x-related'])) { + opMeta.relatedLinks = operation['x-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 @@ -207,83 +236,85 @@ 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 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']); + 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; } - // 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); - } + }); + // 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'] + ); + } + // 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 @@ -292,12 +323,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) @@ -310,61 +341,65 @@ 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 - // 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']); + 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 + // 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'; } - // 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); + operation.tags = [primaryTag]; + usedTags.add(primaryTag); + } }); - console.log(`Generated ${pathSpecFiles.size} path-specific specs in ${outPath}`); - return pathSpecFiles; + // 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 @@ -375,144 +410,38 @@ 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]; + 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); - } - }); -} -/** - * Create article metadata for a path group - * - * @param openapi - OpenAPI document with x-pathGroup - * @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 = [ + // 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', @@ -521,42 +450,161 @@ function createArticleDataForPathGroup(openapi) { '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))); + ]; + httpMethods.forEach((method) => { + const operation = pathItem[method]; + if (operation?.tags) { + operation.tags.forEach((tag) => usedTags.add(tag)); } - // 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; + // 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'] + ); } - // Add OpenAPI tags from operations (for Hugo frontmatter) - if (apiTags.length > 0) { - article.fields.apiTags = apiTags; + // 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); } - return article; + }); +} +/** + * Create article metadata for a path group + * + * @param openapi - OpenAPI document with x-pathGroup + * @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) + ) + ); + } + // 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; } /** * Write OpenAPI article metadata to Hugo data files @@ -567,49 +615,50 @@ 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 }); - } - 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); + /** + * 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); + } } /** * Sanitize markdown description by removing fragment links and ReDoc directives @@ -626,34 +675,37 @@ function writeOpenapiArticleData(sourcePath, targetPath, opts) { * @returns Sanitized description suitable for children shortcode rendering */ function sanitizeDescription(description) { - if (!description) { - return ''; + if (!description) { + return ''; + } + 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; } - 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; + ); + // 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 @@ -664,74 +716,91 @@ 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); + 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 related links from multiple sources into article-level related + // This populates Hugo frontmatter `related` field for "Related content" links + // Supports both plain URL strings and {title, href} objects + const relatedItems = []; + const seenHrefs = new Set(); + // Helper to add a link, deduplicating by href + const addRelated = (item) => { + const href = typeof item === 'string' ? item : item.href; + if (!seenHrefs.has(href)) { + seenHrefs.add(href); + relatedItems.push(item); } - // Show security schemes section on Authentication pages - if (tagName === 'Authentication') { - article.fields.showSecuritySchemes = true; + }; + // Tag-level x-related ({title, href} objects) + if (tagMeta?.['x-related']) { + tagMeta['x-related'].forEach(addRelated); + } + // Tag-level x-influxdatadocs-related (plain URLs) + if (tagMeta?.['x-influxdatadocs-related']) { + tagMeta['x-influxdatadocs-related'].forEach(addRelated); + } + // Tag-level externalDocs (legacy single link) + if (tagMeta?.externalDocs?.url) { + addRelated(tagMeta.externalDocs.url); + } + // Operation-level related links + operations.forEach((op) => { + if (op.relatedLinks) { + op.relatedLinks.forEach(addRelated); } - // Set custom weight for Quick start to appear first in nav - if (tagName === 'Quick start') { - article.fields.weight = 1; + if (op.related) { + op.related.forEach(addRelated); } - // Set default weight for consistent sorting (articles without explicit weight) - if (article.fields.weight === undefined) { - article.fields.weight = 100; + if (op.externalDocs?.url) { + addRelated(op.externalDocs.url); } - // 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; + }); + if (relatedItems.length > 0) { + article.fields.related = relatedItems; + } + return article; } /** * Write tag-based OpenAPI article metadata to Hugo data files @@ -743,82 +812,99 @@ 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 article = createArticleDataForTag(tagOpenapi, operations, tagMeta); - article.fields.source = filePath; - article.fields.staticFilePath = filePath.replace(/^static\//, '/'); - return article; + 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']; + } + // Extract x-related (title/href objects) + if ( + operation['x-related'] && + Array.isArray(operation['x-related']) + ) { + opMeta.relatedLinks = operation['x-related']; + } + operations.push(opMeta); + } + }); }); - 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 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 }); } + 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 @@ -832,24 +918,28 @@ 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 @@ -863,15 +953,17 @@ 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 @@ -883,14 +975,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 \ No newline at end of file +//# sourceMappingURL=index.js.map diff --git a/api-docs/scripts/generate-openapi-articles.ts b/api-docs/scripts/generate-openapi-articles.ts index d53d4419f..8ecf7cb00 100644 --- a/api-docs/scripts/generate-openapi-articles.ts +++ b/api-docs/scripts/generate-openapi-articles.ts @@ -557,7 +557,7 @@ function generateTagPagesFromArticleData( menuGroup?: string; staticFilePath?: string; operations?: OperationMeta[]; - related?: string[]; + related?: (string | { title: string; href: string })[]; weight?: number; }; }>; @@ -738,6 +738,27 @@ All {{% product-name %}} API endpoints, sorted by path. frontmatter.related = article.fields.related; } + // Add client library related link for InfluxDB 3 products + if (contentPath.includes('influxdb3/') && !isConceptual) { + // Extract product segment from contentPath (e.g., "core" from ".../influxdb3/core") + const influxdb3Match = contentPath.match(/influxdb3\/([^/]+)/); + if (influxdb3Match) { + const productSegment = influxdb3Match[1]; + const clientLibLink = { + title: 'InfluxDB 3 API client libraries', + href: `/influxdb3/${productSegment}/reference/client-libraries/v3/`, + }; + const existing = + (frontmatter.related as Array<{ title: string; href: string }>) || []; + const alreadyHas = existing.some( + (r) => typeof r === 'object' && r.href === clientLibLink.href + ); + if (!alreadyHas) { + frontmatter.related = [...existing, clientLibLink]; + } + } + } + // Add alt_links for cross-product API navigation if (apiProductsMap.size > 0) { const altLinks: Record = {}; @@ -781,7 +802,7 @@ interface ArticleData { menuGroup?: string; staticFilePath?: string; operations?: OperationMeta[]; - related?: string[]; + related?: (string | { title: string; href: string })[]; source?: string; }; }>; @@ -827,12 +848,16 @@ function mergeArticleData(articlesFiles: string[], outputPath: string): void { ]; } - // Merge related links + // Merge related links (dedup by href for both strings and objects) if (article.fields.related && article.fields.related.length > 0) { const existingRelated = existing.fields.related || []; - const newRelated = article.fields.related.filter( - (r) => !existingRelated.includes(r) + const existingHrefs = new Set( + existingRelated.map((r) => (typeof r === 'string' ? r : r.href)) ); + const newRelated = article.fields.related.filter((r) => { + const href = typeof r === 'string' ? r : r.href; + return !existingHrefs.has(href); + }); existing.fields.related = [...existingRelated, ...newRelated]; } diff --git a/api-docs/scripts/openapi-paths-to-hugo-data/index.ts b/api-docs/scripts/openapi-paths-to-hugo-data/index.ts index e6ea57d64..185b1e508 100644 --- a/api-docs/scripts/openapi-paths-to-hugo-data/index.ts +++ b/api-docs/scripts/openapi-paths-to-hugo-data/index.ts @@ -11,6 +11,14 @@ import * as yaml from 'js-yaml'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Related link with title and href (from x-influxdata-related) + */ +interface RelatedLink { + title: string; + href: string; +} + /** * OpenAPI path item object */ @@ -41,8 +49,10 @@ interface Operation { externalDocs?: ExternalDocs; /** Compatibility version for migration context (v1 or v2) */ 'x-compatibility-version'?: string; - /** Related documentation links (replaces inline "Related guides" sections) */ + /** Related documentation links as plain URLs */ 'x-influxdatadocs-related'?: string[]; + /** Related documentation links with title and href */ + 'x-related'?: RelatedLink[]; [key: string]: unknown; } @@ -218,8 +228,10 @@ interface Tag { externalDocs?: ExternalDocs; /** Indicates this is a conceptual/supplementary tag (no operations) */ 'x-traitTag'?: boolean; - /** Related documentation links (replaces inline "Related guides" sections) */ + /** Related documentation links as plain URLs */ 'x-influxdatadocs-related'?: string[]; + /** Related documentation links with title and href */ + 'x-related'?: RelatedLink[]; [key: string]: unknown; } @@ -239,8 +251,10 @@ interface OperationMeta { description: string; url: string; }; - /** Related documentation links */ + /** Related documentation links (plain URLs) */ related?: string[]; + /** Related documentation links with title and href */ + relatedLinks?: RelatedLink[]; } /** @@ -265,8 +279,8 @@ interface Article { tags?: string[]; source?: string; staticFilePath?: string; - /** Related documentation links extracted from x-relatedLinks */ - related?: string[]; + /** Related documentation links (plain URLs or {title, href} objects) */ + related?: (string | RelatedLink)[]; /** OpenAPI tags from operations (for Hugo frontmatter) */ apiTags?: string[]; /** Menu display name (actual endpoint path, different from Hugo path) */ @@ -473,6 +487,11 @@ function extractOperationsByTag( opMeta.related = operation['x-influxdatadocs-related']; } + // Extract x-related (title/href objects) if present + if (operation['x-related'] && Array.isArray(operation['x-related'])) { + opMeta.relatedLinks = operation['x-related'] as RelatedLink[]; + } + // Add operation to each of its tags (operation.tags || []).forEach((tag) => { if (!tagOperations.has(tag)) { @@ -1115,36 +1134,51 @@ function createArticleDataForTag( article.fields.weight = 100; } - // Aggregate unique related URLs from multiple sources into article-level related + // Aggregate related links from multiple sources into article-level related // This populates Hugo frontmatter `related` field for "Related content" links - const relatedUrls = new Set(); + // Supports both plain URL strings and {title, href} objects + const relatedItems: (string | RelatedLink)[] = []; + const seenHrefs = new Set(); - // First check tag-level x-influxdatadocs-related - if (tagMeta?.['x-influxdatadocs-related']) { - (tagMeta['x-influxdatadocs-related'] as string[]).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)); + // Helper to add a link, deduplicating by href + const addRelated = (item: string | RelatedLink): void => { + const href = typeof item === 'string' ? item : item.href; + if (!seenHrefs.has(href)) { + seenHrefs.add(href); + relatedItems.push(item); + } + }; + + // Tag-level x-related ({title, href} objects) + if (tagMeta?.['x-related']) { + (tagMeta['x-related'] as RelatedLink[]).forEach(addRelated); + } + + // Tag-level x-influxdatadocs-related (plain URLs) + if (tagMeta?.['x-influxdatadocs-related']) { + (tagMeta['x-influxdatadocs-related'] as string[]).forEach(addRelated); + } + + // Tag-level externalDocs (legacy single link) + if (tagMeta?.externalDocs?.url) { + addRelated(tagMeta.externalDocs.url); + } + + // Operation-level related links + operations.forEach((op) => { + if (op.relatedLinks) { + op.relatedLinks.forEach(addRelated); + } + if (op.related) { + op.related.forEach(addRelated); } - // Check operation-level externalDocs (legacy single link) if (op.externalDocs?.url) { - relatedUrls.add(op.externalDocs.url); + addRelated(op.externalDocs.url); } }); - if (relatedUrls.size > 0) { - article.fields.related = Array.from(relatedUrls); + if (relatedItems.length > 0) { + article.fields.related = relatedItems; } return article; @@ -1231,6 +1265,14 @@ function writeOpenapiTagArticleData( opMeta.related = operation['x-influxdatadocs-related']; } + // Extract x-related (title/href objects) + if ( + operation['x-related'] && + Array.isArray(operation['x-related']) + ) { + opMeta.relatedLinks = operation['x-related'] as RelatedLink[]; + } + operations.push(opMeta); } }); diff --git a/assets/styles/layouts/_api-code-samples.scss b/assets/styles/layouts/_api-code-samples.scss new file mode 100644 index 000000000..546f9a3a0 --- /dev/null +++ b/assets/styles/layouts/_api-code-samples.scss @@ -0,0 +1,67 @@ +// API Code Samples +// Styles for inline curl examples and Ask AI links within API operations + +.api-code-sample { + margin: $api-spacing-lg 0; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: $api-border-radius; + overflow: hidden; + + .dark-theme & { + border-color: rgba(255, 255, 255, 0.1); + } +} + +.api-code-sample-header { + margin: 0; + padding: $api-spacing-sm $api-spacing-md; + font-size: 0.85rem; + font-weight: 600; + background: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + + .dark-theme & { + background: rgba(255, 255, 255, 0.03); + border-bottom-color: rgba(255, 255, 255, 0.1); + } +} + +.api-code-sample-body { + position: relative; +} + +pre.api-code-block { + margin: 0; + padding: $api-spacing-md; + padding-bottom: $api-spacing-lg + 0.5rem; + overflow-x: auto; + background: #1e1e2e; + color: #cdd6f4; + font-size: 0.8rem; + line-height: 1.5; + border-radius: 0; + + code { + background: none; + padding: 0; + color: inherit; + font-size: inherit; + line-height: inherit; + white-space: pre; + } +} + +.api-code-ask-ai { + position: absolute; + right: $api-spacing-md; + bottom: $api-spacing-sm; + font-size: 0.8rem; + color: #89b4fa; + text-decoration: none; + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } +} diff --git a/assets/styles/layouts/_api-operations.scss b/assets/styles/layouts/_api-operations.scss index 8b4fe6dad..9bf538681 100644 --- a/assets/styles/layouts/_api-operations.scss +++ b/assets/styles/layouts/_api-operations.scss @@ -8,17 +8,17 @@ $api-spacing-md: 1rem; $api-spacing-lg: 1.5rem; $api-spacing-xl: 2rem; -// Method colors (matching existing theme) +// Method colors $method-get: #00A3FF; $method-post: #34BB55; $method-put: #FFB94A; -$method-delete: #F95F53; +$method-delete: #D63031; $method-patch: #9b2aff; -// Status code colors +// Status code colors — intentionally distinct from method colors $status-success: #34BB55; $status-redirect: #FFB94A; -$status-client-error: #F95F53; +$status-client-error: #E17055; $status-server-error: #9b2aff; // ============================================ @@ -31,8 +31,8 @@ $status-server-error: #9b2aff; .api-operation { margin-bottom: $api-spacing-xl; - padding-top: $api-spacing-lg; - border-top: 1px solid $nav-border; + padding-top: $api-spacing-xl; + border-top: 2px solid $nav-border; &:first-child { border-top: none; @@ -430,7 +430,10 @@ $status-server-error: #9b2aff; } .api-response-body { - padding-top: $api-spacing-sm; + margin-top: $api-spacing-sm; + margin-left: $api-spacing-lg; + padding: $api-spacing-sm 0 $api-spacing-sm $api-spacing-md; + border-left: 2px solid rgba($article-text, 0.1); } // ============================================ diff --git a/assets/styles/styles-default.scss b/assets/styles/styles-default.scss index bf982f015..310f91773 100644 --- a/assets/styles/styles-default.scss +++ b/assets/styles/styles-default.scss @@ -35,7 +35,8 @@ "layouts/v3-wayfinding", "layouts/api-layout", "layouts/api-security-schemes", - "layouts/api-operations"; + "layouts/api-operations", + "layouts/api-code-samples"; // Import Components @import "components/influxdb-version-detector", diff --git a/content/influxdb3/cloud-serverless/api/authorizations-api-tokens/_index.md b/content/influxdb3/cloud-serverless/api/authorizations-api-tokens/_index.md index ffffab7a4..39924aa18 100644 --- a/content/influxdb3/cloud-serverless/api/authorizations-api-tokens/_index.md +++ b/content/influxdb3/cloud-serverless/api/authorizations-api-tokens/_index.md @@ -93,6 +93,9 @@ operations: summary: Delete an authorization tags: - Authorizations (API tokens) +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/bucket-schemas/_index.md b/content/influxdb3/cloud-serverless/api/bucket-schemas/_index.md index bc92cc9df..a4d79085b 100644 --- a/content/influxdb3/cloud-serverless/api/bucket-schemas/_index.md +++ b/content/influxdb3/cloud-serverless/api/bucket-schemas/_index.md @@ -34,6 +34,9 @@ operations: summary: Update a measurement schema tags: - Bucket Schemas +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/buckets/_index.md b/content/influxdb3/cloud-serverless/api/buckets/_index.md index 8ec29931d..1347919f7 100644 --- a/content/influxdb3/cloud-serverless/api/buckets/_index.md +++ b/content/influxdb3/cloud-serverless/api/buckets/_index.md @@ -112,6 +112,9 @@ operations: summary: Remove an owner from a bucket tags: - Buckets +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/dbrps/_index.md b/content/influxdb3/cloud-serverless/api/dbrps/_index.md index 88282cbca..e6f5d34cf 100644 --- a/content/influxdb3/cloud-serverless/api/dbrps/_index.md +++ b/content/influxdb3/cloud-serverless/api/dbrps/_index.md @@ -63,6 +63,9 @@ operations: summary: Delete a database retention policy tags: - DBRPs +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/delete/_index.md b/content/influxdb3/cloud-serverless/api/delete/_index.md index 60b802fe3..b4bb56fea 100644 --- a/content/influxdb3/cloud-serverless/api/delete/_index.md +++ b/content/influxdb3/cloud-serverless/api/delete/_index.md @@ -16,6 +16,9 @@ operations: summary: Delete data tags: - Delete +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/invokable-scripts/_index.md b/content/influxdb3/cloud-serverless/api/invokable-scripts/_index.md index 06c265f7b..f89afbda1 100644 --- a/content/influxdb3/cloud-serverless/api/invokable-scripts/_index.md +++ b/content/influxdb3/cloud-serverless/api/invokable-scripts/_index.md @@ -69,6 +69,9 @@ operations: summary: Find script parameters. tags: - Invokable Scripts +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/limits/_index.md b/content/influxdb3/cloud-serverless/api/limits/_index.md index c227e7102..de7201bca 100644 --- a/content/influxdb3/cloud-serverless/api/limits/_index.md +++ b/content/influxdb3/cloud-serverless/api/limits/_index.md @@ -16,6 +16,9 @@ operations: summary: Retrieve limits for an organization tags: - Limits +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/organizations/_index.md b/content/influxdb3/cloud-serverless/api/organizations/_index.md index 1b2d9e80c..169794bc2 100644 --- a/content/influxdb3/cloud-serverless/api/organizations/_index.md +++ b/content/influxdb3/cloud-serverless/api/organizations/_index.md @@ -82,6 +82,9 @@ operations: summary: Remove an owner from an organization tags: - Organizations +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/resources/_index.md b/content/influxdb3/cloud-serverless/api/resources/_index.md index c676933c8..f72f88ec0 100644 --- a/content/influxdb3/cloud-serverless/api/resources/_index.md +++ b/content/influxdb3/cloud-serverless/api/resources/_index.md @@ -16,6 +16,9 @@ operations: summary: List all known resources tags: - Resources +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/routes/_index.md b/content/influxdb3/cloud-serverless/api/routes/_index.md index d4b218ac1..4c2b8ea47 100644 --- a/content/influxdb3/cloud-serverless/api/routes/_index.md +++ b/content/influxdb3/cloud-serverless/api/routes/_index.md @@ -16,6 +16,9 @@ operations: summary: List all top level routes tags: - Routes +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/secrets/_index.md b/content/influxdb3/cloud-serverless/api/secrets/_index.md index aac403b5a..0025e6b2c 100644 --- a/content/influxdb3/cloud-serverless/api/secrets/_index.md +++ b/content/influxdb3/cloud-serverless/api/secrets/_index.md @@ -34,6 +34,9 @@ operations: summary: Delete secrets from an organization tags: - Secrets +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/security-and-access-endpoints/_index.md b/content/influxdb3/cloud-serverless/api/security-and-access-endpoints/_index.md index de0b340c0..ffebd0894 100644 --- a/content/influxdb3/cloud-serverless/api/security-and-access-endpoints/_index.md +++ b/content/influxdb3/cloud-serverless/api/security-and-access-endpoints/_index.md @@ -40,6 +40,9 @@ operations: summary: Delete an authorization tags: - Security and access endpoints +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/system-information-endpoints/_index.md b/content/influxdb3/cloud-serverless/api/system-information-endpoints/_index.md index d860e5751..c15b666cd 100644 --- a/content/influxdb3/cloud-serverless/api/system-information-endpoints/_index.md +++ b/content/influxdb3/cloud-serverless/api/system-information-endpoints/_index.md @@ -22,6 +22,9 @@ operations: summary: List all known resources tags: - System information endpoints +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/tasks/_index.md b/content/influxdb3/cloud-serverless/api/tasks/_index.md index aa14a5d43..028d2752e 100644 --- a/content/influxdb3/cloud-serverless/api/tasks/_index.md +++ b/content/influxdb3/cloud-serverless/api/tasks/_index.md @@ -165,6 +165,9 @@ operations: summary: Retry a task run tags: - Tasks +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/telegrafs/_index.md b/content/influxdb3/cloud-serverless/api/telegrafs/_index.md index cb8dd00cb..45a2278f1 100644 --- a/content/influxdb3/cloud-serverless/api/telegrafs/_index.md +++ b/content/influxdb3/cloud-serverless/api/telegrafs/_index.md @@ -94,6 +94,9 @@ operations: summary: Remove an owner from a Telegraf config tags: - Telegrafs +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/templates/_index.md b/content/influxdb3/cloud-serverless/api/templates/_index.md index abf9f52f4..71514c623 100644 --- a/content/influxdb3/cloud-serverless/api/templates/_index.md +++ b/content/influxdb3/cloud-serverless/api/templates/_index.md @@ -87,6 +87,9 @@ operations: summary: Export a new template tags: - Templates +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/usage/_index.md b/content/influxdb3/cloud-serverless/api/usage/_index.md index bf0a648b5..835c467de 100644 --- a/content/influxdb3/cloud-serverless/api/usage/_index.md +++ b/content/influxdb3/cloud-serverless/api/usage/_index.md @@ -16,6 +16,9 @@ operations: summary: Retrieve usage for an organization tags: - Usage +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/content/influxdb3/cloud-serverless/api/variables/_index.md b/content/influxdb3/cloud-serverless/api/variables/_index.md index 1adb72492..a581798af 100644 --- a/content/influxdb3/cloud-serverless/api/variables/_index.md +++ b/content/influxdb3/cloud-serverless/api/variables/_index.md @@ -64,6 +64,9 @@ operations: summary: Delete a label from a variable tags: - Variables +related: + - title: InfluxDB 3 API client libraries + href: /influxdb3/cloud-serverless/reference/client-libraries/v3/ alt_links: core: /influxdb3/core/api/ enterprise: /influxdb3/enterprise/api/ diff --git a/cypress/e2e/content/api-reference.cy.js b/cypress/e2e/content/api-reference.cy.js index 294d188f8..36feaa027 100644 --- a/cypress/e2e/content/api-reference.cy.js +++ b/cypress/e2e/content/api-reference.cy.js @@ -7,6 +7,7 @@ * 1. API reference pages (link validation, content structure) * 2. 3-column layout with TOC (for InfluxDB 3 Core/Enterprise) * 3. Hugo-native tag page rendering + * 4. Related links from OpenAPI x-related → frontmatter → rendered HTML * * Run with: * node cypress/support/run-e2e-specs.js --spec "cypress/e2e/content/api-reference.cy.js" content/influxdb3/core/reference/api/_index.md @@ -346,11 +347,11 @@ describe('All endpoints page', () => { ); }); - it('operation cards link to tag pages with method anchors', () => { + it('operation cards link to tag pages with operation anchors', () => { cy.get('.api-operation-card') .first() .should('have.attr', 'href') - .and('match', /\/api\/.*\/#(get|post|put|patch|delete)-/i); + .and('match', /\/api\/.*\/#operation\//); }); it('is accessible from navigation', () => { @@ -363,3 +364,167 @@ describe('All endpoints page', () => { }); }); }); + +/** + * API Code Sample Tests + * Tests that inline curl examples render correctly on tag pages + */ +describe('API code samples', () => { + const tagPages = [ + '/influxdb3/core/api/write-data/', + '/influxdb3/enterprise/api/write-data/', + ]; + + tagPages.forEach((page) => { + describe(`Code samples on ${page}`, () => { + beforeEach(() => { + cy.intercept('GET', '**', (req) => { + req.continue((res) => { + if (res.headers['content-type']?.includes('text/html')) { + res.body = res.body.replace( + /data-user-analytics-fingerprint-enabled="true"/, + 'data-user-analytics-fingerprint-enabled="false"' + ); + } + }); + }); + cy.visit(page); + }); + + it('each operation has a code sample', () => { + cy.get('.api-operation').each(($op) => { + cy.wrap($op).find('.api-code-sample').should('have.length', 1); + }); + }); + + it('code samples have header and code block', () => { + cy.get('.api-code-sample') + .first() + .within(() => { + cy.get('.api-code-sample-header').should( + 'contain', + 'Example request' + ); + cy.get('.api-code-block code').should('exist'); + }); + }); + + it('code block contains a curl command', () => { + cy.get('.api-code-block code') + .first() + .invoke('text') + .should('match', /curl --request (GET|POST|PUT|PATCH|DELETE)/); + }); + + it('curl command includes Authorization header', () => { + cy.get('.api-code-block code') + .first() + .invoke('text') + .should('include', 'Authorization: Bearer INFLUX_TOKEN'); + }); + + it('POST operations include request body in curl', () => { + cy.get('.api-operation[data-method="post"]') + .first() + .find('.api-code-block code') + .invoke('text') + .should('include', '--data-raw'); + }); + + it('code samples have Ask AI links', () => { + cy.get('.api-code-sample .api-code-ask-ai') + .first() + .should('have.attr', 'data-query') + .and('not.be.empty'); + }); + }); + }); +}); + +/** + * API Client Library Related Link Tests + * Tests that InfluxDB 3 tag pages include client library related links + */ +describe('API client library related links', () => { + const influxdb3Pages = [ + '/influxdb3/core/api/write-data/', + '/influxdb3/enterprise/api/write-data/', + ]; + + influxdb3Pages.forEach((page) => { + describe(`Client library link on ${page}`, () => { + beforeEach(() => { + cy.intercept('GET', '**', (req) => { + req.continue((res) => { + if (res.headers['content-type']?.includes('text/html')) { + res.body = res.body.replace( + /data-user-analytics-fingerprint-enabled="true"/, + 'data-user-analytics-fingerprint-enabled="false"' + ); + } + }); + }); + cy.visit(page); + }); + + it('includes InfluxDB 3 API client libraries in related links', () => { + cy.get('.related ul li a') + .filter(':contains("InfluxDB 3 API client libraries")') + .should('have.length', 1) + .and('have.attr', 'href') + .and('match', /\/influxdb3\/\w+\/reference\/client-libraries\/v3\//); + }); + }); + }); +}); + +/** + * API Related Links Tests + * Tests that x-related from OpenAPI specs renders as related links on tag pages + */ +describe('API related links', () => { + const pagesWithRelated = ['/influxdb3/core/api/write-data/']; + + pagesWithRelated.forEach((page) => { + describe(`Related links on ${page}`, () => { + beforeEach(() => { + cy.intercept('GET', '**', (req) => { + req.continue((res) => { + if (res.headers['content-type']?.includes('text/html')) { + res.body = res.body.replace( + /data-user-analytics-fingerprint-enabled="true"/, + 'data-user-analytics-fingerprint-enabled="false"' + ); + } + }); + }); + cy.visit(page); + }); + + it('displays a related section', () => { + cy.get('.related').should('exist'); + cy.get('.related h4#related').should('contain', 'Related'); + }); + + it('renders related links from x-related as anchor elements', () => { + cy.get('.related ul li a').should('have.length.at.least', 2); + }); + + it('related links have title text and valid href', () => { + cy.get('.related ul li a').each(($a) => { + // Each link has non-empty text + cy.wrap($a).invoke('text').should('not.be.empty'); + // Each link has an href starting with / + cy.wrap($a).should('have.attr', 'href').and('match', /^\//); + }); + }); + + it('related links resolve to valid pages', () => { + cy.get('.related ul li a').each(($a) => { + const href = $a.attr('href'); + cy.request(href).its('status').should('eq', 200); + }); + }); + }); + }); +}); diff --git a/docs/plans/2026-03-07-api-code-samples-design.md b/docs/plans/2026-03-07-api-code-samples-design.md index fe7e2095a..1ef32cc2b 100644 --- a/docs/plans/2026-03-07-api-code-samples-design.md +++ b/docs/plans/2026-03-07-api-code-samples-design.md @@ -1,455 +1,125 @@ -# API Code Samples, Ask AI, & Client Library Integration Plan +# API Code Samples & Ask AI Integration Plan -## Context +## Scope -The docs-v2 site recently migrated from RapiDoc to Hugo-native API templates. The current API pages render operation details (method, path, parameters, request body, response schemas) but have **no code samples**. This plan adds: +This plan covers: -1. **curl request examples** generated at build time, displayed **inline within each operation** in the article flow -2. **Response body schema** for the successful response, shown inline below the request example -3. **"Ask AI about this example"** button on code blocks (API code samples first, then all code blocks site-wide) -4. **Client library integration** (phased — links first, code samples later) +1. **Inline curl examples** for each API operation, generated at Hugo template time from the OpenAPI spec +2. **"Ask AI about this example"** link on each curl example, using the existing Kapa integration +3. **Client library related link** on all InfluxDB 3 API tag pages -## Design Decision: Inline Code Samples (Not Sidebar) +**Out of scope** (separate plans): -Code samples render **within the article flow** inside each operation block, not crammed into the right sidebar. The TOC stays at its current 200px width, unchanged. +- Site-wide Ask AI on all code blocks (render-codeblock hook) +- Client library tabbed code samples with language tabs +- Duplicate response schema rendering (already shown in Responses section) -Each operation block gets a code sample section at the bottom: +*** -``` -┌──────────────────────────────────────────────────┐ -│ POST /api/v2/write │ -│ ───────────────── │ -│ Summary: Write data │ -│ Description: ... │ -│ │ -│ Parameters │ -│ ┌────────┬──────┬────────────────────────┐ │ -│ │ Name │ Type │ Description │ │ -│ └────────┴──────┴────────────────────────┘ │ -│ │ -│ Request Body │ -│ ┌────────────────────────────────────────┐ │ -│ │ schema details... │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ Responses │ -│ ┌────────────────────────────────────────┐ │ -│ │ 204 Success │ │ -│ │ 400 Bad Request │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ ┌─ Example ──────────────────────────────┐ │ -│ │ ┌──────────────────────────────────┐ │ │ -│ │ │ curl --request POST \ │ │ │ -│ │ │ "http://localhost:8181/..." \ │ │ │ -│ │ │ --header "Authorization: ..." \│ │ │ -│ │ │ --header "Content-Type: ..." \ │ │ │ -│ │ │ --data-raw "mem,host=a v=1.0" │ │ │ -│ │ └──────────────────────────────────┘ │ │ -│ │ [Ask AI about this example] │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ ┌─ Client Libraries ────────────────────┐ │ -│ │ [Python] [JavaScript] [Go] [Java] [C#]│ │ -│ │ ┌──────────────────────────────────┐ │ │ -│ │ │ from influxdb_client_3 import │ │ │ -│ │ │ InfluxDBClient3 │ │ │ -│ │ │ client = InfluxDBClient3(...) │ │ │ -│ │ │ client.write(record="...") │ │ │ -│ │ └──────────────────────────────────┘ │ │ -│ │ 📖 Full guide: Write data with │ │ -│ │ client libraries → │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ ┌─ Response ──────────────────────────────┐ │ -│ │ 200 OK │ │ -│ │ ┌──────────────────────────────────┐ │ │ -│ │ │ id string │ │ │ -│ │ │ name string │ │ │ -│ │ │ orgID string │ │ │ -│ │ │ ... │ │ │ -│ │ └──────────────────────────────────┘ │ │ -│ │ ──── or ──── │ │ -│ │ 204 No Content (empty body) │ │ -│ └────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────┘ -``` +## Architecture ---- +**No build script changes for curl generation.** The curl example is constructed entirely in a Hugo partial (`api/code-sample.html`) using data already loaded by `tag-renderer.html` — the full parsed OpenAPI spec with server URLs, parameters, request body schemas, and examples. -## Phase 1: curl Examples in Article Flow (MVP) +The existing `influxdb-url.js` automatically replaces the default placeholder host in `
` elements with the user's custom URL. No new JavaScript is needed for URL personalization.
 
-### 1.1 Generate curl examples in build script
+### Operation layout order (revised)
 
-**Modify:** `api-docs/scripts/generate-openapi-articles.ts`
+1. Header (method + path + summary)
+2. Description
+3. Parameters
+4. Request Body
+5. **Example (curl + Ask AI)** — new
+6. Responses
 
-For each operation, generate a `curlExample` string and store it in the operation metadata. The build script already processes every operation to extract `operationId`, `method`, `path`, `summary`, and `tags` for frontmatter.
+***
 
-Add a function `generateCurlExample(operation, spec)` that:
-1. Gets server URL from `spec.servers[0].url` (fallback: `http://localhost:8181`)
-2. Builds the full URL with path parameter placeholders (e.g., `{bucketID}`)
-3. Adds required query parameters with placeholder values
-4. Adds `Authorization: Bearer $INFLUX_TOKEN` header
-5. Adds `Content-Type` header when request body exists
-6. Generates request body from:
-   - `requestBody.content["application/json"].schema.example` (first choice)
-   - `requestBody.content["application/json"].example` (second choice)
-   - A minimal JSON object from `required` + `properties` with type-based defaults (fallback)
-   - For `text/plain` content types (like line protocol), uses a sample line protocol string
-7. Stores the complete curl command as a multiline string
+## curl Example Generation
 
-The operation frontmatter entry becomes:
-```yaml
-operations:
-  - operationId: PostWrite
-    method: POST
-    path: /api/v2/write
-    summary: Write data
-    tags: [Write]
-    curlExample: |
-      curl --request POST \
-        "http://localhost:8181/api/v2/write?bucket=DATABASE_NAME&precision=ns" \
-        --header "Authorization: Bearer $INFLUX_TOKEN" \
-        --header "Content-Type: text/plain; charset=utf-8" \
-        --data-raw "mem,host=host1 used_percent=23.43234543 1556896326"
-```
+### Partial: `layouts/partials/api/code-sample.html`
 
-### 1.2 Create curl display partial
+Receives the operation definition (`$opDef`), spec (`$spec`), and operation metadata from `operation.html`. Constructs a curl command:
 
-**New file:** `layouts/partials/api/code-sample.html`
+1. **Server URL**: `spec.servers[0].url` — falls back to the product's `placeholder_host`. The existing `influxdb-url.js` replaces this in the DOM if the user has a custom URL.
+2. **Method**: Always explicit `--request METHOD`
+3. **Path**: Appended to server URL. `{param}` placeholders left as-is in the URL.
+4. **Query parameters**: Only required ones. Uses `example` value if available, otherwise an `UPPER_SNAKE_CASE` placeholder derived from the parameter name.
+5. **Headers**:
+   - Always: `--header "Authorization: Bearer INFLUX_TOKEN"`
+   - When request body exists: `--header "Content-Type: ..."` derived from the first key in `requestBody.content`
+6. **Request body**:
+   - `application/json`: Uses `schema.example` if present. If no example, body is omitted entirely — no synthesized fake data.
+   - `text/plain` (line protocol): Hardcoded sample: `--data-raw "measurement,tag=value field=1.0"`
+   - No example and no special content type: body omitted, shows only URL + headers.
 
-Renders the code sample block within the operation. Receives the operation dict (with `curlExample` field) and page context.
+### Ask AI link
 
-Output:
-```html
-
-
-
Example
-
-
-
{{ .operation.curlExample }}
- - Ask AI about this example - -
-
-``` - -### 1.3 Integrate into operation.html - -**Modify:** `layouts/partials/api/operation.html` - -After the Responses section, add: -```html -{{ with .operation.curlExample }} - {{ partial "api/code-sample.html" (dict "operation" $.operation "context" $.context) }} -{{ end }} -``` - -### 1.4 Add styles - -**New file:** `assets/styles/layouts/_api-code-samples.scss` - -Key styles: -- `.api-code-sample`: full-width block within the operation, subtle border/background -- `.api-code-sample-header`: section header with "Example" title -- `.api-code-block`: dark background code block (always dark, regardless of theme), horizontal scroll for overflow -- `.api-code-ask-ai`: small link below code block, uses existing ask-ai-open styling conventions -- Smooth transition/animation when expanding - -**Modify:** `assets/styles/styles-default.scss` — add import for `_api-code-samples.scss`. - ---- - -## Phase 2: Successful Response Schema - -### 2.1 Render the response body schema for the successful status code - -Instead of generating response *examples*, show the **response body schema** for the successful response (typically `200` or `201`) directly below the curl request example. - -This reuses the existing `responses.html` and `schema.html` partials, which already resolve `$ref` and render schema properties with types and descriptions. The difference is we show only the **successful response** in the code sample section (rather than all status codes, which are already shown in the Responses section above). - -**Approach:** -- No build script changes needed — the schema is already in the OpenAPI spec, parsed at Hugo build time -- The `code-sample.html` partial reads the operation from the spec (already loaded by `tag-renderer.html`), finds the first 2xx response, and renders its body schema - -**Modify:** `layouts/partials/api/code-sample.html` - -Below the curl request, add: -```html -{{ $successResponse := false }} -{{ range $code, $resp := $opDef.responses }} - {{ if hasPrefix $code "2" }} - {{ $successResponse = $resp }} - {{ end }} -{{ end }} - -{{ with $successResponse }} - {{ $content := .content | default dict }} - {{ $jsonContent := index $content "application/json" | default dict }} - {{ with $jsonContent.schema }} -
-
Response Body
- {{ partial "api/schema.html" (dict "schema" . "spec" $spec "level" 0) }} -
- {{ else }} -
-
Response
-

No response body

-
- {{ end }} -{{ end }} -``` - -This means for a `204 No Content` response (like the write endpoint), it shows "No response body." For a `200 OK` with a JSON schema (like list endpoints), it renders the full schema properties table — the same compact rendering already used in the Responses section but focused on just the success case. - -**Note:** This requires passing the full operation definition and spec to `code-sample.html`, not just the frontmatter operation metadata. The partial needs access to the parsed spec to resolve the response schema. This is already available within `tag-renderer.html` where `code-sample.html` is called. - ---- - -## Phase 3: "Ask AI about this example" — All Code Blocks - -### 3.1 API code samples (included in Phase 1) - -Already covered — each code sample gets an "Ask AI about this example" link using the existing `ask-ai-open` class and `data-query` attribute, leveraging the Kapa.ai widget. - -### 3.2 Hugo render hook for all code blocks - -**New file:** `layouts/_default/_markup/render-codeblock.html` - -A Hugo markdown render hook that wraps every fenced code block with an optional "Ask AI" link: +Each code sample block includes an "Ask AI about this example" link using the existing `ask-ai-open` CSS class and `data-query` attribute. The existing `ask-ai-trigger.js` handles click events and opens the Kapa widget — no new JavaScript needed. ```html -
-
{{ .Inner }}
- {{ if .Type }} - - Ask AI about this example - - {{ end }} -
+ + Ask AI about this example + ``` -Considerations: -- Only show on code blocks with a language identifier (skip plain text blocks) -- Truncate the code in the query to avoid overly long prompts -- Use page title for context -- Add subtle styling (small text, appears on hover or below the code block) -- Test with existing code block rendering (syntax highlighting, copy button, etc.) -- Must not break existing `pytest-codeblocks` annotations or other code block features +*** -### 3.3 Add styles for code block Ask AI +## Client Library Related Link -**Modify:** `assets/styles/layouts/_api-code-samples.scss` (or create a separate partial) +The generation script adds a related link to `/influxdb3/{product}/reference/client-libraries/v3/` for all InfluxDB 3 product tag pages. -```scss -.code-block-ask-ai { - display: block; - font-size: 0.75rem; - color: $nav-item; - text-decoration: none; - padding: 0.25rem 0; - opacity: 0.6; - transition: opacity 0.2s; +**InfluxDB 3 products** (identified by `pagesDir` containing `influxdb3/`): - &:hover { - opacity: 1; - color: $nav-item-hover; - } -} -``` +- `influxdb3_core` +- `influxdb3_enterprise` +- `cloud-dedicated` +- `cloud-serverless` +- `clustered` ---- +**Excluded** (future plan with v2 client library links): -## Phase 4: Client Library Integration +- `cloud-v2`, `oss-v2`, `oss-v1`, `enterprise-v1` -Multi-language code tabs are **only for client libraries** — not general-purpose language examples. The curl example (Phase 1) covers the raw HTTP request. The client library section helps users accomplish the same task using the InfluxDB 3 client libraries (Python, JavaScript, Go, Java, C#). +The `{product}` segment is derived from the `pagesDir` (e.g., `content/influxdb3/core` yields `core`). -Users and agents can create their own language-specific boilerplate for direct HTTP calls, but the client libraries abstract away HTTP details and provide idiomatic interfaces — that's what we want to surface here. +*** -### 4.1 Client library links on relevant operations +## File Changes -**New file:** `data/api-client-library-links.yml` +### New files -A mapping from operation tags/paths to client library documentation: -```yaml -write: - description: "Write data using InfluxDB 3 client libraries" - operations: - - PostWrite - - PostWriteV1 - links: - - name: Python - url: /influxdb3/{version}/reference/client-libraries/v3/python/ - guide: /influxdb3/{version}/write-data/client-libraries/ - - name: JavaScript - url: /influxdb3/{version}/reference/client-libraries/v3/javascript/ - - name: Go - url: /influxdb3/{version}/reference/client-libraries/v3/go/ - - name: Java - url: /influxdb3/{version}/reference/client-libraries/v3/java/ - - name: C# - url: /influxdb3/{version}/reference/client-libraries/v3/csharp/ -query: - operations: - - PostQuery - links: - - name: Python - url: /influxdb3/{version}/reference/client-libraries/v3/python/ - # ... -``` +| File | Purpose | +| ---------------------------------------------- | ---------------------------- | +| `layouts/partials/api/code-sample.html` | curl example + Ask AI link | +| `assets/styles/layouts/_api-code-samples.scss` | Styles for code sample block | -**Modify:** `layouts/partials/api/code-sample.html` +### Modified files -Below the curl example, render a "Client Libraries" section when the operation matches. This section includes: -1. Links to the relevant client library reference pages -2. A link to the relevant guide (e.g., "Write data with client libraries") +| File | Change | +| ----------------------------------------------- | ------------------------------------------------------------------ | +| `layouts/partials/api/operation.html` | Insert `code-sample.html` between request body and responses | +| `assets/styles/styles-default.scss` | Import `_api-code-samples.scss` | +| `api-docs/scripts/generate-openapi-articles.ts` | Add client library reference related link for InfluxDB 3 tag pages | -```html -
-
Client Libraries
- -

Full guide: Write data with client libraries

-
-``` +### Not modified -### 4.2 Client library code samples with language tabs +| File | Reason | +| ------------------------------------------------------ | ------------------------ | +| `layouts/api/list.html` | No layout changes needed | +| `assets/js/main.js` | No new JS components | +| `assets/js/components/api-toc.ts` | TOC unchanged | +| `assets/styles/layouts/_api-layout.scss` | Layout unchanged | +| `api-docs/scripts/openapi-paths-to-hugo-data/index.ts` | No data model changes | -Add tabbed code samples showing how to accomplish the same operation using each InfluxDB 3 client library. These are **not** raw HTTP examples in different languages — they show the idiomatic client library usage. - -**Scope:** Only operations that have corresponding client library methods: -- **Write** operations: `PostWrite`, `PostWriteV1` — client library `write()` / `writeRecord()` methods -- **Query** operations: `PostQuery` — client library `query()` methods -- Additional operations can be added as client libraries expand their APIs - -**Code sample source:** Maintained in a data directory, not in OpenAPI specs. - -**New directory:** `data/api-code-samples/` - -Structure: -``` -data/api-code-samples/ -├── write/ -│ ├── python.md # Python write example -│ ├── javascript.md # JavaScript write example -│ ├── go.md # Go write example -│ ├── java.md # Java write example -│ └── csharp.md # C# write example -└── query/ - ├── python.md - ├── javascript.md - ├── go.md - ├── java.md - └── csharp.md -``` - -Each file contains just the code sample (no frontmatter), e.g. `data/api-code-samples/write/python.md`: -```python -from influxdb_client_3 import InfluxDBClient3 - -client = InfluxDBClient3( - host="{{< influxdb/host >}}", - token="DATABASE_TOKEN", - database="DATABASE_NAME", -) - -client.write(record="home,room=Living\ Room temp=21.1") -``` - -**Modify:** `layouts/partials/api/code-sample.html` - -Below the curl example, render a tabbed client library section: -```html -
-
- - - - - -
-
-
...
-
- -

- - Full guide: Write data with client libraries - -

-
-``` - -**New file:** `assets/js/components/api-code-tabs.ts` - -A simple tab switcher component (lightweight, not the full `tabs-wrapper` shortcode): -- Listens for clicks on `.api-code-tab` buttons -- Shows/hides corresponding `.api-code-tab-content` panels -- Remembers the user's language preference in `localStorage` -- Syncs language selection across all client library sections on the page - -**Keeping samples in sync:** The code samples in `data/api-code-samples/` are manually maintained. When client library versions change, the samples need to be updated. This is a documentation maintenance task, same as updating any other code example in the docs. The samples should use the **latest stable** client library version and follow the patterns from the client library reference pages. - ---- - -## Files Summary - -### New Files -| File | Phase | Purpose | -|------|-------|---------| -| `layouts/partials/api/code-sample.html` | 1 | Renders inline curl + response schema + client library samples within operations | -| `assets/styles/layouts/_api-code-samples.scss` | 1 | Styles for code sample blocks, tabs, and Ask AI links | -| `layouts/_default/_markup/render-codeblock.html` | 3 | Hugo render hook adding Ask AI to all code blocks | -| `data/api-client-library-links.yml` | 4.1 | Maps operations to client library documentation links | -| `data/api-code-samples/write/*.md` | 4.2 | Client library write examples (Python, JS, Go, Java, C#) | -| `data/api-code-samples/query/*.md` | 4.2 | Client library query examples (Python, JS, Go, Java, C#) | -| `assets/js/components/api-code-tabs.ts` | 4.2 | Lightweight tab switcher for client library code samples | - -### Modified Files -| File | Phase | Change | -|------|-------|--------| -| `api-docs/scripts/generate-openapi-articles.ts` | 1 | Add `curlExample` generation | -| `layouts/partials/api/operation.html` | 1 | Include `code-sample.html` partial after responses | -| `assets/styles/styles-default.scss` | 1 | Import `_api-code-samples.scss` | -| `layouts/partials/api/code-sample.html` | 2, 4.1, 4.2 | Add response body schema, client library links, tabbed code samples | -| `assets/js/main.js` | 4.2 | Register `api-code-tabs` component | - -### NOT Modified (kept as-is) -| File | Reason | -|------|--------| -| `layouts/api/list.html` | TOC sidebar stays unchanged | -| `assets/js/components/api-toc.ts` | TOC behavior stays unchanged | -| `assets/styles/layouts/_api-layout.scss` | Layout widths stay unchanged | - ---- - -## Implementation Order - -1. **Phase 1** (MVP): Build script curl generation + inline code sample partial + styles -2. **Phase 2**: Successful response body schema (small addition to Phase 1, no build script changes) -3. **Phase 3.1**: Ask AI on API code samples (included in Phase 1 HTML) -4. **Phase 3.2**: Ask AI on all code blocks site-wide (Hugo render hook) -5. **Phase 4.1**: Client library links on relevant operations (data file + partial update) -6. **Phase 4.2**: Client library code samples with language tabs (data files + tab component + partial update) - -Phases 1-3.1 can ship together. Phase 3.2 and 4 are independent follow-ups. - -Phase 4.2 starts with write and query operations only, then expands as client libraries add more API coverage. - ---- +*** ## Verification -1. **Regenerate articles**: Run `generate-openapi-articles.ts` → verify `curlExample` in frontmatter YAML -2. **Build**: `npx hugo --quiet` — verify no template errors -3. **Visual**: Dev server → navigate to API tag page → verify each operation has a curl example at the bottom -4. **Ask AI**: Click "Ask AI about this example" → verify Kapa opens with pre-populated query -5. **Dark mode**: Verify code sample blocks look correct in both themes -6. **Responsive**: Verify code samples render well on narrow viewports (no sidebar dependency) -7. **E2E test**: Add Cypress test verifying code samples render on API pages +1. **Build**: `npx hugo --quiet` — no template errors +2. **Visual**: Dev server — navigate to API tag page (e.g., `/influxdb3/core/api/write-data/`) — each operation has a curl example between Request Body and Responses +3. **URL replacement**: Set a custom URL in the URL selector — verify it replaces the host in curl examples +4. **Ask AI**: Click "Ask AI about this example" — Kapa opens with pre-populated query +5. **Related link**: Client library reference link appears at bottom of all InfluxDB 3 API tag pages +6. **Cypress**: Add test verifying `.api-code-sample` elements render on tag pages +7. **Dark/light mode**: Code block renders correctly in both themes +8. **Responsive**: Code sample block handles narrow viewports (horizontal scroll for long curl commands) diff --git a/layouts/partials/api/code-sample.html b/layouts/partials/api/code-sample.html new file mode 100644 index 000000000..2b407d67d --- /dev/null +++ b/layouts/partials/api/code-sample.html @@ -0,0 +1,233 @@ +{{/* + API Code Sample + + Renders an inline curl example for an API operation, constructed from the + OpenAPI spec at Hugo build time. The existing influxdb-url.js replaces + the default host in
 elements if the user has a custom URL set.
+
+  Params:
+    - opDef: The operation definition from the parsed spec
+    - operation: Operation metadata from frontmatter (method, path, summary, operationId)
+    - spec: The full OpenAPI spec object for resolving $ref
+    - context: The page context
+*/}}
+
+{{ $opDef := .opDef }}
+{{ $operation := .operation }}
+{{ $spec := .spec }}
+{{ $method := upper $operation.method }}
+{{ $path := $operation.path }}
+
+{{/* --- Resolve server URL --- */}}
+{{ $serverUrl := "" }}
+{{ with index ($spec.servers | default slice) 0 }}
+  {{ $serverUrl = .url | default "" }}
+  {{/* Resolve {variable} placeholders using variable defaults */}}
+  {{ range $varName, $varDef := .variables | default dict }}
+    {{ $placeholder := printf "{%s}" $varName }}
+    {{ $serverUrl = replace $serverUrl $placeholder ($varDef.default | default "") }}
+  {{ end }}
+{{ end }}
+{{ if not $serverUrl }}
+  {{ $serverUrl = "http://localhost:8086" }}
+{{ end }}
+
+{{/* --- Resolve parameters (handle $ref) --- */}}
+{{ $params := $opDef.parameters | default slice }}
+{{ $resolvedParams := slice }}
+{{ range $params }}
+  {{ $param := . }}
+  {{ if isset . "$ref" }}
+    {{ $refPath := index . "$ref" }}
+    {{ $refParts := split $refPath "/" }}
+    {{ if ge (len $refParts) 4 }}
+      {{ $paramName := index $refParts 3 }}
+      {{ with index $spec.components.parameters $paramName }}
+        {{ $param = . }}
+      {{ end }}
+    {{ end }}
+  {{ end }}
+  {{ $resolvedParams = $resolvedParams | append $param }}
+{{ end }}
+
+{{/* --- Build query string from required query parameters --- */}}
+{{ $queryParts := slice }}
+{{ range $resolvedParams }}
+  {{ if and (eq .in "query") .required }}
+    {{ $value := "" }}
+    {{ with .schema }}
+      {{ if .example }}
+        {{ $value = .example | string }}
+      {{ else if .default }}
+        {{ $value = .default | string }}
+      {{ end }}
+    {{ end }}
+    {{ if not $value }}
+      {{ $value = .name | upper | replaceRE "[^A-Z0-9]" "_" }}
+    {{ end }}
+    {{ $queryParts = $queryParts | append (printf "%s=%s" .name $value) }}
+  {{ end }}
+{{ end }}
+
+{{ $fullUrl := printf "%s%s" $serverUrl $path }}
+{{ if gt (len $queryParts) 0 }}
+  {{ $fullUrl = printf "%s?%s" $fullUrl (delimit $queryParts "&") }}
+{{ end }}
+
+{{/* --- Resolve request body (handle $ref) --- */}}
+{{ $requestBody := $opDef.requestBody | default dict }}
+{{ if isset $requestBody "$ref" }}
+  {{ $refPath := index $requestBody "$ref" }}
+  {{ $refParts := split $refPath "/" }}
+  {{ if ge (len $refParts) 4 }}
+    {{ $rbName := index $refParts 3 }}
+    {{ with index $spec.components.requestBodies $rbName }}
+      {{ $requestBody = . }}
+    {{ end }}
+  {{ end }}
+{{ end }}
+
+{{/* --- Determine content type and body --- */}}
+{{ $contentType := "" }}
+{{ $bodyFlag := "" }}
+{{ $rbContent := $requestBody.content | default dict }}
+{{ if gt (len $rbContent) 0 }}
+  {{/* Get first content type key */}}
+  {{ range $ct, $_ := $rbContent }}
+    {{ if not $contentType }}
+      {{ $contentType = $ct }}
+    {{ end }}
+  {{ end }}
+
+  {{ $mediaType := index $rbContent $contentType | default dict }}
+
+  {{ if hasPrefix $contentType "text/plain" }}
+    {{/* Line protocol — use first example value or a default sample */}}
+    {{ $lpSample := "measurement,tag=value field=1.0" }}
+    {{ with $mediaType.examples }}
+      {{ range $_, $ex := . }}
+        {{ if not $bodyFlag }}
+          {{ $lpSample = $ex.value | string }}
+          {{/* Take only the first line for single-line display */}}
+          {{ $lines := split $lpSample "\n" }}
+          {{ $lpSample = index $lines 0 }}
+        {{ end }}
+      {{ end }}
+    {{ end }}
+    {{ $bodyFlag = printf "--data-raw '%s'" $lpSample }}
+  {{ else if hasPrefix $contentType "application/json" }}
+    {{/* JSON — use schema.example, or build from properties */}}
+    {{ with $mediaType.schema }}
+      {{/* Resolve schema $ref */}}
+      {{ $schema := . }}
+      {{ if isset . "$ref" }}
+        {{ $refPath := index . "$ref" }}
+        {{ $refParts := split $refPath "/" }}
+        {{ if ge (len $refParts) 4 }}
+          {{ $schemaName := index $refParts 3 }}
+          {{ with index $spec.components.schemas $schemaName }}
+            {{ $schema = . }}
+          {{ end }}
+        {{ end }}
+      {{ end }}
+      {{ if $schema.example }}
+        {{ $bodyFlag = printf "--data-raw '%s'" (jsonify $schema.example) }}
+      {{ else if $schema.properties }}
+        {{/* Build example JSON from schema properties */}}
+        {{ $bodyObj := dict }}
+        {{ $requiredList := $schema.required | default slice }}
+        {{ range $propName, $propDef := $schema.properties }}
+          {{/* Resolve property $ref */}}
+          {{ $prop := $propDef }}
+          {{ if isset $propDef "$ref" }}
+            {{ $pRefPath := index $propDef "$ref" }}
+            {{ $pRefParts := split $pRefPath "/" }}
+            {{ if ge (len $pRefParts) 4 }}
+              {{ $pSchemaName := index $pRefParts 3 }}
+              {{ with index $spec.components.schemas $pSchemaName }}
+                {{ $prop = . }}
+              {{ end }}
+            {{ end }}
+          {{ end }}
+          {{/* Use example → default → enum[0] → type placeholder */}}
+          {{ $val := "" }}
+          {{ if ne $prop.example nil }}
+            {{ $val = $prop.example }}
+          {{ else if ne $prop.default nil }}
+            {{ $val = $prop.default }}
+          {{ else if $prop.enum }}
+            {{ $val = index $prop.enum 0 }}
+          {{ else if eq $prop.type "string" }}
+            {{ $val = printf "%s" ($propName | upper) }}
+          {{ else if eq $prop.type "integer" }}
+            {{ $val = 0 }}
+          {{ else if eq $prop.type "number" }}
+            {{ $val = 0 }}
+          {{ else if eq $prop.type "boolean" }}
+            {{ $val = false }}
+          {{ else if eq $prop.type "array" }}
+            {{ if $prop.items }}
+              {{ if eq $prop.items.type "string" }}
+                {{ $val = slice "example" }}
+              {{ else }}
+                {{ $val = slice }}
+              {{ end }}
+            {{ else }}
+              {{ $val = slice }}
+            {{ end }}
+          {{ else if eq $prop.type "object" }}
+            {{ $val = dict }}
+          {{ else }}
+            {{ $val = printf "%s" ($propName | upper) }}
+          {{ end }}
+          {{ $bodyObj = merge $bodyObj (dict $propName $val) }}
+        {{ end }}
+        {{ $bodyFlag = printf "--data-raw '%s'" (jsonify (dict "indent" "  ") $bodyObj) }}
+      {{ end }}
+    {{ end }}
+  {{ end }}
+{{ end }}
+
+{{/* --- Assemble curl command --- */}}
+{{ $lines := slice }}
+{{ $lines = $lines | append (printf "curl --request %s \\" $method) }}
+{{ $lines = $lines | append (printf "  \"%s\" \\" $fullUrl) }}
+{{ $lines = $lines | append "  --header \"Authorization: Bearer INFLUX_TOKEN\" \\" }}
+{{ if $contentType }}
+  {{ $lines = $lines | append (printf "  --header \"Content-Type: %s\" \\" $contentType) }}
+{{ end }}
+{{ if $bodyFlag }}
+  {{/* Last line — no trailing backslash */}}
+  {{ $lines = $lines | append (printf "  %s" $bodyFlag) }}
+{{ else }}
+  {{/* Remove trailing backslash from last header line */}}
+  {{ $lastIdx := sub (len $lines) 1 }}
+  {{ $lastLine := index $lines $lastIdx }}
+  {{ $lastLine = strings.TrimSuffix " \\" $lastLine }}
+  {{ $newLines := slice }}
+  {{ range $i, $line := $lines }}
+    {{ if eq $i $lastIdx }}
+      {{ $newLines = $newLines | append $lastLine }}
+    {{ else }}
+      {{ $newLines = $newLines | append $line }}
+    {{ end }}
+  {{ end }}
+  {{ $lines = $newLines }}
+{{ end }}
+
+{{ $curlCommand := delimit $lines "\n" }}
+
+{{/* --- Build Ask AI query --- */}}
+{{ $aiQuery := printf "Explain this %s %s API request and its response: %s" $method $path ($operation.summary | default "") }}
+
+{{/* --- Render --- */}}
+
+
Example request
+
+
{{ $curlCommand }}
+ + Ask AI about this example + +
+
diff --git a/layouts/partials/api/operation.html b/layouts/partials/api/operation.html index c4983269e..12fb7881a 100644 --- a/layouts/partials/api/operation.html +++ b/layouts/partials/api/operation.html @@ -54,6 +54,14 @@ {{ partial "api/request-body.html" (dict "requestBody" . "spec" $spec) }} {{ end }} + {{/* Code Sample Section */}} + {{ partial "api/code-sample.html" (dict + "opDef" $opDef + "operation" $operation + "spec" $spec + "context" .context + ) }} + {{/* Responses Section */}} {{ with $opDef.responses }} {{ partial "api/responses.html" (dict "responses" . "spec" $spec) }} diff --git a/layouts/partials/api/tag-renderer.html b/layouts/partials/api/tag-renderer.html index ad3f3d61e..828b46bf1 100644 --- a/layouts/partials/api/tag-renderer.html +++ b/layouts/partials/api/tag-renderer.html @@ -34,17 +34,12 @@ {{ end }} {{ end }} -{{/* Tag description and related links from spec */}} +{{/* Tag description from spec */}} {{ $tagDescription := "" }} -{{ $tagRelated := slice }} {{ $tagName := .Params.tag | default "" }} {{ range $spec.tags }} {{ if eq .name $tagName }} {{ $tagDescription = .description | default "" }} - {{/* Extract x-influxdata-related from the tag */}} - {{ with index . "x-influxdata-related" }} - {{ $tagRelated = . }} - {{ end }} {{ end }} {{ end }} @@ -69,15 +64,5 @@ {{ end }} - {{/* Related Guides - displayed at bottom like standard page template */}} - {{ if gt (len $tagRelated) 0 }} - - {{ end }} + {{/* Related links rendered via frontmatter + article/related.html */}} diff --git a/layouts/partials/article/related.html b/layouts/partials/article/related.html index 184b7f6dd..41becf349 100644 --- a/layouts/partials/article/related.html +++ b/layouts/partials/article/related.html @@ -1,32 +1,25 @@ -{{ $scratch := newScratch }} {{ if .Params.related }}