UNPKG

@ai-growth/nextjs

Version:

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

309 lines (308 loc) 9.52 kB
/** * @fileoverview Structured Data Generation Utilities * * This module provides functions for generating JSON-LD structured data * for different content types according to Schema.org standards. */ import { processImage } from './image-processing'; import { buildBreadcrumbUrls } from './url-utils'; /** * Generate all structured data for a page * * @param options - Structured data generation options * @returns Array of structured data objects */ export function generateStructuredData(options) { const { seo, content, config, url, contentAnalysis, customStructuredData, organization } = options; const structuredData = []; // Website/Organization data (always include) structuredData.push(generateWebsiteData(config, organization)); // Content-specific structured data switch (contentAnalysis.type) { case 'article': if (content) { structuredData.push(generateArticleData(content, seo, config, url, organization)); } break; case 'author': if (content) { structuredData.push(generatePersonData(content, config, url)); } break; case 'page': case 'home': // For pages, we might want to add specific WebPage data structuredData.push(generateWebPageData(seo, config, url)); break; } // Breadcrumb data (for all pages except home) if (contentAnalysis.type !== 'home') { structuredData.push(generateBreadcrumbData(url, config.baseUrl)); } // Add custom structured data structuredData.push(...customStructuredData); // Add any SEO-specific structured data if (seo.structuredData) { structuredData.push(...seo.structuredData); } return structuredData; } /** * Generate website structured data * * @param config - SEO configuration * @param organization - Organization data * @returns Website structured data */ export function generateWebsiteData(config, organization) { return { '@context': 'https://schema.org', '@type': 'WebSite', name: config.siteName, url: config.baseUrl, description: config.defaultDescription, publisher: organization, potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl}/search?q={search_term_string}`, 'query-input': 'required name=search_term_string', }, }; } /** * Generate web page structured data * * @param seo - SEO data * @param config - SEO configuration * @param url - Page URL * @returns WebPage structured data */ export function generateWebPageData(seo, config, url) { return { '@context': 'https://schema.org', '@type': 'WebPage', '@id': url, name: seo.title || config.siteName, description: seo.description || config.defaultDescription, url, inLanguage: seo.locale?.replace('_', '-') || config.locale.replace('_', '-'), isPartOf: { '@type': 'WebSite', '@id': config.baseUrl, }, }; } /** * Generate article structured data * * @param content - Content data * @param seo - SEO data * @param config - SEO configuration * @param url - Article URL * @param organization - Organization data * @returns Article structured data */ export function generateArticleData(content, seo, config, url, organization) { const image = content.mainImage || seo.image; const processedImage = image ? processImage(image, config.baseUrl) : undefined; const article = { '@context': 'https://schema.org', '@type': seo.openGraph?.article ? 'BlogPosting' : 'Article', '@id': url, headline: seo.title || content.title, description: seo.description || content.excerpt, url, mainEntityOfPage: url, publisher: organization, }; // Add image if available if (processedImage) { article.image = processedImage.url; } // Add author information if (content.author) { if (typeof content.author === 'string') { article.author = { '@type': 'Person', name: content.author, }; } else if (content.author.name) { article.author = generatePersonData(content.author, config, ''); } } // Add dates if (content.publishedAt || seo.publishedTime) { article.datePublished = content.publishedAt || seo.publishedTime; } if (content._updatedAt || seo.modifiedTime) { article.dateModified = content._updatedAt || seo.modifiedTime; } // Add keywords if (seo.keywords && seo.keywords.length > 0) { article.keywords = seo.keywords; } // Add article section if (seo.section || (content.categories && content.categories.length > 0)) { article.articleSection = seo.section || content.categories[0]?.title || content.categories[0]?.name; } // Add word count if available if (seo.readingTime) { article.wordCount = seo.readingTime * 200; // Estimate based on reading time } return article; } /** * Generate person structured data * * @param person - Person data * @param config - SEO configuration * @param url - Person page URL * @returns Person structured data */ export function generatePersonData(person, config, url) { const personData = { '@context': 'https://schema.org', '@type': 'Person', name: person.name, }; if (url) { personData.url = url; personData['@id'] = url; } // Add image if (person.image) { const processedImage = processImage(person.image, config.baseUrl); personData.image = processedImage.url; } // Add social media links if (person.social) { const sameAs = []; Object.values(person.social).forEach(link => { if (typeof link === 'string' && link) { sameAs.push(link); } }); if (sameAs.length > 0) { personData.sameAs = sameAs; } } // Add job title if (person.jobTitle) { personData.jobTitle = person.jobTitle; } // Add works for organization if (person.worksFor) { personData.worksFor = person.worksFor; } return personData; } /** * Generate breadcrumb structured data * * @param currentUrl - Current page URL * @param baseUrl - Base site URL * @returns Breadcrumb structured data */ export function generateBreadcrumbData(currentUrl, baseUrl) { const breadcrumbs = buildBreadcrumbUrls(currentUrl, baseUrl); return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: breadcrumbs.map(breadcrumb => ({ '@type': 'ListItem', position: breadcrumb.position, name: breadcrumb.label, item: breadcrumb.url, })), }; } /** * Generate organization structured data * * @param config - SEO configuration * @returns Organization structured data */ export function generateOrganizationData(config) { return { '@context': 'https://schema.org', '@type': 'Organization', '@id': `${config.baseUrl}#organization`, name: config.siteName, url: config.baseUrl, logo: `${config.baseUrl}/logo.png`, sameAs: config.organization.sameAs || [], }; } /** * Validate structured data object * * @param data - Structured data to validate * @returns Validation result */ export function validateStructuredData(data) { const errors = []; const warnings = []; // Check required fields if (!data['@type']) { errors.push('Missing required @type field'); } if (!data['@context']) { warnings.push('Missing @context field (recommended)'); } else if (data['@context'] !== 'https://schema.org') { warnings.push('@context should be "https://schema.org"'); } // Type-specific validation switch (data['@type']) { case 'Article': case 'BlogPosting': case 'NewsArticle': { const article = data; if (!article.headline) { errors.push('Article missing required headline field'); } if (!article.datePublished) { warnings.push('Article missing datePublished field (recommended)'); } if (!article.author) { warnings.push('Article missing author field (recommended)'); } break; } case 'Person': { const person = data; if (!person.name) { errors.push('Person missing required name field'); } break; } case 'Organization': { const org = data; if (!org.name) { errors.push('Organization missing required name field'); } break; } } return { isValid: errors.length === 0, errors, warnings, }; } /** * Escape JSON-LD for safe HTML embedding * * @param data - Structured data object * @returns Escaped JSON string */ export function escapeJsonLD(data) { return JSON.stringify(data) .replace(/</g, '\\u003c') .replace(/>/g, '\\u003e') .replace(/&/g, '\\u0026') .replace(/'/g, '\\u0027') .replace(/"/g, '\\u0022'); }