@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.
Files changed (2) hide show
  1. package/dist/index.js +98 -42
  2. 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
- let hasImage = false;
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
- hasImage = true;
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: "Image missing alt attribute — hurts SEO and accessibility",
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: "Image missing explicit dimensions — causes layout shift (CLS)",
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: "Image without loading attribute — consider lazy loading for performance",
53
- suggestion: 'Add loading="lazy" to defer off-screen images. Use loading="eager" only for above-the-fold images.',
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 (!hasH1) {
97
- findings.push({
98
- severity: "warning",
99
- message: "Page has no <h1> element — the primary heading is important for SEO",
100
- suggestion: "Add an <h1> element with the main page title or topic. Each page should have exactly one <h1>.",
101
- element: "<h1>"
102
- });
103
- } else if (h1Count > 1) {
104
- findings.push({
105
- severity: "warning",
106
- message: `Page has ${h1Count} <h1> elements — should have exactly one`,
107
- suggestion: "Keep only one <h1> for the primary page heading. Use <h2> or lower for secondary headings.",
108
- element: "<h1>"
109
- });
110
- }
111
- if (!hasMain) {
112
- findings.push({
113
- severity: "warning",
114
- message: "Page has no <main> landmark — helps search engines identify primary content",
115
- suggestion: "Wrap the primary page content in a <main> element. Each page should have one <main> landmark.",
116
- element: "<main>"
117
- });
118
- }
119
- if (hasImage && !hasFetchPriorityHigh) {
120
- findings.push({
121
- severity: "warning",
122
- message: 'No image has fetchpriority="high" — the LCP image should be prioritized',
123
- 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.',
124
- element: "<img>",
125
- attribute: "fetchpriority"
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.22.2",
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.22.2"
27
+ "@jay-framework/compiler-shared": "^0.23.1"
28
28
  },
29
29
  "devDependencies": {
30
- "@jay-framework/dev-environment": "^0.22.2",
31
- "@jay-framework/jay-stack-cli": "^0.22.2",
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",