@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/client.cjs CHANGED
@@ -24,7 +24,7 @@ var MIN_WORDS_GENERIC = 300;
24
24
  var MIN_WORDS_THIN = 100;
25
25
  var MIN_WORDS_QUALITY_FAIL = 50;
26
26
  var MIN_WORDS_QUALITY_WARN = 200;
27
- var CORNERSTONE_MIN_WORDS = 1500;
27
+ var CORNERSTONE_MIN_WORDS = 600;
28
28
  var THIN_AGING_MIN_WORDS = 500;
29
29
  var KEYWORD_DENSITY_MAX = 3;
30
30
  var KEYWORD_DENSITY_WARN = 2.5;
@@ -1458,6 +1458,9 @@ var fr = {
1458
1458
  groupTechnical: "Technique",
1459
1459
  groupAccessibility: "Accessibilit\xE9",
1460
1460
  groupEcommerce: "E-commerce",
1461
+ groupEeat: "E-E-A-T",
1462
+ groupGeo: "GEO (IA)",
1463
+ groupHreflang: "Hreflang",
1461
1464
  levelExcellent: "Excellent",
1462
1465
  levelGood: "Bon",
1463
1466
  levelFair: "Acceptable",
@@ -1466,6 +1469,8 @@ var fr = {
1466
1469
  categoryImportant: "Important",
1467
1470
  categoryBonus: "Bonus",
1468
1471
  seoScore: "Score SEO",
1472
+ aiReadiness: "IA",
1473
+ 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.",
1469
1474
  outOf100: "/ 100",
1470
1475
  cornerstoneLabel: "PILIER",
1471
1476
  checksPassed: "crit\xE8res valid\xE9s",
@@ -2031,6 +2036,9 @@ var en = {
2031
2036
  groupTechnical: "Technical",
2032
2037
  groupAccessibility: "Accessibility",
2033
2038
  groupEcommerce: "E-commerce",
2039
+ groupEeat: "E-E-A-T",
2040
+ groupGeo: "GEO (AI)",
2041
+ groupHreflang: "Hreflang",
2034
2042
  levelExcellent: "Excellent",
2035
2043
  levelGood: "Good",
2036
2044
  levelFair: "Fair",
@@ -2039,6 +2047,8 @@ var en = {
2039
2047
  categoryImportant: "Important",
2040
2048
  categoryBonus: "Bonus",
2041
2049
  seoScore: "SEO Score",
2050
+ aiReadiness: "AI",
2051
+ 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.",
2042
2052
  outOf100: "/ 100",
2043
2053
  cornerstoneLabel: "CORNERSTONE",
2044
2054
  checksPassed: "checks passed",
@@ -2188,8 +2198,11 @@ var rulesFr = {
2188
2198
  powerWordsPass: (count, words) => `Le title contient ${count} mot(s) puissant(s) : ${words}`,
2189
2199
  powerWordsFail: 'Le title ne contient aucun mot puissant \u2014 Ajoutez un mot comme "gratuit", "guide", "complet" pour booster le CTR.',
2190
2200
  powerWordsTip: "Les mots puissants (gratuit, exclusif, guide, complet, essentiel...) attirent l'attention dans les resultats de recherche.",
2201
+ pixelWidthLabel: "Largeur du title (pixels)",
2202
+ pixelWidthPass: (px) => `Largeur estimee ${px}px \u2014 sous le seuil de troncature SERP (~600px).`,
2203
+ pixelWidthWarn: (px) => `Largeur estimee ${px}px \u2014 risque de troncature dans Google (~600px). Informatif.`,
2191
2204
  hasNumberLabel: "Nombre dans le title",
2192
- hasNumberPass: "Le title contient un nombre \u2014 Les titres avec chiffres generent +36% de CTR.",
2205
+ hasNumberPass: "Le title contient un nombre \u2014 format type liste, souvent plus engageant.",
2193
2206
  hasNumberFail: 'Aucun nombre dans le title \u2014 Les titres avec chiffres (ex: "5 astuces", "Top 10") attirent plus de clics.',
2194
2207
  hasNumberTip: 'Ajoutez un nombre pour creer un titre de type liste (ex: "7 conseils pour...", "Les 3 erreurs a eviter").',
2195
2208
  isQuestionLabel: "Title interrogatif",
@@ -2273,12 +2286,12 @@ var rulesFr = {
2273
2286
  keywordIntroFail: (kw) => `Ajoutez le mot-cle "${kw}" dans les premieres phrases du contenu.`,
2274
2287
  densityLabel: "Densite du mot-cle",
2275
2288
  densityOverstuffed: (density) => `Densite du mot-cle : ${density}% \u2014 Trop eleve (>3%), risque de suroptimisation (keyword stuffing).`,
2276
- densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee. Restez entre 0,5% et 2,5%.`,
2277
- densityPass: (density) => `Densite du mot-cle : ${density}% \u2014 Equilibre ideal.`,
2289
+ densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee, restez naturel pour eviter la sur-optimisation.`,
2290
+ densityPass: (density) => `Densite du mot-cle : ${density}% \u2014 Usage naturel, aucun bourrage detecte.`,
2278
2291
  densityPassWordLevel: (density) => `Les composants du mot-cle sont presents dans le contenu (densite estimee : ${density}%).`,
2279
2292
  densityLowWordLevel: (density) => `Les composants du mot-cle sont presents mais peu frequents (densite estimee : ${density}%). Renforcez leur presence.`,
2280
2293
  densityLow: (density) => `Densite du mot-cle : ${density}% \u2014 Trop faible. Visez 0,5% a 2,5%.`,
2281
- densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait jamais dans le contenu.`,
2294
+ densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait pas dans le corps \u2014 privilegiez une couverture naturelle du sujet plutot que la repetition.`,
2282
2295
  placeholderLabel: "Contenu placeholder",
2283
2296
  placeholderFail: "Du contenu placeholder a ete detecte (lorem ipsum, TODO, etc.) \u2014 Remplacez par du vrai contenu.",
2284
2297
  placeholderPass: "Aucun contenu placeholder detecte.",
@@ -2364,7 +2377,7 @@ var rulesFr = {
2364
2377
  cornerstone: {
2365
2378
  wordcountLabel: "Longueur du contenu pilier",
2366
2379
  wordcountPass: (count) => `${count} mots \u2014 Le contenu pilier est suffisamment complet.`,
2367
- wordcountFail: (count) => `${count} mots \u2014 Un contenu pilier devrait contenir au moins 1500 mots pour etre vraiment complet.`,
2380
+ wordcountFail: (count) => `${count} mots \u2014 Contenu pilier trop leger ; developpez la couverture du sujet (sans viser un nombre de mots arbitraire).`,
2368
2381
  internalLinksLabel: "Maillage interne du contenu pilier",
2369
2382
  internalLinksPass: (count) => `${count} liens internes \u2014 Bon maillage pour un contenu pilier.`,
2370
2383
  internalLinksFail: (count) => `${count} lien(s) interne(s) \u2014 Un contenu pilier devrait avoir au moins 5 liens internes vers du contenu associe.`,
@@ -2418,12 +2431,23 @@ var rulesFr = {
2418
2431
  yearRefWarn: (oldest, current, last) => `Le contenu mentionne l'annee ${oldest} sans reference a ${current} ou ${last} \u2014 Contenu potentiellement obsolete.`,
2419
2432
  yearRefPass: (year) => `Le contenu fait reference a l'annee en cours (${year}).`,
2420
2433
  thinAgingLabel: "Contenu leger et ancien",
2421
- thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence.`
2434
+ thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence.`,
2435
+ fakeRefreshLabel: "Faux rafraichissement",
2436
+ 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.`,
2437
+ fakeRefreshTip: "Google detecte la vraie date de modification. Mettez a jour le fond du contenu, pas seulement la date affichee."
2422
2438
  },
2423
2439
  schema: {
2424
2440
  readinessLabel: "Donnees structurees",
2425
2441
  readinessPass: "La page a suffisamment de metadonnees pour generer du JSON-LD (title, description, image).",
2426
- readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees."
2442
+ readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees.",
2443
+ coverageLabel: "Couverture des donnees structurees",
2444
+ coverageOptional: "Les donnees structurees sont optionnelles pour ce type de page.",
2445
+ coveragePass: (type) => `Donnees CMS suffisantes pour generer un schema ${type} valide.`,
2446
+ coverageMissing: (type, fields) => `Schema ${type} attendu pour cette page \u2014 champ(s) requis manquant(s) dans le CMS : ${fields}.`,
2447
+ coverageMissingTip: "Completez ces champs, ou assurez-vous que le JSON-LD correspondant est injecte au rendu (frontend).",
2448
+ coverageRemind: (type, fields) => `Schema ${type} : champs CMS requis presents. Verifiez aussi ces champs requis non detectables automatiquement : ${fields}.`,
2449
+ faqNoRichResultLabel: "FAQ / donnees structurees",
2450
+ 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."
2427
2451
  },
2428
2452
  technical: {
2429
2453
  canonicalMissingLabel: "URL canonique",
@@ -2432,6 +2456,8 @@ var rulesFr = {
2432
2456
  canonicalInvalidMessage: (url) => `URL canonique "${url}" invalide \u2014 Utilisez une URL absolue (https://...).`,
2433
2457
  canonicalExternalLabel: "URL canonique",
2434
2458
  canonicalExternalMessage: "URL canonique pointe vers un domaine externe \u2014 Verifiez que c'est intentionnel.",
2459
+ canonicalCrossLabel: "URL canonique",
2460
+ 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.`,
2435
2461
  canonicalOkLabel: "URL canonique",
2436
2462
  canonicalOkMessage: "URL canonique correctement definie.",
2437
2463
  robotsNoindexLabel: "Robots noindex",
@@ -2556,8 +2582,11 @@ var rulesEn = {
2556
2582
  powerWordsPass: (count, words) => `The title contains ${count} power word(s): ${words}`,
2557
2583
  powerWordsFail: 'The title contains no power words \u2014 Add a word like "free", "guide", "ultimate" to boost CTR.',
2558
2584
  powerWordsTip: "Power words (free, exclusive, guide, ultimate, essential...) attract attention in search results.",
2585
+ pixelWidthLabel: "Title pixel width",
2586
+ pixelWidthPass: (px) => `Estimated width ${px}px \u2014 under the SERP truncation threshold (~600px).`,
2587
+ pixelWidthWarn: (px) => `Estimated width ${px}px \u2014 may be truncated in Google (~600px). Informational.`,
2559
2588
  hasNumberLabel: "Number in title",
2560
- hasNumberPass: "The title contains a number \u2014 Titles with numbers generate +36% CTR.",
2589
+ hasNumberPass: "The title contains a number \u2014 list-style format, often more engaging.",
2561
2590
  hasNumberFail: 'No number in the title \u2014 Titles with numbers (e.g. "5 tips", "Top 10") attract more clicks.',
2562
2591
  hasNumberTip: 'Add a number to create a list-type title (e.g. "7 tips for...", "The 3 mistakes to avoid").',
2563
2592
  isQuestionLabel: "Question title",
@@ -2641,12 +2670,12 @@ var rulesEn = {
2641
2670
  keywordIntroFail: (kw) => `Add the keyword "${kw}" in the first sentences of the content.`,
2642
2671
  densityLabel: "Keyword density",
2643
2672
  densityOverstuffed: (density) => `Keyword density: ${density}% \u2014 Too high (>3%), risk of keyword stuffing.`,
2644
- densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high. Stay between 0.5% and 2.5%.`,
2645
- densityPass: (density) => `Keyword density: ${density}% \u2014 Ideal balance.`,
2673
+ densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high; keep it natural to avoid over-optimisation.`,
2674
+ densityPass: (density) => `Keyword density: ${density}% \u2014 Natural usage, no stuffing detected.`,
2646
2675
  densityPassWordLevel: (density) => `Keyword components are present in the content (estimated density: ${density}%).`,
2647
2676
  densityLowWordLevel: (density) => `Keyword components are present but infrequent (estimated density: ${density}%). Strengthen their presence.`,
2648
2677
  densityLow: (density) => `Keyword density: ${density}% \u2014 Too low. Aim for 0.5% to 2.5%.`,
2649
- densityMissing: (kw) => `The keyword "${kw}" never appears in the content.`,
2678
+ densityMissing: (kw) => `The keyword "${kw}" does not appear in the body \u2014 focus on covering the topic naturally rather than repeating it.`,
2650
2679
  placeholderLabel: "Placeholder content",
2651
2680
  placeholderFail: "Placeholder content detected (lorem ipsum, TODO, etc.) \u2014 Replace with real content.",
2652
2681
  placeholderPass: "No placeholder content detected.",
@@ -2732,7 +2761,7 @@ var rulesEn = {
2732
2761
  cornerstone: {
2733
2762
  wordcountLabel: "Pillar content length",
2734
2763
  wordcountPass: (count) => `${count} words \u2014 The pillar content is comprehensive enough.`,
2735
- wordcountFail: (count) => `${count} words \u2014 Pillar content should contain at least 1500 words to be truly comprehensive.`,
2764
+ wordcountFail: (count) => `${count} words \u2014 Pillar content seems thin; expand topical coverage (without targeting an arbitrary word count).`,
2736
2765
  internalLinksLabel: "Pillar content internal linking",
2737
2766
  internalLinksPass: (count) => `${count} internal links \u2014 Good linking for pillar content.`,
2738
2767
  internalLinksFail: (count) => `${count} internal link(s) \u2014 Pillar content should have at least 5 internal links to related content.`,
@@ -2786,12 +2815,23 @@ var rulesEn = {
2786
2815
  yearRefWarn: (oldest, current, last) => `The content mentions year ${oldest} without reference to ${current} or ${last} \u2014 Potentially outdated.`,
2787
2816
  yearRefPass: (year) => `The content references the current year (${year}).`,
2788
2817
  thinAgingLabel: "Thin and old content",
2789
- thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly.`
2818
+ thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly.`,
2819
+ fakeRefreshLabel: "Fake refresh",
2820
+ 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.`,
2821
+ fakeRefreshTip: "Google detects the real modification date. Update the actual content, not just the displayed date."
2790
2822
  },
2791
2823
  schema: {
2792
2824
  readinessLabel: "Structured data",
2793
2825
  readinessPass: "The page has sufficient metadata to generate JSON-LD (title, description, image).",
2794
- readinessFail: "Complete the title, description and add an image to fully leverage structured data."
2826
+ readinessFail: "Complete the title, description and add an image to fully leverage structured data.",
2827
+ coverageLabel: "Structured data coverage",
2828
+ coverageOptional: "Structured data is optional for this page type.",
2829
+ coveragePass: (type) => `Enough CMS data to generate a valid ${type} schema.`,
2830
+ coverageMissing: (type, fields) => `${type} schema expected for this page \u2014 required field(s) missing in the CMS: ${fields}.`,
2831
+ coverageMissingTip: "Complete these fields, or make sure the matching JSON-LD is injected at render time (frontend).",
2832
+ coverageRemind: (type, fields) => `${type} schema: required CMS fields are present. Also confirm these required fields the analyzer cannot detect: ${fields}.`,
2833
+ faqNoRichResultLabel: "FAQ / structured data",
2834
+ 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."
2795
2835
  },
2796
2836
  technical: {
2797
2837
  canonicalMissingLabel: "Canonical URL",
@@ -2800,6 +2840,8 @@ var rulesEn = {
2800
2840
  canonicalInvalidMessage: (url) => `Canonical URL "${url}" is invalid \u2014 Use an absolute URL (https://...).`,
2801
2841
  canonicalExternalLabel: "Canonical URL",
2802
2842
  canonicalExternalMessage: "Canonical URL points to an external domain \u2014 Verify this is intentional.",
2843
+ canonicalCrossLabel: "Canonical URL",
2844
+ 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.`,
2803
2845
  canonicalOkLabel: "Canonical URL",
2804
2846
  canonicalOkMessage: "Canonical URL is correctly defined.",
2805
2847
  robotsNoindexLabel: "Robots noindex",
@@ -3152,6 +3194,21 @@ function getTranslations(locale) {
3152
3194
  }
3153
3195
 
3154
3196
  // src/rules/title.ts
3197
+ var TITLE_PIXEL_MAX = 600;
3198
+ var NARROW_CHARS = new Set("iIl.,:;|!'`jft()[]{}/\\".split(""));
3199
+ var WIDE_CHARS = new Set("mwMW@%".split(""));
3200
+ function estimateTitlePixelWidth(title) {
3201
+ let px = 0;
3202
+ for (const ch of title) {
3203
+ if (ch === " ") px += 5;
3204
+ else if (NARROW_CHARS.has(ch)) px += 5;
3205
+ else if (WIDE_CHARS.has(ch)) px += 15;
3206
+ else if (ch >= "A" && ch <= "Z") px += 12;
3207
+ else if (ch >= "0" && ch <= "9") px += 10;
3208
+ else px += 9;
3209
+ }
3210
+ return Math.round(px);
3211
+ }
3155
3212
  function checkTitle(input, ctx) {
3156
3213
  const checks = [];
3157
3214
  const r = getTranslations(ctx.locale).rules.title;
@@ -3177,8 +3234,10 @@ function checkTitle(input, ctx) {
3177
3234
  label: r.lengthLabel,
3178
3235
  status: "warning",
3179
3236
  message: r.lengthShort(titleLen),
3180
- category: "critical",
3181
- weight: 3,
3237
+ // SEO desintox: title length is a SERP-display hint, NOT a ranking factor
3238
+ // (Google rewrites 60%+ of titles). Informational weight, never critical.
3239
+ category: "bonus",
3240
+ weight: 1,
3182
3241
  group: "title",
3183
3242
  tip: r.lengthShortTip
3184
3243
  });
@@ -3188,8 +3247,8 @@ function checkTitle(input, ctx) {
3188
3247
  label: r.lengthLabel,
3189
3248
  status: "warning",
3190
3249
  message: r.lengthLong(titleLen),
3191
- category: "critical",
3192
- weight: 3,
3250
+ category: "bonus",
3251
+ weight: 1,
3193
3252
  group: "title",
3194
3253
  tip: r.lengthLongTip
3195
3254
  });
@@ -3199,11 +3258,21 @@ function checkTitle(input, ctx) {
3199
3258
  label: r.lengthLabel,
3200
3259
  status: "pass",
3201
3260
  message: r.lengthPass(titleLen),
3202
- category: "critical",
3203
- weight: 3,
3261
+ category: "bonus",
3262
+ weight: 1,
3204
3263
  group: "title"
3205
3264
  });
3206
3265
  }
3266
+ const titlePx = estimateTitlePixelWidth(title);
3267
+ checks.push({
3268
+ id: "title-pixel-width",
3269
+ label: r.pixelWidthLabel,
3270
+ status: titlePx > TITLE_PIXEL_MAX ? "warning" : "pass",
3271
+ message: titlePx > TITLE_PIXEL_MAX ? r.pixelWidthWarn(titlePx) : r.pixelWidthPass(titlePx),
3272
+ category: "bonus",
3273
+ weight: 0,
3274
+ group: "title"
3275
+ });
3207
3276
  if (kw) {
3208
3277
  const titleNorm = normalizeForComparison(title);
3209
3278
  const kwPresent = keywordMatchesText(kw, titleNorm);
@@ -3739,44 +3808,15 @@ function checkContent(input, ctx) {
3739
3808
  weight: 2,
3740
3809
  group: "content"
3741
3810
  });
3742
- } else if (density >= KEYWORD_DENSITY_MIN) {
3743
- checks.push({
3744
- id: "content-keyword-density",
3745
- label: r.densityLabel,
3746
- status: "pass",
3747
- message: exactCount > 0 ? r.densityPass(density.toFixed(1)) : r.densityPassWordLevel(density.toFixed(1)),
3748
- category: "important",
3749
- weight: 2,
3750
- group: "content"
3751
- });
3752
- } else if (wordLevelMatch) {
3753
- checks.push({
3754
- id: "content-keyword-density",
3755
- label: r.densityLabel,
3756
- status: "warning",
3757
- message: r.densityLowWordLevel(density.toFixed(1)),
3758
- category: "important",
3759
- weight: 2,
3760
- group: "content"
3761
- });
3762
- } else if (exactCount > 0) {
3763
- checks.push({
3764
- id: "content-keyword-density",
3765
- label: r.densityLabel,
3766
- status: "warning",
3767
- message: r.densityLow(density.toFixed(1)),
3768
- category: "important",
3769
- weight: 2,
3770
- group: "content"
3771
- });
3772
3811
  } else {
3812
+ const present = exactCount > 0 || wordLevelMatch;
3773
3813
  checks.push({
3774
3814
  id: "content-keyword-density",
3775
3815
  label: r.densityLabel,
3776
- status: "fail",
3777
- message: r.densityMissing(input.focusKeyword || normalizedKeyword),
3778
- category: "important",
3779
- weight: 2,
3816
+ status: "pass",
3817
+ message: present ? r.densityPass(density.toFixed(1)) : r.densityMissing(input.focusKeyword || normalizedKeyword),
3818
+ category: "bonus",
3819
+ weight: 0,
3780
3820
  group: "content"
3781
3821
  });
3782
3822
  }
@@ -3823,39 +3863,17 @@ function checkContent(input, ctx) {
3823
3863
  const tiersWithKw = [tier1, tier2, tier3].filter(
3824
3864
  (t) => keywordMatchesText(normalizedKeyword, t)
3825
3865
  ).length;
3826
- if (tiersWithKw >= 2) {
3827
- checks.push({
3828
- id: "content-keyword-distribution",
3829
- label: r.distributionLabel,
3830
- status: "pass",
3831
- message: r.distributionPass(tiersWithKw),
3832
- category: "important",
3833
- weight: 2,
3834
- group: "content"
3835
- });
3836
- } else if (tiersWithKw === 1) {
3837
- checks.push({
3838
- id: "content-keyword-distribution",
3839
- label: r.distributionLabel,
3840
- status: "warning",
3841
- message: r.distributionWarn,
3842
- category: "important",
3843
- weight: 2,
3844
- group: "content",
3845
- tip: r.distributionWarnTip
3846
- });
3847
- } else {
3848
- checks.push({
3849
- id: "content-keyword-distribution",
3850
- label: r.distributionLabel,
3851
- status: "fail",
3852
- message: r.distributionFail,
3853
- category: "important",
3854
- weight: 2,
3855
- group: "content",
3856
- tip: r.distributionFailTip
3857
- });
3858
- }
3866
+ const wellDistributed = tiersWithKw >= 2;
3867
+ checks.push({
3868
+ id: "content-keyword-distribution",
3869
+ label: r.distributionLabel,
3870
+ status: wellDistributed ? "pass" : "warning",
3871
+ message: wellDistributed ? r.distributionPass(tiersWithKw) : r.distributionWarn,
3872
+ category: "bonus",
3873
+ weight: 0,
3874
+ group: "content",
3875
+ ...wellDistributed ? {} : { tip: r.distributionWarnTip }
3876
+ });
3859
3877
  }
3860
3878
  if (wordCount > 500) {
3861
3879
  const allLists = [];
@@ -4347,23 +4365,161 @@ function checkSocial(input, ctx) {
4347
4365
  return checks;
4348
4366
  }
4349
4367
 
4368
+ // src/rules/schema-requirements.ts
4369
+ var SCHEMA_REQUIREMENTS = {
4370
+ Article: {
4371
+ required: ["headline", "image"],
4372
+ recommended: ["author", "datePublished", "dateModified", "publisher"],
4373
+ richResult: true
4374
+ },
4375
+ Product: {
4376
+ // Google needs name + at least one of offers / review / aggregateRating
4377
+ required: ["name", "offers"],
4378
+ recommended: ["image", "brand", "aggregateRating", "review", "sku"],
4379
+ richResult: true
4380
+ },
4381
+ LocalBusiness: {
4382
+ required: ["name", "address"],
4383
+ recommended: ["telephone", "openingHours", "geo", "priceRange"],
4384
+ richResult: true
4385
+ },
4386
+ BreadcrumbList: {
4387
+ required: ["itemListElement"],
4388
+ recommended: [],
4389
+ richResult: true
4390
+ },
4391
+ Organization: {
4392
+ required: ["name", "url"],
4393
+ recommended: ["logo", "sameAs"],
4394
+ richResult: false
4395
+ },
4396
+ Person: {
4397
+ required: ["name"],
4398
+ recommended: ["sameAs", "jobTitle", "image", "url"],
4399
+ richResult: false
4400
+ },
4401
+ FAQPage: {
4402
+ required: ["mainEntity"],
4403
+ recommended: [],
4404
+ // FAQ rich results removed by Google (May 2026). Markup still useful for AI/search understanding.
4405
+ richResult: false
4406
+ },
4407
+ Event: {
4408
+ required: ["name", "startDate", "location"],
4409
+ recommended: ["endDate", "offers", "image", "performer"],
4410
+ richResult: true
4411
+ },
4412
+ Recipe: {
4413
+ required: ["name", "image", "recipeIngredient", "recipeInstructions"],
4414
+ recommended: ["nutrition", "aggregateRating", "totalTime", "recipeYield"],
4415
+ richResult: true
4416
+ },
4417
+ Video: {
4418
+ required: ["name", "thumbnailUrl", "uploadDate"],
4419
+ recommended: ["duration", "contentUrl", "description"],
4420
+ richResult: true
4421
+ }
4422
+ };
4423
+ var CMS_VERIFIABLE_SCHEMA_FIELDS = /* @__PURE__ */ new Set([
4424
+ "headline",
4425
+ "name",
4426
+ "image",
4427
+ "url",
4428
+ "itemListElement",
4429
+ "mainEntity"
4430
+ ]);
4431
+
4350
4432
  // src/rules/schema.ts
4433
+ var FAQ_BLOCK_TYPES = /* @__PURE__ */ new Set(["faq", "FAQ", "faqBlock", "faqs"]);
4434
+ function hasFaqBlock(blocks) {
4435
+ if (!Array.isArray(blocks)) return false;
4436
+ return blocks.some((block) => {
4437
+ if (!block || typeof block !== "object") return false;
4438
+ const t = block.blockType;
4439
+ return typeof t === "string" && FAQ_BLOCK_TYPES.has(t);
4440
+ });
4441
+ }
4442
+ function detectExpectedSchemaType(input, ctx) {
4443
+ if (input.isProduct) return "Product";
4444
+ if (input.isPost || ctx.pageType === "blog") return "Article";
4445
+ if (ctx.pageType === "local-seo") return "LocalBusiness";
4446
+ if (ctx.pageType === "agency") return "Organization";
4447
+ if (ctx.pageType === "legal" || ctx.pageType === "contact" || ctx.pageType === "form") return null;
4448
+ return "Article";
4449
+ }
4351
4450
  function checkSchema(input, ctx) {
4352
4451
  const checks = [];
4353
4452
  const r = getTranslations(ctx.locale).rules.schema;
4354
- const hasMetaTitle = !!input.metaTitle;
4355
- const hasMetaDesc = !!input.metaDescription;
4356
- const hasImage = ctx.imageStats.total > 0;
4357
- const readyForSchema = hasMetaTitle && hasMetaDesc && hasImage;
4358
- checks.push({
4359
- id: "schema-readiness",
4360
- label: r.readinessLabel,
4361
- status: readyForSchema ? "pass" : "warning",
4362
- message: readyForSchema ? r.readinessPass : r.readinessFail,
4363
- category: "bonus",
4364
- weight: 1,
4365
- group: "schema"
4366
- });
4453
+ const faqPresent = hasFaqBlock(input.blocks);
4454
+ const present = {
4455
+ headline: !!(input.metaTitle || input.heroTitle) || ctx.allHeadings.some((h) => h.tag === "h1"),
4456
+ name: !!(input.metaTitle || input.heroTitle),
4457
+ image: ctx.imageStats.total > 0 || !!input.metaImage,
4458
+ url: true,
4459
+ itemListElement: !!input.slug,
4460
+ mainEntity: faqPresent
4461
+ };
4462
+ const expected = detectExpectedSchemaType(input, ctx);
4463
+ if (expected === null) {
4464
+ checks.push({
4465
+ id: "schema-coverage",
4466
+ label: r.coverageLabel,
4467
+ status: "pass",
4468
+ message: r.coverageOptional,
4469
+ category: "bonus",
4470
+ weight: 0,
4471
+ group: "schema"
4472
+ });
4473
+ } else {
4474
+ const reqDef = SCHEMA_REQUIREMENTS[expected];
4475
+ const knownMissing = reqDef.required.filter(
4476
+ (f) => CMS_VERIFIABLE_SCHEMA_FIELDS.has(f) && !present[f]
4477
+ );
4478
+ const unverifiable = reqDef.required.filter((f) => !CMS_VERIFIABLE_SCHEMA_FIELDS.has(f));
4479
+ if (knownMissing.length > 0) {
4480
+ checks.push({
4481
+ id: "schema-coverage",
4482
+ label: r.coverageLabel,
4483
+ status: "warning",
4484
+ message: r.coverageMissing(expected, knownMissing.join(", ")),
4485
+ category: "bonus",
4486
+ weight: 1,
4487
+ group: "schema",
4488
+ tip: r.coverageMissingTip
4489
+ });
4490
+ } else if (unverifiable.length > 0) {
4491
+ checks.push({
4492
+ id: "schema-coverage",
4493
+ label: r.coverageLabel,
4494
+ status: "pass",
4495
+ message: r.coverageRemind(expected, unverifiable.join(", ")),
4496
+ category: "bonus",
4497
+ weight: 0,
4498
+ group: "schema"
4499
+ });
4500
+ } else {
4501
+ checks.push({
4502
+ id: "schema-coverage",
4503
+ label: r.coverageLabel,
4504
+ status: "pass",
4505
+ message: r.coveragePass(expected),
4506
+ category: "bonus",
4507
+ weight: 1,
4508
+ group: "schema"
4509
+ });
4510
+ }
4511
+ }
4512
+ if (faqPresent && true) {
4513
+ checks.push({
4514
+ id: "schema-faq-no-rich-result",
4515
+ label: r.faqNoRichResultLabel,
4516
+ status: "pass",
4517
+ message: r.faqNoRichResult,
4518
+ category: "bonus",
4519
+ weight: 0,
4520
+ group: "schema"
4521
+ });
4522
+ }
4367
4523
  return checks;
4368
4524
  }
4369
4525
 
@@ -4485,8 +4641,8 @@ function checkReadability(input, ctx) {
4485
4641
  label: r.passiveLabelFail,
4486
4642
  status: "warning",
4487
4643
  message: r.passiveFail(passiveSentences.length, sentences.length, Math.round(passiveRatio * 100)),
4488
- category: "important",
4489
- weight: 2,
4644
+ category: "bonus",
4645
+ weight: 0,
4490
4646
  group: "readability"
4491
4647
  });
4492
4648
  } else {
@@ -4495,8 +4651,8 @@ function checkReadability(input, ctx) {
4495
4651
  label: r.passiveLabelPass,
4496
4652
  status: "pass",
4497
4653
  message: r.passivePass(Math.round(passiveRatio * 100)),
4498
- category: "important",
4499
- weight: 2,
4654
+ category: "bonus",
4655
+ weight: 0,
4500
4656
  group: "readability"
4501
4657
  });
4502
4658
  }
@@ -4511,7 +4667,7 @@ function checkReadability(input, ctx) {
4511
4667
  status: "warning",
4512
4668
  message: r.transitionsFail(Math.round(transitionRatio * 100)),
4513
4669
  category: "bonus",
4514
- weight: 1,
4670
+ weight: 0,
4515
4671
  group: "readability"
4516
4672
  });
4517
4673
  } else {
@@ -4521,7 +4677,7 @@ function checkReadability(input, ctx) {
4521
4677
  status: "pass",
4522
4678
  message: r.transitionsPass(Math.round(transitionRatio * 100)),
4523
4679
  category: "bonus",
4524
- weight: 1,
4680
+ weight: 0,
4525
4681
  group: "readability"
4526
4682
  });
4527
4683
  }
@@ -4969,10 +5125,34 @@ function checkFreshness(input, ctx) {
4969
5125
  group: "freshness"
4970
5126
  });
4971
5127
  }
