#!/usr/bin/env node /** * Schema Validation Script * * Validates JSON-LD structured data on all pages * Checks for common issues and provides recommendations * * Usage: node scripts/validate-schema.js */ import { JSDOM } from 'jsdom'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const COLORS = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; const log = { success: (msg) => console.log(`${COLORS.green}✓${COLORS.reset} ${msg}`), error: (msg) => console.log(`${COLORS.red}✗${COLORS.reset} ${msg}`), warning: (msg) => console.log(`${COLORS.yellow}⚠${COLORS.reset} ${msg}`), info: (msg) => console.log(`${COLORS.blue}ℹ${COLORS.reset} ${msg}`), section: (msg) => console.log(`\n${COLORS.cyan}${msg}${COLORS.reset}\n`), }; /** * Extract JSON-LD scripts from HTML */ function extractJsonLd(html) { const dom = new JSDOM(html); const scripts = dom.window.document.querySelectorAll('script[type="application/ld+json"]'); return Array.from(scripts).map(script => { try { return JSON.parse(script.textContent); } catch (e) { log.error(`Failed to parse JSON-LD: ${e.message}`); return null; } }).filter(Boolean); } /** * Validate required fields for each schema type */ function validateSchema(schema) { const issues = []; const warnings = []; if (!schema['@context']) { issues.push('Missing @context'); } if (!schema['@type']) { issues.push('Missing @type'); } const type = schema['@type']; // Validate based on type switch (type) { case 'Organization': if (!schema.name) issues.push('Organization: Missing name'); if (!schema.url) issues.push('Organization: Missing url'); if (!schema.logo) warnings.push('Organization: Consider adding logo'); if (!schema.contactPoint) warnings.push('Organization: Consider adding contactPoint'); break; case 'BlogPosting': if (!schema.headline) issues.push('BlogPosting: Missing headline'); if (!schema.author) issues.push('BlogPosting: Missing author'); if (!schema.datePublished) issues.push('BlogPosting: Missing datePublished'); if (!schema.publisher) issues.push('BlogPosting: Missing publisher'); if (!schema.image) warnings.push('BlogPosting: Consider adding image'); break; case 'BreadcrumbList': if (!schema.itemListElement || !Array.isArray(schema.itemListElement)) { issues.push('BreadcrumbList: Missing or invalid itemListElement'); } else { schema.itemListElement.forEach((item, idx) => { if (!item.position) issues.push(`BreadcrumbList: Item ${idx} missing position`); if (!item.name) issues.push(`BreadcrumbList: Item ${idx} missing name`); if (!item.item) issues.push(`BreadcrumbList: Item ${idx} missing item URL`); }); } break; case 'WebPage': if (!schema.name) issues.push('WebPage: Missing name'); if (!schema.url) issues.push('WebPage: Missing url'); if (!schema.description) warnings.push('WebPage: Consider adding description'); break; case 'FAQPage': if (!schema.mainEntity || !Array.isArray(schema.mainEntity)) { issues.push('FAQPage: Missing or invalid mainEntity'); } else { schema.mainEntity.forEach((item, idx) => { if (item['@type'] !== 'Question') { issues.push(`FAQPage: Item ${idx} should be type Question`); } if (!item.name) issues.push(`FAQPage: Question ${idx} missing name`); if (!item.acceptedAnswer) { issues.push(`FAQPage: Question ${idx} missing acceptedAnswer`); } }); } break; case 'Service': if (!schema.name) issues.push('Service: Missing name'); if (!schema.description) issues.push('Service: Missing description'); if (!schema.provider) warnings.push('Service: Consider adding provider'); break; case 'WebSite': if (!schema.name) issues.push('WebSite: Missing name'); if (!schema.url) issues.push('WebSite: Missing url'); if (!schema.potentialAction) warnings.push('WebSite: Consider adding search action'); break; } return { issues, warnings }; } /** * Validate Open Graph tags */ function validateOpenGraph(html) { const dom = new JSDOM(html); const doc = dom.window.document; const issues = []; const warnings = []; const requiredOgTags = ['og:title', 'og:description', 'og:image', 'og:url', 'og:type']; requiredOgTags.forEach(tag => { const meta = doc.querySelector(`meta[property="${tag}"]`); if (!meta) { issues.push(`Missing required Open Graph tag: ${tag}`); } }); const ogImage = doc.querySelector('meta[property="og:image"]'); if (ogImage) { const imageUrl = ogImage.getAttribute('content'); if (!imageUrl.startsWith('http')) { warnings.push('og:image should be an absolute URL'); } if (!doc.querySelector('meta[property="og:image:width"]')) { warnings.push('Consider adding og:image:width'); } if (!doc.querySelector('meta[property="og:image:height"]')) { warnings.push('Consider adding og:image:height'); } } return { issues, warnings }; } /** * Validate Twitter Card tags */ function validateTwitterCard(html) { const dom = new JSDOM(html); const doc = dom.window.document; const issues = []; const warnings = []; const requiredTwitterTags = ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']; requiredTwitterTags.forEach(tag => { const meta = doc.querySelector(`meta[name="${tag}"]`); if (!meta) { issues.push(`Missing required Twitter Card tag: ${tag}`); } }); const twitterCard = doc.querySelector('meta[name="twitter:card"]'); if (twitterCard && twitterCard.getAttribute('content') === 'summary_large_image') { const twitterImage = doc.querySelector('meta[name="twitter:image"]'); if (twitterImage) { const imageUrl = twitterImage.getAttribute('content'); if (!imageUrl.startsWith('http')) { warnings.push('twitter:image should be an absolute URL'); } } } return { issues, warnings }; } /** * Main validation function */ async function validatePage(filePath, pageName) { log.section(`Validating: ${pageName}`); try { const html = fs.readFileSync(filePath, 'utf-8'); // Extract and validate JSON-LD const schemas = extractJsonLd(html); if (schemas.length === 0) { log.warning('No JSON-LD structured data found'); } else { log.info(`Found ${schemas.length} JSON-LD schema(s)`); schemas.forEach((schema, idx) => { console.log(`\n Schema ${idx + 1}: ${schema['@type'] || 'Unknown'}`); const { issues, warnings } = validateSchema(schema); if (issues.length === 0) { log.success('No critical issues'); } else { issues.forEach(issue => log.error(` ${issue}`)); } if (warnings.length > 0) { warnings.forEach(warning => log.warning(` ${warning}`)); } }); } // Validate Open Graph console.log('\n Open Graph:'); const ogValidation = validateOpenGraph(html); if (ogValidation.issues.length === 0) { log.success('All required Open Graph tags present'); } else { ogValidation.issues.forEach(issue => log.error(` ${issue}`)); } if (ogValidation.warnings.length > 0) { ogValidation.warnings.forEach(warning => log.warning(` ${warning}`)); } // Validate Twitter Cards console.log('\n Twitter Card:'); const twitterValidation = validateTwitterCard(html); if (twitterValidation.issues.length === 0) { log.success('All required Twitter Card tags present'); } else { twitterValidation.issues.forEach(issue => log.error(` ${issue}`)); } if (twitterValidation.warnings.length > 0) { twitterValidation.warnings.forEach(warning => log.warning(` ${warning}`)); } return { schemas: schemas.length, issues: ogValidation.issues.length + twitterValidation.issues.length, warnings: ogValidation.warnings.length + twitterValidation.warnings.length, }; } catch (error) { log.error(`Failed to validate ${pageName}: ${error.message}`); return { schemas: 0, issues: 1, warnings: 0 }; } } /** * Main execution */ async function main() { console.log('\n╔════════════════════════════════════════════════════════╗'); console.log('║ Schema Validation for WorkRoot IT Solutions ║'); console.log('╚════════════════════════════════════════════════════════╝\n'); const distDir = path.join(__dirname, '..', 'dist'); if (!fs.existsSync(distDir)) { log.error('Build directory not found. Please run "npm run build" first.'); process.exit(1); } // Pages to validate const pages = [ { path: path.join(distDir, 'index.html'), name: 'Homepage' }, { path: path.join(distDir, 'about', 'index.html'), name: 'About' }, { path: path.join(distDir, 'services', 'index.html'), name: 'Services' }, { path: path.join(distDir, 'contact', 'index.html'), name: 'Contact' }, { path: path.join(distDir, 'blog', 'index.html'), name: 'Blog Index' }, ]; // Find a blog post to validate const blogDir = path.join(distDir, 'blog'); if (fs.existsSync(blogDir)) { const blogPosts = fs.readdirSync(blogDir) .filter(file => fs.statSync(path.join(blogDir, file)).isDirectory()); if (blogPosts.length > 0) { pages.push({ path: path.join(blogDir, blogPosts[0], 'index.html'), name: `Blog Post (${blogPosts[0]})` }); } } let totalSchemas = 0; let totalIssues = 0; let totalWarnings = 0; for (const page of pages) { if (fs.existsSync(page.path)) { const result = await validatePage(page.path, page.name); totalSchemas += result.schemas; totalIssues += result.issues; totalWarnings += result.warnings; } else { log.warning(`Page not found: ${page.name}`); } } // Summary log.section('Validation Summary'); console.log(`Total schemas found: ${COLORS.cyan}${totalSchemas}${COLORS.reset}`); console.log(`Total issues: ${totalIssues > 0 ? COLORS.red : COLORS.green}${totalIssues}${COLORS.reset}`); console.log(`Total warnings: ${totalWarnings > 0 ? COLORS.yellow : COLORS.green}${totalWarnings}${COLORS.reset}`); if (totalIssues === 0) { log.success('\nAll validations passed! 🎉'); } else { log.error('\nPlease fix the issues above before deployment.'); } console.log('\n'); process.exit(totalIssues > 0 ? 1 : 0); } main();