@ai-growth/nextjs
Version:
Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering
249 lines (248 loc) • 8.78 kB
JavaScript
/**
* @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,
});
const result = {
metaTags,
structuredData,
title: processedSEO.title || finalConfig.siteName,
description: processedSEO.description || finalConfig.defaultDescription,
canonical: processedSEO.canonical || finalUrl,
};
if (processedSEO.image) {
result.image = processImage(processedSEO.image, finalConfig.baseUrl).url;
}
return result;
}
/**
* 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,
keywords: siteSettings?.defaultSeo?.keywords || [],
noIndex: siteSettings?.defaultSeo?.noIndex || false,
noFollow: siteSettings?.defaultSeo?.noFollow || false,
locale: config.locale,
};
if (siteSettings?.defaultSeo?.image) {
baseSEO.image = siteSettings.defaultSeo.image;
}
// 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);
}