5128
+ if (input.displayedDate && input.updatedAt) {
5129
+ const displayedDays = daysSince(input.displayedDate);
5130
+ const updatedDays = daysSince(input.updatedAt);
5131
+ if (displayedDays !== Infinity && updatedDays !== Infinity && updatedDays - displayedDays > 60) {
5132
+ checks.push({
5133
+ id: "freshness-fake-refresh",
5134
+ label: r.fakeRefreshLabel,
5135
+ status: "warning",
5136
+ message: r.fakeRefreshWarn(displayedDays, updatedDays),
5137
+ category: "bonus",
5138
+ weight: 0,
5139
+ group: "freshness",
5140
+ tip: r.fakeRefreshTip
5141
+ });
5142
+ }
5143
+ }
4972
5144
  return checks;
4973
5145
  }
4974
5146
 
4975
5147
  // src/rules/technical.ts
5148
+ function isCrossCanonical(canonical, siteUrl, slug) {
5149
+ const norm = (p) => p.replace(/[?#].*$/, "").replace(/^\/+/, "").replace(/\/+$/, "").toLowerCase();
5150
+ const canonicalPath = norm(canonical.slice(siteUrl.length));
5151
+ const selfPath = norm(slug);
5152
+ const selfIsHome = selfPath === "" || selfPath === "home";
5153
+ if (selfIsHome) return canonicalPath !== "" && canonicalPath !== "home";
5154
+ return canonicalPath !== selfPath;
5155
+ }
4976
5156
  function checkTechnical(input, ctx) {
4977
5157
  const checks = [];
4978
5158
  const r = getTranslations(ctx.locale).rules.technical;
@@ -5010,6 +5190,16 @@ function checkTechnical(input, ctx) {
5010
5190
  weight: 2,
5011
5191
  group: "technical"
5012
5192
  });
5193
+ } else if (siteUrl && input.slug && !input.isGlobal && isCrossCanonical(canonical, siteUrl, input.slug)) {
5194
+ checks.push({
5195
+ id: "canonical-cross",
5196
+ label: r.canonicalCrossLabel,
5197
+ status: "warning",
5198
+ message: r.canonicalCrossMessage(canonical),
5199
+ category: "important",
5200
+ weight: 2,
5201
+ group: "technical"
5202
+ });
5013
5203
  } else {
5014
5204
  checks.push({
5015
5205
  id: "canonical-ok",
@@ -5302,6 +5492,336 @@ function checkEcommerce(input, ctx) {
5302
5492
  return checks;
5303
5493
  }
5304
5494
 
5495
+ // src/rules/eeat.ts
5496
+ var STRINGS = {
5497
+ fr: {
5498
+ authorLabel: "Auteur attribu\xE9 (E-E-A-T)",
5499
+ authorPass: "Un auteur est attribu\xE9 \u2014 bon signal de transparence E-E-A-T.",
5500
+ authorFail: "Aucun auteur identifi\xE9 \u2014 attribuez un auteur r\xE9el pour renforcer la confiance (E-E-A-T).",
5501
+ authorTip: "Ajoutez un auteur avec une courte bio et un lien vers son profil (Person schema + sameAs).",
5502
+ authorEntityLabel: "Entit\xE9 auteur (profil / sameAs)",
5503
+ authorEntityPass: "L'auteur a un lien de profil \u2014 renforce l'entit\xE9 (sameAs) pour les moteurs et l'IA.",
5504
+ authorEntityFail: "L'auteur n'a pas de lien de profil \u2014 ajoutez une URL (LinkedIn, page auteur) comme sameAs.",
5505
+ datesLabel: "Dates de publication / mise \xE0 jour",
5506
+ datesPass: "Dates de publication et de mise \xE0 jour disponibles \u2014 bon pour la fra\xEEcheur et la confiance.",
5507
+ datesFail: "Date de publication ou de mise \xE0 jour manquante \u2014 exposez datePublished et dateModified.",
5508
+ sourcesLabel: "Sources externes cit\xE9es",
5509
+ sourcesPass: (n) => `${n} lien(s) vers des sources externes \u2014 renforce la cr\xE9dibilit\xE9 et l'E-E-A-T.`,
5510
+ sourcesFail: "Aucune source externe cit\xE9e \u2014 liez des sources fiables pour appuyer vos affirmations.",
5511
+ sourcesTip: "Citez des \xE9tudes, donn\xE9es officielles ou r\xE9f\xE9rences sectorielles avec des liens sortants.",
5512
+ dataLabel: "Donn\xE9es originales / chiffr\xE9es",
5513
+ dataPass: "Le contenu pr\xE9sente des donn\xE9es chiffr\xE9es \u2014 signal d'expertise et de contenu original.",
5514
+ dataFail: "Peu de donn\xE9es chiffr\xE9es d\xE9tect\xE9es \u2014 ajoutez des chiffres, statistiques ou r\xE9sultats concrets.",
5515
+ 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."
5516
+ },
5517
+ en: {
5518
+ authorLabel: "Attributed author (E-E-A-T)",
5519
+ authorPass: "An author is attributed \u2014 good E-E-A-T transparency signal.",
5520
+ authorFail: "No identified author \u2014 attribute a real author to strengthen trust (E-E-A-T).",
5521
+ authorTip: "Add an author with a short bio and a link to their profile (Person schema + sameAs).",
5522
+ authorEntityLabel: "Author entity (profile / sameAs)",
5523
+ authorEntityPass: "The author has a profile link \u2014 strengthens the entity (sameAs) for search and AI.",
5524
+ authorEntityFail: "The author has no profile link \u2014 add a URL (LinkedIn, author page) as sameAs.",
5525
+ datesLabel: "Published / updated dates",
5526
+ datesPass: "Published and modified dates available \u2014 good for freshness and trust.",
5527
+ datesFail: "Published or modified date missing \u2014 expose datePublished and dateModified.",
5528
+ sourcesLabel: "External sources cited",
5529
+ sourcesPass: (n) => `${n} link(s) to external sources \u2014 strengthens credibility and E-E-A-T.`,
5530
+ sourcesFail: "No external source cited \u2014 link reliable sources to back your claims.",
5531
+ sourcesTip: "Cite studies, official data or industry references with outbound links.",
5532
+ dataLabel: "Original / quantitative data",
5533
+ dataPass: "The content includes quantitative data \u2014 a signal of expertise and original content.",
5534
+ dataFail: "Little quantitative data detected \u2014 add figures, statistics or concrete results.",
5535
+ dataTip: "First-hand-experience content (data, figures, lived examples) is the #1 visibility lever in 2026."
5536
+ }
5537
+ };
5538
+ var EEAT_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
5539
+ function checkEeat(input, ctx) {
5540
+ const checks = [];
5541
+ if (EEAT_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 100) return checks;
5542
+ const s = STRINGS[ctx.locale] ?? STRINGS.fr;
5543
+ const hasAuthor = !!(input.author && input.author.trim());
5544
+ checks.push({
5545
+ id: "eeat-author",
5546
+ label: s.authorLabel,
5547
+ status: hasAuthor ? "pass" : "warning",
5548
+ message: hasAuthor ? s.authorPass : s.authorFail,
5549
+ category: "important",
5550
+ weight: 0,
5551
+ group: "eeat",
5552
+ ...hasAuthor ? {} : { tip: s.authorTip }
5553
+ });
5554
+ if (hasAuthor) {
5555
+ const hasLink = !!(input.authorUrl && input.authorUrl.trim());
5556
+ checks.push({
5557
+ id: "eeat-author-entity",
5558
+ label: s.authorEntityLabel,
5559
+ status: hasLink ? "pass" : "warning",
5560
+ message: hasLink ? s.authorEntityPass : s.authorEntityFail,
5561
+ category: "bonus",
5562
+ weight: 0,
5563
+ group: "eeat"
5564
+ });
5565
+ }
5566
+ const datesOk = !!input.publishedAt && !!input.updatedAt;
5567
+ checks.push({
5568
+ id: "eeat-dates",
5569
+ label: s.datesLabel,
5570
+ status: datesOk ? "pass" : "warning",
5571
+ message: datesOk ? s.datesPass : s.datesFail,
5572
+ category: "bonus",
5573
+ weight: 0,
5574
+ group: "eeat"
5575
+ });
5576
+ const externalLinks = ctx.allLinks.filter((l) => /^https?:\/\//i.test(l.url));
5577
+ const hasSources = externalLinks.length > 0;
5578
+ checks.push({
5579
+ id: "eeat-sources",
5580
+ label: s.sourcesLabel,
5581
+ status: hasSources ? "pass" : "warning",
5582
+ message: hasSources ? s.sourcesPass(externalLinks.length) : s.sourcesFail,
5583
+ category: "bonus",
5584
+ weight: 0,
5585
+ group: "eeat",
5586
+ ...hasSources ? {} : { tip: s.sourcesTip }
5587
+ });
5588
+ const hasPercent = /\d+([.,]\d+)?\s?%/.test(ctx.fullText);
5589
+ const numberCount = (ctx.fullText.match(/\b\d{2,}\b/g) || []).length;
5590
+ const hasData = hasPercent || numberCount >= 3;
5591
+ checks.push({
5592
+ id: "eeat-original-data",
5593
+ label: s.dataLabel,
5594
+ status: hasData ? "pass" : "warning",
5595
+ message: hasData ? s.dataPass : s.dataFail,
5596
+ category: "bonus",
5597
+ weight: 0,
5598
+ group: "eeat",
5599
+ ...hasData ? {} : { tip: s.dataTip }
5600
+ });
5601
+ return checks;
5602
+ }
5603
+
5604
+ // src/rules/geo.ts
5605
+ var QUESTION_WORDS = {
5606
+ fr: ["comment", "pourquoi", "quand", "quel", "quelle", "quels", "quelles", "combien", "ou", "o\xF9", "qui", "que", "quoi", "est-ce"],
5607
+ en: ["how", "why", "when", "what", "which", "where", "who", "can", "do", "does", "is", "are", "should"]
5608
+ };
5609
+ var STRINGS2 = {
5610
+ fr: {
5611
+ answerLabel: "R\xE9ponse en t\xEAte (answer-first)",
5612
+ answerPass: "Le contenu d\xE9marre par une accroche concise \u2014 favorise l'extraction par l'IA.",
5613
+ answerFail: "Le contenu ne d\xE9marre pas par une r\xE9ponse concise \u2014 placez une r\xE9ponse directe en t\xEAte de page/section.",
5614
+ answerTip: "Format BLUF (Bottom Line Up Front) : r\xE9pondez \xE0 l'intention en 1-2 phrases avant de d\xE9velopper.",
5615
+ questionsLabel: "Titres en question",
5616
+ questionsPass: (n) => `${n} sous-titre(s) formul\xE9(s) en question \u2014 structure Q\u2192R id\xE9ale pour l'IA.`,
5617
+ questionsFail: "Aucun titre en question \u2014 formulez certains H2/H3 en questions (les moteurs IA citent les paires question/r\xE9ponse).",
5618
+ structureLabel: "Contenu extractible (listes / tableaux)",
5619
+ structurePass: "Listes ou tableaux d\xE9tect\xE9s \u2014 unit\xE9s facilement extraites et cit\xE9es par l'IA.",
5620
+ structureFail: "Aucune liste ni tableau \u2014 structurez les \xE9num\xE9rations et comparaisons en listes/tableaux.",
5621
+ chunkLabel: "Contenu d\xE9coup\xE9 (scannable)",
5622
+ chunkPass: "Contenu bien d\xE9coup\xE9 en sections \u2014 facilite l'extraction de passages.",
5623
+ chunkFail: "Contenu peu d\xE9coup\xE9 \u2014 ajoutez des sous-titres pour cr\xE9er des passages auto-suffisants."
5624
+ },
5625
+ en: {
5626
+ answerLabel: "Answer-first lead",
5627
+ answerPass: "Content opens with a concise lead \u2014 helps AI extraction.",
5628
+ answerFail: "Content does not open with a concise answer \u2014 put a direct answer at the top of the page/section.",
5629
+ answerTip: "BLUF (Bottom Line Up Front): answer the intent in 1-2 sentences before expanding.",
5630
+ questionsLabel: "Question-style headings",
5631
+ questionsPass: (n) => `${n} heading(s) phrased as questions \u2014 ideal Q\u2192A structure for AI.`,
5632
+ questionsFail: "No question headings \u2014 phrase some H2/H3 as questions (AI engines cite question/answer pairs).",
5633
+ structureLabel: "Extractable content (lists / tables)",
5634
+ structurePass: "Lists or tables detected \u2014 units easily extracted and cited by AI.",
5635
+ structureFail: "No list or table \u2014 structure enumerations and comparisons as lists/tables.",
5636
+ chunkLabel: "Chunked content (scannable)",
5637
+ chunkPass: "Content is well chunked into sections \u2014 helps passage extraction.",
5638
+ chunkFail: "Content is barely chunked \u2014 add subheadings to create self-contained passages."
5639
+ }
5640
+ };
5641
+ var GEO_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
5642
+ function isQuestionHeading(text, locale) {
5643
+ const t = text.trim().toLowerCase();
5644
+ if (t.endsWith("?")) return true;
5645
+ const words = QUESTION_WORDS[locale] ?? QUESTION_WORDS.fr;
5646
+ return words.some((w) => t.startsWith(w + " ") || t.startsWith(w + "-"));
5647
+ }
5648
+ function collectLexicalSources(input) {
5649
+ const sources = [];
5650
+ if (input.heroRichText) sources.push(input.heroRichText);
5651
+ if (input.content) sources.push(input.content);
5652
+ if (Array.isArray(input.blocks)) {
5653
+ for (const b of input.blocks) {
5654
+ if (!b || typeof b !== "object") continue;
5655
+ const blk = b;
5656
+ if (blk.richText) sources.push(blk.richText);
5657
+ if (Array.isArray(blk.columns)) {
5658
+ for (const c of blk.columns) {
5659
+ if (c && typeof c === "object" && c.richText) {
5660
+ sources.push(c.richText);
5661
+ }
5662
+ }
5663
+ }
5664
+ }
5665
+ }
5666
+ return sources;
5667
+ }
5668
+ function containsLexicalType(node, type, depth = 0) {
5669
+ if (depth > 50 || !node || typeof node !== "object") return false;
5670
+ const n = node;
5671
+ if (n.type === type) return true;
5672
+ const root = n.root || n;
5673
+ const children = root.children || n.children;
5674
+ if (Array.isArray(children)) {
5675
+ for (const c of children) {
5676
+ if (containsLexicalType(c, type, depth + 1)) return true;
5677
+ }
5678
+ }
5679
+ return false;
5680
+ }
5681
+ function checkGeo(input, ctx) {
5682
+ const checks = [];
5683
+ if (GEO_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 150) return checks;
5684
+ const s = STRINGS2[ctx.locale] ?? STRINGS2.fr;
5685
+ const locale = ctx.locale;
5686
+ const firstSentence = (ctx.sentences[0] || "").trim();
5687
+ const firstSentenceWords = firstSentence ? firstSentence.split(/\s+/).length : 0;
5688
+ const answerFirst = firstSentenceWords > 0 && firstSentenceWords <= 30;
5689
+ checks.push({
5690
+ id: "geo-answer-first",
5691
+ label: s.answerLabel,
5692
+ status: answerFirst ? "pass" : "warning",
5693
+ message: answerFirst ? s.answerPass : s.answerFail,
5694
+ category: "bonus",
5695
+ weight: 0,
5696
+ group: "geo",
5697
+ ...answerFirst ? {} : { tip: s.answerTip }
5698
+ });
5699
+ const questionHeadings = ctx.allHeadings.filter(
5700
+ (h) => h.tag !== "h1" && isQuestionHeading(h.text, locale)
5701
+ ).length;
5702
+ checks.push({
5703
+ id: "geo-question-headings",
5704
+ label: s.questionsLabel,
5705
+ status: questionHeadings > 0 ? "pass" : "warning",
5706
+ message: questionHeadings > 0 ? s.questionsPass(questionHeadings) : s.questionsFail,
5707
+ category: "bonus",
5708
+ weight: 0,
5709
+ group: "geo"
5710
+ });
5711
+ const sources = collectLexicalSources(input);
5712
+ const hasList = sources.some((src) => extractListsFromLexical(src).length > 0);
5713
+ const hasTable = sources.some((src) => containsLexicalType(src, "table"));
5714
+ const structured = hasList || hasTable;
5715
+ checks.push({
5716
+ id: "geo-extractable-structure",
5717
+ label: s.structureLabel,
5718
+ status: structured ? "pass" : "warning",
5719
+ message: structured ? s.structurePass : s.structureFail,
5720
+ category: "bonus",
5721
+ weight: 0,
5722
+ group: "geo"
5723
+ });
5724
+ const subheadings = ctx.allHeadings.filter((h) => h.tag !== "h1").length;
5725
+ const expected = Math.max(1, Math.floor(ctx.wordCount / 300));
5726
+ const chunked = subheadings >= expected;
5727
+ checks.push({
5728
+ id: "geo-chunked",
5729
+ label: s.chunkLabel,
5730
+ status: chunked ? "pass" : "warning",
5731
+ message: chunked ? s.chunkPass : s.chunkFail,
5732
+ category: "bonus",
5733
+ weight: 0,
5734
+ group: "geo"
5735
+ });
5736
+ return checks;
5737
+ }
5738
+
5739
+ // src/rules/hreflang.ts
5740
+ var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{2,4})?$/i;
5741
+ var STRINGS3 = {
5742
+ fr: {
5743
+ codesLabel: "Codes hreflang valides",
5744
+ codesPass: "Tous les codes hreflang sont au format valide (langue ISO 639-1, r\xE9gion ISO 3166-1 optionnelle).",
5745
+ codesFail: (bad) => `Code(s) hreflang invalide(s) : ${bad} \u2014 un seul code erron\xE9 fait ignorer tout le cluster par Google.`,
5746
+ dupLabel: "Doublons hreflang",
5747
+ dupPass: "Aucun doublon de code hreflang.",
5748
+ dupFail: (dup) => `Code(s) hreflang en double : ${dup} \u2014 chaque locale doit \xEAtre d\xE9clar\xE9e une seule fois.`,
5749
+ absLabel: "URLs hreflang absolues",
5750
+ absPass: "Toutes les URLs hreflang sont absolues.",
5751
+ absFail: "Certaines URLs hreflang ne sont pas absolues \u2014 utilisez des URLs compl\xE8tes (https://...).",
5752
+ xdefLabel: "hreflang x-default",
5753
+ xdefPass: "Un x-default est d\xE9fini \u2014 bonne pratique pour les visiteurs hors locales cibl\xE9es.",
5754
+ xdefFail: 'Aucun x-default \u2014 ajoutez un hreflang="x-default" pour les locales non couvertes.'
5755
+ },
5756
+ en: {
5757
+ codesLabel: "Valid hreflang codes",
5758
+ codesPass: "All hreflang codes are well-formed (ISO 639-1 language, optional ISO 3166-1 region).",
5759
+ codesFail: (bad) => `Invalid hreflang code(s): ${bad} \u2014 a single bad code makes Google ignore the whole cluster.`,
5760
+ dupLabel: "Duplicate hreflang",
5761
+ dupPass: "No duplicate hreflang code.",
5762
+ dupFail: (dup) => `Duplicate hreflang code(s): ${dup} \u2014 each locale must be declared once.`,
5763
+ absLabel: "Absolute hreflang URLs",
5764
+ absPass: "All hreflang URLs are absolute.",
5765
+ absFail: "Some hreflang URLs are not absolute \u2014 use full URLs (https://...).",
5766
+ xdefLabel: "hreflang x-default",
5767
+ xdefPass: "An x-default is defined \u2014 good practice for visitors outside targeted locales.",
5768
+ xdefFail: 'No x-default \u2014 add hreflang="x-default" for locales you do not cover.'
5769
+ }
5770
+ };
5771
+ function checkHreflang(input, ctx) {
5772
+ const alts = input.localeAlternates;
5773
+ if (!Array.isArray(alts) || alts.length === 0) return [];
5774
+ const s = STRINGS3[ctx.locale] ?? STRINGS3.fr;
5775
+ const checks = [];
5776
+ const codes = alts.map((a) => (a.hreflang || "").trim()).filter(Boolean);
5777
+ const invalid = codes.filter((c) => c.toLowerCase() !== "x-default" && !HREFLANG_RE.test(c));
5778
+ checks.push({
5779
+ id: "hreflang-codes",
5780
+ label: s.codesLabel,
5781
+ status: invalid.length === 0 ? "pass" : "fail",
5782
+ message: invalid.length === 0 ? s.codesPass : s.codesFail(invalid.join(", ")),
5783
+ category: "important",
5784
+ weight: 2,
5785
+ group: "hreflang"
5786
+ });
5787
+ const seen = /* @__PURE__ */ new Set();
5788
+ const dups = /* @__PURE__ */ new Set();
5789
+ for (const c of codes.map((c2) => c2.toLowerCase())) {
5790
+ if (seen.has(c)) dups.add(c);
5791
+ seen.add(c);
5792
+ }
5793
+ checks.push({
5794
+ id: "hreflang-duplicates",
5795
+ label: s.dupLabel,
5796
+ status: dups.size === 0 ? "pass" : "warning",
5797
+ message: dups.size === 0 ? s.dupPass : s.dupFail([...dups].join(", ")),
5798
+ category: "important",
5799
+ weight: 1,
5800
+ group: "hreflang"
5801
+ });
5802
+ const allAbsolute = alts.every((a) => /^https?:\/\//i.test((a.href || "").trim()));
5803
+ checks.push({
5804
+ id: "hreflang-absolute",
5805
+ label: s.absLabel,
5806
+ status: allAbsolute ? "pass" : "warning",
5807
+ message: allAbsolute ? s.absPass : s.absFail,
5808
+ category: "important",
5809
+ weight: 1,
5810
+ group: "hreflang"
5811
+ });
5812
+ const hasXDefault = codes.some((c) => c.toLowerCase() === "x-default");
5813
+ checks.push({
5814
+ id: "hreflang-x-default",
5815
+ label: s.xdefLabel,
5816
+ status: hasXDefault ? "pass" : "warning",
5817
+ message: hasXDefault ? s.xdefPass : s.xdefFail,
5818
+ category: "bonus",
5819
+ weight: 1,
5820
+ group: "hreflang"
5821
+ });
5822
+ return checks;
5823
+ }
5824
+
5305
5825
  // src/index.ts
5306
5826
  function buildContext(data, config) {
5307
5827
  const {
@@ -5506,6 +6026,9 @@ function analyzeSeo(data, config) {
5506
6026
  { group: "freshness", fn: checkFreshness },
5507
6027
  { group: "technical", fn: checkTechnical },
5508
6028
  { group: "accessibility", fn: checkAccessibility },
6029
+ { group: "eeat", fn: checkEeat },
6030
+ { group: "geo", fn: checkGeo },
6031
+ { group: "hreflang", fn: checkHreflang },
5509
6032
  // E-commerce rules only run for product pages
5510
6033
  ...data.isProduct ? [{ group: "ecommerce", fn: checkEcommerce }] : []
5511
6034
  ];
@@ -5537,7 +6060,24 @@ function analyzeSeo(data, config) {
5537
6060
  if (score >= SCORE_EXCELLENT) level = "excellent";
5538
6061
  else if (score >= SCORE_GOOD) level = "good";
5539
6062
  else if (score >= SCORE_OK) level = "ok";
5540
- return { score, level, checks };
6063
+ const aiChecks = checks.filter(
6064
+ (c) => c.group === "geo" || c.group === "eeat" || c.id === "schema-coverage"
6065
+ );
6066
+ let aiReadiness;
6067
+ if (aiChecks.length > 0) {
6068
+ let aiEarned = 0;
6069
+ for (const c of aiChecks) {
6070
+ if (c.status === "pass") aiEarned += 1;
6071
+ else if (c.status === "warning") aiEarned += WARNING_MULTIPLIER;
6072
+ }
6073
+ const aiScore = Math.round(aiEarned / aiChecks.length * 100);
6074
+ let aiLevel = "poor";
6075
+ if (aiScore >= SCORE_EXCELLENT) aiLevel = "excellent";
6076
+ else if (aiScore >= SCORE_GOOD) aiLevel = "good";
6077
+ else if (aiScore >= SCORE_OK) aiLevel = "ok";
6078
+ aiReadiness = { score: aiScore, level: aiLevel, checkCount: aiChecks.length };
6079
+ }
6080
+ return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
5541
6081
  }
5542
6082
  function useSeoLocale() {
5543
6083
  const locale = ui.useLocale();
@@ -6065,7 +6605,10 @@ function getGroupLabels(t) {
6065
6605
  "freshness": t.seoAnalyzer.groupFreshness,
6066
6606
  "technical": t.seoAnalyzer.groupTechnical,
6067
6607
  "accessibility": t.seoAnalyzer.groupAccessibility,
6068
- "ecommerce": t.seoAnalyzer.groupEcommerce
6608
+ "ecommerce": t.seoAnalyzer.groupEcommerce,
6609
+ "eeat": t.seoAnalyzer.groupEeat,
6610
+ "geo": t.seoAnalyzer.groupGeo,
6611
+ "hreflang": t.seoAnalyzer.groupHreflang
6069
6612
  };
6070
6613
  }
6071
6614
  var GROUP_ORDER = [
@@ -6085,6 +6628,9 @@ var GROUP_ORDER = [
6085
6628
  "freshness",
6086
6629
  "technical",
6087
6630
  "accessibility",
6631
+ "eeat",
6632
+ "geo",
6633
+ "hreflang",
6088
6634
  "ecommerce"
6089
6635
  ];
6090
6636
  function getLevelColor(level) {
@@ -6872,7 +7418,34 @@ var SeoAnalyzer = () => {
6872
7418
  border: `2px solid ${C2.border}`,
6873
7419
  textTransform: "uppercase",
6874
7420
  letterSpacing: "0.04em"
6875
- }, children: t.seoAnalyzer.cornerstoneLabel })
7421
+ }, children: t.seoAnalyzer.cornerstoneLabel }),
7422
+ analysis.aiReadiness && /* @__PURE__ */ jsxRuntime.jsxs(
7423
+ "div",
7424
+ {
7425
+ title: t.seoAnalyzer.aiReadinessTooltip,
7426
+ style: {
7427
+ display: "inline-flex",
7428
+ alignItems: "center",
7429
+ gap: 4,
7430
+ padding: "4px 10px",
7431
+ borderRadius: 8,
7432
+ fontSize: 11,
7433
+ fontWeight: 900,
7434
+ backgroundColor: getLevelColor(analysis.aiReadiness.level),
7435
+ color: getLevelColor(analysis.aiReadiness.level) === C2.yellow ? C2.black : C2.white,
7436
+ border: `2px solid ${C2.border}`,
7437
+ textTransform: "uppercase",
7438
+ letterSpacing: "0.04em"
7439
+ },
7440
+ children: [
7441
+ "\u2728",
7442
+ " ",
7443
+ t.seoAnalyzer.aiReadiness,
7444
+ " ",
7445
+ analysis.aiReadiness.score
7446
+ ]
7447
+ }
7448
+ )
6876
7449
  ] }),
6877
7450
  /* @__PURE__ */ jsxRuntime.jsxs(
6878
7451
  "div",
@@ -8298,6 +8871,23 @@ function TableRow({
8298
8871
  },
8299
8872
  children: item.score > item.previousScore ? "\u2191" : "\u2193"
8300
8873
  }
8874
+ ),
8875
+ item.aiReadiness != null && /* @__PURE__ */ jsxRuntime.jsxs(
8876
+ "span",
8877
+ {
8878
+ title: t.seoAnalyzer.aiReadinessTooltip,
8879
+ style: {
8880
+ fontSize: 10,
8881
+ fontWeight: 800,
8882
+ color: getScoreColor(item.aiReadiness),
8883
+ lineHeight: 1,
8884
+ whiteSpace: "nowrap"
8885
+ },
8886
+ children: [
8887
+ "\u2728",
8888
+ item.aiReadiness
8889
+ ]
8890
+ }
8301
8891
  )
8302
8892
  ] }),
