Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
#!/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();
|