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,307 @@
1
+ import { createResult } from './types.js';
2
+ export const crawlRules = [
3
+ {
4
+ id: 'crawl-sitemap-reference',
5
+ name: 'Sitemap Reference',
6
+ category: 'technical',
7
+ severity: 'info',
8
+ description: 'Page should reference sitemap location',
9
+ check: (ctx) => {
10
+ if (ctx.hasSitemapLink) {
11
+ return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'pass', `Sitemap link found: ${ctx.sitemapUrl || 'referenced'}`);
12
+ }
13
+ if (ctx.robotsHasSitemap) {
14
+ return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'pass', 'Sitemap referenced in robots.txt');
15
+ }
16
+ return createResult({ id: 'crawl-sitemap-reference', name: 'Sitemap Reference', category: 'technical', severity: 'info' }, 'info', 'No sitemap reference found', {
17
+ recommendation: 'Add sitemap reference in robots.txt or HTML',
18
+ evidence: {
19
+ expected: 'Sitemap: https://example.com/sitemap.xml in robots.txt',
20
+ example: '<link rel="sitemap" type="application/xml" href="/sitemap.xml">',
21
+ impact: 'Helps search engines discover all pages',
22
+ learnMore: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview',
23
+ },
24
+ });
25
+ },
26
+ },
27
+ {
28
+ id: 'crawl-robots-noindex',
29
+ name: 'Robots Noindex',
30
+ category: 'technical',
31
+ severity: 'error',
32
+ description: 'Check if page is blocked from indexing',
33
+ check: (ctx) => {
34
+ if (!ctx.metaRobots)
35
+ return null;
36
+ const hasNoindex = ctx.metaRobots.some(r => r.toLowerCase().includes('noindex'));
37
+ if (hasNoindex) {
38
+ return createResult({ id: 'crawl-robots-noindex', name: 'Robots Noindex', category: 'technical', severity: 'error' }, 'fail', 'Page is set to noindex', {
39
+ evidence: {
40
+ found: ctx.metaRobots.join(', '),
41
+ issue: 'This page will NOT appear in search results',
42
+ impact: 'Remove noindex if this page should be indexed',
43
+ },
44
+ });
45
+ }
46
+ return null;
47
+ },
48
+ },
49
+ {
50
+ id: 'crawl-robots-nofollow',
51
+ name: 'Robots Nofollow',
52
+ category: 'technical',
53
+ severity: 'warning',
54
+ description: 'Check if page links are blocked from following',
55
+ check: (ctx) => {
56
+ if (!ctx.metaRobots)
57
+ return null;
58
+ const hasNofollow = ctx.metaRobots.some(r => r.toLowerCase().includes('nofollow'));
59
+ if (hasNofollow) {
60
+ return createResult({ id: 'crawl-robots-nofollow', name: 'Robots Nofollow', category: 'technical', severity: 'warning' }, 'warn', 'Page has nofollow directive', {
61
+ evidence: {
62
+ found: ctx.metaRobots.join(', '),
63
+ issue: 'Links on this page will not pass PageRank',
64
+ impact: 'Internal pages should usually not have nofollow',
65
+ },
66
+ });
67
+ }
68
+ return null;
69
+ },
70
+ },
71
+ {
72
+ id: 'crawl-robots-combined',
73
+ name: 'Robots Directives',
74
+ category: 'technical',
75
+ severity: 'info',
76
+ description: 'Review all robots meta directives',
77
+ check: (ctx) => {
78
+ if (!ctx.metaRobots || ctx.metaRobots.length === 0) {
79
+ return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'info', 'No robots meta tag (defaults to index, follow)', {
80
+ recommendation: 'Explicitly set robots directives if needed',
81
+ evidence: {
82
+ expected: '<meta name="robots" content="index, follow">',
83
+ impact: 'Default behavior allows full indexing and following',
84
+ },
85
+ });
86
+ }
87
+ const directives = ctx.metaRobots.join(', ').toLowerCase();
88
+ const hasIndex = directives.includes('index') && !directives.includes('noindex');
89
+ const hasNoindex = directives.includes('noindex');
90
+ if (hasIndex && hasNoindex) {
91
+ return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'warn', 'Conflicting robots directives detected', {
92
+ evidence: {
93
+ found: ctx.metaRobots.join(', '),
94
+ issue: 'Both index and noindex specified',
95
+ },
96
+ });
97
+ }
98
+ return createResult({ id: 'crawl-robots-combined', name: 'Robots Directives', category: 'technical', severity: 'info' }, 'pass', `Robots: ${ctx.metaRobots.join(', ')}`);
99
+ },
100
+ },
101
+ {
102
+ id: 'crawl-x-robots-tag',
103
+ name: 'X-Robots-Tag Header',
104
+ category: 'technical',
105
+ severity: 'warning',
106
+ description: 'Check X-Robots-Tag HTTP header for indexing directives',
107
+ check: (ctx) => {
108
+ if (!ctx.responseHeaders)
109
+ return null;
110
+ const xRobotsTag = ctx.responseHeaders['x-robots-tag'] ||
111
+ ctx.responseHeaders['X-Robots-Tag'];
112
+ if (!xRobotsTag)
113
+ return null;
114
+ const tagValue = Array.isArray(xRobotsTag) ? xRobotsTag.join(', ') : xRobotsTag;
115
+ if (tagValue.toLowerCase().includes('noindex')) {
116
+ return createResult({ id: 'crawl-x-robots-tag', name: 'X-Robots-Tag Header', category: 'technical', severity: 'warning' }, 'fail', 'X-Robots-Tag contains noindex', {
117
+ evidence: {
118
+ found: tagValue,
119
+ issue: 'HTTP header is blocking indexing',
120
+ impact: 'Page will not appear in search results',
121
+ },
122
+ });
123
+ }
124
+ return createResult({ id: 'crawl-x-robots-tag', name: 'X-Robots-Tag Header', category: 'technical', severity: 'warning' }, 'info', `X-Robots-Tag: ${tagValue}`);
125
+ },
126
+ },
127
+ {
128
+ id: 'crawl-canonical-present',
129
+ name: 'Canonical URL',
130
+ category: 'technical',
131
+ severity: 'warning',
132
+ description: 'Pages should have a canonical URL to prevent duplicate content',
133
+ check: (ctx) => {
134
+ if (!ctx.hasCanonical) {
135
+ return createResult({ id: 'crawl-canonical-present', name: 'Canonical URL', category: 'technical', severity: 'warning' }, 'warn', 'Missing canonical URL', {
136
+ recommendation: 'Add a canonical link to prevent duplicate content issues',
137
+ evidence: {
138
+ expected: '<link rel="canonical" href="https://example.com/page">',
139
+ impact: 'Without canonical, search engines may index multiple versions',
140
+ learnMore: 'https://developers.google.com/search/docs/crawling-indexing/canonicalization',
141
+ },
142
+ });
143
+ }
144
+ return createResult({ id: 'crawl-canonical-present', name: 'Canonical URL', category: 'technical', severity: 'warning' }, 'pass', `Canonical: ${ctx.canonicalUrl}`);
145
+ },
146
+ },
147
+ {
148
+ id: 'crawl-canonical-self',
149
+ name: 'Canonical Self-Reference',
150
+ category: 'technical',
151
+ severity: 'info',
152
+ description: 'Canonical should point to the current page or explicit alternate',
153
+ check: (ctx) => {
154
+ if (!ctx.hasCanonical || !ctx.canonicalUrl || !ctx.url)
155
+ return null;
156
+ const normalizeUrl = (url) => {
157
+ try {
158
+ const u = new URL(url);
159
+ let normalized = u.origin + u.pathname.replace(/\/$/, '');
160
+ return normalized.toLowerCase();
161
+ }
162
+ catch {
163
+ return url.toLowerCase().replace(/\/$/, '');
164
+ }
165
+ };
166
+ const currentNorm = normalizeUrl(ctx.url);
167
+ const canonicalNorm = normalizeUrl(ctx.canonicalUrl);
168
+ if (currentNorm !== canonicalNorm) {
169
+ return createResult({ id: 'crawl-canonical-self', name: 'Canonical Self-Reference', category: 'technical', severity: 'info' }, 'info', 'Canonical points to different URL', {
170
+ evidence: {
171
+ found: [`Current: ${ctx.url}`, `Canonical: ${ctx.canonicalUrl}`],
172
+ issue: 'This page canonicalizes to a different URL',
173
+ impact: 'Ensure this is intentional (e.g., www vs non-www consolidation)',
174
+ },
175
+ });
176
+ }
177
+ return null;
178
+ },
179
+ },
180
+ {
181
+ id: 'crawl-canonical-absolute',
182
+ name: 'Canonical Absolute URL',
183
+ category: 'technical',
184
+ severity: 'warning',
185
+ description: 'Canonical URL should be absolute, not relative',
186
+ check: (ctx) => {
187
+ if (!ctx.canonicalUrl)
188
+ return null;
189
+ const isAbsolute = ctx.canonicalUrl.startsWith('http://') ||
190
+ ctx.canonicalUrl.startsWith('https://');
191
+ if (!isAbsolute) {
192
+ return createResult({ id: 'crawl-canonical-absolute', name: 'Canonical Absolute URL', category: 'technical', severity: 'warning' }, 'warn', 'Canonical URL is relative', {
193
+ evidence: {
194
+ found: ctx.canonicalUrl,
195
+ expected: 'Absolute URL starting with https://',
196
+ impact: 'Relative canonicals may be misinterpreted',
197
+ },
198
+ });
199
+ }
200
+ return null;
201
+ },
202
+ },
203
+ {
204
+ id: 'crawl-canonical-https',
205
+ name: 'Canonical HTTPS',
206
+ category: 'technical',
207
+ severity: 'warning',
208
+ description: 'Canonical URL should use HTTPS',
209
+ check: (ctx) => {
210
+ if (!ctx.canonicalUrl)
211
+ return null;
212
+ if (ctx.canonicalUrl.startsWith('http://')) {
213
+ return createResult({ id: 'crawl-canonical-https', name: 'Canonical HTTPS', category: 'technical', severity: 'warning' }, 'warn', 'Canonical URL uses HTTP instead of HTTPS', {
214
+ evidence: {
215
+ found: ctx.canonicalUrl,
216
+ expected: 'HTTPS canonical URL',
217
+ impact: 'Google prefers HTTPS URLs for ranking',
218
+ },
219
+ });
220
+ }
221
+ return null;
222
+ },
223
+ },
224
+ {
225
+ id: 'crawl-url-parameters',
226
+ name: 'URL Parameters',
227
+ category: 'technical',
228
+ severity: 'info',
229
+ description: 'URLs with tracking parameters should have proper canonical',
230
+ check: (ctx) => {
231
+ if (!ctx.url)
232
+ return null;
233
+ try {
234
+ const url = new URL(ctx.url);
235
+ const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid', 'ref'];
236
+ const hasTracking = trackingParams.some(p => url.searchParams.has(p));
237
+ if (hasTracking && !ctx.hasCanonical) {
238
+ return createResult({ id: 'crawl-url-parameters', name: 'URL Parameters', category: 'technical', severity: 'info' }, 'warn', 'URL has tracking parameters but no canonical', {
239
+ recommendation: 'Add canonical pointing to clean URL without parameters',
240
+ evidence: {
241
+ found: url.search,
242
+ expected: 'Canonical to base URL without tracking params',
243
+ impact: 'Tracking parameters can cause duplicate content',
244
+ },
245
+ });
246
+ }
247
+ }
248
+ catch {
249
+ }
250
+ return null;
251
+ },
252
+ },
253
+ {
254
+ id: 'crawl-pagination-rel',
255
+ name: 'Pagination Links',
256
+ category: 'technical',
257
+ severity: 'info',
258
+ description: 'Paginated content should use proper rel attributes',
259
+ check: (ctx) => {
260
+ if (!ctx.isPaginatedPage)
261
+ return null;
262
+ const hasPrevNext = ctx.hasRelPrev || ctx.hasRelNext;
263
+ if (!hasPrevNext) {
264
+ return createResult({ id: 'crawl-pagination-rel', name: 'Pagination Links', category: 'technical', severity: 'info' }, 'info', 'Paginated page missing rel="prev/next" (deprecated but still useful)', {
265
+ recommendation: 'Consider using rel="prev" and rel="next" for pagination',
266
+ evidence: {
267
+ example: '<link rel="prev" href="/page/1">\n<link rel="next" href="/page/3">',
268
+ impact: 'Helps search engines understand pagination structure',
269
+ learnMore: 'https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading',
270
+ },
271
+ });
272
+ }
273
+ return createResult({ id: 'crawl-pagination-rel', name: 'Pagination Links', category: 'technical', severity: 'info' }, 'pass', 'Pagination links present');
274
+ },
275
+ },
276
+ {
277
+ id: 'crawl-noarchive',
278
+ name: 'Cache Directives',
279
+ category: 'technical',
280
+ severity: 'info',
281
+ description: 'Check for noarchive and nocache directives',
282
+ check: (ctx) => {
283
+ if (!ctx.metaRobots)
284
+ return null;
285
+ const directives = ctx.metaRobots.join(', ').toLowerCase();
286
+ const hasNoarchive = directives.includes('noarchive');
287
+ const hasNocache = directives.includes('nocache');
288
+ const hasNosnippet = directives.includes('nosnippet');
289
+ const restrictions = [];
290
+ if (hasNoarchive)
291
+ restrictions.push('noarchive (no cached version)');
292
+ if (hasNocache)
293
+ restrictions.push('nocache (no cache)');
294
+ if (hasNosnippet)
295
+ restrictions.push('nosnippet (no search snippet)');
296
+ if (restrictions.length > 0) {
297
+ return createResult({ id: 'crawl-noarchive', name: 'Cache Directives', category: 'technical', severity: 'info' }, 'info', `Search restrictions: ${restrictions.join(', ')}`, {
298
+ evidence: {
299
+ found: restrictions,
300
+ impact: 'These directives limit how search engines display your page',
301
+ },
302
+ });
303
+ }
304
+ return null;
305
+ },
306
+ },
307
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const cwvRules: SeoRule[];
@@ -0,0 +1,337 @@
1
+ import { createResult } from './types.js';
2
+ export const cwvRules = [
3
+ {
4
+ id: 'cwv-lcp-hero-image',
5
+ name: 'LCP Hero Image',
6
+ category: 'performance',
7
+ severity: 'warning',
8
+ description: 'Hero images should be optimized for fast LCP',
9
+ check: (ctx) => {
10
+ if (!ctx.lcpCandidate)
11
+ return null;
12
+ const issues = [];
13
+ if (ctx.lcpCandidate.loading === 'lazy') {
14
+ issues.push('LCP image has loading="lazy" (should be eager or omitted)');
15
+ }
16
+ if (!ctx.lcpCandidate.fetchpriority) {
17
+ issues.push('Missing fetchpriority="high" on LCP image');
18
+ }
19
+ if (!ctx.hasLcpPreload) {
20
+ issues.push('LCP image not preloaded');
21
+ }
22
+ if (issues.length > 0) {
23
+ return createResult({ id: 'cwv-lcp-hero-image', name: 'LCP Hero Image', category: 'performance', severity: 'warning' }, 'warn', `LCP optimization issues: ${issues.length} found`, {
24
+ recommendation: 'Optimize LCP image loading',
25
+ evidence: {
26
+ found: issues,
27
+ expected: 'LCP image with fetchpriority="high", no lazy loading, and preload link',
28
+ example: `<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
29
+ <img src="hero.webp" fetchpriority="high" alt="Hero">`,
30
+ impact: 'LCP is a Core Web Vital - aim for < 2.5s',
31
+ learnMore: 'https://web.dev/lcp/',
32
+ },
33
+ });
34
+ }
35
+ return createResult({ id: 'cwv-lcp-hero-image', name: 'LCP Hero Image', category: 'performance', severity: 'warning' }, 'pass', 'LCP image appears optimized');
36
+ },
37
+ },
38
+ {
39
+ id: 'cwv-lcp-text-visible',
40
+ name: 'LCP Text Visibility',
41
+ category: 'performance',
42
+ severity: 'warning',
43
+ description: 'Text LCP elements should render immediately without font blocking',
44
+ check: (ctx) => {
45
+ if (!ctx.webFonts || ctx.webFonts.length === 0)
46
+ return null;
47
+ const blockingFonts = ctx.webFonts.filter(f => !f.hasSwap && !f.hasOptional);
48
+ if (blockingFonts.length > 0) {
49
+ return createResult({ id: 'cwv-lcp-text-visible', name: 'LCP Text Visibility', category: 'performance', severity: 'warning' }, 'warn', `${blockingFonts.length} web font(s) may block text rendering`, {
50
+ recommendation: 'Use font-display: swap or optional for web fonts',
51
+ evidence: {
52
+ found: blockingFonts.map(f => f.family).filter(Boolean).slice(0, 3),
53
+ expected: 'font-display: swap or font-display: optional',
54
+ example: '@font-face { font-display: swap; }',
55
+ impact: 'Blocking fonts delay LCP for text elements',
56
+ learnMore: 'https://web.dev/font-display/',
57
+ },
58
+ });
59
+ }
60
+ return createResult({ id: 'cwv-lcp-text-visible', name: 'LCP Text Visibility', category: 'performance', severity: 'warning' }, 'pass', 'Web fonts use non-blocking display');
61
+ },
62
+ },
63
+ {
64
+ id: 'cwv-render-blocking-css',
65
+ name: 'Render-Blocking CSS',
66
+ category: 'performance',
67
+ severity: 'warning',
68
+ description: 'Minimize render-blocking CSS for faster LCP',
69
+ check: (ctx) => {
70
+ if (ctx.renderBlockingStylesheets === undefined)
71
+ return null;
72
+ if (ctx.renderBlockingStylesheets > 3) {
73
+ return createResult({ id: 'cwv-render-blocking-css', name: 'Render-Blocking CSS', category: 'performance', severity: 'warning' }, 'warn', `${ctx.renderBlockingStylesheets} render-blocking stylesheets`, {
74
+ recommendation: 'Reduce render-blocking CSS or use critical CSS inline',
75
+ evidence: {
76
+ found: ctx.renderBlockingStylesheets,
77
+ expected: '1-3 stylesheets or inline critical CSS',
78
+ example: '<style>/* critical CSS */</style>\n<link rel="stylesheet" href="main.css" media="print" onload="this.media=\'all\'">',
79
+ impact: 'Each blocking stylesheet delays first paint',
80
+ },
81
+ });
82
+ }
83
+ return createResult({ id: 'cwv-render-blocking-css', name: 'Render-Blocking CSS', category: 'performance', severity: 'warning' }, 'pass', `${ctx.renderBlockingStylesheets} render-blocking stylesheet(s)`);
84
+ },
85
+ },
86
+ {
87
+ id: 'cwv-render-blocking-js',
88
+ name: 'Render-Blocking JavaScript',
89
+ category: 'performance',
90
+ severity: 'warning',
91
+ description: 'Scripts in head should use async or defer',
92
+ check: (ctx) => {
93
+ if (ctx.renderBlockingScripts === undefined)
94
+ return null;
95
+ if (ctx.renderBlockingScripts > 0) {
96
+ return createResult({ id: 'cwv-render-blocking-js', name: 'Render-Blocking JavaScript', category: 'performance', severity: 'warning' }, 'warn', `${ctx.renderBlockingScripts} render-blocking script(s) in <head>`, {
97
+ recommendation: 'Add async or defer to scripts, or move to end of body',
98
+ evidence: {
99
+ found: ctx.renderBlockingScripts,
100
+ expected: 'Scripts with async, defer, or type="module"',
101
+ example: '<script src="app.js" defer></script>',
102
+ impact: 'Blocking scripts delay HTML parsing and LCP',
103
+ },
104
+ });
105
+ }
106
+ return createResult({ id: 'cwv-render-blocking-js', name: 'Render-Blocking JavaScript', category: 'performance', severity: 'warning' }, 'pass', 'No render-blocking scripts in head');
107
+ },
108
+ },
109
+ {
110
+ id: 'cwv-cls-image-dimensions',
111
+ name: 'Image Dimensions',
112
+ category: 'performance',
113
+ severity: 'warning',
114
+ description: 'Images should have explicit width and height to prevent layout shift',
115
+ check: (ctx) => {
116
+ if (ctx.imagesMissingDimensions === undefined)
117
+ return null;
118
+ if (ctx.imagesMissingDimensions > 0) {
119
+ const total = ctx.totalImages || 0;
120
+ const percent = total > 0 ? Math.round((ctx.imagesMissingDimensions / total) * 100) : 0;
121
+ return createResult({ id: 'cwv-cls-image-dimensions', name: 'Image Dimensions', category: 'performance', severity: 'warning' }, 'warn', `${ctx.imagesMissingDimensions} image(s) missing width/height (${percent}%)`, {
122
+ recommendation: 'Add explicit width and height attributes to all images',
123
+ evidence: {
124
+ found: `${ctx.imagesMissingDimensions} of ${total} images`,
125
+ expected: 'width and height on all <img> elements',
126
+ example: '<img src="photo.jpg" width="800" height="600" alt="...">',
127
+ impact: 'Missing dimensions cause layout shift when images load',
128
+ learnMore: 'https://web.dev/cls/',
129
+ },
130
+ });
131
+ }
132
+ return createResult({ id: 'cwv-cls-image-dimensions', name: 'Image Dimensions', category: 'performance', severity: 'warning' }, 'pass', 'All images have dimensions');
133
+ },
134
+ },
135
+ {
136
+ id: 'cwv-cls-aspect-ratio',
137
+ name: 'Aspect Ratio CSS',
138
+ category: 'performance',
139
+ severity: 'info',
140
+ description: 'Use CSS aspect-ratio for responsive media containers',
141
+ check: (ctx) => {
142
+ if (!ctx.hasAspectRatioCss && ctx.hasResponsiveImages) {
143
+ return createResult({ id: 'cwv-cls-aspect-ratio', name: 'Aspect Ratio CSS', category: 'performance', severity: 'info' }, 'info', 'Consider using CSS aspect-ratio for media containers', {
144
+ recommendation: 'Use aspect-ratio CSS property for responsive containers',
145
+ evidence: {
146
+ example: '.video-container { aspect-ratio: 16 / 9; }',
147
+ impact: 'Reserves space before media loads, preventing CLS',
148
+ learnMore: 'https://web.dev/aspect-ratio/',
149
+ },
150
+ });
151
+ }
152
+ return null;
153
+ },
154
+ },
155
+ {
156
+ id: 'cwv-cls-dynamic-content',
157
+ name: 'Dynamic Content Space',
158
+ category: 'performance',
159
+ severity: 'info',
160
+ description: 'Reserve space for dynamically injected content',
161
+ check: (ctx) => {
162
+ const potentialShifts = [];
163
+ if (ctx.hasAdsWithoutReservedSpace) {
164
+ potentialShifts.push('Ads without reserved space');
165
+ }
166
+ if (ctx.hasBannersWithoutMinHeight) {
167
+ potentialShifts.push('Banners/alerts without min-height');
168
+ }
169
+ if (ctx.hasInfiniteScroll) {
170
+ potentialShifts.push('Infinite scroll may cause shifts');
171
+ }
172
+ if (potentialShifts.length > 0) {
173
+ return createResult({ id: 'cwv-cls-dynamic-content', name: 'Dynamic Content Space', category: 'performance', severity: 'info' }, 'info', 'Potential layout shift sources detected', {
174
+ recommendation: 'Reserve space for dynamic content with min-height or skeleton loaders',
175
+ evidence: {
176
+ found: potentialShifts,
177
+ example: '.ad-slot { min-height: 250px; }',
178
+ impact: 'Dynamic content is a major source of CLS',
179
+ },
180
+ });
181
+ }
182
+ return null;
183
+ },
184
+ },
185
+ {
186
+ id: 'cwv-cls-font-fallback',
187
+ name: 'Font Fallback Metrics',
188
+ category: 'performance',
189
+ severity: 'info',
190
+ description: 'Web fonts should have size-matched fallbacks',
191
+ check: (ctx) => {
192
+ if (!ctx.webFonts || ctx.webFonts.length === 0)
193
+ return null;
194
+ const fontsWithoutMetrics = ctx.webFonts.filter(f => !f.hasSizeAdjust && !f.hasAscentOverride);
195
+ if (fontsWithoutMetrics.length > 0 && ctx.webFonts.length > 0) {
196
+ return createResult({ id: 'cwv-cls-font-fallback', name: 'Font Fallback Metrics', category: 'performance', severity: 'info' }, 'info', 'Web fonts may cause layout shift during swap', {
197
+ recommendation: 'Use size-adjust or metric overrides for fallback fonts',
198
+ evidence: {
199
+ example: `@font-face {
200
+ font-family: 'Custom Font';
201
+ src: url('custom.woff2') format('woff2');
202
+ font-display: swap;
203
+ size-adjust: 92%; /* Match fallback metrics */
204
+ }`,
205
+ impact: 'Font swapping can cause visible layout shift',
206
+ learnMore: 'https://web.dev/css-size-adjust/',
207
+ },
208
+ });
209
+ }
210
+ return null;
211
+ },
212
+ },
213
+ {
214
+ id: 'cwv-inp-main-thread',
215
+ name: 'Main Thread Blocking',
216
+ category: 'performance',
217
+ severity: 'warning',
218
+ description: 'Large inline scripts can block the main thread',
219
+ check: (ctx) => {
220
+ if (ctx.largeInlineScripts === undefined)
221
+ return null;
222
+ if (ctx.largeInlineScripts > 0) {
223
+ return createResult({ id: 'cwv-inp-main-thread', name: 'Main Thread Blocking', category: 'performance', severity: 'warning' }, 'warn', `${ctx.largeInlineScripts} large inline script(s) may block main thread`, {
224
+ recommendation: 'Move large scripts to external files with defer',
225
+ evidence: {
226
+ found: `${ctx.largeInlineScripts} inline scripts > 10KB`,
227
+ expected: 'Small inline scripts only for critical initialization',
228
+ impact: 'Large scripts block main thread and delay interactions',
229
+ learnMore: 'https://web.dev/optimize-inp/',
230
+ },
231
+ });
232
+ }
233
+ return null;
234
+ },
235
+ },
236
+ {
237
+ id: 'cwv-inp-event-handlers',
238
+ name: 'Inline Event Handlers',
239
+ category: 'performance',
240
+ severity: 'info',
241
+ description: 'Avoid inline event handlers for better performance',
242
+ check: (ctx) => {
243
+ if (ctx.inlineEventHandlers === undefined)
244
+ return null;
245
+ if (ctx.inlineEventHandlers > 10) {
246
+ return createResult({ id: 'cwv-inp-event-handlers', name: 'Inline Event Handlers', category: 'performance', severity: 'info' }, 'info', `${ctx.inlineEventHandlers} inline event handlers found`, {
247
+ recommendation: 'Use event delegation instead of inline handlers',
248
+ evidence: {
249
+ found: ctx.inlineEventHandlers,
250
+ expected: 'Minimal inline handlers, prefer addEventListener',
251
+ example: 'document.addEventListener("click", handleClick)',
252
+ impact: 'Many inline handlers increase parsing time',
253
+ },
254
+ });
255
+ }
256
+ return null;
257
+ },
258
+ },
259
+ {
260
+ id: 'cwv-inp-heavy-animations',
261
+ name: 'Heavy Animations',
262
+ category: 'performance',
263
+ severity: 'info',
264
+ description: 'Animations should use transform/opacity for best performance',
265
+ check: (ctx) => {
266
+ if (!ctx.hasHeavyAnimations)
267
+ return null;
268
+ return createResult({ id: 'cwv-inp-heavy-animations', name: 'Heavy Animations', category: 'performance', severity: 'info' }, 'info', 'Potentially expensive animations detected', {
269
+ recommendation: 'Use transform and opacity for animations, avoid layout properties',
270
+ evidence: {
271
+ issue: 'Animating width, height, top, left triggers layout',
272
+ expected: 'Animate transform: translate(), scale(), rotate() and opacity',
273
+ learnMore: 'https://web.dev/animations-guide/',
274
+ },
275
+ });
276
+ },
277
+ },
278
+ {
279
+ id: 'cwv-resource-hints',
280
+ name: 'Resource Hints',
281
+ category: 'performance',
282
+ severity: 'info',
283
+ description: 'Use resource hints to speed up critical resources',
284
+ check: (ctx) => {
285
+ const hints = [];
286
+ if (!ctx.hasPreconnect && ctx.externalOrigins && ctx.externalOrigins > 2) {
287
+ hints.push('preconnect for critical third-party origins');
288
+ }
289
+ if (!ctx.hasDnsPrefetch && ctx.externalOrigins && ctx.externalOrigins > 3) {
290
+ hints.push('dns-prefetch for secondary origins');
291
+ }
292
+ if (!ctx.hasPreload && ctx.hasCriticalResources) {
293
+ hints.push('preload for critical CSS/fonts/images');
294
+ }
295
+ if (hints.length > 0) {
296
+ return createResult({ id: 'cwv-resource-hints', name: 'Resource Hints', category: 'performance', severity: 'info' }, 'info', `Consider adding: ${hints.join(', ')}`, {
297
+ recommendation: 'Add resource hints in <head> for faster loading',
298
+ evidence: {
299
+ expected: hints,
300
+ example: `<link rel="preconnect" href="https://fonts.googleapis.com">
301
+ <link rel="dns-prefetch" href="https://analytics.example.com">
302
+ <link rel="preload" as="font" href="/fonts/main.woff2" crossorigin>`,
303
+ learnMore: 'https://web.dev/preconnect-and-dns-prefetch/',
304
+ },
305
+ });
306
+ }
307
+ return createResult({ id: 'cwv-resource-hints', name: 'Resource Hints', category: 'performance', severity: 'info' }, 'pass', 'Resource hints configured');
308
+ },
309
+ },
310
+ {
311
+ id: 'cwv-critical-css',
312
+ name: 'Critical CSS',
313
+ category: 'performance',
314
+ severity: 'info',
315
+ description: 'Inline critical CSS for above-the-fold content',
316
+ check: (ctx) => {
317
+ if (ctx.hasInlineCriticalCss) {
318
+ return createResult({ id: 'cwv-critical-css', name: 'Critical CSS', category: 'performance', severity: 'info' }, 'pass', 'Inline critical CSS detected');
319
+ }
320
+ if (ctx.renderBlockingStylesheets && ctx.renderBlockingStylesheets > 0) {
321
+ return createResult({ id: 'cwv-critical-css', name: 'Critical CSS', category: 'performance', severity: 'info' }, 'info', 'Consider inlining critical CSS', {
322
+ recommendation: 'Inline critical CSS in <head> and defer non-critical styles',
323
+ evidence: {
324
+ example: `<style>
325
+ /* Critical above-the-fold styles */
326
+ body { margin: 0; font-family: system-ui; }
327
+ </style>
328
+ <link rel="stylesheet" href="main.css" media="print" onload="this.media='all'">`,
329
+ impact: 'Critical CSS eliminates render-blocking stylesheets',
330
+ learnMore: 'https://web.dev/extract-critical-css/',
331
+ },
332
+ });
333
+ }
334
+ return null;
335
+ },
336
+ },
337
+ ];
@@ -0,0 +1,2 @@
1
+ import { SeoRule } from './types.js';
2
+ export declare const ecommerceRules: SeoRule[];