@ai-growth/nextjs
Version:
Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering
357 lines (356 loc) • 11.9 kB
JavaScript
/**
* @fileoverview Image Optimization Utilities for Next.js
*
* This module provides utilities for optimizing images with Next.js Image component,
* including Sanity CDN integration, responsive sizing, and format optimization.
*/
import { buildSanityImageUrl } from './seo/image-processing';
// ============================================================================
// CONFIGURATION
// ============================================================================
/**
* Default image optimization configuration
*/
export const DEFAULT_IMAGE_CONFIG = {
quality: 80,
format: 'auto',
lazyLoading: true,
breakpoints: [
{ name: 'mobile', minWidth: 0, imageWidth: 640 },
{ name: 'tablet', minWidth: 768, imageWidth: 1024 },
{ name: 'desktop', minWidth: 1024, imageWidth: 1280 },
{ name: 'large', minWidth: 1280, imageWidth: 1920 },
],
blurDataURL: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=',
};
/**
* Common aspect ratios for different use cases
*/
export const ASPECT_RATIOS = {
square: 1,
landscape: 16 / 9,
portrait: 3 / 4,
ultrawide: 21 / 9,
golden: 1.618,
card: 4 / 3,
hero: 21 / 9,
thumbnail: 1,
};
// ============================================================================
// SANITY IMAGE LOADER
// ============================================================================
/**
* Create a Sanity image loader for Next.js Image component
*
* @param projectId - Sanity project ID
* @param dataset - Sanity dataset
* @returns Next.js Image loader function
*/
export function createSanityImageLoader(_projectId = 'project', _dataset = 'dataset') {
return ({ src, width, quality = 80 }) => {
// Handle already processed URLs
if (src.startsWith('http')) {
const url = new URL(src);
url.searchParams.set('w', width.toString());
url.searchParams.set('q', quality.toString());
return url.toString();
}
// Handle Sanity asset references
if (src.startsWith('image-')) {
return buildSanityImageUrl(src, {
width,
quality,
format: 'auto',
});
}
// Fallback for other URLs
return src;
};
}
/**
* Default Sanity image loader (uses placeholder project/dataset)
*/
export const sanityImageLoader = createSanityImageLoader();
// ============================================================================
// IMAGE PROCESSING FUNCTIONS
// ============================================================================
/**
* Process a Sanity image for Next.js Image component
*
* @param image - Sanity image object or string URL
* @param options - Sizing and optimization options
* @param config - Image optimization configuration
* @returns Optimized image data for Next.js Image
*/
export function processImageForNextJS(image, options = {}, config = DEFAULT_IMAGE_CONFIG) {
// Default dimensions
const defaultWidth = 1200;
const defaultHeight = 630;
// Handle missing image
if (!image) {
return {
src: '/placeholder-image.jpg',
alt: 'Placeholder image',
width: options.width || defaultWidth,
height: options.height || defaultHeight,
blurDataURL: config.blurDataURL,
loader: sanityImageLoader,
};
}
// Handle string URLs
if (typeof image === 'string') {
const width = options.width || defaultWidth;
const height = options.height || defaultHeight;
return {
src: image,
alt: 'Image',
width,
height,
sizes: generateSizesAttribute(config.breakpoints),
blurDataURL: config.blurDataURL,
loader: sanityImageLoader,
};
}
// Handle Sanity image objects
if (image.asset?._ref) {
const width = options.width || defaultWidth;
const height = options.height || (options.aspectRatio ? width / options.aspectRatio : defaultHeight);
const optimizedSrc = buildSanityImageUrl(image.asset._ref, {
width,
height,
fit: options.fit || 'crop',
format: options.format || config.format,
quality: options.quality || config.quality,
});
return {
src: optimizedSrc,
alt: image.alt || 'Image',
width,
height,
sizes: generateSizesAttribute(config.breakpoints),
blurDataURL: config.blurDataURL || generateBlurDataURL(image),
loader: sanityImageLoader,
};
}
// Fallback
return {
src: '/placeholder-image.jpg',
alt: 'Image',
width: options.width || defaultWidth,
height: options.height || defaultHeight,
blurDataURL: config.blurDataURL,
loader: sanityImageLoader,
};
}
/**
* Generate responsive image data for different use cases
*
* @param image - Sanity image object or string URL
* @param useCase - The intended use case
* @param config - Image optimization configuration
* @returns Optimized image data
*/
export function generateResponsiveImageData(image, useCase, config = DEFAULT_IMAGE_CONFIG) {
let options;
let priority = false;
switch (useCase) {
case 'hero':
options = {
width: 1920,
height: 1080,
aspectRatio: ASPECT_RATIOS.hero,
fit: 'cover',
quality: 90,
};
priority = true;
break;
case 'featured':
options = {
width: 1200,
height: 630,
aspectRatio: ASPECT_RATIOS.landscape,
fit: 'cover',
quality: 85,
};
break;
case 'thumbnail':
options = {
width: 400,
height: 400,
aspectRatio: ASPECT_RATIOS.square,
fit: 'cover',
quality: 80,
};
break;
case 'avatar':
options = {
width: 200,
height: 200,
aspectRatio: ASPECT_RATIOS.square,
fit: 'cover',
quality: 85,
};
break;
case 'card':
options = {
width: 600,
height: 400,
aspectRatio: ASPECT_RATIOS.card,
fit: 'cover',
quality: 80,
};
break;
case 'gallery':
options = {
width: 800,
height: 600,
aspectRatio: ASPECT_RATIOS.card,
fit: 'cover',
quality: 85,
};
break;
default:
options = {
width: 800,
height: 600,
fit: 'cover',
quality: 80,
};
}
const result = processImageForNextJS(image, options, config);
result.priority = priority;
return result;
}
/**
* Generate sizes attribute for responsive images
*
* @param breakpoints - Responsive breakpoint configuration
* @returns CSS sizes attribute string
*/
export function generateSizesAttribute(breakpoints) {
const sizes = breakpoints.map((bp, index) => {
if (index === breakpoints.length - 1) {
// Last breakpoint doesn't need a condition
return `${bp.imageWidth}px`;
}
return `(min-width: ${bp.minWidth}px) ${bp.imageWidth}px`;
});
return sizes.join(', ');
}
/**
* Generate a blur data URL for placeholder
*
* @param image - Sanity image object
* @returns Base64 blur data URL
*/
export function generateBlurDataURL(image) {
if (!image.asset?._ref) {
return undefined;
}
// Generate a tiny version of the image for blur placeholder
const tinyUrl = buildSanityImageUrl(image.asset._ref, {
width: 10,
height: 10,
quality: 20,
format: 'jpg',
});
// In a real implementation, you might want to fetch this and convert to base64
// For now, return the URL (Next.js can handle this)
return tinyUrl;
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Calculate optimal image dimensions based on container and content
*
* @param containerWidth - Container width in pixels
* @param containerHeight - Container height in pixels
* @param imageAspectRatio - Image aspect ratio
* @param fitMode - How the image should fit in the container
* @returns Optimal dimensions
*/
export function calculateOptimalDimensions(containerWidth, containerHeight, imageAspectRatio, fitMode = 'cover') {
const containerAspectRatio = containerWidth / containerHeight;
if (fitMode === 'cover') {
if (imageAspectRatio > containerAspectRatio) {
// Image is wider, fit to height
return {
width: containerHeight * imageAspectRatio,
height: containerHeight,
};
}
else {
// Image is taller, fit to width
return {
width: containerWidth,
height: containerWidth / imageAspectRatio,
};
}
}
else {
// contain mode
if (imageAspectRatio > containerAspectRatio) {
// Image is wider, fit to width
return {
width: containerWidth,
height: containerWidth / imageAspectRatio,
};
}
else {
// Image is taller, fit to height
return {
width: containerHeight * imageAspectRatio,
height: containerHeight,
};
}
}
}
/**
* Get device pixel ratio considerations for image sizing
*
* @param baseWidth - Base image width
* @param baseHeight - Base image height
* @param maxDPR - Maximum device pixel ratio to consider
* @returns Adjusted dimensions
*/
export function getHighDPIDimensions(baseWidth, baseHeight, maxDPR = 2) {
return {
width: Math.round(baseWidth * maxDPR),
height: Math.round(baseHeight * maxDPR),
};
}
/**
* Validate image URL and format
*
* @param url - Image URL to validate
* @returns Validation result
*/
export function validateImageUrl(url) {
const errors = [];
try {
const urlObj = new URL(url);
// Check if it's a valid image format
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.svg'];
const hasValidExtension = validExtensions.some(ext => urlObj.pathname.toLowerCase().endsWith(ext));
if (!hasValidExtension && !url.includes('cdn.sanity.io')) {
errors.push('URL does not appear to be a valid image format');
}
// Check protocol
if (!['http:', 'https:'].includes(urlObj.protocol)) {
errors.push('Image URL must use HTTP or HTTPS protocol');
}
const format = urlObj.pathname.split('.').pop()?.toLowerCase();
return {
isValid: errors.length === 0,
format,
errors,
};
}
catch {
return {
isValid: false,
errors: ['Invalid URL format'],
};
}
}