UNPKG

@ai-growth/nextjs

Version:

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

406 lines (405 loc) 13.3 kB
/** * @fileoverview Content fetching utilities for Sanity CMS * * This module provides type-safe functions for fetching content from Sanity * using GROQ queries. All functions leverage the Sanity client factory and * TypeScript interfaces for maximum type safety and developer experience. * * @example * ```typescript * import { getDocumentBySlug, getDocumentsByType } from '@ai-growth/nextjs/utils'; * * // Fetch a single post by slug * const post = await getDocumentBySlug<SanityPost>('post', 'my-blog-post'); * * // Fetch all published posts * const posts = await getDocumentsByType<SanityPost>('post', { * filter: { status: 'published' }, * orderBy: 'publishedAt desc' * }); * ``` */ import { getSanityClient } from './sanity-client'; import { withRetry, RETRY_PRESETS } from './retry'; import { withErrorHandling } from './error-handling'; // ============================================================================ // QUERY BUILDERS // ============================================================================ /** * Build GROQ filter conditions from options */ function buildFilterConditions(options) { const conditions = []; if (!options.includeDrafts) { conditions.push('!(_id in path("drafts.**"))'); } if (options.filter) { const { status, featured, publishedAfter, publishedBefore, ...customFilters } = options.filter; if (status) { conditions.push(`status == "${status}"`); } if (typeof featured === 'boolean') { conditions.push(`featured == ${featured}`); } if (publishedAfter) { conditions.push(`publishedAt > "${publishedAfter}"`); } if (publishedBefore) { conditions.push(`publishedAt < "${publishedBefore}"`); } // Handle custom filter conditions Object.entries(customFilters).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (typeof value === 'string') { conditions.push(`${key} == "${value}"`); } else if (typeof value === 'boolean' || typeof value === 'number') { conditions.push(`${key} == ${value}`); } } }); } if (options.additionalFilter) { conditions.push(options.additionalFilter); } return conditions.length > 0 ? ` && ${conditions.join(' && ')}` : ''; } /** * Build GROQ projection string */ function buildProjection(projection) { if (!projection) { return ''; } return `{${projection}}`; } /** * Build GROQ order clause */ function buildOrderClause(orderBy) { if (!orderBy) { return ''; } return ` | order(${orderBy})`; } /** * Build GROQ pagination clause */ function buildPaginationClause(limit, offset) { const parts = []; const actualOffset = offset || 0; if (actualOffset > 0) { parts.push(`[${actualOffset}...]`); } if (limit && limit > 0) { const startIndex = actualOffset > 0 ? 0 : actualOffset; parts.push(`[${startIndex}..${actualOffset + limit - 1}]`); } return parts.join(''); } // ============================================================================ // CORE FETCHING FUNCTIONS // ============================================================================ /** * Fetch a single document by its slug * * @template T The expected document type * @param type Document type (e.g., 'post', 'page') * @param slug Document slug * @param options Fetch options including projection and filters * @returns Promise resolving to the document or null if not found * * @example * ```typescript * const post = await getDocumentBySlug<SanityPost>('post', 'my-blog-post', { * projection: '_id, title, slug, publishedAt, author->{name, image}' * }); * ``` */ export async function getDocumentBySlug(type, slug, options = {}) { const retryConfig = { ...RETRY_PRESETS.standard, ...options.retry }; return withRetry(async () => { return withErrorHandling(async () => { const client = getSanityClient(); const projection = buildProjection(options.projection); const filterConditions = buildFilterConditions(options); const query = `*[_type == $type && slug.current == $slug${filterConditions}][0]${projection}`; const params = { type, slug, ...(options.params || {}) }; const result = await client.fetch(query, params); return result; }, 'getDocumentBySlug', { type, slug, query: `*[_type == $type && slug.current == $slug]`, operation: 'fetch_document_by_slug', }); }, retryConfig); } /** * Fetch a single document by its ID * * @template T The expected document type * @param id Document ID * @param options Fetch options including projection * @returns Promise resolving to the document or null if not found * * @example * ```typescript * const post = await getDocumentById<SanityPost>('post-123', { * projection: '_id, title, body, author->{name}' * }); * ``` */ export async function getDocumentById(id, options = {}) { try { const client = getSanityClient(); const projection = buildProjection(options.projection); const filterConditions = buildFilterConditions(options); const query = `*[_id == $id${filterConditions}][0]${projection}`; const params = { id, ...(options.params || {}) }; const result = await client.fetch(query, params); return result; } catch (error) { const sanityError = { message: `Failed to fetch document by ID: ${error instanceof Error ? error.message : 'Unknown error'}`, details: { id, error }, }; throw sanityError; } } /** * Fetch multiple documents by type * * @template T The expected document type * @param type Document type (e.g., 'post', 'page') * @param options List options including pagination, sorting, and filtering * @returns Promise resolving to query result with documents and metadata * * @example * ```typescript * const result = await getDocumentsByType<SanityPost>('post', { * filter: { status: 'published', featured: true }, * orderBy: 'publishedAt desc', * limit: 10, * projection: '_id, title, slug, publishedAt, mainImage', * includeTotal: true * }); * ``` */ export async function getDocumentsByType(type, options = {}) { try { const client = getSanityClient(); const { limit = 10, offset = 0, orderBy, includeTotal = false } = options; const filterConditions = buildFilterConditions(options); const projection = buildProjection(options.projection); const orderClause = buildOrderClause(orderBy); const paginationClause = buildPaginationClause(limit, offset); // Main query for documents const documentsQuery = `*[_type == $type${filterConditions}]${orderClause}${paginationClause}${projection}`; const params = { type, ...(options.params || {}) }; const documents = await client.fetch(documentsQuery, params); // Optional total count query let total; if (includeTotal) { const countQuery = `count(*[_type == $type${filterConditions}])`; total = await client.fetch(countQuery, params); } return { documents, ...(total !== undefined && { total }), offset, limit: documents.length, }; } catch (error) { const sanityError = { message: `Failed to fetch documents by type: ${error instanceof Error ? error.message : 'Unknown error'}`, details: { type, options, error }, }; throw sanityError; } } /** * Execute a custom GROQ query * * @template T The expected result type * @param query GROQ query string * @param params Query parameters * @param options Fetch options * @returns Promise resolving to the query result * * @example * ```typescript * const posts = await getDocuments<SanityPost[]>( * `*[_type == "post" && references($authorId)]{ * _id, title, slug, publishedAt * }`, * { authorId: 'author-123' } * ); * ``` */ export async function getDocuments(query, params = {}, _options = {}) { try { const client = getSanityClient(); const result = await client.fetch(query, params); return result; } catch (error) { const sanityError = { message: `Failed to execute custom query: ${error instanceof Error ? error.message : 'Unknown error'}`, details: { query, params, error }, }; throw sanityError; } } // ============================================================================ // CONVENIENCE FUNCTIONS // ============================================================================ /** * Fetch published documents of a specific type * * @template T The expected document type * @param type Document type * @param options List options (status filter will be overridden to 'published') * @returns Promise resolving to published documents */ export async function getPublishedDocuments(type, options = {}) { return getDocumentsByType(type, { ...options, filter: { ...options.filter, status: 'published', }, }); } /** * Fetch featured documents of a specific type * * @template T The expected document type * @param type Document type * @param options List options (featured filter will be overridden to true) * @returns Promise resolving to featured documents */ export async function getFeaturedDocuments(type, options = {}) { return getDocumentsByType(type, { ...options, filter: { ...options.filter, featured: true, }, }); } /** * Fetch recent documents of a specific type * * @template T The expected document type * @param type Document type * @param options List options (orderBy will be overridden to '_createdAt desc') * @returns Promise resolving to recent documents */ export async function getRecentDocuments(type, options = {}) { return getDocumentsByType(type, { ...options, orderBy: '_createdAt desc', }); } /** * Search documents by text content * * @template T The expected document type * @param type Document type * @param searchTerm Search term * @param searchFields Fields to search in (default: ['title']) * @param options Additional list options * @returns Promise resolving to matching documents * * @example * ```typescript * const posts = await searchDocuments<SanityPost>( * 'post', * 'javascript', * ['title', 'excerpt'], * { limit: 5 } * ); * ``` */ export async function searchDocuments(type, searchTerm, searchFields = ['title'], options = {}) { const searchConditions = searchFields.map(field => `${field} match $searchTerm`).join(' || '); const additionalFilter = `(${searchConditions})`; return getDocumentsByType(type, { ...options, additionalFilter: options.additionalFilter ? `${options.additionalFilter} && ${additionalFilter}` : additionalFilter, params: { searchTerm: `${searchTerm}*`, ...(options.params || {}), }, }); } // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /** * Check if a document exists by slug * * @param type Document type * @param slug Document slug * @param options Fetch options * @returns Promise resolving to boolean indicating existence */ export async function documentExistsBySlug(type, slug, options = {}) { try { const result = await getDocumentBySlug(type, slug, { ...options, projection: '_id', }); return result !== null; } catch { return false; } } /** * Check if a document exists by ID * * @param id Document ID * @param options Fetch options * @returns Promise resolving to boolean indicating existence */ export async function documentExistsById(id, options = {}) { try { const result = await getDocumentById(id, { ...options, projection: '_id', }); return result !== null; } catch { return false; } } /** * Get document count by type * * @param type Document type * @param options Filter options * @returns Promise resolving to document count */ export async function getDocumentCount(type, options = {}) { try { const client = getSanityClient(); const filterConditions = buildFilterConditions(options); const query = `count(*[_type == $type${filterConditions}])`; const params = { type }; return await client.fetch(query, params); } catch (error) { const sanityError = { message: `Failed to get document count: ${error instanceof Error ? error.message : 'Unknown error'}`, details: { type, options, error }, }; throw sanityError; } }