UNPKG

@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
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); }