@adsim/wordpress-mcp-server 3.1.0 → 4.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 (34) hide show
  1. package/README.md +564 -176
  2. package/dxt/manifest.json +93 -9
  3. package/index.js +3624 -36
  4. package/package.json +1 -1
  5. package/src/confirmationToken.js +64 -0
  6. package/src/contentAnalyzer.js +476 -0
  7. package/src/htmlParser.js +80 -0
  8. package/src/linkUtils.js +158 -0
  9. package/src/pluginDetector.js +158 -0
  10. package/src/utils/contentCompressor.js +116 -0
  11. package/src/woocommerceClient.js +88 -0
  12. package/tests/unit/contentAnalyzer.test.js +397 -0
  13. package/tests/unit/pluginDetector.test.js +167 -0
  14. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  15. package/tests/unit/tools/approval.test.js +251 -0
  16. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  17. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  18. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  19. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  20. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  21. package/tests/unit/tools/contentCompressor.test.js +320 -0
  22. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  23. package/tests/unit/tools/destructive.test.js +246 -0
  24. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  25. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  26. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  27. package/tests/unit/tools/findThinContent.test.js +145 -0
  28. package/tests/unit/tools/internalLinks.test.js +283 -0
  29. package/tests/unit/tools/perTargetControls.test.js +228 -0
  30. package/tests/unit/tools/pluginIntelligence.test.js +864 -0
  31. package/tests/unit/tools/site.test.js +6 -1
  32. package/tests/unit/tools/woocommerce.test.js +344 -0
  33. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  34. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,397 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ calculateReadabilityScore,
