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