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