@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.
- package/README.md +242 -0
- package/dist/analyzer-2CSWIQGD.mjs +6 -0
- package/dist/chunk-YNZYHEYM.mjs +774 -0
- package/dist/index.d.mts +4012 -0
- package/dist/index.d.ts +4012 -0
- package/dist/index.js +29672 -0
- package/dist/index.mjs +28602 -0
- package/package.json +53 -0
- package/scripts/build-deno.ts +134 -0
- package/src/audit/ai/analyzer.ts +347 -0
- package/src/audit/ai/index.ts +29 -0
- package/src/audit/ai/prompts/content-analysis.ts +271 -0
- package/src/audit/ai/types.ts +179 -0
- package/src/audit/checks/additional-checks.ts +439 -0
- package/src/audit/checks/ai-citation-worthiness.ts +399 -0
- package/src/audit/checks/ai-content-structure.ts +325 -0
- package/src/audit/checks/ai-readiness.ts +339 -0
- package/src/audit/checks/anchor-text.ts +179 -0
- package/src/audit/checks/answer-conciseness.ts +322 -0
- package/src/audit/checks/asset-minification.ts +270 -0
- package/src/audit/checks/bing-optimization.ts +206 -0
- package/src/audit/checks/brand-mention-optimization.ts +349 -0
- package/src/audit/checks/caching-headers.ts +305 -0
- package/src/audit/checks/canonical-advanced.ts +150 -0
- package/src/audit/checks/canonical-domain.ts +196 -0
- package/src/audit/checks/citation-quality.ts +358 -0
- package/src/audit/checks/client-rendering.ts +542 -0
- package/src/audit/checks/color-contrast.ts +342 -0
- package/src/audit/checks/content-freshness.ts +170 -0
- package/src/audit/checks/content-science.ts +589 -0
- package/src/audit/checks/conversion-elements.ts +526 -0
- package/src/audit/checks/crawlability.ts +220 -0
- package/src/audit/checks/directory-listing.ts +172 -0
- package/src/audit/checks/dom-analysis.ts +191 -0
- package/src/audit/checks/dom-size.ts +246 -0
- package/src/audit/checks/duplicate-content.ts +194 -0
- package/src/audit/checks/eeat-signals.ts +990 -0
- package/src/audit/checks/entity-seo.ts +396 -0
- package/src/audit/checks/featured-snippet.ts +473 -0
- package/src/audit/checks/freshness-signals.ts +443 -0
- package/src/audit/checks/funnel-intent.ts +463 -0
- package/src/audit/checks/hreflang.ts +174 -0
- package/src/audit/checks/html-compliance.ts +302 -0
- package/src/audit/checks/image-dimensions.ts +167 -0
- package/src/audit/checks/images.ts +160 -0
- package/src/audit/checks/indexnow.ts +275 -0
- package/src/audit/checks/interactive-tools.ts +475 -0
- package/src/audit/checks/internal-link-graph.ts +436 -0
- package/src/audit/checks/keyword-analysis.ts +239 -0
- package/src/audit/checks/keyword-cannibalization.ts +385 -0
- package/src/audit/checks/keyword-placement.ts +471 -0
- package/src/audit/checks/links.ts +203 -0
- package/src/audit/checks/llms-txt.ts +224 -0
- package/src/audit/checks/local-seo.ts +296 -0
- package/src/audit/checks/mobile.ts +167 -0
- package/src/audit/checks/modern-images.ts +226 -0
- package/src/audit/checks/navboost-signals.ts +395 -0
- package/src/audit/checks/on-page.ts +209 -0
- package/src/audit/checks/page-resources.ts +285 -0
- package/src/audit/checks/pagination.ts +180 -0
- package/src/audit/checks/performance.ts +153 -0
- package/src/audit/checks/platform-presence.ts +580 -0
- package/src/audit/checks/redirect-analysis.ts +153 -0
- package/src/audit/checks/redirect-chain.ts +389 -0
- package/src/audit/checks/resource-hints.ts +420 -0
- package/src/audit/checks/responsive-css.ts +247 -0
- package/src/audit/checks/responsive-images.ts +396 -0
- package/src/audit/checks/review-ecosystem.ts +415 -0
- package/src/audit/checks/robots-validation.ts +373 -0
- package/src/audit/checks/security-headers.ts +172 -0
- package/src/audit/checks/security.ts +144 -0
- package/src/audit/checks/serp-preview.ts +251 -0
- package/src/audit/checks/site-maturity.ts +444 -0
- package/src/audit/checks/social-meta.test.ts +275 -0
- package/src/audit/checks/social-meta.ts +134 -0
- package/src/audit/checks/soft-404.ts +151 -0
- package/src/audit/checks/structured-data.ts +238 -0
- package/src/audit/checks/tech-detection.ts +496 -0
- package/src/audit/checks/topical-clusters.ts +435 -0
- package/src/audit/checks/tracker-bloat.ts +462 -0
- package/src/audit/checks/tracking-verification.test.ts +371 -0
- package/src/audit/checks/tracking-verification.ts +636 -0
- package/src/audit/checks/url-safety.ts +682 -0
- package/src/audit/deno-entry.ts +66 -0
- package/src/audit/discovery/index.ts +15 -0
- package/src/audit/discovery/link-crawler.ts +232 -0
- package/src/audit/discovery/repo-routes.ts +347 -0
- package/src/audit/engine.ts +620 -0
- package/src/audit/fixes/index.ts +209 -0
- package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
- package/src/audit/fixes/social-meta-fixes.ts +463 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/runner.test.ts +299 -0
- package/src/audit/runner.ts +130 -0
- package/src/audit/types.ts +1953 -0
- package/src/content/featured-snippet.ts +367 -0
- package/src/content/generator.test.ts +534 -0
- package/src/content/generator.ts +501 -0
- package/src/content/headline.ts +317 -0
- package/src/content/index.ts +62 -0
- package/src/content/intent.ts +258 -0
- package/src/content/keyword-density.ts +349 -0
- package/src/content/readability.ts +262 -0
- package/src/executor.ts +336 -0
- package/src/fixer.ts +416 -0
- package/src/frameworks/detector.test.ts +248 -0
- package/src/frameworks/detector.ts +371 -0
- package/src/frameworks/index.ts +68 -0
- package/src/frameworks/recipes/angular.yaml +171 -0
- package/src/frameworks/recipes/astro.yaml +206 -0
- package/src/frameworks/recipes/django.yaml +180 -0
- package/src/frameworks/recipes/laravel.yaml +137 -0
- package/src/frameworks/recipes/nextjs.yaml +268 -0
- package/src/frameworks/recipes/nuxt.yaml +175 -0
- package/src/frameworks/recipes/rails.yaml +188 -0
- package/src/frameworks/recipes/react.yaml +202 -0
- package/src/frameworks/recipes/sveltekit.yaml +154 -0
- package/src/frameworks/recipes/vue.yaml +137 -0
- package/src/frameworks/recipes/wordpress.yaml +209 -0
- package/src/frameworks/suggestion-engine.ts +320 -0
- package/src/geo/geo-content.test.ts +305 -0
- package/src/geo/geo-content.ts +266 -0
- package/src/geo/geo-history.test.ts +473 -0
- package/src/geo/geo-history.ts +433 -0
- package/src/geo/geo-tracker.test.ts +359 -0
- package/src/geo/geo-tracker.ts +411 -0
- package/src/geo/index.ts +10 -0
- package/src/git/commit-helper.test.ts +261 -0
- package/src/git/commit-helper.ts +329 -0
- package/src/git/index.ts +12 -0
- package/src/git/pr-helper.test.ts +284 -0
- package/src/git/pr-helper.ts +307 -0
- package/src/index.ts +66 -0
- package/src/keywords/ai-keyword-engine.ts +1062 -0
- package/src/keywords/ai-summarizer.ts +387 -0
- package/src/keywords/ci-mode.ts +555 -0
- package/src/keywords/engine.ts +359 -0
- package/src/keywords/index.ts +151 -0
- package/src/keywords/llm-judge.ts +357 -0
- package/src/keywords/nlp-analysis.ts +706 -0
- package/src/keywords/prioritizer.ts +295 -0
- package/src/keywords/site-crawler.ts +342 -0
- package/src/keywords/sources/autocomplete.ts +139 -0
- package/src/keywords/sources/competitive-search.ts +450 -0
- package/src/keywords/sources/competitor-analysis.ts +374 -0
- package/src/keywords/sources/dataforseo.ts +206 -0
- package/src/keywords/sources/free-sources.ts +294 -0
- package/src/keywords/sources/gsc.ts +123 -0
- package/src/keywords/topic-grouping.ts +327 -0
- package/src/keywords/types.ts +144 -0
- package/src/keywords/wizard.ts +457 -0
- package/src/loader.ts +40 -0
- package/src/reports/index.ts +7 -0
- package/src/reports/report-generator.test.ts +293 -0
- package/src/reports/report-generator.ts +713 -0
- package/src/scheduler/alerts.test.ts +458 -0
- package/src/scheduler/alerts.ts +328 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/scheduled-audit.test.ts +377 -0
- package/src/scheduler/scheduled-audit.ts +149 -0
- package/src/test/integration-test.ts +325 -0
- package/src/tools/analyzer.ts +373 -0
- package/src/tools/crawl.ts +293 -0
- package/src/tools/files.ts +301 -0
- package/src/tools/h1-fixer.ts +249 -0
- package/src/tools/index.ts +67 -0
- package/src/tracking/github-action.ts +326 -0
- package/src/tracking/google-analytics.ts +265 -0
- package/src/tracking/index.ts +45 -0
- package/src/tracking/report-generator.ts +386 -0
- package/src/tracking/search-console.ts +335 -0
- package/src/types.ts +134 -0
- package/src/utils/http.ts +302 -0
- package/src/wasm-adapter.ts +297 -0
- package/src/wasm-entry.ts +14 -0
- package/tsconfig.json +17 -0
- package/tsup.wasm.config.ts +26 -0
- 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
|
+
}
|