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.

1,483 lines (1,481 loc) 262 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var sanity = require('sanity'); var icons = require('@sanity/icons'); var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var ui = require('@sanity/ui'); var router = require('sanity/router'); function _interopDefaultCompat(e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; } var React__default = /*#__PURE__*/_interopDefaultCompat(React); const SEO_PRO_EVENT = "seo-plugin-pro-changed"; const state = { config: {}, proEnabled: false, licenseValidating: false }; const setPluginConfig = config => { state.config = config; }; const getPluginConfig = () => state.config; const setProEnabled = enabled => { state.proEnabled = enabled; try { window.dispatchEvent(new CustomEvent(SEO_PRO_EVENT, { detail: { enabled } })); } catch (e) {} }; const isProEnabled = () => state.proEnabled; const setLicenseValidating = validating => { state.licenseValidating = validating; try { window.dispatchEvent(new CustomEvent(SEO_PRO_EVENT, { detail: { enabled: state.proEnabled } })); } catch (e) {} }; const isLicenseValidating = () => state.licenseValidating; const TITLE_ANGLES = ["lead with a strong action verb", "lead with the benefit to the reader", "use a how-to format", "use a question format", "lead with a number or statistic", "use a curiosity-driven hook", "emphasise speed or ease", "emphasise expertise or depth"]; const DESC_ANGLES = ["open with a direct answer, then add a call to action", "open with a pain point the reader has, then offer the solution", "open with a bold claim, then back it up briefly", "open with who this is for, then what they will get", "open with a question, then promise the answer", "open with a surprising fact or statistic"]; function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function buildPrompt(field, content, keyword) { const keywordLine = keyword ? 'Focus keyword: "'.concat(keyword, '"') : ""; const snippet = content.slice(0, 2e3); if (field === "title") { const angle = pick(TITLE_ANGLES); return "You are an SEO expert. Generate one SEO-optimized meta title using this specific angle: ".concat(angle, ".\nRequirements:\n- Exactly 50\u201360 characters (count carefully)\n- ").concat(keywordLine || "Make it compelling and clear", "\n- Apply the angle: ").concat(angle, "\n- Return ONLY the title text, no quotes, no explanation, no character count\n\nPage content:\n").concat(snippet); } if (field === "description") { const angle = pick(DESC_ANGLES); return "You are an SEO expert. Generate one SEO-optimized meta description using this specific angle: ".concat(angle, ".\nRequirements:\n- Exactly 100\u2013160 characters (count carefully)\n- ").concat(keywordLine || "Include a clear call to action", "\n- Apply the angle: ").concat(angle, "\n- Return ONLY the description text, no quotes, no explanation, no character count\n\nPage content:\n").concat(snippet); } return "You are an SEO expert. Generate 8 relevant SEO keywords for this page.\nRequirements:\n- Return a comma-separated list only\n- No explanation, no numbering, just keywords\n- Mix short-tail and long-tail keywords\n\nPage content:\n".concat(snippet); } async function callOpenAI(prompt, config) { var _a, _b, _c, _d; const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer ".concat(config.apiKey) }, body: JSON.stringify({ model: config.model || "gpt-4o-mini", messages: [{ role: "user", content: prompt }], max_tokens: 300, temperature: 1 }) }); const data = await res.json(); if (!res.ok) throw new Error(((_a = data == null ? void 0 : data.error) == null ? void 0 : _a.message) || "OpenAI request failed"); return (((_d = (_c = (_b = data.choices) == null ? void 0 : _b[0]) == null ? void 0 : _c.message) == null ? void 0 : _d.content) || "").trim(); } async function callGroq(prompt, config) { var _a, _b, _c, _d; const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer ".concat(config.apiKey) }, body: JSON.stringify({ model: config.model || "llama-3.3-70b-versatile", messages: [{ role: "user", content: prompt }], max_tokens: 300, temperature: 1 }) }); const data = await res.json(); if (!res.ok) throw new Error(((_a = data == null ? void 0 : data.error) == null ? void 0 : _a.message) || "Groq request failed"); return (((_d = (_c = (_b = data.choices) == null ? void 0 : _b[0]) == null ? void 0 : _c.message) == null ? void 0 : _d.content) || "").trim(); } async function callAnthropic(prompt, config) { var _a, _b, _c; const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": config.apiKey, "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true" }, body: JSON.stringify({ model: config.model || "claude-haiku-4-5-20251001", max_tokens: 300, messages: [{ role: "user", content: prompt }] }) }); const data = await res.json(); if (!res.ok) throw new Error(((_a = data == null ? void 0 : data.error) == null ? void 0 : _a.message) || "Anthropic request failed"); return (((_c = (_b = data.content) == null ? void 0 : _b[0]) == null ? void 0 : _c.text) || "").trim(); } async function generateSEOContent(field, bodyContent, focusKeyword, config) { const prompt = buildPrompt(field, bodyContent, focusKeyword); if (config.provider === "anthropic") return callAnthropic(prompt, config); if (config.provider === "groq") return callGroq(prompt, config); return callOpenAI(prompt, config); } function portableTextToString(value) { if (typeof value === "string") return value; if (!Array.isArray(value)) return ""; return value.filter(block => (block == null ? void 0 : block._type) === "block").map(block => (block.children || []).filter(child => child._type === "span").map(child => child.text || "").join("")).join(" ").replace(/\s+/g, " ").trim(); } function AIGenerateButton(_ref) { let { field, focusKeyword = "", onGenerate } = _ref; const config = getPluginConfig(); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const bodyField = config.bodyField || "body"; const bodyValue = sanity.useFormValue([bodyField]); const bodyText = portableTextToString(bodyValue); const handleGenerate = React.useCallback(async () => { if (!config.aiFeature) return; setLoading(true); setError(null); try { const result = await generateSEOContent(field, bodyText, focusKeyword, config.aiFeature); onGenerate(result); } catch (err) { setError(err instanceof Error ? err.message : "AI generation failed. Check your API key."); } finally { setLoading(false); } }, [config.aiFeature, field, bodyText, focusKeyword, onGenerate]); if (!config.aiFeature) return null; return /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 1, children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Button, { mode: "ghost", tone: "primary", padding: 2, icon: icons.SparklesIcon, text: loading ? "Generating\u2026" : "Generate with AI", onClick: handleGenerate, disabled: loading, fontSize: 1 }), !bodyText && /* @__PURE__ */jsxRuntime.jsxs(ui.Text, { size: 1, muted: true, children: ["(Add a ", bodyField, " field for better results)"] })] }), error && /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 2, tone: "critical", radius: 2, children: /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, children: error }) })] }); } var __defProp$9 = Object.defineProperty; var __getOwnPropSymbols$9 = Object.getOwnPropertySymbols; var __hasOwnProp$9 = Object.prototype.hasOwnProperty; var __propIsEnum$9 = Object.prototype.propertyIsEnumerable; var __defNormalProp$9 = (obj, key, value) => key in obj ? __defProp$9(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues$9 = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp$9.call(b, prop)) __defNormalProp$9(a, prop, b[prop]); if (__getOwnPropSymbols$9) for (var prop of __getOwnPropSymbols$9(b)) { if (__propIsEnum$9.call(b, prop)) __defNormalProp$9(a, prop, b[prop]); } return a; }; var __objRest$3 = (source, exclude) => { var target = {}; for (var prop in source) if (__hasOwnProp$9.call(source, prop) && exclude.indexOf(prop) < 0) target[prop] = source[prop]; if (source != null && __getOwnPropSymbols$9) for (var prop of __getOwnPropSymbols$9(source)) { if (exclude.indexOf(prop) < 0 && __propIsEnum$9.call(source, prop)) target[prop] = source[prop]; } return target; }; const DOT$1 = color => /* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 10, height: 10, borderRadius: "50%", backgroundColor: color, flexShrink: 0 } }); const SEOTitleFeedback = _a => { var _b = _a, { value, onChange, renderDefault, path } = _b, rest = __objRest$3(_b, ["value", "onChange", "renderDefault", "path"]); const client = sanity.useClient({ apiVersion: "2021-06-07" }); const parentPath = path.slice(0, -1); const parent = sanity.useFormValue(parentPath); const keywords = (parent == null ? void 0 : parent.seoKeywords) || []; const focusKeyword = (parent == null ? void 0 : parent.focusKeyword) || ""; const defaultFetchType = "homePage"; React.useEffect(() => { if (value) return; const fetchData = async () => { const data = await client.fetch("*[_type=='".concat(defaultFetchType, "'][0]{'title':seo.metaTitle}")); if ((data == null ? void 0 : data.title) && !value) onChange(sanity.set(data.title)); }; fetchData(); }, [client, onChange, value]); const handleAIGenerate = React.useCallback(generated => onChange(sanity.set(generated)), [onChange]); const getFeedback = (title, kw, kwArr) => { if (!(title == null ? void 0 : title.trim())) return [{ text: "Title is empty. Add a title for better SEO.", color: "red" }]; const items = []; const len = title.length; if (len >= 50 && len <= 60) { items.push({ text: "Title length (".concat(len, ") is perfect for SEO."), color: "#43d675" }); } else if (len < 50) { items.push({ text: "Title is short (".concat(len, "/50\u201360 chars)."), color: "#f59e0b" }); } else { items.push({ text: "Title is too long (".concat(len, "/60 chars max)."), color: "#ef4444" }); } const primary = kw || (kwArr.length > 0 ? kwArr[0] : ""); if (primary) { if (title.toLowerCase().includes(primary.toLowerCase())) { items.push({ text: 'Focus keyword "'.concat(primary, '" found in title.'), color: "#43d675" }); } else { items.push({ text: 'Focus keyword "'.concat(primary, '" not in title.'), color: "#ef4444" }); } } else { items.push({ text: "No focus keyword set. Consider adding one.", color: "#f59e0b" }); } return items; }; const feedbackItems = getFeedback(value || "", focusKeyword, keywords); const props = __spreadValues$9({ value, onChange, renderDefault, path }, rest); return /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [renderDefault(props), /* @__PURE__ */jsxRuntime.jsx(AIGenerateButton, { field: "title", focusKeyword, onGenerate: handleAIGenerate }), /* @__PURE__ */jsxRuntime.jsx(ui.Stack, { space: 2, children: feedbackItems.map(item => /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [DOT$1(item.color), /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, children: item.text })] }, item.text)) })] }); }; function countSyllables(word) { var _a; const clean = word.toLowerCase().replace(/[^a-z]/g, ""); if (clean.length <= 3) return 1; const stripped = clean.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, ""); const matches = stripped.match(/[aeiouy]{1,2}/g); return Math.max(1, (_a = matches == null ? void 0 : matches.length) != null ? _a : 1); } function countSentences(text) { return Math.max(1, text.split(/[.!?]+/).filter(s => s.trim().length > 2).length); } function getReadabilityScore(text) { if (!text || text.trim().split(/\s+/).length < 10) return null; const words = text.trim().split(/\s+/).filter(Boolean); const sentences = countSentences(text); const syllables = words.reduce((sum, w) => sum + countSyllables(w), 0); const score = 206.835 - 1.015 * (words.length / sentences) - 84.6 * (syllables / words.length); const clamped = Math.max(0, Math.min(100, score)); if (clamped >= 70) return { score: clamped, label: "Easy \u2014 great for general audiences and AI citation", color: "green" }; if (clamped >= 50) return { score: clamped, label: "Moderate \u2014 good for informed readers", color: "orange" }; return { score: clamped, label: "Difficult \u2014 simplify for better AI search visibility", color: "red" }; } var __defProp$8 = Object.defineProperty; var __getOwnPropSymbols$8 = Object.getOwnPropertySymbols; var __hasOwnProp$8 = Object.prototype.hasOwnProperty; var __propIsEnum$8 = Object.prototype.propertyIsEnumerable; var __defNormalProp$8 = (obj, key, value) => key in obj ? __defProp$8(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues$8 = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp$8.call(b, prop)) __defNormalProp$8(a, prop, b[prop]); if (__getOwnPropSymbols$8) for (var prop of __getOwnPropSymbols$8(b)) { if (__propIsEnum$8.call(b, prop)) __defNormalProp$8(a, prop, b[prop]); } return a; }; var __objRest$2 = (source, exclude) => { var target = {}; for (var prop in source) if (__hasOwnProp$8.call(source, prop) && exclude.indexOf(prop) < 0) target[prop] = source[prop]; if (source != null && __getOwnPropSymbols$8) for (var prop of __getOwnPropSymbols$8(source)) { if (exclude.indexOf(prop) < 0 && __propIsEnum$8.call(source, prop)) target[prop] = source[prop]; } return target; }; const DOT = color => /* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 10, height: 10, borderRadius: "50%", backgroundColor: color, flexShrink: 0 } }); function readabilityColor(c) { if (c === "green") return "#43d675"; if (c === "orange") return "#f59e0b"; return "#ef4444"; } const SEODescriptionFeedback = _a => { var _b = _a, { value, onChange, renderDefault, path } = _b, rest = __objRest$2(_b, ["value", "onChange", "renderDefault", "path"]); const client = sanity.useClient({ apiVersion: "2021-06-07" }); const parentPath = path.slice(0, -1); const parent = sanity.useFormValue(parentPath); const focusKeyword = (parent == null ? void 0 : parent.focusKeyword) || ""; React.useEffect(() => { if (value) return; const fetchData = async () => { const data = await client.fetch("*[_type=='homePage'][0]{'description':seo.metaDescription}"); if ((data == null ? void 0 : data.description) && !value) onChange(sanity.set(data.description)); }; fetchData(); }, [client, onChange, value]); const handleAIGenerate = React.useCallback(generated => onChange(sanity.set(generated)), [onChange]); const text = value || ""; const len = text.length; const items = []; if (!text.trim()) { items.push({ text: "No description. Search engines will auto-generate one \u2014 usually poorly.", color: "#ef4444" }); } else if (len < 100) { items.push({ text: "Too short (".concat(len, "/100\u2013160 chars). Expand it."), color: "#f59e0b" }); } else if (len > 160) { items.push({ text: "Too long (".concat(len, "/160 chars max). It will be cut off in results."), color: "#ef4444" }); } else { items.push({ text: "Description length (".concat(len, ") is perfect."), color: "#43d675" }); } if (focusKeyword && text) { if (text.toLowerCase().includes(focusKeyword.toLowerCase())) { items.push({ text: 'Focus keyword "'.concat(focusKeyword, '" found in description.'), color: "#43d675" }); } else { items.push({ text: 'Focus keyword "'.concat(focusKeyword, '" not in description.'), color: "#ef4444" }); } } const readability = text.length > 50 ? getReadabilityScore(text) : null; if (readability) { items.push({ text: "Readability: ".concat(readability.label), color: readabilityColor(readability.color) }); } const props = __spreadValues$8({ value, onChange, renderDefault, path }, rest); return /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [renderDefault(props), /* @__PURE__ */jsxRuntime.jsx(AIGenerateButton, { field: "description", focusKeyword, onGenerate: handleAIGenerate }), /* @__PURE__ */jsxRuntime.jsx(ui.Stack, { space: 2, children: items.map(item => /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [DOT(item.color), /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, children: item.text })] }, item.text)) })] }); }; function AIKeywordsSection(_ref2) { let { value, onChange } = _ref2; const config = getPluginConfig(); const [loading, setLoading] = React.useState(false); const [suggestions, setSuggestions] = React.useState([]); const [error, setError] = React.useState(null); const bodyField = config.bodyField || "body"; const bodyValue = sanity.useFormValue([bodyField]); const bodyText = portableTextToString(bodyValue); const focusKeyword = (value == null ? void 0 : value.focusKeyword) || ""; const existing = (value == null ? void 0 : value.seoKeywords) || []; const handleSuggest = React.useCallback(async () => { if (!config.aiFeature) return; setLoading(true); setError(null); try { const result = await generateSEOContent("keywords", bodyText, focusKeyword, config.aiFeature); const parsed = result.split(",").map(k => k.trim()).filter(Boolean); setSuggestions(parsed); } catch (err) { setError(err instanceof Error ? err.message : "Keyword generation failed"); } finally { setLoading(false); } }, [config.aiFeature, bodyText, focusKeyword]); const addKeyword = kw => { if (!existing.includes(kw)) onChange([...existing, kw]); setSuggestions(prev => prev.filter(s => s !== kw)); }; if (!config.aiFeature) return null; return /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, shadow: 1, children: /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", justify: "space-between", children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 2, weight: "semibold", children: "AI Keyword Suggestions" }), /* @__PURE__ */jsxRuntime.jsx(ui.Button, { mode: "ghost", tone: "primary", padding: 2, icon: icons.SparklesIcon, text: loading ? "Thinking\u2026" : "Suggest Keywords", onClick: handleSuggest, disabled: loading, fontSize: 1 })] }), error && /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 2, tone: "critical", radius: 2, children: /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, children: error }) }), suggestions.length > 0 && /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, children: "Click a keyword to add it:" }), /* @__PURE__ */jsxRuntime.jsx(ui.Flex, { gap: 2, wrap: "wrap", children: suggestions.map(kw => /* @__PURE__ */jsxRuntime.jsxs("button", { type: "button", onClick: () => addKeyword(kw), style: { padding: "4px 10px", borderRadius: 20, border: "1px solid var(--card-border-color)", background: "var(--card-border-color)", color: "var(--card-fg-color)", cursor: "pointer", fontSize: 12 }, children: ["+ ", kw] }, kw)) })] })] }) }); } function computeSEOScore(value) { var _a, _b, _c, _d, _e; const v = value || {}; const checks = []; const titleLen = (v.metaTitle || "").length; if (titleLen >= 50 && titleLen <= 60) { checks.push({ name: "Meta Title", pass: true, points: 20, maxPoints: 20, hint: "Perfect length (".concat(titleLen, " chars)") }); } else if (titleLen > 0) { checks.push({ name: "Meta Title", pass: "partial", points: 10, maxPoints: 20, hint: titleLen < 50 ? "Too short (".concat(titleLen, "/50\u201360)") : "Too long (".concat(titleLen, "/60)") }); } else { checks.push({ name: "Meta Title", pass: false, points: 0, maxPoints: 20, hint: "No meta title set" }); } const descLen = (v.metaDescription || "").length; if (descLen >= 100 && descLen <= 160) { checks.push({ name: "Description", pass: true, points: 20, maxPoints: 20, hint: "Perfect length (".concat(descLen, " chars)") }); } else if (descLen > 0) { checks.push({ name: "Description", pass: "partial", points: 10, maxPoints: 20, hint: descLen < 100 ? "Too short (".concat(descLen, "/100\u2013160)") : "Too long (".concat(descLen, "/160)") }); } else { checks.push({ name: "Description", pass: false, points: 0, maxPoints: 20, hint: "No meta description set" }); } const kw = (v.focusKeyword || "").toLowerCase().trim(); if (kw) { const inTitle = (_a = v.metaTitle) == null ? void 0 : _a.toLowerCase().includes(kw); const inDesc = (_b = v.metaDescription) == null ? void 0 : _b.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 ".concat(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" }); } if ((_c = v.metaImage) == null ? void 0 : _c.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" }); } const og = v.openGraph || {}; if (og.title && og.description && ((_d = og.image) == null ? void 0 : _d.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 \u2014 add title, description and image" }); } else { checks.push({ name: "Open Graph", pass: false, points: 0, maxPoints: 15, hint: "Not configured" }); } if ((_e = v.twitter) == null ? void 0 : _e.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; let label; 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 }; } const DOT_COLORS = { green: "#43d675", orange: "#f59e0b", red: "#ef4444" }; function dotColor(pass) { if (pass === true) return "green"; if (pass === "partial") return "orange"; return "#94a3b8"; } const CheckRow$1 = _ref3 => { let { check } = _ref3; const color = dotColor(check.pass); return /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 8, height: 8, borderRadius: "50%", backgroundColor: color, flexShrink: 0 } }), /* @__PURE__ */jsxRuntime.jsxs(ui.Text, { size: 1, muted: true, children: [check.name, ": ", check.hint] })] }); }; function cardTone(color) { if (color === "green") return "positive"; if (color === "orange") return "caution"; return "critical"; } function SEOScoreDisplay(_ref4) { let { result } = _ref4; const { score, color, label, checks } = result; const barColor = DOT_COLORS[color]; return /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, shadow: 1, tone: cardTone(color), children: /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", justify: "space-between", children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 2, weight: "semibold", children: "SEO Score" }), /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 4, weight: "bold", style: { color: barColor }, children: score }), /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, children: "/ 100" }), /* @__PURE__ */jsxRuntime.jsx(ui.Box, { style: { background: "#e2e8f0", borderRadius: 100, padding: "6px 8px", backgroundColor: "".concat(barColor, "22") }, children: /* @__PURE__ */jsxRuntime.jsx(ui.Text, { weight: "semibold", style: { color: barColor }, children: label }) })] })] }), /* @__PURE__ */jsxRuntime.jsx("div", { style: { height: 6, background: "#e2e8f0", borderRadius: 3, overflow: "hidden" }, children: /* @__PURE__ */jsxRuntime.jsx("div", { style: { height: "100%", width: "".concat(score, "%"), background: barColor, borderRadius: 3, transition: "width 0.4s ease" } }) }), /* @__PURE__ */jsxRuntime.jsx(ui.Stack, { space: 4, children: checks.map(c => /* @__PURE__ */jsxRuntime.jsx(CheckRow$1, { check: c }, c.name)) })] }) }); } function buildGEOChecklist(value) { var _a, _b, _c, _d, _e, _f, _g; const v = value || {}; const kw = (v.focusKeyword || "").toLowerCase().trim(); return [{ label: "Structured data configured", pass: true, description: "Plugin generates JSON-LD automatically" }, { label: "Description is answer-ready", pass: (v.metaDescription || "").length >= 100 && (v.metaDescription || "").length <= 160, description: "Optimal length for AI snippet extraction (100\u2013160 chars)" }, { label: "Meta image present", pass: Boolean((_a = v.metaImage) == null ? void 0 : _a.asset), description: "AI visual search and social sharing require an image" }, { label: "Keyword in title and description", pass: kw ? ((_b = v.metaTitle) == null ? void 0 : _b.toLowerCase().includes(kw)) && ((_c = v.metaDescription) == null ? void 0 : _c.toLowerCase().includes(kw)) : false, description: "Keyword consistency signals topical relevance to AI crawlers" }, { label: "Open Graph fully configured", pass: Boolean(((_d = v.openGraph) == null ? void 0 : _d.title) && ((_e = v.openGraph) == null ? void 0 : _e.description) && ((_g = (_f = v.openGraph) == null ? void 0 : _f.image) == null ? void 0 : _g.asset)), description: "Required for social sharing and AI citation previews" }]; } function badgeTone(passCount, total) { if (passCount === total) return "positive"; if (passCount >= total / 2) return "caution"; return "critical"; } function GEOChecklist(_ref5) { let { value } = _ref5; const items = buildGEOChecklist(value); const passCount = items.filter(i => i.pass).length; const total = items.length; return /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, shadow: 1, children: /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", justify: "space-between", children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 1, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 2, weight: "semibold", children: "GEO Score AI Search Visibility" }), /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, style: { marginTop: "10px" }, children: "How well this page is optimised for ChatGPT, Perplexity, and Google AI Overviews" })] }), /* @__PURE__ */jsxRuntime.jsxs(ui.Badge, { tone: badgeTone(passCount, total), padding: 2, children: [passCount, "/", total] })] }), /* @__PURE__ */jsxRuntime.jsx(ui.Stack, { space: 4, style: { marginTop: "15px" }, children: items.map(item => /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "flex-start", gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { paddingTop: 2, flexShrink: 0 }, children: /* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 14, height: 14, borderRadius: "50%", backgroundColor: item.pass ? "#43d675" : "#e2e8f0", border: item.pass ? "none" : "2px solid #94a3b8", display: "flex", alignItems: "center", justifyContent: "center" }, children: item.pass && /* @__PURE__ */jsxRuntime.jsx("svg", { width: "8", height: "8", viewBox: "0 0 8 8", fill: "none", children: /* @__PURE__ */jsxRuntime.jsx("path", { d: "M1 4L3 6L7 2", stroke: "white", strokeWidth: "1.5", strokeLinecap: "round" }) }) }) }), /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, weight: item.pass ? "semibold" : "regular", children: item.label }), !item.pass && /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 1, muted: true, children: item.description })] })] }, item.label)) })] }) }); } function buildMetaTags(value) { var _a; const v = value || {}; const lines = []; if (v.metaTitle) lines.push("<title>".concat(v.metaTitle, "</title>")); if (v.metaDescription) lines.push('<meta name="description" content="'.concat(v.metaDescription, '" />')); const robotsParts = []; if (v.nofollowAttributes) robotsParts.push("noindex", "nofollow"); if (Array.isArray(v.robotsMeta) && v.robotsMeta.length > 0) { v.robotsMeta.forEach(r => { if (!robotsParts.includes(r)) robotsParts.push(r); }); } if (robotsParts.length > 0) lines.push('<meta name="robots" content="'.concat(robotsParts.join(", "), '" />')); if (Array.isArray(v.seoKeywords) && v.seoKeywords.length > 0) lines.push('<meta name="keywords" content="'.concat(v.seoKeywords.join(", "), '" />')); const og = v.openGraph || {}; if (og.title) lines.push('<meta property="og:title" content="'.concat(og.title, '" />')); if (og.description) lines.push('<meta property="og:description" content="'.concat(og.description, '" />')); if (og.siteName) lines.push('<meta property="og:site_name" content="'.concat(og.siteName, '" />')); if ((_a = og.image) == null ? void 0 : _a.asset) lines.push('<meta property="og:image" content="[image url]" />'); const tw = v.twitter || {}; if (tw.cardType) lines.push('<meta name="twitter:card" content="'.concat(tw.cardType, '" />')); if (tw.site) lines.push('<meta name="twitter:site" content="'.concat(tw.site, '" />')); if (tw.creator) lines.push('<meta name="twitter:creator" content="'.concat(tw.creator, '" />')); if (Array.isArray(v.hreflang)) { v.hreflang.forEach(h => { if (h.locale && h.url) lines.push('<link rel="alternate" hreflang="'.concat(h.locale, '" href="').concat(h.url, '" />')); }); } return lines.join("\n"); } function MetaTagsPreview(_ref6) { let { value } = _ref6; const [open, setOpen] = React.useState(false); const [copied, setCopied] = React.useState(false); const tags = buildMetaTags(value); const handleCopy = () => { navigator.clipboard.writeText(tags).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2e3); }); }; return /* @__PURE__ */jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, shadow: 1, children: /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 3, children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", justify: "space-between", children: [/* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx(icons.CodeBlockIcon, { style: { color: "#64748b" } }), /* @__PURE__ */jsxRuntime.jsx(ui.Text, { size: 2, weight: "semibold", children: "Meta Tags Preview" })] }), /* @__PURE__ */jsxRuntime.jsx(ui.Button, { mode: "ghost", tone: "default", padding: 2, text: open ? "Hide" : "Show code", onClick: () => setOpen(!open), fontSize: 1 })] }), open && /* @__PURE__ */jsxRuntime.jsxs(ui.Stack, { space: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Box, { style: { background: "var(--card-code-bg-color)", borderRadius: 6, padding: 12, overflow: "auto", maxHeight: 320 }, children: /* @__PURE__ */jsxRuntime.jsx(ui.Code, { language: "html", style: { fontSize: 12, color: "var(--card-code-fg-color)", whiteSpace: "pre", fontFamily: "monospace" }, children: tags || "<!-- No SEO fields filled in yet -->" }) }), /* @__PURE__ */jsxRuntime.jsxs(ui.Flex, { gap: 2, children: [/* @__PURE__ */jsxRuntime.jsx(ui.Button, { mode: "ghost", padding: 2, icon: icons.CopyIcon, text: copied ? "Copied!" : "Copy", onClick: handleCopy, fontSize: 1, tone: copied ? "positive" : "default" }), /* @__PURE__ */jsxRuntime.jsx(ui.Button, { mode: "ghost", padding: 2, icon: icons.LaunchIcon, text: "Rich Results Test", as: "a", href: "https://search.google.com/test/rich-results", target: "_blank", rel: "noopener noreferrer", fontSize: 1 })] })] })] }) }); } function useProEnabled() { const [isPro, setIsPro] = React.useState(isProEnabled()); const [validating, setValidating] = React.useState(isLicenseValidating()); React.useEffect(() => { const handler = () => { setIsPro(isProEnabled()); setValidating(isLicenseValidating()); }; window.addEventListener(SEO_PRO_EVENT, handler); handler(); return () => window.removeEventListener(SEO_PRO_EVENT, handler); }, []); return { isPro, validating }; } const PRO_FEATURES = [{ label: "SEO Health Dashboard", desc: "Score all pages at a glance" }, { label: "SEO Optimizer", desc: "Bulk edit meta fields across all pages" }, { label: "SERP Preview", desc: "Pixel-accurate desktop + mobile preview" }, { label: "Schema.org Wizard", desc: "13 structured data types" }, { label: "CSV Export / Import", desc: "Bulk update via spreadsheet" }]; function PageGate(_ref7) { let { feature } = _ref7; return /* @__PURE__ */jsxRuntime.jsx("div", { style: { height: "100vh", overflow: "hidden", background: "var(--card-bg-color)", display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }, children: /* @__PURE__ */jsxRuntime.jsx("div", { style: { maxWidth: 680, width: "100%" }, children: /* @__PURE__ */jsxRuntime.jsxs("div", { style: { background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 24, overflow: "hidden" }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { height: 4, background: "linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899)" } }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { padding: "40px 48px 38px" }, children: [/* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 28 }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: 60, height: 60, borderRadius: 16, background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)" }, children: /* @__PURE__ */jsxRuntime.jsx(icons.LockIcon, { style: { fontSize: 26, color: "var(--card-link-color)" } }) }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", gap: 10, alignItems: "center" }, children: [/* @__PURE__ */jsxRuntime.jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 16px", background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 99, fontSize: 13, fontWeight: 700, color: "var(--card-link-color)", letterSpacing: 1.2, textTransform: "uppercase" }, children: [/* @__PURE__ */jsxRuntime.jsx(icons.SparklesIcon, { style: { fontSize: 13 } }), "Pro"] }), /* @__PURE__ */jsxRuntime.jsx("span", { style: { padding: "7px 16px", background: "linear-gradient(90deg, #7c3aed22, #3b82f622)", border: "1px solid #7c3aed50", borderRadius: 99, fontSize: 13, fontWeight: 700, color: "#a78bfa", letterSpacing: 1.2, textTransform: "uppercase" }, children: "Coming Soon" })] })] }), /* @__PURE__ */jsxRuntime.jsx("div", { style: { fontSize: 32, fontWeight: 800, color: "var(--card-fg-color)", marginBottom: 10, lineHeight: 1.2 }, children: feature }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { fontSize: 15, color: "var(--card-muted-fg-color)", marginBottom: 28, lineHeight: 1.7 }, children: ["This feature is part of", " ", /* @__PURE__ */jsxRuntime.jsx("span", { style: { color: "var(--card-fg-color)", fontWeight: 600 }, children: "sanity-plugin-seo Pro" }), ". We're working on making it available \u2014 add your license key below when ready."] }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 14, padding: "20px 24px", marginBottom: 20, display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 28px" }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { gridColumn: "1 / -1", fontSize: 11, fontWeight: 700, color: "var(--card-muted-fg-color)", letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 6 }, children: "What's included with Pro" }), PRO_FEATURES.map(item => /* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 10 }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 8, height: 8, borderRadius: "50%", background: "#3b82f6", flexShrink: 0, marginTop: 5 } }), /* @__PURE__ */jsxRuntime.jsxs("div", { children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--card-fg-color)" }, children: item.label }), /* @__PURE__ */jsxRuntime.jsx("div", { style: { fontSize: 12, color: "var(--card-muted-fg-color)", marginTop: 2 }, children: item.desc })] })] }, item.label))] }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 12, padding: "16px 20px", display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--card-muted-fg-color)", flexShrink: 0 }, children: "Already have a key?" }), /* @__PURE__ */jsxRuntime.jsx("code", { style: { flex: 1, fontSize: 13, color: "var(--card-link-color)", background: "var(--card-bg-color)", padding: "8px 14px", borderRadius: 8, border: "1px solid var(--card-border-color)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: "proFeature: process.env.SEO_PRO_LICENSE_KEY" })] })] })] }) }) }); } function CardGate(_ref8) { let { feature } = _ref8; return /* @__PURE__ */jsxRuntime.jsxs("div", { style: { background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 14, overflow: "hidden" }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { height: 2, background: "linear-gradient(90deg, #3b82f6, #8b5cf6)" } }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { padding: "20px 22px" }, children: [/* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }, children: [/* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [/* @__PURE__ */jsxRuntime.jsx("div", { style: { width: 30, height: 30, borderRadius: 8, background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */jsxRuntime.jsx(icons.LockIcon, { style: { fontSize: 13, color: "var(--card-link-color)" } }) }), /* @__PURE__ */jsxRuntime.jsx("span", { style: { fontSize: 13, fontWeight: 700, color: "var(--card-fg-color)" }, children: feature })] }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6 }, children: [/* @__PURE__ */jsxRuntime.jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 9px", background: "var(--card-bg-color)", border: "1px solid var(--card-border-color)", borderRadius: 99, fontSize: 10, fontWeight: 700, color: "var(--card-link-color)", letterSpacing: 0.8, textTransform: "uppercase" }, children: [/* @__PURE__ */jsxRuntime.jsx(icons.SparklesIcon, { style: { fontSize: 10 } }), "Pro"] }), /* @__PURE__ */jsxRuntime.jsx("span", { style: { padding: "3px 9px", background: "linear-gradient(90deg, #7c3aed22, #3b82f622)", border: "1px solid #7c3aed50", borderRadius: 99, fontSize: 10, fontWeight: 700, color: "#a78bfa", letterSpacing: 0.8, textTransform: "uppercase" }, children: "Coming Soon" })] })] }), /* @__PURE__ */jsxRuntime.jsxs("div", { style: { fontSize: 12, color: "var(--card-muted-fg-color)", lineHeight: 1.6 }, children: ["This feature is part of the Pro plan. Add your license key to", " ", /* @__PURE__ */jsxRuntime.jsx("code", { style: { fontSize: 11, color: "var(--card-muted-fg-color)" },