@consilioweb/payload-seo-analyzer 1.7.1 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -15,7 +15,7 @@ var MIN_WORDS_GENERIC = 300;
15
15
  var MIN_WORDS_THIN = 100;
16
16
  var MIN_WORDS_QUALITY_FAIL = 50;
17
17
  var MIN_WORDS_QUALITY_WARN = 200;
18
- var CORNERSTONE_MIN_WORDS = 1500;
18
+ var CORNERSTONE_MIN_WORDS = 600;
19
19
  var THIN_AGING_MIN_WORDS = 500;
20
20
  var KEYWORD_DENSITY_MAX = 3;
21
21
  var KEYWORD_DENSITY_WARN = 2.5;
@@ -1228,6 +1228,44 @@ function buildSeoInputFromDoc(doc, collection, options) {
1228
1228
  const meta = doc.meta || {};
1229
1229
  const hero = doc.hero || {};
1230
1230
  const isPost = collection === "posts";
1231
+ const canonicalUrl = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || void 0;
1232
+ let robotsMeta;
1233
+ const rawRobots = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
1234
+ if (rawRobots) {
1235
+ robotsMeta = rawRobots;
1236
+ } else {
1237
+ const directives = [];
1238
+ if (doc.noindex === true || meta.noindex === true) directives.push("noindex");
1239
+ if (doc.nofollow === true || meta.nofollow === true) directives.push("nofollow");
1240
+ if (directives.length > 0) robotsMeta = directives.join(", ");
1241
+ }
1242
+ let author;
1243
+ let authorUrl;
1244
+ const populatedAuthors = doc.populatedAuthors;
1245
+ if (Array.isArray(populatedAuthors) && populatedAuthors.length > 0) {
1246
+ const a = populatedAuthors[0] || {};
1247
+ author = typeof a.name === "string" && a.name || typeof a.firstName === "string" && a.firstName || void 0;
1248
+ if (typeof a.url === "string") authorUrl = a.url;
1249
+ } else if (Array.isArray(doc.authors) && doc.authors.length > 0) {
1250
+ const a = doc.authors[0];
1251
+ if (a && typeof a === "object" && typeof a.name === "string") author = a.name;
1252
+ } else if (typeof doc.author === "string" && doc.author) {
1253
+ author = doc.author;
1254
+ } else if (doc.author && typeof doc.author === "object" && typeof doc.author.name === "string") {
1255
+ author = doc.author.name;
1256
+ }
1257
+ if (!authorUrl && typeof doc.authorUrl === "string") authorUrl = doc.authorUrl;
1258
+ const publishedAt = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.createdAt === "string" && doc.createdAt || void 0;
1259
+ const displayedDate = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.date === "string" && doc.date || void 0;
1260
+ let localeAlternates;
1261
+ const rawAlts = doc.localeAlternates || doc.alternates || doc.hreflang;
1262
+ if (Array.isArray(rawAlts)) {
1263
+ const mapped = rawAlts.filter((a) => !!a && typeof a === "object").map((a) => ({
1264
+ hreflang: String(a.hreflang || a.locale || a.lang || ""),
1265
+ href: String(a.href || a.url || "")
1266
+ })).filter((a) => a.hreflang && a.href);
1267
+ if (mapped.length > 0) localeAlternates = mapped;
1268
+ }
1231
1269
  return {
1232
1270
  metaTitle: meta.title || "",
1233
1271
  metaDescription: meta.description || "",
@@ -1247,7 +1285,14 @@ function buildSeoInputFromDoc(doc, collection, options) {
1247
1285
  isPost,
1248
1286
  isCornerstone: !!doc.isCornerstone,
1249
1287
  updatedAt: doc.updatedAt || void 0,
1288
+ publishedAt,
1250
1289
  contentLastReviewed: doc.contentLastReviewed || void 0,
1290
+ displayedDate,
1291
+ author,
1292
+ authorUrl,
1293
+ localeAlternates,
1294
+ canonicalUrl,
1295
+ robotsMeta,
1251
1296
  isGlobal: options?.isGlobal ?? false
1252
1297
  };
1253
1298
  }
@@ -1587,7 +1632,10 @@ function analyzeDoc(doc, collection, seoConfig) {
1587
1632
  if (seoInput.isGlobal) {
1588
1633
  seoInput.slug = "";
1589
1634
  }
1590
- const analysis = analyzeSeo(seoInput, seoConfig);
1635
+ const analysis = analyzeSeo(seoInput, {
1636
+ ...seoConfig,
1637
+ disabledRules: [...seoConfig?.disabledRules ?? [], "geo", "eeat", "hreflang"]
1638
+ });
1591
1639
  const extracted = extractDocContent(doc);
1592
1640
  const fullText = extracted.text;
1593
1641
  const allLinks = extracted.links;
@@ -1625,6 +1673,7 @@ function analyzeDoc(doc, collection, seoConfig) {
1625
1673
  hasH1: h1Count > 0,
1626
1674
  h1Count,
1627
1675
  score: analysis.score,
1676
+ aiReadiness: analysis.aiReadiness ? analysis.aiReadiness.score : null,
1628
1677
  level: analysis.level,
1629
1678
  status: doc._status || "published",
1630
1679
  updatedAt: doc.updatedAt || "",
@@ -1647,28 +1696,48 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1647
1696
  let cached = noCache ? null : seoCache.get(CACHE_KEY);
1648
1697
  if (!cached) {
1649
1698
  const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
1699
+ const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "25", 10) || 25));
1700
+ const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
1650
1701
  const allResults = [];
1651
- for (const collectionSlug of collections) {
1652
- try {
1653
- let page2 = 1;
1654
- let hasMore = true;
1655
- while (hasMore) {
1656
- const result = await req.payload.find({
1657
- collection: collectionSlug,
1658
- limit: 100,
1659
- page: page2,
1660
- depth: 1,
1661
- overrideAccess: true
1662
- });
1663
- for (const doc of result.docs) {
1664
- if (ignoredSlugs.includes(doc.slug)) continue;
1665
- allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
1702
+ let capped2 = false;
1703
+ collectionsLoop:
1704
+ for (const collectionSlug of collections) {
1705
+ try {
1706
+ let page2 = 1;
1707
+ let hasMore = true;
1708
+ while (hasMore) {
1709
+ const result = await req.payload.find({
1710
+ collection: collectionSlug,
1711
+ limit: BATCH_SIZE,
1712
+ page: page2,
1713
+ depth: 1,
1714
+ overrideAccess: true
1715
+ });
1716
+ for (const doc of result.docs) {
1717
+ if (ignoredSlugs.includes(doc.slug)) continue;
1718
+ if (allResults.length >= MAX_DOCS2) {
1719
+ capped2 = true;
1720
+ break collectionsLoop;
1721
+ }
1722
+ try {
1723
+ allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
1724
+ } catch (e) {
1725
+ req.payload.logger.warn(
1726
+ `[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
1727
+ );
1728
+ }
1729
+ }
1730
+ hasMore = result.hasNextPage;
1731
+ page2++;
1732
+ await new Promise((resolve) => setImmediate(resolve));
1666
1733
  }
1667
- hasMore = result.hasNextPage;
1668
- page2++;
1734
+ } catch {
1669
1735
  }
1670
- } catch {
1671
1736
  }
1737
+ if (capped2) {
1738
+ req.payload.logger.warn(
1739
+ `[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
1740
+ );
1672
1741
  }
1673
1742
  for (const globalSlug of globals) {
1674
1743
  try {
@@ -1731,10 +1800,10 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1731
1800
  avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
1732
1801
  avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
1733
1802
  };
1734
- cached = { enrichedResults: enrichedResults2, stats: stats2 };
1803
+ cached = { enrichedResults: enrichedResults2, stats: stats2, capped: capped2 };
1735
1804
  seoCache.set(CACHE_KEY, cached);
1736
1805
  }
1737
- const { enrichedResults, stats } = cached;
1806
+ const { enrichedResults, stats, capped } = cached;
1738
1807
  const totalDocs = enrichedResults.length;
1739
1808
  const totalPages = Math.ceil(totalDocs / limit);
1740
1809
  const startIdx = (page - 1) * limit;
@@ -1750,7 +1819,8 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1750
1819
  hasNextPage: page < totalPages,
1751
1820
  hasPrevPage: page > 1
1752
1821
  },
1753
- cached: !noCache && seoCache.get(CACHE_KEY) !== null
1822
+ cached: !noCache && seoCache.get(CACHE_KEY) !== null,
1823
+ capped
1754
1824
  }, { headers: { "Cache-Control": "no-store" } });
1755
1825
  } catch (error) {
1756
1826
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -1760,6 +1830,97 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1760
1830
  };
1761
1831
  }
1762
1832
 
1833
+ // src/endpoints/indexationAudit.ts
1834
+ var INDEXATION_CHECK_IDS = /* @__PURE__ */ new Set([
1835
+ "robots-noindex",
1836
+ "robots-nofollow",
1837
+ "canonical-cross",
1838
+ "canonical-external",
1839
+ "canonical-invalid",
1840
+ "canonical-missing"
1841
+ ]);
1842
+ function createIndexationAuditHandler(collections, seoConfig, globals = []) {
1843
+ return async (req) => {
1844
+ try {
1845
+ if (!req.user) {
1846
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1847
+ }
1848
+ const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
1849
+ const entries = [];
1850
+ const inspect = (doc, collection, isGlobal = false) => {
1851
+ const input = buildSeoInputFromDoc(doc, collection, { isGlobal });
1852
+ const analysis = analyzeSeo(input, mergedConfig);
1853
+ const issues = analysis.checks.filter(
1854
+ (c) => c.group === "technical" && INDEXATION_CHECK_IDS.has(c.id) && c.status !== "pass"
1855
+ ).map((c) => ({ id: c.id, status: c.status, message: c.message }));
1856
+ const noindex = (input.robotsMeta || "").toLowerCase().includes("noindex");
1857
+ if (issues.length > 0 || noindex) {
1858
+ entries.push({
1859
+ collection,
1860
+ id: doc.id ?? (isGlobal ? collection : ""),
1861
+ slug: doc.slug || "",
1862
+ title: doc.title || doc.slug || String(doc.id ?? collection),
1863
+ noindex,
1864
+ issues
1865
+ });
1866
+ }
1867
+ };
1868
+ for (const collectionSlug of collections) {
1869
+ try {
1870
+ let page = 1;
1871
+ let hasMore = true;
1872
+ while (hasMore) {
1873
+ const result = await req.payload.find({
1874
+ collection: collectionSlug,
1875
+ limit: 100,
1876
+ page,
1877
+ depth: 1,
1878
+ overrideAccess: true
1879
+ });
1880
+ for (const doc of result.docs) {
1881
+ if (ignoredSlugs.includes(doc.slug)) continue;
1882
+ inspect(doc, collectionSlug);
1883
+ }
1884
+ hasMore = result.hasNextPage;
1885
+ page++;
1886
+ }
1887
+ } catch {
1888
+ }
1889
+ }
1890
+ for (const globalSlug of globals) {
1891
+ try {
1892
+ const doc = await req.payload.findGlobal({
1893
+ slug: globalSlug,
1894
+ depth: 1,
1895
+ overrideAccess: true
1896
+ });
1897
+ if (doc) inspect(doc, `global:${globalSlug}`, true);
1898
+ } catch {
1899
+ }
1900
+ }
1901
+ const noindexCount = entries.filter((e) => e.noindex).length;
1902
+ const canonicalIssueCount = entries.filter(
1903
+ (e) => e.issues.some((i) => i.id.startsWith("canonical"))
1904
+ ).length;
1905
+ return Response.json(
1906
+ {
1907
+ entries,
1908
+ summary: {
1909
+ totalFlagged: entries.length,
1910
+ noindexCount,
1911
+ canonicalIssueCount
1912
+ }
1913
+ },
1914
+ { headers: { "Cache-Control": "no-store" } }
1915
+ );
1916
+ } catch (error) {
1917
+ const message = error instanceof Error ? error.message : "Internal server error";
1918
+ req.payload.logger.error(`[seo] indexation-audit error: ${message}`);
1919
+ return Response.json({ error: message }, { status: 500 });
1920
+ }
1921
+ };
1922
+ }
1923
+
1763
1924
  // src/endpoints/history.ts
1764
1925
  var TREND_THRESHOLD = 3;
1765
1926
  function createHistoryHandler() {
@@ -2746,6 +2907,9 @@ function createAiGenerateHandler() {
2746
2907
  }
2747
2908
 
2748
2909
  // src/endpoints/cannibalization.ts
2910
+ function canonicalIntent(keyword) {
2911
+ return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
2912
+ }
2749
2913
  function createCannibalizationHandler(collections, globals = []) {
2750
2914
  return async (req) => {
2751
2915
  try {
@@ -2775,26 +2939,28 @@ function createCannibalizationHandler(collections, globals = []) {
2775
2939
  collection: collectionLabel,
2776
2940
  score: 0
2777
2941
  };
2778
- const keywords = [];
2779
- if (d.focusKeyword && typeof d.focusKeyword === "string" && d.focusKeyword.trim()) {
2780
- keywords.push(d.focusKeyword.trim().toLowerCase());
2781
- }
2942
+ const seenCanon = /* @__PURE__ */ new Set();
2943
+ const docKeywords = [];
2944
+ const addKeyword = (raw) => {
2945
+ if (typeof raw !== "string") return;
2946
+ const display = raw.trim();
2947
+ if (!display) return;
2948
+ const key = canonicalIntent(display);
2949
+ if (!key || seenCanon.has(key)) return;
2950
+ seenCanon.add(key);
2951
+ docKeywords.push({ display, key });
2952
+ };
2953
+ addKeyword(d.focusKeyword);
2782
2954
  if (Array.isArray(d.focusKeywords)) {
2783
2955
  for (const kw of d.focusKeywords) {
2784
- const keyword = typeof kw === "string" ? kw : kw?.keyword;
2785
- if (keyword && typeof keyword === "string" && keyword.trim()) {
2786
- const normalized = keyword.trim().toLowerCase();
2787
- if (!keywords.includes(normalized)) {
2788
- keywords.push(normalized);
2789
- }
2790
- }
2956
+ addKeyword(typeof kw === "string" ? kw : kw?.keyword);
2791
2957
  }
2792
2958
  }
2793
- for (const kw of keywords) {
2794
- if (!keywordMap.has(kw)) {
2795
- keywordMap.set(kw, []);
2959
+ for (const { display, key } of docKeywords) {
2960
+ if (!keywordMap.has(key)) {
2961
+ keywordMap.set(key, { display, pages: [] });
2796
2962
  }
2797
- keywordMap.get(kw).push(pageEntry);
2963
+ keywordMap.get(key).pages.push(pageEntry);
2798
2964
  }
2799
2965
  }
2800
2966
  const scoreMap = /* @__PURE__ */ new Map();
@@ -2820,13 +2986,13 @@ function createCannibalizationHandler(collections, globals = []) {
2820
2986
  const conflicts = [];
2821
2987
  let totalAffectedPages = 0;
2822
2988
  const affectedPageIds = /* @__PURE__ */ new Set();
2823
- for (const [keyword, pages] of keywordMap.entries()) {
2989
+ for (const { display, pages } of keywordMap.values()) {
2824
2990
  if (pages.length < 2) continue;
2825
2991
  const enrichedPages = pages.map((p) => ({
2826
2992
  ...p,
2827
2993
  score: scoreMap.get(`${p.collection}::${p.id}`) || 0
2828
2994
  }));
2829
- conflicts.push({ keyword, pages: enrichedPages });
2995
+ conflicts.push({ keyword: display, pages: enrichedPages });
2830
2996
  for (const p of enrichedPages) {
2831
2997
  const pageKey = `${p.collection}::${p.id}`;
2832
2998
  if (!affectedPageIds.has(pageKey)) {
@@ -3600,6 +3766,420 @@ function createPerformanceHandler() {
3600
3766
  };
3601
3767
  }
3602
3768
 
3769
+ // src/endpoints/coreWebVitals.ts
3770
+ var CWV_THRESHOLDS = {
3771
+ lcp: { good: 2500, poor: 4e3 },
3772
+ // ms
3773
+ inp: { good: 200, poor: 500 },
3774
+ // ms
3775
+ cls: { good: 0.1, poor: 0.25 }
3776
+ // unitless
3777
+ };
3778
+ function rate(value, t) {
3779
+ if (value === null || Number.isNaN(value)) return "unknown";
3780
+ if (value <= t.good) return "good";
3781
+ if (value <= t.poor) return "needs-improvement";
3782
+ return "poor";
3783
+ }
3784
+ function resolveSiteUrl(seoConfig) {
3785
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3786
+ }
3787
+ function createCoreWebVitalsHandler(seoConfig) {
3788
+ return async (req) => {
3789
+ try {
3790
+ if (!req.user) {
3791
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
3792
+ }
3793
+ const reqUrl = new URL(req.url);
3794
+ const target = reqUrl.searchParams.get("url");
3795
+ const strategy = reqUrl.searchParams.get("strategy") === "desktop" ? "desktop" : "mobile";
3796
+ if (!target) {
3797
+ return Response.json({ error: "Missing required query param: url" }, { status: 400 });
3798
+ }
3799
+ const siteUrl = resolveSiteUrl(seoConfig);
3800
+ if (!siteUrl) {
3801
+ return Response.json(
3802
+ { error: "siteUrl is not configured \u2014 set it in the plugin config or NEXT_PUBLIC_SERVER_URL." },
3803
+ { status: 400 }
3804
+ );
3805
+ }
3806
+ let targetOrigin;
3807
+ let siteOrigin;
3808
+ try {
3809
+ targetOrigin = new URL(target).origin;
3810
+ siteOrigin = new URL(siteUrl).origin;
3811
+ } catch {
3812
+ return Response.json({ error: "Invalid url." }, { status: 400 });
3813
+ }
3814
+ if (targetOrigin !== siteOrigin) {
3815
+ return Response.json(
3816
+ { error: "Only the configured site origin can be tested." },
3817
+ { status: 403 }
3818
+ );
3819
+ }
3820
+ const apiKey = process.env.PAGESPEED_API_KEY || process.env.GOOGLE_PAGESPEED_API_KEY || "";
3821
+ const psi = new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed");
3822
+ psi.searchParams.set("url", target);
3823
+ psi.searchParams.set("category", "performance");
3824
+ psi.searchParams.set("strategy", strategy);
3825
+ if (apiKey) psi.searchParams.set("key", apiKey);
3826
+ const controller = new AbortController();
3827
+ const timeout = setTimeout(() => controller.abort(), 3e4);
3828
+ let psiData;
3829
+ try {
3830
+ const resp = await fetch(psi.toString(), { signal: controller.signal });
3831
+ if (!resp.ok) {
3832
+ const detail = resp.status === 429 ? " (PSI quota exceeded \u2014 add a PAGESPEED_API_KEY)" : "";
3833
+ return Response.json(
3834
+ { error: `PageSpeed Insights request failed: ${resp.status}${detail}` },
3835
+ { status: 502 }
3836
+ );
3837
+ }
3838
+ psiData = await resp.json();
3839
+ } finally {
3840
+ clearTimeout(timeout);
3841
+ }
3842
+ const loadingExperience = psiData.loadingExperience || {};
3843
+ const metrics = loadingExperience.metrics || {};
3844
+ const fieldMetric = (key) => {
3845
+ const m = metrics[key];
3846
+ const p = m?.percentile;
3847
+ return typeof p === "number" ? p : null;
3848
+ };
3849
+ const fieldLcp = fieldMetric("LARGEST_CONTENTFUL_PAINT_MS");
3850
+ const fieldInp = fieldMetric("INTERACTION_TO_NEXT_PAINT");
3851
+ const fieldClsRaw = fieldMetric("CUMULATIVE_LAYOUT_SHIFT_SCORE");
3852
+ const fieldCls = fieldClsRaw !== null ? fieldClsRaw / 100 : null;
3853
+ const hasFieldData = fieldLcp !== null || fieldInp !== null || fieldCls !== null;
3854
+ const lighthouse = psiData.lighthouseResult || {};
3855
+ const audits = lighthouse.audits || {};
3856
+ const labNumeric = (id) => {
3857
+ const v = audits[id]?.numericValue;
3858
+ return typeof v === "number" ? v : null;
3859
+ };
3860
+ const labLcp = labNumeric("largest-contentful-paint");
3861
+ const labCls = labNumeric("cumulative-layout-shift");
3862
+ const labTbt = labNumeric("total-blocking-time");
3863
+ const lcp = fieldLcp ?? labLcp;
3864
+ const inp = fieldInp;
3865
+ const cls = fieldCls ?? labCls;
3866
+ return Response.json(
3867
+ {
3868
+ url: target,
3869
+ strategy,
3870
+ source: hasFieldData ? "field" : "lab",
3871
+ hasFieldData,
3872
+ metrics: {
3873
+ lcp: { value: lcp, unit: "ms", rating: rate(lcp, CWV_THRESHOLDS.lcp) },
3874
+ inp: {
3875
+ value: inp,
3876
+ unit: "ms",
3877
+ rating: rate(inp, CWV_THRESHOLDS.inp),
3878
+ note: inp === null ? "INP needs real-user (field) data; not available in lab." : void 0
3879
+ },
3880
+ cls: { value: cls, unit: "score", rating: rate(cls, CWV_THRESHOLDS.cls) },
3881
+ tbt: { value: labTbt, unit: "ms", source: "lab" }
3882
+ },
3883
+ thresholds: CWV_THRESHOLDS,
3884
+ keyConfigured: !!apiKey,
3885
+ note: "Informational only \u2014 Core Web Vitals are a ranking tie-breaker, not part of the on-page SEO score."
3886
+ },
3887
+ { headers: { "Cache-Control": "no-store" } }
3888
+ );
3889
+ } catch (error) {
3890
+ const message = error instanceof Error ? error.message : "Internal server error";
3891
+ req.payload.logger.error(`[seo] core-web-vitals error: ${message}`);
3892
+ return Response.json({ error: message }, { status: 500 });
3893
+ }
3894
+ };
3895
+ }
3896
+ var ALGO = "aes-256-gcm";
3897
+ var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
3898
+ var FORMAT_VERSION = "v1";
3899
+ function deriveKey(secret) {
3900
+ const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
3901
+ if (explicit) {
3902
+ const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
3903
+ if (buf.length === 32) return buf;
3904
+ throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
3905
+ }
3906
+ if (!secret) {
3907
+ throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
3908
+ }
3909
+ return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
3910
+ }
3911
+ function encryptToken(plaintext, secret) {
3912
+ const key = deriveKey(secret);
3913
+ const iv = crypto.randomBytes(12);
3914
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
3915
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
3916
+ const tag = cipher.getAuthTag();
3917
+ return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
3918
+ }
3919
+ function decryptToken(payload, secret) {
3920
+ const parts = payload.split(":");
3921
+ if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
3922
+ throw new Error("Invalid encrypted token format.");
3923
+ }
3924
+ const key = deriveKey(secret);
3925
+ const iv = Buffer.from(parts[1], "base64");
3926
+ const tag = Buffer.from(parts[2], "base64");
3927
+ const enc = Buffer.from(parts[3], "base64");
3928
+ const decipher = crypto.createDecipheriv(ALGO, key, iv);
3929
+ decipher.setAuthTag(tag);
3930
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
3931
+ return dec.toString("utf8");
3932
+ }
3933
+ function safeEqual(a, b) {
3934
+ const ba = Buffer.from(a);
3935
+ const bb = Buffer.from(b);
3936
+ if (ba.length !== bb.length) return false;
3937
+ return crypto.timingSafeEqual(ba, bb);
3938
+ }
3939
+
3940
+ // src/endpoints/gscOAuth.ts
3941
+ var AUTH_COLLECTION = "seo-gsc-auth";
3942
+ var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
3943
+ function isAdmin5(user) {
3944
+ if (!user) return false;
3945
+ if (user.role === "admin") return true;
3946
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3947
+ return false;
3948
+ }
3949
+ function resolveSiteUrl2(seoConfig) {
3950
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3951
+ }
3952
+ function getOAuthConfig(basePath, seoConfig) {
3953
+ const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
3954
+ const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
3955
+ const siteUrl = resolveSiteUrl2(seoConfig);
3956
+ if (!clientId || !clientSecret || !siteUrl) return null;
3957
+ return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
3958
+ }
3959
+ async function getOrCreateAuthDoc(payload) {
3960
+ const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
3961
+ if (found.docs.length > 0) return found.docs[0];
3962
+ return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
3963
+ }
3964
+ async function tokenRequest(cfg, body) {
3965
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
3966
+ method: "POST",
3967
+ headers: { "content-type": "application/x-www-form-urlencoded" },
3968
+ body: new URLSearchParams({
3969
+ client_id: cfg.clientId,
3970
+ client_secret: cfg.clientSecret,
3971
+ ...body
3972
+ }).toString()
3973
+ });
3974
+ const json = await resp.json();
3975
+ if (!resp.ok) {
3976
+ throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
3977
+ }
3978
+ return json;
3979
+ }
3980
+ function createGscStatusHandler(basePath, seoConfig) {
3981
+ return async (req) => {
3982
+ try {
3983
+ if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
3984
+ const cfg = getOAuthConfig(basePath, seoConfig);
3985
+ const doc = await getOrCreateAuthDoc(req.payload);
3986
+ return Response.json(
3987
+ {
3988
+ configured: !!cfg,
3989
+ connected: !!doc.refreshTokenEnc,
3990
+ connectedEmail: doc.connectedEmail || null,
3991
+ connectedAt: doc.connectedAt || null,
3992
+ propertyUrl: doc.propertyUrl || cfg?.siteUrl || null,
3993
+ redirectUri: cfg?.redirectUri || null
3994
+ },
3995
+ { headers: { "Cache-Control": "no-store" } }
3996
+ );
3997
+ } catch (error) {
3998
+ const message = error instanceof Error ? error.message : "Internal server error";
3999
+ req.payload.logger.error(`[seo] gsc-status error: ${message}`);
4000
+ return Response.json({ error: message }, { status: 500 });
4001
+ }
4002
+ };
4003
+ }
4004
+ function createGscAuthStartHandler(basePath, seoConfig) {
4005
+ return async (req) => {
4006
+ try {
4007
+ if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4008
+ const cfg = getOAuthConfig(basePath, seoConfig);
4009
+ if (!cfg) {
4010
+ return Response.json(
4011
+ { error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
4012
+ { status: 400 }
4013
+ );
4014
+ }
4015
+ const state = crypto.randomBytes(24).toString("hex");
4016
+ const doc = await getOrCreateAuthDoc(req.payload);
4017
+ await req.payload.update({
4018
+ collection: AUTH_COLLECTION,
4019
+ id: doc.id,
4020
+ data: { pendingState: state },
4021
+ overrideAccess: true
4022
+ });
4023
+ const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
4024
+ authUrl.searchParams.set("client_id", cfg.clientId);
4025
+ authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
4026
+ authUrl.searchParams.set("response_type", "code");
4027
+ authUrl.searchParams.set("scope", SCOPES);
4028
+ authUrl.searchParams.set("access_type", "offline");
4029
+ authUrl.searchParams.set("prompt", "consent");
4030
+ authUrl.searchParams.set("state", state);
4031
+ return Response.json({ authUrl: authUrl.toString() }, { headers: { "Cache-Control": "no-store" } });
4032
+ } catch (error) {
4033
+ const message = error instanceof Error ? error.message : "Internal server error";
4034
+ req.payload.logger.error(`[seo] gsc-auth error: ${message}`);
4035
+ return Response.json({ error: message }, { status: 500 });
4036
+ }
4037
+ };
4038
+ }
4039
+ function createGscCallbackHandler(basePath, seoConfig) {
4040
+ return async (req) => {
4041
+ const htmlPage = (title, body) => new Response(
4042
+ `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family:system-ui;padding:2rem;max-width:40rem;margin:auto"><h1>${title}</h1><p>${body}</p><p><a href="/admin/performance">\u2190 Back to the SEO dashboard</a></p></body></html>`,
4043
+ { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
4044
+ );
4045
+ try {
4046
+ if (!isAdmin5(req.user)) {
4047
+ return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
4048
+ }
4049
+ const cfg = getOAuthConfig(basePath, seoConfig);
4050
+ if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
4051
+ const url = new URL(req.url);
4052
+ const code = url.searchParams.get("code");
4053
+ const state = url.searchParams.get("state");
4054
+ const oauthError = url.searchParams.get("error");
4055
+ if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
4056
+ if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
4057
+ const doc = await getOrCreateAuthDoc(req.payload);
4058
+ if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
4059
+ return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
4060
+ }
4061
+ const tokens = await tokenRequest(cfg, {
4062
+ code,
4063
+ redirect_uri: cfg.redirectUri,
4064
+ grant_type: "authorization_code"
4065
+ });
4066
+ const refreshToken = tokens.refresh_token;
4067
+ const accessToken = tokens.access_token;
4068
+ if (!refreshToken) {
4069
+ return htmlPage(
4070
+ "Connection failed",
4071
+ "Google did not return a refresh token. Revoke the app access in your Google account and try again (the consent screen must show)."
4072
+ );
4073
+ }
4074
+ let email = null;
4075
+ if (accessToken) {
4076
+ try {
4077
+ const ui = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
4078
+ headers: { authorization: `Bearer ${accessToken}` }
4079
+ });
4080
+ if (ui.ok) email = (await ui.json()).email;
4081
+ } catch {
4082
+ }
4083
+ }
4084
+ const secret = req.payload.secret || "";
4085
+ const refreshTokenEnc = encryptToken(refreshToken, secret);
4086
+ await req.payload.update({
4087
+ collection: AUTH_COLLECTION,
4088
+ id: doc.id,
4089
+ data: {
4090
+ refreshTokenEnc,
4091
+ pendingState: null,
4092
+ connectedEmail: email,
4093
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
4094
+ scope: tokens.scope || SCOPES,
4095
+ propertyUrl: doc.propertyUrl || cfg.siteUrl
4096
+ },
4097
+ overrideAccess: true
4098
+ });
4099
+ return htmlPage("Google Search Console connected \u2705", "You can close this tab and return to the SEO dashboard.");
4100
+ } catch (error) {
4101
+ const message = error instanceof Error ? error.message : "Internal server error";
4102
+ req.payload.logger.error(`[seo] gsc-callback error: ${message}`);
4103
+ return htmlPage("Connection failed", "An unexpected error occurred. Check the server logs.");
4104
+ }
4105
+ };
4106
+ }
4107
+ function createGscDataHandler(basePath, seoConfig) {
4108
+ return async (req) => {
4109
+ try {
4110
+ if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4111
+ const cfg = getOAuthConfig(basePath, seoConfig);
4112
+ if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
4113
+ const doc = await getOrCreateAuthDoc(req.payload);
4114
+ if (!doc.refreshTokenEnc) {
4115
+ return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
4116
+ }
4117
+ const secret = req.payload.secret || "";
4118
+ let refreshToken;
4119
+ try {
4120
+ refreshToken = decryptToken(doc.refreshTokenEnc, secret);
4121
+ } catch {
4122
+ return Response.json(
4123
+ { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4124
+ { status: 409 }
4125
+ );
4126
+ }
4127
+ const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
4128
+ const accessToken = tokens.access_token;
4129
+ if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4130
+ const url = new URL(req.url);
4131
+ const today = /* @__PURE__ */ new Date();
4132
+ const defaultEnd = today.toISOString().slice(0, 10);
4133
+ const defaultStart = new Date(today.getTime() - 28 * 864e5).toISOString().slice(0, 10);
4134
+ const startDate = url.searchParams.get("startDate") || defaultStart;
4135
+ const endDate = url.searchParams.get("endDate") || defaultEnd;
4136
+ const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
4137
+ const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
4138
+ const property = doc.propertyUrl || cfg.siteUrl;
4139
+ const gscResp = await fetch(
4140
+ `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
4141
+ {
4142
+ method: "POST",
4143
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
4144
+ body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit })
4145
+ }
4146
+ );
4147
+ const gscJson = await gscResp.json();
4148
+ if (!gscResp.ok) {
4149
+ const err = gscJson.error?.message || gscResp.status;
4150
+ return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
4151
+ }
4152
+ return Response.json(
4153
+ { property, startDate, endDate, dimension, rows: gscJson.rows || [] },
4154
+ { headers: { "Cache-Control": "no-store" } }
4155
+ );
4156
+ } catch (error) {
4157
+ const message = error instanceof Error ? error.message : "Internal server error";
4158
+ req.payload.logger.error(`[seo] gsc-data error: ${message}`);
4159
+ return Response.json({ error: message }, { status: 500 });
4160
+ }
4161
+ };
4162
+ }
4163
+ function createGscDisconnectHandler() {
4164
+ return async (req) => {
4165
+ try {
4166
+ if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4167
+ const doc = await getOrCreateAuthDoc(req.payload);
4168
+ await req.payload.update({
4169
+ collection: AUTH_COLLECTION,
4170
+ id: doc.id,
4171
+ data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
4172
+ overrideAccess: true
4173
+ });
4174
+ return Response.json({ disconnected: true }, { headers: { "Cache-Control": "no-store" } });
4175
+ } catch (error) {
4176
+ const message = error instanceof Error ? error.message : "Internal server error";
4177
+ req.payload.logger.error(`[seo] gsc-disconnect error: ${message}`);
4178
+ return Response.json({ error: message }, { status: 500 });
4179
+ }
4180
+ };
4181
+ }
4182
+
3603
4183
  // src/endpoints/keywordResearch.ts
3604
4184
  var STOP_WORDS_SET = /* @__PURE__ */ new Set();
3605
4185
  function getStopWords2() {
@@ -4136,12 +4716,12 @@ function detectSchemaType(collection, doc) {
4136
4716
  if (collection === "posts") return "Article";
4137
4717
  const layout = doc.layout;
4138
4718
  if (layout && Array.isArray(layout)) {
4139
- const hasFaqBlock = layout.some((block) => {
4719
+ const hasFaqBlock2 = layout.some((block) => {
4140
4720
  if (!block || typeof block !== "object") return false;
4141
4721
  const b = block;
4142
4722
  return b.blockType === "faq" || b.blockType === "FAQ" || b.blockType === "faqBlock";
4143
4723
  });
4144
- if (hasFaqBlock) return "FAQPage";
4724
+ if (hasFaqBlock2) return "FAQPage";
4145
4725
  }
4146
4726
  if (doc.price !== void 0 || doc.sku !== void 0 || collection === "products") {
4147
4727
  return "Product";
@@ -4292,6 +4872,75 @@ function buildOrganizationSchema(doc, siteUrl) {
4292
4872
  if (doc.logo) {
4293
4873
  schema.logo = typeof doc.logo === "string" ? doc.logo : doc.logo?.url;
4294
4874
  }
4875
+ if (Array.isArray(doc.sameAs)) {
4876
+ const sameAs = doc.sameAs.filter((s) => typeof s === "string");
4877
+ if (sameAs.length > 0) schema.sameAs = sameAs;
4878
+ }
4879
+ return schema;
4880
+ }
4881
+ function buildPersonSchema(doc, siteUrl) {
4882
+ const meta = doc.meta || {};
4883
+ const schema = {
4884
+ "@context": "https://schema.org",
4885
+ "@type": "Person",
4886
+ name: doc.name || doc.title || meta.title || ""
4887
+ };
4888
+ if (doc.jobTitle) schema.jobTitle = doc.jobTitle;
4889
+ if (doc.description || meta.description) schema.description = doc.description || meta.description;
4890
+ schema.url = typeof doc.url === "string" && doc.url || `${siteUrl}/${doc.slug || ""}`;
4891
+ if (Array.isArray(doc.sameAs)) {
4892
+ const sameAs = doc.sameAs.filter((s) => typeof s === "string");
4893
+ if (sameAs.length > 0) schema.sameAs = sameAs;
4894
+ }
4895
+ return schema;
4896
+ }
4897
+ function buildEventSchema(doc, siteUrl) {
4898
+ const meta = doc.meta || {};
4899
+ const schema = {
4900
+ "@context": "https://schema.org",
4901
+ "@type": "Event",
4902
+ name: doc.title || meta.title || "",
4903
+ description: meta.description || "",
4904
+ startDate: doc.startDate || doc.eventStart || void 0,
4905
+ endDate: doc.endDate || doc.eventEnd || void 0,
4906
+ url: `${siteUrl}/${doc.slug || ""}`
4907
+ };
4908
+ if (doc.location) {
4909
+ schema.location = typeof doc.location === "string" ? { "@type": "Place", name: doc.location } : { "@type": "Place", ...doc.location };
4910
+ }
4911
+ return schema;
4912
+ }
4913
+ function buildRecipeSchema(doc, siteUrl) {
4914
+ const meta = doc.meta || {};
4915
+ const heroMedia = doc.hero?.media;
4916
+ const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
4917
+ const schema = {
4918
+ "@context": "https://schema.org",
4919
+ "@type": "Recipe",
4920
+ name: doc.title || meta.title || "",
4921
+ description: meta.description || ""
4922
+ };
4923
+ if (imageUrl) schema.image = imageUrl;
4924
+ if (Array.isArray(doc.recipeIngredient)) schema.recipeIngredient = doc.recipeIngredient;
4925
+ else if (Array.isArray(doc.ingredients)) schema.recipeIngredient = doc.ingredients;
4926
+ if (doc.recipeInstructions) schema.recipeInstructions = doc.recipeInstructions;
4927
+ else if (doc.instructions) schema.recipeInstructions = doc.instructions;
4928
+ return schema;
4929
+ }
4930
+ function buildVideoSchema(doc, siteUrl) {
4931
+ const meta = doc.meta || {};
4932
+ const heroMedia = doc.hero?.media;
4933
+ const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
4934
+ const schema = {
4935
+ "@context": "https://schema.org",
4936
+ "@type": "VideoObject",
4937
+ name: doc.title || meta.title || "",
4938
+ description: meta.description || "",
4939
+ uploadDate: doc.uploadDate || doc.createdAt || void 0
4940
+ };
4941
+ if (imageUrl) schema.thumbnailUrl = imageUrl;
4942
+ if (doc.videoUrl || doc.contentUrl) schema.contentUrl = doc.videoUrl || doc.contentUrl;
4943
+ if (doc.duration) schema.duration = doc.duration;
4295
4944
  return schema;
4296
4945
  }
4297
4946
  function getImageUrl(metaImage, heroMedia, siteUrl) {
@@ -4330,7 +4979,7 @@ function createSchemaGeneratorHandler(targetCollections) {
4330
4979
  { status: 400 }
4331
4980
  );
4332
4981
  }
4333
- const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization"];
4982
+ const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"];
4334
4983
  if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
4335
4984
  return Response.json(
4336
4985
  { error: `Invalid schema type. Valid types: ${validTypes.join(", ")}` },
@@ -4375,6 +5024,18 @@ function createSchemaGeneratorHandler(targetCollections) {
4375
5024
  case "Organization":
4376
5025
  jsonLd = buildOrganizationSchema(doc, siteUrl);
4377
5026
  break;
5027
+ case "Person":
5028
+ jsonLd = buildPersonSchema(doc, siteUrl);
5029
+ break;
5030
+ case "Event":
5031
+ jsonLd = buildEventSchema(doc, siteUrl);
5032
+ break;
5033
+ case "Recipe":
5034
+ jsonLd = buildRecipeSchema(doc, siteUrl);
5035
+ break;
5036
+ case "Video":
5037
+ jsonLd = buildVideoSchema(doc, siteUrl);
5038
+ break;
4378
5039
  }
4379
5040
  const cleaned = JSON.parse(JSON.stringify(jsonLd));
4380
5041
  return Response.json({
@@ -4696,7 +5357,7 @@ function createAiRewriteHandler(targetCollections) {
4696
5357
  }
4697
5358
 
4698
5359
  // src/endpoints/robots.ts
4699
- function isAdmin5(user) {
5360
+ function isAdmin6(user) {
4700
5361
  if (!user) return false;
4701
5362
  if (user.role === "admin") return true;
4702
5363
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -4745,7 +5406,7 @@ function createRobotsUpdateHandler() {
4745
5406
  if (!req.user) {
4746
5407
  return Response.json({ error: "Unauthorized" }, { status: 401 });
4747
5408
  }
4748
- if (!isAdmin5(req.user)) {
5409
+ if (!isAdmin6(req.user)) {
4749
5410
  return Response.json({ error: "Admin access required" }, { status: 403 });
4750
5411
  }
4751
5412
  const body = await parseJsonBody(req);
@@ -5481,6 +6142,55 @@ function createSeoLogsCollection() {
5481
6142
  };
5482
6143
  }
5483
6144
 
6145
+ // src/collections/SeoGscAuth.ts
6146
+ function createSeoGscAuthCollection() {
6147
+ return {
6148
+ slug: "seo-gsc-auth",
6149
+ admin: {
6150
+ hidden: true,
6151
+ custom: { navHidden: true }
6152
+ },
6153
+ access: {
6154
+ read: ({ req }) => !!req.user,
6155
+ update: ({ req }) => !!req.user,
6156
+ create: ({ req }) => !!req.user,
6157
+ delete: ({ req }) => !!req.user
6158
+ },
6159
+ fields: [
6160
+ {
6161
+ name: "refreshTokenEnc",
6162
+ type: "text",
6163
+ // The encrypted refresh-token blob must never leave the server.
6164
+ access: {
6165
+ read: () => false,
6166
+ create: () => false,
6167
+ update: () => false
6168
+ },
6169
+ admin: { hidden: true }
6170
+ },
6171
+ {
6172
+ name: "pendingState",
6173
+ type: "text",
6174
+ // CSRF state for the in-flight OAuth handshake — also server-only.
6175
+ access: {
6176
+ read: () => false,
6177
+ create: () => false,
6178
+ update: () => false
6179
+ },
6180
+ admin: { hidden: true }
6181
+ },
6182
+ { name: "connectedEmail", type: "text", admin: { readOnly: true } },
6183
+ { name: "connectedAt", type: "date", admin: { readOnly: true } },
6184
+ {
6185
+ name: "propertyUrl",
6186
+ type: "text",
6187
+ admin: { description: "GSC property (e.g. sc-domain:example.com or https://example.com/)" }
6188
+ },
6189
+ { name: "scope", type: "text", admin: { readOnly: true } }
6190
+ ]
6191
+ };
6192
+ }
6193
+
5484
6194
  // src/rateLimiter.ts
5485
6195
  function createRateLimiter(maxRequests, windowMs) {
5486
6196
  const store = /* @__PURE__ */ new Map();
@@ -5528,7 +6238,7 @@ function getClientIp(req) {
5528
6238
 
5529
6239
  // src/endpoints/seoLogs.ts
5530
6240
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
5531
- function isAdmin6(user) {
6241
+ function isAdmin7(user) {
5532
6242
  if (!user) return false;
5533
6243
  if (user.role === "admin") return true;
5534
6244
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -5625,7 +6335,7 @@ function createSeoLogsHandler(seoLogsSecret) {
5625
6335
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5626
6336
  }
5627
6337
  if (method === "DELETE") {
5628
- if (!isAdmin6(req.user)) {
6338
+ if (!isAdmin7(req.user)) {
5629
6339
  return Response.json({ error: "Admin access required" }, { status: 403 });
5630
6340
  }
5631
6341
  try {
@@ -7209,6 +7919,9 @@ var fr = {
7209
7919
  groupTechnical: "Technique",
7210
7920
  groupAccessibility: "Accessibilit\xE9",
7211
7921
  groupEcommerce: "E-commerce",
7922
+ groupEeat: "E-E-A-T",
7923
+ groupGeo: "GEO (IA)",
7924
+ groupHreflang: "Hreflang",
7212
7925
  levelExcellent: "Excellent",
7213
7926
  levelGood: "Bon",
7214
7927
  levelFair: "Acceptable",
@@ -7217,6 +7930,8 @@ var fr = {
7217
7930
  categoryImportant: "Important",
7218
7931
  categoryBonus: "Bonus",
7219
7932
  seoScore: "Score SEO",
7933
+ aiReadiness: "IA",
7934
+ aiReadinessTooltip: "Pr\xEAt pour l'IA \u2014 qualit\xE9 de structuration pour \xEAtre cit\xE9 par les moteurs g\xE9n\xE9ratifs (AI Overviews, ChatGPT, Perplexity). Distinct du score SEO.",
7220
7935
  outOf100: "/ 100",
7221
7936
  cornerstoneLabel: "PILIER",
7222
7937
  checksPassed: "crit\xE8res valid\xE9s",
@@ -7782,6 +8497,9 @@ var en = {
7782
8497
  groupTechnical: "Technical",
7783
8498
  groupAccessibility: "Accessibility",
7784
8499
  groupEcommerce: "E-commerce",
8500
+ groupEeat: "E-E-A-T",
8501
+ groupGeo: "GEO (AI)",
8502
+ groupHreflang: "Hreflang",
7785
8503
  levelExcellent: "Excellent",
7786
8504
  levelGood: "Good",
7787
8505
  levelFair: "Fair",
@@ -7790,6 +8508,8 @@ var en = {
7790
8508
  categoryImportant: "Important",
7791
8509
  categoryBonus: "Bonus",
7792
8510
  seoScore: "SEO Score",
8511
+ aiReadiness: "AI",
8512
+ aiReadinessTooltip: "AI-readiness \u2014 how well the page is structured to be cited by generative engines (AI Overviews, ChatGPT, Perplexity). Separate from the SEO score.",
7793
8513
  outOf100: "/ 100",
7794
8514
  cornerstoneLabel: "CORNERSTONE",
7795
8515
  checksPassed: "checks passed",
@@ -7959,6 +8679,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
7959
8679
  aiFeatures: true,
7960
8680
  duplicateContent: true,
7961
8681
  settings: true,
8682
+ gscApi: false,
8683
+ // opt-in — requires Google Cloud OAuth setup + secrets
8684
+ warmCache: true,
8685
+ // disable on low-memory hosts to skip startup pre-loading
7962
8686
  ...pluginConfig.features
7963
8687
  };
7964
8688
  function hasExistingSeoMeta(fields) {
@@ -8099,6 +8823,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8099
8823
  if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
8100
8824
  if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
8101
8825
  if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
8826
+ if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
8102
8827
  config.collections = [
8103
8828
  ...config.collections || [],
8104
8829
  ...pluginCollections
@@ -8151,6 +8876,11 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8151
8876
  method: "get",
8152
8877
  handler: withRateLimit(createAuditHandler(targetCollections, seoConfig, targetGlobals))
8153
8878
  });
8879
+ pluginEndpoints.push({
8880
+ path: `${basePath}/indexation-audit`,
8881
+ method: "get",
8882
+ handler: withRateLimit(createIndexationAuditHandler(targetCollections, seoConfig, targetGlobals))
8883
+ });
8154
8884
  }
8155
8885
  if (features.scoreHistory) {
8156
8886
  pluginEndpoints.push({
@@ -8225,7 +8955,18 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8225
8955
  if (features.performance) {
8226
8956
  pluginEndpoints.push(
8227
8957
  { path: `${basePath}/performance`, method: "get", handler: createPerformanceHandler() },
8228
- { path: `${basePath}/performance`, method: "post", handler: createPerformanceHandler() }
8958
+ { path: `${basePath}/performance`, method: "post", handler: createPerformanceHandler() },
8959
+ // Core Web Vitals via PageSpeed Insights — informational, on-demand, SSRF-safe
8960
+ { path: `${basePath}/core-web-vitals`, method: "get", handler: withRateLimit(createCoreWebVitalsHandler(seoConfig)) }
8961
+ );
8962
+ }
8963
+ if (features.gscApi) {
8964
+ pluginEndpoints.push(
8965
+ { path: `${basePath}/gsc/status`, method: "get", handler: createGscStatusHandler(basePath, seoConfig) },
8966
+ { path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
8967
+ { path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
8968
+ { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
8969
+ { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
8229
8970
  );
8230
8971
  }
8231
8972
  if (features.keywords) {
@@ -8368,7 +9109,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8368
9109
  const existingOnInit = config.onInit;
8369
9110
  config.onInit = async (payload) => {
8370
9111
  if (existingOnInit) await existingOnInit(payload);
8371
- startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
9112
+ if (features.warmCache) {
9113
+ startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
9114
+ }
8372
9115
  };
8373
9116
  return config;
8374
9117
  };
@@ -8399,8 +9142,11 @@ var rulesFr = {
8399
9142
  powerWordsPass: (count, words) => `Le title contient ${count} mot(s) puissant(s) : ${words}`,
8400
9143
  powerWordsFail: 'Le title ne contient aucun mot puissant \u2014 Ajoutez un mot comme "gratuit", "guide", "complet" pour booster le CTR.',
8401
9144
  powerWordsTip: "Les mots puissants (gratuit, exclusif, guide, complet, essentiel...) attirent l'attention dans les resultats de recherche.",
9145
+ pixelWidthLabel: "Largeur du title (pixels)",
9146
+ pixelWidthPass: (px) => `Largeur estimee ${px}px \u2014 sous le seuil de troncature SERP (~600px).`,
9147
+ pixelWidthWarn: (px) => `Largeur estimee ${px}px \u2014 risque de troncature dans Google (~600px). Informatif.`,
8402
9148
  hasNumberLabel: "Nombre dans le title",
8403
- hasNumberPass: "Le title contient un nombre \u2014 Les titres avec chiffres generent +36% de CTR.",
9149
+ hasNumberPass: "Le title contient un nombre \u2014 format type liste, souvent plus engageant.",
8404
9150
  hasNumberFail: 'Aucun nombre dans le title \u2014 Les titres avec chiffres (ex: "5 astuces", "Top 10") attirent plus de clics.',
8405
9151
  hasNumberTip: 'Ajoutez un nombre pour creer un titre de type liste (ex: "7 conseils pour...", "Les 3 erreurs a eviter").',
8406
9152
  isQuestionLabel: "Title interrogatif",
@@ -8484,12 +9230,12 @@ var rulesFr = {
8484
9230
  keywordIntroFail: (kw) => `Ajoutez le mot-cle "${kw}" dans les premieres phrases du contenu.`,
8485
9231
  densityLabel: "Densite du mot-cle",
8486
9232
  densityOverstuffed: (density) => `Densite du mot-cle : ${density}% \u2014 Trop eleve (>3%), risque de suroptimisation (keyword stuffing).`,
8487
- densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee. Restez entre 0,5% et 2,5%.`,
8488
- densityPass: (density) => `Densite du mot-cle : ${density}% \u2014 Equilibre ideal.`,
9233
+ densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee, restez naturel pour eviter la sur-optimisation.`,
9234
+ densityPass: (density) => `Densite du mot-cle : ${density}% \u2014 Usage naturel, aucun bourrage detecte.`,
8489
9235
  densityPassWordLevel: (density) => `Les composants du mot-cle sont presents dans le contenu (densite estimee : ${density}%).`,
8490
9236
  densityLowWordLevel: (density) => `Les composants du mot-cle sont presents mais peu frequents (densite estimee : ${density}%). Renforcez leur presence.`,
8491
9237
  densityLow: (density) => `Densite du mot-cle : ${density}% \u2014 Trop faible. Visez 0,5% a 2,5%.`,
8492
- densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait jamais dans le contenu.`,
9238
+ densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait pas dans le corps \u2014 privilegiez une couverture naturelle du sujet plutot que la repetition.`,
8493
9239
  placeholderLabel: "Contenu placeholder",
8494
9240
  placeholderFail: "Du contenu placeholder a ete detecte (lorem ipsum, TODO, etc.) \u2014 Remplacez par du vrai contenu.",
8495
9241
  placeholderPass: "Aucun contenu placeholder detecte.",
@@ -8575,7 +9321,7 @@ var rulesFr = {
8575
9321
  cornerstone: {
8576
9322
  wordcountLabel: "Longueur du contenu pilier",
8577
9323
  wordcountPass: (count) => `${count} mots \u2014 Le contenu pilier est suffisamment complet.`,
8578
- wordcountFail: (count) => `${count} mots \u2014 Un contenu pilier devrait contenir au moins 1500 mots pour etre vraiment complet.`,
9324
+ wordcountFail: (count) => `${count} mots \u2014 Contenu pilier trop leger ; developpez la couverture du sujet (sans viser un nombre de mots arbitraire).`,
8579
9325
  internalLinksLabel: "Maillage interne du contenu pilier",
8580
9326
  internalLinksPass: (count) => `${count} liens internes \u2014 Bon maillage pour un contenu pilier.`,
8581
9327
  internalLinksFail: (count) => `${count} lien(s) interne(s) \u2014 Un contenu pilier devrait avoir au moins 5 liens internes vers du contenu associe.`,
@@ -8629,12 +9375,23 @@ var rulesFr = {
8629
9375
  yearRefWarn: (oldest, current, last) => `Le contenu mentionne l'annee ${oldest} sans reference a ${current} ou ${last} \u2014 Contenu potentiellement obsolete.`,
8630
9376
  yearRefPass: (year) => `Le contenu fait reference a l'annee en cours (${year}).`,
8631
9377
  thinAgingLabel: "Contenu leger et ancien",
8632
- thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence.`
9378
+ thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence.`,
9379
+ fakeRefreshLabel: "Faux rafraichissement",
9380
+ fakeRefreshWarn: (displayedDays, updatedDays) => `La date affichee (il y a ${displayedDays} j) est plus recente que la derniere modification reelle (il y a ${updatedDays} j) \u2014 evitez de rajeunir la date sans vraie mise a jour du contenu.`,
9381
+ fakeRefreshTip: "Google detecte la vraie date de modification. Mettez a jour le fond du contenu, pas seulement la date affichee."
8633
9382
  },
8634
9383
  schema: {
8635
9384
  readinessLabel: "Donnees structurees",
8636
9385
  readinessPass: "La page a suffisamment de metadonnees pour generer du JSON-LD (title, description, image).",
8637
- readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees."
9386
+ readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees.",
9387
+ coverageLabel: "Couverture des donnees structurees",
9388
+ coverageOptional: "Les donnees structurees sont optionnelles pour ce type de page.",
9389
+ coveragePass: (type) => `Donnees CMS suffisantes pour generer un schema ${type} valide.`,
9390
+ coverageMissing: (type, fields) => `Schema ${type} attendu pour cette page \u2014 champ(s) requis manquant(s) dans le CMS : ${fields}.`,
9391
+ coverageMissingTip: "Completez ces champs, ou assurez-vous que le JSON-LD correspondant est injecte au rendu (frontend).",
9392
+ coverageRemind: (type, fields) => `Schema ${type} : champs CMS requis presents. Verifiez aussi ces champs requis non detectables automatiquement : ${fields}.`,
9393
+ faqNoRichResultLabel: "FAQ / donnees structurees",
9394
+ faqNoRichResult: "FAQPage detecte \u2014 markup valide et toujours lu par les moteurs et l'IA, mais Google ne genere plus de rich result FAQ (2026). Conservez le markup, n'attendez pas d'extrait enrichi en SERP."
8638
9395
  },
8639
9396
  technical: {
8640
9397
  canonicalMissingLabel: "URL canonique",
@@ -8643,6 +9400,8 @@ var rulesFr = {
8643
9400
  canonicalInvalidMessage: (url) => `URL canonique "${url}" invalide \u2014 Utilisez une URL absolue (https://...).`,
8644
9401
  canonicalExternalLabel: "URL canonique",
8645
9402
  canonicalExternalMessage: "URL canonique pointe vers un domaine externe \u2014 Verifiez que c'est intentionnel.",
9403
+ canonicalCrossLabel: "URL canonique",
9404
+ canonicalCrossMessage: (target) => `URL canonique pointe vers une AUTRE page (${target}) \u2014 cette page se desindexe au profit de cette URL. Verifiez que c'est intentionnel.`,
8646
9405
  canonicalOkLabel: "URL canonique",
8647
9406
  canonicalOkMessage: "URL canonique correctement definie.",
8648
9407
  robotsNoindexLabel: "Robots noindex",
@@ -8767,8 +9526,11 @@ var rulesEn = {
8767
9526
  powerWordsPass: (count, words) => `The title contains ${count} power word(s): ${words}`,
8768
9527
  powerWordsFail: 'The title contains no power words \u2014 Add a word like "free", "guide", "ultimate" to boost CTR.',
8769
9528
  powerWordsTip: "Power words (free, exclusive, guide, ultimate, essential...) attract attention in search results.",
9529
+ pixelWidthLabel: "Title pixel width",
9530
+ pixelWidthPass: (px) => `Estimated width ${px}px \u2014 under the SERP truncation threshold (~600px).`,
9531
+ pixelWidthWarn: (px) => `Estimated width ${px}px \u2014 may be truncated in Google (~600px). Informational.`,
8770
9532
  hasNumberLabel: "Number in title",
8771
- hasNumberPass: "The title contains a number \u2014 Titles with numbers generate +36% CTR.",
9533
+ hasNumberPass: "The title contains a number \u2014 list-style format, often more engaging.",
8772
9534
  hasNumberFail: 'No number in the title \u2014 Titles with numbers (e.g. "5 tips", "Top 10") attract more clicks.',
8773
9535
  hasNumberTip: 'Add a number to create a list-type title (e.g. "7 tips for...", "The 3 mistakes to avoid").',
8774
9536
  isQuestionLabel: "Question title",
@@ -8852,12 +9614,12 @@ var rulesEn = {
8852
9614
  keywordIntroFail: (kw) => `Add the keyword "${kw}" in the first sentences of the content.`,
8853
9615
  densityLabel: "Keyword density",
8854
9616
  densityOverstuffed: (density) => `Keyword density: ${density}% \u2014 Too high (>3%), risk of keyword stuffing.`,
8855
- densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high. Stay between 0.5% and 2.5%.`,
8856
- densityPass: (density) => `Keyword density: ${density}% \u2014 Ideal balance.`,
9617
+ densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high; keep it natural to avoid over-optimisation.`,
9618
+ densityPass: (density) => `Keyword density: ${density}% \u2014 Natural usage, no stuffing detected.`,
8857
9619
  densityPassWordLevel: (density) => `Keyword components are present in the content (estimated density: ${density}%).`,
8858
9620
  densityLowWordLevel: (density) => `Keyword components are present but infrequent (estimated density: ${density}%). Strengthen their presence.`,
8859
9621
  densityLow: (density) => `Keyword density: ${density}% \u2014 Too low. Aim for 0.5% to 2.5%.`,
8860
- densityMissing: (kw) => `The keyword "${kw}" never appears in the content.`,
9622
+ densityMissing: (kw) => `The keyword "${kw}" does not appear in the body \u2014 focus on covering the topic naturally rather than repeating it.`,
8861
9623
  placeholderLabel: "Placeholder content",
8862
9624
  placeholderFail: "Placeholder content detected (lorem ipsum, TODO, etc.) \u2014 Replace with real content.",
8863
9625
  placeholderPass: "No placeholder content detected.",
@@ -8943,7 +9705,7 @@ var rulesEn = {
8943
9705
  cornerstone: {
8944
9706
  wordcountLabel: "Pillar content length",
8945
9707
  wordcountPass: (count) => `${count} words \u2014 The pillar content is comprehensive enough.`,
8946
- wordcountFail: (count) => `${count} words \u2014 Pillar content should contain at least 1500 words to be truly comprehensive.`,
9708
+ wordcountFail: (count) => `${count} words \u2014 Pillar content seems thin; expand topical coverage (without targeting an arbitrary word count).`,
8947
9709
  internalLinksLabel: "Pillar content internal linking",
8948
9710
  internalLinksPass: (count) => `${count} internal links \u2014 Good linking for pillar content.`,
8949
9711
  internalLinksFail: (count) => `${count} internal link(s) \u2014 Pillar content should have at least 5 internal links to related content.`,
@@ -8997,12 +9759,23 @@ var rulesEn = {
8997
9759
  yearRefWarn: (oldest, current, last) => `The content mentions year ${oldest} without reference to ${current} or ${last} \u2014 Potentially outdated.`,
8998
9760
  yearRefPass: (year) => `The content references the current year (${year}).`,
8999
9761
  thinAgingLabel: "Thin and old content",
9000
- thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly.`
9762
+ thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly.`,
9763
+ fakeRefreshLabel: "Fake refresh",
9764
+ fakeRefreshWarn: (displayedDays, updatedDays) => `The displayed date (${displayedDays} days ago) is newer than the last real modification (${updatedDays} days ago) \u2014 avoid bumping the date without a real content update.`,
9765
+ fakeRefreshTip: "Google detects the real modification date. Update the actual content, not just the displayed date."
9001
9766
  },
9002
9767
  schema: {
9003
9768
  readinessLabel: "Structured data",
9004
9769
  readinessPass: "The page has sufficient metadata to generate JSON-LD (title, description, image).",
9005
- readinessFail: "Complete the title, description and add an image to fully leverage structured data."
9770
+ readinessFail: "Complete the title, description and add an image to fully leverage structured data.",
9771
+ coverageLabel: "Structured data coverage",
9772
+ coverageOptional: "Structured data is optional for this page type.",
9773
+ coveragePass: (type) => `Enough CMS data to generate a valid ${type} schema.`,
9774
+ coverageMissing: (type, fields) => `${type} schema expected for this page \u2014 required field(s) missing in the CMS: ${fields}.`,
9775
+ coverageMissingTip: "Complete these fields, or make sure the matching JSON-LD is injected at render time (frontend).",
9776
+ coverageRemind: (type, fields) => `${type} schema: required CMS fields are present. Also confirm these required fields the analyzer cannot detect: ${fields}.`,
9777
+ faqNoRichResultLabel: "FAQ / structured data",
9778
+ faqNoRichResult: "FAQPage detected \u2014 markup is valid and still read by search/AI engines, but Google no longer renders FAQ rich results (2026). Keep the markup; do not expect an enhanced SERP snippet."
9006
9779
  },
9007
9780
  technical: {
9008
9781
  canonicalMissingLabel: "Canonical URL",
@@ -9011,6 +9784,8 @@ var rulesEn = {
9011
9784
  canonicalInvalidMessage: (url) => `Canonical URL "${url}" is invalid \u2014 Use an absolute URL (https://...).`,
9012
9785
  canonicalExternalLabel: "Canonical URL",
9013
9786
  canonicalExternalMessage: "Canonical URL points to an external domain \u2014 Verify this is intentional.",
9787
+ canonicalCrossLabel: "Canonical URL",
9788
+ canonicalCrossMessage: (target) => `Canonical URL points to a DIFFERENT page (${target}) \u2014 this page de-indexes itself in favor of that URL. Verify this is intentional.`,
9014
9789
  canonicalOkLabel: "Canonical URL",
9015
9790
  canonicalOkMessage: "Canonical URL is correctly defined.",
9016
9791
  robotsNoindexLabel: "Robots noindex",
@@ -9363,6 +10138,21 @@ function getTranslations(locale) {
9363
10138
  }
9364
10139
 
9365
10140
  // src/rules/title.ts
10141
+ var TITLE_PIXEL_MAX = 600;
10142
+ var NARROW_CHARS = new Set("iIl.,:;|!'`jft()[]{}/\\".split(""));
10143
+ var WIDE_CHARS = new Set("mwMW@%".split(""));
10144
+ function estimateTitlePixelWidth(title) {
10145
+ let px = 0;
10146
+ for (const ch of title) {
10147
+ if (ch === " ") px += 5;
10148
+ else if (NARROW_CHARS.has(ch)) px += 5;
10149
+ else if (WIDE_CHARS.has(ch)) px += 15;
10150
+ else if (ch >= "A" && ch <= "Z") px += 12;
10151
+ else if (ch >= "0" && ch <= "9") px += 10;
10152
+ else px += 9;
10153
+ }
10154
+ return Math.round(px);
10155
+ }
9366
10156
  function checkTitle(input, ctx) {
9367
10157
  const checks = [];
9368
10158
  const r = getTranslations(ctx.locale).rules.title;
@@ -9388,8 +10178,10 @@ function checkTitle(input, ctx) {
9388
10178
  label: r.lengthLabel,
9389
10179
  status: "warning",
9390
10180
  message: r.lengthShort(titleLen),
9391
- category: "critical",
9392
- weight: 3,
10181
+ // SEO desintox: title length is a SERP-display hint, NOT a ranking factor
10182
+ // (Google rewrites 60%+ of titles). Informational weight, never critical.
10183
+ category: "bonus",
10184
+ weight: 1,
9393
10185
  group: "title",
9394
10186
  tip: r.lengthShortTip
9395
10187
  });
@@ -9399,8 +10191,8 @@ function checkTitle(input, ctx) {
9399
10191
  label: r.lengthLabel,
9400
10192
  status: "warning",
9401
10193
  message: r.lengthLong(titleLen),
9402
- category: "critical",
9403
- weight: 3,
10194
+ category: "bonus",
10195
+ weight: 1,
9404
10196
  group: "title",
9405
10197
  tip: r.lengthLongTip
9406
10198
  });
@@ -9410,11 +10202,21 @@ function checkTitle(input, ctx) {
9410
10202
  label: r.lengthLabel,
9411
10203
  status: "pass",
9412
10204
  message: r.lengthPass(titleLen),
9413
- category: "critical",
9414
- weight: 3,
10205
+ category: "bonus",
10206
+ weight: 1,
9415
10207
  group: "title"
9416
10208
  });
9417
10209
  }
10210
+ const titlePx = estimateTitlePixelWidth(title);
10211
+ checks.push({
10212
+ id: "title-pixel-width",
10213
+ label: r.pixelWidthLabel,
10214
+ status: titlePx > TITLE_PIXEL_MAX ? "warning" : "pass",
10215
+ message: titlePx > TITLE_PIXEL_MAX ? r.pixelWidthWarn(titlePx) : r.pixelWidthPass(titlePx),
10216
+ category: "bonus",
10217
+ weight: 0,
10218
+ group: "title"
10219
+ });
9418
10220
  if (kw) {
9419
10221
  const titleNorm = normalizeForComparison(title);
9420
10222
  const kwPresent = keywordMatchesText(kw, titleNorm);
@@ -9950,44 +10752,15 @@ function checkContent(input, ctx) {
9950
10752
  weight: 2,
9951
10753
  group: "content"
9952
10754
  });
9953
- } else if (density >= KEYWORD_DENSITY_MIN) {
9954
- checks.push({
9955
- id: "content-keyword-density",
9956
- label: r.densityLabel,
9957
- status: "pass",
9958
- message: exactCount > 0 ? r.densityPass(density.toFixed(1)) : r.densityPassWordLevel(density.toFixed(1)),
9959
- category: "important",
9960
- weight: 2,
9961
- group: "content"
9962
- });
9963
- } else if (wordLevelMatch) {
9964
- checks.push({
9965
- id: "content-keyword-density",
9966
- label: r.densityLabel,
9967
- status: "warning",
9968
- message: r.densityLowWordLevel(density.toFixed(1)),
9969
- category: "important",
9970
- weight: 2,
9971
- group: "content"
9972
- });
9973
- } else if (exactCount > 0) {
9974
- checks.push({
9975
- id: "content-keyword-density",
9976
- label: r.densityLabel,
9977
- status: "warning",
9978
- message: r.densityLow(density.toFixed(1)),
9979
- category: "important",
9980
- weight: 2,
9981
- group: "content"
9982
- });
9983
10755
  } else {
10756
+ const present = exactCount > 0 || wordLevelMatch;
9984
10757
  checks.push({
9985
10758
  id: "content-keyword-density",
9986
10759
  label: r.densityLabel,
9987
- status: "fail",
9988
- message: r.densityMissing(input.focusKeyword || normalizedKeyword),
9989
- category: "important",
9990
- weight: 2,
10760
+ status: "pass",
10761
+ message: present ? r.densityPass(density.toFixed(1)) : r.densityMissing(input.focusKeyword || normalizedKeyword),
10762
+ category: "bonus",
10763
+ weight: 0,
9991
10764
  group: "content"
9992
10765
  });
9993
10766
  }
@@ -10034,39 +10807,17 @@ function checkContent(input, ctx) {
10034
10807
  const tiersWithKw = [tier1, tier2, tier3].filter(
10035
10808
  (t) => keywordMatchesText(normalizedKeyword, t)
10036
10809
  ).length;
10037
- if (tiersWithKw >= 2) {
10038
- checks.push({
10039
- id: "content-keyword-distribution",
10040
- label: r.distributionLabel,
10041
- status: "pass",
10042
- message: r.distributionPass(tiersWithKw),
10043
- category: "important",
10044
- weight: 2,
10045
- group: "content"
10046
- });
10047
- } else if (tiersWithKw === 1) {
10048
- checks.push({
10049
- id: "content-keyword-distribution",
10050
- label: r.distributionLabel,
10051
- status: "warning",
10052
- message: r.distributionWarn,
10053
- category: "important",
10054
- weight: 2,
10055
- group: "content",
10056
- tip: r.distributionWarnTip
10057
- });
10058
- } else {
10059
- checks.push({
10060
- id: "content-keyword-distribution",
10061
- label: r.distributionLabel,
10062
- status: "fail",
10063
- message: r.distributionFail,
10064
- category: "important",
10065
- weight: 2,
10066
- group: "content",
10067
- tip: r.distributionFailTip
10068
- });
10069
- }
10810
+ const wellDistributed = tiersWithKw >= 2;
10811
+ checks.push({
10812
+ id: "content-keyword-distribution",
10813
+ label: r.distributionLabel,
10814
+ status: wellDistributed ? "pass" : "warning",
10815
+ message: wellDistributed ? r.distributionPass(tiersWithKw) : r.distributionWarn,
10816
+ category: "bonus",
10817
+ weight: 0,
10818
+ group: "content",
10819
+ ...wellDistributed ? {} : { tip: r.distributionWarnTip }
10820
+ });
10070
10821
  }
10071
10822
  if (wordCount > 500) {
10072
10823
  const allLists = [];
@@ -10558,23 +11309,161 @@ function checkSocial(input, ctx) {
10558
11309
  return checks;
10559
11310
  }
10560
11311
 
11312
+ // src/rules/schema-requirements.ts
11313
+ var SCHEMA_REQUIREMENTS = {
11314
+ Article: {
11315
+ required: ["headline", "image"],
11316
+ recommended: ["author", "datePublished", "dateModified", "publisher"],
11317
+ richResult: true
11318
+ },
11319
+ Product: {
11320
+ // Google needs name + at least one of offers / review / aggregateRating
11321
+ required: ["name", "offers"],
11322
+ recommended: ["image", "brand", "aggregateRating", "review", "sku"],
11323
+ richResult: true
11324
+ },
11325
+ LocalBusiness: {
11326
+ required: ["name", "address"],
11327
+ recommended: ["telephone", "openingHours", "geo", "priceRange"],
11328
+ richResult: true
11329
+ },
11330
+ BreadcrumbList: {
11331
+ required: ["itemListElement"],
11332
+ recommended: [],
11333
+ richResult: true
11334
+ },
11335
+ Organization: {
11336
+ required: ["name", "url"],
11337
+ recommended: ["logo", "sameAs"],
11338
+ richResult: false
11339
+ },
11340
+ Person: {
11341
+ required: ["name"],
11342
+ recommended: ["sameAs", "jobTitle", "image", "url"],
11343
+ richResult: false
11344
+ },
11345
+ FAQPage: {
11346
+ required: ["mainEntity"],
11347
+ recommended: [],
11348
+ // FAQ rich results removed by Google (May 2026). Markup still useful for AI/search understanding.
11349
+ richResult: false
11350
+ },
11351
+ Event: {
11352
+ required: ["name", "startDate", "location"],
11353
+ recommended: ["endDate", "offers", "image", "performer"],
11354
+ richResult: true
11355
+ },
11356
+ Recipe: {
11357
+ required: ["name", "image", "recipeIngredient", "recipeInstructions"],
11358
+ recommended: ["nutrition", "aggregateRating", "totalTime", "recipeYield"],
11359
+ richResult: true
11360
+ },
11361
+ Video: {
11362
+ required: ["name", "thumbnailUrl", "uploadDate"],
11363
+ recommended: ["duration", "contentUrl", "description"],
11364
+ richResult: true
11365
+ }
11366
+ };
11367
+ var CMS_VERIFIABLE_SCHEMA_FIELDS = /* @__PURE__ */ new Set([
11368
+ "headline",
11369
+ "name",
11370
+ "image",
11371
+ "url",
11372
+ "itemListElement",
11373
+ "mainEntity"
11374
+ ]);
11375
+
10561
11376
  // src/rules/schema.ts
11377
+ var FAQ_BLOCK_TYPES = /* @__PURE__ */ new Set(["faq", "FAQ", "faqBlock", "faqs"]);
11378
+ function hasFaqBlock(blocks) {
11379
+ if (!Array.isArray(blocks)) return false;
11380
+ return blocks.some((block) => {
11381
+ if (!block || typeof block !== "object") return false;
11382
+ const t = block.blockType;
11383
+ return typeof t === "string" && FAQ_BLOCK_TYPES.has(t);
11384
+ });
11385
+ }
11386
+ function detectExpectedSchemaType(input, ctx) {
11387
+ if (input.isProduct) return "Product";
11388
+ if (input.isPost || ctx.pageType === "blog") return "Article";
11389
+ if (ctx.pageType === "local-seo") return "LocalBusiness";
11390
+ if (ctx.pageType === "agency") return "Organization";
11391
+ if (ctx.pageType === "legal" || ctx.pageType === "contact" || ctx.pageType === "form") return null;
11392
+ return "Article";
11393
+ }
10562
11394
  function checkSchema(input, ctx) {
10563
11395
  const checks = [];
10564
11396
  const r = getTranslations(ctx.locale).rules.schema;
10565
- const hasMetaTitle = !!input.metaTitle;
10566
- const hasMetaDesc = !!input.metaDescription;
10567
- const hasImage = ctx.imageStats.total > 0;
10568
- const readyForSchema = hasMetaTitle && hasMetaDesc && hasImage;
10569
- checks.push({
10570
- id: "schema-readiness",
10571
- label: r.readinessLabel,
10572
- status: readyForSchema ? "pass" : "warning",
10573
- message: readyForSchema ? r.readinessPass : r.readinessFail,
10574
- category: "bonus",
10575
- weight: 1,
10576
- group: "schema"
10577
- });
11397
+ const faqPresent = hasFaqBlock(input.blocks);
11398
+ const present = {
11399
+ headline: !!(input.metaTitle || input.heroTitle) || ctx.allHeadings.some((h) => h.tag === "h1"),
11400
+ name: !!(input.metaTitle || input.heroTitle),
11401
+ image: ctx.imageStats.total > 0 || !!input.metaImage,
11402
+ url: true,
11403
+ itemListElement: !!input.slug,
11404
+ mainEntity: faqPresent
11405
+ };
11406
+ const expected = detectExpectedSchemaType(input, ctx);
11407
+ if (expected === null) {
11408
+ checks.push({
11409
+ id: "schema-coverage",
11410
+ label: r.coverageLabel,
11411
+ status: "pass",
11412
+ message: r.coverageOptional,
11413
+ category: "bonus",
11414
+ weight: 0,
11415
+ group: "schema"
11416
+ });
11417
+ } else {
11418
+ const reqDef = SCHEMA_REQUIREMENTS[expected];
11419
+ const knownMissing = reqDef.required.filter(
11420
+ (f) => CMS_VERIFIABLE_SCHEMA_FIELDS.has(f) && !present[f]
11421
+ );
11422
+ const unverifiable = reqDef.required.filter((f) => !CMS_VERIFIABLE_SCHEMA_FIELDS.has(f));
11423
+ if (knownMissing.length > 0) {
11424
+ checks.push({
11425
+ id: "schema-coverage",
11426
+ label: r.coverageLabel,
11427
+ status: "warning",
11428
+ message: r.coverageMissing(expected, knownMissing.join(", ")),
11429
+ category: "bonus",
11430
+ weight: 1,
11431
+ group: "schema",
11432
+ tip: r.coverageMissingTip
11433
+ });
11434
+ } else if (unverifiable.length > 0) {
11435
+ checks.push({
11436
+ id: "schema-coverage",
11437
+ label: r.coverageLabel,
11438
+ status: "pass",
11439
+ message: r.coverageRemind(expected, unverifiable.join(", ")),
11440
+ category: "bonus",
11441
+ weight: 0,
11442
+ group: "schema"
11443
+ });
11444
+ } else {
11445
+ checks.push({
11446
+ id: "schema-coverage",
11447
+ label: r.coverageLabel,
11448
+ status: "pass",
11449
+ message: r.coveragePass(expected),
11450
+ category: "bonus",
11451
+ weight: 1,
11452
+ group: "schema"
11453
+ });
11454
+ }
11455
+ }
11456
+ if (faqPresent && true) {
11457
+ checks.push({
11458
+ id: "schema-faq-no-rich-result",
11459
+ label: r.faqNoRichResultLabel,
11460
+ status: "pass",
11461
+ message: r.faqNoRichResult,
11462
+ category: "bonus",
11463
+ weight: 0,
11464
+ group: "schema"
11465
+ });
11466
+ }
10578
11467
  return checks;
10579
11468
  }
10580
11469
 
@@ -10696,8 +11585,8 @@ function checkReadability(input, ctx) {
10696
11585
  label: r.passiveLabelFail,
10697
11586
  status: "warning",
10698
11587
  message: r.passiveFail(passiveSentences.length, sentences.length, Math.round(passiveRatio * 100)),
10699
- category: "important",
10700
- weight: 2,
11588
+ category: "bonus",
11589
+ weight: 0,
10701
11590
  group: "readability"
10702
11591
  });
10703
11592
  } else {
@@ -10706,8 +11595,8 @@ function checkReadability(input, ctx) {
10706
11595
  label: r.passiveLabelPass,
10707
11596
  status: "pass",
10708
11597
  message: r.passivePass(Math.round(passiveRatio * 100)),
10709
- category: "important",
10710
- weight: 2,
11598
+ category: "bonus",
11599
+ weight: 0,
10711
11600
  group: "readability"
10712
11601
  });
10713
11602
  }
@@ -10722,7 +11611,7 @@ function checkReadability(input, ctx) {
10722
11611
  status: "warning",
10723
11612
  message: r.transitionsFail(Math.round(transitionRatio * 100)),
10724
11613
  category: "bonus",
10725
- weight: 1,
11614
+ weight: 0,
10726
11615
  group: "readability"
10727
11616
  });
10728
11617
  } else {
@@ -10732,7 +11621,7 @@ function checkReadability(input, ctx) {
10732
11621
  status: "pass",
10733
11622
  message: r.transitionsPass(Math.round(transitionRatio * 100)),
10734
11623
  category: "bonus",
10735
- weight: 1,
11624
+ weight: 0,
10736
11625
  group: "readability"
10737
11626
  });
10738
11627
  }
@@ -11180,10 +12069,34 @@ function checkFreshness(input, ctx) {
11180
12069
  group: "freshness"
11181
12070
  });
11182
12071
  }
12072
+ if (input.displayedDate && input.updatedAt) {
12073
+ const displayedDays = daysSince(input.displayedDate);
12074
+ const updatedDays = daysSince(input.updatedAt);
12075
+ if (displayedDays !== Infinity && updatedDays !== Infinity && updatedDays - displayedDays > 60) {
12076
+ checks.push({
12077
+ id: "freshness-fake-refresh",
12078
+ label: r.fakeRefreshLabel,
12079
+ status: "warning",
12080
+ message: r.fakeRefreshWarn(displayedDays, updatedDays),
12081
+ category: "bonus",
12082
+ weight: 0,
12083
+ group: "freshness",
12084
+ tip: r.fakeRefreshTip
12085
+ });
12086
+ }
12087
+ }
11183
12088
  return checks;
11184
12089
  }
11185
12090
 
11186
12091
  // src/rules/technical.ts
12092
+ function isCrossCanonical(canonical, siteUrl, slug) {
12093
+ const norm = (p) => p.replace(/[?#].*$/, "").replace(/^\/+/, "").replace(/\/+$/, "").toLowerCase();
12094
+ const canonicalPath = norm(canonical.slice(siteUrl.length));
12095
+ const selfPath = norm(slug);
12096
+ const selfIsHome = selfPath === "" || selfPath === "home";
12097
+ if (selfIsHome) return canonicalPath !== "" && canonicalPath !== "home";
12098
+ return canonicalPath !== selfPath;
12099
+ }
11187
12100
  function checkTechnical(input, ctx) {
11188
12101
  const checks = [];
11189
12102
  const r = getTranslations(ctx.locale).rules.technical;
@@ -11221,6 +12134,16 @@ function checkTechnical(input, ctx) {
11221
12134
  weight: 2,
11222
12135
  group: "technical"
11223
12136
  });
12137
+ } else if (siteUrl && input.slug && !input.isGlobal && isCrossCanonical(canonical, siteUrl, input.slug)) {
12138
+ checks.push({
12139
+ id: "canonical-cross",
12140
+ label: r.canonicalCrossLabel,
12141
+ status: "warning",
12142
+ message: r.canonicalCrossMessage(canonical),
12143
+ category: "important",
12144
+ weight: 2,
12145
+ group: "technical"
12146
+ });
11224
12147
  } else {
11225
12148
  checks.push({
11226
12149
  id: "canonical-ok",
@@ -11513,6 +12436,336 @@ function checkEcommerce(input, ctx) {
11513
12436
  return checks;
11514
12437
  }
11515
12438
 
12439
+ // src/rules/eeat.ts
12440
+ var STRINGS = {
12441
+ fr: {
12442
+ authorLabel: "Auteur attribu\xE9 (E-E-A-T)",
12443
+ authorPass: "Un auteur est attribu\xE9 \u2014 bon signal de transparence E-E-A-T.",
12444
+ authorFail: "Aucun auteur identifi\xE9 \u2014 attribuez un auteur r\xE9el pour renforcer la confiance (E-E-A-T).",
12445
+ authorTip: "Ajoutez un auteur avec une courte bio et un lien vers son profil (Person schema + sameAs).",
12446
+ authorEntityLabel: "Entit\xE9 auteur (profil / sameAs)",
12447
+ authorEntityPass: "L'auteur a un lien de profil \u2014 renforce l'entit\xE9 (sameAs) pour les moteurs et l'IA.",
12448
+ authorEntityFail: "L'auteur n'a pas de lien de profil \u2014 ajoutez une URL (LinkedIn, page auteur) comme sameAs.",
12449
+ datesLabel: "Dates de publication / mise \xE0 jour",
12450
+ datesPass: "Dates de publication et de mise \xE0 jour disponibles \u2014 bon pour la fra\xEEcheur et la confiance.",
12451
+ datesFail: "Date de publication ou de mise \xE0 jour manquante \u2014 exposez datePublished et dateModified.",
12452
+ sourcesLabel: "Sources externes cit\xE9es",
12453
+ sourcesPass: (n) => `${n} lien(s) vers des sources externes \u2014 renforce la cr\xE9dibilit\xE9 et l'E-E-A-T.`,
12454
+ sourcesFail: "Aucune source externe cit\xE9e \u2014 liez des sources fiables pour appuyer vos affirmations.",
12455
+ sourcesTip: "Citez des \xE9tudes, donn\xE9es officielles ou r\xE9f\xE9rences sectorielles avec des liens sortants.",
12456
+ dataLabel: "Donn\xE9es originales / chiffr\xE9es",
12457
+ dataPass: "Le contenu pr\xE9sente des donn\xE9es chiffr\xE9es \u2014 signal d'expertise et de contenu original.",
12458
+ dataFail: "Peu de donn\xE9es chiffr\xE9es d\xE9tect\xE9es \u2014 ajoutez des chiffres, statistiques ou r\xE9sultats concrets.",
12459
+ dataTip: "Le contenu d\xE9montrant une exp\xE9rience de premi\xE8re main (donn\xE9es, chiffres, exemples v\xE9cus) est le levier de mont\xE9e n\xB01 en 2026."
12460
+ },
12461
+ en: {
12462
+ authorLabel: "Attributed author (E-E-A-T)",
12463
+ authorPass: "An author is attributed \u2014 good E-E-A-T transparency signal.",
12464
+ authorFail: "No identified author \u2014 attribute a real author to strengthen trust (E-E-A-T).",
12465
+ authorTip: "Add an author with a short bio and a link to their profile (Person schema + sameAs).",
12466
+ authorEntityLabel: "Author entity (profile / sameAs)",
12467
+ authorEntityPass: "The author has a profile link \u2014 strengthens the entity (sameAs) for search and AI.",
12468
+ authorEntityFail: "The author has no profile link \u2014 add a URL (LinkedIn, author page) as sameAs.",
12469
+ datesLabel: "Published / updated dates",
12470
+ datesPass: "Published and modified dates available \u2014 good for freshness and trust.",
12471
+ datesFail: "Published or modified date missing \u2014 expose datePublished and dateModified.",
12472
+ sourcesLabel: "External sources cited",
12473
+ sourcesPass: (n) => `${n} link(s) to external sources \u2014 strengthens credibility and E-E-A-T.`,
12474
+ sourcesFail: "No external source cited \u2014 link reliable sources to back your claims.",
12475
+ sourcesTip: "Cite studies, official data or industry references with outbound links.",
12476
+ dataLabel: "Original / quantitative data",
12477
+ dataPass: "The content includes quantitative data \u2014 a signal of expertise and original content.",
12478
+ dataFail: "Little quantitative data detected \u2014 add figures, statistics or concrete results.",
12479
+ dataTip: "First-hand-experience content (data, figures, lived examples) is the #1 visibility lever in 2026."
12480
+ }
12481
+ };
12482
+ var EEAT_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
12483
+ function checkEeat(input, ctx) {
12484
+ const checks = [];
12485
+ if (EEAT_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 100) return checks;
12486
+ const s = STRINGS[ctx.locale] ?? STRINGS.fr;
12487
+ const hasAuthor = !!(input.author && input.author.trim());
12488
+ checks.push({
12489
+ id: "eeat-author",
12490
+ label: s.authorLabel,
12491
+ status: hasAuthor ? "pass" : "warning",
12492
+ message: hasAuthor ? s.authorPass : s.authorFail,
12493
+ category: "important",
12494
+ weight: 0,
12495
+ group: "eeat",
12496
+ ...hasAuthor ? {} : { tip: s.authorTip }
12497
+ });
12498
+ if (hasAuthor) {
12499
+ const hasLink = !!(input.authorUrl && input.authorUrl.trim());
12500
+ checks.push({
12501
+ id: "eeat-author-entity",
12502
+ label: s.authorEntityLabel,
12503
+ status: hasLink ? "pass" : "warning",
12504
+ message: hasLink ? s.authorEntityPass : s.authorEntityFail,
12505
+ category: "bonus",
12506
+ weight: 0,
12507
+ group: "eeat"
12508
+ });
12509
+ }
12510
+ const datesOk = !!input.publishedAt && !!input.updatedAt;
12511
+ checks.push({
12512
+ id: "eeat-dates",
12513
+ label: s.datesLabel,
12514
+ status: datesOk ? "pass" : "warning",
12515
+ message: datesOk ? s.datesPass : s.datesFail,
12516
+ category: "bonus",
12517
+ weight: 0,
12518
+ group: "eeat"
12519
+ });
12520
+ const externalLinks = ctx.allLinks.filter((l) => /^https?:\/\//i.test(l.url));
12521
+ const hasSources = externalLinks.length > 0;
12522
+ checks.push({
12523
+ id: "eeat-sources",
12524
+ label: s.sourcesLabel,
12525
+ status: hasSources ? "pass" : "warning",
12526
+ message: hasSources ? s.sourcesPass(externalLinks.length) : s.sourcesFail,
12527
+ category: "bonus",
12528
+ weight: 0,
12529
+ group: "eeat",
12530
+ ...hasSources ? {} : { tip: s.sourcesTip }
12531
+ });
12532
+ const hasPercent = /\d+([.,]\d+)?\s?%/.test(ctx.fullText);
12533
+ const numberCount = (ctx.fullText.match(/\b\d{2,}\b/g) || []).length;
12534
+ const hasData = hasPercent || numberCount >= 3;
12535
+ checks.push({
12536
+ id: "eeat-original-data",
12537
+ label: s.dataLabel,
12538
+ status: hasData ? "pass" : "warning",
12539
+ message: hasData ? s.dataPass : s.dataFail,
12540
+ category: "bonus",
12541
+ weight: 0,
12542
+ group: "eeat",
12543
+ ...hasData ? {} : { tip: s.dataTip }
12544
+ });
12545
+ return checks;
12546
+ }
12547
+
12548
+ // src/rules/geo.ts
12549
+ var QUESTION_WORDS = {
12550
+ fr: ["comment", "pourquoi", "quand", "quel", "quelle", "quels", "quelles", "combien", "ou", "o\xF9", "qui", "que", "quoi", "est-ce"],
12551
+ en: ["how", "why", "when", "what", "which", "where", "who", "can", "do", "does", "is", "are", "should"]
12552
+ };
12553
+ var STRINGS2 = {
12554
+ fr: {
12555
+ answerLabel: "R\xE9ponse en t\xEAte (answer-first)",
12556
+ answerPass: "Le contenu d\xE9marre par une accroche concise \u2014 favorise l'extraction par l'IA.",
12557
+ answerFail: "Le contenu ne d\xE9marre pas par une r\xE9ponse concise \u2014 placez une r\xE9ponse directe en t\xEAte de page/section.",
12558
+ answerTip: "Format BLUF (Bottom Line Up Front) : r\xE9pondez \xE0 l'intention en 1-2 phrases avant de d\xE9velopper.",
12559
+ questionsLabel: "Titres en question",
12560
+ questionsPass: (n) => `${n} sous-titre(s) formul\xE9(s) en question \u2014 structure Q\u2192R id\xE9ale pour l'IA.`,
12561
+ questionsFail: "Aucun titre en question \u2014 formulez certains H2/H3 en questions (les moteurs IA citent les paires question/r\xE9ponse).",
12562
+ structureLabel: "Contenu extractible (listes / tableaux)",
12563
+ structurePass: "Listes ou tableaux d\xE9tect\xE9s \u2014 unit\xE9s facilement extraites et cit\xE9es par l'IA.",
12564
+ structureFail: "Aucune liste ni tableau \u2014 structurez les \xE9num\xE9rations et comparaisons en listes/tableaux.",
12565
+ chunkLabel: "Contenu d\xE9coup\xE9 (scannable)",
12566
+ chunkPass: "Contenu bien d\xE9coup\xE9 en sections \u2014 facilite l'extraction de passages.",
12567
+ chunkFail: "Contenu peu d\xE9coup\xE9 \u2014 ajoutez des sous-titres pour cr\xE9er des passages auto-suffisants."
12568
+ },
12569
+ en: {
12570
+ answerLabel: "Answer-first lead",
12571
+ answerPass: "Content opens with a concise lead \u2014 helps AI extraction.",
12572
+ answerFail: "Content does not open with a concise answer \u2014 put a direct answer at the top of the page/section.",
12573
+ answerTip: "BLUF (Bottom Line Up Front): answer the intent in 1-2 sentences before expanding.",
12574
+ questionsLabel: "Question-style headings",
12575
+ questionsPass: (n) => `${n} heading(s) phrased as questions \u2014 ideal Q\u2192A structure for AI.`,
12576
+ questionsFail: "No question headings \u2014 phrase some H2/H3 as questions (AI engines cite question/answer pairs).",
12577
+ structureLabel: "Extractable content (lists / tables)",
12578
+ structurePass: "Lists or tables detected \u2014 units easily extracted and cited by AI.",
12579
+ structureFail: "No list or table \u2014 structure enumerations and comparisons as lists/tables.",
12580
+ chunkLabel: "Chunked content (scannable)",
12581
+ chunkPass: "Content is well chunked into sections \u2014 helps passage extraction.",
12582
+ chunkFail: "Content is barely chunked \u2014 add subheadings to create self-contained passages."
12583
+ }
12584
+ };
12585
+ var GEO_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
12586
+ function isQuestionHeading(text, locale) {
12587
+ const t = text.trim().toLowerCase();
12588
+ if (t.endsWith("?")) return true;
12589
+ const words = QUESTION_WORDS[locale] ?? QUESTION_WORDS.fr;
12590
+ return words.some((w) => t.startsWith(w + " ") || t.startsWith(w + "-"));
12591
+ }
12592
+ function collectLexicalSources(input) {
12593
+ const sources = [];
12594
+ if (input.heroRichText) sources.push(input.heroRichText);
12595
+ if (input.content) sources.push(input.content);
12596
+ if (Array.isArray(input.blocks)) {
12597
+ for (const b of input.blocks) {
12598
+ if (!b || typeof b !== "object") continue;
12599
+ const blk = b;
12600
+ if (blk.richText) sources.push(blk.richText);
12601
+ if (Array.isArray(blk.columns)) {
12602
+ for (const c of blk.columns) {
12603
+ if (c && typeof c === "object" && c.richText) {
12604
+ sources.push(c.richText);
12605
+ }
12606
+ }
12607
+ }
12608
+ }
12609
+ }
12610
+ return sources;
12611
+ }
12612
+ function containsLexicalType(node, type, depth = 0) {
12613
+ if (depth > 50 || !node || typeof node !== "object") return false;
12614
+ const n = node;
12615
+ if (n.type === type) return true;
12616
+ const root = n.root || n;
12617
+ const children = root.children || n.children;
12618
+ if (Array.isArray(children)) {
12619
+ for (const c of children) {
12620
+ if (containsLexicalType(c, type, depth + 1)) return true;
12621
+ }
12622
+ }
12623
+ return false;
12624
+ }
12625
+ function checkGeo(input, ctx) {
12626
+ const checks = [];
12627
+ if (GEO_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 150) return checks;
12628
+ const s = STRINGS2[ctx.locale] ?? STRINGS2.fr;
12629
+ const locale = ctx.locale;
12630
+ const firstSentence = (ctx.sentences[0] || "").trim();
12631
+ const firstSentenceWords = firstSentence ? firstSentence.split(/\s+/).length : 0;
12632
+ const answerFirst = firstSentenceWords > 0 && firstSentenceWords <= 30;
12633
+ checks.push({
12634
+ id: "geo-answer-first",
12635
+ label: s.answerLabel,
12636
+ status: answerFirst ? "pass" : "warning",
12637
+ message: answerFirst ? s.answerPass : s.answerFail,
12638
+ category: "bonus",
12639
+ weight: 0,
12640
+ group: "geo",
12641
+ ...answerFirst ? {} : { tip: s.answerTip }
12642
+ });
12643
+ const questionHeadings = ctx.allHeadings.filter(
12644
+ (h) => h.tag !== "h1" && isQuestionHeading(h.text, locale)
12645
+ ).length;
12646
+ checks.push({
12647
+ id: "geo-question-headings",
12648
+ label: s.questionsLabel,
12649
+ status: questionHeadings > 0 ? "pass" : "warning",
12650
+ message: questionHeadings > 0 ? s.questionsPass(questionHeadings) : s.questionsFail,
12651
+ category: "bonus",
12652
+ weight: 0,
12653
+ group: "geo"
12654
+ });
12655
+ const sources = collectLexicalSources(input);
12656
+ const hasList = sources.some((src) => extractListsFromLexical(src).length > 0);
12657
+ const hasTable = sources.some((src) => containsLexicalType(src, "table"));
12658
+ const structured = hasList || hasTable;
12659
+ checks.push({
12660
+ id: "geo-extractable-structure",
12661
+ label: s.structureLabel,
12662
+ status: structured ? "pass" : "warning",
12663
+ message: structured ? s.structurePass : s.structureFail,
12664
+ category: "bonus",
12665
+ weight: 0,
12666
+ group: "geo"
12667
+ });
12668
+ const subheadings = ctx.allHeadings.filter((h) => h.tag !== "h1").length;
12669
+ const expected = Math.max(1, Math.floor(ctx.wordCount / 300));
12670
+ const chunked = subheadings >= expected;
12671
+ checks.push({
12672
+ id: "geo-chunked",
12673
+ label: s.chunkLabel,
12674
+ status: chunked ? "pass" : "warning",
12675
+ message: chunked ? s.chunkPass : s.chunkFail,
12676
+ category: "bonus",
12677
+ weight: 0,
12678
+ group: "geo"
12679
+ });
12680
+ return checks;
12681
+ }
12682
+
12683
+ // src/rules/hreflang.ts
12684
+ var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{2,4})?$/i;
12685
+ var STRINGS3 = {
12686
+ fr: {
12687
+ codesLabel: "Codes hreflang valides",
12688
+ codesPass: "Tous les codes hreflang sont au format valide (langue ISO 639-1, r\xE9gion ISO 3166-1 optionnelle).",
12689
+ codesFail: (bad) => `Code(s) hreflang invalide(s) : ${bad} \u2014 un seul code erron\xE9 fait ignorer tout le cluster par Google.`,
12690
+ dupLabel: "Doublons hreflang",
12691
+ dupPass: "Aucun doublon de code hreflang.",
12692
+ dupFail: (dup) => `Code(s) hreflang en double : ${dup} \u2014 chaque locale doit \xEAtre d\xE9clar\xE9e une seule fois.`,
12693
+ absLabel: "URLs hreflang absolues",
12694
+ absPass: "Toutes les URLs hreflang sont absolues.",
12695
+ absFail: "Certaines URLs hreflang ne sont pas absolues \u2014 utilisez des URLs compl\xE8tes (https://...).",
12696
+ xdefLabel: "hreflang x-default",
12697
+ xdefPass: "Un x-default est d\xE9fini \u2014 bonne pratique pour les visiteurs hors locales cibl\xE9es.",
12698
+ xdefFail: 'Aucun x-default \u2014 ajoutez un hreflang="x-default" pour les locales non couvertes.'
12699
+ },
12700
+ en: {
12701
+ codesLabel: "Valid hreflang codes",
12702
+ codesPass: "All hreflang codes are well-formed (ISO 639-1 language, optional ISO 3166-1 region).",
12703
+ codesFail: (bad) => `Invalid hreflang code(s): ${bad} \u2014 a single bad code makes Google ignore the whole cluster.`,
12704
+ dupLabel: "Duplicate hreflang",
12705
+ dupPass: "No duplicate hreflang code.",
12706
+ dupFail: (dup) => `Duplicate hreflang code(s): ${dup} \u2014 each locale must be declared once.`,
12707
+ absLabel: "Absolute hreflang URLs",
12708
+ absPass: "All hreflang URLs are absolute.",
12709
+ absFail: "Some hreflang URLs are not absolute \u2014 use full URLs (https://...).",
12710
+ xdefLabel: "hreflang x-default",
12711
+ xdefPass: "An x-default is defined \u2014 good practice for visitors outside targeted locales.",
12712
+ xdefFail: 'No x-default \u2014 add hreflang="x-default" for locales you do not cover.'
12713
+ }
12714
+ };
12715
+ function checkHreflang(input, ctx) {
12716
+ const alts = input.localeAlternates;
12717
+ if (!Array.isArray(alts) || alts.length === 0) return [];
12718
+ const s = STRINGS3[ctx.locale] ?? STRINGS3.fr;
12719
+ const checks = [];
12720
+ const codes = alts.map((a) => (a.hreflang || "").trim()).filter(Boolean);
12721
+ const invalid = codes.filter((c) => c.toLowerCase() !== "x-default" && !HREFLANG_RE.test(c));
12722
+ checks.push({
12723
+ id: "hreflang-codes",
12724
+ label: s.codesLabel,
12725
+ status: invalid.length === 0 ? "pass" : "fail",
12726
+ message: invalid.length === 0 ? s.codesPass : s.codesFail(invalid.join(", ")),
12727
+ category: "important",
12728
+ weight: 2,
12729
+ group: "hreflang"
12730
+ });
12731
+ const seen = /* @__PURE__ */ new Set();
12732
+ const dups = /* @__PURE__ */ new Set();
12733
+ for (const c of codes.map((c2) => c2.toLowerCase())) {
12734
+ if (seen.has(c)) dups.add(c);
12735
+ seen.add(c);
12736
+ }
12737
+ checks.push({
12738
+ id: "hreflang-duplicates",
12739
+ label: s.dupLabel,
12740
+ status: dups.size === 0 ? "pass" : "warning",
12741
+ message: dups.size === 0 ? s.dupPass : s.dupFail([...dups].join(", ")),
12742
+ category: "important",
12743
+ weight: 1,
12744
+ group: "hreflang"
12745
+ });
12746
+ const allAbsolute = alts.every((a) => /^https?:\/\//i.test((a.href || "").trim()));
12747
+ checks.push({
12748
+ id: "hreflang-absolute",
12749
+ label: s.absLabel,
12750
+ status: allAbsolute ? "pass" : "warning",
12751
+ message: allAbsolute ? s.absPass : s.absFail,
12752
+ category: "important",
12753
+ weight: 1,
12754
+ group: "hreflang"
12755
+ });
12756
+ const hasXDefault = codes.some((c) => c.toLowerCase() === "x-default");
12757
+ checks.push({
12758
+ id: "hreflang-x-default",
12759
+ label: s.xdefLabel,
12760
+ status: hasXDefault ? "pass" : "warning",
12761
+ message: hasXDefault ? s.xdefPass : s.xdefFail,
12762
+ category: "bonus",
12763
+ weight: 1,
12764
+ group: "hreflang"
12765
+ });
12766
+ return checks;
12767
+ }
12768
+
11516
12769
  // src/index.ts
11517
12770
  function buildContext(data, config) {
11518
12771
  const {
@@ -11717,6 +12970,9 @@ function analyzeSeo(data, config) {
11717
12970
  { group: "freshness", fn: checkFreshness },
11718
12971
  { group: "technical", fn: checkTechnical },
11719
12972
  { group: "accessibility", fn: checkAccessibility },
12973
+ { group: "eeat", fn: checkEeat },
12974
+ { group: "geo", fn: checkGeo },
12975
+ { group: "hreflang", fn: checkHreflang },
11720
12976
  // E-commerce rules only run for product pages
11721
12977
  ...data.isProduct ? [{ group: "ecommerce", fn: checkEcommerce }] : []
11722
12978
  ];
@@ -11748,7 +13004,24 @@ function analyzeSeo(data, config) {
11748
13004
  if (score >= SCORE_EXCELLENT) level = "excellent";
11749
13005
  else if (score >= SCORE_GOOD) level = "good";
11750
13006
  else if (score >= SCORE_OK) level = "ok";
11751
- return { score, level, checks };
13007
+ const aiChecks = checks.filter(
13008
+ (c) => c.group === "geo" || c.group === "eeat" || c.id === "schema-coverage"
13009
+ );
13010
+ let aiReadiness;
13011
+ if (aiChecks.length > 0) {
13012
+ let aiEarned = 0;
13013
+ for (const c of aiChecks) {
13014
+ if (c.status === "pass") aiEarned += 1;
13015
+ else if (c.status === "warning") aiEarned += WARNING_MULTIPLIER;
13016
+ }
13017
+ const aiScore = Math.round(aiEarned / aiChecks.length * 100);
13018
+ let aiLevel = "poor";
13019
+ if (aiScore >= SCORE_EXCELLENT) aiLevel = "excellent";
13020
+ else if (aiScore >= SCORE_GOOD) aiLevel = "good";
13021
+ else if (aiScore >= SCORE_OK) aiLevel = "ok";
13022
+ aiReadiness = { score: aiScore, level: aiLevel, checkCount: aiChecks.length };
13023
+ }
13024
+ return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
11752
13025
  }
11753
13026
 
11754
13027
  exports.ACTION_VERBS = ACTION_VERBS;