|
| 1 | +// Render a standalone HTML scorecard from an audit_page result. |
| 2 | +// No external dependencies - inline CSS only. |
| 3 | + |
| 4 | +import type { CitationVerdict } from "../tools/audit-page.js"; |
| 5 | + |
| 6 | +interface ScorecardInput { |
| 7 | + url: string; |
| 8 | + fetched_at: string; |
| 9 | + score: number; |
| 10 | + grade: "A" | "B" | "C" | "D" | "F"; |
| 11 | + citation_verdict: CitationVerdict; |
| 12 | + dimension_scores: { |
| 13 | + schema: number; |
| 14 | + robots: number; |
| 15 | + technical: number; |
| 16 | + freshness: number; |
| 17 | + structure: number; |
| 18 | + authority: number; |
| 19 | + entity_density: number; |
| 20 | + sitemap: number; |
| 21 | + }; |
| 22 | +} |
| 23 | + |
| 24 | +const DIMENSION_LABELS: Record<string, string> = { |
| 25 | + schema: "Schema", |
| 26 | + technical: "Technical", |
| 27 | + structure: "Structure", |
| 28 | + robots: "Robots", |
| 29 | + freshness: "Freshness", |
| 30 | + authority: "Authority", |
| 31 | + entity_density: "Entity Density", |
| 32 | + sitemap: "Sitemap", |
| 33 | +}; |
| 34 | + |
| 35 | +function escHtml(str: string): string { |
| 36 | + return str |
| 37 | + .replace(/&/g, "&") |
| 38 | + .replace(/</g, "<") |
| 39 | + .replace(/>/g, ">") |
| 40 | + .replace(/"/g, """); |
| 41 | +} |
| 42 | + |
| 43 | +function barColor(score: number): string { |
| 44 | + if (score >= 75) return "#22c55e"; |
| 45 | + if (score >= 50) return "#f59e0b"; |
| 46 | + return "#ef4444"; |
| 47 | +} |
| 48 | + |
| 49 | +function verdictColor(verdict: string): string { |
| 50 | + if (verdict === "likely") return "#22c55e"; |
| 51 | + if (verdict === "marginal") return "#f59e0b"; |
| 52 | + return "#ef4444"; |
| 53 | +} |
| 54 | + |
| 55 | +function gradeColor(grade: string): string { |
| 56 | + if (grade === "A") return "#22c55e"; |
| 57 | + if (grade === "B") return "#84cc16"; |
| 58 | + if (grade === "C") return "#f59e0b"; |
| 59 | + if (grade === "D") return "#f97316"; |
| 60 | + return "#ef4444"; |
| 61 | +} |
| 62 | + |
| 63 | +export function renderScorecardHtml(input: ScorecardInput): string { |
| 64 | + const { url, fetched_at, score, grade, citation_verdict, dimension_scores } = input; |
| 65 | + |
| 66 | + const formattedDate = new Date(fetched_at).toUTCString(); |
| 67 | + const verdictLabel = |
| 68 | + citation_verdict.will_ai_cite === "likely" |
| 69 | + ? "Likely to be cited" |
| 70 | + : citation_verdict.will_ai_cite === "marginal" |
| 71 | + ? "Marginally citable" |
| 72 | + : "Unlikely to be cited"; |
| 73 | + |
| 74 | + const dimensionRows = Object.entries(dimension_scores) |
| 75 | + .map(([key, val]) => { |
| 76 | + const label = DIMENSION_LABELS[key] ?? key; |
| 77 | + const color = barColor(val); |
| 78 | + return ` |
| 79 | + <div class="dim-row"> |
| 80 | + <span class="dim-label">${escHtml(label)}</span> |
| 81 | + <div class="bar-track"> |
| 82 | + <div class="bar-fill" style="width:${val}%;background:${color};"></div> |
| 83 | + </div> |
| 84 | + <span class="dim-score">${val}</span> |
| 85 | + </div>`; |
| 86 | + }) |
| 87 | + .join(""); |
| 88 | + |
| 89 | + const blockerItems = citation_verdict.top_3_blockers |
| 90 | + .map((b, i) => { |
| 91 | + const impactBadge = b.estimated_impact |
| 92 | + ? `<span class="badge badge-${b.estimated_impact}">${escHtml(b.estimated_impact)}</span>` |
| 93 | + : ""; |
| 94 | + return ` |
| 95 | + <div class="blocker"> |
| 96 | + <div class="blocker-header"> |
| 97 | + <span class="blocker-num">${i + 1}</span> |
| 98 | + <span class="blocker-cat">${escHtml(b.category)}</span> |
| 99 | + ${impactBadge} |
| 100 | + </div> |
| 101 | + <p class="blocker-msg">${escHtml(b.message)}</p> |
| 102 | + <p class="blocker-fix"><strong>Fix:</strong> ${escHtml(b.fix)}</p> |
| 103 | + </div>`; |
| 104 | + }) |
| 105 | + .join(""); |
| 106 | + |
| 107 | + const noBlockers = |
| 108 | + citation_verdict.top_3_blockers.length === 0 |
| 109 | + ? `<p class="no-blockers">No critical blockers found.</p>` |
| 110 | + : ""; |
| 111 | + |
| 112 | + return `<!DOCTYPE html> |
| 113 | +<html lang="en"> |
| 114 | +<head> |
| 115 | +<meta charset="UTF-8"> |
| 116 | +<meta name="viewport" content="width=device-width,initial-scale=1"> |
| 117 | +<title>AI-SEO Scorecard - ${escHtml(url)}</title> |
| 118 | +<style> |
| 119 | +*{box-sizing:border-box;margin:0;padding:0} |
| 120 | +body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;padding:2rem 1rem} |
| 121 | +.card{max-width:700px;margin:0 auto;background:#1e293b;border-radius:12px;overflow:hidden;box-shadow:0 4px 32px rgba(0,0,0,.4)} |
| 122 | +.header{padding:2rem;border-bottom:1px solid #334155} |
| 123 | +.header-url{font-size:.85rem;color:#94a3b8;word-break:break-all;margin-bottom:.5rem} |
| 124 | +.header-ts{font-size:.78rem;color:#64748b} |
| 125 | +.grade-row{display:flex;align-items:center;gap:1.5rem;margin-top:1.25rem} |
| 126 | +.grade-badge{font-size:3rem;font-weight:800;line-height:1;color:${gradeColor(grade)}} |
| 127 | +.score-num{font-size:1.75rem;font-weight:700;color:#f1f5f9} |
| 128 | +.score-label{font-size:.8rem;color:#94a3b8;margin-top:.2rem} |
| 129 | +.verdict-pill{display:inline-block;padding:.35rem .9rem;border-radius:999px;font-size:.85rem;font-weight:600;background:${verdictColor(citation_verdict.will_ai_cite)}22;color:${verdictColor(citation_verdict.will_ai_cite)};border:1px solid ${verdictColor(citation_verdict.will_ai_cite)}44;margin-top:.5rem} |
| 130 | +.summary{font-size:.9rem;color:#cbd5e1;margin-top:.75rem;line-height:1.5} |
| 131 | +.section{padding:1.5rem 2rem} |
| 132 | +.section + .section{border-top:1px solid #334155} |
| 133 | +.section-title{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#64748b;margin-bottom:1rem} |
| 134 | +.dim-row{display:grid;grid-template-columns:110px 1fr 36px;align-items:center;gap:.75rem;margin-bottom:.6rem} |
| 135 | +.dim-label{font-size:.82rem;color:#94a3b8;text-align:right} |
| 136 | +.bar-track{height:8px;background:#0f172a;border-radius:4px;overflow:hidden} |
| 137 | +.bar-fill{height:100%;border-radius:4px;transition:width .3s} |
| 138 | +.dim-score{font-size:.82rem;font-weight:600;color:#e2e8f0;text-align:right} |
| 139 | +.blocker{background:#0f172a;border-radius:8px;padding:1rem;margin-bottom:.75rem;border-left:3px solid #ef4444} |
| 140 | +.blocker:last-child{margin-bottom:0} |
| 141 | +.blocker-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem} |
| 142 | +.blocker-num{width:22px;height:22px;border-radius:50%;background:#ef4444;color:#fff;font-size:.75rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0} |
| 143 | +.blocker-cat{font-size:.8rem;font-weight:600;color:#cbd5e1;text-transform:capitalize} |
| 144 | +.badge{padding:.15rem .5rem;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase} |
| 145 | +.badge-high{background:#ef444422;color:#ef4444} |
| 146 | +.badge-medium{background:#f59e0b22;color:#f59e0b} |
| 147 | +.badge-low{background:#22c55e22;color:#22c55e} |
| 148 | +.blocker-msg{font-size:.82rem;color:#94a3b8;margin-bottom:.35rem;line-height:1.4} |
| 149 | +.blocker-fix{font-size:.82rem;color:#64748b;line-height:1.4} |
| 150 | +.blocker-fix strong{color:#94a3b8} |
| 151 | +.no-blockers{font-size:.9rem;color:#22c55e} |
| 152 | +.footer{padding:1rem 2rem;text-align:center;font-size:.75rem;color:#475569;border-top:1px solid #334155} |
| 153 | +.footer a{color:#6366f1;text-decoration:none} |
| 154 | +.footer a:hover{text-decoration:underline} |
| 155 | +</style> |
| 156 | +</head> |
| 157 | +<body> |
| 158 | +<div class="card"> |
| 159 | + <div class="header"> |
| 160 | + <div class="header-url">${escHtml(url)}</div> |
| 161 | + <div class="header-ts">Audited ${escHtml(formattedDate)}</div> |
| 162 | + <div class="grade-row"> |
| 163 | + <div> |
| 164 | + <div class="grade-badge">${escHtml(grade)}</div> |
| 165 | + </div> |
| 166 | + <div> |
| 167 | + <div class="score-num">${score}<span style="font-size:1rem;color:#64748b">/100</span></div> |
| 168 | + <div class="score-label">AI Citation Score</div> |
| 169 | + </div> |
| 170 | + </div> |
| 171 | + <div class="verdict-pill">${escHtml(verdictLabel)}</div> |
| 172 | + <p class="summary">${escHtml(citation_verdict.one_line_summary)}</p> |
| 173 | + </div> |
| 174 | +
|
| 175 | + <div class="section"> |
| 176 | + <div class="section-title">Dimension Scores</div> |
| 177 | + ${dimensionRows} |
| 178 | + </div> |
| 179 | +
|
| 180 | + <div class="section"> |
| 181 | + <div class="section-title">Top Blockers</div> |
| 182 | + ${blockerItems}${noBlockers} |
| 183 | + </div> |
| 184 | +
|
| 185 | + <div class="footer"> |
| 186 | + Generated by <a href="https://github.com/AutomateLab-tech/ai-seo" target="_blank" rel="noopener">AI Citation Toolkit</a> - github.com/AutomateLab-tech/ai-seo |
| 187 | + </div> |
| 188 | +</div> |
| 189 | +</body> |
| 190 | +</html>`; |
| 191 | +} |
0 commit comments