UNPKG

seo-metadata-checker

Version:

Browser-focused SEO metadata checker for SPAs. Automatically monitors and displays SEO tags with real-time route change detection for React, Vue, Angular apps.

442 lines (370 loc) 13.8 kB
/** * SEO Metadata Checker * Browser-focused package for monitoring SEO metadata across SPAs * Supports React, Vue, Angular and vanilla applications */ class SeoMetadataChecker { constructor() { this.isInitialized = false; this.panel = null; this.currentUrl = window.location.href; this.observers = []; this.config = { autoShow: true, position: 'bottom-right', logToConsole: true, panelStyle: 'compact' // 'compact' or 'detailed' }; } init(userConfig = {}) { if (this.isInitialized) return; this.config = { ...this.config, ...userConfig }; this.isInitialized = true; // Initial check this.checkAndDisplay(); // Setup route monitoring this.setupRouteMonitoring(); // Setup global function window.getSeoMetadata = () => this.getSeoMetadata(); console.log('🔍 SEO Metadata Checker initialized'); } getSeoMetadata() { const metadata = { title: document.title || '', description: this.getMetaContent('description') || this.getMetaContent('og:description') || '', canonical: this.getCanonicalUrl(), openGraph: { title: this.getMetaContent('og:title') || document.title || '', description: this.getMetaContent('og:description') || this.getMetaContent('description') || '', url: this.getMetaContent('og:url') || window.location.href, image: this.getMetaContent('og:image') || '', type: this.getMetaContent('og:type') || 'website', siteName: this.getMetaContent('og:site_name') || '' }, twitter: { card: this.getMetaContent('twitter:card') || 'summary', title: this.getMetaContent('twitter:title') || this.getMetaContent('og:title') || document.title || '', description: this.getMetaContent('twitter:description') || this.getMetaContent('og:description') || this.getMetaContent('description') || '', image: this.getMetaContent('twitter:image') || this.getMetaContent('og:image') || '', site: this.getMetaContent('twitter:site') || '', creator: this.getMetaContent('twitter:creator') || '' }, viewport: this.getMetaContent('viewport') || '', robots: this.getMetaContent('robots') || 'index,follow', lang: document.documentElement.lang || document.querySelector('html')?.getAttribute('lang') || '', charset: document.characterSet || document.charset || 'UTF-8', url: window.location.href, timestamp: new Date().toISOString() }; // Additional schema.org structured data const structuredData = this.getStructuredData(); if (structuredData.length > 0) { metadata.structuredData = structuredData; } return metadata; } getMetaContent(name) { // Try different selector patterns const selectors = [ `meta[name="${name}"]`, `meta[property="${name}"]`, `meta[http-equiv="${name}"]` ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) return element.getAttribute('content'); } return null; } getCanonicalUrl() { const canonical = document.querySelector('link[rel="canonical"]'); return canonical ? canonical.href : window.location.href; } getStructuredData() { const scripts = document.querySelectorAll('script[type="application/ld+json"]'); const data = []; scripts.forEach(script => { try { const parsed = JSON.parse(script.textContent); data.push(parsed); } catch (e) { // Ignore invalid JSON } }); return data; } checkAndDisplay() { const metadata = this.getSeoMetadata(); if (this.config.logToConsole) { this.logToConsole(metadata); } if (this.config.autoShow) { this.showPanel(metadata); } } logToConsole(metadata) { console.group('🔍 SEO Metadata Analysis'); console.log('📄 Title:', metadata.title || '❌ Missing'); console.log('📝 Description:', metadata.description || '❌ Missing'); console.log('🔗 Canonical:', metadata.canonical); console.log('🌐 Language:', metadata.lang || '❌ Missing'); console.log('🤖 Robots:', metadata.robots); console.group('📱 Open Graph'); Object.entries(metadata.openGraph).forEach(([key, value]) => { console.log(`${key}:`, value || '❌ Missing'); }); console.groupEnd(); console.group('🐦 Twitter Cards'); Object.entries(metadata.twitter).forEach(([key, value]) => { console.log(`${key}:`, value || '❌ Missing'); }); console.groupEnd(); if (metadata.structuredData && metadata.structuredData.length > 0) { console.group('📊 Structured Data'); metadata.structuredData.forEach((data, index) => { console.log(`Schema ${index + 1}:`, data); }); console.groupEnd(); } console.groupEnd(); } showPanel(metadata) { // Remove existing panel if (this.panel) { this.panel.remove(); } this.panel = this.createPanel(metadata); document.body.appendChild(this.panel); } createPanel(metadata) { const panel = document.createElement('div'); panel.id = 'seo-metadata-panel'; panel.innerHTML = this.getPanelHTML(metadata); panel.style.cssText = this.getPanelCSS(); // Add close functionality const closeBtn = panel.querySelector('.seo-close'); closeBtn?.addEventListener('click', () => panel.remove()); // Add toggle functionality const toggleBtn = panel.querySelector('.seo-toggle'); const content = panel.querySelector('.seo-content'); toggleBtn?.addEventListener('click', () => { const isExpanded = content.style.display !== 'none'; content.style.display = isExpanded ? 'none' : 'block'; toggleBtn.textContent = isExpanded ? '▲' : '▼'; }); return panel; } getPanelHTML(metadata) { const issues = this.findIssues(metadata); const issueCount = issues.length; return ` <div class="seo-header"> <span class="seo-title">SEO Checker</span> <span class="seo-status ${issueCount === 0 ? 'good' : issueCount <= 3 ? 'warning' : 'error'}"> ${issueCount === 0 ? '✅' : issueCount <= 3 ? '⚠️' : '❌'} ${issueCount} issues </span> <button class="seo-toggle">▼</button> <button class="seo-close">×</button> </div> <div class="seo-content"> ${issueCount > 0 ? ` <div class="seo-section"> <h4>⚠️ Issues Found:</h4> <ul>${issues.map(issue => `<li>${issue}</li>`).join('')}</ul> </div> ` : '<div class="seo-section">✅ All basic SEO tags look good!</div>'} <div class="seo-section"> <h4>📄 Basic Info:</h4> <div><strong>Title:</strong> ${metadata.title || '❌ Missing'}</div> <div><strong>Description:</strong> ${metadata.description ? (metadata.description.length > 50 ? metadata.description.substring(0, 50) + '...' : metadata.description) : '❌ Missing'}</div> <div><strong>Language:</strong> ${metadata.lang || '❌ Missing'}</div> </div> <div class="seo-section"> <h4>🔗 Links:</h4> <div><strong>Canonical:</strong> ${metadata.canonical === metadata.url ? '✅ Same as current' : '🔗 ' + metadata.canonical}</div> </div> <div class="seo-section"> <h4>📱 Social:</h4> <div><strong>OG Image:</strong> ${metadata.openGraph.image ? '✅ Set' : '❌ Missing'}</div> <div><strong>Twitter Card:</strong> ${metadata.twitter.card}</div> </div> </div> `; } getPanelCSS() { return ` position: fixed; ${this.config.position.includes('right') ? 'right: 20px;' : 'left: 20px;'} ${this.config.position.includes('top') ? 'top: 20px;' : 'bottom: 20px;'} width: 300px; max-height: 500px; background: #1a1a1a; color: #fff; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px; line-height: 1.4; z-index: 999999; border: 1px solid #333; overflow: hidden; `; } findIssues(metadata) { const issues = []; if (!metadata.title) issues.push('Missing page title'); if (!metadata.description) issues.push('Missing meta description'); if (!metadata.lang) issues.push('Missing language attribute'); if (!metadata.openGraph.image) issues.push('Missing Open Graph image'); if (metadata.title && metadata.title.length > 60) issues.push('Title too long (>60 chars)'); if (metadata.description && metadata.description.length > 160) issues.push('Description too long (>160 chars)'); if (metadata.canonical !== metadata.url && !metadata.canonical.startsWith('http')) issues.push('Invalid canonical URL'); return issues; } setupRouteMonitoring() { // Monitor for URL changes (SPA routing) let lastUrl = window.location.href; const checkUrlChange = () => { const currentUrl = window.location.href; if (currentUrl !== lastUrl) { lastUrl = currentUrl; console.log('🔄 Route changed:', currentUrl); setTimeout(() => this.checkAndDisplay(), 100); // Small delay for DOM updates } }; // History API monitoring (React Router, Vue Router) const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { originalPushState.apply(this, args); setTimeout(checkUrlChange, 0); }; history.replaceState = function(...args) { originalReplaceState.apply(this, args); setTimeout(checkUrlChange, 0); }; // Listen for popstate (back/forward buttons) window.addEventListener('popstate', () => { setTimeout(checkUrlChange, 0); }); // Fallback: poll for changes (for hash routing) setInterval(checkUrlChange, 1000); // MutationObserver for dynamic content changes const observer = new MutationObserver((mutations) => { let titleChanged = false; let metaChanged = false; mutations.forEach((mutation) => { if (mutation.target.tagName === 'TITLE') { titleChanged = true; } if (mutation.target.tagName === 'META' || (mutation.addedNodes && Array.from(mutation.addedNodes).some(node => node.tagName === 'META' || node.tagName === 'TITLE'))) { metaChanged = true; } }); if (titleChanged || metaChanged) { setTimeout(() => this.checkAndDisplay(), 100); } }); observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'href'] }); this.observers.push(observer); } destroy() { if (this.panel) { this.panel.remove(); } this.observers.forEach(observer => observer.disconnect()); this.observers = []; delete window.getSeoMetadata; this.isInitialized = false; } } // Auto-initialize const seoChecker = new SeoMetadataChecker(); // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => seoChecker.init()); } else { seoChecker.init(); } // Export for manual control window.seoMetadataChecker = seoChecker; // Add CSS for the panel const style = document.createElement('style'); style.textContent = ` #seo-metadata-panel .seo-header { background: #2d2d2d; padding: 8px 12px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #444; } #seo-metadata-panel .seo-title { font-weight: bold; flex: 1; } #seo-metadata-panel .seo-status { font-size: 11px; padding: 2px 6px; border-radius: 4px; } #seo-metadata-panel .seo-status.good { background: #0f5132; color: #75b798; } #seo-metadata-panel .seo-status.warning { background: #664d03; color: #ffda6a; } #seo-metadata-panel .seo-status.error { background: #58151c; color: #f1aeb5; } #seo-metadata-panel .seo-toggle, #seo-metadata-panel .seo-close { background: none; border: none; color: #ccc; cursor: pointer; padding: 0; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; } #seo-metadata-panel .seo-toggle:hover, #seo-metadata-panel .seo-close:hover { color: #fff; } #seo-metadata-panel .seo-content { padding: 12px; max-height: 400px; overflow-y: auto; } #seo-metadata-panel .seo-section { margin-bottom: 12px; } #seo-metadata-panel .seo-section:last-child { margin-bottom: 0; } #seo-metadata-panel h4 { margin: 0 0 6px 0; font-size: 12px; color: #ccc; } #seo-metadata-panel div { margin-bottom: 4px; } #seo-metadata-panel ul { margin: 6px 0 0 0; padding-left: 16px; } #seo-metadata-panel li { margin: 2px 0; color: #ffda6a; } #seo-metadata-panel strong { color: #ccc; } `; document.head.appendChild(style); export default seoChecker;