@prenta/core 0.123.0 → 1.0.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 (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/api/routes/seo-public.d.ts.map +1 -1
  3. package/dist/api/routes/seo-public.js +2 -0
  4. package/dist/api/routes/seo-public.js.map +1 -1
  5. package/dist/api/routes/seo.d.ts.map +1 -1
  6. package/dist/api/routes/seo.js +13 -1
  7. package/dist/api/routes/seo.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/page-builder/index.d.ts +0 -2
  13. package/dist/page-builder/index.d.ts.map +1 -1
  14. package/dist/page-builder/index.js +0 -2
  15. package/dist/page-builder/index.js.map +1 -1
  16. package/dist/sections/bridge.d.ts.map +1 -1
  17. package/dist/sections/bridge.js +11 -0
  18. package/dist/sections/bridge.js.map +1 -1
  19. package/dist/security/safe-fetch.d.ts.map +1 -1
  20. package/dist/security/safe-fetch.js +4 -3
  21. package/dist/security/safe-fetch.js.map +1 -1
  22. package/dist/seo/audit-engine.d.ts +3 -0
  23. package/dist/seo/audit-engine.d.ts.map +1 -1
  24. package/dist/seo/audit-engine.js +91 -23
  25. package/dist/seo/audit-engine.js.map +1 -1
  26. package/dist/seo/audit-runner.d.ts +9 -2
  27. package/dist/seo/audit-runner.d.ts.map +1 -1
  28. package/dist/seo/audit-runner.js +82 -22
  29. package/dist/seo/audit-runner.js.map +1 -1
  30. package/dist/seo/config-store.d.ts +1 -0
  31. package/dist/seo/config-store.d.ts.map +1 -1
  32. package/dist/seo/config-store.js +1 -0
  33. package/dist/seo/config-store.js.map +1 -1
  34. package/dist/seo/public-site-url.d.ts +4 -1
  35. package/dist/seo/public-site-url.d.ts.map +1 -1
  36. package/dist/seo/public-site-url.js +15 -12
  37. package/dist/seo/public-site-url.js.map +1 -1
  38. package/dist/seo/rendered-fetch.d.ts +60 -0
  39. package/dist/seo/rendered-fetch.d.ts.map +1 -0
  40. package/dist/seo/rendered-fetch.js +246 -0
  41. package/dist/seo/rendered-fetch.js.map +1 -0
  42. package/dist/seo/rendered-html.d.ts +8 -0
  43. package/dist/seo/rendered-html.d.ts.map +1 -0
  44. package/dist/seo/rendered-html.js +86 -0
  45. package/dist/seo/rendered-html.js.map +1 -0
  46. package/dist/seo/score.d.ts +6 -1
  47. package/dist/seo/score.d.ts.map +1 -1
  48. package/dist/seo/score.js +9 -4
  49. package/dist/seo/score.js.map +1 -1
  50. package/dist/upgrade/update-server.d.ts +15 -3
  51. package/dist/upgrade/update-server.d.ts.map +1 -1
  52. package/dist/upgrade/update-server.js +15 -3
  53. package/dist/upgrade/update-server.js.map +1 -1
  54. package/package.json +1 -1
  55. package/dist/page-builder/seo-analyzer.d.ts +0 -61
  56. package/dist/page-builder/seo-analyzer.d.ts.map +0 -1
  57. package/dist/page-builder/seo-analyzer.js +0 -778
  58. package/dist/page-builder/seo-analyzer.js.map +0 -1
@@ -1,778 +0,0 @@
1
- import { hasChildren } from './types.js';
2
- // ─── Helpers ───────────────────────────────────────────────────────────────
3
- export function stripHtml(html) {
4
- return html.replace(/<[^>]*>/g, '').trim();
5
- }
6
- export function countWords(text) {
7
- const trimmed = text.trim();
8
- if (trimmed.length === 0)
9
- return 0;
10
- return trimmed.split(/\s+/).length;
11
- }
12
- export function countSentences(text) {
13
- const matches = text.match(/[.!?]+/g);
14
- return matches ? matches.length : 1;
15
- }
16
- export function countSyllables(word) {
17
- const w = word.toLowerCase().replace(/[^a-z]/g, '');
18
- if (w.length <= 2)
19
- return 1;
20
- let count = 0;
21
- let prevVowel = false;
22
- for (let i = 0; i < w.length; i++) {
23
- const char = w[i];
24
- const isVowel = /[aeiouy]/.test(char);
25
- if (isVowel && !prevVowel) {
26
- count++;
27
- }
28
- prevVowel = isVowel;
29
- }
30
- if (w.endsWith('e') && count > 1) {
31
- count--;
32
- }
33
- return Math.max(count, 1);
34
- }
35
- function isInternalLink(url) {
36
- return url.startsWith('/') || url.startsWith('#');
37
- }
38
- function extractStringValues(data) {
39
- const parts = [];
40
- for (const value of Object.values(data)) {
41
- if (typeof value === 'string') {
42
- parts.push(stripHtml(value));
43
- }
44
- else if (Array.isArray(value)) {
45
- for (const item of value) {
46
- if (typeof item === 'string') {
47
- parts.push(stripHtml(item));
48
- }
49
- else if (item && typeof item === 'object') {
50
- parts.push(extractStringValues(item));
51
- }
52
- }
53
- }
54
- else if (value && typeof value === 'object') {
55
- parts.push(extractStringValues(value));
56
- }
57
- }
58
- return parts.filter(Boolean).join(' ');
59
- }
60
- // ─── Content Extractor ─────────────────────────────────────────────────────
61
- function extractBlockContent(block, content) {
62
- const { blockType } = block.settings;
63
- const data = block.data;
64
- const texts = [];
65
- switch (blockType) {
66
- case 'hero': {
67
- const title = typeof data.title === 'string' ? data.title : '';
68
- const subtitle = typeof data.subtitle === 'string' ? data.subtitle : '';
69
- const ctaText = typeof data.ctaText === 'string' ? data.ctaText : '';
70
- if (title)
71
- texts.push(title);
72
- if (subtitle)
73
- texts.push(subtitle);
74
- if (ctaText)
75
- texts.push(ctaText);
76
- if (title) {
77
- content.headings.push({ level: 1, text: title, blockId: block.id });
78
- }
79
- if (typeof data.image === 'string' && data.image) {
80
- content.images.push({
81
- src: data.image,
82
- alt: typeof data.alt === 'string' ? data.alt : '',
83
- blockId: block.id,
84
- });
85
- }
86
- if (typeof data.ctaLink === 'string' && data.ctaLink) {
87
- content.links.push({
88
- url: data.ctaLink,
89
- text: ctaText || 'CTA',
90
- internal: isInternalLink(data.ctaLink),
91
- blockId: block.id,
92
- });
93
- }
94
- break;
95
- }
96
- case 'text': {
97
- const heading = typeof data.heading === 'string' ? data.heading : '';
98
- const body = typeof data.body === 'string' ? stripHtml(data.body) : '';
99
- if (heading)
100
- texts.push(heading);
101
- if (body)
102
- texts.push(body);
103
- if (heading) {
104
- const level = typeof data.headingLevel === 'number' ? data.headingLevel : 2;
105
- content.headings.push({ level, text: heading, blockId: block.id });
106
- }
107
- break;
108
- }
109
- case 'image': {
110
- const alt = typeof data.alt === 'string' ? data.alt : '';
111
- const caption = typeof data.caption === 'string' ? data.caption : '';
112
- if (alt)
113
- texts.push(alt);
114
- if (caption)
115
- texts.push(caption);
116
- content.images.push({
117
- src: typeof data.src === 'string' ? data.src : '',
118
- alt,
119
- blockId: block.id,
120
- });
121
- if (typeof data.link === 'string' && data.link) {
122
- content.links.push({
123
- url: data.link,
124
- text: alt || caption || 'Image link',
125
- internal: isInternalLink(data.link),
126
- blockId: block.id,
127
- });
128
- }
129
- break;
130
- }
131
- case 'cards': {
132
- const items = Array.isArray(data.items) ? data.items : [];
133
- for (const item of items) {
134
- if (item && typeof item === 'object') {
135
- const cardItem = item;
136
- const title = typeof cardItem.title === 'string' ? cardItem.title : '';
137
- const desc = typeof cardItem.description === 'string' ? cardItem.description : '';
138
- if (title)
139
- texts.push(title);
140
- if (desc)
141
- texts.push(stripHtml(desc));
142
- if (typeof cardItem.link === 'string' && cardItem.link) {
143
- content.links.push({
144
- url: cardItem.link,
145
- text: title || 'Card link',
146
- internal: isInternalLink(cardItem.link),
147
- blockId: block.id,
148
- });
149
- }
150
- }
151
- }
152
- break;
153
- }
154
- case 'cta': {
155
- const heading = typeof data.heading === 'string' ? data.heading : '';
156
- const body = typeof data.body === 'string' ? stripHtml(data.body) : '';
157
- const buttonText = typeof data.buttonText === 'string' ? data.buttonText : '';
158
- if (heading)
159
- texts.push(heading);
160
- if (body)
161
- texts.push(body);
162
- if (buttonText)
163
- texts.push(buttonText);
164
- if (typeof data.buttonLink === 'string' && data.buttonLink) {
165
- content.links.push({
166
- url: data.buttonLink,
167
- text: buttonText || 'Button',
168
- internal: isInternalLink(data.buttonLink),
169
- blockId: block.id,
170
- });
171
- }
172
- break;
173
- }
174
- case 'video': {
175
- for (const value of Object.values(data)) {
176
- if (typeof value === 'string' && value) {
177
- texts.push(value);
178
- }
179
- }
180
- break;
181
- }
182
- case 'gallery': {
183
- const images = Array.isArray(data.images) ? data.images : [];
184
- for (const img of images) {
185
- if (img && typeof img === 'object') {
186
- const imgItem = img;
187
- const alt = typeof imgItem.alt === 'string' ? imgItem.alt : '';
188
- const caption = typeof imgItem.caption === 'string' ? imgItem.caption : '';
189
- if (alt)
190
- texts.push(alt);
191
- if (caption)
192
- texts.push(caption);
193
- content.images.push({
194
- src: typeof imgItem.src === 'string' ? imgItem.src : '',
195
- alt,
196
- blockId: block.id,
197
- });
198
- }
199
- }
200
- break;
201
- }
202
- case 'faq': {
203
- const items = Array.isArray(data.items) ? data.items : [];
204
- for (const item of items) {
205
- if (item && typeof item === 'object') {
206
- const faqItem = item;
207
- const question = typeof faqItem.question === 'string' ? faqItem.question : '';
208
- const answer = typeof faqItem.answer === 'string' ? stripHtml(faqItem.answer) : '';
209
- if (question)
210
- texts.push(question);
211
- if (answer)
212
- texts.push(answer);
213
- }
214
- }
215
- break;
216
- }
217
- case 'form': {
218
- const msg = typeof data.successMessage === 'string' ? data.successMessage : '';
219
- if (msg)
220
- texts.push(msg);
221
- break;
222
- }
223
- case 'code':
224
- break;
225
- default: {
226
- texts.push(extractStringValues(data));
227
- break;
228
- }
229
- }
230
- const blockText = texts.filter(Boolean).join(' ');
231
- if (blockText) {
232
- content.blockTexts.set(block.id, blockText);
233
- }
234
- }
235
- function traverseTree(node, content) {
236
- if (node.type === 'block') {
237
- extractBlockContent(node, content);
238
- return;
239
- }
240
- if (hasChildren(node)) {
241
- for (const child of node.children) {
242
- traverseTree(child, content);
243
- }
244
- }
245
- }
246
- export function extractContent(tree) {
247
- const content = {
248
- plainText: '',
249
- wordCount: 0,
250
- headings: [],
251
- images: [],
252
- links: [],
253
- blockTexts: new Map(),
254
- };
255
- traverseTree(tree, content);
256
- const allTexts = [];
257
- for (const text of content.blockTexts.values()) {
258
- allTexts.push(text);
259
- }
260
- content.plainText = allTexts.join(' ');
261
- content.wordCount = countWords(content.plainText);
262
- return content;
263
- }
264
- // ─── Readability Analyzer ──────────────────────────────────────────────────
265
- export function analyzeReadability(text) {
266
- const words = text.trim().split(/\s+/).filter(Boolean);
267
- const wordCount = words.length;
268
- if (wordCount === 0) {
269
- return {
270
- fleschScore: 0,
271
- avgSentenceLength: 0,
272
- wordCount: 0,
273
- readingTime: 0,
274
- passiveEstimate: 0,
275
- };
276
- }
277
- const sentenceCount = countSentences(text);
278
- const avgSentenceLength = wordCount / sentenceCount;
279
- let totalSyllables = 0;
280
- for (const word of words) {
281
- totalSyllables += countSyllables(word);
282
- }
283
- const fleschScore = Math.max(0, Math.min(100, 206.835 - 1.015 * avgSentenceLength - 84.6 * (totalSyllables / wordCount)));
284
- const readingTime = Math.ceil(wordCount / 200);
285
- const passivePattern = /\b(is|are|was|were|been|being|be)\s+\w+ed\b/gi;
286
- const passiveMatches = text.match(passivePattern);
287
- const passiveCount = passiveMatches ? passiveMatches.length : 0;
288
- const passiveEstimate = sentenceCount > 0 ? Math.round((passiveCount / sentenceCount) * 100) : 0;
289
- return {
290
- fleschScore: Math.round(fleschScore * 10) / 10,
291
- avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
292
- wordCount,
293
- readingTime,
294
- passiveEstimate: Math.min(passiveEstimate, 100),
295
- };
296
- }
297
- // ─── SEO Checker ───────────────────────────────────────────────────────────
298
- function keyphraseInText(text, keyphrase) {
299
- return text.toLowerCase().includes(keyphrase.toLowerCase());
300
- }
301
- function keyphraseCount(text, keyphrase) {
302
- const lower = text.toLowerCase();
303
- const kp = keyphrase.toLowerCase();
304
- let count = 0;
305
- let idx = 0;
306
- while ((idx = lower.indexOf(kp, idx)) !== -1) {
307
- count++;
308
- idx += kp.length;
309
- }
310
- return count;
311
- }
312
- function getFirstParagraph(content) {
313
- for (const text of content.blockTexts.values()) {
314
- if (text.trim().length > 0)
315
- return text;
316
- }
317
- return '';
318
- }
319
- export function analyzeSEO(tree, pageSettings) {
320
- const content = extractContent(tree);
321
- const readability = analyzeReadability(content.plainText);
322
- const checks = [];
323
- const perBlockHints = new Map();
324
- const kp = pageSettings.focusKeyphrase || '';
325
- const metaTitle = pageSettings.metaTitle || pageSettings.title || '';
326
- const metaDesc = pageSettings.metaDescription || '';
327
- // ── Page-level checks ───────────────────────────────────────────────────
328
- if (metaTitle.length === 0) {
329
- checks.push({
330
- id: 'meta-title-exists',
331
- label: 'Meta title',
332
- status: 'error',
333
- detail: 'Meta title is missing',
334
- });
335
- }
336
- else if (metaTitle.length < 30 || metaTitle.length > 60) {
337
- checks.push({
338
- id: 'meta-title-length',
339
- label: 'Meta title length',
340
- status: 'warning',
341
- detail: `Meta title is ${metaTitle.length} chars (recommended: 30-60)`,
342
- });
343
- }
344
- else {
345
- checks.push({
346
- id: 'meta-title-length',
347
- label: 'Meta title length',
348
- status: 'good',
349
- detail: `Meta title is ${metaTitle.length} chars`,
350
- });
351
- }
352
- if (metaDesc.length === 0) {
353
- checks.push({
354
- id: 'meta-desc-exists',
355
- label: 'Meta description',
356
- status: 'error',
357
- detail: 'Meta description is missing',
358
- });
359
- }
360
- else if (metaDesc.length < 120 || metaDesc.length > 160) {
361
- checks.push({
362
- id: 'meta-desc-length',
363
- label: 'Meta description length',
364
- status: 'warning',
365
- detail: `Meta description is ${metaDesc.length} chars (recommended: 120-160)`,
366
- });
367
- }
368
- else {
369
- checks.push({
370
- id: 'meta-desc-length',
371
- label: 'Meta description length',
372
- status: 'good',
373
- detail: `Meta description is ${metaDesc.length} chars`,
374
- });
375
- }
376
- if (!kp) {
377
- checks.push({
378
- id: 'keyphrase-set',
379
- label: 'Focus keyphrase',
380
- status: 'warning',
381
- detail: 'No focus keyphrase set',
382
- });
383
- }
384
- else {
385
- checks.push({
386
- id: 'keyphrase-set',
387
- label: 'Focus keyphrase',
388
- status: 'good',
389
- detail: `Focus keyphrase: "${kp}"`,
390
- });
391
- if (keyphraseInText(metaTitle, kp)) {
392
- checks.push({
393
- id: 'keyphrase-in-title',
394
- label: 'Keyphrase in title',
395
- status: 'good',
396
- detail: 'Focus keyphrase found in meta title',
397
- });
398
- }
399
- else {
400
- checks.push({
401
- id: 'keyphrase-in-title',
402
- label: 'Keyphrase in title',
403
- status: 'warning',
404
- detail: 'Focus keyphrase not found in meta title',
405
- });
406
- }
407
- if (keyphraseInText(metaDesc, kp)) {
408
- checks.push({
409
- id: 'keyphrase-in-desc',
410
- label: 'Keyphrase in description',
411
- status: 'good',
412
- detail: 'Focus keyphrase found in meta description',
413
- });
414
- }
415
- else {
416
- checks.push({
417
- id: 'keyphrase-in-desc',
418
- label: 'Keyphrase in description',
419
- status: 'warning',
420
- detail: 'Focus keyphrase not found in meta description',
421
- });
422
- }
423
- if (keyphraseInText(pageSettings.slug, kp)) {
424
- checks.push({
425
- id: 'keyphrase-in-slug',
426
- label: 'Keyphrase in slug',
427
- status: 'good',
428
- detail: 'Focus keyphrase found in URL slug',
429
- });
430
- }
431
- else {
432
- checks.push({
433
- id: 'keyphrase-in-slug',
434
- label: 'Keyphrase in slug',
435
- status: 'warning',
436
- detail: 'Focus keyphrase not found in URL slug',
437
- });
438
- }
439
- const firstPara = getFirstParagraph(content);
440
- if (keyphraseInText(firstPara, kp)) {
441
- checks.push({
442
- id: 'keyphrase-in-intro',
443
- label: 'Keyphrase in introduction',
444
- status: 'good',
445
- detail: 'Focus keyphrase found in first paragraph',
446
- });
447
- }
448
- else {
449
- checks.push({
450
- id: 'keyphrase-in-intro',
451
- label: 'Keyphrase in introduction',
452
- status: 'warning',
453
- detail: 'Focus keyphrase not found in first paragraph',
454
- });
455
- }
456
- const kpCount = keyphraseCount(content.plainText, kp);
457
- const density = content.wordCount > 0 ? ((kpCount * countWords(kp)) / content.wordCount) * 100 : 0;
458
- if (density >= 0.5 && density <= 3) {
459
- checks.push({
460
- id: 'keyphrase-density',
461
- label: 'Keyphrase density',
462
- status: 'good',
463
- detail: `Keyphrase density is ${density.toFixed(1)}%`,
464
- });
465
- }
466
- else if (density === 0) {
467
- checks.push({
468
- id: 'keyphrase-density',
469
- label: 'Keyphrase density',
470
- status: 'error',
471
- detail: 'Keyphrase not found in content',
472
- });
473
- }
474
- else {
475
- checks.push({
476
- id: 'keyphrase-density',
477
- label: 'Keyphrase density',
478
- status: 'warning',
479
- detail: `Keyphrase density is ${density.toFixed(1)}% (recommended: 0.5-3%)`,
480
- });
481
- }
482
- }
483
- if (content.wordCount >= 300) {
484
- checks.push({
485
- id: 'content-length',
486
- label: 'Content length',
487
- status: 'good',
488
- detail: `${content.wordCount} words`,
489
- });
490
- }
491
- else {
492
- checks.push({
493
- id: 'content-length',
494
- label: 'Content length',
495
- status: 'warning',
496
- detail: `Only ${content.wordCount} words (recommended: 300+)`,
497
- });
498
- }
499
- if (pageSettings.ogImage) {
500
- checks.push({
501
- id: 'og-image',
502
- label: 'Open Graph image',
503
- status: 'good',
504
- detail: 'OG image is set',
505
- });
506
- }
507
- else {
508
- checks.push({
509
- id: 'og-image',
510
- label: 'Open Graph image',
511
- status: 'warning',
512
- detail: 'No OG image set',
513
- });
514
- }
515
- if (pageSettings.schemaType) {
516
- checks.push({
517
- id: 'schema-type',
518
- label: 'Schema type',
519
- status: 'good',
520
- detail: `Schema type: ${pageSettings.schemaType}`,
521
- });
522
- }
523
- else {
524
- checks.push({
525
- id: 'schema-type',
526
- label: 'Schema type',
527
- status: 'warning',
528
- detail: 'No schema type set',
529
- });
530
- }
531
- // ── Heading structure ───────────────────────────────────────────────────
532
- const h1Count = content.headings.filter((h) => h.level === 1).length;
533
- if (h1Count === 0) {
534
- checks.push({
535
- id: 'h1-exists',
536
- label: 'H1 heading',
537
- status: 'error',
538
- detail: 'No H1 heading found',
539
- });
540
- }
541
- else if (h1Count === 1) {
542
- checks.push({
543
- id: 'h1-exists',
544
- label: 'H1 heading',
545
- status: 'good',
546
- detail: 'Single H1 heading found',
547
- });
548
- }
549
- else {
550
- checks.push({
551
- id: 'h1-exists',
552
- label: 'H1 heading',
553
- status: 'warning',
554
- detail: `Multiple H1 headings found (${h1Count})`,
555
- });
556
- }
557
- let hierarchyGood = true;
558
- const sortedHeadings = [...content.headings];
559
- for (let i = 1; i < sortedHeadings.length; i++) {
560
- const prev = sortedHeadings[i - 1];
561
- const curr = sortedHeadings[i];
562
- if (prev && curr && curr.level > prev.level + 1) {
563
- hierarchyGood = false;
564
- break;
565
- }
566
- }
567
- if (hierarchyGood) {
568
- checks.push({
569
- id: 'heading-hierarchy',
570
- label: 'Heading hierarchy',
571
- status: 'good',
572
- detail: 'Heading levels follow a logical order',
573
- });
574
- }
575
- else {
576
- checks.push({
577
- id: 'heading-hierarchy',
578
- label: 'Heading hierarchy',
579
- status: 'warning',
580
- detail: 'Heading hierarchy skips levels',
581
- });
582
- }
583
- if (kp && content.headings.some((h) => keyphraseInText(h.text, kp))) {
584
- checks.push({
585
- id: 'keyphrase-in-heading',
586
- label: 'Keyphrase in heading',
587
- status: 'good',
588
- detail: 'Focus keyphrase found in a heading',
589
- });
590
- }
591
- else if (kp) {
592
- checks.push({
593
- id: 'keyphrase-in-heading',
594
- label: 'Keyphrase in heading',
595
- status: 'warning',
596
- detail: 'Focus keyphrase not found in any heading',
597
- });
598
- }
599
- // ── Image checks ────────────────────────────────────────────────────────
600
- const imagesWithoutAlt = content.images.filter((img) => !img.alt);
601
- if (imagesWithoutAlt.length === 0 && content.images.length > 0) {
602
- checks.push({
603
- id: 'images-alt',
604
- label: 'Image alt text',
605
- status: 'good',
606
- detail: 'All images have alt text',
607
- });
608
- }
609
- else if (imagesWithoutAlt.length > 0) {
610
- checks.push({
611
- id: 'images-alt',
612
- label: 'Image alt text',
613
- status: 'error',
614
- detail: `${imagesWithoutAlt.length} image(s) missing alt text`,
615
- });
616
- }
617
- if (kp && content.images.some((img) => keyphraseInText(img.alt, kp))) {
618
- checks.push({
619
- id: 'keyphrase-in-alt',
620
- label: 'Keyphrase in image alt',
621
- status: 'good',
622
- detail: 'Focus keyphrase found in at least one image alt text',
623
- });
624
- }
625
- else if (kp && content.images.length > 0) {
626
- checks.push({
627
- id: 'keyphrase-in-alt',
628
- label: 'Keyphrase in image alt',
629
- status: 'warning',
630
- detail: 'Focus keyphrase not found in any image alt text',
631
- });
632
- }
633
- // ── Link checks ─────────────────────────────────────────────────────────
634
- const internalLinks = content.links.filter((l) => l.internal);
635
- const externalLinks = content.links.filter((l) => !l.internal);
636
- if (internalLinks.length > 0) {
637
- checks.push({
638
- id: 'internal-links',
639
- label: 'Internal links',
640
- status: 'good',
641
- detail: `${internalLinks.length} internal link(s)`,
642
- });
643
- }
644
- else {
645
- checks.push({
646
- id: 'internal-links',
647
- label: 'Internal links',
648
- status: 'warning',
649
- detail: 'No internal links found',
650
- });
651
- }
652
- if (externalLinks.length > 0) {
653
- checks.push({
654
- id: 'external-links',
655
- label: 'External links',
656
- status: 'good',
657
- detail: `${externalLinks.length} external link(s)`,
658
- });
659
- }
660
- else {
661
- checks.push({
662
- id: 'external-links',
663
- label: 'External links',
664
- status: 'warning',
665
- detail: 'No external links found',
666
- });
667
- }
668
- // ── Per-block hints ─────────────────────────────────────────────────────
669
- for (const img of content.images) {
670
- if (!img.alt) {
671
- addBlockHint(perBlockHints, img.blockId, {
672
- id: 'block-missing-alt',
673
- label: 'Missing alt text',
674
- status: 'error',
675
- detail: 'Missing alt text',
676
- blockId: img.blockId,
677
- });
678
- }
679
- else if (kp && !keyphraseInText(img.alt, kp)) {
680
- addBlockHint(perBlockHints, img.blockId, {
681
- id: 'block-alt-no-keyphrase',
682
- label: 'Alt text keyphrase',
683
- status: 'warning',
684
- detail: "Alt text doesn't contain focus keyphrase",
685
- blockId: img.blockId,
686
- });
687
- }
688
- }
689
- for (const [blockId, blockText] of content.blockTexts) {
690
- if (kp) {
691
- const heading = content.headings.find((h) => h.blockId === blockId && h.level === 1);
692
- if (heading && !keyphraseInText(heading.text, kp)) {
693
- addBlockHint(perBlockHints, blockId, {
694
- id: 'block-hero-no-keyphrase',
695
- label: 'Title keyphrase',
696
- status: 'warning',
697
- detail: "Title doesn't contain focus keyphrase",
698
- blockId,
699
- });
700
- }
701
- }
702
- if (countWords(blockText) > 300) {
703
- addBlockHint(perBlockHints, blockId, {
704
- id: 'block-paragraph-long',
705
- label: 'Paragraph length',
706
- status: 'warning',
707
- detail: 'Paragraph too long',
708
- blockId,
709
- });
710
- }
711
- }
712
- // Check for text blocks without headings
713
- traverseForTextBlockHints(tree, content, perBlockHints);
714
- // Check for video blocks without poster
715
- traverseForVideoHints(tree, perBlockHints);
716
- // ── Score calculation ───────────────────────────────────────────────────
717
- let totalScore = 0;
718
- for (const check of checks) {
719
- if (check.status === 'good')
720
- totalScore += 100;
721
- else if (check.status === 'warning')
722
- totalScore += 50;
723
- }
724
- const score = checks.length > 0 ? Math.round(totalScore / checks.length) : 0;
725
- return { score, checks, readability, content, perBlockHints };
726
- }
727
- // ─── Internal Utilities ────────────────────────────────────────────────────
728
- function addBlockHint(map, blockId, hint) {
729
- const existing = map.get(blockId);
730
- if (existing) {
731
- existing.push(hint);
732
- }
733
- else {
734
- map.set(blockId, [hint]);
735
- }
736
- }
737
- function traverseForTextBlockHints(node, content, perBlockHints) {
738
- if (node.type === 'block') {
739
- if (node.settings.blockType === 'text') {
740
- const hasHeading = content.headings.some((h) => h.blockId === node.id);
741
- if (!hasHeading) {
742
- addBlockHint(perBlockHints, node.id, {
743
- id: 'block-no-heading',
744
- label: 'Section heading',
745
- status: 'warning',
746
- detail: 'No heading in this section',
747
- blockId: node.id,
748
- });
749
- }
750
- }
751
- return;
752
- }
753
- if (hasChildren(node)) {
754
- for (const child of node.children) {
755
- traverseForTextBlockHints(child, content, perBlockHints);
756
- }
757
- }
758
- }
759
- function traverseForVideoHints(node, perBlockHints) {
760
- if (node.type === 'block') {
761
- if (node.settings.blockType === 'video' && !node.data.poster) {
762
- addBlockHint(perBlockHints, node.id, {
763
- id: 'block-video-no-poster',
764
- label: 'Video poster',
765
- status: 'warning',
766
- detail: 'No poster image set',
767
- blockId: node.id,
768
- });
769
- }
770
- return;
771
- }
772
- if (hasChildren(node)) {
773
- for (const child of node.children) {
774
- traverseForVideoHints(child, perBlockHints);
775
- }
776
- }
777
- }
778
- //# sourceMappingURL=seo-analyzer.js.map