@jay-framework/seo-validator 0.22.2 → 0.23.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/dist/index.js +98 -42
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import { walkElements } from "@jay-framework/compiler-shared";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
function isComponent(filePath) {
|
|
5
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
6
|
+
return normalized.includes("/components/");
|
|
7
|
+
}
|
|
4
8
|
const validate = (ctx) => {
|
|
5
9
|
const findings = [];
|
|
10
|
+
const isComp = isComponent(ctx.filePath);
|
|
6
11
|
let hasH1 = false;
|
|
7
12
|
let h1Count = 0;
|
|
8
13
|
let lastHeadingLevel = 0;
|
|
9
14
|
let hasMain = false;
|
|
10
|
-
|
|
15
|
+
const LARGE_IMAGE_THRESHOLD = 200;
|
|
16
|
+
let hasLargeImage = false;
|
|
11
17
|
let hasFetchPriorityHigh = false;
|
|
12
18
|
walkElements(ctx.body, ctx, (el) => {
|
|
13
19
|
const tag = el.rawTagName?.toLowerCase();
|
|
14
|
-
if (!tag)
|
|
15
|
-
return;
|
|
20
|
+
if (!tag) return;
|
|
16
21
|
if (tag === "img") {
|
|
17
|
-
|
|
22
|
+
const w = parseInt(el.getAttribute?.("width") || "", 10);
|
|
23
|
+
const h = parseInt(el.getAttribute?.("height") || "", 10);
|
|
24
|
+
const hasExplicitSize = !isNaN(w) && !isNaN(h);
|
|
25
|
+
const isLarge = !hasExplicitSize || w >= LARGE_IMAGE_THRESHOLD || h >= LARGE_IMAGE_THRESHOLD;
|
|
26
|
+
if (isLarge) hasLargeImage = true;
|
|
18
27
|
if (el.getAttribute?.("fetchpriority") === "high") {
|
|
19
28
|
hasFetchPriorityHigh = true;
|
|
20
29
|
}
|
|
21
30
|
const alt = el.getAttribute?.("alt");
|
|
31
|
+
const imgTag = el.outerHTML?.split(">")[0] + ">" || "<img>";
|
|
22
32
|
if (alt === void 0 || alt === null) {
|
|
23
33
|
findings.push({
|
|
24
34
|
severity: "warning",
|
|
25
|
-
message:
|
|
35
|
+
message: `Image missing alt attribute: ${imgTag}`,
|
|
26
36
|
suggestion: 'Add an alt attribute with descriptive text. For decorative images use alt="".',
|
|
27
37
|
element: "<img>",
|
|
28
38
|
attribute: "alt"
|
|
@@ -38,8 +48,8 @@ const validate = (ctx) => {
|
|
|
38
48
|
if ((!hasInlineWidth || !hasInlineHeight) && !srcset) {
|
|
39
49
|
findings.push({
|
|
40
50
|
severity: "warning",
|
|
41
|
-
message:
|
|
42
|
-
suggestion: 'Add width and height attributes to prevent Cumulative Layout Shift. Example: <img width="800" height="600" ... />. For responsive images, use srcset with sizes. CLS is a Core Web Vital that affects search ranking.',
|
|
51
|
+
message: `Image missing explicit dimensions — causes layout shift (CLS): ${imgTag}`,
|
|
52
|
+
suggestion: 'Add width and height attributes to prevent Cumulative Layout Shift. Example: <img width="800" height="600" ... />. For small icons, add the actual size (e.g., width="20" height="20"). For responsive images, use srcset with sizes. CLS is a Core Web Vital that affects search ranking.',
|
|
43
53
|
element: "<img>",
|
|
44
54
|
attribute: "width"
|
|
45
55
|
});
|
|
@@ -49,8 +59,8 @@ const validate = (ctx) => {
|
|
|
49
59
|
if (!loading) {
|
|
50
60
|
findings.push({
|
|
51
61
|
severity: "warning",
|
|
52
|
-
message:
|
|
53
|
-
suggestion: 'Add loading="lazy"
|
|
62
|
+
message: `Image without loading attribute: ${imgTag}`,
|
|
63
|
+
suggestion: 'Add loading="lazy" for off-screen images, or loading="eager" for above-the-fold images. Either value suppresses this warning.',
|
|
54
64
|
element: "<img>",
|
|
55
65
|
attribute: "loading"
|
|
56
66
|
});
|
|
@@ -93,44 +103,45 @@ const validate = (ctx) => {
|
|
|
93
103
|
lastHeadingLevel = level;
|
|
94
104
|
}
|
|
95
105
|
});
|
|
96
|
-
if (!
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
if (!isComp) {
|
|
107
|
+
if (!hasH1) {
|
|
108
|
+
findings.push({
|
|
109
|
+
severity: "warning",
|
|
110
|
+
message: "Page has no <h1> element — the primary heading is important for SEO",
|
|
111
|
+
suggestion: "Add an <h1> element with the main page title or topic. Each page should have exactly one <h1>.",
|
|
112
|
+
element: "<h1>"
|
|
113
|
+
});
|
|
114
|
+
} else if (h1Count > 1) {
|
|
115
|
+
findings.push({
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: `Page has ${h1Count} <h1> elements — should have exactly one`,
|
|
118
|
+
suggestion: "Keep only one <h1> for the primary page heading. Use <h2> or lower for secondary headings.",
|
|
119
|
+
element: "<h1>"
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (!hasMain) {
|
|
123
|
+
findings.push({
|
|
124
|
+
severity: "warning",
|
|
125
|
+
message: "Page has no <main> landmark — helps search engines identify primary content",
|
|
126
|
+
suggestion: "Wrap the primary page content in a <main> element. Each page should have one <main> landmark.",
|
|
127
|
+
element: "<main>"
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (hasLargeImage && !hasFetchPriorityHigh) {
|
|
131
|
+
findings.push({
|
|
132
|
+
severity: "warning",
|
|
133
|
+
message: 'No image has fetchpriority="high" — the LCP image should be prioritized',
|
|
134
|
+
suggestion: 'Add fetchpriority="high" to the largest above-the-fold image (the LCP candidate). This tells the browser to download it first, improving Largest Contentful Paint.',
|
|
135
|
+
element: "<img>",
|
|
136
|
+
attribute: "fetchpriority"
|
|
137
|
+
});
|
|
138
|
+
}
|
|
127
139
|
}
|
|
128
140
|
const cssSources = [];
|
|
129
141
|
const styleBlocks = ctx.body.querySelectorAll?.("style") ?? [];
|
|
130
142
|
for (const styleEl of styleBlocks) {
|
|
131
143
|
const cssText = styleEl.textContent || "";
|
|
132
|
-
if (cssText)
|
|
133
|
-
cssSources.push({ css: cssText, source: "<style>" });
|
|
144
|
+
if (cssText) cssSources.push({ css: cssText, source: "<style>" });
|
|
134
145
|
}
|
|
135
146
|
const linkedFiles = ctx.body.querySelectorAll?.('link[rel="stylesheet"]') ?? [];
|
|
136
147
|
for (const link of linkedFiles) {
|
|
@@ -160,6 +171,7 @@ const validate = (ctx) => {
|
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
}
|
|
174
|
+
if (isComp) return findings;
|
|
163
175
|
const componentHeadTags = new Set(
|
|
164
176
|
ctx.headlessImports.flatMap((imp) => imp.providedHeadTags ?? [])
|
|
165
177
|
);
|
|
@@ -207,6 +219,50 @@ const validate = (ctx) => {
|
|
|
207
219
|
attribute: "content"
|
|
208
220
|
});
|
|
209
221
|
}
|
|
222
|
+
const preconnectOrigins = new Set(
|
|
223
|
+
ctx.head.links.filter((l) => l.rel === "preconnect").map((l) => {
|
|
224
|
+
try {
|
|
225
|
+
return new URL(l.href.map((p) => p.value).join("")).origin;
|
|
226
|
+
} catch {
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
}).filter(Boolean)
|
|
230
|
+
);
|
|
231
|
+
const FONT_SERVICE_DOMAINS = ["fonts.googleapis.com", "use.typekit.net"];
|
|
232
|
+
for (const link of ctx.head.links) {
|
|
233
|
+
if (link.rel !== "stylesheet") continue;
|
|
234
|
+
const href = link.href.map((p) => p.value).join("");
|
|
235
|
+
if (!href.startsWith("http://") && !href.startsWith("https://")) continue;
|
|
236
|
+
let origin;
|
|
237
|
+
let hostname;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = new URL(href);
|
|
240
|
+
origin = parsed.origin;
|
|
241
|
+
hostname = parsed.hostname;
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!preconnectOrigins.has(origin)) {
|
|
246
|
+
findings.push({
|
|
247
|
+
severity: "warning",
|
|
248
|
+
message: `External stylesheet from ${hostname} without <link rel="preconnect"> — delays resource discovery`,
|
|
249
|
+
suggestion: `Add <link rel="preconnect" href="${origin}"> before the stylesheet in <head>. Preconnect establishes the connection early, reducing load time.`,
|
|
250
|
+
element: "<link>",
|
|
251
|
+
attribute: "href"
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (FONT_SERVICE_DOMAINS.some((d) => hostname === d || hostname.endsWith("." + d))) {
|
|
255
|
+
if (!href.includes("display=swap")) {
|
|
256
|
+
findings.push({
|
|
257
|
+
severity: "warning",
|
|
258
|
+
message: `Font stylesheet from ${hostname} missing display=swap — blocks text rendering`,
|
|
259
|
+
suggestion: "Add &display=swap to the font URL to avoid invisible text while fonts load. Example: https://fonts.googleapis.com/css2?family=Inter&display=swap",
|
|
260
|
+
element: "<link>",
|
|
261
|
+
attribute: "href"
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
210
266
|
}
|
|
211
267
|
return findings;
|
|
212
268
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/seo-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SEO validation plugin for Jay Framework — checks jay-html templates for SEO best practices",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"test": "vitest run"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@jay-framework/compiler-shared": "^0.
|
|
27
|
+
"@jay-framework/compiler-shared": "^0.23.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@jay-framework/dev-environment": "^0.
|
|
31
|
-
"@jay-framework/jay-stack-cli": "^0.
|
|
30
|
+
"@jay-framework/dev-environment": "^0.23.1",
|
|
31
|
+
"@jay-framework/jay-stack-cli": "^0.23.1",
|
|
32
32
|
"@types/node": "^22.15.21",
|
|
33
33
|
"node-html-parser": "^6.1.0",
|
|
34
34
|
"rimraf": "^5.0.5",
|