recker 1.0.27 → 1.0.28-next.9eb3868

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 (64) hide show
  1. package/dist/browser/scrape/extractors.js +2 -1
  2. package/dist/browser/scrape/types.d.ts +2 -1
  3. package/dist/cli/index.js +142 -3
  4. package/dist/cli/tui/shell.d.ts +1 -0
  5. package/dist/cli/tui/shell.js +157 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/scrape/extractors.js +2 -1
  9. package/dist/scrape/types.d.ts +2 -1
  10. package/dist/seo/analyzer.d.ts +42 -0
  11. package/dist/seo/analyzer.js +727 -0
  12. package/dist/seo/index.d.ts +5 -0
  13. package/dist/seo/index.js +2 -0
  14. package/dist/seo/rules/accessibility.d.ts +2 -0
  15. package/dist/seo/rules/accessibility.js +128 -0
  16. package/dist/seo/rules/content.d.ts +2 -0
  17. package/dist/seo/rules/content.js +236 -0
  18. package/dist/seo/rules/crawl.d.ts +2 -0
  19. package/dist/seo/rules/crawl.js +307 -0
  20. package/dist/seo/rules/cwv.d.ts +2 -0
  21. package/dist/seo/rules/cwv.js +337 -0
  22. package/dist/seo/rules/ecommerce.d.ts +2 -0
  23. package/dist/seo/rules/ecommerce.js +252 -0
  24. package/dist/seo/rules/i18n.d.ts +2 -0
  25. package/dist/seo/rules/i18n.js +222 -0
  26. package/dist/seo/rules/images.d.ts +2 -0
  27. package/dist/seo/rules/images.js +180 -0
  28. package/dist/seo/rules/index.d.ts +52 -0
  29. package/dist/seo/rules/index.js +141 -0
  30. package/dist/seo/rules/internal-linking.d.ts +2 -0
  31. package/dist/seo/rules/internal-linking.js +375 -0
  32. package/dist/seo/rules/links.d.ts +2 -0
  33. package/dist/seo/rules/links.js +150 -0
  34. package/dist/seo/rules/local.d.ts +2 -0
  35. package/dist/seo/rules/local.js +265 -0
  36. package/dist/seo/rules/meta.d.ts +2 -0
  37. package/dist/seo/rules/meta.js +523 -0
  38. package/dist/seo/rules/mobile.d.ts +2 -0
  39. package/dist/seo/rules/mobile.js +71 -0
  40. package/dist/seo/rules/performance.d.ts +2 -0
  41. package/dist/seo/rules/performance.js +246 -0
  42. package/dist/seo/rules/pwa.d.ts +2 -0
  43. package/dist/seo/rules/pwa.js +302 -0
  44. package/dist/seo/rules/readability.d.ts +2 -0
  45. package/dist/seo/rules/readability.js +255 -0
  46. package/dist/seo/rules/schema.d.ts +2 -0
  47. package/dist/seo/rules/schema.js +54 -0
  48. package/dist/seo/rules/security.d.ts +2 -0
  49. package/dist/seo/rules/security.js +147 -0
  50. package/dist/seo/rules/social.d.ts +2 -0
  51. package/dist/seo/rules/social.js +373 -0
  52. package/dist/seo/rules/structural.d.ts +2 -0
  53. package/dist/seo/rules/structural.js +155 -0
  54. package/dist/seo/rules/technical.d.ts +2 -0
  55. package/dist/seo/rules/technical.js +223 -0
  56. package/dist/seo/rules/thresholds.d.ts +196 -0
  57. package/dist/seo/rules/thresholds.js +118 -0
  58. package/dist/seo/rules/types.d.ts +322 -0
  59. package/dist/seo/rules/types.js +11 -0
  60. package/dist/seo/types.d.ts +160 -0
  61. package/dist/seo/types.js +1 -0
  62. package/dist/utils/columns.d.ts +14 -0
  63. package/dist/utils/columns.js +69 -0
  64. package/package.json +1 -1
