UNPKG

@spoolcms/nextjs

Version:

The beautiful headless CMS for Next.js developers

588 lines (581 loc) 23.7 kB
"use strict"; 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); } */