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
JavaScript
'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)"
},