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