@signalwire/docusaurus-plugin-llms-txt
Version:
Generate Markdown versions of Docusaurus HTML pages and an llms.txt index file
161 lines (160 loc) • 6.47 kB
JavaScript
/**
* Route rules engine
* Handles route rule matching, validation, and application
*/
import { createMatcher } from '@docusaurus/utils';
// import { VALIDATION_MESSAGES } from '../constants'; // Currently unused
import { createConfigError } from '../errors';
import { ensureLeadingSlash } from '../utils';
/**
* Find the most specific matching route rule
*/
export function findMostSpecificRule(path, routeRules) {
if (!routeRules.length) {
return null;
}
const normalizedPath = ensureLeadingSlash(path);
// Find all matching rules
const matchingRules = routeRules.filter((rule) => {
const matcher = createMatcher([rule.route]);
return matcher(normalizedPath);
});
if (matchingRules.length === 0) {
return null;
}
// Sort by specificity (longer paths are more specific)
const sortedRules = matchingRules.sort((a, b) => {
const aPath = a.route.replace(/\/\*\*$/, '');
const bPath = b.route.replace(/\/\*\*$/, '');
return bPath.length - aPath.length;
});
return sortedRules[0] ?? null;
}
/**
* Check if a path exactly matches the base path of a route rule
* Only applies categoryName to the exact base path, not subcategories
*/
function isExactBasePath(path, rule) {
if (!rule.categoryName) {
return false; // No categoryName to apply
}
// Extract base path from rule (remove /** suffix)
const ruleBasePath = rule.route.replace(/\/\*\*$/, '');
// Check if current path exactly matches the rule's base path
return path === ruleBasePath;
}
/**
* Apply route rule to create effective configuration
*/
export function applyRouteRule(rule, baseConfig, contentConfig, path, routeSegment) {
const includeOrder = rule?.includeOrder ?? baseConfig.includeOrder ?? [];
// CategoryName: only apply if this is the exact base path of the rule
// Otherwise use routeSegment fallback (preserves subcategory names)
const categoryName = rule && isExactBasePath(path, rule) ? rule.categoryName : routeSegment;
const effectiveConfig = {
...baseConfig,
includeOrder,
content: contentConfig,
path,
// Apply rule-specific overrides if rule exists, with proper fallbacks
...(rule?.depth !== undefined && { depth: rule.depth }),
...(categoryName !== undefined && { categoryName }),
};
return effectiveConfig;
}
/**
* Validate route rules for conflicts and throw errors for true conflicts
*/
export function validateRouteRules(routeRules) {
if (routeRules.length === 0) {
return;
}
const conflicts = findRouteRuleConflicts(routeRules);
// Throw errors for true conflicts (same route pattern with conflicting properties)
conflicts.forEach((conflict) => {
const conflictMessages = [];
if (conflict.categories.length > 1) {
conflictMessages.push(`categoryName: [${conflict.categories.join(', ')}]`);
}
if (conflict.depths.length > 1) {
conflictMessages.push(`depth: [${conflict.depths.join(', ')}]`);
}
if (conflict.includeOrders > 1) {
conflictMessages.push(`includeOrder: ${conflict.includeOrders} different definitions`);
}
if (conflict.contentSelectors > 1) {
conflictMessages.push(`contentSelectors: ${conflict.contentSelectors} different definitions`);
}
if (conflictMessages.length > 0) {
const errorMessage = `Route rule conflict detected for pattern "${conflict.route}". Multiple conflicting values found for: ${conflictMessages.join(', ')}. Each route pattern should have only one value for each property. Please consolidate or use more specific route patterns.`;
throw createConfigError(errorMessage, {
conflictingRoute: conflict.route,
conflictDetails: {
categories: conflict.categories,
depths: conflict.depths,
includeOrders: conflict.includeOrders,
contentSelectors: conflict.contentSelectors,
},
suggestion: 'Use more specific route patterns (e.g., "/api/v1/**" vs "/api/v2/**") or consolidate conflicting rules into a single rule definition.',
});
}
});
}
/**
* Find conflicts between route rules - enhanced to detect all property conflicts
*/
function findRouteRuleConflicts(routeRules) {
const routeMap = new Map();
// Group rules by route pattern
routeRules.forEach((rule) => {
if (!routeMap.has(rule.route)) {
routeMap.set(rule.route, []);
}
const routeRules = routeMap.get(rule.route);
if (routeRules) {
routeRules.push(rule);
}
});
const conflicts = [];
// Check for conflicts within each route group
routeMap.forEach((rulesForRoute, route) => {
if (rulesForRoute.length <= 1) {
return;
}
// Check categoryName conflicts
const categories = rulesForRoute
.map((r) => r.categoryName)
.filter((name) => Boolean(name));
const uniqueCategories = [...new Set(categories)];
// Check depth conflicts
const depths = rulesForRoute
.map((r) => r.depth)
.filter((depth) => depth !== undefined);
const uniqueDepths = [...new Set(depths)];
// Check includeOrder conflicts
const includeOrders = rulesForRoute
.map((r) => r.includeOrder)
.filter(Boolean);
const uniqueIncludeOrders = includeOrders.length;
// Check contentSelectors conflicts
const contentSelectors = rulesForRoute
.map((r) => r.contentSelectors)
.filter(Boolean);
const uniqueContentSelectors = contentSelectors.length;
// Only report if there are actual conflicts (multiple different values for same property)
const hasConflicts = uniqueCategories.length > 1 ||
uniqueDepths.length > 1 ||
uniqueIncludeOrders > 1 ||
uniqueContentSelectors > 1;
if (hasConflicts) {
conflicts.push({
route,
categories: uniqueCategories,
depths: uniqueDepths,
includeOrders: uniqueIncludeOrders,
contentSelectors: uniqueContentSelectors,
});
}
});
return conflicts;
}