@ai-growth/nextjs
Version:
Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering
806 lines (805 loc) • 27.6 kB
JavaScript
import { getCmsRoutePath } from './config';
/**
* Default route patterns for common CMS content types with advanced matching
*/
const DEFAULT_ROUTE_PATTERNS = [
// Blog posts with category support
{
pattern: '^/blog/([\\w-]+)/([\\w-]+)/?$',
contentType: 'post',
priority: 150,
enabled: true,
parameterConfig: {
paramNames: ['category', 'slug'],
validation: {
category: { type: 'category', required: true, minLength: 2, maxLength: 50 },
slug: { type: 'slug', required: true, minLength: 3, maxLength: 100 },
},
},
queryConfig: {
allowedParams: ['author', 'tags', 'limit', 'format'],
filters: {
author: { type: 'string', pattern: '^[\\w-]+$' },
tags: { type: 'array', itemType: 'string', separator: ',' },
limit: { type: 'number', default: 10 },
format: { type: 'string', allowedValues: ['full', 'summary', 'minimal'] },
},
},
},
// Simple blog posts
{
pattern: '^/blog/([\\w-]+)/?$',
contentType: 'post',
priority: 100,
enabled: true,
parameterConfig: {
paramNames: ['slug'],
validation: {
slug: { type: 'slug', required: true, minLength: 3, maxLength: 100 },
},
},
},
// Nested documentation with multiple levels
{
pattern: '^/docs/(.+)/?$',
contentType: 'page',
priority: 120,
enabled: true,
supportsNesting: true,
parameterConfig: {
nested: {
separator: '/',
maxDepth: 5,
levelNames: ['section', 'subsection', 'page', 'subpage', 'detail'],
typeMapping: {
1: 'section',
2: 'subsection',
3: 'page',
4: 'subpage',
5: 'detail',
},
},
},
queryConfig: {
allowedParams: ['version', 'lang', 'format'],
filters: {
version: { type: 'string', pattern: '^[0-9]+\\.[0-9]+$' },
lang: { type: 'string', allowedValues: ['en', 'pt', 'es', 'fr'] },
format: { type: 'string', allowedValues: ['html', 'markdown', 'json'] },
},
},
},
// Simple documentation
{
pattern: '^/docs/([\\w-]+)/?$',
contentType: 'page',
priority: 90,
enabled: true,
parameterConfig: {
paramNames: ['slug'],
},
},
// Homepage
{
pattern: '^/?$',
contentType: 'page',
priority: 50,
enabled: true,
},
// Catch-all for simple pages
{
pattern: '^/([\\w-]+)/?$',
contentType: 'page',
priority: 10,
enabled: true,
parameterConfig: {
paramNames: ['slug'],
},
},
];
/**
* Default paths to exclude from CMS routing
*/
const DEFAULT_EXCLUDE_PATHS = [
'/api',
'/api/*',
'/_next',
'/_next/*',
'/_vercel',
'/_vercel/*',
'/favicon.ico',
'/robots.txt',
'/sitemap.xml',
'/manifest.json',
'/.well-known',
'/.well-known/*',
'/static',
'/static/*',
'/public',
'/public/*',
'/images',
'/images/*',
'/assets',
'/assets/*',
];
/**
* Default CMS route configuration
*/
const DEFAULT_CMS_ROUTE_CONFIG = {
enabled: true,
basePath: '/cms',
patterns: DEFAULT_ROUTE_PATTERNS,
excludePaths: DEFAULT_EXCLUDE_PATHS,
defaultContentType: 'page',
strictMatching: false,
};
/**
* Cache for route configuration to avoid repeated computation
*/
let routeConfigCache = null;
/**
* Get the current CMS route configuration
*
* @param options Configuration options
* @param options.useCache Whether to use cached configuration (default: true)
* @param options.override Custom configuration override
* @returns Current route configuration
*
* @example
* ```typescript
* const routeConfig = getRouteConfig();
* console.log(routeConfig.basePath); // '/cms'
*
* // With custom override
* const customConfig = getRouteConfig({
* override: { basePath: '/blog' }
* });
* ```
*/
export function getRouteConfig(options = {}) {
const { useCache = true, override } = options;
// Return cached configuration if available and no override
if (useCache && routeConfigCache && !override) {
return routeConfigCache;
}
try {
// Get base path from environment configuration
const cmsRoutePath = getCmsRoutePath();
// Build configuration from defaults and environment
const config = {
...DEFAULT_CMS_ROUTE_CONFIG,
basePath: cmsRoutePath.replace(/\/$/, ''), // Remove trailing slash
...override,
};
// Cache the configuration
if (useCache && !override) {
routeConfigCache = config;
}
return config;
}
catch (error) {
// Fallback to default configuration if environment config fails
console.warn('Failed to load route configuration from environment, using defaults:', error);
return {
...DEFAULT_CMS_ROUTE_CONFIG,
...override,
};
}
}
/**
* Clear the route configuration cache
*/
export function clearRouteConfigCache() {
routeConfigCache = null;
}
/**
* Check if a given path should be handled by the CMS router
*
* @param path The URL path to check
* @param config Optional custom route configuration
* @returns True if the path should be handled by CMS
*
* @example
* ```typescript
* isValidCmsRoute('/blog/my-post'); // true
* isValidCmsRoute('/api/users'); // false
* isValidCmsRoute('/_next/static/css/app.css'); // false
* ```
*/
export function isValidCmsRoute(path, config) {
const routeConfig = config || getRouteConfig();
// Check if CMS routing is enabled
if (!routeConfig.enabled) {
return false;
}
// Normalize path (remove query string and hash)
const normalizedPath = normalizePath(path);
// Check if path is in exclude list
if (isExcludedPath(normalizedPath, routeConfig.excludePaths)) {
return false;
}
// Check if path matches any route patterns
return matchesAnyPattern(normalizedPath, routeConfig.patterns);
}
/**
* Enhanced version of extractRouteInfo with advanced pattern matching
*
* @param path The URL path to parse (can include query string)
* @param config Optional custom route configuration
* @returns Detailed route match information with parameters and validation
*/
export function extractRouteInfo(path, config) {
const routeConfig = config || getRouteConfig();
// Parse URL to separate path and query string
const url = new URL(path, 'http://localhost');
const pathname = url.pathname;
const searchParams = url.searchParams;
// Check if this is a valid CMS route first
if (!isValidCmsRoute(pathname, routeConfig)) {
return { matched: false };
}
const normalizedPath = normalizePath(pathname);
// Sort patterns by priority (highest first)
const sortedPatterns = [...routeConfig.patterns]
.filter(pattern => pattern.enabled !== false)
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Try to match against each pattern
for (const pattern of sortedPatterns) {
try {
const regex = new RegExp(pattern.pattern);
const match = normalizedPath.match(regex);
if (match) {
// Extract basic parameters
const params = extractParameters(match, pattern.parameterConfig);
// Extract query parameters
const queryParams = extractQueryParameters(searchParams, pattern.queryConfig);
// Handle nested parameters if pattern supports nesting
const nestedParams = pattern.supportsNesting
? extractNestedParameters(match, pattern.parameterConfig)
: undefined;
// Validate all parameters
const validation = validateRouteParameters({
params,
queryParams,
...(nestedParams && { nestedParams }),
pattern,
});
// Determine slug and content type
let slug = '';
let contentType = pattern.contentType;
if (nestedParams && nestedParams.leaf) {
slug = nestedParams.leaf.value;
contentType = nestedParams.leaf.contentType || pattern.contentType;
}
else if (params.slug) {
slug = params.slug;
}
else if (match[1] !== undefined) {
slug = match[1];
}
return {
matched: true,
slug,
contentType,
pattern,
params,
queryParams,
...(nestedParams && { nestedParams }),
validation,
};
}
}
catch {
// Skip invalid regex patterns
continue;
}
}
// No pattern matched
return {
matched: false,
params: {},
queryParams: {},
};
}
/**
* Extract parameters from route match using parameter configuration
*/
function extractParameters(match, paramConfig) {
const params = {};
if (!paramConfig?.paramNames) {
// Fallback to numbered capture groups
for (let i = 1; i < match.length; i++) {
if (match[i] !== undefined) {
params[`param${i}`] = match[i];
}
}
return params;
}
// Map capture groups to named parameters
paramConfig.paramNames.forEach((name, index) => {
const captureIndex = index + 1;
if (match[captureIndex] !== undefined) {
params[name] = match[captureIndex];
}
});
return params;
}
/**
* Extract and validate query parameters
*/
function extractQueryParameters(searchParams, queryConfig) {
const queryParams = {};
if (!queryConfig) {
// Return all parameters as strings if no configuration
for (const [key, value] of searchParams.entries()) {
queryParams[key] = value;
}
return queryParams;
}
// Process configured parameters
if (queryConfig.allowedParams) {
for (const paramName of queryConfig.allowedParams) {
const filter = queryConfig.filters?.[paramName];
const rawValue = searchParams.get(paramName);
if (rawValue !== null) {
queryParams[paramName] = transformQueryParameter(rawValue, filter);
}
else if (filter?.default !== undefined) {
queryParams[paramName] = filter.default;
}
}
}
// Handle unknown parameters if preservation is enabled
if (queryConfig.preserveUnknown) {
for (const [key, value] of searchParams.entries()) {
if (!queryConfig.allowedParams?.includes(key)) {
queryParams[key] = value;
}
}
}
return queryParams;
}
/**
* Transform query parameter value based on filter configuration
*/
function transformQueryParameter(value, filter) {
if (!filter)
return value;
switch (filter.type) {
case 'number': {
const num = parseInt(value, 10);
return isNaN(num) ? filter.default : num;
}
case 'boolean':
return value.toLowerCase() === 'true' || value === '1';
case 'array': {
const separator = filter.separator || ',';
const items = value.split(separator).map(item => item.trim());
if (filter.itemType === 'number') {
return items.map(item => parseInt(item, 10)).filter(num => !isNaN(num));
}
return items.filter(item => item.length > 0);
}
default:
return value;
}
}
/**
* Extract nested parameters for hierarchical content structures
*/
function extractNestedParameters(match, paramConfig) {
if (!paramConfig?.nested || match.length < 2) {
return undefined;
}
const fullPath = match[1]; // Assumes first capture group contains the nested path
const separator = paramConfig.nested.separator || '/';
const segments = fullPath.split(separator).filter(seg => seg.length > 0);
const maxDepth = paramConfig.nested.maxDepth || segments.length;
const actualDepth = Math.min(segments.length, maxDepth);
const levels = segments.slice(0, actualDepth).map((value, index) => {
const levelNames = paramConfig.nested?.levelNames || [];
const typeMapping = paramConfig.nested?.typeMapping || {};
return {
name: levelNames[index] || `level${index + 1}`,
value,
contentType: typeMapping[index + 1],
index,
};
});
return {
levels,
segments,
depth: actualDepth,
leaf: levels[levels.length - 1],
};
}
/**
* Validate route parameters against their configuration
*/
function validateRouteParameters({ params, queryParams, nestedParams, pattern, }) {
const results = {
valid: true,
parameters: {},
queryParameters: {},
errors: [],
warnings: [],
};
// Validate route parameters
if (pattern.parameterConfig?.validation) {
for (const [paramName, validation] of Object.entries(pattern.parameterConfig.validation)) {
const value = params[paramName];
const result = validateSingleParameter(value, validation, paramName);
results.parameters[paramName] = result;
if (!result.valid) {
results.valid = false;
results.errors.push(...result.errors);
}
}
}
// Validate query parameters
if (pattern.queryConfig?.filters) {
for (const [paramName, filter] of Object.entries(pattern.queryConfig.filters)) {
const value = queryParams[paramName];
const result = validateQueryParameter(value, filter, paramName);
results.queryParameters[paramName] = result;
if (!result.valid) {
results.valid = false;
results.errors.push(...result.errors);
}
}
}
// Validate nested parameters
if (nestedParams && pattern.parameterConfig?.nested) {
const maxDepth = pattern.parameterConfig.nested.maxDepth;
if (maxDepth && nestedParams.depth > maxDepth) {
results.valid = false;
results.errors.push(`Nesting depth ${nestedParams.depth} exceeds maximum ${maxDepth}`);
}
}
return results;
}
/**
* Validate a single route parameter
*/
function validateSingleParameter(value, validation, paramName) {
const result = {
valid: true,
originalValue: value || '',
transformedValue: value,
errors: [],
type: validation.type || 'string',
};
// Check if required parameter is missing
if (validation.required && !value) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' is required`);
return result;
}
if (!value) {
return result; // Optional parameter not provided
}
// Length validation
if (validation.minLength && value.length < validation.minLength) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' is too short (minimum ${validation.minLength})`);
}
if (validation.maxLength && value.length > validation.maxLength) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' is too long (maximum ${validation.maxLength})`);
}
// Pattern validation
if (validation.pattern) {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' does not match required pattern`);
}
}
// Type-specific validation
switch (validation.type) {
case 'slug':
if (!/^[a-z0-9-]+$/.test(value)) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' must be a valid slug (lowercase, numbers, hyphens)`);
}
break;
case 'category':
if (!/^[a-zA-Z0-9-_]+$/.test(value)) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' must be a valid category name`);
}
break;
case 'number':
if (!/^\d+$/.test(value)) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' must be a number`);
}
else {
result.transformedValue = parseInt(value, 10);
}
break;
}
// Allowed values validation
if (validation.allowedValues && !validation.allowedValues.includes(value)) {
result.valid = false;
result.errors.push(`Parameter '${paramName}' must be one of: ${validation.allowedValues.join(', ')}`);
}
return result;
}
/**
* Validate a query parameter
*/
function validateQueryParameter(value, filter, paramName) {
const result = {
valid: true,
originalValue: String(value || ''),
transformedValue: value,
errors: [],
type: filter.type,
};
// Check required parameters
if (filter.required && (value === undefined || value === null)) {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' is required`);
return result;
}
if (value === undefined || value === null) {
return result; // Optional parameter
}
// Type-specific validation
switch (filter.type) {
case 'string':
if (filter.pattern) {
const regex = new RegExp(filter.pattern);
if (!regex.test(String(value))) {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' does not match required pattern`);
}
}
break;
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' must be a number`);
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' must be a boolean`);
}
break;
case 'array':
if (!Array.isArray(value)) {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' must be an array`);
}
break;
}
// Allowed values validation
if (filter.allowedValues && !filter.allowedValues.includes(String(value))) {
result.valid = false;
result.errors.push(`Query parameter '${paramName}' must be one of: ${filter.allowedValues.join(', ')}`);
}
return result;
}
/**
* Build a route path from parameters and configuration
*
* @param contentType The content type to build a route for
* @param params Route parameters
* @param queryParams Optional query parameters
* @param config Optional route configuration
* @returns Built route path or null if no matching pattern
*/
export function buildRoutePath(contentType, params, queryParams, config) {
const routeConfig = config || getRouteConfig();
// Find pattern for content type
const pattern = routeConfig.patterns
.filter(p => p.enabled !== false && p.contentType === contentType)
.sort((a, b) => (b.priority || 0) - (a.priority || 0))[0];
if (!pattern || !pattern.parameterConfig?.paramNames) {
return null;
}
// Build path from pattern
let path = pattern.pattern;
// Replace capture groups with parameter values
pattern.parameterConfig.paramNames.forEach(paramName => {
const value = params[paramName];
if (value) {
// Replace the corresponding capture group pattern with the actual value
const capturePattern = `([\\w-]+)`;
path = path.replace(capturePattern, value);
}
});
// Clean up regex syntax
path = path.replace(/^\^/, '').replace(/\$.*$/, '');
// Add query parameters if provided
if (queryParams && Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
if (queryString) {
path += `?${queryString}`;
}
}
return path;
}
/**
* Get all available route patterns with their configurations
*
* @param config Optional route configuration
* @returns Array of configured route patterns
*/
export function getAdvancedRoutePatterns(config) {
const routeConfig = config || getRouteConfig();
return routeConfig.patterns
.filter(pattern => pattern.enabled !== false)
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Validate route configuration including advanced pattern features
*/
export function validateAdvancedRouteConfig(config) {
const baseValidation = validateRouteConfig(config);
const errors = [...baseValidation.errors];
const warnings = [...baseValidation.warnings];
// Validate advanced pattern features
config.patterns.forEach((pattern, _index) => {
// Validate parameter configuration
if (pattern.parameterConfig) {
const paramConfig = pattern.parameterConfig;
// Check parameter names vs regex capture groups
if (paramConfig.paramNames) {
const regex = new RegExp(pattern.pattern);
const testMatch = regex.exec('/test/example/path');
const captureGroupCount = (testMatch?.length || 1) - 1;
if (paramConfig.paramNames.length > captureGroupCount) {
warnings.push(`Pattern ${_index}: More parameter names than capture groups`);
}
}
// Validate nested configuration
if (paramConfig.nested) {
if (paramConfig.nested.maxDepth && paramConfig.nested.maxDepth < 1) {
errors.push(`Pattern ${_index}: Invalid nested maxDepth (must be >= 1)`);
}
}
}
// Validate query configuration
if (pattern.queryConfig?.filters) {
for (const [paramName, filter] of Object.entries(pattern.queryConfig.filters)) {
if (filter.type === 'array' &&
filter.itemType &&
!['string', 'number'].includes(filter.itemType)) {
errors.push(`Pattern ${_index}: Invalid itemType '${filter.itemType}' for array parameter '${paramName}'`);
}
}
}
});
return {
valid: errors.length === 0,
errors,
warnings,
};
}
// Utility functions
/**
* Normalize a URL path by removing query string, hash, and trailing slashes
*/
function normalizePath(path) {
// Remove query string and hash
const cleanPath = path.split('?')[0].split('#')[0];
// Remove trailing slash (except for root)
return cleanPath === '/' ? cleanPath : cleanPath.replace(/\/$/, '');
}
/**
* Check if a path matches any exclude pattern
*/
function isExcludedPath(path, excludePaths) {
return excludePaths.some(excludePattern => {
if (excludePattern.endsWith('*')) {
// Handle wildcard patterns
const prefix = excludePattern.slice(0, -1);
return path.startsWith(prefix);
}
else {
// Exact match
return path === excludePattern;
}
});
}
/**
* Check if a path matches any route pattern
*/
function matchesAnyPattern(path, patterns) {
return patterns
.filter(pattern => pattern.enabled !== false)
.some(pattern => {
const regex = new RegExp(pattern.pattern);
return regex.test(path);
});
}
/**
* Validate route configuration
*
* @param config Route configuration to validate
* @returns Validation result with any errors
*/
export function validateRouteConfig(config) {
const errors = [];
const warnings = [];
// Check basic structure
if (!config.basePath.startsWith('/')) {
errors.push('basePath must start with "/"');
}
if (config.patterns.length === 0) {
warnings.push('No route patterns defined');
}
// Validate patterns
config.patterns.forEach((pattern, _index) => {
try {
new RegExp(pattern.pattern);
}
catch {
errors.push(`Invalid regex pattern at index ${_index}: ${pattern.pattern}`);
}
if (!pattern.contentType) {
errors.push(`Missing contentType for pattern at index ${_index}`);
}
});
// Check for duplicate patterns
const patternStrings = config.patterns.map(p => p.pattern);
const uniquePatterns = new Set(patternStrings);
if (patternStrings.length !== uniquePatterns.size) {
warnings.push('Duplicate route patterns detected');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
// ============================================================================
// LEGACY COMPATIBILITY FUNCTIONS
// ============================================================================
/**
* Extract slug from a CMS route path (legacy compatibility function)
* @param path The URL path to extract slug from
* @param config Optional route configuration
* @returns The extracted slug or null if invalid route
*/
export function extractSlugFromPath(path, config) {
const routeInfo = extractRouteInfo(path, config);
return routeInfo.matched ? routeInfo.slug || null : null;
}
/**
* Get content type from a CMS route path (legacy compatibility function)
* @param path The URL path to get content type from
* @param config Optional route configuration
* @returns The content type or null if invalid route
*/
export function getContentTypeFromPath(path, config) {
const routeInfo = extractRouteInfo(path, config);
return routeInfo.matched ? routeInfo.contentType || null : null;
}
/**
* Check multiple paths for CMS route validity (legacy compatibility function)
* @param paths Array of paths to check
* @param config Optional route configuration
* @returns Array of boolean values indicating validity
*/
export function areValidCmsRoutes(paths, config) {
return paths.map(path => isValidCmsRoute(path, config));
}
/**
* Get enabled route patterns (legacy compatibility function)
* @param config Optional route configuration
* @returns Array of enabled route patterns
*/
export function getRoutePatterns(config) {
return getAdvancedRoutePatterns(config);
}