@spoolcms/nextjs
Version:
The beautiful headless CMS for Next.js developers
588 lines (581 loc) • 23.7 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.__testing__ = exports.SpoolError = void 0;
exports.getSpoolContent = getSpoolContent;
exports.getSpoolStaticParams = getSpoolStaticParams;
exports.generateSpoolSitemap = generateSpoolSitemap;
exports.getSpoolCollections = getSpoolCollections;
const config_1 = require("./config");
const cache_1 = require("./cache");
const environment_1 = require("./environment");
// Error types for better error handling
class SpoolError extends Error {
constructor(message, code, status, retryable = false) {
super(message);
this.code = code;
this.status = status;
this.retryable = retryable;
this.name = 'SpoolError';
}
}
exports.SpoolError = SpoolError;
// Helper to add a timeout to fetch calls
function fetchWithTimeout(resource, options = {}, timeoutMs = 10000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
const mergedOptions = { ...options, signal: controller.signal };
return fetch(resource, mergedOptions).finally(() => clearTimeout(id));
}
// Enhanced fetch function with proper error handling and redirect preservation for Authorization
async function enhancedFetch(url, options = {}) {
const environment = (0, environment_1.detectEnvironment)();
const cachedFetch = (0, cache_1.createCachedFetch)();
try {
let response;
if (environment.isServer) {
// Server-side: use Next.js fetch with caching
const fetchOptions = {
...options,
next: {
revalidate: environment.isDevelopment ? 0 : 300, // No cache in dev, 5 minutes in production
...(options.next || {}),
},
redirect: 'manual',
};
response = await cachedFetch(url, fetchOptions);
// Handle manual redirect to preserve Authorization across host changes
if (response.status === 307 || response.status === 308 || response.status === 301 || response.status === 302) {
const location = response.headers.get('location');
if (location) {
const redirectedUrl = new URL(location, url).toString();
const redirectedFetchOptions = {
...options,
next: { revalidate: environment.isDevelopment ? 0 : 300, ...(options.next || {}) },
};
response = await cachedFetch(redirectedUrl, redirectedFetchOptions);
}
}
}
else {
// Client-side: use regular fetch with timeout
const clientOpts = { ...options, redirect: 'manual' };
response = await fetchWithTimeout(url, clientOpts);
if (response.status === 307 || response.status === 308 || response.status === 301 || response.status === 302) {
const location = response.headers.get('location');
if (location) {
const redirectedUrl = new URL(location, url).toString();
response = await fetchWithTimeout(redirectedUrl, options);
}
}
}
if (!response.ok) {
throw createSpoolError(response);
}
return response;
}
catch (error) {
if (error instanceof SpoolError) {
throw error;
}
// Handle network errors
if (error instanceof TypeError || error.name === 'AbortError') {
throw new SpoolError('Network error: Unable to connect to Spool CMS', 'NETWORK_ERROR', undefined, true);
}
throw new SpoolError(`Unexpected error: ${error.message}`, 'SERVER_ERROR', undefined, false);
}
}
// Create appropriate SpoolError from HTTP response
function createSpoolError(response) {
const status = response.status;
switch (status) {
case 401:
case 403:
return new SpoolError('Authentication failed: Invalid API key or insufficient permissions', 'AUTH_ERROR', status, false);
case 404:
return new SpoolError('Content not found', 'NOT_FOUND', status, false);
case 429:
return new SpoolError('Rate limit exceeded: Too many requests', 'RATE_LIMITED', status, true);
case 500:
case 502:
case 503:
case 504:
return new SpoolError('Server error: Spool CMS is temporarily unavailable', 'SERVER_ERROR', status, true);
default:
return new SpoolError(`HTTP ${status}: ${response.statusText}`, 'SERVER_ERROR', status, status >= 500);
}
}
// Retry logic with exponential backoff
async function withRetry(operation, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
}
catch (error) {
lastError = error instanceof SpoolError ? error : new SpoolError(error.message, 'SERVER_ERROR', undefined, false);
// Don't retry non-retryable errors
if (!lastError.retryable || attempt === maxRetries) {
throw lastError;
}
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
/**
* Flatten content item structure to provide unified field access
* Merges data fields with top-level fields, with data fields taking precedence
* Creates smart markdown field objects for better developer experience
*/
function flattenContentItem(item) {
if (!item || typeof item !== 'object') {
return item;
}
const { data, ...systemFields } = item;
// If there's no data object, return as-is
if (!data || typeof data !== 'object') {
return item;
}
// Process data fields to create smart markdown objects and image objects
const processedData = { ...data };
// Find markdown fields and create smart objects
Object.keys(data).forEach(fieldName => {
const htmlFieldName = `${fieldName}_html`;
const mdFieldName = `${fieldName}_markdown`;
// If we have both markdown and HTML versions, use HTML as default and store markdown separately
if (data[fieldName] && data[htmlFieldName]) {
// Use HTML as the default value (React-serializable)
processedData[fieldName] = data[htmlFieldName];
// Store markdown in a separate field for access when needed
processedData[mdFieldName] = data[mdFieldName] || data[fieldName];
// Remove the _html field since the main field now contains HTML
delete processedData[htmlFieldName];
}
});
// Process image fields to create image objects with thumbnail URLs
Object.keys(processedData).forEach(fieldName => {
const fieldValue = processedData[fieldName];
// Check if this looks like an image URL string
if (typeof fieldValue === 'string' &&
(fieldValue.includes('/media/') || fieldValue.includes('storage')) &&
(fieldValue.match(/\.(jpg|jpeg|png|gif|webp)$/i))) {
// Generate thumbnail URLs by replacing extension with suffix + webp
const originalUrl = fieldValue;
const thumbUrl = originalUrl.replace(/(\.[^.]+)$/, '_thumb.webp');
const smallUrl = originalUrl.replace(/(\.[^.]+)$/, '_small.webp');
// Replace the string with an image object
processedData[fieldName] = {
original: originalUrl,
thumb: thumbUrl,
small: smallUrl
};
}
});
// Merge system fields with processed data fields, data fields take precedence
const flattened = {
...systemFields,
...processedData,
// Keep original data object for backward compatibility (marked as deprecated)
data: {
...data,
__deprecated: 'Access fields directly on the item instead of item.data.field'
}
};
return flattened;
}
// Export for testing
exports.__testing__ = {
clearCache: () => cache_1.globalCache.clear(),
disableCache: false,
flattenContentItem,
};
/**
* Main function to get content from Spool CMS
* Works seamlessly in both server and client components
*
* Supports both old and new API:
* - getSpoolContent(config, 'blog') // Old way
* - getSpoolContent({ collection: 'blog' }) // New simplified way
*/
async function getSpoolContent(options) {
const { collection, slug, config, ...contentOptions } = options;
// Use provided config or default from environment
const resolvedConfig = (0, config_1.resolveConfig)(config || {
apiKey: process.env.NEXT_PUBLIC_SPOOL_API_KEY,
siteId: process.env.NEXT_PUBLIC_SPOOL_SITE_ID,
});
// Build endpoint URL
let endpoint = slug
? `/api/spool/${resolvedConfig.siteId}/content/${collection}/${slug}`
: `/api/spool/${resolvedConfig.siteId}/content/${collection}`;
// Build query parameters
const queryParams = new URLSearchParams();
// Always request HTML for markdown fields by default (better DX)
// Users can opt out by setting renderHtml: false; use _format to request other formats
const shouldRenderHtml = contentOptions?.renderHtml !== false;
if (shouldRenderHtml) {
queryParams.set('_format', 'html');
}
// By default, only return published content (better DX for public sites)
// Users can opt in to include drafts with includeDrafts: true
if (!contentOptions?.includeDrafts) {
queryParams.set('status', 'published');
}
// Support for fetching all items (bypasses pagination)
if (contentOptions?.fetchAll) {
queryParams.set('all', 'true');
}
// Support for pagination
if (contentOptions?.limit) {
queryParams.set('limit', contentOptions.limit.toString());
}
if (contentOptions?.offset) {
queryParams.set('offset', contentOptions.offset.toString());
}
// Reference population toggle (default true)
if (contentOptions?.includeReferences === false) {
queryParams.set('_refs', 'false');
}
// Add query parameters to endpoint if any exist
// If caching is disabled, add a cache-busting timestamp so that our server-side fetch cache is bypassed.
if (options?.cache === 'no-store') {
queryParams.set('_cb', Date.now().toString());
}
if (queryParams.toString()) {
endpoint += '?' + queryParams.toString();
}
const url = `${resolvedConfig.baseUrl}${endpoint}`;
const cacheKey = (0, cache_1.generateCacheKey)(resolvedConfig.baseUrl, resolvedConfig.siteId, collection, slug, contentOptions);
try {
// Check if caching should be bypassed
const shouldBypassCache = options?.cache === 'no-store';
// Use unified caching that works in both server and client contexts
const fetchData = async () => {
return withRetry(async () => {
const response = await enhancedFetch(url, {
headers: {
'Authorization': `Bearer ${resolvedConfig.apiKey}`,
},
// next: options?.revalidate ? { revalidate: options.revalidate } : undefined,
cache: options?.cache,
});
try {
return await response.json();
}
catch (jsonError) {
// Handle "Body is unusable" error by retrying without cache
if (jsonError.message?.includes('unusable') || jsonError.message?.includes('disturbed')) {
// Clear cache and retry
cache_1.globalCache.clear();
const retryResponse = await enhancedFetch(url, {
headers: {
'Authorization': `Bearer ${resolvedConfig.apiKey}`,
},
cache: 'no-store', // Force no cache on retry
});
return await retryResponse.json();
}
throw jsonError;
}
});
};
// If bypassing cache, fetch directly without using internal cache
const data = shouldBypassCache
? await fetchData()
: await cache_1.globalCache.getOrFetch(cacheKey, fetchData);
// Handle different response formats
if (slug) {
// Single item - return just the item (could be null if not found)
const item = data?.item ?? data ?? null;
return item ? flattenContentItem(item) : null;
}
// Collection request – always return an array for consistency
let items = [];
if (Array.isArray(data)) {
items = data;
}
else if (Array.isArray(data?.items)) {
items = data.items;
}
// Flatten each item in the collection
return items.map(item => flattenContentItem(item));
}
catch (error) {
if (error instanceof SpoolError) {
// For NOT_FOUND errors, return appropriate empty values
if (error.code === 'NOT_FOUND') {
return (slug ? null : []);
}
// Log API errors for debugging
if (resolvedConfig.environment.isDevelopment) {
console.error(`SpoolCMS API error: ${error.message}`);
}
}
else {
// Log other errors for debugging
if (resolvedConfig.environment.isDevelopment) {
console.error('SpoolCMS content fetch failed:', error);
}
}
// Always return empty values on any error to prevent breaking the UI
return (slug ? null : []);
}
}
/**
* Generate static params for Next.js generateStaticParams - ONE LINE HELPER
* Supports both old and new API
*/
async function getSpoolStaticParams(options) {
const { collection, config } = options;
const items = await getSpoolContent({ collection, config });
return Array.isArray(items) ? items.map(item => ({ slug: item.slug })) : [];
}
/**
* Generate sitemap for Next.js sitemap.ts - ONE LINE HELPER
* Supports both old and new API
*/
async function generateSpoolSitemap(options) {
const { collections, staticPages, config } = options;
// Auto-detect site URL
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ||
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) ||
'http://localhost:3000';
const sitemap = [];
// Add static pages
if (staticPages) {
sitemap.push(...staticPages.map(page => ({
url: `${siteUrl}${page.url}`,
lastModified: new Date(),
changeFrequency: page.changeFrequency || 'monthly',
priority: page.priority || 0.8,
})));
}
// Add content from collections
for (const collection of collections) {
try {
const items = await getSpoolContent({ collection, config });
if (Array.isArray(items)) {
sitemap.push(...items.map(item => ({
url: `${siteUrl}/${collection}/${item.slug}`,
lastModified: new Date(item.updated_at || item.created_at),
changeFrequency: 'weekly',
priority: 0.7,
})));
}
}
catch (error) {
console.warn(`Failed to fetch ${collection} for sitemap:`, error);
}
}
return sitemap;
}
/**
* Helper function to get all collections from Spool CMS
* Supports both old and new API
*/
async function getSpoolCollections(config) {
// Auto-detect config if not provided
const resolvedConfigInput = config || {
apiKey: process.env.NEXT_PUBLIC_SPOOL_API_KEY,
siteId: process.env.NEXT_PUBLIC_SPOOL_SITE_ID,
};
const resolvedConfig = (0, config_1.resolveConfig)(resolvedConfigInput);
const url = `${resolvedConfig.baseUrl}/api/spool/${resolvedConfig.siteId}/collections`;
const cacheKey = (0, cache_1.generateCacheKey)(resolvedConfig.baseUrl, resolvedConfig.siteId, 'collections');
try {
const data = await cache_1.globalCache.getOrFetch(cacheKey, async () => {
return withRetry(async () => {
const response = await enhancedFetch(url, {
headers: {
'Authorization': `Bearer ${resolvedConfig.apiKey}`,
},
});
try {
return await response.json();
}
catch (jsonError) {
// Handle "Body is unusable" error by retrying without cache
if (jsonError.message?.includes('unusable') || jsonError.message?.includes('disturbed')) {
// Clear cache and retry
cache_1.globalCache.clear();
const retryResponse = await enhancedFetch(url, {
headers: {
'Authorization': `Bearer ${resolvedConfig.apiKey}`,
},
cache: 'no-store', // Force no cache on retry
});
return await retryResponse.json();
}
throw jsonError;
}
});
});
// Always return an array of collections
if (Array.isArray(data)) {
return data;
}
if (Array.isArray(data?.collections)) {
return data.collections;
}
return [];
}
catch (error) {
if (error instanceof SpoolError) {
// Log API errors for debugging
if (resolvedConfig.environment.isDevelopment) {
console.error(`SpoolCMS Collections API error: ${error.message}`);
}
}
else {
// Log other errors for debugging
if (resolvedConfig.environment.isDevelopment) {
console.error('SpoolCMS collections fetch failed:', error);
}
}
return [];
}
}
/**
* Build a JSON-LD object from a type and mappings using a sample source of values
*/
function buildSchemaFromMapping(type, mappings, sample) {
const root = { '@context': 'https://schema.org', '@type': type };
for (const [path, map] of Object.entries(mappings || {})) {
const value = map.source === 'value' ? map.value : (sample?.[map.field] ?? sample?.data?.[map.field]);
if (value === undefined || value === null || value === '')
continue;
const segments = path.split('.');
let cursor = root;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const arrayMatch = seg.match(/^(\w+)\[(\d+)\]$/);
const isLast = i === segments.length - 1;
if (arrayMatch) {
const key = arrayMatch[1];
const index = Number(arrayMatch[2]);
cursor[key] = Array.isArray(cursor[key]) ? cursor[key] : [];
cursor[key][index] = cursor[key][index] ?? {};
if (isLast) {
cursor[key][index] = value;
}
else {
cursor = cursor[key][index];
}
}
else {
if (isLast) {
cursor[seg] = value;
}
else {
cursor[seg] = cursor[seg] ?? {};
cursor = cursor[seg];
}
}
}
}
return root;
}
/**
* Get root-level JSON-LD configuration for a collection and build the JSON-LD object.
* Use this in collection index pages like /blog.
*/
// Removed: JSON-LD root helper (getSpoolCollectionRootJsonLd)
/* export async function getSpoolCollectionRootJsonLd(collection: string, options?: { sample?: Record<string, any>, config?: SpoolConfig }) {
// Find collection config (includes settings)
const collections = await getSpoolCollections(options?.config);
const col = collections.find((c: any) => c.slug === collection);
if (!col) return null;
const cfg = col?.settings?.jsonldRoot;
if (!cfg || !cfg.enabled || !cfg.type) return null;
// Build a minimal sample for mapping resolution
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000');
const sample = {
slug: collection,
url: `${siteUrl}/${collection}`,
title: col.name,
seoTitle: col.name,
seoDescription: col.description || '',
ogImage: undefined,
publishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(options?.sample || {}),
} as Record<string, any>;
try {
const jsonld = buildSchemaFromMapping(cfg.type, cfg.mappings || {}, sample);
return jsonld;
} catch {
return null;
}
} */
/**
* Generate metadata for Next.js App Router - SIMPLIFIED VERSION
* Auto-detects site URL, path, and everything else from Next.js context
*/
// Removed: generateSpoolMetadata helper
/* export function generateSpoolMetadata(content: any): SpoolMetadata {
if (!content) {
return {
title: 'Content Not Found',
description: '',
openGraph: {
title: 'Content Not Found',
description: '',
siteName: process.env.NEXT_PUBLIC_SITE_NAME || 'Site',
images: [],
type: 'article',
},
robots: 'noindex,nofollow',
};
}
// Auto-detect site URL from environment
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ||
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null) ||
'http://localhost:3000';
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || content?.siteName || undefined;
const title = content?.seoTitle || content?.title || 'Untitled';
const description = content?.seoDescription || content?.description || content?.excerpt || '';
// Handle ogImage which can be string or ImageSizes object
const ogImageUrl = content?.ogImage
? (typeof content.ogImage === 'string' ? content.ogImage : content.ogImage.original)
: `${siteUrl}/api/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title: content?.ogTitle || title,
description: content?.ogDescription || description,
siteName: siteName,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: title,
},
],
type: 'article',
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: [ogImageUrl],
},
robots: content?.noIndex ? 'noindex,nofollow' : 'index,follow',
};
} */
/**
* Legacy version for backward compatibility
* @deprecated Use generateSpoolMetadata(content) instead
*/
/* export function generateSpoolMetadataLegacy(options: {
content: any;
collection: string;
path: string;
siteUrl: string;
}) {
console.warn('generateSpoolMetadata with options object is deprecated. Use generateSpoolMetadata(content) instead.');
return generateSpoolMetadata(options.content);
} */
;