@rankcli/agent-runtime 0.0.8 → 0.0.11
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 +90 -196
- package/dist/analyzer-GMURJADU.mjs +7 -0
- package/dist/chunk-2JADKV3Z.mjs +244 -0
- package/dist/chunk-3ZSCLNTW.mjs +557 -0
- package/dist/chunk-4E4MQOSP.mjs +374 -0
- package/dist/chunk-6BWS3CLP.mjs +16 -0
- package/dist/chunk-AK2IC22C.mjs +206 -0
- package/dist/chunk-K6VSXDD6.mjs +293 -0
- package/dist/chunk-M27NQCWW.mjs +303 -0
- package/dist/{chunk-YNZYHEYM.mjs → chunk-PJLNXOLN.mjs} +0 -14
- package/dist/chunk-VSQD74I7.mjs +474 -0
- package/dist/core-web-vitals-analyzer-TE6LQJMS.mjs +7 -0
- package/dist/geo-analyzer-D47LTMMA.mjs +25 -0
- package/dist/image-optimization-analyzer-XP4OQGRP.mjs +9 -0
- package/dist/index.d.mts +1523 -17
- package/dist/index.d.ts +1523 -17
- package/dist/index.js +9582 -2664
- package/dist/index.mjs +4812 -380
- package/dist/internal-linking-analyzer-MRMBV7NM.mjs +9 -0
- package/dist/mobile-seo-analyzer-67HNQ7IO.mjs +7 -0
- package/dist/security-headers-analyzer-3ZUQARS5.mjs +9 -0
- package/dist/structured-data-analyzer-2I4NQAUP.mjs +9 -0
- package/package.json +2 -2
- package/src/analyzers/core-web-vitals-analyzer.test.ts +236 -0
- package/src/analyzers/core-web-vitals-analyzer.ts +557 -0
- package/src/analyzers/geo-analyzer.test.ts +310 -0
- package/src/analyzers/geo-analyzer.ts +814 -0
- package/src/analyzers/image-optimization-analyzer.test.ts +145 -0
- package/src/analyzers/image-optimization-analyzer.ts +348 -0
- package/src/analyzers/index.ts +233 -0
- package/src/analyzers/internal-linking-analyzer.test.ts +141 -0
- package/src/analyzers/internal-linking-analyzer.ts +419 -0
- package/src/analyzers/mobile-seo-analyzer.test.ts +140 -0
- package/src/analyzers/mobile-seo-analyzer.ts +455 -0
- package/src/analyzers/security-headers-analyzer.test.ts +115 -0
- package/src/analyzers/security-headers-analyzer.ts +318 -0
- package/src/analyzers/structured-data-analyzer.test.ts +210 -0
- package/src/analyzers/structured-data-analyzer.ts +590 -0
- package/src/audit/engine.ts +3 -3
- package/src/audit/types.ts +3 -2
- package/src/fixer/framework-fixes.test.ts +489 -0
- package/src/fixer/framework-fixes.ts +3418 -0
- package/src/fixer/index.ts +1 -0
- package/src/fixer/schemas.ts +971 -0
- package/src/frameworks/detector.ts +642 -114
- package/src/frameworks/suggestion-engine.ts +38 -1
- package/src/index.ts +6 -0
- package/src/types.ts +15 -1
- package/dist/analyzer-2CSWIQGD.mjs +0 -6
package/README.md
CHANGED
|
@@ -1,241 +1,135 @@
|
|
|
1
1
|
# @rankcli/agent-runtime
|
|
2
2
|
|
|
3
|
-
Core
|
|
3
|
+
Core SEO engine for RankCLI. 280+ checks, 7 deep analyzers, framework-specific fix generation.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
This package is **isomorphic** - it works in both Node.js and Deno environments:
|
|
8
|
-
|
|
9
|
-
- **Node.js**: Used by the CLI (`packages/cli`)
|
|
10
|
-
- **Deno**: Used by Supabase Edge Functions (`packages/saas/supabase/functions`)
|
|
11
|
-
|
|
12
|
-
The isomorphic design uses native `fetch` API instead of Node-specific modules like `axios` or `https`.
|
|
5
|
+
## Features
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
### 🤖 GEO (Generative Engine Optimization)
|
|
8
|
+
First SEO engine with AI search optimization:
|
|
9
|
+
- AI crawler detection (GPTBot, ClaudeBot, PerplexityBot, etc.)
|
|
10
|
+
- JS rendering analysis for AI crawlers
|
|
11
|
+
- LLM-friendliness scoring
|
|
12
|
+
- Citation readiness analysis
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
├── content/ # Content generation
|
|
32
|
-
├── geo/ # GEO tracking
|
|
33
|
-
├── git/ # Git/PR helpers
|
|
34
|
-
└── index.ts # Main entry point
|
|
14
|
+
### 📊 7 Deep Analyzers
|
|
15
|
+
```typescript
|
|
16
|
+
import { analyzers } from '@rankcli/agent-runtime';
|
|
17
|
+
|
|
18
|
+
// Comprehensive analysis
|
|
19
|
+
const result = await analyzers.analyzeComprehensive(html, url);
|
|
20
|
+
|
|
21
|
+
// Individual analyzers
|
|
22
|
+
const geo = await analyzers.analyzeGEO(html, url, robotsTxt);
|
|
23
|
+
const cwv = analyzers.analyzeCoreWebVitals(html, url);
|
|
24
|
+
const security = analyzers.analyzeSecurityHeaders(headers, url);
|
|
25
|
+
const schema = analyzers.analyzeStructuredData(html, url);
|
|
26
|
+
const images = analyzers.analyzeImages(html, url);
|
|
27
|
+
const links = analyzers.analyzeInternalLinking(html, url);
|
|
28
|
+
const mobile = analyzers.analyzeMobileSEO(html, url);
|
|
35
29
|
```
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
31
|
+
| Analyzer | Checks |
|
|
32
|
+
|----------|--------|
|
|
33
|
+
| **GEO** | AI crawler access, robots.txt, SSR detection, LLM signals |
|
|
34
|
+
| **Core Web Vitals** | LCP, CLS, INP, TTFB estimation |
|
|
35
|
+
| **Security** | HTTPS, HSTS, CSP, grades A+ to F |
|
|
36
|
+
| **Structured Data** | JSON-LD validation, rich results |
|
|
37
|
+
| **Images** | Alt text, dimensions, formats, lazy loading |
|
|
38
|
+
| **Internal Links** | Anchor text, orphan detection |
|
|
39
|
+
| **Mobile** | Viewport, touch targets, PWA |
|
|
43
40
|
|
|
44
|
-
###
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
# Install dependencies
|
|
48
|
-
pnpm install
|
|
41
|
+
### 🔧 Framework-Specific Fixes
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
pnpm dev
|
|
43
|
+
Auto-generate SEO fixes for 25+ frameworks:
|
|
52
44
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
pnpm test:run # Single run
|
|
56
|
-
|
|
57
|
-
# Build for production
|
|
58
|
-
pnpm build # Builds Node.js + Deno bundles
|
|
45
|
+
```typescript
|
|
46
|
+
import { generateFrameworkFix } from '@rankcli/agent-runtime';
|
|
59
47
|
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
const fix = generateFrameworkFix({
|
|
49
|
+
framework: 'nextjs-app',
|
|
50
|
+
issue: { code: 'MISSING_TITLE', ... },
|
|
51
|
+
siteMeta: { title: 'My Site', description: '...' }
|
|
52
|
+
});
|
|
62
53
|
```
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
2. Starts tsup in watch mode for Node.js
|
|
70
|
-
3. Rebuilds the Deno bundle on every successful Node.js build
|
|
71
|
-
|
|
72
|
-
This ensures both the CLI (Node.js) and edge functions (Deno) stay in sync during development.
|
|
55
|
+
**Supported:**
|
|
56
|
+
- React, Next.js, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, Remix, Gatsby, Solid.js, Qwik
|
|
57
|
+
- Rails, Django, Laravel, Spring Boot, ASP.NET Core, Phoenix, Go
|
|
58
|
+
- Hugo, Jekyll, Eleventy, Pelican
|
|
59
|
+
- HTMX, Hotwire/Turbo
|
|
73
60
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
| Script | Description |
|
|
77
|
-
|--------|-------------|
|
|
78
|
-
| `pnpm dev` | Watch mode - rebuilds Node + Deno on changes |
|
|
79
|
-
| `pnpm dev:node` | Watch mode - Node.js only |
|
|
80
|
-
| `pnpm dev:deno` | Watch mode - Deno bundle only |
|
|
81
|
-
| `pnpm build` | Production build (Node.js + Deno) |
|
|
82
|
-
| `pnpm build:deno` | Build Deno bundle only |
|
|
83
|
-
| `pnpm test` | Run tests in watch mode |
|
|
84
|
-
| `pnpm test:run` | Run tests once |
|
|
61
|
+
## Architecture
|
|
85
62
|
|
|
86
|
-
|
|
63
|
+
Isomorphic engine - works in Node.js, Deno, and WASM:
|
|
87
64
|
|
|
88
|
-
The Deno bundle is generated at:
|
|
89
|
-
```
|
|
90
|
-
packages/saas/supabase/functions/_shared/audit/
|
|
91
|
-
├── engine.bundle.js # Bundled audit engine
|
|
92
|
-
└── index.ts # Wrapper with cheerio import
|
|
93
65
|
```
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
66
|
+
src/
|
|
67
|
+
├── analyzers/ # 7 deep analyzers (GEO, CWV, etc.)
|
|
68
|
+
├── audit/ # 280+ individual checks
|
|
69
|
+
├── fixer/ # Framework-specific fix generation
|
|
70
|
+
├── frameworks/ # Framework detection
|
|
71
|
+
├── content/ # Content analysis
|
|
72
|
+
├── geo/ # GEO tracking
|
|
73
|
+
├── git/ # PR/commit helpers
|
|
74
|
+
└── index.ts
|
|
98
75
|
```
|
|
99
76
|
|
|
100
|
-
|
|
77
|
+
## Installation
|
|
101
78
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
### Isomorphic Considerations
|
|
107
|
-
|
|
108
|
-
When adding new features to the audit engine:
|
|
109
|
-
|
|
110
|
-
- Use `fetch` instead of `axios` or Node's `http`/`https`
|
|
111
|
-
- Use the helpers in `src/utils/http.ts` for HTTP requests
|
|
112
|
-
- Avoid Node-specific modules (`fs`, `path`, `dns`, `crypto`, etc.)
|
|
113
|
-
- For DNS lookups, use DNS-over-HTTPS (see `additional-checks.ts`)
|
|
114
|
-
- Test in both Node.js (`pnpm test`) and edge functions
|
|
115
|
-
|
|
116
|
-
## VS Code Tasks
|
|
117
|
-
|
|
118
|
-
If using VS Code, these tasks are available (Cmd/Ctrl+Shift+P → "Tasks: Run Task"):
|
|
119
|
-
|
|
120
|
-
| Task | Description |
|
|
121
|
-
|------|-------------|
|
|
122
|
-
| Dev: Agent Runtime (with Deno watch) | Watches and rebuilds both bundles |
|
|
123
|
-
| Dev: SaaS Frontend | Runs the Vite dev server |
|
|
124
|
-
| Dev: Full Stack | Runs both in parallel |
|
|
125
|
-
| Build: Deno Bundle | One-time Deno bundle build |
|
|
126
|
-
| Deploy: Edge Functions | Deploys to Supabase |
|
|
127
|
-
|
|
128
|
-
## DevContainer
|
|
129
|
-
|
|
130
|
-
When using the devcontainer:
|
|
131
|
-
|
|
132
|
-
- The Deno bundle is built automatically on container creation
|
|
133
|
-
- Run `pnpm dev` in `packages/agent-runtime` to start watching for changes
|
|
134
|
-
- The bundle is rebuilt automatically when you modify audit code
|
|
135
|
-
|
|
136
|
-
## API
|
|
79
|
+
```bash
|
|
80
|
+
pnpm add @rankcli/agent-runtime
|
|
81
|
+
```
|
|
137
82
|
|
|
138
|
-
|
|
83
|
+
## Usage
|
|
139
84
|
|
|
140
|
-
|
|
85
|
+
### Full Audit
|
|
141
86
|
|
|
142
87
|
```typescript
|
|
143
|
-
import {
|
|
88
|
+
import { runAudit, generateFixes } from '@rankcli/agent-runtime';
|
|
144
89
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
90
|
+
const audit = await runAudit({
|
|
91
|
+
url: 'https://example.com',
|
|
92
|
+
maxPages: 5,
|
|
93
|
+
options: { geo: true }
|
|
149
94
|
});
|
|
150
95
|
|
|
151
|
-
|
|
152
|
-
console.log(report.issues); // Array of issues
|
|
153
|
-
console.log(report.healthScores); // Category scores
|
|
154
|
-
console.log(report.checksRun); // Number of checks run
|
|
96
|
+
const fixes = await generateFixes(audit.issues);
|
|
155
97
|
```
|
|
156
98
|
|
|
157
|
-
###
|
|
99
|
+
### Generate AI-Friendly robots.txt
|
|
158
100
|
|
|
159
101
|
```typescript
|
|
160
|
-
|
|
161
|
-
code: string; // e.g., 'TITLE_MISSING'
|
|
162
|
-
severity: 'error' | 'warning' | 'notice';
|
|
163
|
-
category: string; // e.g., 'on-page', 'security'
|
|
164
|
-
title: string; // Human-readable title
|
|
165
|
-
description?: string; // Detailed description
|
|
166
|
-
impact?: string; // SEO impact explanation
|
|
167
|
-
howToFix?: string; // Fix instructions
|
|
168
|
-
affectedUrls?: string[];
|
|
169
|
-
details?: Record<string, unknown>;
|
|
170
|
-
}
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### Health Scores
|
|
102
|
+
import { analyzers } from '@rankcli/agent-runtime';
|
|
174
103
|
|
|
175
|
-
|
|
176
|
-
interface HealthScores {
|
|
177
|
-
crawlability: number; // 0-100
|
|
178
|
-
onPage: number;
|
|
179
|
-
content: number;
|
|
180
|
-
performance: number;
|
|
181
|
-
security: number;
|
|
182
|
-
socialMeta: number;
|
|
183
|
-
aiReadiness: number;
|
|
184
|
-
mobile: number;
|
|
185
|
-
}
|
|
104
|
+
const robotsTxt = analyzers.generateAIFriendlyRobotsTxt('https://example.com');
|
|
186
105
|
```
|
|
187
106
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
1. Create a new file in `src/audit/checks/` or add to an existing one
|
|
191
|
-
2. Export the check function
|
|
192
|
-
3. Add to `src/audit/engine.ts` to include in the audit flow
|
|
193
|
-
4. Add to `src/audit/deno-entry.ts` to export for Deno
|
|
194
|
-
5. Run `pnpm build:deno` to regenerate the bundle
|
|
195
|
-
6. Add tests in a `.test.ts` file
|
|
196
|
-
|
|
197
|
-
Example check:
|
|
107
|
+
### Generate Schema Templates
|
|
198
108
|
|
|
199
109
|
```typescript
|
|
200
|
-
|
|
201
|
-
import { httpGet } from '../../utils/http.js';
|
|
202
|
-
import type { AuditIssue } from '../types.js';
|
|
203
|
-
|
|
204
|
-
export async function checkMyThing(url: string): Promise<AuditIssue[]> {
|
|
205
|
-
const issues: AuditIssue[] = [];
|
|
206
|
-
|
|
207
|
-
const response = await httpGet(url);
|
|
208
|
-
|
|
209
|
-
if (/* condition */) {
|
|
210
|
-
issues.push({
|
|
211
|
-
code: 'MY_ISSUE_CODE',
|
|
212
|
-
severity: 'warning',
|
|
213
|
-
category: 'my-category',
|
|
214
|
-
title: 'Issue title',
|
|
215
|
-
description: 'What this means',
|
|
216
|
-
howToFix: 'How to fix it',
|
|
217
|
-
affectedUrls: [url],
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return issues;
|
|
222
|
-
}
|
|
223
|
-
```
|
|
110
|
+
import { analyzers } from '@rankcli/agent-runtime';
|
|
224
111
|
|
|
225
|
-
|
|
112
|
+
const schema = analyzers.generateSchemaTemplate('article', {
|
|
113
|
+
siteName: 'My Blog',
|
|
114
|
+
siteUrl: 'https://myblog.com',
|
|
115
|
+
authorName: 'John Doe'
|
|
116
|
+
});
|
|
117
|
+
```
|
|
226
118
|
|
|
227
|
-
|
|
119
|
+
## Development
|
|
228
120
|
|
|
229
121
|
```bash
|
|
230
|
-
|
|
231
|
-
pnpm
|
|
122
|
+
pnpm install
|
|
123
|
+
pnpm dev # Watch mode
|
|
124
|
+
pnpm test # Run tests
|
|
125
|
+
pnpm build # Build Node.js + Deno
|
|
126
|
+
```
|
|
232
127
|
|
|
233
|
-
|
|
234
|
-
pnpm test:run src/audit/checks/social-meta.test.ts
|
|
128
|
+
## Related
|
|
235
129
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
130
|
+
- [@rankcli/cli](../cli) - Command-line interface
|
|
131
|
+
- [@rankcli/mcp-server](../mcp-server) - MCP server for AI assistants
|
|
132
|
+
- [rankcli.dev](https://rankcli.dev) - Documentation
|
|
239
133
|
|
|
240
134
|
## License
|
|
241
135
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// src/analyzers/image-optimization-analyzer.ts
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
var MODERN_FORMATS = ["webp", "avif"];
|
|
4
|
+
var LEGACY_FORMATS = ["jpg", "jpeg", "png", "gif", "bmp"];
|
|
5
|
+
var MAX_RECOMMENDED_DIMENSION = 2e3;
|
|
6
|
+
function getImageFormat(src) {
|
|
7
|
+
if (src.startsWith("data:image/")) {
|
|
8
|
+
const match = src.match(/data:image\/(\w+)/);
|
|
9
|
+
return match ? match[1] : void 0;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(src, "https://example.com");
|
|
13
|
+
const pathname = url.pathname.toLowerCase();
|
|
14
|
+
const ext = pathname.split(".").pop();
|
|
15
|
+
return ext && ext.length <= 5 ? ext : void 0;
|
|
16
|
+
} catch {
|
|
17
|
+
const ext = src.split(".").pop()?.toLowerCase();
|
|
18
|
+
return ext && ext.length <= 5 ? ext : void 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function analyzeImages(html, url) {
|
|
22
|
+
const $ = cheerio.load(html);
|
|
23
|
+
const issues = [];
|
|
24
|
+
const recommendations = [];
|
|
25
|
+
let score = 100;
|
|
26
|
+
const allImages = [];
|
|
27
|
+
const missingAlt = [];
|
|
28
|
+
const oversizedImages = [];
|
|
29
|
+
const decorativeWithoutRole = [];
|
|
30
|
+
let imagesWithAlt = 0;
|
|
31
|
+
let imagesWithDimensions = 0;
|
|
32
|
+
let imagesWithLazyLoading = 0;
|
|
33
|
+
let modernFormats = 0;
|
|
34
|
+
let legacyFormats = 0;
|
|
35
|
+
$("img").each((_, el) => {
|
|
36
|
+
const $img = $(el);
|
|
37
|
+
const src = $img.attr("src") || $img.attr("data-src") || "";
|
|
38
|
+
const alt = $img.attr("alt");
|
|
39
|
+
const width = parseInt($img.attr("width") || "0");
|
|
40
|
+
const height = parseInt($img.attr("height") || "0");
|
|
41
|
+
const loading = $img.attr("loading");
|
|
42
|
+
const role = $img.attr("role");
|
|
43
|
+
const format = getImageFormat(src);
|
|
44
|
+
const imageIssues = [];
|
|
45
|
+
const imgInfo = {
|
|
46
|
+
src: src.substring(0, 200),
|
|
47
|
+
// Truncate long data URLs
|
|
48
|
+
alt,
|
|
49
|
+
width: width || void 0,
|
|
50
|
+
height: height || void 0,
|
|
51
|
+
format,
|
|
52
|
+
loading,
|
|
53
|
+
issues: imageIssues
|
|
54
|
+
};
|
|
55
|
+
if (alt === void 0) {
|
|
56
|
+
imageIssues.push("Missing alt attribute");
|
|
57
|
+
missingAlt.push(imgInfo);
|
|
58
|
+
} else if (alt === "") {
|
|
59
|
+
if (role !== "presentation" && role !== "none") {
|
|
60
|
+
imageIssues.push('Empty alt without role="presentation"');
|
|
61
|
+
decorativeWithoutRole.push(imgInfo);
|
|
62
|
+
}
|
|
63
|
+
imagesWithAlt++;
|
|
64
|
+
} else {
|
|
65
|
+
imagesWithAlt++;
|
|
66
|
+
if (alt.toLowerCase().includes("image") || alt.toLowerCase().includes("picture") || alt.toLowerCase().includes("photo")) {
|
|
67
|
+
imageIssues.push("Alt text contains generic terms");
|
|
68
|
+
}
|
|
69
|
+
if (alt.length > 125) {
|
|
70
|
+
imageIssues.push("Alt text too long (>125 chars)");
|
|
71
|
+
}
|
|
72
|
+
if (/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(alt)) {
|
|
73
|
+
imageIssues.push("Alt text appears to be a filename");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (width > 0 && height > 0) {
|
|
77
|
+
imagesWithDimensions++;
|
|
78
|
+
if (width > MAX_RECOMMENDED_DIMENSION || height > MAX_RECOMMENDED_DIMENSION) {
|
|
79
|
+
imageIssues.push(`Dimensions too large (${width}x${height})`);
|
|
80
|
+
oversizedImages.push(imgInfo);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
imageIssues.push("Missing width/height attributes");
|
|
84
|
+
}
|
|
85
|
+
if (loading === "lazy") {
|
|
86
|
+
imagesWithLazyLoading++;
|
|
87
|
+
}
|
|
88
|
+
if (format) {
|
|
89
|
+
if (MODERN_FORMATS.includes(format)) {
|
|
90
|
+
modernFormats++;
|
|
91
|
+
} else if (LEGACY_FORMATS.includes(format)) {
|
|
92
|
+
legacyFormats++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
allImages.push(imgInfo);
|
|
96
|
+
});
|
|
97
|
+
const totalImages = allImages.length;
|
|
98
|
+
if (missingAlt.length > 0) {
|
|
99
|
+
const percentage = Math.round(missingAlt.length / totalImages * 100);
|
|
100
|
+
const severity = percentage > 50 ? "critical" : percentage > 20 ? "warning" : "info";
|
|
101
|
+
issues.push({
|
|
102
|
+
code: "IMG_MISSING_ALT",
|
|
103
|
+
severity,
|
|
104
|
+
category: "content",
|
|
105
|
+
title: `${missingAlt.length} images missing alt attribute`,
|
|
106
|
+
description: `${percentage}% of images lack alt text, hurting accessibility and SEO.`,
|
|
107
|
+
impact: "Screen readers cannot describe images; search engines lose context",
|
|
108
|
+
howToFix: 'Add descriptive alt text to all images. Use alt="" for decorative images.',
|
|
109
|
+
affectedUrls: [url]
|
|
110
|
+
});
|
|
111
|
+
score -= Math.min(missingAlt.length * 3, 25);
|
|
112
|
+
}
|
|
113
|
+
const missingDimensions = totalImages - imagesWithDimensions;
|
|
114
|
+
if (missingDimensions > 0) {
|
|
115
|
+
issues.push({
|
|
116
|
+
code: "IMG_MISSING_DIMENSIONS",
|
|
117
|
+
severity: "warning",
|
|
118
|
+
category: "performance",
|
|
119
|
+
title: `${missingDimensions} images missing width/height`,
|
|
120
|
+
description: "Images without explicit dimensions cause layout shifts (CLS).",
|
|
121
|
+
impact: "Poor Core Web Vitals, degraded user experience",
|
|
122
|
+
howToFix: "Add width and height attributes to all images.",
|
|
123
|
+
affectedUrls: [url]
|
|
124
|
+
});
|
|
125
|
+
score -= Math.min(missingDimensions * 2, 20);
|
|
126
|
+
}
|
|
127
|
+
if (legacyFormats > 0 && modernFormats === 0) {
|
|
128
|
+
issues.push({
|
|
129
|
+
code: "IMG_NO_MODERN_FORMATS",
|
|
130
|
+
severity: "warning",
|
|
131
|
+
category: "performance",
|
|
132
|
+
title: "No modern image formats (WebP/AVIF)",
|
|
133
|
+
description: `All ${legacyFormats} images use legacy formats (JPEG/PNG).`,
|
|
134
|
+
impact: "Larger file sizes, slower page load",
|
|
135
|
+
howToFix: "Convert images to WebP or AVIF format. Use <picture> element for fallbacks.",
|
|
136
|
+
affectedUrls: [url]
|
|
137
|
+
});
|
|
138
|
+
score -= 10;
|
|
139
|
+
recommendations.push("Convert images to WebP/AVIF for 30-50% smaller file sizes");
|
|
140
|
+
}
|
|
141
|
+
if (oversizedImages.length > 0) {
|
|
142
|
+
issues.push({
|
|
143
|
+
code: "IMG_OVERSIZED",
|
|
144
|
+
severity: "warning",
|
|
145
|
+
category: "performance",
|
|
146
|
+
title: `${oversizedImages.length} images have excessive dimensions`,
|
|
147
|
+
description: `Images larger than ${MAX_RECOMMENDED_DIMENSION}px may be wasteful on most screens.`,
|
|
148
|
+
impact: "Unnecessary bandwidth usage, slower loading",
|
|
149
|
+
howToFix: "Resize images to match their display size. Use srcset for responsive images.",
|
|
150
|
+
affectedUrls: [url]
|
|
151
|
+
});
|
|
152
|
+
score -= Math.min(oversizedImages.length * 3, 15);
|
|
153
|
+
}
|
|
154
|
+
const firstImage = allImages[0];
|
|
155
|
+
if (firstImage?.loading === "lazy") {
|
|
156
|
+
issues.push({
|
|
157
|
+
code: "IMG_HERO_LAZY",
|
|
158
|
+
severity: "warning",
|
|
159
|
+
category: "performance",
|
|
160
|
+
title: 'First image has loading="lazy"',
|
|
161
|
+
description: "Above-the-fold images should not be lazy-loaded as it delays LCP.",
|
|
162
|
+
impact: "Slower Largest Contentful Paint",
|
|
163
|
+
howToFix: 'Remove loading="lazy" from hero/above-fold images. Keep it for below-fold images.',
|
|
164
|
+
affectedUrls: [url]
|
|
165
|
+
});
|
|
166
|
+
score -= 10;
|
|
167
|
+
}
|
|
168
|
+
const belowFoldWithoutLazy = allImages.slice(3).filter((img) => img.loading !== "lazy");
|
|
169
|
+
if (belowFoldWithoutLazy.length > 5) {
|
|
170
|
+
recommendations.push(`Consider adding loading="lazy" to ${belowFoldWithoutLazy.length} below-fold images`);
|
|
171
|
+
}
|
|
172
|
+
if (decorativeWithoutRole.length > 0) {
|
|
173
|
+
issues.push({
|
|
174
|
+
code: "IMG_DECORATIVE_NO_ROLE",
|
|
175
|
+
severity: "info",
|
|
176
|
+
category: "content",
|
|
177
|
+
title: `${decorativeWithoutRole.length} decorative images without role`,
|
|
178
|
+
description: 'Images with empty alt should have role="presentation" for accessibility.',
|
|
179
|
+
impact: "Minor accessibility issue",
|
|
180
|
+
howToFix: 'Add role="presentation" to decorative images with empty alt.',
|
|
181
|
+
affectedUrls: [url]
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const elementsWithBgImage = $('[style*="background-image"], [style*="background:"]').length;
|
|
185
|
+
if (elementsWithBgImage > 3) {
|
|
186
|
+
recommendations.push(`${elementsWithBgImage} CSS background images detected - ensure they're not content images`);
|
|
187
|
+
}
|
|
188
|
+
const svgImages = $('svg, img[src$=".svg"]').length;
|
|
189
|
+
if (svgImages > 0) {
|
|
190
|
+
}
|
|
191
|
+
if (totalImages > 0 && imagesWithAlt === totalImages) {
|
|
192
|
+
recommendations.push("\u2713 All images have alt attributes");
|
|
193
|
+
}
|
|
194
|
+
if (modernFormats > legacyFormats) {
|
|
195
|
+
recommendations.push("\u2713 Majority of images use modern formats");
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
score: Math.max(0, Math.min(100, score)),
|
|
199
|
+
totalImages,
|
|
200
|
+
imagesWithAlt,
|
|
201
|
+
imagesWithDimensions,
|
|
202
|
+
imagesWithLazyLoading,
|
|
203
|
+
modernFormats,
|
|
204
|
+
legacyFormats,
|
|
205
|
+
oversizedImages,
|
|
206
|
+
missingAlt,
|
|
207
|
+
decorativeWithoutRole,
|
|
208
|
+
issues,
|
|
209
|
+
recommendations
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function generateResponsiveImage(options) {
|
|
213
|
+
const { src, alt, widths, sizes = "100vw", loading = "lazy", className } = options;
|
|
214
|
+
const basename = src.replace(/\.[^.]+$/, "");
|
|
215
|
+
const ext = src.split(".").pop() || "jpg";
|
|
216
|
+
const srcset = widths.map((w) => `${basename}-${w}w.webp ${w}w`).join(", ");
|
|
217
|
+
const fallbackSrcset = widths.map((w) => `${basename}-${w}w.${ext} ${w}w`).join(", ");
|
|
218
|
+
return `<picture>
|
|
219
|
+
<source
|
|
220
|
+
type="image/webp"
|
|
221
|
+
srcset="${srcset}"
|
|
222
|
+
sizes="${sizes}"
|
|
223
|
+
/>
|
|
224
|
+
<source
|
|
225
|
+
type="image/${ext === "jpg" ? "jpeg" : ext}"
|
|
226
|
+
srcset="${fallbackSrcset}"
|
|
227
|
+
sizes="${sizes}"
|
|
228
|
+
/>
|
|
229
|
+
<img
|
|
230
|
+
src="${src}"
|
|
231
|
+
alt="${alt}"
|
|
232
|
+
width="${widths[widths.length - 1]}"
|
|
233
|
+
height="auto"
|
|
234
|
+
loading="${loading}"${className ? `
|
|
235
|
+
class="${className}"` : ""}
|
|
236
|
+
decoding="async"
|
|
237
|
+
/>
|
|
238
|
+
</picture>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export {
|
|
242
|
+
analyzeImages,
|
|
243
|
+
generateResponsiveImage
|
|
244
|
+
};
|