@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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Headers Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Analyzes HTTP security headers that impact SEO and trust signals.
|
|
5
|
+
* Many security headers are now considered ranking factors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AuditIssue } from '../audit/types.js';
|
|
9
|
+
|
|
10
|
+
export interface SecurityHeadersResult {
|
|
11
|
+
score: number;
|
|
12
|
+
https: {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
hasHSTS: boolean;
|
|
15
|
+
hstsMaxAge?: number;
|
|
16
|
+
includesSubdomains: boolean;
|
|
17
|
+
preload: boolean;
|
|
18
|
+
};
|
|
19
|
+
contentSecurity: {
|
|
20
|
+
hasCSP: boolean;
|
|
21
|
+
policy?: string;
|
|
22
|
+
issues: string[];
|
|
23
|
+
};
|
|
24
|
+
frameOptions: {
|
|
25
|
+
hasXFrameOptions: boolean;
|
|
26
|
+
value?: string;
|
|
27
|
+
};
|
|
28
|
+
contentTypeOptions: {
|
|
29
|
+
hasXContentTypeOptions: boolean;
|
|
30
|
+
};
|
|
31
|
+
xssProtection: {
|
|
32
|
+
hasXXSSProtection: boolean;
|
|
33
|
+
value?: string;
|
|
34
|
+
};
|
|
35
|
+
referrerPolicy: {
|
|
36
|
+
hasReferrerPolicy: boolean;
|
|
37
|
+
value?: string;
|
|
38
|
+
};
|
|
39
|
+
permissionsPolicy: {
|
|
40
|
+
hasPermissionsPolicy: boolean;
|
|
41
|
+
policy?: string;
|
|
42
|
+
};
|
|
43
|
+
issues: AuditIssue[];
|
|
44
|
+
grade: 'A+' | 'A' | 'B' | 'C' | 'D' | 'F';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SECURITY_HEADERS = {
|
|
48
|
+
'strict-transport-security': {
|
|
49
|
+
name: 'HSTS',
|
|
50
|
+
importance: 'critical',
|
|
51
|
+
description: 'Enforces HTTPS connections',
|
|
52
|
+
},
|
|
53
|
+
'content-security-policy': {
|
|
54
|
+
name: 'CSP',
|
|
55
|
+
importance: 'high',
|
|
56
|
+
description: 'Prevents XSS and injection attacks',
|
|
57
|
+
},
|
|
58
|
+
'x-frame-options': {
|
|
59
|
+
name: 'X-Frame-Options',
|
|
60
|
+
importance: 'medium',
|
|
61
|
+
description: 'Prevents clickjacking',
|
|
62
|
+
},
|
|
63
|
+
'x-content-type-options': {
|
|
64
|
+
name: 'X-Content-Type-Options',
|
|
65
|
+
importance: 'medium',
|
|
66
|
+
description: 'Prevents MIME sniffing',
|
|
67
|
+
},
|
|
68
|
+
'x-xss-protection': {
|
|
69
|
+
name: 'X-XSS-Protection',
|
|
70
|
+
importance: 'low',
|
|
71
|
+
description: 'Legacy XSS filter (deprecated but still useful)',
|
|
72
|
+
},
|
|
73
|
+
'referrer-policy': {
|
|
74
|
+
name: 'Referrer-Policy',
|
|
75
|
+
importance: 'medium',
|
|
76
|
+
description: 'Controls referrer information',
|
|
77
|
+
},
|
|
78
|
+
'permissions-policy': {
|
|
79
|
+
name: 'Permissions-Policy',
|
|
80
|
+
importance: 'medium',
|
|
81
|
+
description: 'Controls browser features',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Analyze security headers
|
|
87
|
+
*/
|
|
88
|
+
export function analyzeSecurityHeaders(
|
|
89
|
+
headers: Record<string, string>,
|
|
90
|
+
url: string
|
|
91
|
+
): SecurityHeadersResult {
|
|
92
|
+
const issues: AuditIssue[] = [];
|
|
93
|
+
let score = 100;
|
|
94
|
+
|
|
95
|
+
// Normalize headers to lowercase
|
|
96
|
+
const normalizedHeaders: Record<string, string> = {};
|
|
97
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
98
|
+
normalizedHeaders[key.toLowerCase()] = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check HTTPS
|
|
102
|
+
const isHTTPS = url.startsWith('https://');
|
|
103
|
+
if (!isHTTPS) {
|
|
104
|
+
score -= 30;
|
|
105
|
+
issues.push({
|
|
106
|
+
code: 'SEC_NO_HTTPS',
|
|
107
|
+
severity: 'critical',
|
|
108
|
+
category: 'technical',
|
|
109
|
+
title: 'Site not using HTTPS',
|
|
110
|
+
description: 'HTTPS is required for security and is a confirmed Google ranking factor.',
|
|
111
|
+
impact: 'Major negative ranking impact and browser security warnings',
|
|
112
|
+
howToFix: 'Install SSL certificate and redirect all HTTP traffic to HTTPS',
|
|
113
|
+
affectedUrls: [url],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check HSTS
|
|
118
|
+
const hsts = normalizedHeaders['strict-transport-security'];
|
|
119
|
+
let hasHSTS = false;
|
|
120
|
+
let hstsMaxAge: number | undefined;
|
|
121
|
+
let includesSubdomains = false;
|
|
122
|
+
let preload = false;
|
|
123
|
+
|
|
124
|
+
if (hsts) {
|
|
125
|
+
hasHSTS = true;
|
|
126
|
+
const maxAgeMatch = hsts.match(/max-age=(\d+)/);
|
|
127
|
+
if (maxAgeMatch) {
|
|
128
|
+
hstsMaxAge = parseInt(maxAgeMatch[1]);
|
|
129
|
+
if (hstsMaxAge < 31536000) { // Less than 1 year
|
|
130
|
+
score -= 5;
|
|
131
|
+
issues.push({
|
|
132
|
+
code: 'SEC_HSTS_SHORT',
|
|
133
|
+
severity: 'info',
|
|
134
|
+
category: 'technical',
|
|
135
|
+
title: 'HSTS max-age is less than 1 year',
|
|
136
|
+
description: `Current max-age is ${hstsMaxAge} seconds. Recommended: 31536000 (1 year) or more.`,
|
|
137
|
+
impact: 'Reduced protection window',
|
|
138
|
+
howToFix: 'Set Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
|
|
139
|
+
affectedUrls: [url],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
includesSubdomains = hsts.toLowerCase().includes('includesubdomains');
|
|
144
|
+
preload = hsts.toLowerCase().includes('preload');
|
|
145
|
+
} else if (isHTTPS) {
|
|
146
|
+
score -= 15;
|
|
147
|
+
issues.push({
|
|
148
|
+
code: 'SEC_NO_HSTS',
|
|
149
|
+
severity: 'warning',
|
|
150
|
+
category: 'technical',
|
|
151
|
+
title: 'Missing HSTS header',
|
|
152
|
+
description: 'HTTP Strict Transport Security (HSTS) header is not set.',
|
|
153
|
+
impact: 'Users may access site over insecure HTTP',
|
|
154
|
+
howToFix: 'Add header: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
|
|
155
|
+
affectedUrls: [url],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check CSP
|
|
160
|
+
const csp = normalizedHeaders['content-security-policy'];
|
|
161
|
+
const cspIssues: string[] = [];
|
|
162
|
+
|
|
163
|
+
if (csp) {
|
|
164
|
+
// Check for unsafe directives
|
|
165
|
+
if (csp.includes("'unsafe-inline'")) {
|
|
166
|
+
cspIssues.push("Contains 'unsafe-inline' - reduces XSS protection");
|
|
167
|
+
score -= 5;
|
|
168
|
+
}
|
|
169
|
+
if (csp.includes("'unsafe-eval'")) {
|
|
170
|
+
cspIssues.push("Contains 'unsafe-eval' - allows eval()");
|
|
171
|
+
score -= 5;
|
|
172
|
+
}
|
|
173
|
+
if (csp.includes('*') && !csp.includes('*.')) {
|
|
174
|
+
cspIssues.push("Contains wildcard (*) - too permissive");
|
|
175
|
+
score -= 3;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
score -= 10;
|
|
179
|
+
issues.push({
|
|
180
|
+
code: 'SEC_NO_CSP',
|
|
181
|
+
severity: 'warning',
|
|
182
|
+
category: 'technical',
|
|
183
|
+
title: 'Missing Content-Security-Policy header',
|
|
184
|
+
description: 'CSP helps prevent XSS attacks and is a security best practice.',
|
|
185
|
+
impact: 'Vulnerable to cross-site scripting attacks',
|
|
186
|
+
howToFix: "Add Content-Security-Policy header with appropriate directives. Start with: default-src 'self'",
|
|
187
|
+
affectedUrls: [url],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check X-Frame-Options
|
|
192
|
+
const xFrameOptions = normalizedHeaders['x-frame-options'];
|
|
193
|
+
if (!xFrameOptions) {
|
|
194
|
+
score -= 5;
|
|
195
|
+
issues.push({
|
|
196
|
+
code: 'SEC_NO_XFRAME',
|
|
197
|
+
severity: 'info',
|
|
198
|
+
category: 'technical',
|
|
199
|
+
title: 'Missing X-Frame-Options header',
|
|
200
|
+
description: 'X-Frame-Options prevents clickjacking attacks.',
|
|
201
|
+
impact: 'Site can be embedded in malicious iframes',
|
|
202
|
+
howToFix: 'Add header: X-Frame-Options: SAMEORIGIN (or DENY)',
|
|
203
|
+
affectedUrls: [url],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check X-Content-Type-Options
|
|
208
|
+
const xContentTypeOptions = normalizedHeaders['x-content-type-options'];
|
|
209
|
+
if (!xContentTypeOptions) {
|
|
210
|
+
score -= 5;
|
|
211
|
+
issues.push({
|
|
212
|
+
code: 'SEC_NO_XCONTENT_TYPE',
|
|
213
|
+
severity: 'info',
|
|
214
|
+
category: 'technical',
|
|
215
|
+
title: 'Missing X-Content-Type-Options header',
|
|
216
|
+
description: 'Prevents browsers from MIME-sniffing responses.',
|
|
217
|
+
impact: 'Potential for MIME confusion attacks',
|
|
218
|
+
howToFix: 'Add header: X-Content-Type-Options: nosniff',
|
|
219
|
+
affectedUrls: [url],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check Referrer-Policy
|
|
224
|
+
const referrerPolicy = normalizedHeaders['referrer-policy'];
|
|
225
|
+
if (!referrerPolicy) {
|
|
226
|
+
score -= 3;
|
|
227
|
+
issues.push({
|
|
228
|
+
code: 'SEC_NO_REFERRER_POLICY',
|
|
229
|
+
severity: 'info',
|
|
230
|
+
category: 'technical',
|
|
231
|
+
title: 'Missing Referrer-Policy header',
|
|
232
|
+
description: 'Controls how much referrer information is sent with requests.',
|
|
233
|
+
impact: 'May leak sensitive URL information',
|
|
234
|
+
howToFix: 'Add header: Referrer-Policy: strict-origin-when-cross-origin',
|
|
235
|
+
affectedUrls: [url],
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check Permissions-Policy
|
|
240
|
+
const permissionsPolicy = normalizedHeaders['permissions-policy'] ||
|
|
241
|
+
normalizedHeaders['feature-policy']; // Legacy name
|
|
242
|
+
if (!permissionsPolicy) {
|
|
243
|
+
score -= 3;
|
|
244
|
+
issues.push({
|
|
245
|
+
code: 'SEC_NO_PERMISSIONS_POLICY',
|
|
246
|
+
severity: 'info',
|
|
247
|
+
category: 'technical',
|
|
248
|
+
title: 'Missing Permissions-Policy header',
|
|
249
|
+
description: 'Controls which browser features can be used.',
|
|
250
|
+
impact: 'All browser features enabled by default',
|
|
251
|
+
howToFix: 'Add header: Permissions-Policy: geolocation=(), microphone=(), camera=()',
|
|
252
|
+
affectedUrls: [url],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Calculate grade
|
|
257
|
+
let grade: 'A+' | 'A' | 'B' | 'C' | 'D' | 'F';
|
|
258
|
+
if (score >= 95) grade = 'A+';
|
|
259
|
+
else if (score >= 85) grade = 'A';
|
|
260
|
+
else if (score >= 70) grade = 'B';
|
|
261
|
+
else if (score >= 55) grade = 'C';
|
|
262
|
+
else if (score >= 40) grade = 'D';
|
|
263
|
+
else grade = 'F';
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
score: Math.max(0, score),
|
|
267
|
+
https: {
|
|
268
|
+
enabled: isHTTPS,
|
|
269
|
+
hasHSTS,
|
|
270
|
+
hstsMaxAge,
|
|
271
|
+
includesSubdomains,
|
|
272
|
+
preload,
|
|
273
|
+
},
|
|
274
|
+
contentSecurity: {
|
|
275
|
+
hasCSP: !!csp,
|
|
276
|
+
policy: csp,
|
|
277
|
+
issues: cspIssues,
|
|
278
|
+
},
|
|
279
|
+
frameOptions: {
|
|
280
|
+
hasXFrameOptions: !!xFrameOptions,
|
|
281
|
+
value: xFrameOptions,
|
|
282
|
+
},
|
|
283
|
+
contentTypeOptions: {
|
|
284
|
+
hasXContentTypeOptions: !!xContentTypeOptions,
|
|
285
|
+
},
|
|
286
|
+
xssProtection: {
|
|
287
|
+
hasXXSSProtection: !!normalizedHeaders['x-xss-protection'],
|
|
288
|
+
value: normalizedHeaders['x-xss-protection'],
|
|
289
|
+
},
|
|
290
|
+
referrerPolicy: {
|
|
291
|
+
hasReferrerPolicy: !!referrerPolicy,
|
|
292
|
+
value: referrerPolicy,
|
|
293
|
+
},
|
|
294
|
+
permissionsPolicy: {
|
|
295
|
+
hasPermissionsPolicy: !!permissionsPolicy,
|
|
296
|
+
policy: permissionsPolicy,
|
|
297
|
+
},
|
|
298
|
+
issues,
|
|
299
|
+
grade,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generate recommended security headers
|
|
305
|
+
*/
|
|
306
|
+
export function generateSecurityHeaders(siteUrl: string): Record<string, string> {
|
|
307
|
+
const domain = new URL(siteUrl).hostname;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
|
|
311
|
+
'Content-Security-Policy': `default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.${domain}`,
|
|
312
|
+
'X-Frame-Options': 'SAMEORIGIN',
|
|
313
|
+
'X-Content-Type-Options': 'nosniff',
|
|
314
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
315
|
+
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()',
|
|
316
|
+
'X-XSS-Protection': '1; mode=block',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyzeStructuredData, generateSchemaTemplate } from './structured-data-analyzer.js';
|
|
3
|
+
|
|
4
|
+
describe('Structured Data Analyzer', () => {
|
|
5
|
+
describe('analyzeStructuredData', () => {
|
|
6
|
+
it('detects valid Article schema', () => {
|
|
7
|
+
const html = `
|
|
8
|
+
<!DOCTYPE html>
|
|
9
|
+
<html>
|
|
10
|
+
<head>
|
|
11
|
+
<script type="application/ld+json">
|
|
12
|
+
{
|
|
13
|
+
"@context": "https://schema.org",
|
|
14
|
+
"@type": "Article",
|
|
15
|
+
"headline": "Test Article",
|
|
16
|
+
"author": {"@type": "Person", "name": "John Doe"},
|
|
17
|
+
"datePublished": "2024-01-15"
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
</head>
|
|
21
|
+
<body><h1>Test</h1></body>
|
|
22
|
+
</html>
|
|
23
|
+
`;
|
|
24
|
+
const result = analyzeStructuredData(html, 'https://example.com/article');
|
|
25
|
+
expect(result.hasArticle).toBe(true);
|
|
26
|
+
expect(result.schemas.some(s => s.type === 'Article')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('validates required properties', () => {
|
|
30
|
+
const html = `
|
|
31
|
+
<!DOCTYPE html>
|
|
32
|
+
<html>
|
|
33
|
+
<head>
|
|
34
|
+
<script type="application/ld+json">
|
|
35
|
+
{
|
|
36
|
+
"@context": "https://schema.org",
|
|
37
|
+
"@type": "Article",
|
|
38
|
+
"headline": "Test Article"
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
</head>
|
|
42
|
+
<body><h1>Test</h1></body>
|
|
43
|
+
</html>
|
|
44
|
+
`;
|
|
45
|
+
const result = analyzeStructuredData(html, 'https://example.com/article');
|
|
46
|
+
const articleSchema = result.schemas.find(s => s.type === 'Article');
|
|
47
|
+
expect(articleSchema?.isValid).toBe(false);
|
|
48
|
+
expect(articleSchema?.errors.some(e => e.includes('author'))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('detects FAQPage schema', () => {
|
|
52
|
+
const html = `
|
|
53
|
+
<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<script type="application/ld+json">
|
|
57
|
+
{
|
|
58
|
+
"@context": "https://schema.org",
|
|
59
|
+
"@type": "FAQPage",
|
|
60
|
+
"mainEntity": [
|
|
61
|
+
{
|
|
62
|
+
"@type": "Question",
|
|
63
|
+
"name": "What is SEO?",
|
|
64
|
+
"acceptedAnswer": {
|
|
65
|
+
"@type": "Answer",
|
|
66
|
+
"text": "SEO stands for Search Engine Optimization."
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
</head>
|
|
73
|
+
<body><h1>FAQ</h1></body>
|
|
74
|
+
</html>
|
|
75
|
+
`;
|
|
76
|
+
const result = analyzeStructuredData(html, 'https://example.com/faq');
|
|
77
|
+
expect(result.hasFAQ).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('detects Product schema', () => {
|
|
81
|
+
const html = `
|
|
82
|
+
<!DOCTYPE html>
|
|
83
|
+
<html>
|
|
84
|
+
<head>
|
|
85
|
+
<script type="application/ld+json">
|
|
86
|
+
{
|
|
87
|
+
"@context": "https://schema.org",
|
|
88
|
+
"@type": "Product",
|
|
89
|
+
"name": "Test Product",
|
|
90
|
+
"offers": {
|
|
91
|
+
"@type": "Offer",
|
|
92
|
+
"price": "29.99",
|
|
93
|
+
"priceCurrency": "USD"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</script>
|
|
97
|
+
</head>
|
|
98
|
+
<body><h1>Product</h1></body>
|
|
99
|
+
</html>
|
|
100
|
+
`;
|
|
101
|
+
const result = analyzeStructuredData(html, 'https://example.com/product');
|
|
102
|
+
expect(result.hasProduct).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('detects BreadcrumbList schema', () => {
|
|
106
|
+
const html = `
|
|
107
|
+
<!DOCTYPE html>
|
|
108
|
+
<html>
|
|
109
|
+
<head>
|
|
110
|
+
<script type="application/ld+json">
|
|
111
|
+
{
|
|
112
|
+
"@context": "https://schema.org",
|
|
113
|
+
"@type": "BreadcrumbList",
|
|
114
|
+
"itemListElement": [
|
|
115
|
+
{"@type": "ListItem", "position": 1, "name": "Home", "item": "/"},
|
|
116
|
+
{"@type": "ListItem", "position": 2, "name": "Products", "item": "/products"}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
</head>
|
|
121
|
+
<body><h1>Product</h1></body>
|
|
122
|
+
</html>
|
|
123
|
+
`;
|
|
124
|
+
const result = analyzeStructuredData(html, 'https://example.com/product');
|
|
125
|
+
expect(result.hasBreadcrumb).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles @graph format', () => {
|
|
129
|
+
const html = `
|
|
130
|
+
<!DOCTYPE html>
|
|
131
|
+
<html>
|
|
132
|
+
<head>
|
|
133
|
+
<script type="application/ld+json">
|
|
134
|
+
{
|
|
135
|
+
"@context": "https://schema.org",
|
|
136
|
+
"@graph": [
|
|
137
|
+
{"@type": "Organization", "name": "Test Org", "url": "https://example.com"},
|
|
138
|
+
{"@type": "WebSite", "name": "Test Site", "url": "https://example.com"}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
142
|
+
</head>
|
|
143
|
+
<body><h1>Home</h1></body>
|
|
144
|
+
</html>
|
|
145
|
+
`;
|
|
146
|
+
const result = analyzeStructuredData(html, 'https://example.com');
|
|
147
|
+
expect(result.hasOrganization).toBe(true);
|
|
148
|
+
expect(result.hasWebSite).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports invalid JSON', () => {
|
|
152
|
+
const html = `
|
|
153
|
+
<!DOCTYPE html>
|
|
154
|
+
<html>
|
|
155
|
+
<head>
|
|
156
|
+
<script type="application/ld+json">
|
|
157
|
+
{invalid json here}
|
|
158
|
+
</script>
|
|
159
|
+
</head>
|
|
160
|
+
<body><h1>Test</h1></body>
|
|
161
|
+
</html>
|
|
162
|
+
`;
|
|
163
|
+
const result = analyzeStructuredData(html, 'https://example.com');
|
|
164
|
+
expect(result.issues.some(i => i.code === 'SCHEMA_INVALID_JSON')).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('reports when no schema is found', () => {
|
|
168
|
+
const html = `
|
|
169
|
+
<!DOCTYPE html>
|
|
170
|
+
<html>
|
|
171
|
+
<head><title>Test</title></head>
|
|
172
|
+
<body><h1>No Schema</h1></body>
|
|
173
|
+
</html>
|
|
174
|
+
`;
|
|
175
|
+
const result = analyzeStructuredData(html, 'https://example.com');
|
|
176
|
+
expect(result.issues.some(i => i.code === 'SCHEMA_NONE_FOUND')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('generateSchemaTemplate', () => {
|
|
181
|
+
it('generates article schema template', () => {
|
|
182
|
+
const schema = generateSchemaTemplate('article', {
|
|
183
|
+
siteName: 'Test Site',
|
|
184
|
+
siteUrl: 'https://example.com',
|
|
185
|
+
authorName: 'John Doe',
|
|
186
|
+
});
|
|
187
|
+
expect(schema).toContain('Article');
|
|
188
|
+
expect(schema).toContain('John Doe');
|
|
189
|
+
expect(schema).toContain('https://example.com');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('generates product schema template', () => {
|
|
193
|
+
const schema = generateSchemaTemplate('product', {
|
|
194
|
+
siteName: 'Test Store',
|
|
195
|
+
siteUrl: 'https://store.example.com',
|
|
196
|
+
});
|
|
197
|
+
expect(schema).toContain('Product');
|
|
198
|
+
expect(schema).toContain('Offer');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('generates FAQ schema template', () => {
|
|
202
|
+
const schema = generateSchemaTemplate('faq', {
|
|
203
|
+
siteName: 'Help Center',
|
|
204
|
+
siteUrl: 'https://help.example.com',
|
|
205
|
+
});
|
|
206
|
+
expect(schema).toContain('FAQPage');
|
|
207
|
+
expect(schema).toContain('Question');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|