8303
8893
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -15758,6 +16348,400 @@ ${jsonString}
15758
16348
  }
15759
16349
  );
15760
16350
  }
16351
+ var C4 = {
16352
+ text: "var(--theme-text, #1a1a1a)",
16353
+ sub: "var(--theme-elevation-600, #6b7280)",
16354
+ card: "var(--theme-elevation-50, #f9fafb)",
16355
+ bg: "var(--theme-elevation-0, #fff)",
16356
+ border: "var(--theme-elevation-200, #e5e7eb)",
16357
+ green: "#22c55e",
16358
+ yellow: "#f59e0b",
16359
+ red: "#ef4444",
16360
+ blue: "#3b82f6"
16361
+ };
16362
+ var S = {
16363
+ fr: {
16364
+ title: "Core Web Vitals",
16365
+ subtitle: "LCP / INP / CLS r\xE9els via PageSpeed Insights \u2014 informationnel, hors du score SEO (tie-breaker).",
16366
+ urlPlaceholder: "https://votre-site.fr/page-a-tester",
16367
+ test: "Tester",
16368
+ testing: "Analyse en cours\u2026",
16369
+ mobile: "Mobile",
16370
+ desktop: "Desktop",
16371
+ sourceField: "Donn\xE9es terrain (utilisateurs r\xE9els, CrUX)",
16372
+ sourceLab: "Donn\xE9es labo (Lighthouse)",
16373
+ noInp: "INP n\xE9cessite des donn\xE9es terrain r\xE9elles (indisponible en labo).",
16374
+ noKey: "Astuce : d\xE9finissez PAGESPEED_API_KEY c\xF4t\xE9 serveur pour augmenter le quota.",
16375
+ good: "Bon",
16376
+ ni: "\xC0 am\xE9liorer",
16377
+ poor: "Mauvais",
16378
+ na: "\u2014"
16379
+ },
16380
+ en: {
16381
+ title: "Core Web Vitals",
16382
+ subtitle: "Real LCP / INP / CLS via PageSpeed Insights \u2014 informational, outside the SEO score (tie-breaker).",
16383
+ urlPlaceholder: "https://your-site.com/page-to-test",
16384
+ test: "Test",
16385
+ testing: "Analyzing\u2026",
16386
+ mobile: "Mobile",
16387
+ desktop: "Desktop",
16388
+ sourceField: "Field data (real users, CrUX)",
16389
+ sourceLab: "Lab data (Lighthouse)",
16390
+ noInp: "INP needs real-user field data (not available in lab).",
16391
+ noKey: "Tip: set PAGESPEED_API_KEY on the server to raise the quota.",
16392
+ good: "Good",
16393
+ ni: "Needs improvement",
16394
+ poor: "Poor",
16395
+ na: "\u2014"
16396
+ }
16397
+ };
16398
+ function ratingColor(r) {
16399
+ if (r === "good") return C4.green;
16400
+ if (r === "needs-improvement") return C4.yellow;
16401
+ if (r === "poor") return C4.red;
16402
+ return C4.sub;
16403
+ }
16404
+ function formatValue(m, key) {
16405
+ if (m.value === null || Number.isNaN(m.value)) return "\u2014";
16406
+ if (key === "cls") return m.value.toFixed(2);
16407
+ if (m.unit === "ms") return m.value >= 1e3 ? `${(m.value / 1e3).toFixed(2)} s` : `${Math.round(m.value)} ms`;
16408
+ return String(Math.round(m.value));
16409
+ }
16410
+ function CoreWebVitalsPanel({ locale }) {
16411
+ const s = S[locale] ?? S.fr;
16412
+ const [url, setUrl] = React4.useState(typeof window !== "undefined" ? window.location.origin : "");
16413
+ const [strategy, setStrategy] = React4.useState("mobile");
16414
+ const [loading, setLoading] = React4.useState(false);
16415
+ const [error, setError] = React4.useState(null);
16416
+ const [data, setData] = React4.useState(null);
16417
+ const run = async () => {
16418
+ if (!url) return;
16419
+ setLoading(true);
16420
+ setError(null);
16421
+ setData(null);
16422
+ try {
16423
+ const res = await fetch(
16424
+ `/api/seo-plugin/core-web-vitals?url=${encodeURIComponent(url)}&strategy=${strategy}`,
16425
+ { credentials: "include" }
16426
+ );
16427
+ const json = await res.json();
16428
+ if (!res.ok) setError(json.error || `Error ${res.status}`);
16429
+ else setData(json);
16430
+ } catch (e) {
16431
+ setError(e instanceof Error ? e.message : "Network error");
16432
+ } finally {
16433
+ setLoading(false);
16434
+ }
16435
+ };
16436
+ const labelByKey = { lcp: "LCP", inp: "INP", cls: "CLS" };
16437
+ const metricCard = (key, m) => /* @__PURE__ */ jsxRuntime.jsxs(
16438
+ "div",
16439
+ {
16440
+ style: {
16441
+ flex: 1,
16442
+ minWidth: 120,
16443
+ padding: 14,
16444
+ borderRadius: 10,
16445
+ border: `1px solid ${C4.border}`,
16446
+ backgroundColor: C4.bg
16447
+ },
16448
+ children: [
16449
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, color: C4.sub, textTransform: "uppercase", letterSpacing: "0.04em" }, children: labelByKey[key] }),
16450
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 24, fontWeight: 800, color: ratingColor(m.rating), lineHeight: 1.2, marginTop: 4 }, children: formatValue(m, key) }),
16451
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, color: ratingColor(m.rating) }, children: m.rating === "good" ? s.good : m.rating === "needs-improvement" ? s.ni : m.rating === "poor" ? s.poor : s.na }),
16452
+ m.note && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 10, color: C4.sub, marginTop: 4 }, children: s.noInp })
16453
+ ]
16454
+ },
16455
+ key
16456
+ );
16457
+ const inputStyle4 = {
16458
+ flex: 1,
16459
+ minWidth: 200,
16460
+ padding: "8px 10px",
16461
+ borderRadius: 8,
16462
+ border: `1px solid ${C4.border}`,
16463
+ backgroundColor: C4.bg,
16464
+ color: C4.text,
16465
+ fontSize: 13
16466
+ };
16467
+ const btnStyle = (active) => ({
16468
+ padding: "8px 12px",
16469
+ borderRadius: 8,
16470
+ border: `1px solid ${active ? C4.blue : C4.border}`,
16471
+ backgroundColor: active ? C4.blue : C4.bg,
16472
+ color: active ? "#fff" : C4.text,
16473
+ fontSize: 12,
16474
+ fontWeight: 700,
16475
+ cursor: "pointer"
16476
+ });
16477
+ return /* @__PURE__ */ jsxRuntime.jsxs(
16478
+ "div",
16479
+ {
16480
+ style: {
16481
+ padding: 16,
16482
+ borderRadius: 12,
16483
+ border: `1px solid ${C4.border}`,
16484
+ backgroundColor: C4.card,
16485
+ marginBottom: 20
16486
+ },
16487
+ children: [
16488
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 16, fontWeight: 800, color: C4.text }, children: s.title }),
16489
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 12, color: C4.sub, marginTop: 2, marginBottom: 12 }, children: s.subtitle }),
16490
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginBottom: 12 }, children: [
16491
+ /* @__PURE__ */ jsxRuntime.jsx(
16492
+ "input",
16493
+ {
16494
+ type: "url",
16495
+ value: url,
16496
+ onChange: (e) => setUrl(e.target.value),
16497
+ placeholder: s.urlPlaceholder,
16498
+ style: inputStyle4
16499
+ }
16500
+ ),
16501
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => setStrategy("mobile"), style: btnStyle(strategy === "mobile"), children: s.mobile }),
16502
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => setStrategy("desktop"), style: btnStyle(strategy === "desktop"), children: s.desktop }),
16503
+ /* @__PURE__ */ jsxRuntime.jsx(
16504
+ "button",
16505
+ {
16506
+ type: "button",
16507
+ onClick: run,
16508
+ disabled: loading || !url,
16509
+ style: { ...btnStyle(true), opacity: loading || !url ? 0.6 : 1 },
16510
+ children: loading ? s.testing : s.test
16511
+ }
16512
+ )
16513
+ ] }),
16514
+ error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { color: C4.red, fontSize: 13, fontWeight: 600 }, children: error }),
16515
+ data && /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
16516
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 10 }, children: [
16517
+ metricCard("lcp", data.metrics.lcp),
16518
+ metricCard("inp", data.metrics.inp),
16519
+ metricCard("cls", data.metrics.cls)
16520
+ ] }),
16521
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { fontSize: 11, color: C4.sub, marginTop: 8 }, children: [
16522
+ data.source === "field" ? s.sourceField : s.sourceLab,
16523
+ !data.keyConfigured && ` \xB7 ${s.noKey}`
16524
+ ] })
16525
+ ] })
16526
+ ]
16527
+ }
16528
+ );
16529
+ }
16530
+ var C5 = {
16531
+ text: "var(--theme-text, #1a1a1a)",
16532
+ sub: "var(--theme-elevation-600, #6b7280)",
16533
+ card: "var(--theme-elevation-50, #f9fafb)",
16534
+ bg: "var(--theme-elevation-0, #fff)",
16535
+ border: "var(--theme-elevation-200, #e5e7eb)",
16536
+ green: "#22c55e",
16537
+ red: "#ef4444",
16538
+ blue: "#3b82f6"
16539
+ };
16540
+ var S2 = {
16541
+ fr: {
16542
+ title: "Google Search Console",
16543
+ subtitle: "Connexion OAuth pour importer automatiquement clics, impressions et positions r\xE9elles.",
16544
+ notConfigured: "Int\xE9gration non configur\xE9e. Activez features.gscApi et d\xE9finissez GSC_OAUTH_CLIENT_ID / GSC_OAUTH_CLIENT_SECRET c\xF4t\xE9 serveur, puis enregistrez l'URI de redirection dans Google Cloud.",
16545
+ redirectHint: "URI de redirection \xE0 enregistrer :",
16546
+ connect: "Connecter Google Search Console",
16547
+ disconnect: "D\xE9connecter",
16548
+ connectedAs: "Connect\xE9",
16549
+ fetch: "R\xE9cup\xE9rer les donn\xE9es (28 j)",
16550
+ fetching: "Chargement\u2026",
16551
+ byQuery: "Par requ\xEAte",
16552
+ byPage: "Par page",
16553
+ query: "Requ\xEAte",
16554
+ page: "Page",
16555
+ clicks: "Clics",
16556
+ impressions: "Impressions",
16557
+ ctr: "CTR",
16558
+ position: "Position",
16559
+ noData: "Aucune donn\xE9e sur la p\xE9riode.",
16560
+ refreshStatus: "Rafra\xEEchir le statut",
16561
+ connectHint: "Une fen\xEAtre Google va s'ouvrir. Apr\xE8s autorisation, revenez ici et rafra\xEEchissez le statut."
16562
+ },
16563
+ en: {
16564
+ title: "Google Search Console",
16565
+ subtitle: "OAuth connection to automatically import real clicks, impressions and positions.",
16566
+ notConfigured: "Integration not configured. Enable features.gscApi and set GSC_OAUTH_CLIENT_ID / GSC_OAUTH_CLIENT_SECRET on the server, then register the redirect URI in Google Cloud.",
16567
+ redirectHint: "Redirect URI to register:",
16568
+ connect: "Connect Google Search Console",
16569
+ disconnect: "Disconnect",
16570
+ connectedAs: "Connected",
16571
+ fetch: "Fetch data (28 d)",
16572
+ fetching: "Loading\u2026",
16573
+ byQuery: "By query",
16574
+ byPage: "By page",
16575
+ query: "Query",
16576
+ page: "Page",
16577
+ clicks: "Clicks",
16578
+ impressions: "Impressions",
16579
+ ctr: "CTR",
16580
+ position: "Position",
16581
+ noData: "No data for the period.",
16582
+ refreshStatus: "Refresh status",
16583
+ connectHint: "A Google window will open. After authorizing, come back here and refresh the status."
16584
+ }
16585
+ };
16586
+ function GscPanel({ locale }) {
16587
+ const s = S2[locale] ?? S2.fr;
16588
+ const [status, setStatus] = React4.useState(null);
16589
+ const [busy, setBusy] = React4.useState(false);
16590
+ const [error, setError] = React4.useState(null);
16591
+ const [rows, setRows] = React4.useState(null);
16592
+ const [dimension, setDimension] = React4.useState("query");
16593
+ const [dataLoading, setDataLoading] = React4.useState(false);
16594
+ const loadStatus = React4.useCallback(async () => {
16595
+ setError(null);
16596
+ try {
16597
+ const res = await fetch("/api/seo-plugin/gsc/status", { credentials: "include" });
16598
+ const json = await res.json();
16599
+ if (!res.ok) setError(json.error || `Error ${res.status}`);
16600
+ else setStatus(json);
16601
+ } catch (e) {
16602
+ setError(e instanceof Error ? e.message : "Network error");
16603
+ }
16604
+ }, []);
16605
+ React4.useEffect(() => {
16606
+ void loadStatus();
16607
+ }, [loadStatus]);
16608
+ const connect = async () => {
16609
+ setBusy(true);
16610
+ setError(null);
16611
+ try {
16612
+ const res = await fetch("/api/seo-plugin/gsc/auth", { credentials: "include" });
16613
+ const json = await res.json();
16614
+ if (!res.ok) setError(json.error || `Error ${res.status}`);
16615
+ else if (json.authUrl) window.open(json.authUrl, "_blank", "noopener,noreferrer");
16616
+ } catch (e) {
16617
+ setError(e instanceof Error ? e.message : "Network error");
16618
+ } finally {
16619
+ setBusy(false);
16620
+ }
16621
+ };
16622
+ const disconnect = async () => {
16623
+ setBusy(true);
16624
+ setError(null);
16625
+ try {
16626
+ await fetch("/api/seo-plugin/gsc/disconnect", { method: "POST", credentials: "include" });
16627
+ setRows(null);
16628
+ await loadStatus();
16629
+ } catch (e) {
16630
+ setError(e instanceof Error ? e.message : "Network error");
16631
+ } finally {
16632
+ setBusy(false);
16633
+ }
16634
+ };
16635
+ const fetchData = async (dim) => {
16636
+ setDimension(dim);
16637
+ setDataLoading(true);
16638
+ setError(null);
16639
+ setRows(null);
16640
+ try {
16641
+ const res = await fetch(`/api/seo-plugin/gsc/data?dimension=${dim}&rowLimit=50`, { credentials: "include" });
16642
+ const json = await res.json();
16643
+ if (!res.ok) setError(json.error || `Error ${res.status}`);
16644
+ else setRows(json.rows || []);
16645
+ } catch (e) {
16646
+ setError(e instanceof Error ? e.message : "Network error");
16647
+ } finally {
16648
+ setDataLoading(false);
16649
+ }
16650
+ };
16651
+ const btn = (primary) => ({
16652
+ padding: "8px 12px",
16653
+ borderRadius: 8,
16654
+ border: `1px solid ${primary ? C5.blue : C5.border}`,
16655
+ backgroundColor: primary ? C5.blue : C5.bg,
16656
+ color: primary ? "#fff" : C5.text,
16657
+ fontSize: 12,
16658
+ fontWeight: 700,
16659
+ cursor: "pointer",
16660
+ opacity: busy ? 0.6 : 1
16661
+ });
16662
+ const card = {
16663
+ padding: 16,
16664
+ borderRadius: 12,
16665
+ border: `1px solid ${C5.border}`,
16666
+ backgroundColor: C5.card,
16667
+ marginBottom: 20
16668
+ };
16669
+ if (!status) {
16670
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: card, children: [
16671
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 16, fontWeight: 800, color: C5.text }, children: s.title }),
16672
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 12, color: C5.sub, marginTop: 6 }, children: "\u2026" })
16673
+ ] });
16674
+ }
16675
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: card, children: [
16676
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap" }, children: [
16677
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
16678
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 16, fontWeight: 800, color: C5.text }, children: s.title }),
16679
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 12, color: C5.sub, marginTop: 2 }, children: s.subtitle })
16680
+ ] }),
16681
+ /* @__PURE__ */ jsxRuntime.jsx(
16682
+ "span",
16683
+ {
16684
+ style: {
16685
+ fontSize: 11,
16686
+ fontWeight: 800,
16687
+ padding: "4px 10px",
16688
+ borderRadius: 999,
16689
+ color: "#fff",
16690
+ backgroundColor: status.connected ? C5.green : C5.sub
16691
+ },
16692
+ children: status.connected ? "\u25CF " + s.connectedAs : "\u25CB"
16693
+ }
16694
+ )
16695
+ ] }),
16696
+ error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { color: C5.red, fontSize: 13, fontWeight: 600, marginTop: 10 }, children: error }),
16697
+ !status.configured && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: 12, fontSize: 13, color: C5.sub, lineHeight: 1.5 }, children: [
16698
+ s.notConfigured,
16699
+ status.redirectUri && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: 8 }, children: [
16700
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontWeight: 700 }, children: s.redirectHint }),
16701
+ " ",
16702
+ /* @__PURE__ */ jsxRuntime.jsx("code", { style: { fontSize: 12 }, children: status.redirectUri })
16703
+ ] })
16704
+ ] }),
16705
+ status.configured && !status.connected && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: 12 }, children: [
16706
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: connect, disabled: busy, style: btn(true), children: s.connect }),
16707
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => void loadStatus(), disabled: busy, style: { ...btn(false), marginLeft: 8 }, children: s.refreshStatus }),
16708
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, color: C5.sub, marginTop: 8 }, children: s.connectHint })
16709
+ ] }),
16710
+ status.configured && status.connected && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: 12 }, children: [
16711
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { fontSize: 12, color: C5.sub, marginBottom: 10 }, children: [
16712
+ s.connectedAs,
16713
+ status.connectedEmail ? ` \xB7 ${status.connectedEmail}` : "",
16714
+ status.propertyUrl ? ` \xB7 ${status.propertyUrl}` : ""
16715
+ ] }),
16716
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: [
16717
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => void fetchData("query"), disabled: dataLoading, style: btn(dimension === "query"), children: s.byQuery }),
16718
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: () => void fetchData("page"), disabled: dataLoading, style: btn(dimension === "page"), children: s.byPage }),
16719
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: disconnect, disabled: busy, style: btn(false), children: s.disconnect })
16720
+ ] }),
16721
+ dataLoading && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 13, color: C5.sub }, children: s.fetching }),
16722
+ rows && rows.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 13, color: C5.sub }, children: s.noData }),
16723
+ rows && rows.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { overflowX: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 12 }, children: [
16724
+ /* @__PURE__ */ jsxRuntime.jsx("thead", { children: /* @__PURE__ */ jsxRuntime.jsxs("tr", { style: { textAlign: "left", color: C5.sub }, children: [
16725
+ /* @__PURE__ */ jsxRuntime.jsx("th", { style: { padding: "6px 8px" }, children: dimension === "query" ? s.query : s.page }),
16726
+ /* @__PURE__ */ jsxRuntime.jsx("th", { style: { padding: "6px 8px", textAlign: "right" }, children: s.clicks }),
16727
+ /* @__PURE__ */ jsxRuntime.jsx("th", { style: { padding: "6px 8px", textAlign: "right" }, children: s.impressions }),
16728
+ /* @__PURE__ */ jsxRuntime.jsx("th", { style: { padding: "6px 8px", textAlign: "right" }, children: s.ctr }),
16729
+ /* @__PURE__ */ jsxRuntime.jsx("th", { style: { padding: "6px 8px", textAlign: "right" }, children: s.position })
16730
+ ] }) }),
16731
+ /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: rows.map((r, i) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { style: { borderTop: `1px solid ${C5.border}`, color: C5.text }, children: [
16732
+ /* @__PURE__ */ jsxRuntime.jsx("td", { style: { padding: "6px 8px", maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: r.keys?.[0] || "\u2014" }),
16733
+ /* @__PURE__ */ jsxRuntime.jsx("td", { style: { padding: "6px 8px", textAlign: "right", fontWeight: 700 }, children: r.clicks }),
16734
+ /* @__PURE__ */ jsxRuntime.jsx("td", { style: { padding: "6px 8px", textAlign: "right" }, children: r.impressions }),
16735
+ /* @__PURE__ */ jsxRuntime.jsxs("td", { style: { padding: "6px 8px", textAlign: "right" }, children: [
16736
+ (r.ctr * 100).toFixed(1),
16737
+ "%"
16738
+ ] }),
16739
+ /* @__PURE__ */ jsxRuntime.jsx("td", { style: { padding: "6px 8px", textAlign: "right" }, children: r.position.toFixed(1) })
16740
+ ] }, i)) })
16741
+ ] }) })
16742
+ ] })
16743
+ ] });
16744
+ }
15761
16745
  var V8 = {
15762
16746
  text: "var(--theme-text, #1a1a1a)",
15763
16747
  textSecondary: "var(--theme-elevation-600, #6b7280)",
@@ -16170,6 +17154,8 @@ function PerformanceView() {
16170
17154
  ]
16171
17155
  }
