UNPKG

@ai-growth/nextjs

Version:

Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering

763 lines (762 loc) 26.6 kB
import { z } from 'zod'; /** * Schema for Sanity CMS configuration */ const sanityConfigSchema = z.object({ /** * Sanity project ID - unique identifier for your Sanity project * @example "abc123def" */ projectId: z.string().min(1, 'Sanity project ID is required'), /** * Sanity dataset name - typically 'production', 'development', or 'staging' * @example "production" */ dataset: z.string().min(1, 'Sanity dataset is required'), /** * Optional Sanity API token for authenticated requests * Required for write operations or accessing private datasets */ apiToken: z.string().optional(), /** * Sanity API version - determines which version of the API to use * @default "2023-05-03" */ apiVersion: z.string().default('2023-05-03'), }); /** * Schema for CMS routing configuration */ const cmsConfigSchema = z.object({ /** * Base route path for CMS pages * Must start and end with forward slashes * @default "/cms/" * @example "/blog/", "/content/" */ routePath: z .string() .default('/cms/') .refine(path => path.startsWith('/') && path.endsWith('/'), 'Route path must start and end with forward slashes (e.g., "/cms/")'), }); /** * Complete configuration schema for the AI Growth CMS library */ export const configSchema = z.object({ sanity: sanityConfigSchema, cms: cmsConfigSchema, }); /** * Schema for client-safe configuration (browser-accessible) * Only includes non-sensitive data that can be safely exposed to the client */ const clientConfigSchema = z.object({ cms: z.object({ routePath: z .string() .default('/cms/') .refine(path => path.startsWith('/') && path.endsWith('/'), { message: 'Route path must start and end with forward slashes', }), }), sanity: z.object({ projectId: z.string().optional(), dataset: z.string().optional(), apiVersion: z.string().default('2023-05-03'), }), }); /** * Environment variable names used by the configuration system */ export const ENV_VARS = { SANITY_PROJECT_ID: 'SANITY_PROJECT_ID', SANITY_DATASET: 'SANITY_DATASET', SANITY_API_TOKEN: 'SANITY_API_TOKEN', SANITY_API_VERSION: 'SANITY_API_VERSION', CMS_ROUTE_PATH: 'CMS_ROUTE_PATH', // Next.js public environment variables NEXT_PUBLIC_SANITY_PROJECT_ID: 'NEXT_PUBLIC_SANITY_PROJECT_ID', NEXT_PUBLIC_SANITY_DATASET: 'NEXT_PUBLIC_SANITY_DATASET', NEXT_PUBLIC_SANITY_API_VERSION: 'NEXT_PUBLIC_SANITY_API_VERSION', NEXT_PUBLIC_CMS_ROUTE_PATH: 'NEXT_PUBLIC_CMS_ROUTE_PATH', }; /** * Error class for configuration validation failures */ export class ConfigurationError extends Error { constructor(message, details) { super(message); this.details = details; this.name = 'ConfigurationError'; } } /** * Configuration cache to avoid re-parsing environment variables */ let configCache = null; /** * Loads and validates configuration from environment variables * * @param options Configuration loading options * @param options.useCache Whether to use cached configuration (default: true) * @param options.env Custom environment object (default: process.env) * @returns Validated configuration object * @throws ConfigurationError when validation fails * * @example * ```typescript * // Load configuration with caching * const config = getConfig(); * console.log(config.sanity.projectId); * * // Force reload without cache * const freshConfig = getConfig({ useCache: false }); * * // Use custom environment (useful for testing) * const testConfig = getConfig({ * env: { SANITY_PROJECT_ID: 'test', SANITY_DATASET: 'test' } * }); * ``` */ export function getConfig(options = {}) { const { useCache = true, env = process.env } = options; // Return cached configuration if available and caching is enabled if (useCache && configCache) { return configCache; } try { // Create configuration object from environment variables const rawConfig = { sanity: { projectId: env[ENV_VARS.SANITY_PROJECT_ID], dataset: env[ENV_VARS.SANITY_DATASET], apiToken: env[ENV_VARS.SANITY_API_TOKEN], apiVersion: env[ENV_VARS.SANITY_API_VERSION], }, cms: { routePath: env[ENV_VARS.CMS_ROUTE_PATH], }, }; // Validate configuration against schema const validatedConfig = configSchema.parse(rawConfig); // Cache the validated configuration if (useCache) { configCache = validatedConfig; } return validatedConfig; } catch (error) { if (error instanceof z.ZodError) { // Create a more helpful error message const missingFields = []; const invalidFields = []; error.issues.forEach(issue => { const field = issue.path.join('.'); if (issue.code === 'invalid_type' && issue.received === 'undefined') { missingFields.push(field); } else { invalidFields.push(`${field}: ${issue.message}`); } }); let errorMessage = 'Configuration validation failed:\n'; if (missingFields.length > 0) { errorMessage += `\nMissing required environment variables:\n`; missingFields.forEach(field => { const envVar = getEnvVarName(field); errorMessage += ` - ${envVar}\n`; }); } if (invalidFields.length > 0) { errorMessage += `\nInvalid configuration:\n`; invalidFields.forEach(field => { errorMessage += ` - ${field}\n`; }); } errorMessage += `\nPlease check your environment variables and ensure they are properly set.`; throw new ConfigurationError(errorMessage, error); } // Re-throw other errors throw error; } } /** * Helper function to map configuration field paths to environment variable names */ function getEnvVarName(fieldPath) { const mapping = { 'sanity.projectId': ENV_VARS.SANITY_PROJECT_ID, 'sanity.dataset': ENV_VARS.SANITY_DATASET, 'sanity.apiToken': ENV_VARS.SANITY_API_TOKEN, 'sanity.apiVersion': ENV_VARS.SANITY_API_VERSION, 'cms.routePath': ENV_VARS.CMS_ROUTE_PATH, }; return mapping[fieldPath] || fieldPath; } /** * Clears the configuration cache * Useful for testing or when environment variables change at runtime */ export function clearConfigCache() { configCache = null; } // ============================================================================= // Configuration Helper Functions // ============================================================================= /** * Gets the complete Sanity configuration object * * @param options Configuration loading options * @returns Sanity configuration object with projectId, dataset, apiToken, and apiVersion * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * const sanityConfig = getSanityConfig(); * console.log(sanityConfig.projectId, sanityConfig.dataset); * ``` */ export function getSanityConfig(options) { const config = getConfig(options); return config.sanity; } /** * Gets the CMS route path * * @param options Configuration loading options * @returns The base route path for CMS pages (e.g., "/cms/") * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * const routePath = getCmsRoutePath(); * // Use in Next.js router: router.push(`${routePath}posts`) * ``` */ export function getCmsRoutePath(options) { const config = getConfig(options); return config.cms.routePath; } /** * Gets configuration specific to the Sanity client initialization * Optimized for CDN usage with published content when no token is available * * @param options Configuration loading options * @returns Sanity client configuration object * * @example * ```typescript * const clientConfig = getSanityClientConfig(); * const sanityClient = createClient(clientConfig); * ``` */ export function getSanityClientConfig(options) { const config = getSanityConfig(options); const token = config.apiToken; // For performance, automatically use CDN when no token is provided // This optimizes for read-only access to published content // When a token is provided, disable CDN to get real-time content updates const useCdn = token ? false : true; return { projectId: config.projectId, dataset: config.dataset, apiVersion: config.apiVersion, ...(token ? { token } : {}), useCdn, }; } /** * Checks if a Sanity API token is available * Useful for conditional features that require authentication * * @param options Configuration loading options * @returns true if API token is configured, false otherwise * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * if (isSanityTokenAvailable()) { * // Enable write operations * await client.create(document); * } else { * // Read-only mode * console.log('Running in read-only mode'); * } * ``` */ export function isSanityTokenAvailable(options) { const sanityConfig = getSanityConfig(options); return Boolean(sanityConfig.apiToken); } /** * Gets the Sanity API version string * * @param options Configuration loading options * @returns The API version string (e.g., "2023-05-03") * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * const apiVersion = getApiVersion(); * // Use in direct API calls: `https://projectId.api.sanity.io/v${apiVersion}/...` * ``` */ export function getApiVersion(options) { const sanityConfig = getSanityConfig(options); return sanityConfig.apiVersion; } /** * Gets the Sanity project ID * * @param options Configuration loading options * @returns The Sanity project ID string * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * const projectId = getProjectId(); * // Use for custom API endpoints or analytics * ``` */ export function getProjectId(options) { const sanityConfig = getSanityConfig(options); return sanityConfig.projectId; } /** * Gets the Sanity dataset name * * @param options Configuration loading options * @returns The dataset name (e.g., "production", "development") * @throws ConfigurationError when configuration is invalid * * @example * ```typescript * const dataset = getDataset(); * // Use for environment-specific logic * if (dataset === 'development') { * console.log('Running in development mode'); * } * ``` */ export function getDataset(options) { const sanityConfig = getSanityConfig(options); return sanityConfig.dataset; } // ============================================================================= // Next.js Environment Variable Support // ============================================================================= /** * Configuration cache for client-safe configuration */ let clientConfigCache = null; /** * Detects if the code is running in a browser environment */ export function isClient() { return typeof window !== 'undefined'; } /** * Detects if the code is running in a server environment */ export function isServer() { return typeof window === 'undefined'; } /** * Gets client-safe configuration for browser environment * Automatically falls back from NEXT_PUBLIC_ variables to regular variables if not found * * @param options Configuration loading options * @returns Client-safe configuration object * * @example * ```typescript * const clientConfig = getClientConfig(); * const routePath = clientConfig.cms.routePath; // '/cms/' * const projectId = clientConfig.sanity.projectId; // Uses NEXT_PUBLIC_SANITY_PROJECT_ID or SANITY_PROJECT_ID * const dataset = clientConfig.sanity.dataset; // Uses NEXT_PUBLIC_SANITY_DATASET or SANITY_DATASET * const apiVersion = clientConfig.sanity.apiVersion; // '2023-05-03' * ``` */ export function getClientConfig(options = {}) { const { useCache = true, env = process.env } = options; // Return cached configuration if available and caching is enabled if (useCache && clientConfigCache) { return clientConfigCache; } try { // Create client configuration object with fallback to NEXT_PUBLIC_ variables // If NEXT_PUBLIC_ variable is not available, fall back to regular variable const rawConfig = { cms: { routePath: env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH] || env[ENV_VARS.CMS_ROUTE_PATH], }, sanity: { projectId: env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID] || env[ENV_VARS.SANITY_PROJECT_ID], dataset: env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET] || env[ENV_VARS.SANITY_DATASET], apiVersion: env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION] || env[ENV_VARS.SANITY_API_VERSION], }, }; // Validate configuration against client schema const validatedConfig = clientConfigSchema.parse(rawConfig); // Cache the validated configuration if (useCache) { clientConfigCache = validatedConfig; } return validatedConfig; } catch (error) { if (error instanceof z.ZodError) { let errorMessage = 'Client configuration validation failed:\n'; error.issues.forEach(issue => { const field = issue.path.join('.'); errorMessage += ` - ${field}: ${issue.message}\n`; }); errorMessage += `\nNote: Client configuration only includes non-sensitive values.`; errorMessage += `\nFor server-side configuration, use getConfig() instead.`; throw new ConfigurationError(errorMessage, error); } throw error; } } /** * Gets configuration specifically from NEXT_PUBLIC_ environment variables * These are the only variables available in the browser in Next.js * * @param options Configuration loading options * @returns Public configuration object with only NEXT_PUBLIC_ variables * * @example * ```typescript * // Only works with NEXT_PUBLIC_ prefixed variables * const publicConfig = getPublicConfig(); * ``` */ export function getPublicConfig(options = {}) { const { env = process.env } = options; // Force to only use NEXT_PUBLIC_ variables const publicEnv = { [ENV_VARS.SANITY_PROJECT_ID]: env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID], [ENV_VARS.SANITY_DATASET]: env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET], [ENV_VARS.CMS_ROUTE_PATH]: env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH], [ENV_VARS.SANITY_API_VERSION]: env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION], }; return getClientConfig({ ...options, env: publicEnv }); } /** * Clears the client configuration cache * Useful for testing or when environment variables change at runtime */ export function clearClientConfigCache() { clientConfigCache = null; } /** * Gets a configuration object appropriate for the current environment * Returns full configuration on server, client-safe configuration in browser * * @param options Configuration loading options * @returns Configuration object appropriate for current environment * * @example * ```typescript * // Automatically chooses the right config based on environment * const config = getEnvironmentConfig(); * * // On server: includes projectId, dataset, apiToken, etc. * // In browser: only includes apiVersion and routePath * ``` */ export function getEnvironmentConfig(options = {}) { if (isClient()) { return getClientConfig(options); } else { // On server, we can safely return full config, but we'll cast to the union type return getConfig(options); } } /** * Helper function to get CMS route path that works in both environments * Uses client-safe configuration in browser, full configuration on server * * @param options Configuration loading options * @returns CMS route path string * * @example * ```typescript * // Works in both server and client * const routePath = getCmsRoutePathSafe(); * ``` */ export function getCmsRoutePathSafe(options) { if (isClient()) { const clientConfig = getClientConfig(options); return clientConfig.cms.routePath; } else { return getCmsRoutePath(options); } } /** * Helper function to get API version that works in both environments * Uses client-safe configuration in browser, full configuration on server * * @param options Configuration loading options * @returns API version string * * @example * ```typescript * // Works in both server and client * const apiVersion = getApiVersionSafe(); * ``` */ export function getApiVersionSafe(options) { if (isClient()) { const clientConfig = getClientConfig(options); return clientConfig.sanity.apiVersion; } else { return getApiVersion(options); } } /** * Helper function to get project ID that works in both environments * Uses client-safe configuration in browser (if available), full configuration on server * * @param options Configuration loading options * @returns Project ID string or undefined if not available in client environment * * @example * ```typescript * // Works in both server and client * const projectId = getProjectIdSafe(); * if (projectId) { * // Use project ID * } * ``` */ export function getProjectIdSafe(options) { if (isClient()) { const clientConfig = getClientConfig(options); return clientConfig.sanity.projectId; } else { return getProjectId(options); } } /** * Helper function to get dataset that works in both environments * Uses client-safe configuration in browser (if available), full configuration on server * * @param options Configuration loading options * @returns Dataset string or undefined if not available in client environment * * @example * ```typescript * // Works in both server and client * const dataset = getDatasetSafe(); * if (dataset) { * // Use dataset * } * ``` */ export function getDatasetSafe(options) { if (isClient()) { const clientConfig = getClientConfig(options); return clientConfig.sanity.dataset; } else { return getDataset(options); } } /** * Validates configuration at startup and provides detailed feedback * Useful for verifying all required configuration is present before the application starts * * @param options Validation options * @param options.env Custom environment object (default: process.env) * @param options.throwOnError Whether to throw an error if validation fails (default: false) * @returns Detailed validation result with errors, warnings, and config if valid * * @example * ```typescript * // Validate configuration at app startup * const result = validateConfiguration(); * if (!result.isValid) { * console.error('Configuration errors:', result.errors); * process.exit(1); * } * * // In development, show warnings too * if (result.warnings.length > 0) { * console.warn('Configuration warnings:', result.warnings); * } * ``` */ export function validateConfiguration(options = {}) { const { env = process.env, throwOnError = false } = options; const environment = isClient() ? 'client' : 'server'; const errors = []; const warnings = []; let config; try { if (environment === 'server') { // Validate full server configuration const serverConfig = getConfig({ env, useCache: false }); config = serverConfig; // Check for common configuration issues if (serverConfig.sanity.apiToken && serverConfig.sanity.apiToken.startsWith('sk_')) { // Valid API token format } else if (serverConfig.sanity.apiToken) { warnings.push('SANITY_API_TOKEN does not appear to be a valid Sanity API token (should start with "sk_")'); } else { warnings.push('SANITY_API_TOKEN is not set - some features may be limited to read-only access'); } // Check dataset naming if (serverConfig.sanity.dataset && !['production', 'development', 'staging'].includes(serverConfig.sanity.dataset)) { warnings.push(`Dataset "${serverConfig.sanity.dataset}" is not a standard name (consider: production, development, staging)`); } } else { // Validate client configuration config = getClientConfig({ env, useCache: false }); // Check for client-side best practices const hasPublicProjectId = env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID]; const hasPublicDataset = env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET]; const hasPublicApiVersion = env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION]; const hasPublicRoutePath = env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH]; if (!hasPublicProjectId && !hasPublicDataset && !hasPublicApiVersion && !hasPublicRoutePath) { warnings.push('No NEXT_PUBLIC_ variables set - consider using NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET, NEXT_PUBLIC_SANITY_API_VERSION and NEXT_PUBLIC_CMS_ROUTE_PATH for client-side access'); } } return { isValid: true, errors, warnings, config, environment, }; } catch (error) { let errorMessage; if (error instanceof ConfigurationError) { errorMessage = error.message; } else if (error instanceof Error) { errorMessage = `Configuration validation failed: ${error.message}`; } else { errorMessage = 'Unknown configuration validation error'; } errors.push(errorMessage); if (throwOnError) { throw new ConfigurationError(`Configuration validation failed:\n${errors.join('\n')}`, error instanceof ConfigurationError ? error.details : undefined); } return { isValid: false, errors, warnings, environment, }; } } /** * Gets the current configuration status and health * Provides a quick overview of configuration state without throwing errors * * @param options Configuration options * @returns Configuration status information * * @example * ```typescript * const status = getConfigurationStatus(); * console.log(`Environment: ${status.environment}`); * console.log(`Valid: ${status.isValid}`); * console.log(`Has Token: ${status.hasApiToken}`); * ``` */ export function getConfigurationStatus(options = {}) { const { env = process.env } = options; const environment = isClient() ? 'client' : 'server'; const requiredVars = environment === 'server' ? [ENV_VARS.SANITY_PROJECT_ID, ENV_VARS.SANITY_DATASET] : []; // Client has no required vars (all have defaults) const allVars = [ ENV_VARS.SANITY_PROJECT_ID, ENV_VARS.SANITY_DATASET, ENV_VARS.SANITY_API_TOKEN, ENV_VARS.SANITY_API_VERSION, ENV_VARS.CMS_ROUTE_PATH, ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID, ENV_VARS.NEXT_PUBLIC_SANITY_DATASET, ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION, ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH, ]; const configuredVariables = allVars.filter(varName => env[varName] && env[varName].trim() !== ''); const missingRequiredVariables = requiredVars.filter(varName => !env[varName] || env[varName].trim() === ''); const hasApiToken = Boolean(env[ENV_VARS.SANITY_API_TOKEN]); const hasPublicVariables = Boolean(env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID] || env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET] || env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION] || env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH]); const isValid = missingRequiredVariables.length === 0; return { environment, isValid, hasApiToken, hasPublicVariables, configuredVariables, missingRequiredVariables, }; } /** * Debug utility to display current configuration state * Useful during development to understand what configuration is loaded * * @param options Debug options * @param options.env Custom environment object (default: process.env) * @param options.showValues Whether to show actual values (default: false for security) * @param options.maskSensitive Whether to mask sensitive values (default: true) * @returns Debug information object * * @example * ```typescript * // Safe debugging (masks sensitive values) * const debug = debugConfiguration(); * console.log('Debug Info:', debug); * * // Development debugging (shows values but still masks sensitive) * const debugWithValues = debugConfiguration({ showValues: true }); * console.log('Config Values:', debugWithValues); * ``` */ export function debugConfiguration(options = {}) { const { env = process.env, showValues = false, maskSensitive = true } = options; const sensitiveVars = [ENV_VARS.SANITY_API_TOKEN]; const environmentVariables = {}; if (showValues) { Object.values(ENV_VARS).forEach(varName => { const value = env[varName]; if (value && maskSensitive && sensitiveVars.includes(varName)) { environmentVariables[varName] = value.substring(0, 8) + '***'; } else { environmentVariables[varName] = value; } }); } else { Object.values(ENV_VARS).forEach(varName => { environmentVariables[varName] = env[varName] ? '***set***' : undefined; }); } return { environment: isClient() ? 'client' : 'server', timestamp: new Date().toISOString(), configurationStatus: getConfigurationStatus({ env }), validationResult: validateConfiguration({ env, throwOnError: false }), environmentVariables, }; }