UNPKG

sanity-plugin-seo

Version:

Complete SEO toolkit for Sanity Studio with live scoring, AI suggestions, team workflows, and 30+ structured data types. Free and AI tiers live. Pro coming soon.

200 lines (188 loc) 4.55 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ export interface SEOCheck { name: string; pass: boolean | "partial"; points: number; maxPoints: number; hint: string; } export interface SEOScoreResult { score: number; checks: SEOCheck[]; color: "green" | "orange" | "red"; label: string; } export function computeSEOScore(value: Record<string, any> | undefined): SEOScoreResult { const v = value || {}; const checks: SEOCheck[] = []; // Title — 20 pts const titleLen = (v.metaTitle || "").length; if (titleLen >= 50 && titleLen <= 60) { checks.push({ name: "Meta Title", pass: true, points: 20, maxPoints: 20, hint: `Perfect length (${titleLen} chars)`, }); } else if (titleLen > 0) { checks.push({ name: "Meta Title", pass: "partial", points: 10, maxPoints: 20, hint: titleLen < 50 ? `Too short (${titleLen}/5060)` : `Too long (${titleLen}/60)`, }); } else { checks.push({ name: "Meta Title", pass: false, points: 0, maxPoints: 20, hint: "No meta title set", }); } // Description — 20 pts const descLen = (v.metaDescription || "").length; if (descLen >= 100 && descLen <= 160) { checks.push({ name: "Description", pass: true, points: 20, maxPoints: 20, hint: `Perfect length (${descLen} chars)`, }); } else if (descLen > 0) { checks.push({ name: "Description", pass: "partial", points: 10, maxPoints: 20, hint: descLen < 100 ? `Too short (${descLen}/100160)` : `Too long (${descLen}/160)`, }); } else { checks.push({ name: "Description", pass: false, points: 0, maxPoints: 20, hint: "No meta description set", }); } // Focus keyword — 15 pts const kw = (v.focusKeyword || "").toLowerCase().trim(); if (kw) { const inTitle = v.metaTitle?.toLowerCase().includes(kw); const inDesc = v.metaDescription?.toLowerCase().includes(kw); if (inTitle && inDesc) { checks.push({ name: "Focus Keyword", pass: true, points: 15, maxPoints: 15, hint: "Keyword in title and description", }); } else if (inTitle || inDesc) { checks.push({ name: "Focus Keyword", pass: "partial", points: 8, maxPoints: 15, hint: `Keyword in ${inTitle ? "title" : "description"} only`, }); } else { checks.push({ name: "Focus Keyword", pass: false, points: 0, maxPoints: 15, hint: "Keyword not found in title or description", }); } } else { checks.push({ name: "Focus Keyword", pass: false, points: 0, maxPoints: 15, hint: "No focus keyword set", }); } // Meta image — 10 pts if (v.metaImage?.asset) { checks.push({ name: "Meta Image", pass: true, points: 10, maxPoints: 10, hint: "Meta image is set", }); } else { checks.push({ name: "Meta Image", pass: false, points: 0, maxPoints: 10, hint: "No meta image", }); } // Open Graph — 15 pts const og = v.openGraph || {}; if (og.title && og.description && og.image?.asset) { checks.push({ name: "Open Graph", pass: true, points: 15, maxPoints: 15, hint: "Fully configured", }); } else if (og.title || og.description) { checks.push({ name: "Open Graph", pass: "partial", points: 7, maxPoints: 15, hint: "Partially configured — add title, description and image", }); } else { checks.push({ name: "Open Graph", pass: false, points: 0, maxPoints: 15, hint: "Not configured", }); } // Twitter — 10 pts if (v.twitter?.cardType) { checks.push({ name: "Twitter Card", pass: true, points: 10, maxPoints: 10, hint: "Twitter card is set", }); } else { checks.push({ name: "Twitter Card", pass: false, points: 0, maxPoints: 10, hint: "No Twitter card type", }); } const score = checks.reduce((s, c) => s + c.points, 0); let color: "green" | "orange" | "red"; let label: string; if (score >= 80) { color = "green"; label = "Good"; } else if (score >= 50) { color = "orange"; label = "Needs Work"; } else { color = "red"; label = "Poor"; } return { score, checks, color, label }; }