16172
17156
  ),
17157
+ /* @__PURE__ */ jsxRuntime.jsx(CoreWebVitalsPanel, { locale }),
17158
+ /* @__PURE__ */ jsxRuntime.jsx(GscPanel, { locale }),
16173
17159
  showImport && /* @__PURE__ */ jsxRuntime.jsxs(
16174
17160
  "div",
16175
17161
  {
@@ -17829,7 +18815,7 @@ var controlBtnStyle = {
17829
18815
  cursor: "pointer",
17830
18816
  lineHeight: 1.4
17831
18817
  };
17832
- var C4 = {
18818
+ var C6 = {
17833
18819
  cyan: "#00E5FF",
17834
18820
  black: "#000",
17835
18821
  green: "#22c55e",
@@ -17844,10 +18830,10 @@ var C4 = {
17844
18830
  var TITLE_MIN = 30;
17845
18831
  var TITLE_MAX = 60;
17846
18832
  function getCharColor(len) {
17847
- if (len === 0) return C4.textSecondary;
17848
- if (len >= TITLE_MIN && len <= TITLE_MAX) return C4.green;
17849
- if (len > 0 && len < TITLE_MIN) return C4.orange;
17850
- return C4.red;
18833
+ if (len === 0) return C6.textSecondary;
18834
+ if (len >= TITLE_MIN && len <= TITLE_MAX) return C6.green;
18835
+ if (len > 0 && len < TITLE_MIN) return C6.orange;
18836
+ return C6.red;
17851
18837
  }
17852
18838
  function getProgressPercent(len) {
17853
18839
  if (len === 0) return 0;
@@ -17855,9 +18841,9 @@ function getProgressPercent(len) {
17855
18841
  }
17856
18842
  function getProgressColor(len) {
17857
18843
  if (len === 0) return "var(--theme-elevation-200, #e5e7eb)";
17858
- if (len >= TITLE_MIN && len <= TITLE_MAX) return C4.green;
17859
- if (len < TITLE_MIN) return C4.orange;
17860
- return C4.red;
18844
+ if (len >= TITLE_MIN && len <= TITLE_MAX) return C6.green;
18845
+ if (len < TITLE_MIN) return C6.orange;
18846
+ return C6.red;
17861
18847
  }
17862
18848
  function MetaTitleField({
17863
18849
  path,
@@ -17928,7 +18914,7 @@ function MetaTitleField({
17928
18914
  style: {
17929
18915
  fontSize: 13,
17930
18916
  fontWeight: 700,
17931
- color: C4.textPrimary
18917
+ color: C6.textPrimary
17932
18918
  },
17933
18919
  children: t.metaTitle.label
17934
18920
  }
@@ -17968,9 +18954,9 @@ function MetaTitleField({
17968
18954
  fontSize: 14,
17969
18955
  fontFamily: "inherit",
17970
18956
  borderRadius: 8,
17971
- border: `2px solid ${C4.border}`,
17972
- backgroundColor: C4.surfaceBg,
17973
- color: C4.textPrimary,
18957
+ border: `2px solid ${C6.border}`,
18958
+ backgroundColor: C6.surfaceBg,
18959
+ color: C6.textPrimary,
17974
18960
  outline: "none",
17975
18961
  boxShadow: "2px 2px 0 0 var(--theme-border-color, rgba(0,0,0,1))"
17976
18962
  }
@@ -17988,9 +18974,9 @@ function MetaTitleField({
17988
18974
  gap: 5,
17989
18975
  padding: "8px 14px",
17990
18976
  borderRadius: 8,
17991
- border: `2px solid ${C4.border}`,
17992
- backgroundColor: loading ? C4.surface50 : C4.cyan,
17993
- color: loading ? C4.textSecondary : C4.black,
18977
+ border: `2px solid ${C6.border}`,
18978
+ backgroundColor: loading ? C6.surface50 : C6.cyan,
18979
+ color: loading ? C6.textSecondary : C6.black,
17994
18980
  fontWeight: 800,
17995
18981
  fontSize: 11,
17996
18982
  textTransform: "uppercase",
@@ -18037,7 +19023,7 @@ function MetaTitleField({
18037
19023
  justifyContent: "space-between",
18038
19024
  marginTop: 4,
18039
19025
  fontSize: 10,
18040
- color: C4.textSecondary
19026
+ color: C6.textSecondary
18041
19027
  },
18042
19028
  children: [
18043
19029
  /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
@@ -18071,9 +19057,9 @@ function MetaTitleField({
18071
19057
  borderRadius: 6,
18072
19058
  fontSize: 11,
18073
19059
  fontWeight: 600,
18074
- color: C4.red,
19060
+ color: C6.red,
18075
19061
  backgroundColor: "rgba(239,68,68,0.08)",
18076
- border: `1px solid ${C4.red}`
19062
+ border: `1px solid ${C6.red}`
18077
19063
  },
18078
19064
  children: error
18079
19065
  }
@@ -18082,7 +19068,7 @@ function MetaTitleField({
18082
19068
  }
18083
19069
  );
18084
19070
  }
18085
- var C5 = {
19071
+ var C7 = {
18086
19072
  cyan: "#00E5FF",
18087
19073
  black: "#000",
18088
19074
  green: "#22c55e",
@@ -18097,10 +19083,10 @@ var C5 = {
18097
19083
  var DESC_MIN = 120;
18098
19084
  var DESC_MAX = 160;
18099
19085
  function getCharColor2(len) {
18100
- if (len === 0) return C5.textSecondary;
18101
- if (len >= DESC_MIN && len <= DESC_MAX) return C5.green;
18102
- if (len > 0 && len < DESC_MIN) return C5.orange;
18103
- return C5.red;
19086
+ if (len === 0) return C7.textSecondary;
19087
+ if (len >= DESC_MIN && len <= DESC_MAX) return C7.green;
19088
+ if (len > 0 && len < DESC_MIN) return C7.orange;
19089
+ return C7.red;
18104
19090
  }
18105
19091
  function getProgressPercent2(len) {
18106
19092
  if (len === 0) return 0;
@@ -18108,9 +19094,9 @@ function getProgressPercent2(len) {
18108
19094
  }
18109
19095
  function getProgressColor2(len) {
18110
19096
  if (len === 0) return "var(--theme-elevation-200, #e5e7eb)";
18111
- if (len >= DESC_MIN && len <= DESC_MAX) return C5.green;
18112
- if (len < DESC_MIN) return C5.orange;
18113
- return C5.red;
19097
+ if (len >= DESC_MIN && len <= DESC_MAX) return C7.green;
19098
+ if (len < DESC_MIN) return C7.orange;
19099
+ return C7.red;
18114
19100
  }
18115
19101
  function MetaDescriptionField({
18116
19102
  path,
@@ -18181,7 +19167,7 @@ function MetaDescriptionField({
18181
19167
  style: {
18182
19168
  fontSize: 13,
18183
19169
  fontWeight: 700,
18184
- color: C5.textPrimary
19170
+ color: C7.textPrimary
18185
19171
  },
18186
19172
  children: t.metaDescription.label
18187
19173
  }
@@ -18221,9 +19207,9 @@ function MetaDescriptionField({
18221
19207
  fontSize: 14,
18222
19208
  fontFamily: "inherit",
18223
19209
  borderRadius: 8,
18224
- border: `2px solid ${C5.border}`,
18225
- backgroundColor: C5.surfaceBg,
18226
- color: C5.textPrimary,
19210
+ border: `2px solid ${C7.border}`,
19211
+ backgroundColor: C7.surfaceBg,
19212
+ color: C7.textPrimary,
18227
19213
  outline: "none",
18228
19214
  resize: "vertical",
18229
19215
  lineHeight: 1.5,
@@ -18243,9 +19229,9 @@ function MetaDescriptionField({
18243
19229
  gap: 5,
18244
19230
  padding: "8px 14px",
18245
19231
  borderRadius: 8,
18246
- border: `2px solid ${C5.border}`,
18247
- backgroundColor: loading ? C5.surface50 : C5.cyan,
18248
- color: loading ? C5.textSecondary : C5.black,
19232
+ border: `2px solid ${C7.border}`,
19233
+ backgroundColor: loading ? C7.surface50 : C7.cyan,
19234
+ color: loading ? C7.textSecondary : C7.black,
18249
19235
  fontWeight: 800,
18250
19236
  fontSize: 11,
18251
19237
  textTransform: "uppercase",
@@ -18293,7 +19279,7 @@ function MetaDescriptionField({
18293
19279
  justifyContent: "space-between",
18294
19280
  marginTop: 4,
18295
19281
  fontSize: 10,
18296
- color: C5.textSecondary
19282
+ color: C7.textSecondary
18297
19283
  },
18298
19284
  children: [
18299
19285
  /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
@@ -18327,9 +19313,9 @@ function MetaDescriptionField({
18327
19313
  borderRadius: 6,
18328
19314
  fontSize: 11,
18329
19315
  fontWeight: 600,
18330
- color: C5.red,
19316
+ color: C7.red,
18331
19317
  backgroundColor: "rgba(239,68,68,0.08)",
18332
- border: `1px solid ${C5.red}`
19318
+ border: `1px solid ${C7.red}`
18333
19319
  },
18334
19320
  children: error
18335
19321
  }
@@ -18338,7 +19324,7 @@ function MetaDescriptionField({
18338
19324
  }
18339
19325
  );
18340
19326
  }
18341
- var C6 = {
19327
+ var C8 = {
18342
19328
  cyan: "#00E5FF",
18343
19329
  black: "#000",
18344
19330
  white: "#fff",
@@ -18415,8 +19401,8 @@ function MetaImageField({
18415
19401
  gap: 10,
18416
19402
  padding: "10px 14px",
18417
19403
  borderRadius: 8,
18418
- border: `2px solid ${C6.border}`,
18419
- backgroundColor: C6.surfaceBg,
19404
+ border: `2px solid ${C8.border}`,
19405
+ backgroundColor: C8.surfaceBg,
18420
19406
  boxShadow: "2px 2px 0 0 var(--theme-border-color, rgba(0,0,0,1))"
18421
19407
  },
18422
19408
  children: [
@@ -18435,7 +19421,7 @@ function MetaImageField({
18435
19421
  fontWeight: 900,
18436
19422
  backgroundColor: hasImage ? "rgba(34,197,94,0.15)" : "rgba(255,138,0,0.15)",
18437
19423
  color: hasImage ? "#16a34a" : "#d97706",
18438
- border: `1px solid ${hasImage ? C6.green : C6.orange}`
19424
+ border: `1px solid ${hasImage ? C8.green : C8.orange}`
18439
19425
  },
18440
19426
  children: hasImage ? "\u2713" : "!"
18441
19427
  }
@@ -18447,7 +19433,7 @@ function MetaImageField({
18447
19433
  style: {
18448
19434
  fontSize: 12,
18449
19435
  fontWeight: 700,
18450
- color: C6.textPrimary
19436
+ color: C8.textPrimary
18451
19437
  },
18452
19438
  children: t.metaImage.label
18453
19439
  }
@@ -18457,7 +19443,7 @@ function MetaImageField({
18457
19443
  {
18458
19444
  style: {
18459
19445
  fontSize: 10,
18460
- color: C6.textSecondary,
19446
+ color: C8.textSecondary,
18461
19447
  lineHeight: 1.4
18462
19448
  },
18463
19449
  children: hasImage ? t.metaImage.imageSet : t.metaImage.noImage
@@ -18477,9 +19463,9 @@ function MetaImageField({
18477
19463
  gap: 5,
18478
19464
  padding: "8px 14px",
18479
19465
  borderRadius: 8,
18480
- border: `2px solid ${C6.border}`,
18481
- backgroundColor: loading ? C6.surface50 : success ? C6.green : C6.cyan,
18482
- color: loading ? C6.textSecondary : success ? C6.white : C6.black,
19466
+ border: `2px solid ${C8.border}`,
19467
+ backgroundColor: loading ? C8.surface50 : success ? C8.green : C8.cyan,
19468
+ color: loading ? C8.textSecondary : success ? C8.white : C8.black,
18483
19469
  fontWeight: 800,
18484
19470
  fontSize: 11,
18485
19471
  textTransform: "uppercase",
@@ -18506,9 +19492,9 @@ function MetaImageField({
18506
19492
  borderRadius: 6,
18507
19493
  fontSize: 11,
18508
19494
  fontWeight: 600,
18509
- color: C6.red,
19495
+ color: C8.red,
18510
19496
  backgroundColor: "rgba(239,68,68,0.08)",
18511
- border: `1px solid ${C6.red}`
19497
+ border: `1px solid ${C8.red}`
18512
19498
  },
18513
19499
  children: error
18514
19500
  }
@@ -18517,7 +19503,7 @@ function MetaImageField({
18517
19503
  }
18518
19504
  );
18519
19505
  }
18520
- var C7 = {
19506
+ var C9 = {
18521
19507
  black: "#000",
18522
19508
  white: "#fff",
18523
19509
  green: "#22c55e",
@@ -18531,15 +19517,15 @@ var C7 = {
18531
19517
  function getCompletenessColor(count) {
18532
19518
  switch (count) {
18533
19519
  case 0:
18534
- return C7.red;
19520
+ return C9.red;
18535
19521
  case 1:
18536
- return C7.orange;
19522
+ return C9.orange;
18537
19523
  case 2:
18538
- return C7.yellow;
19524
+ return C9.yellow;
18539
19525
  case 3:
18540
- return C7.green;
19526
+ return C9.green;
18541
19527
  default:
18542
- return C7.textSecondary;
19528
+ return C9.textSecondary;
18543
19529
  }
18544
19530
  }
18545
19531
  function getCompletenessLabel(count, ov) {
@@ -18597,8 +19583,8 @@ function OverviewField({
18597
19583
  fontFamily: "var(--font-body, Inter, system-ui, sans-serif)",
18598
19584
  padding: "12px 14px",
18599
19585
  borderRadius: 10,
18600
- border: `2px solid ${C7.border}`,
18601
- backgroundColor: C7.surfaceBg,
19586
+ border: `2px solid ${C9.border}`,
19587
+ backgroundColor: C9.surfaceBg,
18602
19588
  boxShadow: "3px 3px 0 0 var(--theme-border-color, rgba(0,0,0,1))",
18603
19589
  marginBottom: 12
18604
19590
  },
@@ -18621,7 +19607,7 @@ function OverviewField({
18621
19607
  fontWeight: 800,
18622
19608
  textTransform: "uppercase",
18623
19609
  letterSpacing: "0.04em",
18624
- color: C7.textPrimary
19610
+ color: C9.textPrimary
18625
19611
  },
18626
19612
  children: t.overview.metaCompleteness
18627
19613
  }
@@ -18636,8 +19622,8 @@ function OverviewField({
18636
19622
  fontSize: 11,
18637
19623
  fontWeight: 800,
18638
19624
  backgroundColor: completenessColor,
18639
- color: completenessColor === C7.yellow ? C7.black : C7.white,
18640
- border: `2px solid ${C7.border}`,
19625
+ color: completenessColor === C9.yellow ? C9.black : C9.white,
19626
+ border: `2px solid ${C9.border}`,
18641
19627
  textTransform: "uppercase",
18642
19628
  letterSpacing: "0.03em"
18643
19629
  },
@@ -18735,7 +19721,7 @@ function OverviewField({
18735
19721
  style: {
18736
19722
  fontSize: 12,
18737
19723
  fontWeight: 600,
18738
- color: item.filled ? C7.textPrimary : C7.textSecondary
19724
+ color: item.filled ? C9.textPrimary : C9.textSecondary
18739
19725
  },
18740
19726
  children: item.label
18741
19727
  }
@@ -18747,7 +19733,7 @@ function OverviewField({
18747
19733
  marginLeft: "auto",
18748
19734
  fontSize: 10,
18749
19735
  fontWeight: 700,
18750
- color: item.filled ? C7.green : C7.red,
19736
+ color: item.filled ? C9.green : C9.red,
18751
19737
  textTransform: "uppercase",
18752
19738
  letterSpacing: "0.03em"
18753
19739
  },
@@ -18764,7 +19750,7 @@ function OverviewField({
18764
19750
  }
18765
19751
  );
18766
19752
  }
18767
- var C8 = {
19753
+ var C10 = {
18768
19754
  cyan: "#00E5FF",
18769
19755
  black: "#000",
18770
19756
  white: "#fff",
@@ -18783,10 +19769,10 @@ var G = {
18783
19769
  descGrey: "#4d5156",
18784
19770
  faviconBg: "#e8eaed"};
18785
19771
  function charCountColor2(len, min, max) {
18786
- if (len >= min && len <= max) return C8.green;
18787
- if (len > 0 && len < min) return C8.orange;
18788
- if (len > max) return C8.red;
18789
- return C8.textSecondary;
19772
+ if (len >= min && len <= max) return C10.green;
19773
+ if (len > 0 && len < min) return C10.orange;
19774
+ if (len > max) return C10.red;
19775
+ return C10.textSecondary;
18790
19776
  }
18791
19777
  function truncateText(text, maxChars) {
18792
19778
  if (text.length <= maxChars) return text;
@@ -18841,8 +19827,8 @@ function SerpPreview({
18841
19827
  padding: "10px 12px",
18842
19828
  cursor: "pointer",
18843
19829
  borderRadius: 8,
18844
- border: `2px solid ${C8.border}`,
18845
- backgroundColor: C8.surface50,
19830
+ border: `2px solid ${C10.border}`,
19831
+ backgroundColor: C10.surface50,
18846
19832
  userSelect: "none"
18847
19833
  },
18848
19834
  children: [
@@ -18857,7 +19843,7 @@ function SerpPreview({
18857
19843
  fontWeight: 800,
18858
19844
  textTransform: "uppercase",
18859
19845
  letterSpacing: "0.04em",
18860
- color: C8.textPrimary
19846
+ color: C10.textPrimary
18861
19847
  },
18862
19848
  children: [
18863
19849
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -18889,7 +19875,7 @@ function SerpPreview({
18889
19875
  transition: "transform 0.2s",
18890
19876
  display: "inline-block",
18891
19877
  transform: open ? "rotate(90deg)" : "none",
18892
- color: C8.textSecondary
19878
+ color: C10.textSecondary
18893
19879
  },
18894
19880
  children: "\u25B6"
18895
19881
  }
@@ -18922,10 +19908,10 @@ function SerpPreview({
18922
19908
  "div",
18923
19909
  {
18924
19910
  style: {
18925
- backgroundColor: C8.white,
18926
- border: `2px solid ${C8.border}`,
19911
+ backgroundColor: C10.white,
19912
+ border: `2px solid ${C10.border}`,
18927
19913
  borderRadius: 12,
18928
- boxShadow: `3px 3px 0 0 ${C8.border}`,
19914
+ boxShadow: `3px 3px 0 0 ${C10.border}`,
18929
19915
  padding: isDesktop ? 20 : 14,
18930
19916
  maxWidth: isDesktop ? 650 : 380,
18931
19917
  overflow: "hidden"
@@ -18988,7 +19974,7 @@ function SerpPreview({
18988
19974
  style: {
18989
19975
  fontSize: 14,
18990
19976
  fontWeight: 400,
18991
- color: C8.black,
19977
+ color: C10.black,
18992
19978
  lineHeight: 1.3,
18993
19979
  whiteSpace: "nowrap",
18994
19980
  overflow: "hidden",
@@ -19052,7 +20038,7 @@ function SerpPreview({
19052
20038
  "span",
19053
20039
  {
19054
20040
  style: {
19055
- color: C8.textSecondary,
20041
+ color: C10.textSecondary,
19056
20042
  fontStyle: "italic",
19057
20043
  fontSize: titleFontSize - 2
19058
20044
  },
@@ -19081,7 +20067,7 @@ function SerpPreview({
19081
20067
  "span",
19082
20068
  {
19083
20069
  style: {
19084
- color: C8.textSecondary,
20070
+ color: C10.textSecondary,
19085
20071
  fontStyle: "italic",
19086
20072
  fontSize: descFontSize - 1
19087
20073
  },
@@ -19114,7 +20100,7 @@ function SerpPreview({
19114
20100
  fontSize: 11
19115
20101
  },
19116
20102
  children: [
19117
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C8.textSecondary, fontWeight: 600 }, children: t.serpPreview.previewTitle }),
20103
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C10.textSecondary, fontWeight: 600 }, children: t.serpPreview.previewTitle }),
19118
20104
  /* @__PURE__ */ jsxRuntime.jsxs(
19119
20105
  "span",
19120
20106
  {
@@ -19143,7 +20129,7 @@ function SerpPreview({
19143
20129
  fontSize: 11
19144
20130
  },
19145
20131
  children: [
19146
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C8.textSecondary, fontWeight: 600 }, children: t.serpPreview.previewDescription }),
20132
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C10.textSecondary, fontWeight: 600 }, children: t.serpPreview.previewDescription }),
19147
20133
  /* @__PURE__ */ jsxRuntime.jsxs(
19148
20134
  "span",
19149
20135
  {
@@ -19172,14 +20158,14 @@ function SerpPreview({
19172
20158
  fontSize: 11
19173
20159
  },
19174
20160
  children: [
19175
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C8.textSecondary, fontWeight: 600 }, children: t.serpPreview.url }),
20161
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: C10.textSecondary, fontWeight: 600 }, children: t.serpPreview.url }),
19176
20162
  /* @__PURE__ */ jsxRuntime.jsxs(
19177
20163
  "span",
19178
20164
  {
19179
20165
  style: {
19180
20166
  fontWeight: 700,
19181
20167
  fontVariantNumeric: "tabular-nums",
19182
- color: fullUrl.length <= 75 ? C8.green : C8.red
20168
+ color: fullUrl.length <= 75 ? C10.green : C10.red
19183
20169
  },
19184
20170
  children: [
19185
20171
  fullUrl.length,
@@ -19214,13 +20200,13 @@ function DeviceButton({
19214
20200
  gap: 5,
19215
20201
  padding: "4px 12px",
19216
20202
  borderRadius: 6,
19217
- border: `2px solid ${C8.border}`,
20203
+ border: `2px solid ${C10.border}`,
19218
20204
  fontSize: 11,
19219
20205
  fontWeight: 700,
19220
20206
  cursor: "pointer",
19221
- backgroundColor: active ? C8.cyan : C8.surfaceBg,
19222
- color: active ? C8.black : C8.textPrimary,
19223
- boxShadow: active ? `2px 2px 0 0 ${C8.border}` : "none",
20207
+ backgroundColor: active ? C10.cyan : C10.surfaceBg,
20208
+ color: active ? C10.black : C10.textPrimary,
20209
+ boxShadow: active ? `2px 2px 0 0 ${C10.border}` : "none",
19224
20210
  transition: "background-color 0.15s"
19225
20211
  },
19226
20212
  children: [