4
+ countSyllablesFr,
5
+ extractTransitionWords,
6
+ countPassiveSentences,
7
+ extractHeadingsOutline,
8
+ detectContentSections
9
+ } from '../../src/contentAnalyzer.js';
10
+
11
+ // =========================================================================
12
+ // countSyllablesFr
13
+ // =========================================================================
14
+
15
+ describe('countSyllablesFr', () => {
16
+ it('counts syllables for common French words', () => {
17
+ expect(countSyllablesFr('maison')).toBe(2);
18
+ expect(countSyllablesFr('a')).toBe(1);
19
+ expect(countSyllablesFr('le')).toBe(1);
20
+ });
21
+
22
+ it('handles accented vowels', () => {
23
+ expect(countSyllablesFr('éléphant')).toBe(3);
24
+ expect(countSyllablesFr('château')).toBe(2);
25
+ });
26
+
27
+ it('returns 1 for single-syllable words', () => {
28
+ expect(countSyllablesFr('chat')).toBe(1);
29
+ expect(countSyllablesFr('bras')).toBe(1);
30
+ });
31
+
32
+ it('returns 0 for empty/null input', () => {
33
+ expect(countSyllablesFr('')).toBe(0);
34
+ expect(countSyllablesFr(null)).toBe(0);
35
+ expect(countSyllablesFr(undefined)).toBe(0);
36
+ });
37
+
38
+ it('minimum 1 syllable for any non-empty word', () => {
39
+ expect(countSyllablesFr('x')).toBe(1);
40
+ expect(countSyllablesFr('bcd')).toBe(1);
41
+ });
42
+ });
43
+
44
+ // =========================================================================
45
+ // calculateReadabilityScore
46
+ // =========================================================================
47
+
48
+ describe('calculateReadabilityScore', () => {
49
+ it('returns high score for simple short sentences', () => {
50
+ const html = '<p>Le chat est petit. Il dort bien. Il mange.</p>';
51
+ const result = calculateReadabilityScore(html);
52
+ expect(result.score).toBeGreaterThan(60);
53
+ expect(result.level).toMatch(/facile/);
54
+ expect(result.words).toBeGreaterThan(0);
55
+ expect(result.sentences).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('returns lower score for complex long sentences', () => {
59
+ const complex = '<p>La problématique fondamentale de la conceptualisation épistémologique contemporaine réside dans la difficulté intrinsèque de réconcilier les paradigmes herméneutiques avec les méthodologies quantitatives traditionnellement utilisées dans les sciences sociales et les humanités numériques.</p>';
60
+ const result = calculateReadabilityScore(complex);
61
+ expect(result.score).toBeLessThan(50);
62
+ });
63
+
64
+ it('returns all required fields', () => {
65
+ const result = calculateReadabilityScore('<p>Un texte simple.</p>');
66
+ expect(result).toHaveProperty('score');
67
+ expect(result).toHaveProperty('sentences');
68
+ expect(result).toHaveProperty('words');
69
+ expect(result).toHaveProperty('syllables');
70
+ expect(result).toHaveProperty('avg_words_per_sentence');
71
+ expect(result).toHaveProperty('avg_syllables_per_word');
72
+ expect(result).toHaveProperty('level');
73
+ });
74
+
75
+ it('score clamped between 0 and 100', () => {
76
+ const result = calculateReadabilityScore('<p>Mot.</p>');
77
+ expect(result.score).toBeGreaterThanOrEqual(0);
78
+ expect(result.score).toBeLessThanOrEqual(100);
79
+ });
80
+
81
+ it('handles empty HTML', () => {
82
+ const result = calculateReadabilityScore('');
83
+ expect(result.words).toBeLessThanOrEqual(1);
84
+ });
85
+ });
86
+
87
+ // =========================================================================
88
+ // extractTransitionWords
89
+ // =========================================================================
90
+
91
+ describe('extractTransitionWords', () => {
92
+ it('finds transition words in French text', () => {
93
+ const text = 'Le produit est bon. Cependant, il est cher. De plus, la livraison est lente. En effet, le délai est trop long.';
94
+ const result = extractTransitionWords(text);
95
+ expect(result.count).toBeGreaterThan(0);
96
+ expect(result.words_found).toContain('cependant');
97
+ expect(result.words_found).toContain('de plus');
98
+ expect(result.words_found).toContain('en effet');
99
+ });
100
+
101
+ it('returns density relative to sentence count', () => {
102
+ const text = 'Premièrement, on analyse. Ensuite, on conclut.';
103
+ const result = extractTransitionWords(text);
104
+ expect(result.density).toBeGreaterThan(0);
105
+ expect(typeof result.density).toBe('number');
106
+ });
107
+
108
+ it('returns empty for text without transitions', () => {
109
+ const text = 'Le chat dort. Il mange. Il joue.';
110
+ const result = extractTransitionWords(text);
111
+ expect(result.count).toBe(0);
112
+ expect(result.words_found).toHaveLength(0);
113
+ });
114
+
115
+ it('returns empty for null/empty input', () => {
116
+ expect(extractTransitionWords('')).toEqual({ count: 0, density: 0, words_found: [] });
117
+ expect(extractTransitionWords(null)).toEqual({ count: 0, density: 0, words_found: [] });
118
+ });
119
+ });
120
+
121
+ // =========================================================================
122
+ // countPassiveSentences
123
+ // =========================================================================
124
+
125
+ describe('countPassiveSentences', () => {
126
+ it('detects French passive voice', () => {
127
+ const text = 'Le rapport est rédigé par le comité. La décision a été prise hier.';
128
+ const result = countPassiveSentences(text);
129
+ expect(result.count).toBeGreaterThan(0);
130
+ expect(result.ratio).toBeGreaterThan(0);
131
+ });
132
+
133
+ it('returns zero for active voice text', () => {
134
+ const text = 'Le chat mange la souris. Le chien court vite.';
135
+ const result = countPassiveSentences(text);
136
+ expect(result.count).toBe(0);
137
+ });
138
+
139
+ it('returns all required fields', () => {
140
+ const result = countPassiveSentences('Une phrase active.');
141
+ expect(result).toHaveProperty('count');
142
+ expect(result).toHaveProperty('total_sentences');
143
+ expect(result).toHaveProperty('ratio');
144
+ });
145
+
146
+ it('handles empty input', () => {
147
+ const result = countPassiveSentences('');
148
+ expect(result.count).toBe(0);
149
+ expect(result.total_sentences).toBe(0);
150
+ });
151
+ });
152
+
153
+ // =========================================================================
154
+ // extractHeadingsOutline
155
+ // =========================================================================
156
+
157
+ describe('extractHeadingsOutline', () => {
158
+ it('extracts H1-H6 headings', () => {
159
+ const html = '<h1>Title</h1><h2>Section A</h2><h3>Sub A.1</h3><h2>Section B</h2>';
160
+ const result = extractHeadingsOutline(html);
161
+ expect(result).toHaveLength(4);
162
+ expect(result[0]).toEqual({ level: 1, text: 'Title' });
163
+ expect(result[1]).toEqual({ level: 2, text: 'Section A' });
164
+ expect(result[2]).toEqual({ level: 3, text: 'Sub A.1' });
165
+ expect(result[3]).toEqual({ level: 2, text: 'Section B' });
166
+ });
167
+
168
+ it('returns empty array for no headings', () => {
169
+ expect(extractHeadingsOutline('<p>Just text</p>')).toEqual([]);
170
+ });
171
+
172
+ it('handles empty/null input', () => {
173
+ expect(extractHeadingsOutline('')).toEqual([]);
174
+ expect(extractHeadingsOutline(null)).toEqual([]);
175
+ });
176
+ });
177
+
178
+ // =========================================================================
179
+ // detectContentSections
180
+ // =========================================================================
181
+
182
+ describe('detectContentSections', () => {
183
+ it('detects intro before first H2', () => {
184
+ const html = '<p>This is a long enough introduction paragraph with enough content.</p><h2>First Section</h2><p>Body.</p>';
185
+ const result = detectContentSections(html);
186
+ expect(result.has_intro).toBe(true);
187
+ });
188
+
189
+ it('detects conclusion heading', () => {
190
+ const html = '<h2>Introduction</h2><p>Text.</p><h2>Conclusion</h2><p>Final.</p>';
191
+ const result = detectContentSections(html);
192
+ expect(result.has_conclusion).toBe(true);
193
+ });
194
+
195
+ it('detects FAQ section', () => {
196
+ const html = '<h2>FAQ</h2><p>Question?</p>';
197
+ const result = detectContentSections(html);
198
+ expect(result.has_faq).toBe(true);
199
+ });
200
+
201
+ it('counts lists, tables, images', () => {
202
+ const html = '<ul><li>A</li></ul><ol><li>B</li></ol><table><tr><td>C</td></tr></table><img src="x.jpg"><img src="y.jpg">';
203
+ const result = detectContentSections(html);
204
+ expect(result.lists_count).toBe(2);
205
+ expect(result.tables_count).toBe(1);
206
+ expect(result.images_count).toBe(2);
207
+ });
208
+
209
+ it('returns correct headings_count', () => {
210
+ const html = '<h2>A</h2><h3>B</h3><h2>C</h2>';
211
+ const result = detectContentSections(html);
212
+ expect(result.headings_count).toBe(3);
213
+ });
214
+
215
+ it('handles empty input', () => {
216
+ const result = detectContentSections('');
217
+ expect(result.has_intro).toBe(false);
218
+ expect(result.has_conclusion).toBe(false);
219
+ expect(result.has_faq).toBe(false);
220
+ expect(result.lists_count).toBe(0);
221
+ });
222
+
223
+ it('no intro when H2 is at the start', () => {
224
+ const html = '<h2>Section</h2><p>Body text here.</p>';
225
+ const result = detectContentSections(html);
226
+ expect(result.has_intro).toBe(false);
227
+ });
228
+ });
229
+
230
+ // =========================================================================
231
+ // TF-IDF: buildTFIDFVectors, computeCosineSimilarity, findDuplicatePairs
232
+ // =========================================================================
233
+
234
+ import {
235
+ buildTFIDFVectors,
236
+ computeCosineSimilarity,
237
+ findDuplicatePairs,
238
+ extractEntities,
239
+ computeTextDiff
240
+ } from '../../src/contentAnalyzer.js';
241
+
242
+ describe('buildTFIDFVectors', () => {
243
+ it('builds vectors with correct terms from 2 documents', () => {
244
+ const docs = [
245
+ { id: 1, text: 'marketing digital stratégie entreprise marketing' },
246
+ { id: 2, text: 'cuisine française recette traditionnelle cuisine' }
247
+ ];
248
+ const { vectors, terms } = buildTFIDFVectors(docs);
249
+ expect(vectors.size).toBe(2);
250
+ expect(terms.size).toBeGreaterThan(0);
251
+ expect(vectors.get(1).has('marketing')).toBe(true);
252
+ expect(vectors.get(2).has('cuisine')).toBe(true);
253
+ });
254
+
255
+ it('filters French stop words', () => {
256
+ const docs = [{ id: 1, text: 'les des une pour dans par sur avec marketing' }];
257
+ const { vectors } = buildTFIDFVectors(docs);
258
+ const vec = vectors.get(1);
259
+ expect(vec.has('les')).toBe(false);
260
+ expect(vec.has('des')).toBe(false);
261
+ expect(vec.has('marketing')).toBe(true);
262
+ });
263
+
264
+ it('filters tokens shorter than 3 characters', () => {
265
+ const docs = [{ id: 1, text: 'le un marketing de la stratégie' }];
266
+ const { vectors } = buildTFIDFVectors(docs);
267
+ const vec = vectors.get(1);
268
+ expect(vec.has('le')).toBe(false);
269
+ expect(vec.has('un')).toBe(false);
270
+ expect(vec.has('marketing')).toBe(true);
271
+ });
272
+ });
273
+
274
+ describe('computeCosineSimilarity', () => {
275
+ it('returns 1.0 for identical vectors', () => {
276
+ const vec = new Map([['marketing', 0.5], ['digital', 0.3]]);
277
+ const sim = computeCosineSimilarity(vec, vec);
278
+ expect(sim).toBeCloseTo(1.0, 5);
279
+ });
280
+
281
+ it('returns 0.0 for orthogonal vectors', () => {
282
+ const vec1 = new Map([['marketing', 0.5]]);
283
+ const vec2 = new Map([['cuisine', 0.5]]);
284
+ const sim = computeCosineSimilarity(vec1, vec2);
285
+ expect(sim).toBe(0);
286
+ });
287
+
288
+ it('returns 0.0 for empty vector', () => {
289
+ const vec1 = new Map();
290
+ const vec2 = new Map([['marketing', 0.5]]);
291
+ expect(computeCosineSimilarity(vec1, vec2)).toBe(0);
292
+ expect(computeCosineSimilarity(null, vec2)).toBe(0);
293
+ });
294
+ });
295
+
296
+ describe('findDuplicatePairs', () => {
297
+ it('finds pair when two texts are similar', () => {
298
+ const docs = [
299
+ { id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement naturel visibilité clients campagne publicitaire conversion' },
300
+ { id: 2, title: 'B', text: 'marketing digital stratégie société contenu référencement naturel visibilité clients campagne publicitaire résultats' },
301
+ { id: 3, title: 'C', text: 'cuisine française recette traditionnelle plats gastronomie ingrédients frais terroir culinaire desserts pâtisserie' }
302
+ ];
303
+ const pairs = findDuplicatePairs(docs, 0.5);
304
+ expect(pairs.length).toBeGreaterThanOrEqual(1);
305
+ const hasPair12 = pairs.some(p => (p.doc1_id === 1 && p.doc2_id === 2) || (p.doc1_id === 2 && p.doc2_id === 1));
306
+ expect(hasPair12).toBe(true);
307
+ });
308
+
309
+ it('returns 0 pairs for completely different texts', () => {
310
+ const docs = [
311
+ { id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement' },
312
+ { id: 2, title: 'B', text: 'cuisine française recette traditionnelle plats gastronomie' }
313
+ ];
314
+ const pairs = findDuplicatePairs(docs, 0.7);
315
+ expect(pairs).toHaveLength(0);
316
+ });
317
+ });
318
+
319
+ // =========================================================================
320
+ // extractEntities
321
+ // =========================================================================
322
+
323
+ describe('extractEntities', () => {
324
+ it('detects named entities from text', () => {
325
+ const text = 'Le site utilise Google Analytics pour le suivi. À Bruxelles, Marie Martin gère le projet.';
326
+ const entities = extractEntities(text);
327
+ expect(entities.length).toBeGreaterThan(0);
328
+ const names = entities.map(e => e.name);
329
+ expect(names).toContain('Google Analytics');
330
+ });
331
+
332
+ it('filters sentence-start common words from exclusion list', () => {
333
+ const text = 'Le chat dort. Cette maison est belle. Pour les clients, WordPress est important.';
334
+ const entities = extractEntities(text);
335
+ const names = entities.map(e => e.name);
336
+ expect(names).not.toContain('Le');
337
+ expect(names).not.toContain('Cette');
338
+ expect(names).not.toContain('Pour');
339
+ });
340
+
341
+ it('groups consecutive capitalized words', () => {
342
+ const text = 'Nous utilisons Google Cloud Platform pour héberger le site.';
343
+ const entities = extractEntities(text);
344
+ const names = entities.map(e => e.name);
345
+ expect(names).toContain('Google Cloud Platform');
346
+ });
347
+
348
+ it('classifies brand, location, person correctly', () => {
349
+ const text = 'Le site WordPress est hébergé en Belgique. Marie Martin gère le contenu.';
350
+ const entities = extractEntities(text);
351
+ const wp = entities.find(e => e.name === 'WordPress');
352
+ expect(wp).toBeDefined();
353
+ expect(wp.type).toBe('brand');
354
+ const be = entities.find(e => e.name === 'Belgique');
355
+ expect(be).toBeDefined();
356
+ expect(be.type).toBe('location');
357
+ const mm = entities.find(e => e.name === 'Marie Martin');
358
+ expect(mm).toBeDefined();
359
+ expect(mm.type).toBe('person');
360
+ });
361
+
362
+ it('returns empty array for text without entities', () => {
363
+ const text = 'le chat dort sur le canapé.';
364
+ const entities = extractEntities(text);
365
+ expect(entities).toHaveLength(0);
366
+ });
367
+ });
368
+
369
+ // =========================================================================
370
+ // computeTextDiff
371
+ // =========================================================================
372
+
373
+ describe('computeTextDiff', () => {
374
+ it('returns zero changes for identical texts', () => {
375
+ const text = 'Ligne un\nLigne deux\nLigne trois';
376
+ const diff = computeTextDiff(text, text);
377
+ expect(diff.change_ratio).toBe(0);
378
+ expect(diff.lines_added).toBe(0);
379
+ expect(diff.lines_removed).toBe(0);
380
+ });
381
+
382
+ it('returns high change_ratio for completely different texts', () => {
383
+ const a = 'Première ligne\nDeuxième ligne';
384
+ const b = 'Tout autre contenu\nRien en commun';
385
+ const diff = computeTextDiff(a, b);
386
+ expect(diff.change_ratio).toBeGreaterThan(0.9);
387
+ });
388
+
389
+ it('detects added lines correctly', () => {
390
+ const a = 'Ligne un\nLigne deux';
391
+ const b = 'Ligne un\nLigne deux\nLigne trois\nLigne quatre';
392
+ const diff = computeTextDiff(a, b);
393
+ expect(diff.lines_added).toBe(2);
394
+ expect(diff.lines_removed).toBe(0);
395
+ expect(diff.lines_unchanged).toBe(2);
396
+ });
397
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ detectSeoPlugin,
4
+ _clearPluginCache,
5
+ getRenderedHead,
6
+ parseRenderedHead
7
+ } from '../../src/pluginDetector.js';
8
+
9
+ // =========================================================================
10
+ // detectSeoPlugin
11
+ // =========================================================================
12
+
13
+ describe('detectSeoPlugin', () => {
14
+ beforeEach(() => { _clearPluginCache(); });
15
+
16
+ function mockFetch(namespaces, ok = true) {
17
+ let callCount = 0;
18
+ return (..._args) => {
19
+ callCount++;
20
+ return Promise.resolve({
21
+ ok,
22
+ status: ok ? 200 : 500,
23
+ json: () => Promise.resolve({ namespaces })
24
+ });
25
+ };
26
+ }
27
+
28
+ it('detects RankMath from namespaces', async () => {
29
+ const fn = mockFetch(['wp/v2', 'rankmath/v1', 'oembed/1.0']);
30
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('rankmath');
31
+ });
32
+
33
+ it('detects Yoast from namespaces', async () => {
34
+ const fn = mockFetch(['wp/v2', 'yoast/v1']);
35
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('yoast');
36
+ });
37
+
38
+ it('detects SEOPress from namespaces', async () => {
39
+ const fn = mockFetch(['wp/v2', 'seopress/v1']);
40
+ expect(await detectSeoPlugin('https://site.com', fn)).toBe('seopress');
41
+ });
42
+
43
+ it('returns null when no SEO plugin namespace found', async () => {
44
+ const fn = mockFetch(['wp/v2', 'oembed/1.0']);
45
+ expect(await detectSeoPlugin('https://site.com', fn)).toBeNull();
46
+ });
47
+
48
+ it('uses cache on second call (no extra fetch)', async () => {
49
+ let calls = 0;
50
+ const fn = () => { calls++; return Promise.resolve({ ok: true, json: () => Promise.resolve({ namespaces: ['rankmath/v1'] }) }); };
51
+ await detectSeoPlugin('https://cached.com', fn);
52
+ await detectSeoPlugin('https://cached.com', fn);
53
+ expect(calls).toBe(1);
54
+ });
55
+
56
+ it('returns null on fetch error', async () => {
57
+ const fn = () => Promise.reject(new Error('Network error'));
58
+ expect(await detectSeoPlugin('https://fail.com', fn)).toBeNull();
59
+ });
60
+ });
61
+
62
+ // =========================================================================
63
+ // getRenderedHead
64
+ // =========================================================================
65
+
66
+ describe('getRenderedHead', () => {
67
+ it('calls RankMath getHead endpoint', async () => {
68
+ let calledUrl;
69
+ const fn = (url) => {
70
+ calledUrl = url;
71
+ return Promise.resolve({
72
+ ok: true,
73
+ json: () => Promise.resolve({ success: true, head: '<title>Test</title>' })
74
+ });
75
+ };
76
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'rankmath', fn, 'dTpw');
77
+ expect(calledUrl).toContain('rankmath/v1/getHead');
78
+ expect(res.success).toBe(true);
79
+ expect(res.head).toBe('<title>Test</title>');
80
+ expect(res.plugin).toBe('rankmath');
81
+ });
82
+
83
+ it('calls Yoast get_head endpoint', async () => {
84
+ let calledUrl;
85
+ const fn = (url) => {
86
+ calledUrl = url;
87
+ return Promise.resolve({
88
+ ok: true,
89
+ json: () => Promise.resolve({ html: '<title>Yoast</title>', json: { title: 'Yoast' } })
90
+ });
91
+ };
92
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'yoast', fn, 'dTpw');
93
+ expect(calledUrl).toContain('yoast/v1/get_head');
94
+ expect(res.success).toBe(true);
95
+ expect(res.head).toBe('<title>Yoast</title>');
96
+ expect(res.plugin).toBe('yoast');
97
+ });
98
+
99
+ it('returns error for unsupported plugin', async () => {
100
+ const fn = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
101
+ const res = await getRenderedHead('https://site.com', 'https://site.com/hello/', 'seopress', fn, 'dTpw');
102
+ expect(res.success).toBe(false);
103
+ expect(res.error).toContain('does not support');
104
+ });
105
+ });
106
+
107
+ // =========================================================================
108
+ // parseRenderedHead
109
+ // =========================================================================
110
+
111
+ describe('parseRenderedHead', () => {
112
+ const fullHead = [
113
+ '<title>Mon Article Test | MonSite</title>',
114
+ '<meta name="description" content="Description rendue par RankMath" />',
115
+ '<link rel="canonical" href="https://example.com/mon-article-test" />',
116
+ '<meta name="robots" content="index, follow" />',
117
+ '<meta property="og:title" content="Mon Article Test" />',
118
+ '<meta property="og:description" content="OG Description" />',
119
+ '<meta property="og:image" content="https://example.com/image.jpg" />',
120
+ '<meta property="og:type" content="article" />',
121
+ '<meta name="twitter:card" content="summary_large_image" />',
122
+ '<meta name="twitter:title" content="Mon Article Test" />',
123
+ '<meta name="twitter:description" content="Twitter Desc" />',
124
+ '<meta name="twitter:image" content="https://example.com/tw.jpg" />',
125
+ '<script type="application/ld+json">{"@type":"Article","headline":"Mon Article Test"}</script>'
126
+ ].join('\n');
127
+
128
+ it('extracts all fields from a complete head', () => {
129
+ const r = parseRenderedHead(fullHead);
130
+ expect(r.title).toBe('Mon Article Test | MonSite');
131
+ expect(r.meta_description).toBe('Description rendue par RankMath');
132
+ expect(r.canonical).toBe('https://example.com/mon-article-test');
133
+ expect(r.robots).toBe('index, follow');
134
+ expect(r.og_title).toBe('Mon Article Test');
135
+ expect(r.og_description).toBe('OG Description');
136
+ expect(r.og_image).toBe('https://example.com/image.jpg');
137
+ expect(r.og_type).toBe('article');
138
+ expect(r.twitter_card).toBe('summary_large_image');
139
+ expect(r.twitter_title).toBe('Mon Article Test');
140
+ expect(r.twitter_description).toBe('Twitter Desc');
141
+ expect(r.twitter_image).toBe('https://example.com/tw.jpg');
142
+ expect(r.schema_json_ld).toHaveLength(1);
143
+ expect(r.schema_json_ld[0]['@type']).toBe('Article');
144
+ });
145
+
146
+ it('handles minimal head (title only)', () => {
147
+ const r = parseRenderedHead('<title>Simple</title>');
148
+ expect(r.title).toBe('Simple');
149
+ expect(r.meta_description).toBeNull();
150
+ expect(r.canonical).toBeNull();
151
+ expect(r.schema_json_ld).toHaveLength(0);
152
+ });
153
+
154
+ it('returns all nulls for empty string', () => {
155
+ const r = parseRenderedHead('');
156
+ expect(r.title).toBeNull();
157
+ expect(r.meta_description).toBeNull();
158
+ expect(r.canonical).toBeNull();
159
+ expect(r.schema_json_ld).toHaveLength(0);
160
+ });
161
+
162
+ it('ignores malformed JSON-LD', () => {
163
+ const html = '<script type="application/ld+json">{invalid json}</script>';
164
+ const r = parseRenderedHead(html);
165
+ expect(r.schema_json_ld).toHaveLength(0);
166
+ });
167
+ });