UNPKG

astro-loader-hashnode

Version:

Astro content loader for seamlessly integrating Hashnode blog posts into your Astro website using the Content Layer API

318 lines (317 loc) 8.42 kB
/** * SEO Utilities */ import { extractTextFromHtml, generateExcerpt } from './content.js'; /** * Generate SEO metadata from post data */ export function generateSEOMetadata(data) { const { title, subtitle, brief, content, url, canonicalUrl, coverImage, author, publishedAt, updatedAt, tags, seo, } = data; // Use explicit SEO data if provided, otherwise generate from content const seoTitle = seo?.title || optimizeTitle(title, subtitle); const seoDescription = seo?.description || generateMetaDescription(brief, content); const metadata = { title: seoTitle, description: seoDescription, canonical: canonicalUrl || url, // Open Graph ogTitle: seoTitle, ogDescription: seoDescription, ogImage: coverImage?.url, ogType: 'article', // Twitter Card twitterCard: coverImage?.url ? 'summary_large_image' : 'summary', twitterTitle: seoTitle, twitterDescription: seoDescription, twitterImage: coverImage?.url, // Article metadata author: author?.name, publishedTime: publishedAt?.toISOString(), modifiedTime: updatedAt?.toISOString(), section: 'blog', tags: tags?.map(tag => tag.name), keywords: generateKeywords(title, brief, content, tags), }; return metadata; } /** * Optimize title for SEO (length and format) */ export function optimizeTitle(title, subtitle) { if (!title) return ''; let optimizedTitle = title.trim(); // Add subtitle if it exists and title is short enough if (subtitle && optimizedTitle.length + subtitle.length + 3 <= 60) { optimizedTitle = `${optimizedTitle} - ${subtitle}`; } // Ensure title is not too long for search results if (optimizedTitle.length > 60) { const truncated = optimizedTitle.substring(0, 57); const lastSpaceIndex = truncated.lastIndexOf(' '); if (lastSpaceIndex > 30) { optimizedTitle = `${truncated.substring(0, lastSpaceIndex)}...`; } else { optimizedTitle = `${truncated}...`; } } return optimizedTitle; } /** * Generate meta description from content */ export function generateMetaDescription(brief, content) { // Use brief if available if (brief) { return generateExcerpt(brief, 160); } // Extract from content if (content) { const text = extractTextFromHtml(content); return generateExcerpt(text, 160); } return ''; } /** * Generate keywords from content */ export function generateKeywords(title, brief, content, tags) { const keywords = new Set(); // Add tags as primary keywords if (tags) { tags.forEach(tag => { keywords.add(tag.name.toLowerCase()); }); } // Extract keywords from title if (title) { const titleWords = extractImportantWords(title); titleWords.forEach(word => keywords.add(word)); } // Extract keywords from brief if (brief) { const briefWords = extractImportantWords(brief); briefWords.slice(0, 3).forEach(word => keywords.add(word)); // Limit to top 3 } // Extract keywords from content (limited) if (content && keywords.size < 10) { const text = extractTextFromHtml(content); const contentWords = extractImportantWords(text); contentWords.slice(0, 5).forEach(word => keywords.add(word)); // Limit to top 5 } return Array.from(keywords).slice(0, 10); // Limit total keywords } /** * Extract important words from text (simple keyword extraction) */ function extractImportantWords(text) { if (!text) return []; // Common stop words to exclude const stopWords = new Set([ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'can', 'will', 'just', 'should', 'now', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their', ]); // Extract words and count frequency const words = text .toLowerCase() .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(word => word.length > 2 && !stopWords.has(word)); // Count word frequency const frequency = new Map(); words.forEach(word => { frequency.set(word, (frequency.get(word) || 0) + 1); }); // Sort by frequency and return top words return Array.from(frequency.entries()) .sort((a, b) => b[1] - a[1]) // Sort by frequency (descending) .map(([word]) => word) .slice(0, 10); // Return top 10 } /** * Generate JSON-LD structured data for blog post */ export function generateJSONLD(data) { const { title, description, url, coverImage, author, publishedAt, updatedAt, organization, } = data; const structuredData = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: title, description, url, datePublished: publishedAt.toISOString(), dateModified: (updatedAt || publishedAt).toISOString(), author: { '@type': 'Person', name: author.name, ...(author.url && { url: author.url }), }, ...(coverImage && { image: { '@type': 'ImageObject', url: coverImage.url, }, }), }; if (organization) { structuredData.publisher = { '@type': 'Organization', name: organization.name, url: organization.url, ...(organization.logo && { logo: { '@type': 'ImageObject', url: organization.logo, }, }), }; } return structuredData; } /** * Validate SEO metadata */ export function validateSEOMetadata(metadata) { const warnings = []; const errors = []; // Required fields if (!metadata.title) { errors.push('Title is required'); } else if (metadata.title.length > 60) { warnings.push('Title is longer than 60 characters'); } else if (metadata.title.length < 10) { warnings.push('Title is shorter than 10 characters'); } if (!metadata.description) { errors.push('Description is required'); } else if (metadata.description.length > 160) { warnings.push('Description is longer than 160 characters'); } else if (metadata.description.length < 50) { warnings.push('Description is shorter than 50 characters'); } // Optional but recommended fields if (!metadata.ogImage) { warnings.push('Open Graph image is missing'); } if (!metadata.canonical) { warnings.push('Canonical URL is missing'); } if (!metadata.keywords || metadata.keywords.length === 0) { warnings.push('Keywords are missing'); } return { isValid: errors.length === 0, warnings, errors, }; }