UNPKG

@ai-growth/nextjs

Version:

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

246 lines (245 loc) 8.71 kB
/** * @fileoverview SEO Utility Functions * * This module provides utility functions for processing SEO data, * generating meta tags, and creating JSON-LD structured data. */ export * from './meta-tags'; export * from './structured-data'; export * from './content-analysis'; export * from './url-utils'; export * from './image-processing'; import { generateMetaTags } from './meta-tags'; import { generateStructuredData } from './structured-data'; import { analyzeContent } from './content-analysis'; import { buildURL } from './url-utils'; import { processImage } from './image-processing'; /** * Default SEO configuration */ export const DEFAULT_SEO_CONFIG = { siteName: 'AI Growth', titleTemplate: '%s | AI Growth', titleSeparator: ' | ', defaultDescription: 'Advanced AI-powered content management and growth solutions.', defaultImage: '/default-og-image.jpg', baseUrl: 'https://aigrowth.com', locale: 'en_US', twitterSite: '@aigrowth', organization: { '@type': 'Organization', '@context': 'https://schema.org', name: 'AI Growth', url: 'https://aigrowth.com', logo: 'https://aigrowth.com/logo.png', sameAs: ['https://twitter.com/aigrowth', 'https://linkedin.com/company/aigrowth'], }, defaultOGType: 'website', defaultTwitterCard: 'summary_large_image', }; /** * Process SEO data and generate complete SEO result * * @param props - SEO Head component props * @param config - SEO configuration (optional, uses defaults) * @returns Complete SEO result with meta tags and structured data */ export function processSEO(props, config = {}) { const finalConfig = { ...DEFAULT_SEO_CONFIG, ...config }; const { content, siteSettings, seo, template, url, baseUrl } = props; // Analyze content to determine type and characteristics const contentAnalysis = analyzeContent(content, template); // Build final URL const finalUrl = url || buildURL({ baseUrl: baseUrl || finalConfig.baseUrl, path: content?.slug?.current || '', }); // Process SEO data with fallbacks const processedSEO = processSEOData(content, siteSettings, seo, finalConfig); // Generate meta tags const metaTags = generateMetaTags({ seo: processedSEO, content, config: finalConfig, url: finalUrl, contentAnalysis, includeOpenGraph: props.includeOpenGraph ?? true, includeTwitterCard: props.includeTwitterCard ?? true, additionalMeta: props.additionalMeta || [], }); // Generate structured data const structuredData = generateStructuredData({ seo: processedSEO, content, config: finalConfig, url: finalUrl, contentAnalysis, customStructuredData: props.customStructuredData || [], organization: props.organization || finalConfig.organization, }); return { metaTags, structuredData, title: processedSEO.title || finalConfig.siteName, description: processedSEO.description || finalConfig.defaultDescription, canonical: processedSEO.canonical || finalUrl, image: processedSEO.image ? processImage(processedSEO.image, finalConfig.baseUrl).url : undefined, }; } /** * Process and merge SEO data from multiple sources with proper fallbacks * * @param content - Content data * @param siteSettings - Site settings * @param customSEO - Custom SEO overrides * @param config - SEO configuration * @returns Processed SEO data */ export function processSEOData(content, siteSettings, customSEO, config = DEFAULT_SEO_CONFIG) { // Start with default/site settings const baseSEO = { title: siteSettings?.title || config.siteName, description: siteSettings?.description || config.defaultDescription, canonical: config.baseUrl, image: siteSettings?.defaultSeo?.image, keywords: siteSettings?.defaultSeo?.keywords || [], noIndex: siteSettings?.defaultSeo?.noIndex || false, noFollow: siteSettings?.defaultSeo?.noFollow || false, locale: config.locale, }; // Merge with content SEO if (content?.seo) { Object.assign(baseSEO, { title: content.seo.title || content.title || baseSEO.title, description: content.seo.description || content.excerpt || baseSEO.description, canonical: content.seo.canonical || baseSEO.canonical, image: content.seo.image || content.mainImage || baseSEO.image, keywords: content.seo.keywords || baseSEO.keywords, noIndex: content.seo.noIndex ?? baseSEO.noIndex, noFollow: content.seo.noFollow ?? baseSEO.noFollow, }); } // Add content-specific data if (content) { baseSEO.publishedTime = content.publishedAt; baseSEO.modifiedTime = content._updatedAt; baseSEO.author = content.author; baseSEO.readingTime = content.readingTime; // Extract tags from categories or explicit tags if (content.categories && Array.isArray(content.categories)) { baseSEO.tags = content.categories.map((cat) => cat.title || cat.name).filter(Boolean); } } // Apply custom SEO overrides if (customSEO) { Object.assign(baseSEO, customSEO); } // Process title with template if (baseSEO.title && baseSEO.title !== config.siteName) { baseSEO.title = config.titleTemplate.replace('%s', baseSEO.title); } return baseSEO; } /** * Sanitize and validate SEO data * * @param seo - SEO data to sanitize * @returns Sanitized SEO data */ export function sanitizeSEO(seo) { const sanitized = { ...seo }; // Sanitize title (max 60 characters for optimal display) if (sanitized.title) { sanitized.title = sanitized.title.trim(); if (sanitized.title.length > 60) { sanitized.title = sanitized.title.substring(0, 57) + '...'; } } // Sanitize description (max 160 characters for optimal display) if (sanitized.description) { sanitized.description = sanitized.description.trim(); if (sanitized.description.length > 160) { sanitized.description = sanitized.description.substring(0, 157) + '...'; } } // Validate keywords (max 10 for optimal performance) if (sanitized.keywords && sanitized.keywords.length > 10) { sanitized.keywords = sanitized.keywords.slice(0, 10); } // Ensure canonical URL is valid if (sanitized.canonical && !isValidURL(sanitized.canonical)) { delete sanitized.canonical; } return sanitized; } /** * Check if a string is a valid URL * * @param url - URL string to validate * @returns Whether the URL is valid */ export function isValidURL(url) { try { new URL(url); return true; } catch { return false; } } /** * Generate SEO-friendly slug from text * * @param text - Text to convert to slug * @returns SEO-friendly slug */ export function generateSlug(text) { return text .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special characters .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single .trim() .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens } /** * Extract text content from portable text or HTML * * @param content - Content to extract text from * @param maxLength - Maximum length of extracted text * @returns Extracted text */ export function extractTextContent(content, maxLength = 160) { if (typeof content === 'string') { return content .replace(/<[^>]*>/g, '') .trim() .substring(0, maxLength); } if (Array.isArray(content)) { const text = content .filter(block => block._type === 'block') .map(block => block.children ?.filter((child) => child._type === 'span') ?.map((span) => span.text) ?.join('')) .join(' '); return text.trim().substring(0, maxLength); } return ''; } /** * Calculate estimated reading time for content * * @param content - Content to analyze * @param wordsPerMinute - Reading speed (default: 200 wpm) * @returns Estimated reading time in minutes */ export function calculateReadingTime(content, wordsPerMinute = 200) { const text = extractTextContent(content, Number.MAX_SAFE_INTEGER); const wordCount = text.split(/\s+/).filter(word => word.length > 0).length; return Math.ceil(wordCount / wordsPerMinute); }