UNPKG

svarog-ui-core

Version:

Core components of Svarog UI without themes

377 lines (332 loc) 10.1 kB
// src/utils/seoManager.js /** * SEO optimization and validation utilities */ /** * Validates SEO metadata for optimal search engine performance * @param {Object} seoData - SEO metadata to validate * @returns {Object} Validation result with recommendations */ export const validateSEO = (seoData) => { const issues = []; const warnings = []; const recommendations = []; // Title validation if (!seoData.title) { issues.push('Missing page title'); } else { if (seoData.title.length < 30) { warnings.push('Title is shorter than recommended (30-60 characters)'); } if (seoData.title.length > 60) { warnings.push('Title exceeds recommended length (60 characters)'); } if (!seoData.title.includes('|') && !seoData.title.includes('-')) { recommendations.push('Consider adding brand name separator (| or -)'); } } // Description validation if (!seoData.description) { issues.push('Missing meta description'); } else { if (seoData.description.length < 120) { warnings.push( 'Description is shorter than recommended (120-160 characters)' ); } if (seoData.description.length > 160) { warnings.push('Description exceeds recommended length (160 characters)'); } } // Keywords validation if (seoData.keywords && seoData.keywords.length > 10) { warnings.push('Too many keywords (recommended: 5-10)'); } // Open Graph validation if (!seoData.openGraph) { recommendations.push( 'Add Open Graph metadata for better social media sharing' ); } else { if (!seoData.openGraph.image) { warnings.push('Missing Open Graph image'); } if (seoData.openGraph.image && !isValidImageUrl(seoData.openGraph.image)) { warnings.push('Invalid Open Graph image URL'); } } // Twitter Card validation if (!seoData.twitterCard) { recommendations.push( 'Add Twitter Card metadata for better Twitter sharing' ); } // Canonical URL validation if (!seoData.canonicalUrl) { recommendations.push( 'Add canonical URL to prevent duplicate content issues' ); } else if (!isValidUrl(seoData.canonicalUrl)) { issues.push('Invalid canonical URL format'); } // Structured data validation if (!seoData.schema) { recommendations.push('Add structured data (Schema.org) for rich snippets'); } return { valid: issues.length === 0, score: calculateSEOScore(issues, warnings, recommendations), issues, warnings, recommendations, }; }; /** * Calculates SEO score based on validation results * @param {Array} issues - Critical issues * @param {Array} warnings - Non-critical warnings * @param {Array} recommendations - Improvement suggestions * @returns {number} Score from 0-100 */ const calculateSEOScore = (issues, warnings, recommendations) => { let score = 100; // Deduct points for issues score -= issues.length * 20; // Deduct points for warnings score -= warnings.length * 10; // Deduct points for missing recommendations score -= recommendations.length * 5; return Math.max(0, score); }; /** * Validates if URL is properly formatted * @param {string} url - URL to validate * @returns {boolean} Whether URL is valid */ const isValidUrl = (url) => { try { new URL(url); return true; } catch { return false; } }; /** * Validates if image URL is accessible and properly formatted * @param {string} imageUrl - Image URL to validate * @returns {boolean} Whether image URL is valid */ const isValidImageUrl = (imageUrl) => { if (!isValidUrl(imageUrl)) { return false; } const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']; const url = new URL(imageUrl); return imageExtensions.some((ext) => url.pathname.toLowerCase().includes(ext) ); }; /** * Generates SEO-friendly slug from title * @param {string} title - Page title * @returns {string} URL-friendly slug */ export const generateSlug = (title) => { return title .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') // Remove special characters .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens }; /** * Optimizes SEO metadata with best practices * @param {Object} seoData - Original SEO data * @param {Object} options - Optimization options * @returns {Object} Optimized SEO data */ export const optimizeSEO = (seoData, options = {}) => { const optimized = { ...seoData }; // Optimize title if (optimized.title) { optimized.title = optimizeTitle(optimized.title, options.brandName); } // Optimize description if (optimized.description) { optimized.description = optimizeDescription(optimized.description); } // Add default robots if missing if (!optimized.robots) { optimized.robots = { index: true, follow: true }; } // Generate canonical URL if missing if (!optimized.canonicalUrl && options.baseUrl && optimized.title) { const slug = generateSlug(optimized.title); optimized.canonicalUrl = `${options.baseUrl}/${slug}`; } // Add default Open Graph if missing critical data if (!optimized.openGraph && optimized.title && optimized.description) { optimized.openGraph = { title: optimized.title, description: optimized.description, type: 'website', }; } // Add image dimensions for Open Graph if possible if (optimized.openGraph?.image && !optimized.openGraph.imageWidth) { optimized.openGraph.imageWidth = 1200; optimized.openGraph.imageHeight = 630; } return optimized; }; /** * Optimizes page title with best practices * @param {string} title - Original title * @param {string} brandName - Brand name to append * @returns {string} Optimized title */ const optimizeTitle = (title, brandName) => { let optimized = title.trim(); // Add brand name if missing and provided if (brandName && !optimized.includes(brandName)) { if (optimized.length + brandName.length + 3 <= 60) { optimized = `${optimized} | ${brandName}`; } } // Truncate if too long if (optimized.length > 60) { optimized = `${optimized.substring(0, 57)}...`; } return optimized; }; /** * Optimizes meta description with best practices * @param {string} description - Original description * @returns {string} Optimized description */ const optimizeDescription = (description) => { let optimized = description.trim(); // Ensure it ends with proper punctuation if (!/[.!?]$/.test(optimized)) { optimized += '.'; } // Truncate if too long if (optimized.length > 160) { optimized = `${optimized.substring(0, 157)}...`; } return optimized; }; /** * Generates structured data for common page types * @param {string} type - Page type (article, product, website, etc.) * @param {Object} data - Page data * @returns {Object} JSON-LD structured data */ export const generateStructuredData = (type, data) => { const baseSchema = { '@context': 'https://schema.org', }; switch (type) { case 'article': { return { ...baseSchema, '@type': 'Article', headline: data.title, description: data.description, author: data.author ? { '@type': 'Person', name: data.author, } : undefined, datePublished: data.publishDate, dateModified: data.modifiedDate, image: data.image, publisher: data.publisher ? { '@type': 'Organization', name: data.publisher.name, logo: data.publisher.logo ? { '@type': 'ImageObject', url: data.publisher.logo, } : undefined, } : undefined, }; } case 'product': { return { ...baseSchema, '@type': 'Product', name: data.title, description: data.description, image: data.image, offers: data.price ? { '@type': 'Offer', price: data.price, priceCurrency: data.currency || 'USD', availability: data.availability || 'https://schema.org/InStock', } : undefined, brand: data.brand ? { '@type': 'Brand', name: data.brand, } : undefined, }; } case 'website': default: { return { ...baseSchema, '@type': 'WebSite', name: data.title, description: data.description, url: data.url, }; } } }; /** * Extracts SEO data from CMS content * @param {Object} cmsContent - Raw CMS content * @param {Object} mappings - Field mapping configuration * @returns {Object} Extracted SEO data */ export const extractSEOFromCMS = (cmsContent, mappings = {}) => { const defaultMappings = { title: 'title', description: 'description', keywords: 'keywords', image: 'featured_image', publishDate: 'published_at', author: 'author', }; const fieldMappings = { ...defaultMappings, ...mappings }; const extractField = (path) => { return path.split('.').reduce((obj, key) => obj?.[key], cmsContent); }; return { title: extractField(fieldMappings.title), description: extractField(fieldMappings.description), keywords: extractField(fieldMappings.keywords), openGraph: { title: extractField(fieldMappings.title), description: extractField(fieldMappings.description), image: extractField(fieldMappings.image), type: cmsContent.content_type || 'website', }, schema: generateStructuredData(cmsContent.content_type || 'website', { title: extractField(fieldMappings.title), description: extractField(fieldMappings.description), image: extractField(fieldMappings.image), publishDate: extractField(fieldMappings.publishDate), author: extractField(fieldMappings.author), }), }; };