Files
PromptArch/lib/seo-report.ts

251 lines
13 KiB
TypeScript

/**
* SEO Audit Report Generator
* Generates standalone HTML reports from audit data
*/
export interface SeoAuditData {
url: string;
domain: string;
responseTime: number;
server: string;
htmlSize: number;
title: string | null;
titleLength: number;
titleStatus: string;
metaDescription: string | null;
descLength: number;
descStatus: string;
metaKeywords: string | null;
viewport: string | null;
charset: string | null;
robotsDirectives: string | null;
canonical: string | null;
hasCanonicalMismatch: boolean;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
headingStatus: string;
headings: { level: number; text: string }[];
links?: { total: number; internal: number; external: number; nofollow: number };
images?: { total: number; withAlt: number; withoutAlt: number; lazyLoaded: number; altCoverage: number };
content?: { wordCount: number; sentenceCount: number; paragraphCount: number; avgWordsPerSentence: number };
openGraph?: { title: string | null; description: string | null; image: string | null; type: string | null };
twitterCard?: { card: string | null };
performance?: {
inlineStyles: number;
externalScripts: number;
externalStylesheets: number;
hasPreconnect: boolean;
hasPreload: boolean;
hasDnsPrefetch: boolean;
usesAsyncScripts: boolean;
usesDeferScripts: boolean;
};
accessibility?: { hasLangAttr: boolean; hasAriaLabels: boolean; hasAltOnFirstImage: boolean };
structuredData?: { hasJsonLd: boolean; hasMicrodata: boolean; types: { type: string; found: boolean }[] };
scores?: { overall: number; technical: number; content: number; performance: number; social: number };
issues?: { severity: string; category: string; message: string }[];
}
const scoreColor = (s: number): string =>
s >= 80 ? "#22c55e" : s >= 60 ? "#f59e0b" : "#ef4444";
const sevColor = (s: string): string =>
s === "critical" ? "#ef4444" : s === "warning" ? "#f59e0b" : "#6b7280";
const fixInstructions: Record<string, string> = {
Meta: "Ensure every page has a unique title tag (50-60 chars) with primary keyword near start. Write meta descriptions (150-160 chars) with CTA. Add viewport meta tag.",
Content: "Maintain exactly one H1 per page with primary keyword. Build logical heading hierarchy (H1>H2>H3). Aim for 1000+ words on core pages.",
Technical: "Set self-referencing canonical tags. Ensure valid SSL with HTTPS redirect. Check robots.txt is not blocking important pages.",
Mobile: "Add viewport meta tag. Test with Google Mobile-Friendly tool. Ensure tap targets are 48x48px minimum.",
Security: "Migrate to HTTPS with valid SSL. Set up HTTP-to-HTTPS redirects. Configure HSTS headers.",
Performance: "Minimize inline styles. Add preconnect hints. Implement lazy loading. Use async/defer for scripts. Compress images.",
Social: "Add OG tags (og:title, og:description, og:image). Add Twitter Card meta. Ensure OG images are 1200x630px+.",
Accessibility: "Add lang attribute to <html>. Implement ARIA labels. Add descriptive alt text to all images.",
Links: "Fix broken internal links. Add external links to authoritative sources. Review nofollow attributes.",
"Structured Data": "Implement JSON-LD schema. Validate with Google Rich Results Test. Add FAQ/Article/Product schema.",
};
export function generateSeoReportHtml(d: SeoAuditData): string {
const now = new Date().toLocaleString();
const sc = scoreColor;
const svc = sevColor;
const issueRows = (d.issues || [])
.map(
(issue) =>
"<tr><td><span style=\"display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;color:white;background:" +
svc(issue.severity) +
"\">" +
issue.severity.toUpperCase() +
"</span></td><td style=\"font-weight:600\">" +
issue.category +
"</td><td>" +
issue.message +
"</td><td style=\"white-space:pre-wrap;font-size:12px;color:#22c55e\">" +
(fixInstructions[issue.category] || "Review and address this issue.") +
"</td></tr>"
)
.join("");
const headingRows = (d.headings || [])
.map((h) => "<tr><td style=\"font-weight:700\">H" + h.level + "</td><td>" + h.text + "</td></tr>")
.join("");
const t = (v: string | null | undefined, fallback = "None") => v || "<em>" + fallback + "</em>";
return "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>SEO Audit - " +
(d.domain || "report") +
"</title><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.6;padding:40px}h1{font-size:28px;margin-bottom:8px}h2{font-size:20px;margin:32px 0 16px;padding-bottom:8px;border-bottom:2px solid #334155}.container{max-width:900px;margin:0 auto}.meta{text-align:center;padding:24px;background:#1e293b;border-radius:16px;margin-bottom:32px}.scores{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:24px 0}.score-card{text-align:center;padding:20px 12px;background:#1e293b;border-radius:12px}.score-value{font-size:36px;font-weight:800}.score-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#94a3b8;margin-top:4px}table{width:100%;border-collapse:collapse;margin:16px 0;background:#1e293b;border-radius:12px;overflow:hidden}th,td{padding:12px 16px;text-align:left;border-bottom:1px solid #334155}th{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#94a3b8}tr:hover{background:#334155}a{color:#60a5fa}.section{background:#1e293b;border-radius:16px;padding:24px;margin-bottom:20px}@media print{body{background:white;color:#1e293b}.section,.meta,.scores{background:#f8fafc;border:1px solid #e2e8f0}.score-value{color:#0f172a!important}}</style></head><body><div class=\"container\"><div class=\"meta\"><h1>SEO/GEO Audit Report</h1><p style=\"color:#60a5fa;font-family:monospace\">" +
(d.url || "N/A") +
"</p><p style=\"color:#94a3b8;margin-top:8px\">PromptArch Vibe Architect | " +
now +
"</p></div><div class=\"scores\"><div class=\"score-card\"><div class=\"score-value\" style=\"color:" +
sc(d.scores?.overall || 0) +
"\">" +
(d.scores?.overall || 0) +
"</div><div class=\"score-label\">Overall</div></div><div class=\"score-card\"><div class=\"score-value\" style=\"color:" +
sc(d.scores?.technical || 0) +
"\">" +
(d.scores?.technical || 0) +
"</div><div class=\"score-label\">Technical</div></div><div class=\"score-card\"><div class=\"score-value\" style=\"color:" +
sc(d.scores?.content || 0) +
"\">" +
(d.scores?.content || 0) +
"</div><div class=\"score-label\">Content</div></div><div class=\"score-card\"><div class=\"score-value\" style=\"color:" +
sc(d.scores?.performance || 0) +
"\">" +
(d.scores?.performance || 0) +
"</div><div class=\"score-label\">Performance</div></div><div class=\"score-card\"><div class=\"score-value\" style=\"color:" +
sc(d.scores?.social || 0) +
"\">" +
(d.scores?.social || 0) +
"</div><div class=\"score-label\">Social</div></div></div>" +
// Meta Tags
"<div class=\"section\"><h2>Meta Tags</h2><table><tr><th>Element</th><th>Value</th><th>Status</th></tr><tr><td>Title</td><td>" +
t(d.title, "MISSING") +
" (" +
(d.titleLength || 0) +
" chars)</td><td>" +
(d.titleStatus || "?") +
"</td></tr><tr><td>Description</td><td>" +
t(d.metaDescription, "MISSING") +
" (" +
(d.descLength || 0) +
" chars)</td><td>" +
(d.descStatus || "?") +
"</td></tr><tr><td>Viewport</td><td>" +
t(d.viewport, "MISSING") +
"</td><td>" +
(d.viewport ? "OK" : "Missing") +
"</td></tr><tr><td>Canonical</td><td>" +
(d.canonical || "None") +
"</td><td>" +
(d.hasCanonicalMismatch ? "MISMATCH" : d.canonical ? "OK" : "Missing") +
"</td></tr><tr><td>Robots</td><td>" +
(d.robotsDirectives || "None") +
"</td><td></td></tr></table></div>" +
// Social
"<div class=\"section\"><h2>Social / Open Graph</h2><table><tr><th>Property</th><th>Value</th></tr><tr><td>OG Title</td><td>" +
(d.openGraph?.title || "None") +
"</td></tr><tr><td>OG Description</td><td>" +
(d.openGraph?.description || "None") +
"</td></tr><tr><td>OG Image</td><td>" +
(d.openGraph?.image || "None") +
"</td></tr><tr><td>OG Type</td><td>" +
(d.openGraph?.type || "None") +
"</td></tr><tr><td>Twitter Card</td><td>" +
(d.twitterCard?.card || "None") +
"</td></tr></table></div>" +
// Headings
"<div class=\"section\"><h2>Headings</h2><p style=\"color:#94a3b8\">H1: " +
d.h1Count +
" | H2: " +
d.h2Count +
" | H3: " +
d.h3Count +
" | H4: " +
d.h4Count +
" [" +
(d.headingStatus || "?") +
"]</p>" +
(headingRows ? "<table><tr><th>Level</th><th>Text</th></tr>" + headingRows + "</table>" : "") +
"</div>" +
// Links & Images
"<div class=\"section\"><h2>Links & Images</h2><table><tr><th>Metric</th><th>Value</th></tr><tr><td>Total Links</td><td>" +
(d.links?.total || 0) +
"</td></tr><tr><td>Internal</td><td>" +
(d.links?.internal || 0) +
"</td></tr><tr><td>External</td><td>" +
(d.links?.external || 0) +
"</td></tr><tr><td>Nofollow</td><td>" +
(d.links?.nofollow || 0) +
"</td></tr><tr><td>Images</td><td>" +
(d.images?.total || 0) +
" (" +
(d.images?.altCoverage || 0) +
"% alt)</td></tr><tr><td>Missing Alt</td><td>" +
(d.images?.withoutAlt || 0) +
"</td></tr><tr><td>Lazy Loaded</td><td>" +
(d.images?.lazyLoaded || 0) +
"</td></tr></table></div>" +
// Content
"<div class=\"section\"><h2>Content</h2><table><tr><th>Metric</th><th>Value</th></tr><tr><td>Words</td><td>" +
(d.content?.wordCount || 0) +
"</td></tr><tr><td>Sentences</td><td>" +
(d.content?.sentenceCount || 0) +
"</td></tr><tr><td>Avg Words/Sentence</td><td>" +
(d.content?.avgWordsPerSentence || 0) +
"</td></tr></table></div>" +
// Performance
"<div class=\"section\"><h2>Performance</h2><table><tr><th>Metric</th><th>Value</th></tr><tr><td>Server</td><td>" +
(d.server || "Unknown") +
"</td></tr><tr><td>Response Time</td><td>" +
d.responseTime +
"ms</td></tr><tr><td>HTML Size</td><td>" +
(d.htmlSize || 0).toLocaleString() +
" bytes</td></tr><tr><td>External Scripts</td><td>" +
(d.performance?.externalScripts || 0) +
"</td></tr><tr><td>Inline Styles</td><td>" +
(d.performance?.inlineStyles || 0) +
"</td></tr><tr><td>Preconnect</td><td>" +
(d.performance?.hasPreconnect ? "Yes" : "No") +
"</td></tr><tr><td>Async/Defer</td><td>" +
(d.performance?.usesAsyncScripts || d.performance?.usesDeferScripts ? "Yes" : "No") +
"</td></tr></table></div>" +
// Issues
"<div class=\"section\"><h2>Issues & How to Fix</h2><p style=\"color:#94a3b8\">" +
(d.issues?.length || 0) +
" issues found</p><table><tr><th>Severity</th><th>Category</th><th>Issue</th><th>How to Fix</th></tr>" +
(issueRows || "<tr><td colspan=\"4\">No issues.</td></tr>") +
"</table></div>" +
// Recommendations
"<div class=\"section\"><h2>SEO/GEO Recommendations</h2><ol style=\"padding-left:20px\"><li><strong>Title Tags:</strong> Keep under 60 chars, primary keyword near start, unique per page.</li><li><strong>Meta Descriptions:</strong> 150-160 chars with CTA, include target keywords naturally.</li><li><strong>Headings:</strong> One H1 per page, logical hierarchy, keywords in headings.</li><li><strong>Content:</strong> 1000+ words on core pages, authoritative, regularly updated.</li><li><strong>Images:</strong> Descriptive alt text, WebP format, lazy loading, compressed.</li><li><strong>Page Speed:</strong> Minimize render-blocking resources, enable compression, use CDN.</li><li><strong>Mobile:</strong> Responsive design, 48x48px tap targets, no horizontal scroll.</li><li><strong>Structured Data:</strong> JSON-LD schema, validate with Google Rich Results Test.</li><li><strong>Internal Linking:</strong> Logical architecture, descriptive anchors, 3-click rule.</li><li><strong>Technical:</strong> XML sitemap, robots.txt, fix broken links, canonical tags.</li><li><strong>GEO:</strong> Structure content with clear facts/lists/tables. Use schema for entities. Optimize for featured snippets. Create authoritative content AI models can cite.</li></ol></div><div style=\"text-align:center;padding:32px;color:#64748b;font-size:12px\"><p>PromptArch Vibe Architect | " +
now +
"</p><p><a href=\"https://rommark.dev/tools/promptarch/\">rommark.dev/tools/promptarch</a></p></div></div></body></html>";
}
export function downloadSeoReport(d: SeoAuditData, format: "html" | "pdf") {
const html = generateSeoReportHtml(d);
if (format === "html") {
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "seo-audit-" + (d.domain || "report") + "-" + new Date().toISOString().slice(0, 10) + ".html";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const win = window.open("", "_blank");
if (win) {
win.document.write(html);
win.document.close();
setTimeout(() => win.print(), 500);
}
}
}