@@ -0,0 +1,375 @@
1
+ import { createResult } from './types.js';
2
+ export const internalLinkingRules = [
3
+ {
4
+ id: 'linking-internal-count',
5
+ name: 'Internal Link Count',
6
+ category: 'links',
7
+ severity: 'warning',
8
+ description: 'Pages should have a healthy number of internal links',
9
+ check: (ctx) => {
10
+ if (ctx.internalLinks === undefined)
11
+ return null;
12
+ const count = ctx.internalLinks;
13
+ if (count === 0) {
14
+ return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'warn', 'No internal links found', {
15
+ recommendation: 'Add internal links to improve navigation and crawlability',
16
+ evidence: {
17
+ found: 0,
18
+ expected: 'At least 3-5 internal links per page',
19
+ impact: 'Pages without internal links are harder to discover and may not pass link equity',
20
+ },
21
+ });
22
+ }
23
+ if (count < 3) {
24
+ return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'info', `Only ${count} internal link(s)`, {
25
+ recommendation: 'Consider adding more internal links',
26
+ evidence: {
27
+ found: count,
28
+ expected: 'At least 3-5 internal links',
29
+ },
30
+ });
31
+ }
32
+ return createResult({ id: 'linking-internal-count', name: 'Internal Link Count', category: 'links', severity: 'warning' }, 'pass', `${count} internal links`);
33
+ },
34
+ },
35
+ {
36
+ id: 'linking-internal-ratio',
37
+ name: 'Internal/External Link Ratio',
38
+ category: 'links',
39
+ severity: 'info',
40
+ description: 'Pages should have more internal than external links',
41
+ check: (ctx) => {
42
+ if (ctx.internalLinks === undefined || ctx.externalLinks === undefined)
43
+ return null;
44
+ if (ctx.totalLinks === undefined || ctx.totalLinks === 0)
45
+ return null;
46
+ const internal = ctx.internalLinks;
47
+ const external = ctx.externalLinks;
48
+ const ratio = internal / (external || 1);
49
+ if (external > internal && external > 5) {
50
+ return createResult({ id: 'linking-internal-ratio', name: 'Internal/External Link Ratio', category: 'links', severity: 'info' }, 'info', `More external (${external}) than internal (${internal}) links`, {
51
+ recommendation: 'Consider adding more internal links for better link equity',
52
+ evidence: {
53
+ found: `${internal} internal, ${external} external`,
54
+ expected: 'Internal links should exceed external links',
55
+ impact: 'External links pass PageRank to other sites',
56
+ },
57
+ });
58
+ }
59
+ return createResult({ id: 'linking-internal-ratio', name: 'Internal/External Link Ratio', category: 'links', severity: 'info' }, 'pass', `Ratio: ${ratio.toFixed(1)}:1 (internal:external)`);
60
+ },
61
+ },
62
+ {
63
+ id: 'linking-anchor-diversity',
64
+ name: 'Anchor Text Diversity',
65
+ category: 'links',
66
+ severity: 'info',
67
+ description: 'Internal links should use diverse, descriptive anchor text',
68
+ check: (ctx) => {
69
+ if (!ctx.allLinks || ctx.allLinks.length === 0)
70
+ return null;
71
+ const internalLinks = ctx.allLinks.filter(l => l.type === 'internal');
72
+ if (internalLinks.length < 3)
73
+ return null;
74
+ const anchorCounts = {};
75
+ for (const link of internalLinks) {
76
+ const anchor = (link.text || '').toLowerCase().trim();
77
+ if (anchor && anchor.length > 2) {
78
+ anchorCounts[anchor] = (anchorCounts[anchor] || 0) + 1;
79
+ }
80
+ }
81
+ const overused = Object.entries(anchorCounts)
82
+ .filter(([_, count]) => count >= 3)
83
+ .map(([anchor, count]) => `"${anchor}" (${count}x)`);
84
+ if (overused.length > 0) {
85
+ return createResult({ id: 'linking-anchor-diversity', name: 'Anchor Text Diversity', category: 'links', severity: 'info' }, 'info', `Some anchor texts overused`, {
86
+ recommendation: 'Vary anchor text for better SEO signal distribution',
87
+ evidence: {
88
+ found: overused.slice(0, 3),
89
+ impact: 'Repetitive anchors may look spammy to search engines',
90
+ },
91
+ });
92
+ }
93
+ return createResult({ id: 'linking-anchor-diversity', name: 'Anchor Text Diversity', category: 'links', severity: 'info' }, 'pass', 'Good anchor text diversity');
94
+ },
95
+ },
96
+ {
97
+ id: 'linking-deep-links',
98
+ name: 'Deep Linking',
99
+ category: 'links',
100
+ severity: 'info',
101
+ description: 'Pages should link to deep content, not just homepage',
102
+ check: (ctx) => {
103
+ if (!ctx.allLinks || ctx.allLinks.length === 0)
104
+ return null;
105
+ const internalLinks = ctx.allLinks.filter(l => l.type === 'internal');
106
+ if (internalLinks.length === 0)
107
+ return null;
108
+ let rootLinks = 0;
109
+ let deepLinks = 0;
110
+ for (const link of internalLinks) {
111
+ try {
112
+ const url = new URL(link.href, ctx.url);
113
+ if (url.pathname === '/' || url.pathname === '') {
114
+ rootLinks++;
115
+ }
116
+ else {
117
+ deepLinks++;
118
+ }
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ }
124
+ const deepRatio = deepLinks / internalLinks.length;
125
+ if (rootLinks > deepLinks && internalLinks.length > 5) {
126
+ return createResult({ id: 'linking-deep-links', name: 'Deep Linking', category: 'links', severity: 'info' }, 'info', `Most internal links go to homepage`, {
127
+ recommendation: 'Add more links to inner pages',
128
+ evidence: {
129
+ found: `${rootLinks} homepage links, ${deepLinks} deep links`,
130
+ expected: 'More deep links than homepage links',
131
+ impact: 'Deep linking improves crawlability of inner pages',
132
+ },
133
+ });
134
+ }
135
+ return createResult({ id: 'linking-deep-links', name: 'Deep Linking', category: 'links', severity: 'info' }, 'pass', `${Math.round(deepRatio * 100)}% deep links`);
136
+ },
137
+ },
138
+ {
139
+ id: 'linking-nav-links',
140
+ name: 'Navigation Links',
141
+ category: 'links',
142
+ severity: 'info',
143
+ description: 'Check for proper navigation link structure',
144
+ check: (ctx) => {
145
+ if (!ctx.hasNav)
146
+ return null;
147
+ if (ctx.navLinkCount === undefined)
148
+ return null;
149
+ if (ctx.navLinkCount === 0) {
150
+ return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'warn', 'Navigation element has no links', {
151
+ recommendation: 'Add links to navigation for user experience and SEO',
152
+ evidence: {
153
+ found: '<nav> element with no links',
154
+ expected: 'Navigation should contain meaningful links',
155
+ },
156
+ });
157
+ }
158
+ if (ctx.navLinkCount > 20) {
159
+ return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'info', `Navigation has ${ctx.navLinkCount} links (high)`, {
160
+ recommendation: 'Consider simplifying navigation',
161
+ evidence: {
162
+ found: ctx.navLinkCount,
163
+ expected: 'Under 20 links for optimal UX',
164
+ impact: 'Too many nav links may dilute link equity',
165
+ },
166
+ });
167
+ }
168
+ return createResult({ id: 'linking-nav-links', name: 'Navigation Links', category: 'links', severity: 'info' }, 'pass', `${ctx.navLinkCount} navigation links`);
169
+ },
170
+ },
171
+ {
172
+ id: 'linking-footer-links',
173
+ name: 'Footer Links',
174
+ category: 'links',
175
+ severity: 'info',
176
+ description: 'Footer should contain important site-wide links',
177
+ check: (ctx) => {
178
+ if (!ctx.hasFooter)
179
+ return null;
180
+ if (ctx.footerLinkCount === undefined)
181
+ return null;
182
+ if (ctx.footerLinkCount === 0) {
183
+ return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'info', 'Footer has no links', {
184
+ recommendation: 'Add important links to footer',
185
+ evidence: {
186
+ expected: 'Links to privacy policy, terms, contact, sitemap',
187
+ },
188
+ });
189
+ }
190
+ if (ctx.footerLinkCount > 50) {
191
+ return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'info', `Footer has ${ctx.footerLinkCount} links (excessive)`, {
192
+ recommendation: 'Reduce footer links to essential pages',
193
+ evidence: {
194
+ found: ctx.footerLinkCount,
195
+ impact: 'Excessive footer links may be seen as link spam',
196
+ },
197
+ });
198
+ }
199
+ return createResult({ id: 'linking-footer-links', name: 'Footer Links', category: 'links', severity: 'info' }, 'pass', `${ctx.footerLinkCount} footer links`);
200
+ },
201
+ },
202
+ {
203
+ id: 'linking-contextual',
204
+ name: 'Contextual Links',
205
+ category: 'links',
206
+ severity: 'info',
207
+ description: 'Check for in-content contextual links',
208
+ check: (ctx) => {
209
+ if (ctx.contextualLinkCount === undefined)
210
+ return null;
211
+ if (!ctx.wordCount || ctx.wordCount < 300)
212
+ return null;
213
+ const count = ctx.contextualLinkCount;
214
+ if (count === 0) {
215
+ return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'info', 'No contextual links in content', {
216
+ recommendation: 'Add links within body content to related pages',
217
+ evidence: {
218
+ expected: 'At least 2-3 contextual links per 500 words',
219
+ impact: 'Contextual links pass more link equity than navigation links',
220
+ },
221
+ });
222
+ }
223
+ const linksPerWords = (count / ctx.wordCount) * 500;
224
+ if (linksPerWords < 1 && ctx.wordCount > 500) {
225
+ return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'info', `Only ${count} contextual link(s) in ${ctx.wordCount} words`, {
226
+ recommendation: 'Add more in-content links',
227
+ evidence: {
228
+ found: `${linksPerWords.toFixed(1)} links per 500 words`,
229
+ expected: '2-3 links per 500 words',
230
+ },
231
+ });
232
+ }
233
+ return createResult({ id: 'linking-contextual', name: 'Contextual Links', category: 'links', severity: 'info' }, 'pass', `${count} contextual links`);
234
+ },
235
+ },
236
+ {
237
+ id: 'linking-orphan-page',
238
+ name: 'Orphan Page Detection',
239
+ category: 'links',
240
+ severity: 'warning',
241
+ description: 'Pages should be linked from other pages on the site',
242
+ check: (ctx) => {
243
+ if (ctx.incomingInternalLinks === undefined)
244
+ return null;
245
+ if (ctx.incomingInternalLinks === 0) {
246
+ return createResult({ id: 'linking-orphan-page', name: 'Orphan Page Detection', category: 'links', severity: 'warning' }, 'warn', 'Page may be an orphan (no incoming internal links)', {
247
+ recommendation: 'Link to this page from other pages on your site',
248
+ evidence: {
249
+ found: '0 incoming internal links detected',
250
+ impact: 'Orphan pages are harder for search engines to discover',
251
+ learnMore: 'https://ahrefs.com/blog/orphan-pages/',
252
+ },
253
+ });
254
+ }
255
+ return createResult({ id: 'linking-orphan-page', name: 'Orphan Page Detection', category: 'links', severity: 'warning' }, 'pass', `${ctx.incomingInternalLinks} incoming internal link(s)`);
256
+ },
257
+ },
258
+ {
259
+ id: 'linking-self-referencing',
260
+ name: 'Self-Referencing Links',
261
+ category: 'links',
262
+ severity: 'info',
263
+ description: 'Avoid excessive self-referencing links',
264
+ check: (ctx) => {
265
+ if (ctx.selfReferencingLinks === undefined)
266
+ return null;
267
+ if (ctx.selfReferencingLinks > 3) {
268
+ return createResult({ id: 'linking-self-referencing', name: 'Self-Referencing Links', category: 'links', severity: 'info' }, 'info', `${ctx.selfReferencingLinks} self-referencing links`, {
269
+ recommendation: 'Reduce links that point to the current page',
270
+ evidence: {
271
+ found: ctx.selfReferencingLinks,
272
+ expected: '0-1 self-referencing links (e.g., canonical only)',
273
+ impact: 'Self-links waste crawl budget and confuse users',
274
+ },
275
+ });
276
+ }
277
+ return null;
278
+ },
279
+ },
280
+ {
281
+ id: 'linking-broken-internal',
282
+ name: 'Broken Internal Links',
283
+ category: 'links',
284
+ severity: 'error',
285
+ description: 'Internal links should not be broken',
286
+ check: (ctx) => {
287
+ if (ctx.brokenInternalLinks === undefined)
288
+ return null;
289
+ if (ctx.brokenInternalLinks > 0) {
290
+ return createResult({ id: 'linking-broken-internal', name: 'Broken Internal Links', category: 'links', severity: 'error' }, 'fail', `${ctx.brokenInternalLinks} broken internal link(s)`, {
291
+ recommendation: 'Fix or remove broken internal links',
292
+ evidence: {
293
+ found: ctx.brokenInternalLinks,
294
+ expected: '0 broken links',
295
+ impact: 'Broken links waste crawl budget and harm user experience',
296
+ },
297
+ });
298
+ }
299
+ return createResult({ id: 'linking-broken-internal', name: 'Broken Internal Links', category: 'links', severity: 'error' }, 'pass', 'No broken internal links');
300
+ },
301
+ },
302
+ {
303
+ id: 'linking-redirect-chains',
304
+ name: 'Redirect Chains',
305
+ category: 'links',
306
+ severity: 'warning',
307
+ description: 'Internal links should not go through redirect chains',
308
+ check: (ctx) => {
309
+ if (ctx.redirectChainLinks === undefined)
310
+ return null;
311
+ if (ctx.redirectChainLinks > 0) {
312
+ return createResult({ id: 'linking-redirect-chains', name: 'Redirect Chains', category: 'links', severity: 'warning' }, 'warn', `${ctx.redirectChainLinks} link(s) go through redirects`, {
313
+ recommendation: 'Update links to point to final destination URLs',
314
+ evidence: {
315
+ found: ctx.redirectChainLinks,
316
+ expected: '0 redirect chain links',
317
+ impact: 'Redirect chains slow down crawling and lose link equity',
318
+ },
319
+ });
320
+ }
321
+ return createResult({ id: 'linking-redirect-chains', name: 'Redirect Chains', category: 'links', severity: 'warning' }, 'pass', 'No redirect chain links');
322
+ },
323
+ },
324
+ {
325
+ id: 'linking-nofollow-internal',
326
+ name: 'Nofollow Internal Links',
327
+ category: 'links',
328
+ severity: 'warning',
329
+ description: 'Internal links should not use nofollow',
330
+ check: (ctx) => {
331
+ if (!ctx.allLinks)
332
+ return null;
333
+ const nofollowInternal = ctx.allLinks.filter(l => l.type === 'internal' && l.rel?.includes('nofollow'));
334
+ if (nofollowInternal.length > 0) {
335
+ return createResult({ id: 'linking-nofollow-internal', name: 'Nofollow Internal Links', category: 'links', severity: 'warning' }, 'warn', `${nofollowInternal.length} internal link(s) have nofollow`, {
336
+ recommendation: 'Remove nofollow from internal links',
337
+ evidence: {
338
+ found: nofollowInternal.slice(0, 3).map(l => l.href),
339
+ impact: 'Nofollow on internal links wastes PageRank',
340
+ learnMore: 'https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links',
341
+ },
342
+ });
343
+ }
344
+ return createResult({ id: 'linking-nofollow-internal', name: 'Nofollow Internal Links', category: 'links', severity: 'warning' }, 'pass', 'No nofollow on internal links');
345
+ },
346
+ },
347
+ {
348
+ id: 'linking-click-depth',
349
+ name: 'Click Depth',
350
+ category: 'links',
351
+ severity: 'info',
352
+ description: 'Important pages should be reachable in few clicks',
353
+ check: (ctx) => {
354
+ if (ctx.pageClickDepth === undefined)
355
+ return null;
356
+ const depth = ctx.pageClickDepth;
357
+ if (depth > 4) {
358
+ return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'warn', `Page is ${depth} clicks from homepage`, {
359
+ recommendation: 'Improve site architecture for better accessibility',
360
+ evidence: {
361
+ found: `${depth} clicks deep`,
362
+ expected: 'Under 4 clicks from homepage',
363
+ impact: 'Deep pages receive less crawl priority and link equity',
364
+ },
365
+ });
366
+ }
367
+ if (depth > 3) {
368
+ return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'info', `Page is ${depth} clicks from homepage`, {
369
+ recommendation: 'Consider adding shortcuts to this page',
370
+ });
371
+ }
372
+ return createResult({ id: 'linking-click-depth', name: 'Click Depth', category: 'links', severity: 'info' }, 'pass', `${depth} click(s) from homepage`);
373
+ },
374
+ },
375
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const linkRules: SeoRule[];
@@ -0,0 +1,150 @@
1
+ import { createResult } from './types.js';
2
+ import { SEO_THRESHOLDS } from './thresholds.js';
3
+ export const linkRules = [
4
+ {
5
+ id: 'links-descriptive-text',
6
+ name: 'Link Text',
7
+ category: 'links',
8
+ severity: 'warning',
9
+ description: 'Links should have descriptive anchor text',
10
+ check: (ctx) => {
11
+ if (ctx.totalLinks === undefined || ctx.totalLinks === 0)
12
+ return null;
13
+ const withoutText = ctx.problematicLinks?.withoutText ?? [];
14
+ if (withoutText.length > 0) {
15
+ const examples = withoutText.slice(0, 3).map((l) => l.href).join(', ');
16
+ return createResult({ id: 'links-descriptive-text', name: 'Link Text', category: 'links', severity: 'warning' }, 'warn', `${withoutText.length} link(s) without descriptive text`, {
17
+ value: withoutText.length,
18
+ recommendation: 'Add descriptive anchor text to all links for better accessibility and SEO',
19
+ evidence: {
20
+ found: withoutText.map((l) => l.href),
21
+ example: '<a href="/page">Learn more about our services</a>',
22
+ impact: 'Screen readers cannot describe the link destination to users',
23
+ },
24
+ });
25
+ }
26
+ return createResult({ id: 'links-descriptive-text', name: 'Link Text', category: 'links', severity: 'warning' }, 'pass', 'All links have descriptive text');
27
+ },
28
+ },
29
+ {
30
+ id: 'links-generic-text',
31
+ name: 'Generic Link Text',
32
+ category: 'links',
33
+ severity: 'warning',
34
+ description: 'Avoid generic link text like "click here" or "read more"',
35
+ check: (ctx) => {
36
+ const genericLinks = ctx.problematicLinks?.genericText ?? [];
37
+ if (genericLinks.length > 0) {
38
+ return createResult({ id: 'links-generic-text', name: 'Generic Link Text', category: 'links', severity: 'warning' }, 'warn', `${genericLinks.length} link(s) with generic text`, {
39
+ value: genericLinks.length,
40
+ recommendation: 'Replace generic anchor text with descriptive text that explains where the link goes',
41
+ evidence: {
42
+ found: genericLinks.map((l) => `"${l.text}" → ${l.href}`),
43
+ issue: 'Generic text like "click here", "read more", "here" provides no context',
44
+ example: 'Instead of <a href="/docs">Click here</a>, use <a href="/docs">View documentation</a>',
45
+ },
46
+ });
47
+ }
48
+ return null;
49
+ },
50
+ },
51
+ {
52
+ id: 'links-internal-count',
53
+ name: 'Internal Links',
54
+ category: 'links',
55
+ severity: 'info',
56
+ description: 'Page should have at least 3 internal links',
57
+ check: (ctx) => {
58
+ if (ctx.internalLinks === undefined)
59
+ return null;
60
+ const min = SEO_THRESHOLDS.links.minInternal;
61
+ if (ctx.internalLinks < min) {
62
+ return createResult({ id: 'links-internal-count', name: 'Internal Links', category: 'links', severity: 'info' }, 'info', `Few internal links (${ctx.internalLinks})`, { value: ctx.internalLinks, recommendation: `Add at least ${min} internal links for better navigation` });
63
+ }
64
+ return createResult({ id: 'links-internal-count', name: 'Internal Links', category: 'links', severity: 'info' }, 'pass', `Good internal linking (${ctx.internalLinks} links)`, { value: ctx.internalLinks });
65
+ },
66
+ },
67
+ {
68
+ id: 'links-external-count',
69
+ name: 'External Links',
70
+ category: 'links',
71
+ severity: 'info',
72
+ description: 'Page should not have too many external links',
73
+ check: (ctx) => {
74
+ if (ctx.externalLinks === undefined)
75
+ return null;
76
+ const max = SEO_THRESHOLDS.links.maxExternal;
77
+ if (ctx.externalLinks > max) {
78
+ return createResult({ id: 'links-external-count', name: 'External Links', category: 'links', severity: 'info' }, 'warn', `Too many external links (${ctx.externalLinks})`, { value: ctx.externalLinks, recommendation: `Reduce external links to under ${max}` });
79
+ }
80
+ return null;
81
+ },
82
+ },
83
+ {
84
+ id: 'links-external-noopener',
85
+ name: 'External Links Noopener',
86
+ category: 'security',
87
+ severity: 'warning',
88
+ description: 'External links with target="_blank" should have rel="noopener"',
89
+ check: (ctx) => {
90
+ const missingNoopener = ctx.problematicLinks?.missingNoopener ?? [];
91
+ if (missingNoopener.length > 0) {
92
+ return createResult({ id: 'links-external-noopener', name: 'External Links Noopener', category: 'security', severity: 'warning' }, 'warn', `${missingNoopener.length} external link(s) missing rel="noopener"`, {
93
+ value: missingNoopener.length,
94
+ recommendation: 'Add rel="noopener" to all external links with target="_blank"',
95
+ evidence: {
96
+ found: missingNoopener.map((l) => l.href),
97
+ issue: 'Links with target="_blank" without rel="noopener" allow the new page to access window.opener',
98
+ impact: 'Security vulnerability: the linked page can redirect your page or access sensitive data',
99
+ example: '<a href="https://external.com" target="_blank" rel="noopener noreferrer">External Site</a>',
100
+ },
101
+ });
102
+ }
103
+ return null;
104
+ },
105
+ },
106
+ {
107
+ id: 'links-external-noreferrer',
108
+ name: 'External Links Noreferrer',
109
+ category: 'security',
110
+ severity: 'info',
111
+ description: 'External links may benefit from rel="noreferrer" for privacy',
112
+ check: (ctx) => {
113
+ const missingNoreferrer = ctx.problematicLinks?.missingNoreferrer ?? [];
114
+ if (missingNoreferrer.length > 3) {
115
+ return createResult({ id: 'links-external-noreferrer', name: 'External Links Noreferrer', category: 'security', severity: 'info' }, 'info', `${missingNoreferrer.length} external link(s) without rel="noreferrer"`, {
116
+ value: missingNoreferrer.length,
117
+ recommendation: 'Consider adding rel="noreferrer" to prevent referrer leakage to external sites',
118
+ evidence: {
119
+ found: missingNoreferrer.slice(0, 5).map((l) => l.href),
120
+ issue: 'External sites can see your page URL in their analytics via the Referer header',
121
+ example: '<a href="https://external.com" target="_blank" rel="noopener noreferrer">External</a>',
122
+ },
123
+ });
124
+ }
125
+ return null;
126
+ },
127
+ },
128
+ {
129
+ id: 'links-sponsored-ugc-directives',
130
+ name: 'Sponsored/UGC Links',
131
+ category: 'links',
132
+ severity: 'info',
133
+ description: 'Rel attributes `sponsored` and `ugc` should be used for paid or user-generated content links.',
134
+ check: (ctx) => {
135
+ if (!ctx.totalLinks)
136
+ return null;
137
+ let messages = [];
138
+ if (ctx.sponsoredLinks && ctx.sponsoredLinks > 0) {
139
+ messages.push(`${ctx.sponsoredLinks} link(s) with rel="sponsored".`);
140
+ }
141
+ if (ctx.ugcLinks && ctx.ugcLinks > 0) {
142
+ messages.push(`${ctx.ugcLinks} link(s) with rel="ugc".`);
143
+ }
144
+ if (messages.length > 0) {
145
+ return createResult({ id: 'links-sponsored-ugc-directives', name: 'Sponsored/UGC Links', category: 'links', severity: 'info' }, 'info', messages.join(' '), { recommendation: 'Ensure rel="sponsored" is used for paid links and rel="ugc" for user-generated content.' });
146
+ }
147
+ return null;
148
+ },
149
+ },
150
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const localRules: SeoRule[];