UNPKG

@ai-growth/nextjs

Version:

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

398 lines (397 loc) 12.3 kB
import { getDocumentBySlug, getDocumentById, getDocumentsByType } from './content-fetching'; import { extractRouteInfo, isValidCmsRoute, getRouteConfig } from './route-config'; import { createSanityError, SANITY_ERROR_CODES, SanityError } from './error-handling'; import { withRetry, RETRY_PRESETS } from './retry'; // ============================================================================ // CONTENT PROJECTION TEMPLATES // ============================================================================ /** * Default projection for router content */ const DEFAULT_ROUTER_PROJECTION = ` _id, _type, _createdAt, _updatedAt, title, slug, content, publishedAt, status ` .replace(/\s+/g, ' ') .trim(); /** * SEO-enhanced projection */ const SEO_ENHANCED_PROJECTION = ` ${DEFAULT_ROUTER_PROJECTION}, seo->{ title, description, keywords, image, noIndex, noFollow } ` .replace(/\s+/g, ' ') .trim(); /** * Author-enhanced projection */ const AUTHOR_ENHANCED_PROJECTION = ` ${DEFAULT_ROUTER_PROJECTION}, author->{ _id, name, slug, image, bio } ` .replace(/\s+/g, ' ') .trim(); /** * Full projection with SEO and author */ const FULL_PROJECTION = ` ${DEFAULT_ROUTER_PROJECTION}, seo->{ title, description, keywords, image, noIndex, noFollow }, author->{ _id, name, slug, image, bio }, categories[]->{ _id, title, slug }, tags[]->{ _id, title, slug } ` .replace(/\s+/g, ' ') .trim(); // ============================================================================ // CORE CONTENT FETCHING FUNCTIONS // ============================================================================ /** * Fetch content for a specific route path */ export async function fetchContentForRoute(path, options = {}) { const routeConfig = options.routeConfig ? { ...getRouteConfig(), ...options.routeConfig } : getRouteConfig(); // Validate if this is a CMS route if (!isValidCmsRoute(path, routeConfig)) { return null; } // Extract route information const routeInfo = extractRouteInfo(path, routeConfig); if (!routeInfo.matched || !routeInfo.slug || !routeInfo.contentType) { return null; } return withRetry(async () => { try { // Build projection based on options const projection = buildProjection(options); // Fetch content using existing function const document = await getDocumentBySlug(routeInfo.contentType, routeInfo.slug, { projection, ...(options.includeDrafts !== undefined && { includeDrafts: options.includeDrafts }), }); if (!document) { return null; } // Transform to CmsContent format return transformToCmsContent(document); } catch (error) { throw createSanityError(error, SANITY_ERROR_CODES.FETCH_FAILED, { operation: 'fetchContentForRoute', path, contentType: routeInfo.contentType, slug: routeInfo.slug, }); } }, RETRY_PRESETS.standard); } /** * Fetch content by content type and slug */ export async function fetchContentBySlug(contentType, slug, options = {}) { return withRetry(async () => { try { const projection = buildProjection(options); const document = await getDocumentBySlug(contentType, slug, { projection, ...(options.includeDrafts !== undefined && { includeDrafts: options.includeDrafts }), }); if (!document) { return null; } return transformToCmsContent(document); } catch (error) { throw createSanityError(error, SANITY_ERROR_CODES.FETCH_FAILED, { operation: 'fetchContentBySlug', contentType, slug, }); } }, RETRY_PRESETS.standard); } /** * Fetch content by document ID */ export async function fetchContentById(contentId, options = {}) { return withRetry(async () => { try { const projection = buildProjection(options); const document = await getDocumentById(contentId, { projection, ...(options.includeDrafts !== undefined && { includeDrafts: options.includeDrafts }), }); if (!document) { return null; } return transformToCmsContent(document); } catch (error) { throw createSanityError(error, SANITY_ERROR_CODES.FETCH_FAILED, { operation: 'fetchContentById', contentId, }); } }, RETRY_PRESETS.standard); } // ============================================================================ // ROUTE VALIDATION WITH CONTENT // ============================================================================ /** * Validate a route and fetch its content if available */ export async function validateContentRoute(path, options = {}) { const routeConfig = options.routeConfig ? { ...getRouteConfig(), ...options.routeConfig } : getRouteConfig(); try { // Check if path is a valid CMS route if (!isValidCmsRoute(path, routeConfig)) { return { isValid: false, error: 'Path is not a valid CMS route', }; } // Extract route information const routeInfo = extractRouteInfo(path, routeConfig); if (!routeInfo.matched) { return { isValid: false, error: 'Path does not match any configured patterns', routeInfo, }; } if (!routeInfo.slug || !routeInfo.contentType) { return { isValid: false, error: 'Could not extract content type and slug from path', routeInfo, }; } // Try to fetch content const content = await fetchContentForRoute(path, options); const result = { isValid: content !== null, contentType: routeInfo.contentType, slug: routeInfo.slug, routeInfo, }; if (content) { result.content = content; } else { result.error = 'Content not found'; } return result; } catch (error) { return { isValid: false, error: error instanceof SanityError ? error.message : String(error), }; } } // ============================================================================ // BATCH OPERATIONS // ============================================================================ /** * Preload content for multiple routes */ export async function preloadRouteContent(paths, options = {}) { const startTime = Date.now(); const maxRoutes = options.maxRoutes || paths.length; const pathsToProcess = paths.slice(0, maxRoutes); const loaded = new Map(); const failed = new Map(); if (options.parallel !== false) { // Parallel processing const results = await Promise.allSettled(pathsToProcess.map(async (path) => { const content = await fetchContentForRoute(path, options); return { path, content }; })); results.forEach((result, index) => { const path = pathsToProcess[index]; if (result.status === 'fulfilled' && result.value.content) { loaded.set(path, result.value.content); } else { const error = result.status === 'rejected' ? result.reason : 'Content not found'; failed.set(path, String(error)); } }); } else { // Sequential processing for (const path of pathsToProcess) { try { const content = await fetchContentForRoute(path, options); if (content) { loaded.set(path, content); } else { failed.set(path, 'Content not found'); } } catch (error) { failed.set(path, String(error)); } } } return { loaded, failed, duration: Date.now() - startTime, totalRoutes: pathsToProcess.length, }; } /** * Get available routes for a content type */ export async function getAvailableRoutes(contentType, options = {}) { const routeConfig = getRouteConfig(); const contentTypes = contentType ? [contentType] : ['post', 'page']; const routes = []; for (const type of contentTypes) { try { const documents = await getDocumentsByType(type, { projection: '_id, title, slug, publishedAt, status, _updatedAt', ...(options.includeDrafts !== undefined && { includeDrafts: options.includeDrafts }), limit: 100, // Reasonable limit for route generation }); for (const doc of documents.documents) { const docAny = doc; if (docAny.slug?.current) { // Build path based on route patterns const path = buildPathForContentType(type, docAny.slug.current, routeConfig); routes.push({ path, contentType: type, slug: docAny.slug.current, title: docAny.title || 'Untitled', contentId: docAny._id, isPublished: docAny.status === 'published', lastModified: docAny._updatedAt, }); } } } catch (error) { // Continue with other content types if one fails console.warn(`Failed to fetch routes for content type ${type}:`, error); } } return routes; } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Build projection string based on options */ function buildProjection(options) { if (options.projection) { return options.projection; } if (options.includeSEO && options.includeAuthor) { return FULL_PROJECTION; } if (options.includeSEO) { return SEO_ENHANCED_PROJECTION; } if (options.includeAuthor) { return AUTHOR_ENHANCED_PROJECTION; } return DEFAULT_ROUTER_PROJECTION; } /** * Transform Sanity document to CmsContent format */ function transformToCmsContent(document) { return { _id: document._id, _type: document._type, slug: document.slug?.current || '', title: document.title || 'Untitled', content: document.content || document.body || null, metadata: document.seo, publishedAt: document.publishedAt, author: document.author, }; } /** * Build path for content type and slug based on route patterns */ function buildPathForContentType(contentType, slug, routeConfig) { // Find the first pattern that matches this content type const pattern = routeConfig.patterns.find(p => p.enabled !== false && p.contentType === contentType); if (pattern) { // Extract path template from pattern if (pattern.pattern.includes('/blog/')) { return `/blog/${slug}`; } if (pattern.pattern.includes('/docs/')) { return `/docs/${slug}`; } } // Default to slug-based path return `/${slug}`; } /** * Check if content exists for a given route */ export async function contentExistsForRoute(path, options = {}) { try { const content = await fetchContentForRoute(path, { ...options, projection: '_id', // Minimal projection for existence check }); return content !== null; } catch { return false; } }