@rankcli/agent-runtime 0.0.1

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 (178) hide show
  1. package/README.md +242 -0
  2. package/dist/analyzer-2CSWIQGD.mjs +6 -0
  3. package/dist/chunk-YNZYHEYM.mjs +774 -0
  4. package/dist/index.d.mts +4012 -0
  5. package/dist/index.d.ts +4012 -0
  6. package/dist/index.js +29672 -0
  7. package/dist/index.mjs +28602 -0
  8. package/package.json +53 -0
  9. package/scripts/build-deno.ts +134 -0
  10. package/src/audit/ai/analyzer.ts +347 -0
  11. package/src/audit/ai/index.ts +29 -0
  12. package/src/audit/ai/prompts/content-analysis.ts +271 -0
  13. package/src/audit/ai/types.ts +179 -0
  14. package/src/audit/checks/additional-checks.ts +439 -0
  15. package/src/audit/checks/ai-citation-worthiness.ts +399 -0
  16. package/src/audit/checks/ai-content-structure.ts +325 -0
  17. package/src/audit/checks/ai-readiness.ts +339 -0
  18. package/src/audit/checks/anchor-text.ts +179 -0
  19. package/src/audit/checks/answer-conciseness.ts +322 -0
  20. package/src/audit/checks/asset-minification.ts +270 -0
  21. package/src/audit/checks/bing-optimization.ts +206 -0
  22. package/src/audit/checks/brand-mention-optimization.ts +349 -0
  23. package/src/audit/checks/caching-headers.ts +305 -0
  24. package/src/audit/checks/canonical-advanced.ts +150 -0
  25. package/src/audit/checks/canonical-domain.ts +196 -0
  26. package/src/audit/checks/citation-quality.ts +358 -0
  27. package/src/audit/checks/client-rendering.ts +542 -0
  28. package/src/audit/checks/color-contrast.ts +342 -0
  29. package/src/audit/checks/content-freshness.ts +170 -0
  30. package/src/audit/checks/content-science.ts +589 -0
  31. package/src/audit/checks/conversion-elements.ts +526 -0
  32. package/src/audit/checks/crawlability.ts +220 -0
  33. package/src/audit/checks/directory-listing.ts +172 -0
  34. package/src/audit/checks/dom-analysis.ts +191 -0
  35. package/src/audit/checks/dom-size.ts +246 -0
  36. package/src/audit/checks/duplicate-content.ts +194 -0
  37. package/src/audit/checks/eeat-signals.ts +990 -0
  38. package/src/audit/checks/entity-seo.ts +396 -0
  39. package/src/audit/checks/featured-snippet.ts +473 -0
  40. package/src/audit/checks/freshness-signals.ts +443 -0
  41. package/src/audit/checks/funnel-intent.ts +463 -0
  42. package/src/audit/checks/hreflang.ts +174 -0
  43. package/src/audit/checks/html-compliance.ts +302 -0
  44. package/src/audit/checks/image-dimensions.ts +167 -0
  45. package/src/audit/checks/images.ts +160 -0
  46. package/src/audit/checks/indexnow.ts +275 -0
  47. package/src/audit/checks/interactive-tools.ts +475 -0
  48. package/src/audit/checks/internal-link-graph.ts +436 -0
  49. package/src/audit/checks/keyword-analysis.ts +239 -0
  50. package/src/audit/checks/keyword-cannibalization.ts +385 -0
  51. package/src/audit/checks/keyword-placement.ts +471 -0
  52. package/src/audit/checks/links.ts +203 -0
  53. package/src/audit/checks/llms-txt.ts +224 -0
  54. package/src/audit/checks/local-seo.ts +296 -0
  55. package/src/audit/checks/mobile.ts +167 -0
  56. package/src/audit/checks/modern-images.ts +226 -0
  57. package/src/audit/checks/navboost-signals.ts +395 -0
  58. package/src/audit/checks/on-page.ts +209 -0
  59. package/src/audit/checks/page-resources.ts +285 -0
  60. package/src/audit/checks/pagination.ts +180 -0
  61. package/src/audit/checks/performance.ts +153 -0
  62. package/src/audit/checks/platform-presence.ts +580 -0
  63. package/src/audit/checks/redirect-analysis.ts +153 -0
  64. package/src/audit/checks/redirect-chain.ts +389 -0
  65. package/src/audit/checks/resource-hints.ts +420 -0
  66. package/src/audit/checks/responsive-css.ts +247 -0
  67. package/src/audit/checks/responsive-images.ts +396 -0
  68. package/src/audit/checks/review-ecosystem.ts +415 -0
  69. package/src/audit/checks/robots-validation.ts +373 -0
  70. package/src/audit/checks/security-headers.ts +172 -0
  71. package/src/audit/checks/security.ts +144 -0
  72. package/src/audit/checks/serp-preview.ts +251 -0
  73. package/src/audit/checks/site-maturity.ts +444 -0
  74. package/src/audit/checks/social-meta.test.ts +275 -0
  75. package/src/audit/checks/social-meta.ts +134 -0
  76. package/src/audit/checks/soft-404.ts +151 -0
  77. package/src/audit/checks/structured-data.ts +238 -0
  78. package/src/audit/checks/tech-detection.ts +496 -0
  79. package/src/audit/checks/topical-clusters.ts +435 -0
  80. package/src/audit/checks/tracker-bloat.ts +462 -0
  81. package/src/audit/checks/tracking-verification.test.ts +371 -0
  82. package/src/audit/checks/tracking-verification.ts +636 -0
  83. package/src/audit/checks/url-safety.ts +682 -0
  84. package/src/audit/deno-entry.ts +66 -0
  85. package/src/audit/discovery/index.ts +15 -0
  86. package/src/audit/discovery/link-crawler.ts +232 -0
  87. package/src/audit/discovery/repo-routes.ts +347 -0
  88. package/src/audit/engine.ts +620 -0
  89. package/src/audit/fixes/index.ts +209 -0
  90. package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
  91. package/src/audit/fixes/social-meta-fixes.ts +463 -0
  92. package/src/audit/index.ts +74 -0
  93. package/src/audit/runner.test.ts +299 -0
  94. package/src/audit/runner.ts +130 -0
  95. package/src/audit/types.ts +1953 -0
  96. package/src/content/featured-snippet.ts +367 -0
  97. package/src/content/generator.test.ts +534 -0
  98. package/src/content/generator.ts +501 -0
  99. package/src/content/headline.ts +317 -0
  100. package/src/content/index.ts +62 -0
  101. package/src/content/intent.ts +258 -0
  102. package/src/content/keyword-density.ts +349 -0
  103. package/src/content/readability.ts +262 -0
  104. package/src/executor.ts +336 -0
  105. package/src/fixer.ts +416 -0
  106. package/src/frameworks/detector.test.ts +248 -0
  107. package/src/frameworks/detector.ts +371 -0
  108. package/src/frameworks/index.ts +68 -0
  109. package/src/frameworks/recipes/angular.yaml +171 -0
  110. package/src/frameworks/recipes/astro.yaml +206 -0
  111. package/src/frameworks/recipes/django.yaml +180 -0
  112. package/src/frameworks/recipes/laravel.yaml +137 -0
  113. package/src/frameworks/recipes/nextjs.yaml +268 -0
  114. package/src/frameworks/recipes/nuxt.yaml +175 -0
  115. package/src/frameworks/recipes/rails.yaml +188 -0
  116. package/src/frameworks/recipes/react.yaml +202 -0
  117. package/src/frameworks/recipes/sveltekit.yaml +154 -0
  118. package/src/frameworks/recipes/vue.yaml +137 -0
  119. package/src/frameworks/recipes/wordpress.yaml +209 -0
  120. package/src/frameworks/suggestion-engine.ts +320 -0
  121. package/src/geo/geo-content.test.ts +305 -0
  122. package/src/geo/geo-content.ts +266 -0
  123. package/src/geo/geo-history.test.ts +473 -0
  124. package/src/geo/geo-history.ts +433 -0
  125. package/src/geo/geo-tracker.test.ts +359 -0
  126. package/src/geo/geo-tracker.ts +411 -0
  127. package/src/geo/index.ts +10 -0
  128. package/src/git/commit-helper.test.ts +261 -0
  129. package/src/git/commit-helper.ts +329 -0
  130. package/src/git/index.ts +12 -0
  131. package/src/git/pr-helper.test.ts +284 -0
  132. package/src/git/pr-helper.ts +307 -0
  133. package/src/index.ts +66 -0
  134. package/src/keywords/ai-keyword-engine.ts +1062 -0
  135. package/src/keywords/ai-summarizer.ts +387 -0
  136. package/src/keywords/ci-mode.ts +555 -0
  137. package/src/keywords/engine.ts +359 -0
  138. package/src/keywords/index.ts +151 -0
  139. package/src/keywords/llm-judge.ts +357 -0
  140. package/src/keywords/nlp-analysis.ts +706 -0
  141. package/src/keywords/prioritizer.ts +295 -0
  142. package/src/keywords/site-crawler.ts +342 -0
  143. package/src/keywords/sources/autocomplete.ts +139 -0
  144. package/src/keywords/sources/competitive-search.ts +450 -0
  145. package/src/keywords/sources/competitor-analysis.ts +374 -0
  146. package/src/keywords/sources/dataforseo.ts +206 -0
  147. package/src/keywords/sources/free-sources.ts +294 -0
  148. package/src/keywords/sources/gsc.ts +123 -0
  149. package/src/keywords/topic-grouping.ts +327 -0
  150. package/src/keywords/types.ts +144 -0
  151. package/src/keywords/wizard.ts +457 -0
  152. package/src/loader.ts +40 -0
  153. package/src/reports/index.ts +7 -0
  154. package/src/reports/report-generator.test.ts +293 -0
  155. package/src/reports/report-generator.ts +713 -0
  156. package/src/scheduler/alerts.test.ts +458 -0
  157. package/src/scheduler/alerts.ts +328 -0
  158. package/src/scheduler/index.ts +8 -0
  159. package/src/scheduler/scheduled-audit.test.ts +377 -0
  160. package/src/scheduler/scheduled-audit.ts +149 -0
  161. package/src/test/integration-test.ts +325 -0
  162. package/src/tools/analyzer.ts +373 -0
  163. package/src/tools/crawl.ts +293 -0
  164. package/src/tools/files.ts +301 -0
  165. package/src/tools/h1-fixer.ts +249 -0
  166. package/src/tools/index.ts +67 -0
  167. package/src/tracking/github-action.ts +326 -0
  168. package/src/tracking/google-analytics.ts +265 -0
  169. package/src/tracking/index.ts +45 -0
  170. package/src/tracking/report-generator.ts +386 -0
  171. package/src/tracking/search-console.ts +335 -0
  172. package/src/types.ts +134 -0
  173. package/src/utils/http.ts +302 -0
  174. package/src/wasm-adapter.ts +297 -0
  175. package/src/wasm-entry.ts +14 -0
  176. package/tsconfig.json +17 -0
  177. package/tsup.wasm.config.ts +26 -0
  178. package/vitest.config.ts +15 -0
