docs-v2/scripts/docs-cli/lib/product-resolver.js

286 lines
8.2 KiB
JavaScript

/**
* Product resolution module
*
* Centralizes logic for resolving product identifiers (keys, paths, URLs)
* to canonical product keys.
*/
import { DEFAULT_CONFIG } from './defaults.js';
/**
* Mapping from content paths to product keys.
* Paths are normalized (no leading/trailing slashes, no 'content/' prefix).
*/
const PATH_TO_PRODUCT_KEY = {
// InfluxDB 3 products
'influxdb3/core': 'influxdb3_core',
'influxdb3/enterprise': 'influxdb3_enterprise',
'influxdb3/cloud-serverless': 'influxdb3_cloud_serverless',
'influxdb3/cloud-dedicated': 'influxdb3_cloud_dedicated',
'influxdb3/clustered': 'influxdb3_clustered',
'influxdb3/explorer': 'influxdb3_explorer',
// InfluxDB OSS (v1/v2) - versioned paths
'influxdb/v1': 'influxdb',
'influxdb/v2': 'influxdb',
'influxdb/cloud': 'influxdb_cloud',
// Telegraf - versioned paths
telegraf: 'telegraf',
// Other tools
chronograf: 'chronograf',
kapacitor: 'kapacitor',
enterprise_influxdb: 'enterprise_influxdb',
flux: 'flux',
};
/**
* Get all valid product keys from defaults.js
* @returns {Set<string>} Set of valid product keys
*/
export function getValidProductKeys() {
return new Set(Object.keys(DEFAULT_CONFIG.repositories));
}
/**
* Normalize a path by removing common prefixes and cleaning up.
* @param {string} input - Raw path input
* @returns {string} Normalized path (e.g., "influxdb3/core")
*/
function normalizePath(input) {
let path = input.trim();
// Remove URL components if present
// e.g., "https://docs.influxdata.com/influxdb3/core/admin/..." -> "/influxdb3/core/admin/..."
if (path.includes('docs.influxdata.com')) {
const match = path.match(/docs\.influxdata\.com(\/[^?#]*)/);
if (match) {
path = match[1];
}
}
// Remove leading "content/" prefix
path = path.replace(/^content\//, '');
// Remove leading and trailing slashes
path = path.replace(/^\/+/, '').replace(/\/+$/, '');
// Extract just the product portion (first 1-2 segments for most products)
const segments = path.split('/');
// Handle telegraf with version (telegraf/v1.33 -> telegraf)
if (segments[0] === 'telegraf') {
// Check if second segment is a version
if (segments[1] && /^v\d/.test(segments[1])) {
return 'telegraf';
}
return 'telegraf';
}
// Handle influxdb3 products (influxdb3/core, influxdb3/enterprise, etc.)
if (segments[0] === 'influxdb3' && segments[1]) {
return `influxdb3/${segments[1]}`;
}
// Handle influxdb with version (influxdb/v2, influxdb/cloud)
if (segments[0] === 'influxdb' && segments[1]) {
return `influxdb/${segments[1]}`;
}
// Handle other single-segment products
if (segments[0]) {
return segments[0];
}
return path;
}
/**
* Find similar product keys for error suggestions.
* @param {string} input - The invalid input
* @returns {Array<{key: string, path: string, description: string}>} Similar products
*/
function findSimilarProducts(input) {
const normalized = input.toLowerCase().replace(/[_-]/g, '');
const suggestions = [];
for (const [key, config] of Object.entries(DEFAULT_CONFIG.repositories)) {
const keyNormalized = key.toLowerCase().replace(/[_-]/g, '');
// Check if input is a substring or similar
if (
keyNormalized.includes(normalized) ||
normalized.includes(keyNormalized)
) {
// Find the path that maps to this key
const path = Object.entries(PATH_TO_PRODUCT_KEY).find(
([, v]) => v === key
)?.[0];
suggestions.push({
key,
path: path ? `/${path}/` : null,
description: config.description,
});
}
}
return suggestions.slice(0, 5); // Limit to 5 suggestions
}
/**
* Resolve a single product identifier to a canonical product key.
*
* @param {string} input - Product key, content path, or URL
* @returns {{ key: string, contentPath: string | null }} Resolved product info
* @throws {Error} If input cannot be resolved
*
* @example
* resolveProduct('influxdb3_core')
* // => { key: 'influxdb3_core', contentPath: 'influxdb3/core' }
*
* resolveProduct('/influxdb3/core')
* // => { key: 'influxdb3_core', contentPath: 'influxdb3/core' }
*
* resolveProduct('https://docs.influxdata.com/influxdb3/core/admin/')
* // => { key: 'influxdb3_core', contentPath: 'influxdb3/core' }
*/
export function resolveProduct(input) {
if (!input || typeof input !== 'string') {
throw new Error('Product identifier is required');
}
const trimmed = input.trim();
const validKeys = getValidProductKeys();
// 1. Check if exact product key match
if (validKeys.has(trimmed)) {
// Find the content path for this key
const contentPath =
Object.entries(PATH_TO_PRODUCT_KEY).find(([, v]) => v === trimmed)?.[0] ||
null;
return { key: trimmed, contentPath };
}
// 2. Try to parse as path/URL and extract product key
const normalizedPath = normalizePath(trimmed);
if (PATH_TO_PRODUCT_KEY[normalizedPath]) {
return {
key: PATH_TO_PRODUCT_KEY[normalizedPath],
contentPath: normalizedPath,
};
}
// 3. Could not resolve - throw helpful error
const suggestions = findSimilarProducts(trimmed);
let errorMessage = `Could not resolve product identifier '${trimmed}'`;
if (suggestions.length > 0) {
errorMessage += '\n\nDid you mean one of these?';
for (const { key, path, description } of suggestions) {
const pathHint = path ? `${path}` : '';
errorMessage += `\n ${key}${pathHint} (${description})`;
}
} else {
errorMessage += '\n\nValid product keys:';
const keys = Array.from(validKeys).slice(0, 10);
for (const key of keys) {
errorMessage += `\n ${key}`;
}
if (validKeys.size > 10) {
errorMessage += `\n ... and ${validKeys.size - 10} more`;
}
}
throw new Error(errorMessage);
}
/**
* Resolve multiple product identifiers from a comma-separated string.
*
* @param {string} input - Comma-separated product identifiers
* @returns {Array<{ key: string, contentPath: string | null }>} Array of resolved products
* @throws {Error} If any input cannot be resolved
*
* @example
* resolveProducts('influxdb3_core,influxdb3_enterprise')
* // => [
* // { key: 'influxdb3_core', contentPath: 'influxdb3/core' },
* // { key: 'influxdb3_enterprise', contentPath: 'influxdb3/enterprise' }
* // ]
*
* resolveProducts('/influxdb3/core,/influxdb3/enterprise')
* // => [
* // { key: 'influxdb3_core', contentPath: 'influxdb3/core' },
* // { key: 'influxdb3_enterprise', contentPath: 'influxdb3/enterprise' }
* // ]
*/
export function resolveProducts(input) {
if (!input || typeof input !== 'string') {
throw new Error('Product identifiers are required');
}
const identifiers = input
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (identifiers.length === 0) {
throw new Error('At least one product identifier is required');
}
return identifiers.map((identifier) => resolveProduct(identifier));
}
/**
* Get the content path for a product key.
*
* @param {string} key - Product key
* @returns {string | null} Content path or null if not found
*/
export function getContentPath(key) {
return (
Object.entries(PATH_TO_PRODUCT_KEY).find(([, v]) => v === key)?.[0] || null
);
}
/**
* Get product info including description from defaults.
*
* @param {string} key - Product key
* @returns {{ key: string, contentPath: string | null, description: string } | null}
*/
export function getProductInfo(key) {
const config = DEFAULT_CONFIG.repositories[key];
if (!config) {
return null;
}
return {
key,
contentPath: getContentPath(key),
description: config.description,
url: config.url || null,
};
}
/**
* Validate that --products and --repos are mutually exclusive.
* Exits with error if both are provided.
*
* @param {object} options - Parsed command options
* @param {string} [options.products] - Products flag value
* @param {string} [options.repos] - Repos flag value
*/
export function validateMutualExclusion(options) {
if (options.products && options.repos) {
console.error('Error: --products and --repos are mutually exclusive');
console.error(
'Use --products for product keys/paths, or --repos for direct repository paths'
);
process.exit(1);
}
}