@lynxflow/seo-engine 1.4.0 โ†’ 1.5.0

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.
Files changed (86) hide show
  1. package/README.md +84 -339
  2. package/TUTORIEL_INTEGRATION_SITE.md +283 -0
  3. package/connectors/wordpress/lynxseo-connector.php +5 -5
  4. package/connectors/wordpress/lynxseo-connector.zip +0 -0
  5. package/dist/ad-intelligence-cro.d.ts +58 -0
  6. package/dist/ai-bots-log-analyzer.d.ts +36 -0
  7. package/dist/analytics-client.d.ts +48 -11
  8. package/dist/auth-key.d.ts +9 -0
  9. package/dist/brand-dna-calendar.d.ts +35 -0
  10. package/dist/copy-frameworks-master.d.ts +56 -0
  11. package/dist/cro-copywriting-engine.d.ts +46 -0
  12. package/dist/crosslink-scorer.d.ts +33 -0
  13. package/dist/embeddable-seo-widget.d.ts +12 -0
  14. package/dist/engine.d.ts +32 -1
  15. package/dist/engine.test.d.ts +1 -0
  16. package/dist/extended-schemas.d.ts +106 -0
  17. package/dist/geo-mesh-linking.d.ts +33 -0
  18. package/dist/index.d.ts +88 -0
  19. package/dist/index.js +4164 -130
  20. package/dist/index.mjs +4092 -100
  21. package/dist/instant-matrix-search.d.ts +19 -0
  22. package/dist/isr-cache-manager.d.ts +35 -0
  23. package/dist/knowledge-graph-linker.d.ts +29 -0
  24. package/dist/legal-disclaimers.d.ts +46 -0
  25. package/dist/llm-content-cleaner.d.ts +16 -0
  26. package/dist/llm-prompt.d.ts +13 -0
  27. package/dist/master-marketing-engine.d.ts +114 -0
  28. package/dist/matrix-engine.d.ts +201 -0
  29. package/dist/mcp-seo-server.d.ts +21 -0
  30. package/dist/ngram-density-analyzer.d.ts +35 -0
  31. package/dist/rank-math-parity.d.ts +58 -0
  32. package/dist/real-reviews-sync.d.ts +73 -0
  33. package/dist/rss-syndication-feed.d.ts +25 -0
  34. package/dist/schema-builder.d.ts +6 -0
  35. package/dist/seo-opportunities-decay.d.ts +54 -0
  36. package/dist/serp-history-alerts.d.ts +27 -0
  37. package/dist/slug-engine.d.ts +32 -0
  38. package/dist/social-ads-seo.d.ts +77 -0
  39. package/dist/social-growth-suite.d.ts +125 -0
  40. package/dist/social-trend-seo.d.ts +38 -0
  41. package/dist/social-video-seo.d.ts +62 -0
  42. package/dist/team-rbac.d.ts +17 -0
  43. package/dist/technical-rules-auditor.d.ts +50 -0
  44. package/dist/types.d.ts +4 -44
  45. package/dist/yoast-parity.d.ts +45 -0
  46. package/package.json +8 -5
  47. package/src/ad-intelligence-cro.ts +140 -0
  48. package/src/ai-bots-log-analyzer.ts +127 -0
  49. package/src/analytics-client.ts +222 -48
  50. package/src/auth-key.ts +60 -1
  51. package/src/brand-dna-calendar.ts +120 -0
  52. package/src/copy-frameworks-master.ts +89 -0
  53. package/src/cro-copywriting-engine.ts +105 -0
  54. package/src/crosslink-scorer.ts +101 -0
  55. package/src/embeddable-seo-widget.ts +57 -0
  56. package/src/engine.test.ts +136 -0
  57. package/src/engine.ts +229 -28
  58. package/src/extended-schemas.ts +279 -0
  59. package/src/geo-mesh-linking.ts +93 -0
  60. package/src/index.ts +89 -0
  61. package/src/instant-matrix-search.ts +48 -0
  62. package/src/isr-cache-manager.ts +78 -0
  63. package/src/knowledge-graph-linker.ts +81 -0
  64. package/src/legal-disclaimers.ts +407 -0
  65. package/src/llm-content-cleaner.ts +64 -0
  66. package/src/llm-prompt.ts +70 -0
  67. package/src/master-marketing-engine.ts +314 -0
  68. package/src/matrix-engine.ts +818 -0
  69. package/src/mcp-seo-server.ts +108 -0
  70. package/src/ngram-density-analyzer.ts +108 -0
  71. package/src/rank-math-parity.ts +235 -0
  72. package/src/real-reviews-sync.ts +207 -0
  73. package/src/rss-syndication-feed.ts +56 -0
  74. package/src/schema-builder.ts +71 -24
  75. package/src/seo-opportunities-decay.ts +154 -0
  76. package/src/serp-history-alerts.ts +75 -0
  77. package/src/slug-engine.ts +183 -0
  78. package/src/social-ads-seo.ts +211 -0
  79. package/src/social-growth-suite.ts +310 -0
  80. package/src/social-trend-seo.ts +103 -0
  81. package/src/social-video-seo.ts +100 -0
  82. package/src/team-rbac.ts +61 -0
  83. package/src/technical-rules-auditor.ts +171 -0
  84. package/src/types.ts +22 -47
  85. package/src/yoast-parity.ts +161 -0
  86. package/tsconfig.json +2 -1