@@ -0,0 +1,420 @@
1
+ // Resource Hints Optimization Checks
2
+ // Analyzes dns-prefetch, preconnect, prefetch, preload, modulepreload, and early hints
3
+ // Based on advanced performance SEO research
4
+
5
+ import * as cheerio from 'cheerio';
6
+ import { httpGet } from '../../utils/http.js';
7
+ import type { AuditIssue } from '../types.js';
8
+
9
+ export interface ResourceHint {
10
+ type: 'dns-prefetch' | 'preconnect' | 'prefetch' | 'preload' | 'modulepreload';
11
+ href: string;
12
+ as?: string;
13
+ crossorigin?: string;
14
+ }
15
+
16
+ export interface ResourceHintsData {
17
+ dnsPrefetch: string[];
18
+ preconnect: string[];
19
+ prefetch: string[];
20
+ preload: ResourceHint[];
21
+ modulepreload: string[];
22
+ thirdPartyDomains: string[];
23
+ missingPreconnects: string[];
24
+ earlyHints: boolean;
25
+ http2Push: boolean;
26
+ }
27
+
28
+ /**
29
+ * Extract all resource hints from HTML
30
+ */
31
+ export function extractResourceHints(html: string): ResourceHint[] {
32
+ const $ = cheerio.load(html);
33
+ const hints: ResourceHint[] = [];
34
+
35
+ // dns-prefetch
36
+ $('link[rel="dns-prefetch"]').each((_, el) => {
37
+ const href = $(el).attr('href');
38
+ if (href) {
39
+ hints.push({ type: 'dns-prefetch', href });
40
+ }
41
+ });
42
+
43
+ // preconnect
44
+ $('link[rel="preconnect"]').each((_, el) => {
45
+ const href = $(el).attr('href');
46
+ if (href) {
47
+ hints.push({
48
+ type: 'preconnect',
49
+ href,
50
+ crossorigin: $(el).attr('crossorigin'),
51
+ });
52
+ }
53
+ });
54
+
55
+ // prefetch
56
+ $('link[rel="prefetch"]').each((_, el) => {
57
+ const href = $(el).attr('href');
58
+ if (href) {
59
+ hints.push({
60
+ type: 'prefetch',
61
+ href,
62
+ as: $(el).attr('as'),
63
+ });
64
+ }
65
+ });
66
+
67
+ // preload
68
+ $('link[rel="preload"]').each((_, el) => {
69
+ const href = $(el).attr('href');
70
+ if (href) {
71
+ hints.push({
72
+ type: 'preload',
73
+ href,
74
+ as: $(el).attr('as'),
75
+ crossorigin: $(el).attr('crossorigin'),
76
+ });
77
+ }
78
+ });
79
+
80
+ // modulepreload
81
+ $('link[rel="modulepreload"]').each((_, el) => {
82
+ const href = $(el).attr('href');
83
+ if (href) {
84
+ hints.push({ type: 'modulepreload', href });
85
+ }
86
+ });
87
+
88
+ return hints;
89
+ }
90
+
91
+ /**
92
+ * Detect third-party domains used on the page
93
+ */
94
+ export function detectThirdPartyDomains(html: string, pageUrl: string): string[] {
95
+ const $ = cheerio.load(html);
96
+ const baseDomain = new URL(pageUrl).hostname;
97
+ const domains = new Set<string>();
98
+
99
+ // Check scripts
100
+ $('script[src]').each((_, el) => {
101
+ try {
102
+ const src = $(el).attr('src')!;
103
+ const url = new URL(src, pageUrl);
104
+ if (url.hostname !== baseDomain) {
105
+ domains.add(url.origin);
106
+ }
107
+ } catch {
108
+ // Invalid URL
109
+ }
110
+ });
111
+
112
+ // Check stylesheets
113
+ $('link[rel="stylesheet"][href]').each((_, el) => {
114
+ try {
115
+ const href = $(el).attr('href')!;
116
+ const url = new URL(href, pageUrl);
117
+ if (url.hostname !== baseDomain) {
118
+ domains.add(url.origin);
119
+ }
120
+ } catch {
121
+ // Invalid URL
122
+ }
123
+ });
124
+
125
+ // Check images
126
+ $('img[src]').each((_, el) => {
127
+ try {
128
+ const src = $(el).attr('src')!;
129
+ const url = new URL(src, pageUrl);
130
+ if (url.hostname !== baseDomain) {
131
+ domains.add(url.origin);
132
+ }
133
+ } catch {
134
+ // Invalid URL
135
+ }
136
+ });
137
+
138
+ // Check fonts (from @font-face in style tags - simplified)
139
+ const styleContent = $('style').text();
140
+ const fontUrlMatches = styleContent.match(/url\(['"]?(https?:\/\/[^'")\s]+)/g) || [];
141
+ for (const match of fontUrlMatches) {
142
+ try {
143
+ const url = match.replace(/url\(['"]?/, '');
144
+ const parsed = new URL(url);
145
+ if (parsed.hostname !== baseDomain) {
146
+ domains.add(parsed.origin);
147
+ }
148
+ } catch {
149
+ // Invalid URL
150
+ }
151
+ }
152
+
153
+ return [...domains];
154
+ }
155
+
156
+ /**
157
+ * Common third-party services that should have preconnect
158
+ */
159
+ const CRITICAL_THIRD_PARTIES: Record<string, string[]> = {
160
+ 'fonts.googleapis.com': ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'],
161
+ 'fonts.gstatic.com': ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'],
162
+ 'www.google-analytics.com': ['https://www.google-analytics.com'],
163
+ 'www.googletagmanager.com': ['https://www.googletagmanager.com'],
164
+ 'cdn.jsdelivr.net': ['https://cdn.jsdelivr.net'],
165
+ 'unpkg.com': ['https://unpkg.com'],
166
+ 'cdnjs.cloudflare.com': ['https://cdnjs.cloudflare.com'],
167
+ 'ajax.googleapis.com': ['https://ajax.googleapis.com'],
168
+ 'connect.facebook.net': ['https://connect.facebook.net'],
169
+ 'platform.twitter.com': ['https://platform.twitter.com'],
170
+ 'www.youtube.com': ['https://www.youtube.com', 'https://i.ytimg.com'],
171
+ 'player.vimeo.com': ['https://player.vimeo.com'],
172
+ 'js.stripe.com': ['https://js.stripe.com'],
173
+ };
174
+
175
+ /**
176
+ * Find missing preconnects for critical third-party resources
177
+ */
178
+ export function findMissingPreconnects(
179
+ thirdPartyDomains: string[],
180
+ existingHints: ResourceHint[]
181
+ ): string[] {
182
+ const preconnected = new Set(
183
+ existingHints
184
+ .filter((h) => h.type === 'preconnect' || h.type === 'dns-prefetch')
185
+ .map((h) => {
186
+ try {
187
+ return new URL(h.href).origin;
188
+ } catch {
189
+ return h.href;
190
+ }
191
+ })
192
+ );
193
+
194
+ const missing: string[] = [];
195
+
196
+ for (const domain of thirdPartyDomains) {
197
+ try {
198
+ const hostname = new URL(domain).hostname;
199
+ const recommended = CRITICAL_THIRD_PARTIES[hostname] || [domain];
200
+
201
+ for (const rec of recommended) {
202
+ if (!preconnected.has(rec)) {
203
+ missing.push(rec);
204
+ }
205
+ }
206
+ } catch {
207
+ // Invalid URL
208
+ }
209
+ }
210
+
211
+ return [...new Set(missing)];
212
+ }
213
+
214
+ /**
215
+ * Check for HTTP/2 Server Push and 103 Early Hints
216
+ */
217
+ export async function checkAdvancedHints(
218
+ url: string
219
+ ): Promise<{ earlyHints: boolean; http2Push: boolean; linkHeaders: string[] }> {
220
+ try {
221
+ const response = await httpGet<string>(url, {
222
+
223
+ timeout: 10000,
224
+ validateStatus: () => true,
225
+ // Note: fetch doesn't expose HTTP/2 details natively, this is simplified
226
+ });
227
+
228
+ const linkHeader = response.headers['link'] || '';
229
+ const linkHeaders = Array.isArray(linkHeader) ? linkHeader : [linkHeader];
230
+
231
+ // Check for preload hints in Link header
232
+ const hasPreloadLinks = linkHeaders.some(
233
+ (h) => h.includes('rel=preload') || h.includes("rel='preload'") || h.includes('rel="preload"')
234
+ );
235
+
236
+ return {
237
+ earlyHints: false, // Would need special handling to detect 103
238
+ http2Push: hasPreloadLinks,
239
+ linkHeaders: linkHeaders.filter(Boolean),
240
+ };
241
+ } catch {
242
+ return { earlyHints: false, http2Push: false, linkHeaders: [] };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Analyze preload usage for critical resources
248
+ */
249
+ export function analyzePreloads(
250
+ html: string,
251
+ preloads: ResourceHint[]
252
+ ): { missing: string[]; unused: string[] } {
253
+ const $ = cheerio.load(html);
254
+ const criticalResources: string[] = [];
255
+
256
+ // LCP image candidates (large hero images)
257
+ $('img').each((i, el) => {
258
+ if (i === 0) {
259
+ // First image is often LCP
260
+ const src = $(el).attr('src');
261
+ if (src) criticalResources.push(src);
262
+ }
263
+ });
264
+
265
+ // Critical fonts
266
+ $('link[rel="stylesheet"][href*="font"]').each((_, el) => {
267
+ const href = $(el).attr('href');
268
+ if (href) criticalResources.push(href);
269
+ });
270
+
271
+ // Check for missing preloads
272
+ const preloadedUrls = new Set(preloads.map((p) => p.href));
273
+ const missing = criticalResources.filter((r) => !preloadedUrls.has(r));
274
+
275
+ // Check for unused preloads (preloaded but not used)
276
+ // This would need runtime analysis, so we'll do a basic check
277
+ const pageContent = $.html();
278
+ const unused = preloads
279
+ .map((p) => p.href)
280
+ .filter((href) => !pageContent.includes(href.split('/').pop() || ''));
281
+
282
+ return { missing: missing.slice(0, 3), unused };
283
+ }
284
+
285
+ /**
286
+ * Main function: Analyze resource hints optimization
287
+ */
288
+ export function analyzeResourceHints(
289
+ html: string,
290
+ url: string
291
+ ): { issues: AuditIssue[]; data: ResourceHintsData } {
292
+ const issues: AuditIssue[] = [];
293
+
294
+ const hints = extractResourceHints(html);
295
+ const thirdPartyDomains = detectThirdPartyDomains(html, url);
296
+ const missingPreconnects = findMissingPreconnects(thirdPartyDomains, hints);
297
+
298
+ const dnsPrefetch = hints.filter((h) => h.type === 'dns-prefetch').map((h) => h.href);
299
+ const preconnect = hints.filter((h) => h.type === 'preconnect').map((h) => h.href);
300
+ const prefetch = hints.filter((h) => h.type === 'prefetch').map((h) => h.href);
301
+ const preload = hints.filter((h) => h.type === 'preload');
302
+ const modulepreload = hints.filter((h) => h.type === 'modulepreload').map((h) => h.href);
303
+
304
+ // Check for missing preconnects
305
+ if (missingPreconnects.length > 0) {
306
+ issues.push({
307
+ code: 'MISSING_PRECONNECT',
308
+ severity: 'warning',
309
+ category: 'performance',
310
+ title: 'Missing preconnect hints for third-party origins',
311
+ description: `${missingPreconnects.length} third-party origins used without preconnect hints.`,
312
+ impact: 'Connection setup to third-party origins adds latency to resource loading.',
313
+ howToFix: `Add preconnect hints: ${missingPreconnects.slice(0, 3).map((d) => `<link rel="preconnect" href="${d}">`).join(' ')}`,
314
+ affectedUrls: [url],
315
+ details: { missingPreconnects: missingPreconnects.slice(0, 5) },
316
+ });
317
+ }
318
+
319
+ // Check if Google Fonts are used without proper hints
320
+ const usesGoogleFonts = thirdPartyDomains.some(
321
+ (d) => d.includes('fonts.googleapis.com') || d.includes('fonts.gstatic.com')
322
+ );
323
+ const hasGoogleFontsPreconnect =
324
+ preconnect.some((h) => h.includes('fonts.googleapis.com')) &&
325
+ preconnect.some((h) => h.includes('fonts.gstatic.com'));
326
+
327
+ if (usesGoogleFonts && !hasGoogleFontsPreconnect) {
328
+ issues.push({
329
+ code: 'GOOGLE_FONTS_NO_PRECONNECT',
330
+ severity: 'warning',
331
+ category: 'performance',
332
+ title: 'Google Fonts without preconnect',
333
+ description: 'Google Fonts are used but preconnect hints are missing.',
334
+ impact: 'Adds significant latency to font loading, affecting LCP.',
335
+ howToFix:
336
+ 'Add: <link rel="preconnect" href="https://fonts.googleapis.com"> and <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
337
+ affectedUrls: [url],
338
+ });
339
+ }
340
+
341
+ // Check for LCP image preload
342
+ const $ = cheerio.load(html);
343
+ const hasHeroImage = $('img').first().length > 0;
344
+ const hasImagePreload = preload.some((p) => p.as === 'image');
345
+
346
+ if (hasHeroImage && !hasImagePreload) {
347
+ issues.push({
348
+ code: 'HERO_IMAGE_NOT_PRELOADED',
349
+ severity: 'notice',
350
+ category: 'performance',
351
+ title: 'Hero image not preloaded',
352
+ description: 'Page has images but none are preloaded for faster LCP.',
353
+ impact: 'Preloading LCP images can improve Largest Contentful Paint.',
354
+ howToFix: 'Add <link rel="preload" href="hero-image.jpg" as="image"> for your LCP image.',
355
+ affectedUrls: [url],
356
+ });
357
+ }
358
+
359
+ // Check for critical CSS preload
360
+ const hasRenderBlockingCSS =
361
+ $('link[rel="stylesheet"]').filter((_, el) => !$(el).attr('media') || $(el).attr('media') === 'all').length > 0;
362
+ const hasCSSPreload = preload.some((p) => p.as === 'style');
363
+
364
+ if (hasRenderBlockingCSS && !hasCSSPreload && preload.length === 0) {
365
+ issues.push({
366
+ code: 'NO_CRITICAL_RESOURCE_PRELOAD',
367
+ severity: 'notice',
368
+ category: 'performance',
369
+ title: 'No critical resources preloaded',
370
+ description: 'Page has render-blocking resources but no preload hints.',
371
+ impact: 'Preloading critical resources can improve initial render time.',
372
+ howToFix: 'Consider preloading critical CSS, fonts, or hero images.',
373
+ affectedUrls: [url],
374
+ });
375
+ }
376
+
377
+ // Check for excessive preloads (can hurt performance)
378
+ if (preload.length > 10) {
379
+ issues.push({
380
+ code: 'EXCESSIVE_PRELOADS',
381
+ severity: 'warning',
382
+ category: 'performance',
383
+ title: 'Too many preload hints',
384
+ description: `Page has ${preload.length} preload hints, which can degrade performance.`,
385
+ impact: 'Preloading too many resources competes for bandwidth and can delay critical resources.',
386
+ howToFix: 'Limit preloads to truly critical resources (2-5 maximum).',
387
+ affectedUrls: [url],
388
+ });
389
+ }
390
+
391
+ // Check for modulepreload for ES modules
392
+ const hasESModules = $('script[type="module"]').length > 0;
393
+ if (hasESModules && modulepreload.length === 0) {
394
+ issues.push({
395
+ code: 'ES_MODULES_NO_MODULEPRELOAD',
396
+ severity: 'notice',
397
+ category: 'performance',
398
+ title: 'ES modules without modulepreload',
399
+ description: 'Page uses ES modules but no modulepreload hints are present.',
400
+ impact: 'Modulepreload can parallelize module loading for faster execution.',
401
+ howToFix: 'Add <link rel="modulepreload" href="critical-module.js"> for critical modules.',
402
+ affectedUrls: [url],
403
+ });
404
+ }
405
+
406
+ return {
407
+ issues,
408
+ data: {
409
+ dnsPrefetch,
410
+ preconnect,
411
+ prefetch,
412
+ preload,
413
+ modulepreload,
414
+ thirdPartyDomains,
415
+ missingPreconnects,
416
+ earlyHints: false,
417
+ http2Push: false,
418
+ },
419
+ };
420
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Responsive CSS Check
3
+ *
4
+ * Checks if the site uses responsive design through CSS media queries.
5
+ * Responsive design is essential for:
6
+ * - Mobile-first indexing (Google uses mobile version for ranking)
7
+ * - User experience across devices
8
+ * - Core Web Vitals on mobile
9
+ *
10
+ * Detection methods:
11
+ * - @media queries in inline styles
12
+ * - @media queries in linked stylesheets
13
+ * - Viewport meta tag (required for responsiveness)
14
+ */
15
+
16
+ import { httpGet } from '../../utils/http.js';
17
+ import * as cheerio from 'cheerio';
18
+ import type { AuditIssue } from '../types.js';
19
+
20
+ export interface ResponsiveCssData {
21
+ hasViewport: boolean;
22
+ viewportContent?: string;
23
+ hasMediaQueries: boolean;
24
+ mediaQueryCount: number;
25
+ mediaQuerySources: {
26
+ inline: number;
27
+ external: string[];
28
+ };
29
+ breakpoints: string[];
30
+ hasMobileBreakpoint: boolean;
31
+ hasTabletBreakpoint: boolean;
32
+ hasDesktopBreakpoint: boolean;
33
+ }
34
+
35
+ /**
36
+ * Extract media queries from CSS content
37
+ */
38
+ function extractMediaQueries(css: string): string[] {
39
+ const mediaQueries: string[] = [];
40
+ // Match @media queries
41
+ const regex = /@media\s*([^{]+)/g;
42
+ let match;
43
+ while ((match = regex.exec(css)) !== null) {
44
+ mediaQueries.push(match[1].trim());
45
+ }
46
+ return mediaQueries;
47
+ }
48
+
49
+ /**
50
+ * Classify breakpoints
51
+ */
52
+ function classifyBreakpoints(mediaQueries: string[]): {
53
+ hasMobile: boolean;
54
+ hasTablet: boolean;
55
+ hasDesktop: boolean;
56
+ breakpoints: string[];
57
+ } {
58
+ const breakpoints = new Set<string>();
59
+ let hasMobile = false;
60
+ let hasTablet = false;
61
+ let hasDesktop = false;
62
+
63
+ for (const query of mediaQueries) {
64
+ // Extract width values from media queries
65
+ const widthMatches = query.match(/(?:min|max)-width\s*:\s*(\d+)(?:px|em|rem)?/gi);
66
+ if (widthMatches) {
67
+ for (const match of widthMatches) {
68
+ const valueMatch = match.match(/(\d+)/);
69
+ if (valueMatch) {
70
+ const value = parseInt(valueMatch[1], 10);
71
+ breakpoints.add(`${value}px`);
72
+
73
+ // Classify breakpoint
74
+ if (value <= 480) {
75
+ hasMobile = true;
76
+ } else if (value <= 768) {
77
+ hasMobile = true; // Small tablet / large phone
78
+ } else if (value <= 1024) {
79
+ hasTablet = true;
80
+ } else {
81
+ hasDesktop = true;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Check for common responsive patterns
88
+ if (/screen\s+and/i.test(query)) {
89
+ // Has screen media type - indicates responsive design
90
+ }
91
+
92
+ // Check for print media (not responsive but valid)
93
+ if (/print/i.test(query)) {
94
+ continue; // Don't count print as responsive
95
+ }
96
+ }
97
+
98
+ return {
99
+ hasMobile,
100
+ hasTablet,
101
+ hasDesktop,
102
+ breakpoints: Array.from(breakpoints).sort((a, b) => parseInt(a) - parseInt(b)),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Analyze responsive CSS
108
+ */
109
+ export async function analyzeResponsiveCss(
110
+ html: string,
111
+ url: string
112
+ ): Promise<{ issues: AuditIssue[]; data: ResponsiveCssData }> {
113
+ const issues: AuditIssue[] = [];
114
+ const $ = cheerio.load(html);
115
+ const baseUrl = new URL(url);
116
+
117
+ // Check viewport meta tag
118
+ const viewportMeta = $('meta[name="viewport"]').attr('content');
119
+ const hasViewport = !!viewportMeta;
120
+
121
+ // Collect all media queries
122
+ const allMediaQueries: string[] = [];
123
+ const externalSources: string[] = [];
124
+ let inlineCount = 0;
125
+
126
+ // Check inline styles
127
+ $('style').each((_, el) => {
128
+ const css = $(el).text();
129
+ const queries = extractMediaQueries(css);
130
+ if (queries.length > 0) {
131
+ allMediaQueries.push(...queries);
132
+ inlineCount += queries.length;
133
+ }
134
+ });
135
+
136
+ // Check linked stylesheets (first-party only, limit to 3)
137
+ const cssLinks = $('link[rel="stylesheet"][href]')
138
+ .toArray()
139
+ .slice(0, 3)
140
+ .map((el) => $(el).attr('href'))
141
+ .filter((href): href is string => {
142
+ if (!href || href.startsWith('data:')) return false;
143
+ try {
144
+ const cssHostname = new URL(href, url).hostname;
145
+ return cssHostname === baseUrl.hostname;
146
+ } catch {
147
+ return false;
148
+ }
149
+ });
150
+
151
+ // Fetch CSS files in parallel with short timeout
152
+ const cssPromises = cssLinks.map(async (cssUrl) => {
153
+ try {
154
+ const fullUrl = new URL(cssUrl, url).href;
155
+ const response = await httpGet<string>(fullUrl, {
156
+ timeout: 3000, // Reduced timeout
157
+ validateStatus: () => true,
158
+ });
159
+
160
+ if (response.status === 200 && typeof response.data === 'string') {
161
+ const queries = extractMediaQueries(response.data);
162
+ if (queries.length > 0) {
163
+ allMediaQueries.push(...queries);
164
+ externalSources.push(cssUrl);
165
+ }
166
+ }
167
+ } catch {
168
+ // Skip failed requests
169
+ }
170
+ });
171
+
172
+ // Wait for all CSS checks with overall timeout
173
+ await Promise.race([
174
+ Promise.all(cssPromises),
175
+ new Promise<void>((resolve) => setTimeout(resolve, 6000)), // 6s overall timeout
176
+ ]);
177
+
178
+ const hasMediaQueries = allMediaQueries.length > 0;
179
+ const { hasMobile, hasTablet, hasDesktop, breakpoints } = classifyBreakpoints(allMediaQueries);
180
+
181
+ // Generate issues
182
+ if (!hasViewport) {
183
+ issues.push({
184
+ code: 'RESPONSIVE_NO_VIEWPORT',
185
+ severity: 'error',
186
+ category: 'mobile',
187
+ title: 'Missing viewport meta tag',
188
+ description: 'No viewport meta tag found. This is required for responsive design to work properly.',
189
+ impact:
190
+ 'Without a viewport meta tag, mobile browsers will render the page at desktop width and scale down, making it unusable.',
191
+ howToFix: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to the <head> section.',
192
+ affectedUrls: [url],
193
+ });
194
+ }
195
+
196
+ if (!hasMediaQueries) {
197
+ issues.push({
198
+ code: 'RESPONSIVE_NO_MEDIA_QUERIES',
199
+ severity: 'warning',
200
+ category: 'mobile',
201
+ title: 'No CSS media queries detected',
202
+ description: 'No @media queries found in inline styles or first-party stylesheets.',
203
+ impact:
204
+ 'Without media queries, the site likely uses fixed layouts that do not adapt to different screen sizes. This hurts mobile usability and SEO.',
205
+ howToFix:
206
+ 'Add CSS media queries to adapt layout for different screen sizes. Consider using a mobile-first approach with min-width breakpoints.',
207
+ affectedUrls: [url],
208
+ details: {
209
+ suggestion: 'Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)',
210
+ },
211
+ });
212
+ } else if (!hasMobile && !hasTablet) {
213
+ issues.push({
214
+ code: 'RESPONSIVE_NO_MOBILE_BREAKPOINT',
215
+ severity: 'warning',
216
+ category: 'mobile',
217
+ title: 'No mobile breakpoints detected',
218
+ description: `Found ${allMediaQueries.length} media queries but none target mobile screen sizes (≤768px).`,
219
+ impact:
220
+ 'Site may not be optimized for mobile devices, which affects majority of web traffic and Google mobile-first indexing.',
221
+ howToFix:
222
+ 'Add media queries for mobile devices. Example: @media (max-width: 768px) { ... } or use min-width for mobile-first.',
223
+ affectedUrls: [url],
224
+ details: {
225
+ detectedBreakpoints: breakpoints,
226
+ },
227
+ });
228
+ }
229
+
230
+ return {
231
+ issues,
232
+ data: {
233
+ hasViewport,
234
+ viewportContent: viewportMeta,
235
+ hasMediaQueries,
236
+ mediaQueryCount: allMediaQueries.length,
237
+ mediaQuerySources: {
238
+ inline: inlineCount,
239
+ external: externalSources,
240
+ },
241
+ breakpoints,
242
+ hasMobileBreakpoint: hasMobile,
243
+ hasTabletBreakpoint: hasTablet,
244
+ hasDesktopBreakpoint: hasDesktop,
245
+ },
246
+ };
247
+ }