docs-v2/helper-scripts/migrate-api-links.cjs

332 lines
10 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* migrate-api-links.js
*
* One-time migration script to convert Redoc API links to RapiDoc format.
*
* Usage:
* node helper-scripts/migrate-api-links.js --dry-run # Preview changes
* node helper-scripts/migrate-api-links.js # Execute migration
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { glob } = require('glob');
// CLI arguments
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const VERBOSE = args.includes('--verbose');
// Paths
const ROOT_DIR = path.resolve(__dirname, '..');
const CONTENT_DIR = path.join(ROOT_DIR, 'content');
const API_DOCS_DIR = path.join(ROOT_DIR, 'api-docs');
// Spec file → product URL mapping
const SPEC_MAPPINGS = [
{ spec: 'influxdb/cloud/v2/ref.yml', urlPrefix: '/influxdb/cloud/api/' },
{ spec: 'influxdb/v2/v2/ref.yml', urlPrefix: '/influxdb/v2/api/' },
{ spec: 'influxdb/v1/v1/ref.yml', urlPrefix: '/influxdb/v1/api/' },
{ spec: 'enterprise_influxdb/v1/v1/ref.yml', urlPrefix: '/enterprise_influxdb/v1/api/' },
{ spec: 'influxdb3/core/v3/ref.yml', urlPrefix: '/influxdb3/core/api/' },
{ spec: 'influxdb3/enterprise/v3/ref.yml', urlPrefix: '/influxdb3/enterprise/api/' },
{ spec: 'influxdb3/cloud-dedicated/v2/ref.yml', urlPrefix: '/influxdb3/cloud-dedicated/api/' },
{ spec: 'influxdb3/cloud-dedicated/management/openapi.yml', urlPrefix: '/influxdb3/cloud-dedicated/api/management/' },
{ spec: 'influxdb3/cloud-serverless/v2/ref.yml', urlPrefix: '/influxdb3/cloud-serverless/api/' },
{ spec: 'influxdb3/clustered/v2/ref.yml', urlPrefix: '/influxdb3/clustered/api/' },
{ spec: 'influxdb3/clustered/management/openapi.yml', urlPrefix: '/influxdb3/clustered/api/management/' },
];
// Version placeholder mappings for shared content
// Maps /version/ placeholder URLs to representative specs for operationId lookup
const VERSION_PLACEHOLDER_MAPPINGS = [
// InfluxDB 3 v3 API (core/enterprise share same operationIds)
{ pattern: /^\/influxdb3\/version\/api\/v3\//, lookupPrefix: '/influxdb3/core/api/' },
// InfluxDB 3 reference path variant
{ pattern: /^\/influxdb3\/[^/]+\/reference\/api\/v3\//, lookupPrefix: '/influxdb3/core/api/' },
// InfluxDB v2 API - use v2 (OSS) as it has more operations than cloud (replication, etc.)
{ pattern: /^\/influxdb\/version\/api\/v2\//, lookupPrefix: '/influxdb/v2/api/' },
{ pattern: /^\/influxdb\/version\/api\/v1\//, lookupPrefix: '/influxdb/v2/api/' }, // v1 compat is in v2 spec
{ pattern: /^\/influxdb\/version\/api\//, lookupPrefix: '/influxdb/v2/api/' },
// InfluxDB 3 version placeholder (generic)
{ pattern: /^\/influxdb3\/version\/api\//, lookupPrefix: '/influxdb3/cloud-serverless/api/' },
];
/**
* Convert path parameters from {param} to -param- (RapiDoc format)
*/
function convertPathParams(path) {
return path.replace(/\{([^}]+)\}/g, '-$1-');
}
/**
* Build RapiDoc anchor from method and path
* Format: {method}-{path} with {param} → -param-
*/
function buildAnchor(method, pathStr) {
const convertedPath = convertPathParams(pathStr);
return `${method.toLowerCase()}-${convertedPath}`;
}
/**
* Parse OpenAPI spec and extract operationId → anchor mapping
*/
function parseSpec(specPath) {
const mapping = {};
try {
const content = fs.readFileSync(specPath, 'utf8');
const spec = yaml.load(content);
if (!spec.paths) {
console.warn(` Warning: No paths in ${specPath}`);
return mapping;
}
for (const [pathStr, pathItem] of Object.entries(spec.paths)) {
const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'];
for (const method of methods) {
const operation = pathItem[method];
if (operation && operation.operationId) {
const anchor = buildAnchor(method, pathStr);
mapping[operation.operationId] = anchor;
if (VERBOSE) {
console.log(` ${operation.operationId} → #${anchor}`);
}
}
}
}
} catch (error) {
console.error(` Error parsing ${specPath}: ${error.message}`);
}
return mapping;
}
/**
* Build complete lookup table from all specs
* Returns: { urlPrefix: { operationId: anchor } }
*/
function buildLookupTable() {
const lookup = {};
console.log('Building operationId lookup table...\n');
for (const { spec, urlPrefix } of SPEC_MAPPINGS) {
const specPath = path.join(API_DOCS_DIR, spec);
if (!fs.existsSync(specPath)) {
console.warn(` Skipping missing spec: ${spec}`);
continue;
}
console.log(` Processing: ${spec}`);
const mapping = parseSpec(specPath);
lookup[urlPrefix] = mapping;
console.log(` Found ${Object.keys(mapping).length} operations`);
}
console.log('');
return lookup;
}
/**
* Find all #operation/ links in a file
* Returns array of { match, operationId, urlPath, fullUrl }
*/
function findOperationLinks(content) {
const links = [];
// Match patterns like: /influxdb/cloud/api/#operation/PostTasks
// or /influxdb3/cloud-dedicated/api/management/#operation/CreateDatabaseToken
const regex = /(\/[a-z0-9_/-]+\/api(?:\/management)?(?:\/[a-z0-9-]*)?\/)#operation\/(\w+)/g;
let match;
while ((match = regex.exec(content)) !== null) {
links.push({
match: match[0],
urlPath: match[1],
operationId: match[2],
});
}
return links;
}
/**
* Find the best matching URL prefix for a given URL path
* Also handles /version/ placeholders in shared content
*/
function findUrlPrefix(urlPath, lookup) {
// Sort by length descending to match most specific first
const prefixes = Object.keys(lookup).sort((a, b) => b.length - a.length);
for (const prefix of prefixes) {
if (urlPath.startsWith(prefix) || urlPath === prefix.slice(0, -1)) {
return prefix;
}
}
// Check version placeholder mappings for shared content
for (const { pattern, lookupPrefix } of VERSION_PLACEHOLDER_MAPPINGS) {
if (pattern.test(urlPath)) {
if (VERBOSE) {
console.log(` Mapped ${urlPath}${lookupPrefix} (version placeholder)`);
}
return lookupPrefix;
}
}
return null;
}
/**
* Scan content directory for files with #operation/ links
*/
async function scanContentFiles(lookup) {
console.log('Scanning content files for #operation/ links...\n');
const files = await glob('**/*.md', { cwd: CONTENT_DIR });
const results = {
filesWithLinks: [],
totalLinks: 0,
unmapped: [],
};
for (const file of files) {
const filePath = path.join(CONTENT_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const links = findOperationLinks(content);
if (links.length > 0) {
const fileResult = {
file,
links: [],
};
for (const link of links) {
const urlPrefix = findUrlPrefix(link.urlPath, lookup);
if (!urlPrefix) {
results.unmapped.push({ file, ...link, reason: 'No matching URL prefix' });
continue;
}
const productLookup = lookup[urlPrefix];
const anchor = productLookup[link.operationId];
if (!anchor) {
results.unmapped.push({ file, ...link, reason: 'OperationId not found in spec' });
continue;
}
fileResult.links.push({
...link,
urlPrefix,
newAnchor: anchor,
oldLink: `${link.urlPath}#operation/${link.operationId}`,
newLink: `${link.urlPath}#${anchor}`,
});
}
if (fileResult.links.length > 0) {
results.filesWithLinks.push(fileResult);
results.totalLinks += fileResult.links.length;
}
}
}
return results;
}
/**
* Replace operation links in a file
* Returns the modified content
*/
function replaceLinks(content, links) {
let modified = content;
for (const link of links) {
// Replace all occurrences of this specific link
modified = modified.split(link.oldLink).join(link.newLink);
}
return modified;
}
/**
* Apply migrations to files
*/
async function applyMigrations(results) {
console.log('\n=== APPLYING MIGRATIONS ===\n');
let filesModified = 0;
let linksReplaced = 0;
for (const { file, links } of results.filesWithLinks) {
const filePath = path.join(CONTENT_DIR, file);
const originalContent = fs.readFileSync(filePath, 'utf8');
const modifiedContent = replaceLinks(originalContent, links);
if (originalContent !== modifiedContent) {
fs.writeFileSync(filePath, modifiedContent, 'utf8');
filesModified++;
linksReplaced += links.length;
console.log(`${file} (${links.length} links)`);
}
}
console.log(`\nMigration complete: ${filesModified} files modified, ${linksReplaced} links replaced.`);
}
async function main() {
console.log(`API Link Migration Script`);
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (no changes)' : 'EXECUTE'}\n`);
// Build lookup table
const lookupTable = buildLookupTable();
// Scan content files
const results = await scanContentFiles(lookupTable);
// Report findings
console.log('=== SCAN RESULTS ===\n');
console.log(`Files with links: ${results.filesWithLinks.length}`);
console.log(`Total links to migrate: ${results.totalLinks}`);
console.log(`Unmapped links: ${results.unmapped.length}\n`);
if (VERBOSE && results.filesWithLinks.length > 0) {
console.log('Links to migrate:');
for (const { file, links } of results.filesWithLinks) {
console.log(`\n ${file}:`);
for (const link of links) {
console.log(` ${link.oldLink}`);
console.log(`${link.newLink}`);
}
}
}
if (results.unmapped.length > 0) {
console.log('\n=== UNMAPPED LINKS (require manual review) ===\n');
for (const item of results.unmapped) {
console.log(` ${item.file}:`);
console.log(` ${item.match}`);
console.log(` Reason: ${item.reason}\n`);
}
}
// Apply migrations if not dry-run
if (DRY_RUN) {
console.log('\n[DRY RUN] No files modified. Run without --dry-run to apply changes.');
} else if (results.filesWithLinks.length > 0) {
await applyMigrations(results);
} else {
console.log('\nNo links to migrate.');
}
}
main().catch(console.error);