@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,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Compliance Checks
|
|
3
|
+
*
|
|
4
|
+
* Validates basic HTML requirements that all major SEO tools check:
|
|
5
|
+
* - Doctype declaration (HTML5)
|
|
6
|
+
* - Character encoding (UTF-8)
|
|
7
|
+
* - Favicon presence
|
|
8
|
+
* - Language attribute
|
|
9
|
+
* - Viewport meta tag
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as cheerio from 'cheerio';
|
|
13
|
+
import { httpGet } from '../../utils/http.js';
|
|
14
|
+
import type { AuditIssue } from '../types.js';
|
|
15
|
+
|
|
16
|
+
export interface HtmlComplianceData {
|
|
17
|
+
doctype: {
|
|
18
|
+
present: boolean;
|
|
19
|
+
isHtml5: boolean;
|
|
20
|
+
value?: string;
|
|
21
|
+
};
|
|
22
|
+
charset: {
|
|
23
|
+
present: boolean;
|
|
24
|
+
isUtf8: boolean;
|
|
25
|
+
value?: string;
|
|
26
|
+
source: 'meta' | 'header' | 'none';
|
|
27
|
+
};
|
|
28
|
+
favicon: {
|
|
29
|
+
present: boolean;
|
|
30
|
+
locations: string[];
|
|
31
|
+
hasAppleTouchIcon: boolean;
|
|
32
|
+
formats: string[];
|
|
33
|
+
};
|
|
34
|
+
language: {
|
|
35
|
+
present: boolean;
|
|
36
|
+
value?: string;
|
|
37
|
+
};
|
|
38
|
+
viewport: {
|
|
39
|
+
present: boolean;
|
|
40
|
+
value?: string;
|
|
41
|
+
isResponsive: boolean;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function analyzeHtmlCompliance(
|
|
46
|
+
html: string,
|
|
47
|
+
url: string,
|
|
48
|
+
headers?: Record<string, string>
|
|
49
|
+
): Promise<{ issues: AuditIssue[]; data: HtmlComplianceData }> {
|
|
50
|
+
const issues: AuditIssue[] = [];
|
|
51
|
+
const $ = cheerio.load(html, { decodeEntities: false });
|
|
52
|
+
const parsedUrl = new URL(url);
|
|
53
|
+
|
|
54
|
+
// === DOCTYPE CHECK ===
|
|
55
|
+
const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
|
|
56
|
+
const hasDoctype = doctypeMatch !== null;
|
|
57
|
+
const doctypeValue = doctypeMatch ? doctypeMatch[0] : undefined;
|
|
58
|
+
const isHtml5 = hasDoctype && /<!DOCTYPE\s+html\s*>/i.test(html);
|
|
59
|
+
|
|
60
|
+
if (!hasDoctype) {
|
|
61
|
+
issues.push({
|
|
62
|
+
code: 'HTML_NO_DOCTYPE',
|
|
63
|
+
severity: 'warning',
|
|
64
|
+
category: 'on-page',
|
|
65
|
+
title: 'Missing DOCTYPE declaration',
|
|
66
|
+
description: 'Page is missing a DOCTYPE declaration. This puts browsers in quirks mode and may cause rendering issues.',
|
|
67
|
+
impact: 'Browsers may render the page inconsistently. Search engines expect valid HTML.',
|
|
68
|
+
howToFix: 'Add <!DOCTYPE html> as the very first line of your HTML document.',
|
|
69
|
+
affectedUrls: [url],
|
|
70
|
+
});
|
|
71
|
+
} else if (!isHtml5) {
|
|
72
|
+
issues.push({
|
|
73
|
+
code: 'HTML_OLD_DOCTYPE',
|
|
74
|
+
severity: 'notice',
|
|
75
|
+
category: 'on-page',
|
|
76
|
+
title: 'Using older DOCTYPE',
|
|
77
|
+
description: `Page uses "${doctypeValue}" instead of HTML5 DOCTYPE. HTML5 is the current standard.`,
|
|
78
|
+
impact: 'Older doctypes may limit modern HTML features.',
|
|
79
|
+
howToFix: 'Change your DOCTYPE to the HTML5 declaration: <!DOCTYPE html>',
|
|
80
|
+
affectedUrls: [url],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// === CHARSET CHECK ===
|
|
85
|
+
let charsetPresent = false;
|
|
86
|
+
let charsetValue: string | undefined;
|
|
87
|
+
let charsetSource: 'meta' | 'header' | 'none' = 'none';
|
|
88
|
+
|
|
89
|
+
// Check meta charset
|
|
90
|
+
const metaCharset = $('meta[charset]').attr('charset');
|
|
91
|
+
const metaContentType = $('meta[http-equiv="Content-Type"]').attr('content');
|
|
92
|
+
|
|
93
|
+
if (metaCharset) {
|
|
94
|
+
charsetPresent = true;
|
|
95
|
+
charsetValue = metaCharset;
|
|
96
|
+
charsetSource = 'meta';
|
|
97
|
+
} else if (metaContentType) {
|
|
98
|
+
const charsetMatch = metaContentType.match(/charset=([^\s;]+)/i);
|
|
99
|
+
if (charsetMatch) {
|
|
100
|
+
charsetPresent = true;
|
|
101
|
+
charsetValue = charsetMatch[1];
|
|
102
|
+
charsetSource = 'meta';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check Content-Type header
|
|
107
|
+
if (!charsetPresent && headers) {
|
|
108
|
+
const contentType = headers['content-type'] || headers['Content-Type'];
|
|
109
|
+
if (contentType) {
|
|
110
|
+
const charsetMatch = contentType.match(/charset=([^\s;]+)/i);
|
|
111
|
+
if (charsetMatch) {
|
|
112
|
+
charsetPresent = true;
|
|
113
|
+
charsetValue = charsetMatch[1];
|
|
114
|
+
charsetSource = 'header';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isUtf8 = charsetValue?.toLowerCase().replace('-', '') === 'utf8';
|
|
120
|
+
|
|
121
|
+
if (!charsetPresent) {
|
|
122
|
+
issues.push({
|
|
123
|
+
code: 'HTML_NO_CHARSET',
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
category: 'on-page',
|
|
126
|
+
title: 'Missing character encoding declaration',
|
|
127
|
+
description: 'Page does not specify character encoding. This can cause text display issues.',
|
|
128
|
+
impact: 'Characters may display incorrectly. Search engines may misinterpret content.',
|
|
129
|
+
howToFix: 'Add <meta charset="UTF-8"> in the <head> section, before any other elements.',
|
|
130
|
+
affectedUrls: [url],
|
|
131
|
+
});
|
|
132
|
+
} else if (!isUtf8) {
|
|
133
|
+
issues.push({
|
|
134
|
+
code: 'HTML_NOT_UTF8',
|
|
135
|
+
severity: 'notice',
|
|
136
|
+
category: 'on-page',
|
|
137
|
+
title: 'Not using UTF-8 encoding',
|
|
138
|
+
description: `Page uses "${charsetValue}" encoding. UTF-8 is recommended for universal character support.`,
|
|
139
|
+
impact: 'Some characters may not display correctly. UTF-8 is the web standard.',
|
|
140
|
+
howToFix: 'Change your charset to UTF-8: <meta charset="UTF-8">',
|
|
141
|
+
affectedUrls: [url],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// === FAVICON CHECK ===
|
|
146
|
+
const faviconLocations: string[] = [];
|
|
147
|
+
const faviconFormats: string[] = [];
|
|
148
|
+
let hasAppleTouchIcon = false;
|
|
149
|
+
|
|
150
|
+
// Check link tags for favicon
|
|
151
|
+
$('link[rel*="icon"]').each((_, el) => {
|
|
152
|
+
const href = $(el).attr('href');
|
|
153
|
+
const rel = $(el).attr('rel') || '';
|
|
154
|
+
const type = $(el).attr('type') || '';
|
|
155
|
+
|
|
156
|
+
if (href) {
|
|
157
|
+
faviconLocations.push(href);
|
|
158
|
+
|
|
159
|
+
// Detect format
|
|
160
|
+
if (href.endsWith('.ico')) faviconFormats.push('ico');
|
|
161
|
+
else if (href.endsWith('.png')) faviconFormats.push('png');
|
|
162
|
+
else if (href.endsWith('.svg')) faviconFormats.push('svg');
|
|
163
|
+
else if (type) faviconFormats.push(type);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (rel.includes('apple-touch-icon')) {
|
|
167
|
+
hasAppleTouchIcon = true;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Check for Apple Touch Icon separately
|
|
172
|
+
if ($('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').length > 0) {
|
|
173
|
+
hasAppleTouchIcon = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hasFavicon = faviconLocations.length > 0;
|
|
177
|
+
|
|
178
|
+
if (!hasFavicon) {
|
|
179
|
+
// Check if /favicon.ico exists
|
|
180
|
+
try {
|
|
181
|
+
const faviconUrl = new URL('/favicon.ico', parsedUrl.origin).href;
|
|
182
|
+
const response = await httpGet<string>(faviconUrl, {
|
|
183
|
+
timeout: 5000,
|
|
184
|
+
validateStatus: () => true,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (response.status === 200) {
|
|
188
|
+
faviconLocations.push('/favicon.ico');
|
|
189
|
+
faviconFormats.push('ico');
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Favicon check failed
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (faviconLocations.length === 0) {
|
|
197
|
+
issues.push({
|
|
198
|
+
code: 'HTML_NO_FAVICON',
|
|
199
|
+
severity: 'notice',
|
|
200
|
+
category: 'on-page',
|
|
201
|
+
title: 'No favicon found',
|
|
202
|
+
description: 'Page does not have a favicon defined. Favicons help with brand recognition and browser tab identification.',
|
|
203
|
+
impact: 'Missing favicon affects brand visibility in browser tabs, bookmarks, and search results.',
|
|
204
|
+
howToFix: 'Add a favicon link in <head>: <link rel="icon" href="/favicon.ico"> and consider adding multiple sizes.',
|
|
205
|
+
affectedUrls: [url],
|
|
206
|
+
details: {
|
|
207
|
+
recommendation: 'Include multiple formats: ICO for legacy browsers, PNG for modern browsers, and SVG for scalability.',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!hasAppleTouchIcon && faviconLocations.length > 0) {
|
|
213
|
+
issues.push({
|
|
214
|
+
code: 'HTML_NO_APPLE_TOUCH_ICON',
|
|
215
|
+
severity: 'notice',
|
|
216
|
+
category: 'mobile',
|
|
217
|
+
title: 'No Apple Touch Icon',
|
|
218
|
+
description: 'Page does not define an Apple Touch Icon. This icon is used when users add your site to their iOS home screen.',
|
|
219
|
+
impact: 'iOS users who bookmark your site won\'t see a proper app icon.',
|
|
220
|
+
howToFix: 'Add: <link rel="apple-touch-icon" href="/apple-touch-icon.png"> with a 180x180 PNG image.',
|
|
221
|
+
affectedUrls: [url],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// === LANGUAGE CHECK ===
|
|
226
|
+
const htmlLang = $('html').attr('lang');
|
|
227
|
+
const langPresent = Boolean(htmlLang);
|
|
228
|
+
|
|
229
|
+
if (!langPresent) {
|
|
230
|
+
issues.push({
|
|
231
|
+
code: 'HTML_NO_LANG',
|
|
232
|
+
severity: 'warning',
|
|
233
|
+
category: 'on-page',
|
|
234
|
+
title: 'Missing language attribute',
|
|
235
|
+
description: 'The <html> element does not have a lang attribute. This helps search engines and screen readers.',
|
|
236
|
+
impact: 'Search engines may not properly identify the page language. Accessibility is reduced.',
|
|
237
|
+
howToFix: 'Add lang attribute to <html>: <html lang="en"> (use appropriate language code).',
|
|
238
|
+
affectedUrls: [url],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// === VIEWPORT CHECK ===
|
|
243
|
+
const viewportMeta = $('meta[name="viewport"]').attr('content');
|
|
244
|
+
const hasViewport = Boolean(viewportMeta);
|
|
245
|
+
const isResponsive = viewportMeta?.includes('width=device-width') || false;
|
|
246
|
+
|
|
247
|
+
if (!hasViewport) {
|
|
248
|
+
issues.push({
|
|
249
|
+
code: 'HTML_NO_VIEWPORT',
|
|
250
|
+
severity: 'warning',
|
|
251
|
+
category: 'mobile',
|
|
252
|
+
title: 'Missing viewport meta tag',
|
|
253
|
+
description: 'Page does not have a viewport meta tag. This is required for proper mobile rendering.',
|
|
254
|
+
impact: 'Page will not render correctly on mobile devices. Fails mobile-friendliness test.',
|
|
255
|
+
howToFix: 'Add: <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
256
|
+
affectedUrls: [url],
|
|
257
|
+
});
|
|
258
|
+
} else if (!isResponsive) {
|
|
259
|
+
issues.push({
|
|
260
|
+
code: 'HTML_FIXED_VIEWPORT',
|
|
261
|
+
severity: 'notice',
|
|
262
|
+
category: 'mobile',
|
|
263
|
+
title: 'Viewport not responsive',
|
|
264
|
+
description: `Viewport is set to "${viewportMeta}" but doesn't include width=device-width for responsive design.`,
|
|
265
|
+
impact: 'Page may not scale properly on different screen sizes.',
|
|
266
|
+
howToFix: 'Update viewport to: <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
267
|
+
affectedUrls: [url],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
issues,
|
|
273
|
+
data: {
|
|
274
|
+
doctype: {
|
|
275
|
+
present: hasDoctype,
|
|
276
|
+
isHtml5,
|
|
277
|
+
value: doctypeValue,
|
|
278
|
+
},
|
|
279
|
+
charset: {
|
|
280
|
+
present: charsetPresent,
|
|
281
|
+
isUtf8,
|
|
282
|
+
value: charsetValue,
|
|
283
|
+
source: charsetSource,
|
|
284
|
+
},
|
|
285
|
+
favicon: {
|
|
286
|
+
present: faviconLocations.length > 0,
|
|
287
|
+
locations: faviconLocations,
|
|
288
|
+
hasAppleTouchIcon,
|
|
289
|
+
formats: [...new Set(faviconFormats)],
|
|
290
|
+
},
|
|
291
|
+
language: {
|
|
292
|
+
present: langPresent,
|
|
293
|
+
value: htmlLang,
|
|
294
|
+
},
|
|
295
|
+
viewport: {
|
|
296
|
+
present: hasViewport,
|
|
297
|
+
value: viewportMeta,
|
|
298
|
+
isResponsive,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Dimensions Check
|
|
3
|
+
*
|
|
4
|
+
* Verifies that images have explicit width and height attributes.
|
|
5
|
+
* This prevents Cumulative Layout Shift (CLS) - a Core Web Vital.
|
|
6
|
+
*
|
|
7
|
+
* When images don't have dimensions, the browser doesn't know how much
|
|
8
|
+
* space to reserve, causing content to jump when images load.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as cheerio from 'cheerio';
|
|
12
|
+
import type { AuditIssue } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export interface ImageDimensionsData {
|
|
15
|
+
totalImages: number;
|
|
16
|
+
withDimensions: number;
|
|
17
|
+
withoutDimensions: number;
|
|
18
|
+
missingDimensions: ImageInfo[];
|
|
19
|
+
aspectRatioSet: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ImageInfo {
|
|
23
|
+
src: string;
|
|
24
|
+
hasWidth: boolean;
|
|
25
|
+
hasHeight: boolean;
|
|
26
|
+
hasAspectRatio: boolean;
|
|
27
|
+
context: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function analyzeImageDimensions(
|
|
31
|
+
html: string,
|
|
32
|
+
url: string
|
|
33
|
+
): { issues: AuditIssue[]; data: ImageDimensionsData } {
|
|
34
|
+
const issues: AuditIssue[] = [];
|
|
35
|
+
const $ = cheerio.load(html);
|
|
36
|
+
|
|
37
|
+
const images = $('img');
|
|
38
|
+
const totalImages = images.length;
|
|
39
|
+
|
|
40
|
+
let withDimensions = 0;
|
|
41
|
+
let withoutDimensions = 0;
|
|
42
|
+
let aspectRatioSet = 0;
|
|
43
|
+
const missingDimensions: ImageInfo[] = [];
|
|
44
|
+
|
|
45
|
+
images.each((_, img) => {
|
|
46
|
+
const $img = $(img);
|
|
47
|
+
const src = $img.attr('src') || $img.attr('data-src') || '';
|
|
48
|
+
|
|
49
|
+
// Check for width attribute (HTML attribute or style)
|
|
50
|
+
const widthAttr = $img.attr('width');
|
|
51
|
+
const heightAttr = $img.attr('height');
|
|
52
|
+
const style = $img.attr('style') || '';
|
|
53
|
+
|
|
54
|
+
const hasWidthAttr = Boolean(widthAttr);
|
|
55
|
+
const hasHeightAttr = Boolean(heightAttr);
|
|
56
|
+
const hasStyleWidth = /width\s*:/i.test(style);
|
|
57
|
+
const hasStyleHeight = /height\s*:/i.test(style);
|
|
58
|
+
const hasAspectRatio = /aspect-ratio\s*:/i.test(style);
|
|
59
|
+
|
|
60
|
+
// Check CSS classes that might set dimensions
|
|
61
|
+
const className = $img.attr('class') || '';
|
|
62
|
+
|
|
63
|
+
// Consider dimensions set if either HTML attributes or inline styles are present
|
|
64
|
+
const hasWidth = hasWidthAttr || hasStyleWidth;
|
|
65
|
+
const hasHeight = hasHeightAttr || hasStyleHeight;
|
|
66
|
+
const hasBothDimensions = (hasWidth && hasHeight) || hasAspectRatio;
|
|
67
|
+
|
|
68
|
+
if (hasBothDimensions) {
|
|
69
|
+
withDimensions++;
|
|
70
|
+
if (hasAspectRatio) {
|
|
71
|
+
aspectRatioSet++;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
withoutDimensions++;
|
|
75
|
+
|
|
76
|
+
// Only report visible images (not lazy-loaded placeholder images)
|
|
77
|
+
if (src && !src.includes('data:image/') && !src.includes('placeholder')) {
|
|
78
|
+
// Get context (parent element for identification)
|
|
79
|
+
const parent = $img.parent();
|
|
80
|
+
const parentTag = parent.length > 0 ? (parent[0] as cheerio.Element).name || '' : '';
|
|
81
|
+
const parentClass = parent.attr('class')?.split(' ')[0] || '';
|
|
82
|
+
const context = parentClass ? `${parentTag}.${parentClass}` : parentTag;
|
|
83
|
+
|
|
84
|
+
missingDimensions.push({
|
|
85
|
+
src: truncateSrc(src),
|
|
86
|
+
hasWidth,
|
|
87
|
+
hasHeight,
|
|
88
|
+
hasAspectRatio,
|
|
89
|
+
context,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Generate issues
|
|
96
|
+
if (withoutDimensions > 0) {
|
|
97
|
+
// Calculate percentage
|
|
98
|
+
const percentage = Math.round((withoutDimensions / totalImages) * 100);
|
|
99
|
+
|
|
100
|
+
if (percentage > 50) {
|
|
101
|
+
issues.push({
|
|
102
|
+
code: 'IMAGES_MANY_MISSING_DIMENSIONS',
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
category: 'performance',
|
|
105
|
+
title: 'Most images missing explicit dimensions',
|
|
106
|
+
description: `${withoutDimensions} of ${totalImages} images (${percentage}%) don't have width and height attributes. This causes layout shifts.`,
|
|
107
|
+
impact: 'High Cumulative Layout Shift (CLS). Content jumps around as images load, frustrating users.',
|
|
108
|
+
howToFix: 'Add width and height attributes to all <img> tags. Use CSS aspect-ratio for responsive images. Example: <img src="..." width="800" height="600">',
|
|
109
|
+
affectedUrls: [url],
|
|
110
|
+
details: {
|
|
111
|
+
totalImages,
|
|
112
|
+
withDimensions,
|
|
113
|
+
withoutDimensions,
|
|
114
|
+
examples: missingDimensions.slice(0, 5),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
} else if (withoutDimensions > 0) {
|
|
118
|
+
issues.push({
|
|
119
|
+
code: 'IMAGES_MISSING_DIMENSIONS',
|
|
120
|
+
severity: 'notice',
|
|
121
|
+
category: 'performance',
|
|
122
|
+
title: 'Some images missing explicit dimensions',
|
|
123
|
+
description: `${withoutDimensions} of ${totalImages} image(s) don't have width and height attributes.`,
|
|
124
|
+
impact: 'May cause layout shifts when images load, affecting CLS score.',
|
|
125
|
+
howToFix: 'Add width and height attributes: <img src="..." width="800" height="600">. For responsive images, also use CSS: img { max-width: 100%; height: auto; }',
|
|
126
|
+
affectedUrls: [url],
|
|
127
|
+
details: {
|
|
128
|
+
images: missingDimensions.slice(0, 10),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check for aspect-ratio usage (modern approach)
|
|
135
|
+
if (withDimensions > 0 && aspectRatioSet === 0) {
|
|
136
|
+
issues.push({
|
|
137
|
+
code: 'IMAGES_NO_ASPECT_RATIO',
|
|
138
|
+
severity: 'notice',
|
|
139
|
+
category: 'performance',
|
|
140
|
+
title: 'Consider using CSS aspect-ratio for images',
|
|
141
|
+
description: 'Images have width/height but no CSS aspect-ratio. The aspect-ratio property provides better responsive behavior.',
|
|
142
|
+
impact: 'Minor - existing dimensions prevent CLS, but aspect-ratio offers better control.',
|
|
143
|
+
howToFix: 'Add CSS aspect-ratio for responsive images: img { aspect-ratio: 16/9; width: 100%; height: auto; }',
|
|
144
|
+
affectedUrls: [url],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
issues,
|
|
150
|
+
data: {
|
|
151
|
+
totalImages,
|
|
152
|
+
withDimensions,
|
|
153
|
+
withoutDimensions,
|
|
154
|
+
missingDimensions: missingDimensions.slice(0, 20),
|
|
155
|
+
aspectRatioSet,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function truncateSrc(src: string): string {
|
|
161
|
+
// Remove query strings and truncate long URLs
|
|
162
|
+
const cleanSrc = src.split('?')[0];
|
|
163
|
+
if (cleanSrc.length > 60) {
|
|
164
|
+
return cleanSrc.substring(0, 30) + '...' + cleanSrc.substring(cleanSrc.length - 25);
|
|
165
|
+
}
|
|
166
|
+
return cleanSrc;
|
|
167
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import { httpHead } from '../../utils/http.js';
|
|
3
|
+
import type { AuditIssue } from '../types.js';
|
|
4
|
+
import { ISSUE_DEFINITIONS } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export interface ImageData {
|
|
7
|
+
total: number;
|
|
8
|
+
missingAlt: { src: string }[];
|
|
9
|
+
emptyAlt: { src: string }[];
|
|
10
|
+
missingDimensions: { src: string }[];
|
|
11
|
+
largImages: { src: string; size?: number }[];
|
|
12
|
+
brokenImages: { src: string }[];
|
|
13
|
+
lazyLoaded: number;
|
|
14
|
+
withDimensions: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function analyzeImages(
|
|
18
|
+
html: string,
|
|
19
|
+
baseUrl: string,
|
|
20
|
+
checkBroken: boolean = false
|
|
21
|
+
): Promise<{ issues: AuditIssue[]; data: ImageData }> {
|
|
22
|
+
const issues: AuditIssue[] = [];
|
|
23
|
+
const $ = cheerio.load(html);
|
|
24
|
+
|
|
25
|
+
const missingAlt: ImageData['missingAlt'] = [];
|
|
26
|
+
const emptyAlt: ImageData['emptyAlt'] = [];
|
|
27
|
+
const missingDimensions: ImageData['missingDimensions'] = [];
|
|
28
|
+
const largeImages: ImageData['largImages'] = [];
|
|
29
|
+
const brokenImages: ImageData['brokenImages'] = [];
|
|
30
|
+
let lazyLoaded = 0;
|
|
31
|
+
let withDimensions = 0;
|
|
32
|
+
let total = 0;
|
|
33
|
+
|
|
34
|
+
const images: { src: string; alt: string | null; width: string | undefined; height: string | undefined; loading: string | undefined }[] = [];
|
|
35
|
+
|
|
36
|
+
$('img').each((_, el) => {
|
|
37
|
+
const src = $(el).attr('src') || $(el).attr('data-src') || '';
|
|
38
|
+
const alt = $(el).attr('alt');
|
|
39
|
+
const width = $(el).attr('width');
|
|
40
|
+
const height = $(el).attr('height');
|
|
41
|
+
const loading = $(el).attr('loading');
|
|
42
|
+
|
|
43
|
+
if (!src || src.startsWith('data:')) return; // Skip data URIs
|
|
44
|
+
|
|
45
|
+
total++;
|
|
46
|
+
images.push({ src, alt: alt === undefined ? null : alt, width, height, loading });
|
|
47
|
+
|
|
48
|
+
// Check alt text
|
|
49
|
+
if (alt === undefined || alt === null) {
|
|
50
|
+
missingAlt.push({ src });
|
|
51
|
+
} else if (alt === '') {
|
|
52
|
+
emptyAlt.push({ src });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check dimensions
|
|
56
|
+
if (!width || !height) {
|
|
57
|
+
missingDimensions.push({ src });
|
|
58
|
+
} else {
|
|
59
|
+
withDimensions++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check lazy loading
|
|
63
|
+
if (loading === 'lazy') {
|
|
64
|
+
lazyLoaded++;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Check for broken images and large images (limited)
|
|
69
|
+
if (checkBroken) {
|
|
70
|
+
const imagesToCheck = images.slice(0, 10);
|
|
71
|
+
for (const img of imagesToCheck) {
|
|
72
|
+
try {
|
|
73
|
+
const fullUrl = new URL(img.src, baseUrl).href;
|
|
74
|
+
const response = await httpHead(fullUrl, {
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
validateStatus: () => true,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (response.status >= 400) {
|
|
80
|
+
brokenImages.push({ src: img.src });
|
|
81
|
+
} else {
|
|
82
|
+
// Check file size
|
|
83
|
+
const contentLength = parseInt(response.headers['content-length'] || '0', 10);
|
|
84
|
+
if (contentLength > 200 * 1024) { // > 200KB
|
|
85
|
+
largeImages.push({ src: img.src, size: contentLength });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Don't mark as broken for network errors
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Generate issues
|
|
95
|
+
if (missingAlt.length > 0) {
|
|
96
|
+
issues.push({
|
|
97
|
+
...ISSUE_DEFINITIONS.IMG_ALT_MISSING,
|
|
98
|
+
affectedUrls: [baseUrl],
|
|
99
|
+
details: {
|
|
100
|
+
count: missingAlt.length,
|
|
101
|
+
images: missingAlt.slice(0, 5).map(i => i.src),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (emptyAlt.length > 0) {
|
|
107
|
+
issues.push({
|
|
108
|
+
...ISSUE_DEFINITIONS.IMG_ALT_EMPTY,
|
|
109
|
+
affectedUrls: [baseUrl],
|
|
110
|
+
details: {
|
|
111
|
+
count: emptyAlt.length,
|
|
112
|
+
images: emptyAlt.slice(0, 5).map(i => i.src),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (missingDimensions.length > 0) {
|
|
118
|
+
issues.push({
|
|
119
|
+
...ISSUE_DEFINITIONS.IMG_NO_DIMENSIONS,
|
|
120
|
+
affectedUrls: [baseUrl],
|
|
121
|
+
details: {
|
|
122
|
+
count: missingDimensions.length,
|
|
123
|
+
images: missingDimensions.slice(0, 5).map(i => i.src),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const img of largeImages) {
|
|
129
|
+
issues.push({
|
|
130
|
+
...ISSUE_DEFINITIONS.IMG_TOO_LARGE,
|
|
131
|
+
affectedUrls: [baseUrl],
|
|
132
|
+
details: {
|
|
133
|
+
src: img.src,
|
|
134
|
+
size: img.size ? `${(img.size / 1024).toFixed(0)}KB` : 'Unknown',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const img of brokenImages) {
|
|
140
|
+
issues.push({
|
|
141
|
+
...ISSUE_DEFINITIONS.IMG_BROKEN,
|
|
142
|
+
affectedUrls: [baseUrl],
|
|
143
|
+
details: { src: img.src },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
issues,
|
|
149
|
+
data: {
|
|
150
|
+
total,
|
|
151
|
+
missingAlt,
|
|
152
|
+
emptyAlt,
|
|
153
|
+
missingDimensions,
|
|
154
|
+
largImages: largeImages,
|
|
155
|
+
brokenImages,
|
|
156
|
+
lazyLoaded,
|
|
157
|
+
withDimensions,
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|