@qobi/seocode 0.1.0
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/LICENSE +21 -0
- package/dist/src/billing/polar.js +127 -0
- package/dist/src/billing/repo-limits.js +60 -0
- package/dist/src/billing/subscription-service.js +123 -0
- package/dist/src/billing/tier.js +14 -0
- package/dist/src/billing/types.js +1 -0
- package/dist/src/cli/discover.js +62 -0
- package/dist/src/cli/fix.js +24 -0
- package/dist/src/cli/format-terminal.js +66 -0
- package/dist/src/cli/index.js +204 -0
- package/dist/src/cli/init.js +48 -0
- package/dist/src/cli/staged.js +24 -0
- package/dist/src/config/seocode-config.js +97 -0
- package/dist/src/engine/page-detector.js +82 -0
- package/dist/src/engine/pr-delta.js +31 -0
- package/dist/src/engine/rule-engine.js +112 -0
- package/dist/src/engine/rule-loader.js +35 -0
- package/dist/src/engine/rules/declarative-evaluator.js +105 -0
- package/dist/src/engine/rules/heading-rules.js +43 -0
- package/dist/src/engine/rules/helpers.js +16 -0
- package/dist/src/engine/rules/image-rules.js +82 -0
- package/dist/src/engine/rules/index.js +28 -0
- package/dist/src/engine/rules/link-rules.js +39 -0
- package/dist/src/engine/rules/meta-rules.js +34 -0
- package/dist/src/engine/rules/performance-rules.js +24 -0
- package/dist/src/engine/rules/schema-rules.js +228 -0
- package/dist/src/engine/rules/suggest.js +103 -0
- package/dist/src/engine/rules/technical-rules.js +100 -0
- package/dist/src/parsers/frameworks/ast-value.js +83 -0
- package/dist/src/parsers/frameworks/astro.js +107 -0
- package/dist/src/parsers/frameworks/index.js +36 -0
- package/dist/src/parsers/frameworks/nextjs.js +170 -0
- package/dist/src/parsers/frameworks/remix.js +130 -0
- package/dist/src/parsers/frameworks/roles.js +63 -0
- package/dist/src/parsers/frameworks/types.js +1 -0
- package/dist/src/parsers/html-parser.js +118 -0
- package/dist/src/parsers/index.js +62 -0
- package/dist/src/parsers/jsx-parser.js +174 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/types/worker-env.js +1 -0
- package/package.json +75 -0
- package/rules/seo-rules.json +3414 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Evaluates a rule using its declarative `check` descriptor.
|
|
4
|
+
* Returns `null` — rule passed.
|
|
5
|
+
* Returns `SeoIssue` — rule fired.
|
|
6
|
+
* Returns `undefined` — no check descriptor present; caller must use hand-coded handler.
|
|
7
|
+
*/
|
|
8
|
+
export function evaluateDeclarative(rule, doc) {
|
|
9
|
+
const check = rule.check;
|
|
10
|
+
if (!check)
|
|
11
|
+
return undefined;
|
|
12
|
+
return dispatch(check, rule, doc);
|
|
13
|
+
}
|
|
14
|
+
// ── dispatcher ───────────────────────────────────────────────────────────────
|
|
15
|
+
function dispatch(check, rule, doc) {
|
|
16
|
+
const raw = doc;
|
|
17
|
+
switch (check.type) {
|
|
18
|
+
// field is null / undefined / falsy
|
|
19
|
+
case 'field-null': {
|
|
20
|
+
const val = raw[check.field];
|
|
21
|
+
return !val ? buildIssue(rule, doc, null, rule.fixTemplate) : null;
|
|
22
|
+
}
|
|
23
|
+
// boolean field is true
|
|
24
|
+
case 'field-truthy': {
|
|
25
|
+
const val = raw[check.field];
|
|
26
|
+
return val ? buildIssue(rule, doc, null, rule.fixTemplate) : null;
|
|
27
|
+
}
|
|
28
|
+
// boolean field is false / falsy
|
|
29
|
+
case 'field-falsy': {
|
|
30
|
+
const val = raw[check.field];
|
|
31
|
+
return !val ? buildIssue(rule, doc, null, rule.fixTemplate) : null;
|
|
32
|
+
}
|
|
33
|
+
// { value: string; line: number } field whose value.length > threshold
|
|
34
|
+
case 'field-length-gt': {
|
|
35
|
+
const field = raw[check.field];
|
|
36
|
+
if (!field)
|
|
37
|
+
return null; // absence handled by a separate missing-X rule
|
|
38
|
+
const len = field.value.length;
|
|
39
|
+
if (len > check.threshold) {
|
|
40
|
+
return buildIssue(rule, doc, field.line, rule.fixTemplate.replace(/\{\{CURRENT_LENGTH\}\}/g, String(len)));
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// { value: string; line: number } field whose value.length < threshold (and non-empty)
|
|
45
|
+
case 'field-length-lt': {
|
|
46
|
+
const field = raw[check.field];
|
|
47
|
+
if (!field)
|
|
48
|
+
return null;
|
|
49
|
+
const len = field.value.length;
|
|
50
|
+
if (len > 0 && len < check.threshold) {
|
|
51
|
+
return buildIssue(rule, doc, field.line, rule.fixTemplate.replace(/\{\{CURRENT_LENGTH\}\}/g, String(len)));
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// { value: string; line: number } field whose value contains a substring (case-insensitive)
|
|
56
|
+
case 'field-contains': {
|
|
57
|
+
const field = raw[check.field];
|
|
58
|
+
if (!field)
|
|
59
|
+
return null;
|
|
60
|
+
if (field.value.toLowerCase().includes(check.value.toLowerCase())) {
|
|
61
|
+
return buildIssue(rule, doc, field.line, rule.fixTemplate);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// array field that is empty
|
|
66
|
+
case 'field-array-empty': {
|
|
67
|
+
const arr = raw[check.field];
|
|
68
|
+
return (!arr || arr.length === 0)
|
|
69
|
+
? buildIssue(rule, doc, null, rule.fixTemplate)
|
|
70
|
+
: null;
|
|
71
|
+
}
|
|
72
|
+
// any JSON-LD block contains the given @type
|
|
73
|
+
case 'jsonld-has-type': {
|
|
74
|
+
for (const block of doc.jsonLdBlocks) {
|
|
75
|
+
try {
|
|
76
|
+
const items = (() => {
|
|
77
|
+
const p = JSON.parse(block.content);
|
|
78
|
+
return Array.isArray(p) ? p : [p];
|
|
79
|
+
})();
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const t = item['@type'];
|
|
82
|
+
const types = Array.isArray(t) ? t : [t];
|
|
83
|
+
if (types.includes(check.schemaType)) {
|
|
84
|
+
return buildIssue(rule, doc, block.line, rule.fixTemplate);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { /* invalid JSON handled by invalid-json-ld rule */ }
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// rawContent matches a single regex pattern
|
|
93
|
+
case 'raw-match': {
|
|
94
|
+
const re = new RegExp(check.pattern, check.flags ?? 'i');
|
|
95
|
+
return re.test(doc.rawContent)
|
|
96
|
+
? buildIssue(rule, doc, null, rule.fixTemplate)
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
// rawContent matches ALL patterns in the list
|
|
100
|
+
case 'raw-all-match': {
|
|
101
|
+
const allMatch = check.patterns.every(p => new RegExp(p, 'i').test(doc.rawContent));
|
|
102
|
+
return allMatch ? buildIssue(rule, doc, null, rule.fixTemplate) : null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
export function checkHeadingRules(rule, doc) {
|
|
3
|
+
switch (rule.id) {
|
|
4
|
+
case 'missing-h1': {
|
|
5
|
+
const h1s = doc.headings.filter((h) => h.tag === 'h1');
|
|
6
|
+
if (h1s.length === 0)
|
|
7
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
case 'multiple-h1': {
|
|
11
|
+
const h1s = doc.headings.filter((h) => h.tag === 'h1');
|
|
12
|
+
if (h1s.length > 1) {
|
|
13
|
+
return buildIssue(rule, doc, h1s[1].line, rule.fixTemplate.replace('{{COUNT}}', String(h1s.length)));
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
case 'heading-hierarchy': {
|
|
18
|
+
const issues = [];
|
|
19
|
+
let prevLevel = 0;
|
|
20
|
+
for (const heading of doc.headings) {
|
|
21
|
+
const currentLevel = parseInt(heading.tag[1], 10);
|
|
22
|
+
if (currentLevel > prevLevel + 1 && prevLevel !== 0) {
|
|
23
|
+
issues.push(buildIssue(rule, doc, heading.line, rule.fixTemplate
|
|
24
|
+
.replace('{{FROM}}', `h${prevLevel}`)
|
|
25
|
+
.replace('{{TO}}', heading.tag)));
|
|
26
|
+
}
|
|
27
|
+
prevLevel = currentLevel;
|
|
28
|
+
}
|
|
29
|
+
return issues.length > 0 ? issues : null;
|
|
30
|
+
}
|
|
31
|
+
case 'empty-heading': {
|
|
32
|
+
const issues = [];
|
|
33
|
+
for (const heading of doc.headings) {
|
|
34
|
+
if (!heading.text.trim()) {
|
|
35
|
+
issues.push(buildIssue(rule, doc, heading.line, rule.fixTemplate.replace('{{TAG}}', heading.tag)));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return issues.length > 0 ? issues : null;
|
|
39
|
+
}
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function buildIssue(rule, doc, line, fix, suggestion) {
|
|
2
|
+
const issue = {
|
|
3
|
+
ruleId: rule.id,
|
|
4
|
+
ruleName: rule.name,
|
|
5
|
+
severity: rule.severity,
|
|
6
|
+
category: rule.category,
|
|
7
|
+
file: doc.filePath,
|
|
8
|
+
line,
|
|
9
|
+
description: rule.description,
|
|
10
|
+
fix,
|
|
11
|
+
references: rule.references,
|
|
12
|
+
};
|
|
13
|
+
if (suggestion)
|
|
14
|
+
issue.suggestion = suggestion;
|
|
15
|
+
return issue;
|
|
16
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
import { suggestImgAttr } from './suggest.js';
|
|
3
|
+
export function checkImageRules(rule, doc) {
|
|
4
|
+
switch (rule.id) {
|
|
5
|
+
case 'image-missing-alt': {
|
|
6
|
+
const issues = [];
|
|
7
|
+
for (const img of doc.images) {
|
|
8
|
+
if (img.alt === null) {
|
|
9
|
+
issues.push(buildIssue(rule, doc, img.line, rule.fixTemplate.replace('{{SRC}}', img.src)));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return issues.length > 0 ? issues : null;
|
|
13
|
+
}
|
|
14
|
+
case 'image-empty-alt': {
|
|
15
|
+
const issues = [];
|
|
16
|
+
for (const img of doc.images) {
|
|
17
|
+
if (img.alt === '' && img.src && !img.src.includes('icon') && !img.src.includes('decoration')) {
|
|
18
|
+
issues.push(buildIssue(rule, doc, img.line, rule.fixTemplate.replace('{{SRC}}', img.src)));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return issues.length > 0 ? issues : null;
|
|
22
|
+
}
|
|
23
|
+
case 'image-missing-lazy-loading': {
|
|
24
|
+
const issues = [];
|
|
25
|
+
for (const img of doc.images) {
|
|
26
|
+
if (!img.loading) {
|
|
27
|
+
// `loading="lazy"` is a deterministic, always-safe addition — offer it
|
|
28
|
+
// as a committable 1-click fix when the <img> sits on a single line.
|
|
29
|
+
const suggestion = suggestImgAttr(doc, img.line, 'loading="lazy"', 'loading');
|
|
30
|
+
issues.push(buildIssue(rule, doc, img.line, rule.fixTemplate.replace('{{SRC}}', img.src), suggestion));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return issues.length > 0 ? issues : null;
|
|
34
|
+
}
|
|
35
|
+
case 'avif-image-format-supported': {
|
|
36
|
+
if (doc.images.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const hasAvifSource = /type\s*=\s*["']image\/avif["']/.test(doc.rawContent);
|
|
39
|
+
if (!hasAvifSource) {
|
|
40
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
case 'image-preferred-source-metadata': {
|
|
45
|
+
// Only fire when JSON-LD is present but the og:image URL isn't reflected in it
|
|
46
|
+
if (!doc.ogImage || doc.jsonLdBlocks.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const ogImgUrl = doc.ogImage.value;
|
|
49
|
+
let hasMatchingImage = false;
|
|
50
|
+
for (const block of doc.jsonLdBlocks) {
|
|
51
|
+
try {
|
|
52
|
+
const str = JSON.stringify(JSON.parse(block.content));
|
|
53
|
+
if (str.includes('"image"') && str.includes(ogImgUrl)) {
|
|
54
|
+
hasMatchingImage = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
if (!hasMatchingImage) {
|
|
61
|
+
return buildIssue(rule, doc, doc.ogImage.line, rule.fixTemplate);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
case 'image-url-consistency': {
|
|
66
|
+
if (doc.images.length < 2)
|
|
67
|
+
return null;
|
|
68
|
+
const httpImages = doc.images.filter(img => img.src.startsWith('http://'));
|
|
69
|
+
const httpsImages = doc.images.filter(img => img.src.startsWith('https://'));
|
|
70
|
+
if (httpImages.length > 0 && httpsImages.length > 0) {
|
|
71
|
+
return buildIssue(rule, doc, httpImages[0].line, rule.fixTemplate);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
// Cannot verify thumbnail opacity or C2PA metadata from HTML alone
|
|
76
|
+
case 'video-transparency':
|
|
77
|
+
case 'c2pa-metadata':
|
|
78
|
+
return null;
|
|
79
|
+
default:
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { evaluateDeclarative } from './declarative-evaluator.js';
|
|
2
|
+
import { checkMetaRules } from './meta-rules.js';
|
|
3
|
+
import { checkHeadingRules } from './heading-rules.js';
|
|
4
|
+
import { checkImageRules } from './image-rules.js';
|
|
5
|
+
import { checkLinkRules } from './link-rules.js';
|
|
6
|
+
import { checkTechnicalRules } from './technical-rules.js';
|
|
7
|
+
import { checkSchemaRules } from './schema-rules.js';
|
|
8
|
+
import { checkPerformanceRules } from './performance-rules.js';
|
|
9
|
+
// Returns null if rule passes, SeoIssue or SeoIssue[] if it fails
|
|
10
|
+
export function runRule(rule, doc) {
|
|
11
|
+
// Declarative path: rule carries its own check descriptor in the JSON.
|
|
12
|
+
// undefined means "no descriptor" — fall through to hand-coded handler.
|
|
13
|
+
const declarativeResult = evaluateDeclarative(rule, doc);
|
|
14
|
+
if (declarativeResult !== undefined)
|
|
15
|
+
return declarativeResult;
|
|
16
|
+
// Hand-coded path: for rules whose logic is too complex to express as a
|
|
17
|
+
// single descriptor (iteration, compound conditions, nested schema checks).
|
|
18
|
+
switch (rule.category) {
|
|
19
|
+
case 'meta': return checkMetaRules(rule, doc);
|
|
20
|
+
case 'headings': return checkHeadingRules(rule, doc);
|
|
21
|
+
case 'images': return checkImageRules(rule, doc);
|
|
22
|
+
case 'links': return checkLinkRules(rule, doc);
|
|
23
|
+
case 'technical': return checkTechnicalRules(rule, doc);
|
|
24
|
+
case 'schema': return checkSchemaRules(rule, doc);
|
|
25
|
+
case 'performance': return checkPerformanceRules(rule, doc);
|
|
26
|
+
default: return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
import { suggestTagAttr } from './suggest.js';
|
|
3
|
+
const VAGUE_LINK_TEXTS = ['click here', 'read more', 'learn more', 'here', 'this', 'link', 'more'];
|
|
4
|
+
export function checkLinkRules(rule, doc) {
|
|
5
|
+
switch (rule.id) {
|
|
6
|
+
case 'vague-link-text': {
|
|
7
|
+
const issues = [];
|
|
8
|
+
for (const link of doc.links) {
|
|
9
|
+
if (VAGUE_LINK_TEXTS.includes(link.text.toLowerCase().trim())) {
|
|
10
|
+
issues.push(buildIssue(rule, doc, link.line, rule.fixTemplate.replace('{{TEXT}}', link.text).replace('{{HREF}}', link.href)));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return issues.length > 0 ? issues : null;
|
|
14
|
+
}
|
|
15
|
+
case 'external-link-no-rel': {
|
|
16
|
+
const issues = [];
|
|
17
|
+
for (const link of doc.links) {
|
|
18
|
+
const isExternal = link.href.startsWith('http') || link.href.startsWith('//');
|
|
19
|
+
if (isExternal) {
|
|
20
|
+
// noreferrer implies noopener in all modern browsers — either token satisfies the rule
|
|
21
|
+
const tokens = (link.rel ?? '').split(/\s+/).filter(Boolean);
|
|
22
|
+
const isSafe = tokens.includes('noopener') || tokens.includes('noreferrer');
|
|
23
|
+
if (!isSafe) {
|
|
24
|
+
// Provably-correct 1-click fix: add rel="noopener noreferrer" — but
|
|
25
|
+
// only when the <a> has no rel at all (merging tokens into an
|
|
26
|
+
// existing rel is left advisory to avoid clobbering author intent).
|
|
27
|
+
const suggestion = link.rel
|
|
28
|
+
? undefined
|
|
29
|
+
: suggestTagAttr(doc, link.line, 'a', 'rel="noopener noreferrer"', 'rel');
|
|
30
|
+
issues.push(buildIssue(rule, doc, link.line, rule.fixTemplate.replace('{{HREF}}', link.href), suggestion));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return issues.length > 0 ? issues : null;
|
|
35
|
+
}
|
|
36
|
+
default:
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Hand-coded handlers for meta rules whose logic is too complex for a
|
|
4
|
+
* declarative check descriptor. Simple "missing X" and length checks for
|
|
5
|
+
* meta tags live in seo-rules.json as `check` descriptors instead.
|
|
6
|
+
*/
|
|
7
|
+
export function checkMetaRules(rule, doc) {
|
|
8
|
+
switch (rule.id) {
|
|
9
|
+
case 'missing-og-locale': {
|
|
10
|
+
const hasHreflang = doc.hreflangLinks.length > 0;
|
|
11
|
+
const hasNonEnglishLang = doc.langAttribute && !/^en(-|$)/i.test(doc.langAttribute.value);
|
|
12
|
+
if ((hasHreflang || hasNonEnglishLang) && !doc.ogLocale) {
|
|
13
|
+
return buildIssue(rule, doc, doc.langAttribute?.line ?? null, rule.fixTemplate);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
case 'read-more-deeplink': {
|
|
18
|
+
if (!doc.isPageDocument)
|
|
19
|
+
return null;
|
|
20
|
+
const subheadings = doc.headings.filter(h => h.tag !== 'h1');
|
|
21
|
+
if (subheadings.length < 2)
|
|
22
|
+
return null;
|
|
23
|
+
const headingsWithId = (doc.rawContent.match(/<h[2-6][^>]+id\s*=/gi) ?? []).length;
|
|
24
|
+
if (headingsWithId === 0)
|
|
25
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Content-strategy advisory — nothing actionable to detect statically
|
|
29
|
+
case 'generative-ai-optimization':
|
|
30
|
+
return null;
|
|
31
|
+
default:
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
export function checkPerformanceRules(rule, doc) {
|
|
3
|
+
switch (rule.id) {
|
|
4
|
+
case 'missing-preload-lcp':
|
|
5
|
+
if (doc.images.length > 0 && doc.preloadLinks.length === 0) {
|
|
6
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
case 'inp-core-web-vital': {
|
|
10
|
+
// Detect render-blocking external scripts (no async or defer attribute)
|
|
11
|
+
const scriptTags = doc.rawContent.match(/<script[^>]+src\s*=\s*["'][^"']+["'][^>]*>/gi) ?? [];
|
|
12
|
+
const blockingCount = scriptTags.filter(tag => !/\b(?:async|defer)\b/i.test(tag)).length;
|
|
13
|
+
if (blockingCount > 1) {
|
|
14
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
// FID was removed as a Core Web Vital in March 2024 — disabled
|
|
19
|
+
case 'missing-fid':
|
|
20
|
+
return null;
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { buildIssue } from './helpers.js';
|
|
2
|
+
// ── JSON-LD helpers ──────────────────────────────────────────────────────────
|
|
3
|
+
function parseAllJsonLd(doc) {
|
|
4
|
+
const result = [];
|
|
5
|
+
for (const block of doc.jsonLdBlocks) {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(block.content);
|
|
8
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
9
|
+
for (const item of items) {
|
|
10
|
+
result.push(item);
|
|
11
|
+
if (Array.isArray(item?.['@graph']))
|
|
12
|
+
result.push(...item['@graph']);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch { /* invalid JSON is handled by the invalid-json-ld rule */ }
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
function schemaTypes(item) {
|
|
20
|
+
const t = item?.['@type'];
|
|
21
|
+
if (!t)
|
|
22
|
+
return [];
|
|
23
|
+
return Array.isArray(t) ? t : [t];
|
|
24
|
+
}
|
|
25
|
+
function hasSchemaType(items, type) {
|
|
26
|
+
return items.some(item => schemaTypes(item).includes(type));
|
|
27
|
+
}
|
|
28
|
+
function findByType(items, type) {
|
|
29
|
+
return items.filter(item => schemaTypes(item).includes(type));
|
|
30
|
+
}
|
|
31
|
+
function lineForType(doc, type) {
|
|
32
|
+
for (const block of doc.jsonLdBlocks) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(block.content);
|
|
35
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
36
|
+
if (items.some((item) => {
|
|
37
|
+
const t = item?.['@type'];
|
|
38
|
+
return Array.isArray(t) ? t.includes(type) : t === type;
|
|
39
|
+
}))
|
|
40
|
+
return block.line;
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const HOMEPAGE_PATTERN = /(?:^|[/\\])(?:index|page|app|root)\.[a-z]+$/i;
|
|
47
|
+
// ── Rule handler ─────────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Hand-coded handlers for schema rules whose logic is too complex for a
|
|
50
|
+
* declarative check descriptor.
|
|
51
|
+
*
|
|
52
|
+
* Simple deprecated-type checks (faq-schema-deprecated, etc.) and
|
|
53
|
+
* missing-json-ld live as `check` descriptors in seo-rules.json instead.
|
|
54
|
+
*/
|
|
55
|
+
export function checkSchemaRules(rule, doc) {
|
|
56
|
+
switch (rule.id) {
|
|
57
|
+
case 'invalid-json-ld': {
|
|
58
|
+
const issues = [];
|
|
59
|
+
for (const block of doc.jsonLdBlocks) {
|
|
60
|
+
// Runtime-generated JSON-LD (dangerouslySetInnerHTML / {expr}) has no
|
|
61
|
+
// literal body to validate — never flag it as invalid.
|
|
62
|
+
if (block.dynamic)
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
JSON.parse(block.content);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
issues.push(buildIssue(rule, doc, block.line, rule.fixTemplate));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return issues.length > 0 ? issues : null;
|
|
72
|
+
}
|
|
73
|
+
case 'json-ld-recommended-format': {
|
|
74
|
+
const hasMicrodata = /\bitemscope\b/.test(doc.rawContent);
|
|
75
|
+
const hasRdfa = /\btypeof\s*=\s*["']/.test(doc.rawContent);
|
|
76
|
+
if ((hasMicrodata || hasRdfa) && doc.jsonLdBlocks.length === 0) {
|
|
77
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
case 'structured-data-javascript-initial-html': {
|
|
82
|
+
if (doc.fileType === 'html')
|
|
83
|
+
return null;
|
|
84
|
+
if (doc.jsonLdBlocks.length > 0) {
|
|
85
|
+
return buildIssue(rule, doc, doc.jsonLdBlocks[0].line, rule.fixTemplate);
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
case 'review-snippet-requires-comment': {
|
|
90
|
+
const items = parseAllJsonLd(doc);
|
|
91
|
+
for (const review of findByType(items, 'Review')) {
|
|
92
|
+
if (!review.reviewBody || !review.author) {
|
|
93
|
+
return buildIssue(rule, doc, lineForType(doc, 'Review'), rule.fixTemplate);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
case 'review-no-multiple-reviewed-entities': {
|
|
99
|
+
const items = parseAllJsonLd(doc);
|
|
100
|
+
for (const type of ['Review', 'AggregateRating']) {
|
|
101
|
+
for (const item of findByType(items, type)) {
|
|
102
|
+
const reviewed = item.itemReviewed;
|
|
103
|
+
if (Array.isArray(reviewed) && reviewed.length > 1) {
|
|
104
|
+
return buildIssue(rule, doc, lineForType(doc, type), rule.fixTemplate);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
case 'video-indexing-criteria': {
|
|
111
|
+
const items = parseAllJsonLd(doc);
|
|
112
|
+
for (const video of findByType(items, 'VideoObject')) {
|
|
113
|
+
const missing = [];
|
|
114
|
+
if (!video.name)
|
|
115
|
+
missing.push('name');
|
|
116
|
+
if (!video.thumbnailUrl && !video.thumbnail)
|
|
117
|
+
missing.push('thumbnailUrl');
|
|
118
|
+
if (!video.contentUrl && !video.embedUrl)
|
|
119
|
+
missing.push('contentUrl/embedUrl');
|
|
120
|
+
if (!video.uploadDate)
|
|
121
|
+
missing.push('uploadDate');
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
return buildIssue(rule, doc, lineForType(doc, 'VideoObject'), `${rule.fixTemplate} Missing: ${missing.join(', ')}.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
case 'recipe-thumbnail-property': {
|
|
129
|
+
const items = parseAllJsonLd(doc);
|
|
130
|
+
for (const recipe of findByType(items, 'Recipe')) {
|
|
131
|
+
if (recipe.thumbnail && !recipe.image) {
|
|
132
|
+
return buildIssue(rule, doc, lineForType(doc, 'Recipe'), rule.fixTemplate);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
case 'product-structured-data-single-product': {
|
|
138
|
+
const items = parseAllJsonLd(doc);
|
|
139
|
+
if (findByType(items, 'Product').length > 1) {
|
|
140
|
+
return buildIssue(rule, doc, lineForType(doc, 'Product'), rule.fixTemplate);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
case 'merchant-shipping-policy-schema': {
|
|
145
|
+
const items = parseAllJsonLd(doc);
|
|
146
|
+
if (findByType(items, 'Product').length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
const hasShipping = items.some(item => item.shippingDetails) ||
|
|
149
|
+
findByType(items, 'Product').some(p => JSON.stringify(p.offers ?? {}).includes('shippingDetails'));
|
|
150
|
+
if (!hasShipping) {
|
|
151
|
+
return buildIssue(rule, doc, lineForType(doc, 'Product'), rule.fixTemplate);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
case 'merchant-return-policy-schema': {
|
|
156
|
+
const items = parseAllJsonLd(doc);
|
|
157
|
+
if (findByType(items, 'Product').length === 0)
|
|
158
|
+
return null;
|
|
159
|
+
const hasReturn = hasSchemaType(items, 'MerchantReturnPolicy') ||
|
|
160
|
+
items.some(item => item.hasMerchantReturnPolicy) ||
|
|
161
|
+
findByType(items, 'Product').some(p => p.hasMerchantReturnPolicy);
|
|
162
|
+
if (!hasReturn) {
|
|
163
|
+
return buildIssue(rule, doc, lineForType(doc, 'Product'), rule.fixTemplate);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
case 'loyalty-program-schema': {
|
|
168
|
+
const items = parseAllJsonLd(doc);
|
|
169
|
+
if (findByType(items, 'Product').length === 0)
|
|
170
|
+
return null;
|
|
171
|
+
const orgs = findByType(items, 'Organization').concat(findByType(items, 'LocalBusiness'));
|
|
172
|
+
if (orgs.length === 0)
|
|
173
|
+
return null;
|
|
174
|
+
if (!orgs.some(org => org.memberOf || org.hasMembershipProgram)) {
|
|
175
|
+
return buildIssue(rule, doc, lineForType(doc, 'Organization'), rule.fixTemplate);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
case 'event-structured-data-requirements': {
|
|
180
|
+
const items = parseAllJsonLd(doc);
|
|
181
|
+
for (const event of findByType(items, 'Event')) {
|
|
182
|
+
const location = event.location;
|
|
183
|
+
if (!location) {
|
|
184
|
+
return buildIssue(rule, doc, lineForType(doc, 'Event'), rule.fixTemplate);
|
|
185
|
+
}
|
|
186
|
+
const locArr = Array.isArray(location) ? location : [location];
|
|
187
|
+
const hasPhysicalPlace = locArr.some((loc) => {
|
|
188
|
+
const t = schemaTypes(loc);
|
|
189
|
+
return t.includes('Place') || t.includes('PostalAddress');
|
|
190
|
+
});
|
|
191
|
+
if (!hasPhysicalPlace) {
|
|
192
|
+
return buildIssue(rule, doc, lineForType(doc, 'Event'), rule.fixTemplate);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
case 'site-name-schema': {
|
|
198
|
+
if (!doc.isPageDocument || !HOMEPAGE_PATTERN.test(doc.filePath))
|
|
199
|
+
return null;
|
|
200
|
+
const items = parseAllJsonLd(doc);
|
|
201
|
+
if (!hasSchemaType(items, 'WebSite')) {
|
|
202
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
case 'organization-markup': {
|
|
207
|
+
if (!doc.isPageDocument || !HOMEPAGE_PATTERN.test(doc.filePath))
|
|
208
|
+
return null;
|
|
209
|
+
const items = parseAllJsonLd(doc);
|
|
210
|
+
if (!hasSchemaType(items, 'Organization') && !hasSchemaType(items, 'LocalBusiness')) {
|
|
211
|
+
return buildIssue(rule, doc, null, rule.fixTemplate);
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
case 'breadcrumbs-desktop-only': {
|
|
216
|
+
const items = parseAllJsonLd(doc);
|
|
217
|
+
if (hasSchemaType(items, 'BreadcrumbList')) {
|
|
218
|
+
return buildIssue(rule, doc, lineForType(doc, 'BreadcrumbList'), rule.fixTemplate);
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
// Regional feature — cannot determine site geography from file content
|
|
223
|
+
case 'south-africa-carousel-structured-data':
|
|
224
|
+
return null;
|
|
225
|
+
default:
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|