@@ -0,0 +1,56 @@
1
+ /**
2
+ * ๐Ÿ“ฐ RSS 2.0 & Atom Syndication Feed Generator (Inspiration: pages-seo/feed.xml.js)
3
+ *
4
+ * Automatically generates standards-compliant RSS 2.0 and Atom feeds for published
5
+ * programmatic landing pages and articles to accelerate syndication and indexing.
6
+ */
7
+
8
+ export interface FeedItem {
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ publishedAt: string; // ISO date
13
+ author?: string;
14
+ category?: string;
15
+ }
16
+
17
+ export class RssSyndicationFeedGenerator {
18
+ /**
19
+ * Generates a valid RSS 2.0 XML string.
20
+ */
21
+ static generateRss2(opts: {
22
+ siteTitle: string;
23
+ siteUrl: string;
24
+ description: string;
25
+ items: FeedItem[];
26
+ }): string {
27
+ const itemsXml = opts.items
28
+ .map(
29
+ (item) => `
30
+ <item>
31
+ <title><![CDATA[${item.title}]]></title>
32
+ <link>${item.url}</link>
33
+ <guid isPermaLink="true">${item.url}</guid>
34
+ <description><![CDATA[${item.description}]]></description>
35
+ <pubDate>${new Date(item.publishedAt).toUTCString()}</pubDate>
36
+ ${item.category ? `<category>${item.category}</category>` : ""}
37
+ ${item.author ? `<author>${item.author}</author>` : ""}
38
+ </item>
39
+ `.trim(),
40
+ )
41
+ .join("\n");
42
+
43
+ return `<?xml version="1.0" encoding="UTF-8"?>
44
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
45
+ <channel>
46
+ <title><![CDATA[${opts.siteTitle}]]></title>
47
+ <link>${opts.siteUrl}</link>
48
+ <description><![CDATA[${opts.description}]]></description>
49
+ <atom:link href="${opts.siteUrl}/feed.xml" rel="self" type="application/rss+xml" />
50
+ <language>fr</language>
51
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
52
+ ${itemsXml}
53
+ </channel>
54
+ </rss>`.trim();
55
+ }
56
+ }
@@ -21,35 +21,82 @@ export class SchemaGraphBuilder {
21
21
  ratingValue?: string;
22
22
  reviewCount?: string;
23
23
  locale?: string;
24
+ reviews?: Array<{
25
+ authorName: string;
26
+ ratingValue: number;
27
+ reviewText: string;
28
+ datePublished?: string;
29
+ }>;
24
30
  }): Record<string, unknown> {
25
- const graph: Record<string, unknown>[] = [
26
- {
27
- "@type": "Product",
28
- "@id": `${opts.url}#product`,
29
- name: opts.name,
30
- description: opts.description,
31
- inLanguage: opts.locale || "fr",
32
- image: opts.image || `${opts.url}/api/og`,
33
- brand: {
34
- "@type": "Brand",
35
- name: opts.brandName,
31
+ const productNode: Record<string, unknown> = {
32
+ "@type": "Product",
33
+ "@id": `${opts.url}#product`,
34
+ name: opts.name,
35
+ description: opts.description,
36
+ image: opts.image || `${opts.url}/api/og`,
37
+ brand: {
38
+ "@type": "Brand",
39
+ name: opts.brandName,
40
+ },
41
+ };
42
+
43
+ if (opts.locale) {
44
+ productNode.inLanguage = opts.locale;
45
+ }
46
+
47
+ // Automatically compute or attach real AggregateRating
48
+ if (opts.ratingValue && opts.reviewCount) {
49
+ productNode.aggregateRating = {
50
+ "@type": "AggregateRating",
51
+ ratingValue: opts.ratingValue,
52
+ reviewCount: opts.reviewCount,
53
+ bestRating: "5",
54
+ worstRating: "1",
55
+ };
56
+ } else if (opts.reviews && opts.reviews.length > 0) {
57
+ const avg = (
58
+ opts.reviews.reduce((acc, r) => acc + r.ratingValue, 0) / opts.reviews.length
59
+ ).toFixed(1);
60
+ productNode.aggregateRating = {
61
+ "@type": "AggregateRating",
62
+ ratingValue: avg,
63
+ reviewCount: opts.reviews.length.toString(),
64
+ bestRating: "5",
65
+ worstRating: "1",
66
+ };
67
+ }
68
+
69
+ // Attach individual authentic reviews if provided
70
+ if (opts.reviews && opts.reviews.length > 0) {
71
+ productNode.review = opts.reviews.map((r) => ({
72
+ "@type": "Review",
73
+ author: {
74
+ "@type": "Person",
75
+ name: r.authorName,
36
76
  },
37
- aggregateRating: {
38
- "@type": "AggregateRating",
39
- ratingValue: opts.ratingValue || "4.9",
40
- reviewCount: opts.reviewCount || "1280",
77
+ datePublished: r.datePublished || new Date().toISOString().split("T")[0],
78
+ reviewBody: r.reviewText,
79
+ reviewRating: {
80
+ "@type": "Rating",
81
+ ratingValue: r.ratingValue.toString(),
41
82
  bestRating: "5",
42
83
  worstRating: "1",
43
84
  },
44
- offers: {
45
- "@type": "Offer",
46
- price: (opts.price || 49).toString(),
47
- priceCurrency: opts.currency || "EUR",
48
- availability: "https://schema.org/InStock",
49
- url: opts.url,
50
- },
51
- },
52
- ];
85
+ }));
86
+ }
87
+
88
+ // Only emit Offer if real price is provided
89
+ if (opts.price !== undefined) {
90
+ productNode.offers = {
91
+ "@type": "Offer",
92
+ price: opts.price.toString(),
93
+ priceCurrency: opts.currency || "EUR",
94
+ availability: "https://schema.org/InStock",
95
+ url: opts.url,
96
+ };
97
+ }
98
+
99
+ const graph: Record<string, unknown>[] = [productNode];
53
100
 
54
101
  if (opts.faqs && opts.faqs.length > 0) {
55
102
  graph.push({
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ๐ŸŽฏ Advanced SEO Opportunities & Content Decay Detector (Inspiration: crawlseo/seo-opportunities.ts)
3
+ *
4
+ * Implements mathematical models to detect:
5
+ * 1. Striking Distance Keywords (Positions 4 to 20 with high impressions)
6
+ * 2. Low CTR Gaps (Real CTR < Expected Industry Curve)
7
+ * 3. Content Decay (Pages losing > 30% traffic over 28-day windows)
8
+ * 4. Keyword Cannibalization (Multiple internal URLs splitting Google rankings)
9
+ */
10
+
11
+ export interface KeywordMetric {
12
+ query: string;
13
+ url: string;
14
+ position: number;
15
+ clicks: number;
16
+ impressions: number;
17
+ ctr: number;
18
+ }
19
+
20
+ export interface ContentDecayInput {
21
+ url: string;
22
+ previous28DaysClicks: number;
23
+ last28DaysClicks: number;
24
+ }
25
+
26
+ export interface OpportunityFinding {
27
+ type: "striking_distance" | "low_ctr" | "content_decay" | "cannibalization";
28
+ severity: "critical" | "high" | "medium" | "low";
29
+ title: string;
30
+ detail: string;
31
+ query?: string;
32
+ url?: string;
33
+ potentialTrafficGain?: number;
34
+ suggestedAction: string;
35
+ }
36
+
37
+ export class SeoOpportunitiesDecayDetector {
38
+ /**
39
+ * Expected organic CTR curve by Google SERP rank position.
40
+ */
41
+ static getExpectedCtr(position: number): number {
42
+ if (position <= 1) return 0.28;
43
+ if (position <= 2) return 0.15;
44
+ if (position <= 3) return 0.11;
45
+ if (position <= 5) return 0.07;
46
+ if (position <= 10) return 0.03;
47
+ if (position <= 20) return 0.01;
48
+ return 0.005;
49
+ }
50
+
51
+ /**
52
+ * Detects Striking Distance opportunities (Keywords ranked between #4 and #20).
53
+ */
54
+ static detectStrikingDistance(keywords: KeywordMetric[], minImpressions = 30): OpportunityFinding[] {
55
+ return keywords
56
+ .filter((k) => k.position >= 4 && k.position <= 20 && k.impressions >= minImpressions)
57
+ .sort((a, b) => b.impressions - a.impressions)
58
+ .map((k) => {
59
+ const targetPos = Math.max(1, k.position - 3);
60
+ const potentialClicks = Math.round(k.impressions * this.getExpectedCtr(targetPos)) - k.clicks;
61
+
62
+ return {
63
+ type: "striking_distance",
64
+ severity: k.position <= 10 ? "high" : "medium",
65
+ title: `๐ŸŽฏ Mot-clรฉ ร  portรฉe de main : "${k.query}" (#${k.position.toFixed(1)})`,
66
+ detail: `${k.impressions} impressions mensuelles actuelles. Atteindre le top 3 gรฉnรฉrerait environ +${Math.max(1, potentialClicks)} clics/mois.`,
67
+ query: k.query,
68
+ url: k.url,
69
+ potentialTrafficGain: Math.max(1, potentialClicks),
70
+ suggestedAction: "Ajoutez 1 lien interne contextualisรฉ vers cette page et enrichissez la section FAQ avec ce terme.",
71
+ };
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Detects Low CTR Gaps (Ranked in top 15 but underperforming in clicks).
77
+ */
78
+ static detectLowCtrGaps(keywords: KeywordMetric[], minImpressions = 50): OpportunityFinding[] {
79
+ return keywords
80
+ .filter((k) => k.impressions >= minImpressions && k.position <= 15)
81
+ .map((k) => {
82
+ const expected = this.getExpectedCtr(k.position);
83
+ const gap = expected - k.ctr;
84
+ return { ...k, expected, gap };
85
+ })
86
+ .filter((k) => k.gap > 0.02)
87
+ .sort((a, b) => b.impressions * b.gap - a.impressions * a.gap)
88
+ .map((k) => ({
89
+ type: "low_ctr" as const,
90
+ severity: "high" as const,
91
+ title: `โšก CTR anormalement bas sur "${k.query}" (${(k.ctr * 100).toFixed(1)}% vs ${(k.expected * 100).toFixed(1)}% attendu)`,
92
+ detail: `Classรฉ #${k.position.toFixed(1)} avec ${k.impressions} impressions, mais un taux de clic infรฉrieur aux moyennes du secteur.`,
93
+ query: k.query,
94
+ url: k.url,
95
+ potentialTrafficGain: Math.round(k.impressions * k.gap),
96
+ suggestedAction: "Rรฉรฉcrivez la balise <title> et la mรฉta-description avec des mots d'action, des chiffres ou un crochet d'actualitรฉ.",
97
+ }));
98
+ }
99
+
100
+ /**
101
+ * Detects Content Decay (Pages losing more than 30% of traffic).
102
+ */
103
+ static detectContentDecay(pages: ContentDecayInput[]): OpportunityFinding[] {
104
+ return pages
105
+ .filter((p) => p.previous28DaysClicks >= 20)
106
+ .map((p) => {
107
+ const dropRatio = (p.previous28DaysClicks - p.last28DaysClicks) / p.previous28DaysClicks;
108
+ return { ...p, dropRatio };
109
+ })
110
+ .filter((p) => p.dropRatio >= 0.3)
111
+ .sort((a, b) => b.dropRatio - a.dropRatio)
112
+ .map((p) => ({
113
+ type: "content_decay" as const,
114
+ severity: "critical" as const,
115
+ title: `๐Ÿ“‰ Dรฉclin de contenu dรฉtectรฉ (-${Math.round(p.dropRatio * 100)}% de trafic)`,
116
+ detail: `Le trafic est passรฉ de ${p.previous28DaysClicks} ร  ${p.last28DaysClicks} clics sur les 28 derniers jours.`,
117
+ url: p.url,
118
+ potentialTrafficGain: p.previous28DaysClicks - p.last28DaysClicks,
119
+ suggestedAction: "Mettez ร  jour les donnรฉes datรฉes, ajoutez une section 2026 et vรฉrifiez si des concurrents ont publiรฉ un meilleur guide.",
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * Detects Keyword Cannibalization across internal URLs.
125
+ */
126
+ static detectCannibalization(keywords: KeywordMetric[]): OpportunityFinding[] {
127
+ const queryMap: Record<string, KeywordMetric[]> = {};
128
+
129
+ for (const k of keywords) {
130
+ if (!queryMap[k.query]) queryMap[k.query] = [];
131
+ queryMap[k.query].push(k);
132
+ }
133
+
134
+ const cannibalized: OpportunityFinding[] = [];
135
+
136
+ for (const [query, urls] of Object.entries(queryMap)) {
137
+ if (urls.length > 1) {
138
+ const totalImpressions = urls.reduce((acc, curr) => acc + curr.impressions, 0);
139
+ if (totalImpressions >= 20) {
140
+ cannibalized.push({
141
+ type: "cannibalization",
142
+ severity: "high",
143
+ title: `โš”๏ธ Cannibalisation dรฉtectรฉe sur "${query}" (${urls.length} URLs en compรฉtition)`,
144
+ detail: `Les URLs suivantes se partagent les clics : ${urls.map((u) => u.url).join(", ")}.`,
145
+ query,
146
+ suggestedAction: "Fusionnez les deux contenus en une seule page maรฎtresse ou placez une balise canonique vers la plus performante.",
147
+ });
148
+ }
149
+ }
150
+ }
151
+
152
+ return cannibalized;
153
+ }
154
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ๐Ÿ“ˆ SERP Chronological History & Webhook Alert Engine (Pole 5)
3
+ *
4
+ * Tracks keyword rank history over time, detects ranking shifts,
5
+ * and triggers automated alerts (Top 3, Top 10, rank drop).
6
+ */
7
+
8
+ export interface KeywordRankSnapshot {
9
+ keyword: string;
10
+ date: string; // ISO format: YYYY-MM-DD
11
+ position: number;
12
+ url: string;
13
+ searchEngine: "google-mobile" | "google-desktop" | "bing";
14
+ }
15
+
16
+ export interface RankShiftAlert {
17
+ keyword: string;
18
+ previousPosition: number;
19
+ newPosition: number;
20
+ shiftType: "entered-top-3" | "entered-top-10" | "critical-drop" | "improved";
21
+ message: string;
22
+ timestamp: string;
23
+ }
24
+
25
+ export class SerpRankHistoryEngine {
26
+ /**
27
+ * Evaluates position shifts between two snapshots and returns alerts if thresholds are breached.
28
+ */
29
+ static analyzeRankShift(
30
+ previousSnapshot: KeywordRankSnapshot,
31
+ currentSnapshot: KeywordRankSnapshot,
32
+ ): RankShiftAlert | null {
33
+ const prev = previousSnapshot.position;
34
+ const curr = currentSnapshot.position;
35
+
36
+ if (prev === curr) return null;
37
+
38
+ const diff = prev - curr; // Positive means gained positions, negative means dropped
39
+
40
+ if (curr <= 3 && prev > 3) {
41
+ return {
42
+ keyword: currentSnapshot.keyword,
43
+ previousPosition: prev,
44
+ newPosition: curr,
45
+ shiftType: "entered-top-3",
46
+ message: `๐Ÿš€ Le mot-clรฉ "${currentSnapshot.keyword}" a pรฉnรฉtrรฉ le TOP 3 (#${curr}) !`,
47
+ timestamp: new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ if (curr <= 10 && prev > 10) {
52
+ return {
53
+ keyword: currentSnapshot.keyword,
54
+ previousPosition: prev,
55
+ newPosition: curr,
56
+ shiftType: "entered-top-10",
57
+ message: `๐ŸŽฏ Le mot-clรฉ "${currentSnapshot.keyword}" est dรฉsormais en Premiรจre Page (#${curr}) !`,
58
+ timestamp: new Date().toISOString(),
59
+ };
60
+ }
61
+
62
+ if (diff <= -5) {
63
+ return {
64
+ keyword: currentSnapshot.keyword,
65
+ previousPosition: prev,
66
+ newPosition: curr,
67
+ shiftType: "critical-drop",
68
+ message: `โš ๏ธ Chute de position dรฉtectรฉe sur "${currentSnapshot.keyword}" (${prev} โž” ${curr}).`,
69
+ timestamp: new Date().toISOString(),
70
+ };
71
+ }
72
+
73
+ return null;
74
+ }
75
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Global Multilingual Programmatic SEO Slug Engine
3
+ * Enforces the 6 golden rules of SEO-optimized URL paths across 10+ languages:
4
+ * English, French, German, Spanish, Italian, Portuguese, Dutch, Russian, Swedish, Polish.
5
+ *
6
+ * 1. Short & focused (3-5 target keywords)
7
+ * 2. Hyphen (-) as the sole delimiter
8
+ * 3. 100% lowercase, diacritics/accents stripped
9
+ * 4. Stop words removed for 10+ international languages
10
+ * 5. Zero dates/numbers for evergreen content
11
+ * 6. Primary keyword positioned first
12
+ */
13
+
14
+ export const MULTILINGUAL_STOP_WORDS: Record<string, Set<string>> = {
15
+ // ๐Ÿ‡ฌ๐Ÿ‡ง English
16
+ en: new Set([
17
+ "the", "a", "an", "and", "or", "for", "with", "without", "in", "on", "at",
18
+ "by", "to", "of", "from", "as", "is", "are", "be", "this", "that", "these",
19
+ "those", "how", "what", "which", "who", "whom", "when", "where", "why",
20
+ "your", "my", "our", "their", "its", "into", "over", "after", "before",
21
+ ]),
22
+
23
+ // ๐Ÿ‡ซ๐Ÿ‡ท French
24
+ fr: new Set([
25
+ "le", "la", "les", "un", "une", "des", "du", "de", "d", "l", "pour", "avec",
26
+ "sans", "sur", "sous", "dans", "par", "et", "ou", "a", "au", "aux", "en",
27
+ "ce", "cet", "cette", "ces", "son", "sa", "ses", "leur", "leurs", "notre",
28
+ "votre", "nos", "vos", "qui", "que", "quoi", "dont", "comment", "pourquoi",
29
+ ]),
30
+
31
+ // ๐Ÿ‡ฉ๐Ÿ‡ช German
32
+ de: new Set([
33
+ "der", "die", "das", "ein", "eine", "eines", "einer", "einem", "einen",
34
+ "und", "oder", "fur", "fรผr", "mit", "ohne", "auf", "unter", "in", "im", "von",
35
+ "vom", "zu", "zum", "zur", "bei", "beim", "nach", "aus", "uber", "รผber", "vor",
36
+ "wie", "was", "wer", "warum", "ihr", "ihre", "sein", "seine", "mein",
37
+ ]),
38
+
39
+ // ๐Ÿ‡ช๐Ÿ‡ธ Spanish
40
+ es: new Set([
41
+ "el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "para",
42
+ "con", "sin", "en", "por", "y", "o", "a", "al", "este", "esta", "estos",
43
+ "estas", "su", "sus", "mi", "mis", "tu", "tus", "como", "que", "cual",
44
+ ]),
45
+
46
+ // ๐Ÿ‡ฎ๐Ÿ‡น Italian
47
+ it: new Set([
48
+ "il", "lo", "la", "i", "gli", "le", "un", "uno", "una", "un'", "di", "del",
49
+ "dello", "della", "dei", "degli", "delle", "a", "al", "allo", "alla", "ai",
50
+ "agli", "alle", "da", "dal", "in", "con", "su", "per", "tra", "fra", "e", "o",
51
+ "come", "cosa", "perche", "questo", "questa",
52
+ ]),
53
+
54
+ // ๐Ÿ‡ต๐Ÿ‡น Portuguese
55
+ pt: new Set([
56
+ "o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "do", "da", "dos",
57
+ "das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "e",
58
+ "ou", "como", "que", "qual", "seu", "sua", "seus", "suas", "este", "esta",
59
+ ]),
60
+
61
+ // ๐Ÿ‡ณ๐Ÿ‡ฑ Dutch
62
+ nl: new Set([
63
+ "de", "het", "een", "en", "of", "voor", "met", "zonder", "in", "op", "bij",
64
+ "van", "naar", "door", "over", "onder", "als", "hoe", "wat", "wie", "waarom",
65
+ "zijn", "haar", "hun", "onze", "uw",
66
+ ]),
67
+
68
+ // ๐Ÿ‡ท๐Ÿ‡บ Russian (Transliterated / Latinized)
69
+ ru: new Set([
70
+ "i", "v", "na", "s", "po", "dlya", "ot", "iz", "k", "o", "za", "kak",
71
+ "chto", "gde", "kogda", "pochemu", "eto", "etot", "eta", "eti", "ili",
72
+ ]),
73
+
74
+ // ๐Ÿ‡ธ๐Ÿ‡ช Swedish
75
+ sv: new Set([
76
+ "en", "ett", "den", "det", "de", "och", "eller", "for", "med", "utan",
77
+ "pa", "under", "i", "av", "till", "fran", "om", "hur", "vad", "varfor",
78
+ ]),
79
+
80
+ // ๐Ÿ‡ต๐Ÿ‡ฑ Polish
81
+ pl: new Set([
82
+ "i", "w", "we", "z", "ze", "na", "do", "dla", "od", "o", "po", "za", "jak",
83
+ "co", "gdzie", "dlaczego", "to", "ten", "ta", "te", "lub", "albo",
84
+ ]),
85
+ };
86
+
87
+ // Global merged stop words set
88
+ const ALL_STOP_WORDS = new Set<string>();
89
+ for (const langSet of Object.values(MULTILINGUAL_STOP_WORDS)) {
90
+ for (const word of langSet) {
91
+ ALL_STOP_WORDS.add(word);
92
+ }
93
+ }
94
+
95
+ export interface SlugOptions {
96
+ language?: string; // e.g. "en", "fr", "de", "es", "all"
97
+ removeStopWords?: boolean;
98
+ stripDates?: boolean;
99
+ maxWords?: number;
100
+ maxLength?: number;
101
+ }
102
+
103
+ /**
104
+ * Normalizes any text into a clean, canonical SEO slug with multilingual stop word filtering.
105
+ */
106
+ export function cleanSeoSlug(input: string, options: SlugOptions = {}): string {
107
+ const {
108
+ language = "en",
109
+ removeStopWords = true,
110
+ stripDates = true,
111
+ maxWords = 5,
112
+ maxLength = 75,
113
+ } = options;
114
+
115
+ if (!input || typeof input !== "string") {
116
+ return "page";
117
+ }
118
+
119
+ let text = input
120
+ .normalize("NFKD")
121
+ .replace(/[\u0300-\u036f]/g, "")
122
+ .toLowerCase()
123
+ .trim();
124
+
125
+ if (stripDates) {
126
+ // Strip 4-digit years (2010-2039) and transient top lists (e.g. "top 10")
127
+ text = text.replace(/\b(20[1-3][0-9])\b/g, "");
128
+ text = text.replace(/\btop\s*\d+\s*/g, "");
129
+ }
130
+
131
+ let tokens = text
132
+ .replace(/[^a-z0-9]+/g, " ")
133
+ .trim()
134
+ .split(/\s+/)
135
+ .filter(Boolean);
136
+
137
+ if (removeStopWords) {
138
+ const activeSet = language === "all" || !MULTILINGUAL_STOP_WORDS[language]
139
+ ? ALL_STOP_WORDS
140
+ : MULTILINGUAL_STOP_WORDS[language];
141
+
142
+ tokens = tokens.filter((token) => !activeSet.has(token) && token.length > 1);
143
+ }
144
+
145
+ tokens = tokens.slice(0, maxWords);
146
+ let result = tokens.join("-");
147
+
148
+ if (result.length > maxLength) {
149
+ result = result.substring(0, maxLength).replace(/-[^-]*$/, "");
150
+ }
151
+
152
+ return result || "page";
153
+ }
154
+
155
+ export interface SlugValidationResult {
156
+ isValid: boolean;
157
+ errors: string[];
158
+ }
159
+
160
+ /**
161
+ * Validates a slug against universal SEO golden rules.
162
+ */
163
+ export function validateSeoSlug(slug: string): SlugValidationResult {
164
+ const errors: string[] = [];
165
+
166
+ if (!slug || slug.length < 2) {
167
+ errors.push("Slug must contain at least 2 characters");
168
+ }
169
+ if (slug.length > 75) {
170
+ errors.push("Slug should not exceed 75 characters for optimal CTR");
171
+ }
172
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
173
+ errors.push("Slug must be 100% lowercase alphanumeric with single hyphen (-) separators");
174
+ }
175
+ if (/(?:20[1-3][0-9])/.test(slug)) {
176
+ errors.push("Evergreen slug should not contain yearly date stamps");
177
+ }
178
+
179
+ return {
180
+ isValid: errors.length === 0,
181
+ errors,
182
+ };
183
+ }