feat: SEO audit export, default Vibe Architect, /vibe route (v2.0.0)
This commit is contained in:
250
lib/seo-report.ts
Normal file
250
lib/seo-report.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user