@jay-framework/seo-validator 0.24.1 → 0.24.3
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.d.ts +1 -4
- package/dist/index.js +1 -271
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +279 -0
- package/package.json +8 -6
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,271 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
function isComponent(filePath) {
|
|
5
|
-
const normalized = filePath.replace(/\\/g, "/");
|
|
6
|
-
return normalized.includes("/components/");
|
|
7
|
-
}
|
|
8
|
-
const validate = (ctx) => {
|
|
9
|
-
const findings = [];
|
|
10
|
-
const isComp = isComponent(ctx.filePath);
|
|
11
|
-
let hasH1 = false;
|
|
12
|
-
let h1Count = 0;
|
|
13
|
-
let lastHeadingLevel = 0;
|
|
14
|
-
let hasMain = false;
|
|
15
|
-
const LARGE_IMAGE_THRESHOLD = 200;
|
|
16
|
-
let hasLargeImage = false;
|
|
17
|
-
let hasFetchPriorityHigh = false;
|
|
18
|
-
walkElements(ctx.body, ctx, (el) => {
|
|
19
|
-
const tag = el.rawTagName?.toLowerCase();
|
|
20
|
-
if (!tag) return;
|
|
21
|
-
if (tag === "img") {
|
|
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;
|
|
27
|
-
if (el.getAttribute?.("fetchpriority") === "high") {
|
|
28
|
-
hasFetchPriorityHigh = true;
|
|
29
|
-
}
|
|
30
|
-
const alt = el.getAttribute?.("alt");
|
|
31
|
-
const imgTag = el.outerHTML?.split(">")[0] + ">" || "<img>";
|
|
32
|
-
if (alt === void 0 || alt === null) {
|
|
33
|
-
findings.push({
|
|
34
|
-
severity: "warning",
|
|
35
|
-
message: `Image missing alt attribute: ${imgTag}`,
|
|
36
|
-
suggestion: 'Add an alt attribute with descriptive text. For decorative images use alt="".',
|
|
37
|
-
element: "<img>",
|
|
38
|
-
attribute: "alt"
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
const width = el.getAttribute?.("width");
|
|
42
|
-
const height = el.getAttribute?.("height");
|
|
43
|
-
const srcset = el.getAttribute?.("srcset");
|
|
44
|
-
if (!width || !height) {
|
|
45
|
-
const style = el.getAttribute?.("style") || "";
|
|
46
|
-
const hasInlineWidth = /width\s*:/.test(style);
|
|
47
|
-
const hasInlineHeight = /height\s*:/.test(style);
|
|
48
|
-
if ((!hasInlineWidth || !hasInlineHeight) && !srcset) {
|
|
49
|
-
findings.push({
|
|
50
|
-
severity: "warning",
|
|
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.',
|
|
53
|
-
element: "<img>",
|
|
54
|
-
attribute: "width"
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const loading = el.getAttribute?.("loading");
|
|
59
|
-
if (!loading) {
|
|
60
|
-
findings.push({
|
|
61
|
-
severity: "warning",
|
|
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.',
|
|
64
|
-
element: "<img>",
|
|
65
|
-
attribute: "loading"
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (tag === "a") {
|
|
70
|
-
const href = el.getAttribute?.("href");
|
|
71
|
-
const text = el.textContent?.trim();
|
|
72
|
-
if (href && (!text || text.length === 0) && !el.querySelector?.("img")) {
|
|
73
|
-
const ariaLabel = el.getAttribute?.("aria-label");
|
|
74
|
-
if (!ariaLabel) {
|
|
75
|
-
findings.push({
|
|
76
|
-
severity: "warning",
|
|
77
|
-
message: "Anchor element has no visible text or aria-label — bad for SEO link signals",
|
|
78
|
-
suggestion: "Add descriptive text content inside the <a> tag, or add an aria-label attribute.",
|
|
79
|
-
element: "<a>",
|
|
80
|
-
attribute: "href"
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (tag === "main") {
|
|
86
|
-
hasMain = true;
|
|
87
|
-
}
|
|
88
|
-
const headingMatch = tag.match(/^h([1-6])$/);
|
|
89
|
-
if (headingMatch) {
|
|
90
|
-
const level = parseInt(headingMatch[1], 10);
|
|
91
|
-
if (level === 1) {
|
|
92
|
-
hasH1 = true;
|
|
93
|
-
h1Count++;
|
|
94
|
-
}
|
|
95
|
-
if (lastHeadingLevel > 0 && level > lastHeadingLevel + 1) {
|
|
96
|
-
findings.push({
|
|
97
|
-
severity: "warning",
|
|
98
|
-
message: `Heading level skipped: <h${lastHeadingLevel}> followed by <h${level}>`,
|
|
99
|
-
suggestion: `Use <h${lastHeadingLevel + 1}> instead of <h${level}> to maintain heading hierarchy. Search engines use heading structure to understand content organization.`,
|
|
100
|
-
element: `<h${level}>`
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
lastHeadingLevel = level;
|
|
104
|
-
}
|
|
105
|
-
});
|
|
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
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const cssSources = [];
|
|
141
|
-
const styleBlocks = ctx.body.querySelectorAll?.("style") ?? [];
|
|
142
|
-
for (const styleEl of styleBlocks) {
|
|
143
|
-
const cssText = styleEl.textContent || "";
|
|
144
|
-
if (cssText) cssSources.push({ css: cssText, source: "<style>" });
|
|
145
|
-
}
|
|
146
|
-
const linkedFiles = ctx.body.querySelectorAll?.('link[rel="stylesheet"]') ?? [];
|
|
147
|
-
for (const link of linkedFiles) {
|
|
148
|
-
const href = link.getAttribute?.("href");
|
|
149
|
-
if (href && !href.startsWith("http")) {
|
|
150
|
-
try {
|
|
151
|
-
const dir = path.dirname(path.resolve(ctx.projectRoot, ctx.filePath));
|
|
152
|
-
const cssPath = path.resolve(dir, href);
|
|
153
|
-
const cssText = fs.readFileSync(cssPath, "utf-8");
|
|
154
|
-
cssSources.push({ css: cssText, source: href });
|
|
155
|
-
} catch {
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
for (const { css, source } of cssSources) {
|
|
160
|
-
const importRegex = /@import\s+(?:url\(\s*['"]?([^'")]+)['"]?\s*\)|['"]([^'"]+)['"])/g;
|
|
161
|
-
let importMatch;
|
|
162
|
-
while ((importMatch = importRegex.exec(css)) !== null) {
|
|
163
|
-
const url = importMatch[1] || importMatch[2];
|
|
164
|
-
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
165
|
-
findings.push({
|
|
166
|
-
severity: "warning",
|
|
167
|
-
message: `CSS @import of external URL "${url}" creates a chained blocking request that delays page rendering`,
|
|
168
|
-
suggestion: 'Move this to a <link rel="stylesheet" href="..."> tag in the HTML <head> instead. This allows the browser preload scanner to discover both resources in parallel.',
|
|
169
|
-
element: source === "<style>" ? "<style>" : `<link href="${source}">`
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
if (isComp) return findings;
|
|
175
|
-
const componentHeadTags = new Set(
|
|
176
|
-
ctx.headlessImports.flatMap((imp) => imp.providedHeadTags ?? [])
|
|
177
|
-
);
|
|
178
|
-
if (ctx.head) {
|
|
179
|
-
if (!ctx.head.title && !componentHeadTags.has("title")) {
|
|
180
|
-
findings.push({
|
|
181
|
-
severity: "warning",
|
|
182
|
-
message: "Page has no <title> element",
|
|
183
|
-
suggestion: "Add <title>Page Title</title> in <head>. The title appears in search results and browser tabs.",
|
|
184
|
-
element: "<title>"
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
const hasDescription = ctx.head.meta.some((m) => m.name?.toLowerCase() === "description");
|
|
188
|
-
if (!hasDescription && !componentHeadTags.has("meta:description")) {
|
|
189
|
-
findings.push({
|
|
190
|
-
severity: "warning",
|
|
191
|
-
message: 'Page has no <meta name="description">',
|
|
192
|
-
suggestion: 'Add <meta name="description" content="..."> in <head>. Search engines use this for result snippets.',
|
|
193
|
-
element: "<meta>",
|
|
194
|
-
attribute: "name"
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
const canonical = ctx.head.links.find((l) => l.rel === "canonical");
|
|
198
|
-
if (canonical) {
|
|
199
|
-
const hasBinding = canonical.href.some((p) => p.kind === "binding");
|
|
200
|
-
const hrefStr = canonical.href.map((p) => p.value).join("");
|
|
201
|
-
if (!hrefStr.startsWith("http://") && !hrefStr.startsWith("https://") && !hasBinding) {
|
|
202
|
-
findings.push({
|
|
203
|
-
severity: "warning",
|
|
204
|
-
message: "Canonical URL should be absolute",
|
|
205
|
-
suggestion: "Change the canonical href to an absolute URL (e.g., https://example.com/page). Relative canonicals may not be interpreted correctly by all search engines.",
|
|
206
|
-
element: "<link>",
|
|
207
|
-
attribute: "href"
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
const robotsMeta = ctx.head.meta.find((m) => m.name?.toLowerCase() === "robots");
|
|
212
|
-
const robotsContent = robotsMeta?.content.map((p) => p.value).join("");
|
|
213
|
-
if (robotsContent && /noindex/i.test(robotsContent)) {
|
|
214
|
-
findings.push({
|
|
215
|
-
severity: "warning",
|
|
216
|
-
message: 'Page has <meta name="robots" content="noindex"> — it will not appear in search results',
|
|
217
|
-
suggestion: "Remove noindex from the robots meta tag if this page should be indexed. If intentional (e.g. admin pages), this warning can be ignored.",
|
|
218
|
-
element: "<meta>",
|
|
219
|
-
attribute: "content"
|
|
220
|
-
});
|
|
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
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return findings;
|
|
268
|
-
};
|
|
269
|
-
export {
|
|
270
|
-
validate
|
|
271
|
-
};
|
|
1
|
+
|
package/dist/tools.d.ts
ADDED
package/dist/tools.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { walkElements } from "@jay-framework/compiler-shared";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
function isComponent(filePath) {
|
|
5
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
6
|
+
return normalized.includes("/components/");
|
|
7
|
+
}
|
|
8
|
+
function isSuppressed(ctx, rule) {
|
|
9
|
+
return ctx.validationOverrides?.seo?.[rule] === true;
|
|
10
|
+
}
|
|
11
|
+
const validate = (ctx) => {
|
|
12
|
+
const findings = [];
|
|
13
|
+
const isComp = isComponent(ctx.filePath);
|
|
14
|
+
let hasH1 = false;
|
|
15
|
+
let h1Count = 0;
|
|
16
|
+
let lastHeadingLevel = 0;
|
|
17
|
+
let hasMain = false;
|
|
18
|
+
const LARGE_IMAGE_THRESHOLD = 200;
|
|
19
|
+
let hasLargeImage = false;
|
|
20
|
+
let hasFetchPriorityHigh = false;
|
|
21
|
+
walkElements(ctx.body, ctx, (el) => {
|
|
22
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
23
|
+
if (!tag) return;
|
|
24
|
+
if (tag === "img") {
|
|
25
|
+
const w = parseInt(el.getAttribute?.("width") || "", 10);
|
|
26
|
+
const h = parseInt(el.getAttribute?.("height") || "", 10);
|
|
27
|
+
const hasExplicitSize = !isNaN(w) && !isNaN(h);
|
|
28
|
+
const isLarge = !hasExplicitSize || w >= LARGE_IMAGE_THRESHOLD || h >= LARGE_IMAGE_THRESHOLD;
|
|
29
|
+
if (isLarge) hasLargeImage = true;
|
|
30
|
+
if (el.getAttribute?.("fetchpriority") === "high") {
|
|
31
|
+
hasFetchPriorityHigh = true;
|
|
32
|
+
}
|
|
33
|
+
const alt = el.getAttribute?.("alt");
|
|
34
|
+
const imgTag = el.outerHTML?.split(">")[0] + ">" || "<img>";
|
|
35
|
+
if (alt === void 0 || alt === null) {
|
|
36
|
+
findings.push({
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Image missing alt attribute: ${imgTag}`,
|
|
39
|
+
suggestion: 'Add an alt attribute with descriptive text. For decorative images use alt="".',
|
|
40
|
+
element: "<img>",
|
|
41
|
+
attribute: "alt"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const width = el.getAttribute?.("width");
|
|
45
|
+
const height = el.getAttribute?.("height");
|
|
46
|
+
const srcset = el.getAttribute?.("srcset");
|
|
47
|
+
if (!width || !height) {
|
|
48
|
+
const style = el.getAttribute?.("style") || "";
|
|
49
|
+
const hasInlineWidth = /width\s*:/.test(style);
|
|
50
|
+
const hasInlineHeight = /height\s*:/.test(style);
|
|
51
|
+
if ((!hasInlineWidth || !hasInlineHeight) && !srcset) {
|
|
52
|
+
findings.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: `Image missing explicit dimensions — causes layout shift (CLS): ${imgTag}`,
|
|
55
|
+
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.',
|
|
56
|
+
element: "<img>",
|
|
57
|
+
attribute: "width"
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const loading = el.getAttribute?.("loading");
|
|
62
|
+
if (!loading) {
|
|
63
|
+
findings.push({
|
|
64
|
+
severity: "warning",
|
|
65
|
+
message: `Image without loading attribute: ${imgTag}`,
|
|
66
|
+
suggestion: 'Add loading="lazy" for off-screen images, or loading="eager" for above-the-fold images. Either value suppresses this warning.',
|
|
67
|
+
element: "<img>",
|
|
68
|
+
attribute: "loading"
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (tag === "a") {
|
|
73
|
+
const href = el.getAttribute?.("href");
|
|
74
|
+
const text = el.textContent?.trim();
|
|
75
|
+
if (href && (!text || text.length === 0) && !el.querySelector?.("img")) {
|
|
76
|
+
const ariaLabel = el.getAttribute?.("aria-label");
|
|
77
|
+
if (!ariaLabel) {
|
|
78
|
+
findings.push({
|
|
79
|
+
severity: "warning",
|
|
80
|
+
message: "Anchor element has no visible text or aria-label — bad for SEO link signals",
|
|
81
|
+
suggestion: "Add descriptive text content inside the <a> tag, or add an aria-label attribute.",
|
|
82
|
+
element: "<a>",
|
|
83
|
+
attribute: "href"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (tag === "main") {
|
|
89
|
+
hasMain = true;
|
|
90
|
+
}
|
|
91
|
+
const headingMatch = tag.match(/^h([1-6])$/);
|
|
92
|
+
if (headingMatch) {
|
|
93
|
+
const level = parseInt(headingMatch[1], 10);
|
|
94
|
+
if (level === 1) {
|
|
95
|
+
hasH1 = true;
|
|
96
|
+
h1Count++;
|
|
97
|
+
}
|
|
98
|
+
if (lastHeadingLevel > 0 && level > lastHeadingLevel + 1) {
|
|
99
|
+
findings.push({
|
|
100
|
+
severity: "warning",
|
|
101
|
+
message: `Heading level skipped: <h${lastHeadingLevel}> followed by <h${level}>`,
|
|
102
|
+
suggestion: `Use <h${lastHeadingLevel + 1}> instead of <h${level}> to maintain heading hierarchy. Search engines use heading structure to understand content organization.`,
|
|
103
|
+
element: `<h${level}>`
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
lastHeadingLevel = level;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
if (!isComp) {
|
|
110
|
+
if (!hasH1) {
|
|
111
|
+
findings.push({
|
|
112
|
+
severity: "warning",
|
|
113
|
+
message: "Page has no <h1> element — the primary heading is important for SEO",
|
|
114
|
+
suggestion: "Add an <h1> element with the main page title or topic. Each page should have exactly one <h1>.",
|
|
115
|
+
element: "<h1>"
|
|
116
|
+
});
|
|
117
|
+
} else if (h1Count > 1) {
|
|
118
|
+
findings.push({
|
|
119
|
+
severity: "warning",
|
|
120
|
+
message: `Page has ${h1Count} <h1> elements — should have exactly one`,
|
|
121
|
+
suggestion: "Keep only one <h1> for the primary page heading. Use <h2> or lower for secondary headings.",
|
|
122
|
+
element: "<h1>"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!hasMain) {
|
|
126
|
+
findings.push({
|
|
127
|
+
severity: "warning",
|
|
128
|
+
message: "Page has no <main> landmark — helps search engines identify primary content",
|
|
129
|
+
suggestion: "Wrap the primary page content in a <main> element. Each page should have one <main> landmark.",
|
|
130
|
+
element: "<main>"
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (hasLargeImage && !hasFetchPriorityHigh) {
|
|
134
|
+
const suppressLcp = isSuppressed(ctx, "no-lcp-image");
|
|
135
|
+
if (!suppressLcp) {
|
|
136
|
+
findings.push({
|
|
137
|
+
severity: "warning",
|
|
138
|
+
message: 'No image has fetchpriority="high" — the LCP image should be prioritized',
|
|
139
|
+
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. If this page has no LCP image (e.g. text-first hero), suppress with seo: { no-lcp-image: true } in <script type="application/jay-validations">. See agent-kit/designer/validation-guide.md',
|
|
140
|
+
element: "<img>",
|
|
141
|
+
attribute: "fetchpriority"
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const cssSources = [];
|
|
147
|
+
const styleBlocks = ctx.body.querySelectorAll?.("style") ?? [];
|
|
148
|
+
for (const styleEl of styleBlocks) {
|
|
149
|
+
const cssText = styleEl.textContent || "";
|
|
150
|
+
if (cssText) cssSources.push({ css: cssText, source: "<style>" });
|
|
151
|
+
}
|
|
152
|
+
const linkedFiles = ctx.body.querySelectorAll?.('link[rel="stylesheet"]') ?? [];
|
|
153
|
+
for (const link of linkedFiles) {
|
|
154
|
+
const href = link.getAttribute?.("href");
|
|
155
|
+
if (href && !href.startsWith("http")) {
|
|
156
|
+
try {
|
|
157
|
+
const dir = path.dirname(path.resolve(ctx.projectRoot, ctx.filePath));
|
|
158
|
+
const cssPath = path.resolve(dir, href);
|
|
159
|
+
const cssText = fs.readFileSync(cssPath, "utf-8");
|
|
160
|
+
cssSources.push({ css: cssText, source: href });
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const { css, source } of cssSources) {
|
|
166
|
+
const importRegex = /@import\s+(?:url\(\s*['"]?([^'")]+)['"]?\s*\)|['"]([^'"]+)['"])/g;
|
|
167
|
+
let importMatch;
|
|
168
|
+
while ((importMatch = importRegex.exec(css)) !== null) {
|
|
169
|
+
const url = importMatch[1] || importMatch[2];
|
|
170
|
+
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
171
|
+
if (!isSuppressed(ctx, "allow-css-import")) {
|
|
172
|
+
findings.push({
|
|
173
|
+
severity: "warning",
|
|
174
|
+
message: `CSS @import of external URL "${url}" creates a chained blocking request that delays page rendering`,
|
|
175
|
+
suggestion: 'Move this to a <link rel="stylesheet" href="..."> tag in the HTML <head> instead. This allows the browser preload scanner to discover both resources in parallel. If intentional, suppress with seo: { allow-css-import: true } in <script type="application/jay-validations">. See agent-kit/designer/validation-guide.md',
|
|
176
|
+
element: source === "<style>" ? "<style>" : `<link href="${source}">`
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (isComp) return findings;
|
|
183
|
+
const componentHeadTags = new Set(
|
|
184
|
+
ctx.headlessImports.flatMap((imp) => imp.providedHeadTags ?? [])
|
|
185
|
+
);
|
|
186
|
+
if (ctx.head) {
|
|
187
|
+
if (!ctx.head.title && !componentHeadTags.has("title")) {
|
|
188
|
+
findings.push({
|
|
189
|
+
severity: "warning",
|
|
190
|
+
message: "Page has no <title> element",
|
|
191
|
+
suggestion: "Add <title>Page Title</title> in <head>. The title appears in search results and browser tabs.",
|
|
192
|
+
element: "<title>"
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const hasDescription = ctx.head.meta.some((m) => m.name?.toLowerCase() === "description");
|
|
196
|
+
if (!hasDescription && !componentHeadTags.has("meta:description")) {
|
|
197
|
+
findings.push({
|
|
198
|
+
severity: "warning",
|
|
199
|
+
message: 'Page has no <meta name="description">',
|
|
200
|
+
suggestion: 'Add <meta name="description" content="..."> in <head>. Search engines use this for result snippets.',
|
|
201
|
+
element: "<meta>",
|
|
202
|
+
attribute: "name"
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const canonical = ctx.head.links.find((l) => l.rel === "canonical");
|
|
206
|
+
if (canonical) {
|
|
207
|
+
const hasBinding = canonical.href.some((p) => p.kind === "binding");
|
|
208
|
+
const hrefStr = canonical.href.map((p) => p.value).join("");
|
|
209
|
+
if (!hrefStr.startsWith("http://") && !hrefStr.startsWith("https://") && !hasBinding) {
|
|
210
|
+
findings.push({
|
|
211
|
+
severity: "warning",
|
|
212
|
+
message: "Canonical URL should be absolute",
|
|
213
|
+
suggestion: "Change the canonical href to an absolute URL (e.g., https://example.com/page). Relative canonicals may not be interpreted correctly by all search engines.",
|
|
214
|
+
element: "<link>",
|
|
215
|
+
attribute: "href"
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const robotsMeta = ctx.head.meta.find((m) => m.name?.toLowerCase() === "robots");
|
|
220
|
+
const robotsContent = robotsMeta?.content.map((p) => p.value).join("");
|
|
221
|
+
if (robotsContent && /noindex/i.test(robotsContent) && !isSuppressed(ctx, "allow-noindex")) {
|
|
222
|
+
findings.push({
|
|
223
|
+
severity: "warning",
|
|
224
|
+
message: 'Page has <meta name="robots" content="noindex"> — it will not appear in search results',
|
|
225
|
+
suggestion: 'Remove noindex from the robots meta tag if this page should be indexed. If intentional, suppress with seo: { allow-noindex: true } in <script type="application/jay-validations">. See agent-kit/designer/validation-guide.md',
|
|
226
|
+
element: "<meta>",
|
|
227
|
+
attribute: "content"
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const preconnectOrigins = new Set(
|
|
231
|
+
ctx.head.links.filter((l) => l.rel === "preconnect").map((l) => {
|
|
232
|
+
try {
|
|
233
|
+
return new URL(l.href.map((p) => p.value).join("")).origin;
|
|
234
|
+
} catch {
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
}).filter(Boolean)
|
|
238
|
+
);
|
|
239
|
+
const FONT_SERVICE_DOMAINS = ["fonts.googleapis.com", "use.typekit.net"];
|
|
240
|
+
for (const link of ctx.head.links) {
|
|
241
|
+
if (link.rel !== "stylesheet") continue;
|
|
242
|
+
const href = link.href.map((p) => p.value).join("");
|
|
243
|
+
if (!href.startsWith("http://") && !href.startsWith("https://")) continue;
|
|
244
|
+
let origin;
|
|
245
|
+
let hostname;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = new URL(href);
|
|
248
|
+
origin = parsed.origin;
|
|
249
|
+
hostname = parsed.hostname;
|
|
250
|
+
} catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!preconnectOrigins.has(origin)) {
|
|
254
|
+
findings.push({
|
|
255
|
+
severity: "warning",
|
|
256
|
+
message: `External stylesheet from ${hostname} without <link rel="preconnect"> — delays resource discovery`,
|
|
257
|
+
suggestion: `Add <link rel="preconnect" href="${origin}"> before the stylesheet in <head>. Preconnect establishes the connection early, reducing load time.`,
|
|
258
|
+
element: "<link>",
|
|
259
|
+
attribute: "href"
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (FONT_SERVICE_DOMAINS.some((d) => hostname === d || hostname.endsWith("." + d))) {
|
|
263
|
+
if (!href.includes("display=swap")) {
|
|
264
|
+
findings.push({
|
|
265
|
+
severity: "warning",
|
|
266
|
+
message: `Font stylesheet from ${hostname} missing display=swap — blocks text rendering`,
|
|
267
|
+
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",
|
|
268
|
+
element: "<link>",
|
|
269
|
+
attribute: "href"
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return findings;
|
|
276
|
+
};
|
|
277
|
+
export {
|
|
278
|
+
validate
|
|
279
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/seo-validator",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.3",
|
|
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",
|
|
@@ -11,24 +11,26 @@
|
|
|
11
11
|
],
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./dist/index.js",
|
|
14
|
+
"./tools": "./dist/tools.js",
|
|
14
15
|
"./plugin.yaml": "./plugin.yaml"
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "npm run clean && npm run build:server && npm run build:types && npm run validate",
|
|
18
19
|
"validate": "jay-stack-cli validate-plugin",
|
|
19
20
|
"build:server": "vite build --ssr",
|
|
20
|
-
"build:types": "tsup lib/index.ts --dts-only --format esm",
|
|
21
|
+
"build:types": "tsup lib/index.ts lib/tools.ts --dts-only --format esm",
|
|
21
22
|
"build:check-types": "tsc",
|
|
22
23
|
"clean": "rimraf dist",
|
|
23
24
|
"confirm": "npm run clean && npm run build && npm run test",
|
|
24
25
|
"test": "vitest run"
|
|
25
26
|
},
|
|
26
|
-
"
|
|
27
|
-
"@jay-framework/compiler-shared": "^0.24.
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@jay-framework/compiler-shared": "^0.24.3"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"@jay-framework/
|
|
31
|
-
"@jay-framework/
|
|
31
|
+
"@jay-framework/compiler-shared": "^0.24.3",
|
|
32
|
+
"@jay-framework/dev-environment": "^0.24.3",
|
|
33
|
+
"@jay-framework/jay-stack-cli": "^0.24.3",
|
|
32
34
|
"@types/node": "^22.15.21",
|
|
33
35
|
"node-html-parser": "^6.1.0",
|
|
34
36
|
"rimraf": "^5.0.5",
|