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
JavaScript
/**
* 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;