feat(api): Update OpenAPI article generators for tag-based structure
- Add support for tag-based article generation with operations metadata - Generate articles.yml data files with tag, menuName, and isConceptual fields - Include operations array in frontmatter for tag pagesclaude/api-code-samples-plan-MEkQO
parent
a7dff61792
commit
eb33772b60
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
"use strict";
|
||||
/**
|
||||
* Generate OpenAPI Articles Script
|
||||
*
|
||||
|
|
@ -20,58 +20,47 @@
|
|||
*
|
||||
* @module generate-openapi-articles
|
||||
*/
|
||||
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 (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null)
|
||||
for (var k in mod)
|
||||
if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k))
|
||||
__createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
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 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;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.productConfigs = void 0;
|
||||
exports.processProduct = processProduct;
|
||||
exports.generateDataFromOpenAPI = generateDataFromOpenAPI;
|
||||
exports.generatePagesFromArticleData = generatePagesFromArticleData;
|
||||
const child_process_1 = require('child_process');
|
||||
const path = __importStar(require('path'));
|
||||
const fs = __importStar(require('fs'));
|
||||
const child_process_1 = require("child_process");
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
// Import the OpenAPI to Hugo converter
|
||||
const openapiPathsToHugo = require('./openapi-paths-to-hugo-data/index.js');
|
||||
// Calculate the relative paths
|
||||
|
|
@ -85,19 +74,20 @@ const API_DOCS_ROOT = 'api-docs';
|
|||
* @throws Exits process with code 1 on error
|
||||
*/
|
||||
function execCommand(command, description) {
|
||||
try {
|
||||
if (description) {
|
||||
console.log(`\n${description}...`);
|
||||
try {
|
||||
if (description) {
|
||||
console.log(`\n${description}...`);
|
||||
}
|
||||
console.log(`Executing: ${command}\n`);
|
||||
(0, child_process_1.execSync)(command, { stdio: 'inherit' });
|
||||
}
|
||||
console.log(`Executing: ${command}\n`);
|
||||
(0, child_process_1.execSync)(command, { stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Error executing command: ${command}`);
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
catch (error) {
|
||||
console.error(`\n❌ Error executing command: ${command}`);
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate Hugo data files from OpenAPI specification
|
||||
|
|
@ -107,14 +97,14 @@ function execCommand(command, description) {
|
|||
* @param articleOutPath - Output path for article metadata
|
||||
*/
|
||||
function generateDataFromOpenAPI(specFile, dataOutPath, articleOutPath) {
|
||||
if (!fs.existsSync(dataOutPath)) {
|
||||
fs.mkdirSync(dataOutPath, { recursive: true });
|
||||
}
|
||||
openapiPathsToHugo.generateHugoData({
|
||||
dataOutPath,
|
||||
articleOutPath,
|
||||
specFile,
|
||||
});
|
||||
if (!fs.existsSync(dataOutPath)) {
|
||||
fs.mkdirSync(dataOutPath, { recursive: true });
|
||||
}
|
||||
openapiPathsToHugo.generateHugoData({
|
||||
dataOutPath,
|
||||
articleOutPath,
|
||||
specFile,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate Hugo content pages from article data
|
||||
|
|
@ -122,51 +112,213 @@ function generateDataFromOpenAPI(specFile, dataOutPath, articleOutPath) {
|
|||
* Creates markdown files with frontmatter from article metadata.
|
||||
* Each article becomes a page with type: api that renders via Scalar.
|
||||
*
|
||||
* @param articlesPath - Path to the articles data directory
|
||||
* @param contentPath - Output path for generated content pages
|
||||
* @param options - Generation options
|
||||
*/
|
||||
function generatePagesFromArticleData(articlesPath, contentPath) {
|
||||
const yaml = require('js-yaml');
|
||||
const articlesFile = path.join(articlesPath, 'articles.yml');
|
||||
if (!fs.existsSync(articlesFile)) {
|
||||
console.warn(`⚠️ Articles file not found: ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Read articles data
|
||||
const articlesContent = fs.readFileSync(articlesFile, 'utf8');
|
||||
const data = yaml.load(articlesContent);
|
||||
if (!data.articles || !Array.isArray(data.articles)) {
|
||||
console.warn(`⚠️ No articles found in ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Ensure content directory exists
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
fs.mkdirSync(contentPath, { recursive: true });
|
||||
}
|
||||
// Generate a page for each article
|
||||
for (const article of data.articles) {
|
||||
const pagePath = path.join(contentPath, article.path);
|
||||
const pageFile = path.join(pagePath, '_index.md');
|
||||
// Create directory if needed
|
||||
if (!fs.existsSync(pagePath)) {
|
||||
fs.mkdirSync(pagePath, { recursive: true });
|
||||
function generatePagesFromArticleData(options) {
|
||||
const { articlesPath, contentPath, menuKey, menuParent, productDescription, skipParentMenu, } = options;
|
||||
const yaml = require('js-yaml');
|
||||
const articlesFile = path.join(articlesPath, 'articles.yml');
|
||||
if (!fs.existsSync(articlesFile)) {
|
||||
console.warn(`⚠️ Articles file not found: ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Generate frontmatter
|
||||
const frontmatter = {
|
||||
title: article.fields.name || article.path,
|
||||
description: `API reference for ${article.fields.name || article.path}`,
|
||||
type: 'api',
|
||||
staticFilePath: article.fields.staticFilePath,
|
||||
weight: 100,
|
||||
};
|
||||
const pageContent = `---
|
||||
// Read articles data
|
||||
const articlesContent = fs.readFileSync(articlesFile, 'utf8');
|
||||
const data = yaml.load(articlesContent);
|
||||
if (!data.articles || !Array.isArray(data.articles)) {
|
||||
console.warn(`⚠️ No articles found in ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Ensure content directory exists
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
fs.mkdirSync(contentPath, { recursive: true });
|
||||
}
|
||||
// Determine the API parent directory from the first article's path
|
||||
// e.g., if article path is "api/v1/health", the API root is "api"
|
||||
const firstArticlePath = data.articles[0]?.path || '';
|
||||
const apiRootDir = firstArticlePath.split('/')[0];
|
||||
// Generate parent _index.md for the API section
|
||||
if (apiRootDir) {
|
||||
const apiParentDir = path.join(contentPath, apiRootDir);
|
||||
const parentIndexFile = path.join(apiParentDir, '_index.md');
|
||||
if (!fs.existsSync(apiParentDir)) {
|
||||
fs.mkdirSync(apiParentDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(parentIndexFile)) {
|
||||
const parentFrontmatter = {
|
||||
title: menuParent || 'HTTP API',
|
||||
description: productDescription ||
|
||||
'API reference documentation for all available endpoints.',
|
||||
weight: 104,
|
||||
};
|
||||
// Add menu entry for parent page (unless skipParentMenu is true)
|
||||
if (menuKey && !skipParentMenu) {
|
||||
parentFrontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: menuParent || 'HTTP API',
|
||||
},
|
||||
};
|
||||
}
|
||||
const parentContent = `---
|
||||
${yaml.dump(parentFrontmatter)}---
|
||||
`;
|
||||
fs.writeFileSync(parentIndexFile, parentContent);
|
||||
console.log(`✓ Generated parent index at ${parentIndexFile}`);
|
||||
}
|
||||
}
|
||||
// Generate a page for each article
|
||||
for (const article of data.articles) {
|
||||
const pagePath = path.join(contentPath, article.path);
|
||||
const pageFile = path.join(pagePath, '_index.md');
|
||||
// Create directory if needed
|
||||
if (!fs.existsSync(pagePath)) {
|
||||
fs.mkdirSync(pagePath, { recursive: true });
|
||||
}
|
||||
// Build frontmatter object
|
||||
// Use menuName for display (actual endpoint path like /health)
|
||||
// Fall back to name or path if menuName is not set
|
||||
const displayName = article.fields.menuName || article.fields.name || article.path;
|
||||
const frontmatter = {
|
||||
title: displayName,
|
||||
description: `API reference for ${displayName}`,
|
||||
type: 'api',
|
||||
// Use explicit layout to override Hugo's default section template lookup
|
||||
// (Hugo's section lookup ignores `type`, so we need `layout` for the 3-column API layout)
|
||||
layout: 'list',
|
||||
staticFilePath: article.fields.staticFilePath,
|
||||
weight: 100,
|
||||
};
|
||||
// Add menu entry if menuKey is provided
|
||||
// Use menuName for menu display (shows actual endpoint path like /health)
|
||||
if (menuKey) {
|
||||
frontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: displayName,
|
||||
...(menuParent && { parent: menuParent }),
|
||||
},
|
||||
};
|
||||
}
|
||||
// Add related links if present in article fields
|
||||
if (article.fields.related &&
|
||||
Array.isArray(article.fields.related) &&
|
||||
article.fields.related.length > 0) {
|
||||
frontmatter.related = article.fields.related;
|
||||
}
|
||||
// Add OpenAPI tags if present in article fields (for frontmatter metadata)
|
||||
if (article.fields.apiTags &&
|
||||
Array.isArray(article.fields.apiTags) &&
|
||||
article.fields.apiTags.length > 0) {
|
||||
frontmatter.api_tags = article.fields.apiTags;
|
||||
}
|
||||
const pageContent = `---
|
||||
${yaml.dump(frontmatter)}---
|
||||
`;
|
||||
fs.writeFileSync(pageFile, pageContent);
|
||||
}
|
||||
console.log(
|
||||
`✓ Generated ${data.articles.length} content pages in ${contentPath}`
|
||||
);
|
||||
fs.writeFileSync(pageFile, pageContent);
|
||||
}
|
||||
console.log(`✓ Generated ${data.articles.length} content pages in ${contentPath}`);
|
||||
}
|
||||
/**
|
||||
* Generate Hugo content pages from tag-based article data
|
||||
*
|
||||
* Creates markdown files with frontmatter from article metadata.
|
||||
* Each article becomes a page with type: api that renders via RapiDoc.
|
||||
* Includes operation metadata for TOC generation.
|
||||
*
|
||||
* @param options - Generation options
|
||||
*/
|
||||
function generateTagPagesFromArticleData(options) {
|
||||
const { articlesPath, contentPath, menuKey, menuParent, productDescription, skipParentMenu, } = options;
|
||||
const yaml = require('js-yaml');
|
||||
const articlesFile = path.join(articlesPath, 'articles.yml');
|
||||
if (!fs.existsSync(articlesFile)) {
|
||||
console.warn(`⚠️ Articles file not found: ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Read articles data
|
||||
const articlesContent = fs.readFileSync(articlesFile, 'utf8');
|
||||
const data = yaml.load(articlesContent);
|
||||
if (!data.articles || !Array.isArray(data.articles)) {
|
||||
console.warn(`⚠️ No articles found in ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
// Ensure content directory exists
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
fs.mkdirSync(contentPath, { recursive: true });
|
||||
}
|
||||
// Generate parent _index.md for the API section
|
||||
const apiParentDir = path.join(contentPath, 'api');
|
||||
const parentIndexFile = path.join(apiParentDir, '_index.md');
|
||||
if (!fs.existsSync(apiParentDir)) {
|
||||
fs.mkdirSync(apiParentDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(parentIndexFile)) {
|
||||
const parentFrontmatter = {
|
||||
title: menuParent || 'HTTP API',
|
||||
description: productDescription ||
|
||||
'API reference documentation for all available endpoints.',
|
||||
weight: 104,
|
||||
};
|
||||
// Add menu entry for parent page (unless skipParentMenu is true)
|
||||
if (menuKey && !skipParentMenu) {
|
||||
parentFrontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: menuParent || 'HTTP API',
|
||||
},
|
||||
};
|
||||
}
|
||||
const parentContent = `---
|
||||
${yaml.dump(parentFrontmatter)}---
|
||||
`;
|
||||
fs.writeFileSync(parentIndexFile, parentContent);
|
||||
console.log(`✓ Generated parent index at ${parentIndexFile}`);
|
||||
}
|
||||
// Generate a page for each article (tag)
|
||||
for (const article of data.articles) {
|
||||
const pagePath = path.join(contentPath, article.path);
|
||||
const pageFile = path.join(pagePath, '_index.md');
|
||||
// Create directory if needed
|
||||
if (!fs.existsSync(pagePath)) {
|
||||
fs.mkdirSync(pagePath, { recursive: true });
|
||||
}
|
||||
// Build frontmatter object
|
||||
const title = article.fields.title || article.fields.name || article.path;
|
||||
const isConceptual = article.fields.isConceptual === true;
|
||||
const frontmatter = {
|
||||
title,
|
||||
description: article.fields.description || `API reference for ${title}`,
|
||||
type: 'api',
|
||||
layout: isConceptual ? 'single' : 'list',
|
||||
staticFilePath: article.fields.staticFilePath,
|
||||
weight: 100,
|
||||
// Tag-based fields
|
||||
tag: article.fields.tag,
|
||||
isConceptual,
|
||||
menuGroup: article.fields.menuGroup,
|
||||
};
|
||||
// Add operations for TOC generation (only for non-conceptual pages)
|
||||
if (!isConceptual && article.fields.operations && article.fields.operations.length > 0) {
|
||||
frontmatter.operations = article.fields.operations;
|
||||
}
|
||||
// Add tag description for conceptual pages
|
||||
if (isConceptual && article.fields.tagDescription) {
|
||||
frontmatter.tagDescription = article.fields.tagDescription;
|
||||
}
|
||||
// Note: We deliberately don't add menu entries for tag-based API pages.
|
||||
// The API sidebar navigation (api/sidebar-nav.html) handles navigation
|
||||
// for API reference pages, avoiding conflicts with existing menu items
|
||||
// like "Query data" and "Write data" that exist in the main sidebar.
|
||||
// Add related links if present in article fields
|
||||
if (article.fields.related &&
|
||||
Array.isArray(article.fields.related) &&
|
||||
article.fields.related.length > 0) {
|
||||
frontmatter.related = article.fields.related;
|
||||
}
|
||||
const pageContent = `---
|
||||
${yaml.dump(frontmatter)}---
|
||||
`;
|
||||
fs.writeFileSync(pageFile, pageContent);
|
||||
}
|
||||
console.log(`✓ Generated ${data.articles.length} tag-based content pages in ${contentPath}`);
|
||||
}
|
||||
/**
|
||||
* Product configurations for all InfluxDB editions
|
||||
|
|
@ -174,53 +326,61 @@ ${yaml.dump(frontmatter)}---
|
|||
* Maps product identifiers to their OpenAPI specs and content directories
|
||||
*/
|
||||
const productConfigs = {
|
||||
'cloud-v2': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb/cloud/v2/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb/cloud/api/v2'),
|
||||
description: 'InfluxDB Cloud (v2 API)',
|
||||
},
|
||||
'oss-v2': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb/v2/v2/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb/v2/api/v2'),
|
||||
description: 'InfluxDB OSS v2',
|
||||
},
|
||||
'influxdb3-core': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/core/v3/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/core/reference/api'),
|
||||
description: 'InfluxDB 3 Core',
|
||||
},
|
||||
'influxdb3-enterprise': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/enterprise/v3/ref.yml'),
|
||||
pagesDir: path.join(
|
||||
DOCS_ROOT,
|
||||
'content/influxdb3/enterprise/reference/api'
|
||||
),
|
||||
description: 'InfluxDB 3 Enterprise',
|
||||
},
|
||||
'cloud-dedicated': {
|
||||
specFile: path.join(
|
||||
API_DOCS_ROOT,
|
||||
'influxdb3/cloud-dedicated/management/openapi.yml'
|
||||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-dedicated/api'),
|
||||
description: 'InfluxDB Cloud Dedicated',
|
||||
},
|
||||
'cloud-serverless': {
|
||||
specFile: path.join(
|
||||
API_DOCS_ROOT,
|
||||
'influxdb3/cloud-serverless/management/openapi.yml'
|
||||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-serverless/api'),
|
||||
description: 'InfluxDB Cloud Serverless',
|
||||
},
|
||||
clustered: {
|
||||
specFile: path.join(
|
||||
API_DOCS_ROOT,
|
||||
'influxdb3/clustered/management/openapi.yml'
|
||||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/clustered/api'),
|
||||
description: 'InfluxDB Clustered',
|
||||
},
|
||||
// TODO: v2 products (cloud-v2, oss-v2) are disabled for now because they
|
||||
// have existing Redoc-based API reference at /reference/api/
|
||||
// Uncomment when ready to migrate v2 products to Scalar
|
||||
// 'cloud-v2': {
|
||||
// specFile: path.join(API_DOCS_ROOT, 'influxdb/cloud/v2/ref.yml'),
|
||||
// pagesDir: path.join(DOCS_ROOT, 'content/influxdb/cloud/api'),
|
||||
// description: 'InfluxDB Cloud (v2 API)',
|
||||
// menuKey: 'influxdb_cloud',
|
||||
// },
|
||||
// 'oss-v2': {
|
||||
// specFile: path.join(API_DOCS_ROOT, 'influxdb/v2/v2/ref.yml'),
|
||||
// pagesDir: path.join(DOCS_ROOT, 'content/influxdb/v2/api'),
|
||||
// description: 'InfluxDB OSS v2',
|
||||
// menuKey: 'influxdb_v2',
|
||||
// },
|
||||
// InfluxDB 3 products use tag-based generation for better UX
|
||||
'influxdb3-core': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/core/v3/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/core'),
|
||||
description: 'InfluxDB 3 Core',
|
||||
menuKey: 'influxdb3_core',
|
||||
useTagBasedGeneration: true,
|
||||
},
|
||||
'influxdb3-enterprise': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/enterprise/v3/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/enterprise'),
|
||||
description: 'InfluxDB 3 Enterprise',
|
||||
menuKey: 'influxdb3_enterprise',
|
||||
useTagBasedGeneration: true,
|
||||
},
|
||||
// Note: Cloud Dedicated, Serverless, and Clustered use management APIs
|
||||
// with paths like /accounts/{accountId}/... so we put them under /api/
|
||||
// These products have existing /reference/api/ pages with menu entries,
|
||||
// so we skip adding menu entries to the generated parent pages.
|
||||
'cloud-dedicated': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/cloud-dedicated/management/openapi.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-dedicated/api'),
|
||||
description: 'InfluxDB Cloud Dedicated',
|
||||
menuKey: 'influxdb3_cloud_dedicated',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
'cloud-serverless': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/cloud-serverless/management/openapi.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-serverless/api'),
|
||||
description: 'InfluxDB Cloud Serverless',
|
||||
menuKey: 'influxdb3_cloud_serverless',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
clustered: {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/clustered/management/openapi.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/clustered/api'),
|
||||
description: 'InfluxDB Clustered',
|
||||
menuKey: 'influxdb3_clustered',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
};
|
||||
exports.productConfigs = productConfigs;
|
||||
/**
|
||||
|
|
@ -230,114 +390,127 @@ exports.productConfigs = productConfigs;
|
|||
* @param config - Product configuration
|
||||
*/
|
||||
function processProduct(productKey, config) {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`Processing ${config.description || productKey}`);
|
||||
console.log('='.repeat(80));
|
||||
const staticPath = path.join(DOCS_ROOT, 'static/openapi');
|
||||
const staticSpecPath = path.join(staticPath, `influxdb-${productKey}.yml`);
|
||||
const staticJsonSpecPath = path.join(
|
||||
staticPath,
|
||||
`influxdb-${productKey}.json`
|
||||
);
|
||||
const staticPathsPath = path.join(staticPath, `influxdb-${productKey}/paths`);
|
||||
const articlesPath = path.join(
|
||||
DOCS_ROOT,
|
||||
`data/article-data/influxdb/${productKey}`
|
||||
);
|
||||
// Check if spec file exists
|
||||
if (!fs.existsSync(config.specFile)) {
|
||||
console.warn(`⚠️ Spec file not found: ${config.specFile}`);
|
||||
console.log('Skipping this product. Run getswagger.sh first if needed.\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Step 1: Execute the getswagger.sh script to fetch/bundle the spec
|
||||
const getswaggerScript = path.join(API_DOCS_ROOT, 'getswagger.sh');
|
||||
if (fs.existsSync(getswaggerScript)) {
|
||||
execCommand(
|
||||
`${getswaggerScript} ${productKey} -B`,
|
||||
`Fetching OpenAPI spec for ${productKey}`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ getswagger.sh not found, skipping fetch step`);
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`Processing ${config.description || productKey}`);
|
||||
console.log('='.repeat(80));
|
||||
const staticPath = path.join(DOCS_ROOT, 'static/openapi');
|
||||
const staticSpecPath = path.join(staticPath, `influxdb-${productKey}.yml`);
|
||||
const staticJsonSpecPath = path.join(staticPath, `influxdb-${productKey}.json`);
|
||||
const staticPathsPath = path.join(staticPath, `influxdb-${productKey}/paths`);
|
||||
const articlesPath = path.join(DOCS_ROOT, `data/article-data/influxdb/${productKey}`);
|
||||
// Check if spec file exists
|
||||
if (!fs.existsSync(config.specFile)) {
|
||||
console.warn(`⚠️ Spec file not found: ${config.specFile}`);
|
||||
console.log('Skipping this product. Run getswagger.sh first if needed.\n');
|
||||
return;
|
||||
}
|
||||
// Step 2: Ensure static directory exists
|
||||
if (!fs.existsSync(staticPath)) {
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
try {
|
||||
// Step 1: Execute the getswagger.sh script to fetch/bundle the spec
|
||||
// Note: getswagger.sh must run from api-docs/ because it uses relative paths
|
||||
const getswaggerScript = path.join(API_DOCS_ROOT, 'getswagger.sh');
|
||||
if (fs.existsSync(getswaggerScript)) {
|
||||
execCommand(`cd ${API_DOCS_ROOT} && ./getswagger.sh ${productKey} -B`, `Fetching OpenAPI spec for ${productKey}`);
|
||||
}
|
||||
else {
|
||||
console.log(`⚠️ getswagger.sh not found, skipping fetch step`);
|
||||
}
|
||||
// Step 2: Ensure static directory exists
|
||||
if (!fs.existsSync(staticPath)) {
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
}
|
||||
// Step 3: Copy the generated OpenAPI spec to static folder (YAML)
|
||||
if (fs.existsSync(config.specFile)) {
|
||||
fs.copyFileSync(config.specFile, staticSpecPath);
|
||||
console.log(`✓ Copied spec to ${staticSpecPath}`);
|
||||
// Step 4: Generate JSON version of the spec
|
||||
try {
|
||||
const yaml = require('js-yaml');
|
||||
const specContent = fs.readFileSync(config.specFile, 'utf8');
|
||||
const specObject = yaml.load(specContent);
|
||||
fs.writeFileSync(staticJsonSpecPath, JSON.stringify(specObject, null, 2));
|
||||
console.log(`✓ Generated JSON spec at ${staticJsonSpecPath}`);
|
||||
}
|
||||
catch (jsonError) {
|
||||
console.warn(`⚠️ Could not generate JSON spec: ${jsonError}`);
|
||||
}
|
||||
}
|
||||
// Step 5: Generate Hugo data from OpenAPI spec
|
||||
if (config.useTagBasedGeneration) {
|
||||
// Tag-based generation: group operations by OpenAPI tag
|
||||
const staticTagsPath = path.join(staticPath, `influxdb-${productKey}/tags`);
|
||||
console.log(`\n📋 Using tag-based generation for ${productKey}...`);
|
||||
openapiPathsToHugo.generateHugoDataByTag({
|
||||
specFile: config.specFile,
|
||||
dataOutPath: staticTagsPath,
|
||||
articleOutPath: articlesPath,
|
||||
includePaths: true, // Also generate path-based files for backwards compatibility
|
||||
});
|
||||
// Step 6: Generate Hugo content pages from tag-based article data
|
||||
generateTagPagesFromArticleData({
|
||||
articlesPath,
|
||||
contentPath: config.pagesDir,
|
||||
menuKey: config.menuKey,
|
||||
menuParent: 'InfluxDB HTTP API',
|
||||
skipParentMenu: config.skipParentMenu,
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Path-based generation: group paths by URL prefix (legacy)
|
||||
generateDataFromOpenAPI(config.specFile, staticPathsPath, articlesPath);
|
||||
// Step 6: Generate Hugo content pages from path-based article data
|
||||
generatePagesFromArticleData({
|
||||
articlesPath,
|
||||
contentPath: config.pagesDir,
|
||||
menuKey: config.menuKey,
|
||||
menuParent: 'InfluxDB HTTP API',
|
||||
skipParentMenu: config.skipParentMenu,
|
||||
});
|
||||
}
|
||||
console.log(`\n✅ Successfully processed ${config.description || productKey}\n`);
|
||||
}
|
||||
// Step 3: Copy the generated OpenAPI spec to static folder (YAML)
|
||||
if (fs.existsSync(config.specFile)) {
|
||||
fs.copyFileSync(config.specFile, staticSpecPath);
|
||||
console.log(`✓ Copied spec to ${staticSpecPath}`);
|
||||
// Step 4: Generate JSON version of the spec
|
||||
try {
|
||||
const yaml = require('js-yaml');
|
||||
const specContent = fs.readFileSync(config.specFile, 'utf8');
|
||||
const specObject = yaml.load(specContent);
|
||||
fs.writeFileSync(
|
||||
staticJsonSpecPath,
|
||||
JSON.stringify(specObject, null, 2)
|
||||
);
|
||||
console.log(`✓ Generated JSON spec at ${staticJsonSpecPath}`);
|
||||
} catch (jsonError) {
|
||||
console.warn(`⚠️ Could not generate JSON spec: ${jsonError}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`\n❌ Error processing ${productKey}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
// Step 5: Generate Hugo data from OpenAPI spec (path fragments for AI agents)
|
||||
generateDataFromOpenAPI(config.specFile, staticPathsPath, articlesPath);
|
||||
// Step 6: Generate Hugo content pages from article data
|
||||
generatePagesFromArticleData(articlesPath, config.pagesDir);
|
||||
console.log(
|
||||
`\n✅ Successfully processed ${config.description || productKey}\n`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Error processing ${productKey}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Main execution function
|
||||
*/
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
// Determine which products to process
|
||||
let productsToProcess;
|
||||
if (args.length === 0) {
|
||||
// No arguments: process all products
|
||||
productsToProcess = Object.keys(productConfigs);
|
||||
console.log('\n📋 Processing all products...\n');
|
||||
} else {
|
||||
// Arguments provided: process only specified products
|
||||
productsToProcess = args;
|
||||
console.log(
|
||||
`\n📋 Processing specified products: ${productsToProcess.join(', ')}\n`
|
||||
);
|
||||
}
|
||||
// Validate product keys
|
||||
const invalidProducts = productsToProcess.filter(
|
||||
(key) => !productConfigs[key]
|
||||
);
|
||||
if (invalidProducts.length > 0) {
|
||||
console.error(
|
||||
`\n❌ Invalid product identifier(s): ${invalidProducts.join(', ')}`
|
||||
);
|
||||
console.error('\nValid products:');
|
||||
Object.keys(productConfigs).forEach((key) => {
|
||||
console.error(` - ${key}: ${productConfigs[key].description}`);
|
||||
const args = process.argv.slice(2);
|
||||
// Determine which products to process
|
||||
let productsToProcess;
|
||||
if (args.length === 0) {
|
||||
// No arguments: process all products
|
||||
productsToProcess = Object.keys(productConfigs);
|
||||
console.log('\n📋 Processing all products...\n');
|
||||
}
|
||||
else {
|
||||
// Arguments provided: process only specified products
|
||||
productsToProcess = args;
|
||||
console.log(`\n📋 Processing specified products: ${productsToProcess.join(', ')}\n`);
|
||||
}
|
||||
// Validate product keys
|
||||
const invalidProducts = productsToProcess.filter((key) => !productConfigs[key]);
|
||||
if (invalidProducts.length > 0) {
|
||||
console.error(`\n❌ Invalid product identifier(s): ${invalidProducts.join(', ')}`);
|
||||
console.error('\nValid products:');
|
||||
Object.keys(productConfigs).forEach((key) => {
|
||||
console.error(` - ${key}: ${productConfigs[key].description}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
// Process each product
|
||||
productsToProcess.forEach((productKey) => {
|
||||
const config = productConfigs[productKey];
|
||||
processProduct(productKey, config);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
// Process each product
|
||||
productsToProcess.forEach((productKey) => {
|
||||
const config = productConfigs[productKey];
|
||||
processProduct(productKey, config);
|
||||
});
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('✅ All products processed successfully!');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('✅ All products processed successfully!');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
}
|
||||
// Execute if run directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
main();
|
||||
}
|
||||
//# sourceMappingURL=generate-openapi-articles.js.map
|
||||
//# sourceMappingURL=generate-openapi-articles.js.map
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
'use strict';
|
||||
"use strict";
|
||||
/**
|
||||
* OpenAPI to Hugo Data Converter
|
||||
*
|
||||
|
|
@ -7,55 +7,45 @@
|
|||
*
|
||||
* @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 (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null)
|
||||
for (var k in mod)
|
||||
if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k))
|
||||
__createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
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 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;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateHugoDataByTag = generateHugoDataByTag;
|
||||
exports.generateHugoData = generateHugoData;
|
||||
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
|
||||
*
|
||||
|
|
@ -64,8 +54,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
|
||||
|
|
@ -74,7 +64,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
|
||||
|
|
@ -83,23 +73,196 @@ 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
|
||||
*
|
||||
* @param tagName - Tag name (e.g., "Write data", "Processing engine")
|
||||
* @returns URL-friendly slug (e.g., "write-data", "processing-engine")
|
||||
*/
|
||||
function slugifyTag(tagName) {
|
||||
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',
|
||||
};
|
||||
/**
|
||||
* Get menu group for a tag
|
||||
*
|
||||
* @param tagName - Tag name
|
||||
* @returns Menu group name or 'Other' if not mapped
|
||||
*/
|
||||
function getMenuGroupForTag(tagName) {
|
||||
return TAG_MENU_GROUPS[tagName] || 'Other';
|
||||
}
|
||||
/**
|
||||
* HTTP methods to check for operations
|
||||
*/
|
||||
const HTTP_METHODS = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'trace',
|
||||
];
|
||||
/**
|
||||
* Extract all operations from an OpenAPI document grouped by tag
|
||||
*
|
||||
* @param openapi - OpenAPI document
|
||||
* @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 || [],
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
/**
|
||||
* Write OpenAPI specs grouped by tag to separate files
|
||||
* Generates both YAML and JSON versions per tag
|
||||
*
|
||||
* @param openapi - OpenAPI document
|
||||
* @param prefix - Filename prefix for output files
|
||||
* @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)) {
|
||||
filteredPathItem[method] = operation;
|
||||
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']);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Write OpenAPI specs grouped by path to separate files
|
||||
* Generates both YAML and JSON versions
|
||||
|
|
@ -109,51 +272,79 @@ const openapiUtils = {
|
|||
* @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);
|
||||
}
|
||||
});
|
||||
// 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];
|
||||
doc.info.title = `${pg}\n${doc.info.title}`;
|
||||
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
|
||||
|
|
@ -162,33 +353,107 @@ function writePathOpenapis(openapi, prefix, outPath) {
|
|||
* @returns Article metadata object
|
||||
*/
|
||||
function createArticleDataForPathGroup(openapi) {
|
||||
const article = {
|
||||
path: '',
|
||||
fields: {
|
||||
name: openapi['x-pathGroup'] || '',
|
||||
describes: Object.keys(openapi.paths),
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Convert path to snake case for article path
|
||||
*
|
||||
* @param p - Path to convert
|
||||
* @returns Snake-cased path
|
||||
*/
|
||||
const snakifyPath = (p) => {
|
||||
if (!p) {
|
||||
return '';
|
||||
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;
|
||||
}
|
||||
return p.replace(/^\//, '').replaceAll('/', '-');
|
||||
};
|
||||
article.path = snakifyPath(openapi['x-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) => snakifyPath(t));
|
||||
return article;
|
||||
// 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
|
||||
|
|
@ -199,50 +464,187 @@ function createArticleDataForPathGroup(openapi) {
|
|||
* @param opts - Options including file pattern filter
|
||||
*/
|
||||
function writeOpenapiArticleData(sourcePath, targetPath, opts) {
|
||||
/**
|
||||
* Check if path is a file
|
||||
*/
|
||||
const isFile = (filePath) => {
|
||||
return fs.lstatSync(filePath).isFile();
|
||||
};
|
||||
/**
|
||||
* Check if filename matches pattern
|
||||
*/
|
||||
const matchesPattern = (filePath) => {
|
||||
return opts.filePattern
|
||||
? path.parse(filePath).name.startsWith(opts.filePattern)
|
||||
: true;
|
||||
};
|
||||
try {
|
||||
const articles = fs
|
||||
.readdirSync(sourcePath)
|
||||
.map((fileName) => path.join(sourcePath, fileName))
|
||||
.filter(matchesPattern)
|
||||
.filter(isFile)
|
||||
.filter(
|
||||
(filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml')
|
||||
) // Only process YAML files
|
||||
.map((filePath) => {
|
||||
const openapi = readFile(filePath);
|
||||
const article = createArticleDataForPathGroup(openapi);
|
||||
article.fields.source = filePath;
|
||||
// Hugo omits "/static" from the URI when serving files stored in "./static"
|
||||
article.fields.staticFilePath = filePath.replace(/^static\//, '/');
|
||||
return article;
|
||||
});
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
/**
|
||||
* Check if path is a file
|
||||
*/
|
||||
const isFile = (filePath) => {
|
||||
return fs.lstatSync(filePath).isFile();
|
||||
};
|
||||
/**
|
||||
* Check if filename matches pattern
|
||||
*/
|
||||
const matchesPattern = (filePath) => {
|
||||
return opts.filePattern
|
||||
? path.parse(filePath).name.startsWith(opts.filePattern)
|
||||
: true;
|
||||
};
|
||||
try {
|
||||
const articles = fs
|
||||
.readdirSync(sourcePath)
|
||||
.map((fileName) => path.join(sourcePath, fileName))
|
||||
.filter(matchesPattern)
|
||||
.filter(isFile)
|
||||
.filter((filePath) => filePath.endsWith('.yaml') || filePath.endsWith('.yml')) // Only process YAML files
|
||||
.map((filePath) => {
|
||||
const openapi = readFile(filePath);
|
||||
const article = createArticleDataForPathGroup(openapi);
|
||||
article.fields.source = filePath;
|
||||
// Hugo omits "/static" from the URI when serving files stored in "./static"
|
||||
article.fields.staticFilePath = filePath.replace(/^static\//, '/');
|
||||
return article;
|
||||
});
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
}
|
||||
const articleCollection = { articles };
|
||||
// Write both YAML and JSON versions
|
||||
const yamlPath = path.resolve(targetPath, 'articles.yml');
|
||||
const jsonPath = path.resolve(targetPath, 'articles.json');
|
||||
writeDataFile(articleCollection, yamlPath);
|
||||
writeJsonFile(articleCollection, jsonPath);
|
||||
console.log(`Generated ${articles.length} articles in ${targetPath}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Error writing article data:', e);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create article data for a tag-based grouping
|
||||
*
|
||||
* @param openapi - OpenAPI document with x-tagGroup
|
||||
* @param operations - Operations for this tag
|
||||
* @param tagMeta - Tag metadata from OpenAPI spec
|
||||
* @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: 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
// Add tag description for conceptual pages
|
||||
if (tagMeta?.description) {
|
||||
article.fields.tagDescription = tagMeta.description;
|
||||
}
|
||||
return article;
|
||||
}
|
||||
/**
|
||||
* Write tag-based OpenAPI article metadata to Hugo data files
|
||||
* Generates articles.yml and articles.json
|
||||
*
|
||||
* @param sourcePath - Path to directory containing tag-based OpenAPI fragment files
|
||||
* @param targetPath - Output path for article data
|
||||
* @param openapi - Original OpenAPI document (for tag metadata)
|
||||
* @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) {
|
||||
operations.push({
|
||||
operationId: operation.operationId || `${method}-${pathKey}`,
|
||||
method: method.toUpperCase(),
|
||||
path: pathKey,
|
||||
summary: operation.summary || '',
|
||||
tags: operation.tags || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
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
|
||||
*
|
||||
* This function:
|
||||
* 1. Reads the OpenAPI spec file
|
||||
* 2. Groups operations by their OpenAPI tags
|
||||
* 3. Writes each tag group to separate YAML and JSON files
|
||||
* 4. Generates tag-based article metadata for Hugo
|
||||
*
|
||||
* @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');
|
||||
}
|
||||
/**
|
||||
* Generate Hugo data files from an OpenAPI specification
|
||||
|
|
@ -256,20 +658,19 @@ function writeOpenapiArticleData(sourcePath, targetPath, opts) {
|
|||
* @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');
|
||||
}
|
||||
// CommonJS export for backward compatibility
|
||||
module.exports = {
|
||||
generateHugoData,
|
||||
generateHugoData,
|
||||
generateHugoDataByTag,
|
||||
};
|
||||
//# sourceMappingURL=index.js.map
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -27,6 +27,17 @@ import * as fs from 'fs';
|
|||
// Import the OpenAPI to Hugo converter
|
||||
const openapiPathsToHugo = require('./openapi-paths-to-hugo-data/index.js');
|
||||
|
||||
/**
|
||||
* Operation metadata structure from tag-based articles
|
||||
*/
|
||||
interface OperationMeta {
|
||||
operationId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product configuration for API generation
|
||||
*/
|
||||
|
|
@ -37,6 +48,12 @@ interface ProductConfig {
|
|||
pagesDir: string;
|
||||
/** Optional description of the product */
|
||||
description?: string;
|
||||
/** Hugo menu identifier for this product (e.g., 'influxdb3_core') */
|
||||
menuKey?: string;
|
||||
/** Skip adding menu entry to generated parent page (use when existing reference page has menu entry) */
|
||||
skipParentMenu?: boolean;
|
||||
/** Use tag-based generation instead of path-based (default: false) */
|
||||
useTagBasedGeneration?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,19 +111,41 @@ function generateDataFromOpenAPI(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating pages from article data
|
||||
*/
|
||||
interface GeneratePagesOptions {
|
||||
/** Path to the articles data directory */
|
||||
articlesPath: string;
|
||||
/** Output path for generated content pages */
|
||||
contentPath: string;
|
||||
/** Hugo menu identifier for navigation (e.g., 'influxdb3_core') */
|
||||
menuKey?: string;
|
||||
/** Parent menu item name (e.g., 'InfluxDB HTTP API') */
|
||||
menuParent?: string;
|
||||
/** Product description for the parent page */
|
||||
productDescription?: string;
|
||||
/** Skip adding menu entry to generated parent page */
|
||||
skipParentMenu?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Hugo content pages from article data
|
||||
*
|
||||
* Creates markdown files with frontmatter from article metadata.
|
||||
* Each article becomes a page with type: api that renders via Scalar.
|
||||
*
|
||||
* @param articlesPath - Path to the articles data directory
|
||||
* @param contentPath - Output path for generated content pages
|
||||
* @param options - Generation options
|
||||
*/
|
||||
function generatePagesFromArticleData(
|
||||
articlesPath: string,
|
||||
contentPath: string
|
||||
): void {
|
||||
function generatePagesFromArticleData(options: GeneratePagesOptions): void {
|
||||
const {
|
||||
articlesPath,
|
||||
contentPath,
|
||||
menuKey,
|
||||
menuParent,
|
||||
productDescription,
|
||||
skipParentMenu,
|
||||
} = options;
|
||||
const yaml = require('js-yaml');
|
||||
const articlesFile = path.join(articlesPath, 'articles.yml');
|
||||
|
||||
|
|
@ -118,7 +157,10 @@ function generatePagesFromArticleData(
|
|||
// Read articles data
|
||||
const articlesContent = fs.readFileSync(articlesFile, 'utf8');
|
||||
const data = yaml.load(articlesContent) as {
|
||||
articles: Array<{ path: string; fields: Record<string, unknown> }>;
|
||||
articles: Array<{
|
||||
path: string;
|
||||
fields: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!data.articles || !Array.isArray(data.articles)) {
|
||||
|
|
@ -131,6 +173,47 @@ function generatePagesFromArticleData(
|
|||
fs.mkdirSync(contentPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Determine the API parent directory from the first article's path
|
||||
// e.g., if article path is "api/v1/health", the API root is "api"
|
||||
const firstArticlePath = data.articles[0]?.path || '';
|
||||
const apiRootDir = firstArticlePath.split('/')[0];
|
||||
|
||||
// Generate parent _index.md for the API section
|
||||
if (apiRootDir) {
|
||||
const apiParentDir = path.join(contentPath, apiRootDir);
|
||||
const parentIndexFile = path.join(apiParentDir, '_index.md');
|
||||
|
||||
if (!fs.existsSync(apiParentDir)) {
|
||||
fs.mkdirSync(apiParentDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(parentIndexFile)) {
|
||||
const parentFrontmatter: Record<string, unknown> = {
|
||||
title: menuParent || 'HTTP API',
|
||||
description:
|
||||
productDescription ||
|
||||
'API reference documentation for all available endpoints.',
|
||||
weight: 104,
|
||||
};
|
||||
|
||||
// Add menu entry for parent page (unless skipParentMenu is true)
|
||||
if (menuKey && !skipParentMenu) {
|
||||
parentFrontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: menuParent || 'HTTP API',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const parentContent = `---
|
||||
${yaml.dump(parentFrontmatter)}---
|
||||
`;
|
||||
|
||||
fs.writeFileSync(parentIndexFile, parentContent);
|
||||
console.log(`✓ Generated parent index at ${parentIndexFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a page for each article
|
||||
for (const article of data.articles) {
|
||||
const pagePath = path.join(contentPath, article.path);
|
||||
|
|
@ -141,15 +224,51 @@ function generatePagesFromArticleData(
|
|||
fs.mkdirSync(pagePath, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate frontmatter
|
||||
const frontmatter = {
|
||||
title: article.fields.name || article.path,
|
||||
description: `API reference for ${article.fields.name || article.path}`,
|
||||
// Build frontmatter object
|
||||
// Use menuName for display (actual endpoint path like /health)
|
||||
// Fall back to name or path if menuName is not set
|
||||
const displayName =
|
||||
article.fields.menuName || article.fields.name || article.path;
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
title: displayName,
|
||||
description: `API reference for ${displayName}`,
|
||||
type: 'api',
|
||||
// Use explicit layout to override Hugo's default section template lookup
|
||||
// (Hugo's section lookup ignores `type`, so we need `layout` for the 3-column API layout)
|
||||
layout: 'list',
|
||||
staticFilePath: article.fields.staticFilePath,
|
||||
weight: 100,
|
||||
};
|
||||
|
||||
// Add menu entry if menuKey is provided
|
||||
// Use menuName for menu display (shows actual endpoint path like /health)
|
||||
if (menuKey) {
|
||||
frontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: displayName,
|
||||
...(menuParent && { parent: menuParent }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add related links if present in article fields
|
||||
if (
|
||||
article.fields.related &&
|
||||
Array.isArray(article.fields.related) &&
|
||||
article.fields.related.length > 0
|
||||
) {
|
||||
frontmatter.related = article.fields.related;
|
||||
}
|
||||
|
||||
// Add OpenAPI tags if present in article fields (for frontmatter metadata)
|
||||
if (
|
||||
article.fields.apiTags &&
|
||||
Array.isArray(article.fields.apiTags) &&
|
||||
article.fields.apiTags.length > 0
|
||||
) {
|
||||
frontmatter.api_tags = article.fields.apiTags;
|
||||
}
|
||||
|
||||
const pageContent = `---
|
||||
${yaml.dump(frontmatter)}---
|
||||
`;
|
||||
|
|
@ -162,35 +281,217 @@ ${yaml.dump(frontmatter)}---
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating tag-based pages from article data
|
||||
*/
|
||||
interface GenerateTagPagesOptions {
|
||||
/** Path to the articles data directory */
|
||||
articlesPath: string;
|
||||
/** Output path for generated content pages */
|
||||
contentPath: string;
|
||||
/** Hugo menu identifier for navigation (e.g., 'influxdb3_core') */
|
||||
menuKey?: string;
|
||||
/** Parent menu item name (e.g., 'InfluxDB HTTP API') */
|
||||
menuParent?: string;
|
||||
/** Product description for the parent page */
|
||||
productDescription?: string;
|
||||
/** Skip adding menu entry to generated parent page */
|
||||
skipParentMenu?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Hugo content pages from tag-based article data
|
||||
*
|
||||
* Creates markdown files with frontmatter from article metadata.
|
||||
* Each article becomes a page with type: api that renders via RapiDoc.
|
||||
* Includes operation metadata for TOC generation.
|
||||
*
|
||||
* @param options - Generation options
|
||||
*/
|
||||
function generateTagPagesFromArticleData(options: GenerateTagPagesOptions): void {
|
||||
const {
|
||||
articlesPath,
|
||||
contentPath,
|
||||
menuKey,
|
||||
menuParent,
|
||||
productDescription,
|
||||
skipParentMenu,
|
||||
} = options;
|
||||
const yaml = require('js-yaml');
|
||||
const articlesFile = path.join(articlesPath, 'articles.yml');
|
||||
|
||||
if (!fs.existsSync(articlesFile)) {
|
||||
console.warn(`⚠️ Articles file not found: ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read articles data
|
||||
const articlesContent = fs.readFileSync(articlesFile, 'utf8');
|
||||
const data = yaml.load(articlesContent) as {
|
||||
articles: Array<{
|
||||
path: string;
|
||||
fields: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
tag?: string;
|
||||
isConceptual?: boolean;
|
||||
tagDescription?: string;
|
||||
menuGroup?: string;
|
||||
staticFilePath?: string;
|
||||
operations?: OperationMeta[];
|
||||
related?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!data.articles || !Array.isArray(data.articles)) {
|
||||
console.warn(`⚠️ No articles found in ${articlesFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure content directory exists
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
fs.mkdirSync(contentPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate parent _index.md for the API section
|
||||
const apiParentDir = path.join(contentPath, 'api');
|
||||
const parentIndexFile = path.join(apiParentDir, '_index.md');
|
||||
|
||||
if (!fs.existsSync(apiParentDir)) {
|
||||
fs.mkdirSync(apiParentDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(parentIndexFile)) {
|
||||
const parentFrontmatter: Record<string, unknown> = {
|
||||
title: menuParent || 'HTTP API',
|
||||
description:
|
||||
productDescription ||
|
||||
'API reference documentation for all available endpoints.',
|
||||
weight: 104,
|
||||
};
|
||||
|
||||
// Add menu entry for parent page (unless skipParentMenu is true)
|
||||
if (menuKey && !skipParentMenu) {
|
||||
parentFrontmatter.menu = {
|
||||
[menuKey]: {
|
||||
name: menuParent || 'HTTP API',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const parentContent = `---
|
||||
${yaml.dump(parentFrontmatter)}---
|
||||
`;
|
||||
|
||||
fs.writeFileSync(parentIndexFile, parentContent);
|
||||
console.log(`✓ Generated parent index at ${parentIndexFile}`);
|
||||
}
|
||||
|
||||
// Generate a page for each article (tag)
|
||||
for (const article of data.articles) {
|
||||
const pagePath = path.join(contentPath, article.path);
|
||||
const pageFile = path.join(pagePath, '_index.md');
|
||||
|
||||
// Create directory if needed
|
||||
if (!fs.existsSync(pagePath)) {
|
||||
fs.mkdirSync(pagePath, { recursive: true });
|
||||
}
|
||||
|
||||
// Build frontmatter object
|
||||
const title = article.fields.title || article.fields.name || article.path;
|
||||
const isConceptual = article.fields.isConceptual === true;
|
||||
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
title,
|
||||
description: article.fields.description || `API reference for ${title}`,
|
||||
type: 'api',
|
||||
layout: isConceptual ? 'single' : 'list',
|
||||
staticFilePath: article.fields.staticFilePath,
|
||||
weight: 100,
|
||||
// Tag-based fields
|
||||
tag: article.fields.tag,
|
||||
isConceptual,
|
||||
menuGroup: article.fields.menuGroup,
|
||||
};
|
||||
|
||||
// Add operations for TOC generation (only for non-conceptual pages)
|
||||
if (!isConceptual && article.fields.operations && article.fields.operations.length > 0) {
|
||||
frontmatter.operations = article.fields.operations;
|
||||
}
|
||||
|
||||
// Add tag description for conceptual pages
|
||||
if (isConceptual && article.fields.tagDescription) {
|
||||
frontmatter.tagDescription = article.fields.tagDescription;
|
||||
}
|
||||
|
||||
// Note: We deliberately don't add menu entries for tag-based API pages.
|
||||
// The API sidebar navigation (api/sidebar-nav.html) handles navigation
|
||||
// for API reference pages, avoiding conflicts with existing menu items
|
||||
// like "Query data" and "Write data" that exist in the main sidebar.
|
||||
|
||||
// Add related links if present in article fields
|
||||
if (
|
||||
article.fields.related &&
|
||||
Array.isArray(article.fields.related) &&
|
||||
article.fields.related.length > 0
|
||||
) {
|
||||
frontmatter.related = article.fields.related;
|
||||
}
|
||||
|
||||
const pageContent = `---
|
||||
${yaml.dump(frontmatter)}---
|
||||
`;
|
||||
|
||||
fs.writeFileSync(pageFile, pageContent);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ Generated ${data.articles.length} tag-based content pages in ${contentPath}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product configurations for all InfluxDB editions
|
||||
*
|
||||
* Maps product identifiers to their OpenAPI specs and content directories
|
||||
*/
|
||||
const productConfigs: ProductConfigMap = {
|
||||
'cloud-v2': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb/cloud/v2/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb/cloud/api/v2'),
|
||||
description: 'InfluxDB Cloud (v2 API)',
|
||||
},
|
||||
'oss-v2': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb/v2/v2/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb/v2/api/v2'),
|
||||
description: 'InfluxDB OSS v2',
|
||||
},
|
||||
// TODO: v2 products (cloud-v2, oss-v2) are disabled for now because they
|
||||
// have existing Redoc-based API reference at /reference/api/
|
||||
// Uncomment when ready to migrate v2 products to Scalar
|
||||
// 'cloud-v2': {
|
||||
// specFile: path.join(API_DOCS_ROOT, 'influxdb/cloud/v2/ref.yml'),
|
||||
// pagesDir: path.join(DOCS_ROOT, 'content/influxdb/cloud/api'),
|
||||
// description: 'InfluxDB Cloud (v2 API)',
|
||||
// menuKey: 'influxdb_cloud',
|
||||
// },
|
||||
// 'oss-v2': {
|
||||
// specFile: path.join(API_DOCS_ROOT, 'influxdb/v2/v2/ref.yml'),
|
||||
// pagesDir: path.join(DOCS_ROOT, 'content/influxdb/v2/api'),
|
||||
// description: 'InfluxDB OSS v2',
|
||||
// menuKey: 'influxdb_v2',
|
||||
// },
|
||||
// InfluxDB 3 products use tag-based generation for better UX
|
||||
'influxdb3-core': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/core/v3/ref.yml'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/core/reference/api'),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/core'),
|
||||
description: 'InfluxDB 3 Core',
|
||||
menuKey: 'influxdb3_core',
|
||||
useTagBasedGeneration: true,
|
||||
},
|
||||
'influxdb3-enterprise': {
|
||||
specFile: path.join(API_DOCS_ROOT, 'influxdb3/enterprise/v3/ref.yml'),
|
||||
pagesDir: path.join(
|
||||
DOCS_ROOT,
|
||||
'content/influxdb3/enterprise/reference/api'
|
||||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/enterprise'),
|
||||
description: 'InfluxDB 3 Enterprise',
|
||||
menuKey: 'influxdb3_enterprise',
|
||||
useTagBasedGeneration: true,
|
||||
},
|
||||
// Note: Cloud Dedicated, Serverless, and Clustered use management APIs
|
||||
// with paths like /accounts/{accountId}/... so we put them under /api/
|
||||
// These products have existing /reference/api/ pages with menu entries,
|
||||
// so we skip adding menu entries to the generated parent pages.
|
||||
'cloud-dedicated': {
|
||||
specFile: path.join(
|
||||
API_DOCS_ROOT,
|
||||
|
|
@ -198,6 +499,8 @@ const productConfigs: ProductConfigMap = {
|
|||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-dedicated/api'),
|
||||
description: 'InfluxDB Cloud Dedicated',
|
||||
menuKey: 'influxdb3_cloud_dedicated',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
'cloud-serverless': {
|
||||
specFile: path.join(
|
||||
|
|
@ -206,6 +509,8 @@ const productConfigs: ProductConfigMap = {
|
|||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/cloud-serverless/api'),
|
||||
description: 'InfluxDB Cloud Serverless',
|
||||
menuKey: 'influxdb3_cloud_serverless',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
clustered: {
|
||||
specFile: path.join(
|
||||
|
|
@ -214,6 +519,8 @@ const productConfigs: ProductConfigMap = {
|
|||
),
|
||||
pagesDir: path.join(DOCS_ROOT, 'content/influxdb3/clustered/api'),
|
||||
description: 'InfluxDB Clustered',
|
||||
menuKey: 'influxdb3_clustered',
|
||||
skipParentMenu: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -249,10 +556,11 @@ function processProduct(productKey: string, config: ProductConfig): void {
|
|||
|
||||
try {
|
||||
// Step 1: Execute the getswagger.sh script to fetch/bundle the spec
|
||||
// Note: getswagger.sh must run from api-docs/ because it uses relative paths
|
||||
const getswaggerScript = path.join(API_DOCS_ROOT, 'getswagger.sh');
|
||||
if (fs.existsSync(getswaggerScript)) {
|
||||
execCommand(
|
||||
`${getswaggerScript} ${productKey} -B`,
|
||||
`cd ${API_DOCS_ROOT} && ./getswagger.sh ${productKey} -B`,
|
||||
`Fetching OpenAPI spec for ${productKey}`
|
||||
);
|
||||
} else {
|
||||
|
|
@ -284,11 +592,39 @@ function processProduct(productKey: string, config: ProductConfig): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Step 5: Generate Hugo data from OpenAPI spec (path fragments for AI agents)
|
||||
generateDataFromOpenAPI(config.specFile, staticPathsPath, articlesPath);
|
||||
// Step 5: Generate Hugo data from OpenAPI spec
|
||||
if (config.useTagBasedGeneration) {
|
||||
// Tag-based generation: group operations by OpenAPI tag
|
||||
const staticTagsPath = path.join(staticPath, `influxdb-${productKey}/tags`);
|
||||
console.log(`\n📋 Using tag-based generation for ${productKey}...`);
|
||||
openapiPathsToHugo.generateHugoDataByTag({
|
||||
specFile: config.specFile,
|
||||
dataOutPath: staticTagsPath,
|
||||
articleOutPath: articlesPath,
|
||||
includePaths: true, // Also generate path-based files for backwards compatibility
|
||||
});
|
||||
|
||||
// Step 6: Generate Hugo content pages from article data
|
||||
generatePagesFromArticleData(articlesPath, config.pagesDir);
|
||||
// Step 6: Generate Hugo content pages from tag-based article data
|
||||
generateTagPagesFromArticleData({
|
||||
articlesPath,
|
||||
contentPath: config.pagesDir,
|
||||
menuKey: config.menuKey,
|
||||
menuParent: 'InfluxDB HTTP API',
|
||||
skipParentMenu: config.skipParentMenu,
|
||||
});
|
||||
} else {
|
||||
// Path-based generation: group paths by URL prefix (legacy)
|
||||
generateDataFromOpenAPI(config.specFile, staticPathsPath, articlesPath);
|
||||
|
||||
// Step 6: Generate Hugo content pages from path-based article data
|
||||
generatePagesFromArticleData({
|
||||
articlesPath,
|
||||
contentPath: config.pagesDir,
|
||||
menuKey: config.menuKey,
|
||||
menuParent: 'InfluxDB HTTP API',
|
||||
skipParentMenu: config.skipParentMenu,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n✅ Successfully processed ${config.description || productKey}\n`
|
||||
|
|
|
|||
|
|
@ -211,9 +211,22 @@ interface Tag {
|
|||
name: string;
|
||||
description?: string;
|
||||
externalDocs?: ExternalDocs;
|
||||
/** Indicates this is a conceptual/supplementary tag (no operations) */
|
||||
'x-traitTag'?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation metadata for TOC generation
|
||||
*/
|
||||
interface OperationMeta {
|
||||
operationId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI external docs object
|
||||
*/
|
||||
|
|
@ -236,6 +249,22 @@ interface Article {
|
|||
tags?: string[];
|
||||
source?: string;
|
||||
staticFilePath?: string;
|
||||
/** Related documentation links extracted from x-relatedLinks */
|
||||
related?: string[];
|
||||
/** OpenAPI tags from operations (for Hugo frontmatter) */
|
||||
apiTags?: string[];
|
||||
/** Menu display name (actual endpoint path, different from Hugo path) */
|
||||
menuName?: string;
|
||||
/** OpenAPI tag name (for tag-based articles) */
|
||||
tag?: string;
|
||||
/** Whether this is a conceptual tag (x-traitTag) */
|
||||
isConceptual?: boolean;
|
||||
/** Tag description from OpenAPI spec */
|
||||
tagDescription?: string;
|
||||
/** Sidebar navigation group */
|
||||
menuGroup?: string;
|
||||
/** Operations metadata for TOC generation */
|
||||
operations?: OperationMeta[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +346,212 @@ const openapiUtils = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert tag name to URL-friendly slug
|
||||
*
|
||||
* @param tagName - Tag name (e.g., "Write data", "Processing engine")
|
||||
* @returns URL-friendly slug (e.g., "write-data", "processing-engine")
|
||||
*/
|
||||
function slugifyTag(tagName: string): string {
|
||||
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: Record<string, string> = {
|
||||
// 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
|
||||
*
|
||||
* @param tagName - Tag name
|
||||
* @returns Menu group name or 'Other' if not mapped
|
||||
*/
|
||||
function getMenuGroupForTag(tagName: string): string {
|
||||
return TAG_MENU_GROUPS[tagName] || 'Other';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP methods to check for operations
|
||||
*/
|
||||
const HTTP_METHODS = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'trace',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Extract all operations from an OpenAPI document grouped by tag
|
||||
*
|
||||
* @param openapi - OpenAPI document
|
||||
* @returns Map of tag name to operations with that tag
|
||||
*/
|
||||
function extractOperationsByTag(
|
||||
openapi: OpenAPIDocument
|
||||
): Map<string, OperationMeta[]> {
|
||||
const tagOperations = new Map<string, OperationMeta[]>();
|
||||
|
||||
Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => {
|
||||
HTTP_METHODS.forEach((method) => {
|
||||
const operation = pathItem[method] as Operation | undefined;
|
||||
if (operation) {
|
||||
const opMeta: OperationMeta = {
|
||||
operationId: operation.operationId || `${method}-${pathKey}`,
|
||||
method: method.toUpperCase(),
|
||||
path: pathKey,
|
||||
summary: operation.summary || '',
|
||||
tags: operation.tags || [],
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write OpenAPI specs grouped by tag to separate files
|
||||
* Generates both YAML and JSON versions per tag
|
||||
*
|
||||
* @param openapi - OpenAPI document
|
||||
* @param prefix - Filename prefix for output files
|
||||
* @param outPath - Output directory path
|
||||
*/
|
||||
function writeTagOpenapis(
|
||||
openapi: OpenAPIDocument,
|
||||
prefix: string,
|
||||
outPath: string
|
||||
): void {
|
||||
const tagOperations = extractOperationsByTag(openapi);
|
||||
|
||||
// Process each tag
|
||||
tagOperations.forEach((operations, tagName) => {
|
||||
// Deep copy openapi
|
||||
const doc: OpenAPIDocument = JSON.parse(JSON.stringify(openapi));
|
||||
|
||||
// Filter paths to only include those with operations for this tag
|
||||
const filteredPaths: Record<string, PathItem> = {};
|
||||
Object.entries(openapi.paths).forEach(([pathKey, pathItem]) => {
|
||||
const filteredPathItem: PathItem = {};
|
||||
let hasOperations = false;
|
||||
|
||||
HTTP_METHODS.forEach((method) => {
|
||||
const operation = pathItem[method] as Operation | undefined;
|
||||
if (operation?.tags?.includes(tagName)) {
|
||||
filteredPathItem[method] = operation;
|
||||
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']
|
||||
);
|
||||
}
|
||||
|
||||
// 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: OpenAPIDocument = 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write OpenAPI specs grouped by path to separate files
|
||||
* Generates both YAML and JSON versions
|
||||
|
|
@ -364,7 +599,39 @@ function writePathOpenapis(
|
|||
// Deep copy openapi
|
||||
const doc: OpenAPIDocument = JSON.parse(JSON.stringify(openapi));
|
||||
doc.paths = pathGroups[pg];
|
||||
doc.info.title = `${pg}\n${doc.info.title}`;
|
||||
|
||||
// Collect tags used by operations in this path group
|
||||
const usedTags = new Set<string>();
|
||||
Object.values(doc.paths).forEach((pathItem: PathItem) => {
|
||||
const httpMethods = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'trace',
|
||||
];
|
||||
httpMethods.forEach((method) => {
|
||||
const operation = pathItem[method] as Operation | undefined;
|
||||
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 {
|
||||
|
|
@ -403,26 +670,118 @@ function createArticleDataForPathGroup(openapi: OpenAPIDocument): Article {
|
|||
};
|
||||
|
||||
/**
|
||||
* Convert path to snake case for article path
|
||||
* 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: string): string => {
|
||||
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 Snake-cased path
|
||||
* @returns Tag-friendly path
|
||||
*/
|
||||
const snakifyPath = (p: string): string => {
|
||||
const toTagPath = (p: string): string => {
|
||||
if (!p) {
|
||||
return '';
|
||||
}
|
||||
return p.replace(/^\//, '').replaceAll('/', '-');
|
||||
};
|
||||
|
||||
article.path = snakifyPath(openapi['x-pathGroup'] || '');
|
||||
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) => snakifyPath(t));
|
||||
.map((t) => toTagPath(t));
|
||||
|
||||
// Extract x-relatedLinks and OpenAPI tags from path items or operations
|
||||
const relatedLinks: string[] = [];
|
||||
const apiTags: string[] = [];
|
||||
const httpMethods = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'trace',
|
||||
];
|
||||
|
||||
Object.values(openapi.paths).forEach((pathItem: PathItem) => {
|
||||
// Check path-level x-relatedLinks
|
||||
if (
|
||||
pathItem['x-relatedLinks'] &&
|
||||
Array.isArray(pathItem['x-relatedLinks'])
|
||||
) {
|
||||
relatedLinks.push(
|
||||
...(pathItem['x-relatedLinks'] as string[]).filter(
|
||||
(link) => !relatedLinks.includes(link)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check operation-level x-relatedLinks and tags
|
||||
httpMethods.forEach((method) => {
|
||||
const operation = pathItem[method] as Operation | undefined;
|
||||
if (operation) {
|
||||
// Extract x-relatedLinks
|
||||
if (
|
||||
operation['x-relatedLinks'] &&
|
||||
Array.isArray(operation['x-relatedLinks'])
|
||||
) {
|
||||
relatedLinks.push(
|
||||
...(operation['x-relatedLinks'] as string[]).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;
|
||||
}
|
||||
|
|
@ -493,6 +852,185 @@ function writeOpenapiArticleData(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create article data for a tag-based grouping
|
||||
*
|
||||
* @param openapi - OpenAPI document with x-tagGroup
|
||||
* @param operations - Operations for this tag
|
||||
* @param tagMeta - Tag metadata from OpenAPI spec
|
||||
* @returns Article metadata object
|
||||
*/
|
||||
function createArticleDataForTag(
|
||||
openapi: OpenAPIDocument,
|
||||
operations: OperationMeta[],
|
||||
tagMeta?: Tag
|
||||
): Article {
|
||||
const tagName = openapi['x-tagGroup'] as string || '';
|
||||
const tagSlug = slugifyTag(tagName);
|
||||
const isConceptual = tagMeta?.['x-traitTag'] === true;
|
||||
|
||||
const article: Article = {
|
||||
path: `api/${tagSlug}`,
|
||||
fields: {
|
||||
name: tagName,
|
||||
describes: Object.keys(openapi.paths),
|
||||
title: tagName,
|
||||
description: 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
// Add tag description for conceptual pages
|
||||
if (tagMeta?.description) {
|
||||
article.fields.tagDescription = tagMeta.description;
|
||||
}
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tag-based OpenAPI article metadata to Hugo data files
|
||||
* Generates articles.yml and articles.json
|
||||
*
|
||||
* @param sourcePath - Path to directory containing tag-based OpenAPI fragment files
|
||||
* @param targetPath - Output path for article data
|
||||
* @param openapi - Original OpenAPI document (for tag metadata)
|
||||
* @param opts - Options including file pattern filter
|
||||
*/
|
||||
function writeOpenapiTagArticleData(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
openapi: OpenAPIDocument,
|
||||
opts: WriteOpenapiArticleDataOptions
|
||||
): void {
|
||||
const isFile = (filePath: string): boolean => {
|
||||
return fs.lstatSync(filePath).isFile();
|
||||
};
|
||||
|
||||
const matchesPattern = (filePath: string): boolean => {
|
||||
return opts.filePattern
|
||||
? path.parse(filePath).name.startsWith(opts.filePattern)
|
||||
: true;
|
||||
};
|
||||
|
||||
// Create tag metadata lookup
|
||||
const tagMetaMap = new Map<string, Tag>();
|
||||
(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'] as string || tagOpenapi.info?.title || '';
|
||||
const tagMeta = tagMetaMap.get(tagName);
|
||||
|
||||
// Extract operations from the tag-filtered spec
|
||||
const operations: OperationMeta[] = [];
|
||||
Object.entries(tagOpenapi.paths).forEach(([pathKey, pathItem]) => {
|
||||
HTTP_METHODS.forEach((method) => {
|
||||
const operation = pathItem[method] as Operation | undefined;
|
||||
if (operation) {
|
||||
operations.push({
|
||||
operationId: operation.operationId || `${method}-${pathKey}`,
|
||||
method: method.toUpperCase(),
|
||||
path: pathKey,
|
||||
summary: operation.summary || '',
|
||||
tags: operation.tags || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating Hugo data by tag
|
||||
*/
|
||||
export interface GenerateHugoDataByTagOptions extends GenerateHugoDataOptions {
|
||||
/** Whether to also generate path-based files (for backwards compatibility) */
|
||||
includePaths?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Hugo data files from an OpenAPI specification grouped by tag
|
||||
*
|
||||
* This function:
|
||||
* 1. Reads the OpenAPI spec file
|
||||
* 2. Groups operations by their OpenAPI tags
|
||||
* 3. Writes each tag group to separate YAML and JSON files
|
||||
* 4. Generates tag-based article metadata for Hugo
|
||||
*
|
||||
* @param options - Generation options
|
||||
*/
|
||||
export function generateHugoDataByTag(options: GenerateHugoDataByTagOptions): void {
|
||||
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
|
||||
*
|
||||
|
|
@ -525,4 +1063,5 @@ export function generateHugoData(options: GenerateHugoDataOptions): void {
|
|||
// CommonJS export for backward compatibility
|
||||
module.exports = {
|
||||
generateHugoData,
|
||||
generateHugoDataByTag,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue