@svelte-vitals/core 0.13.0 → 0.15.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/dist/index.d.ts +109 -3
- package/dist/index.js +773 -14
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -100,7 +100,7 @@ interface Runtime {
|
|
|
100
100
|
* these, so rules never need to know which mode produced them.
|
|
101
101
|
*/
|
|
102
102
|
interface HeadTag {
|
|
103
|
-
kind: 'title' | 'meta' | 'link' | 'jsonld';
|
|
103
|
+
kind: 'title' | 'meta' | 'link' | 'jsonld' | 'script';
|
|
104
104
|
/** <meta name="...">. */
|
|
105
105
|
name?: string;
|
|
106
106
|
/** <meta property="..."> (e.g. og:image). */
|
|
@@ -115,6 +115,16 @@ interface HeadTag {
|
|
|
115
115
|
hasCrossorigin?: boolean;
|
|
116
116
|
/** True when a <meta name="robots"> literal content contains `noindex`/`none`. Undefined when dynamic or absent. */
|
|
117
117
|
noindex?: boolean;
|
|
118
|
+
/** Literal `<script type="application/ld+json">` content, set only when the script is static. Undefined when dynamic. */
|
|
119
|
+
jsonld?: string;
|
|
120
|
+
/** Literal visible text of a static <title> or <meta name="description"> content, set only when static. Undefined when dynamic. */
|
|
121
|
+
text?: string;
|
|
122
|
+
/** Literal `hreflang` of a `<link rel="alternate">` (e.g. 'en', 'en-US', 'x-default'). Undefined when dynamic/absent. */
|
|
123
|
+
hreflang?: string;
|
|
124
|
+
/** Literal href (link) / src (script) URL when static — used for third-party origin analysis (PERF008). */
|
|
125
|
+
href?: string;
|
|
126
|
+
/** True for a render-blocking `<script src>` in <head> (no defer/async/module) (PERF007). */
|
|
127
|
+
blocking?: boolean;
|
|
118
128
|
/** Where this tag was set relative to the route. Never 'none' (absence = no tag). */
|
|
119
129
|
presence: Exclude<Presence, 'none'>;
|
|
120
130
|
/** Whether the tag's value is static/dynamic/absent (design §4). */
|
|
@@ -152,6 +162,12 @@ interface ImageInfo {
|
|
|
152
162
|
hasWidth: boolean;
|
|
153
163
|
hasHeight: boolean;
|
|
154
164
|
hasLoading: boolean;
|
|
165
|
+
/** True when the <img> has an `alt` attribute at all (incl. empty `alt=""` decorative; SEO025). */
|
|
166
|
+
hasAlt: boolean;
|
|
167
|
+
/** True when the <img> has a literal `loading="lazy"` (PERF005). Dynamic/spread → false. */
|
|
168
|
+
lazy: boolean;
|
|
169
|
+
/** True when the <img> has a `srcset` attribute (PERF006). */
|
|
170
|
+
hasSrcset: boolean;
|
|
155
171
|
/** 1-based source line, or 0 if unknown. */
|
|
156
172
|
line: number;
|
|
157
173
|
/** Source file the <img> came from. */
|
|
@@ -163,6 +179,25 @@ interface ResolvedImages {
|
|
|
163
179
|
images: ImageInfo[];
|
|
164
180
|
}
|
|
165
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A normalized page-body heading occurrence — the mode-independent boundary for
|
|
184
|
+
* the heading-hierarchy rule (mirrors images.ts). Both providers collect these
|
|
185
|
+
* so SEO027 never needs to know which mode produced them.
|
|
186
|
+
*/
|
|
187
|
+
interface HeadingInfo {
|
|
188
|
+
/** Heading level 1–6 (the `n` in <hn>). */
|
|
189
|
+
level: number;
|
|
190
|
+
/** 1-based source line, or 0 if unknown (rendered mode does not track lines). */
|
|
191
|
+
line: number;
|
|
192
|
+
/** Source file the heading came from. */
|
|
193
|
+
file: string;
|
|
194
|
+
}
|
|
195
|
+
/** Resolved page-body headings for a single route (page + layout chain). */
|
|
196
|
+
interface ResolvedHeadings {
|
|
197
|
+
route: string;
|
|
198
|
+
headings: HeadingInfo[];
|
|
199
|
+
}
|
|
200
|
+
|
|
166
201
|
/**
|
|
167
202
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
168
203
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -178,6 +213,8 @@ interface RuleContext {
|
|
|
178
213
|
heads: ResolvedHead[];
|
|
179
214
|
/** Per-route <img> elements for Performance rules (absent in modes that don't collect them). */
|
|
180
215
|
images?: ResolvedImages[];
|
|
216
|
+
/** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
|
|
217
|
+
headings?: ResolvedHeadings[];
|
|
181
218
|
project: Project;
|
|
182
219
|
config: Config;
|
|
183
220
|
}
|
|
@@ -238,10 +275,36 @@ declare const seo009HtmlLang: Rule;
|
|
|
238
275
|
|
|
239
276
|
declare const perf001ImageDimensions: Rule;
|
|
240
277
|
declare const perf002ImageLoading: Rule;
|
|
278
|
+
declare const perf006ResponsiveImage: Rule;
|
|
241
279
|
|
|
242
280
|
declare const perf003PreloadAs: Rule;
|
|
243
281
|
declare const perf004FontPreloadCrossorigin: Rule;
|
|
244
282
|
|
|
283
|
+
/**
|
|
284
|
+
* PERF005 — LCP image not lazy-loaded. Lazy-loading the largest contentful paint
|
|
285
|
+
* image delays it. Static analysis approximates the LCP as the first <img> in
|
|
286
|
+
* document order for the route; if that image is loading="lazy", flag it.
|
|
287
|
+
* CLI/static only (the rendered provider does not collect <img>).
|
|
288
|
+
*/
|
|
289
|
+
declare const perf005LcpImage: Rule;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* PERF007 — Render-blocking <script> in <head>. A <script src> without
|
|
293
|
+
* defer/async/type=module blocks the parser. SvelteKit's own scripts are
|
|
294
|
+
* module/deferred, so this catches hand-added blocking scripts — in app.html
|
|
295
|
+
* (rendered mode) or in <svelte:head> (static mode). A head with no <script>
|
|
296
|
+
* emits nothing (no signal), like the image rules.
|
|
297
|
+
*/
|
|
298
|
+
declare const perf007RenderBlockingScript: Rule;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* PERF008 — Preconnect for third-party origins. A resource from a well-known
|
|
302
|
+
* third-party origin (e.g. Google Fonts) without a preconnect/dns-prefetch pays a
|
|
303
|
+
* connection-setup round-trip. Opt-in by construction: only origins in the
|
|
304
|
+
* allowlist are checked; routes referencing none emit nothing.
|
|
305
|
+
*/
|
|
306
|
+
declare const perf008Preconnect: Rule;
|
|
307
|
+
|
|
245
308
|
declare const seo010Indexability: Rule;
|
|
246
309
|
declare const seo011TwitterCard: Rule;
|
|
247
310
|
declare const seo012OgDescription: Rule;
|
|
@@ -249,6 +312,47 @@ declare const seo013OgUrl: Rule;
|
|
|
249
312
|
declare const seo014Viewport: Rule;
|
|
250
313
|
declare const seo015SitemapInRobots: Rule;
|
|
251
314
|
|
|
315
|
+
declare const seo016JsonLdValidity: Rule;
|
|
316
|
+
declare const seo017DeprecatedType: Rule;
|
|
317
|
+
declare const seo018RelativeUrl: Rule;
|
|
318
|
+
declare const seo019DateFormat: Rule;
|
|
319
|
+
declare const seo020Placeholder: Rule;
|
|
320
|
+
declare const seo021RequiredProps: Rule;
|
|
321
|
+
|
|
322
|
+
declare const seo022TitleLength: Rule;
|
|
323
|
+
declare const seo023DescriptionLength: Rule;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* SEO024 — Character encoding. The charset meta lives in `src/app.html`, so it is
|
|
327
|
+
* only visible to rendered analysis (`appliesTo: rendered`), exactly like SEO014
|
|
328
|
+
* (viewport). Static route analysis emits nothing instead of false-flagging it.
|
|
329
|
+
*/
|
|
330
|
+
declare const seo024Charset: Rule;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* SEO025 — Image alt text. Reuses the <img> collection (CLI/static only; rendered
|
|
334
|
+
* mode does not collect images, so the rule no-ops there, like PERF001/002).
|
|
335
|
+
* Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
|
|
336
|
+
* passes; a spread `{...rest}` may supply alt, so it is not flagged.
|
|
337
|
+
*/
|
|
338
|
+
declare const seo025ImageAlt: Rule;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* SEO026 — hreflang / x-default validity. Opt-in: a route with no
|
|
342
|
+
* `<link rel="alternate" hreflang>` emits nothing (monolingual sites are never
|
|
343
|
+
* flagged). When alternates exist, every code must be well-formed and a set of
|
|
344
|
+
* two or more must declare an x-default. Works in both modes.
|
|
345
|
+
*/
|
|
346
|
+
declare const seo026Hreflang: Rule;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* SEO027 — Heading hierarchy (single H1). Reads the per-route page-body headings
|
|
350
|
+
* channel (collected by both providers). Zero <h1> (no primary heading) and two
|
|
351
|
+
* or more (diluted topic) are both flagged; exactly one passes. A route whose
|
|
352
|
+
* headings were not collected (channel unset) emits nothing.
|
|
353
|
+
*/
|
|
354
|
+
declare const seo027Heading: Rule;
|
|
355
|
+
|
|
252
356
|
declare const allRules: Rule[];
|
|
253
357
|
|
|
254
358
|
interface RuleInfo {
|
|
@@ -291,6 +395,8 @@ interface ImageRuleOptions {
|
|
|
291
395
|
id: string;
|
|
292
396
|
title: string;
|
|
293
397
|
severity: Severity;
|
|
398
|
+
/** Vitals category (default 'performance'); SEO025 (alt text) reports under 'seo'. */
|
|
399
|
+
category?: Category;
|
|
294
400
|
/** Noun phrase for messages, e.g. '<img> width/height'. */
|
|
295
401
|
label: string;
|
|
296
402
|
recommendation: string;
|
|
@@ -299,7 +405,7 @@ interface ImageRuleOptions {
|
|
|
299
405
|
/** Returns true when the image satisfies the rule (passes). */
|
|
300
406
|
ok: (img: ImageInfo) => boolean;
|
|
301
407
|
}
|
|
302
|
-
/** Build a route-scoped
|
|
408
|
+
/** Build a route-scoped <img> rule that checks each image against `ok` (issue #10). */
|
|
303
409
|
declare function imageRule(opts: ImageRuleOptions): Rule;
|
|
304
410
|
|
|
305
411
|
interface LinkRuleOptions {
|
|
@@ -455,4 +561,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
455
561
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
456
562
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
457
563
|
|
|
458
|
-
export { BAND_COLOR, type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, summarize };
|
|
564
|
+
export { BAND_COLOR, type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type HeadingInfo, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedHeadings, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, seo016JsonLdValidity, seo017DeprecatedType, seo018RelativeUrl, seo019DateFormat, seo020Placeholder, seo021RequiredProps, seo022TitleLength, seo023DescriptionLength, seo024Charset, seo025ImageAlt, seo026Hreflang, seo027Heading, summarize };
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ function detect(head, match) {
|
|
|
94
94
|
return tag ? { presence: tag.presence, value: tag.value } : { presence: "none", value: "absent" };
|
|
95
95
|
}
|
|
96
96
|
function headTagRule(opts) {
|
|
97
|
-
const
|
|
97
|
+
const docsUrl6 = docsUrlFor(opts.id);
|
|
98
98
|
return {
|
|
99
99
|
id: opts.id,
|
|
100
100
|
title: opts.title,
|
|
@@ -117,7 +117,7 @@ function headTagRule(opts) {
|
|
|
117
117
|
location: head.file,
|
|
118
118
|
message,
|
|
119
119
|
recommendation: opts.recommendation,
|
|
120
|
-
docsUrl,
|
|
120
|
+
docsUrl: docsUrl6,
|
|
121
121
|
// Copy per finding: opts.fix is a rule-level template shared across all
|
|
122
122
|
// results this rule emits; a fresh object keeps findings independent.
|
|
123
123
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
@@ -296,11 +296,12 @@ var seo009HtmlLang = {
|
|
|
296
296
|
|
|
297
297
|
// src/rules/perf/image-rule.ts
|
|
298
298
|
function imageRule(opts) {
|
|
299
|
-
const
|
|
299
|
+
const docsUrl6 = docsUrlFor(opts.id);
|
|
300
|
+
const category = opts.category ?? "performance";
|
|
300
301
|
return {
|
|
301
302
|
id: opts.id,
|
|
302
303
|
title: opts.title,
|
|
303
|
-
category
|
|
304
|
+
category,
|
|
304
305
|
severity: opts.severity,
|
|
305
306
|
scope: "route",
|
|
306
307
|
rationale: opts.rationale,
|
|
@@ -313,20 +314,20 @@ function imageRule(opts) {
|
|
|
313
314
|
if (bad.length === 0) {
|
|
314
315
|
out.push({
|
|
315
316
|
id: opts.id,
|
|
316
|
-
category
|
|
317
|
+
category,
|
|
317
318
|
severity: opts.severity,
|
|
318
319
|
detection: { presence: "own", value: "static" },
|
|
319
320
|
route: route.route,
|
|
320
321
|
message: opts.label,
|
|
321
322
|
recommendation: opts.recommendation,
|
|
322
|
-
docsUrl
|
|
323
|
+
docsUrl: docsUrl6
|
|
323
324
|
});
|
|
324
325
|
continue;
|
|
325
326
|
}
|
|
326
327
|
for (const img of bad) {
|
|
327
328
|
out.push({
|
|
328
329
|
id: opts.id,
|
|
329
|
-
category
|
|
330
|
+
category,
|
|
330
331
|
severity: opts.severity,
|
|
331
332
|
detection: { presence: "none", value: "absent" },
|
|
332
333
|
route: route.route,
|
|
@@ -334,7 +335,7 @@ function imageRule(opts) {
|
|
|
334
335
|
...img.line > 0 ? { line: img.line } : {},
|
|
335
336
|
message: `Missing ${opts.label}`,
|
|
336
337
|
recommendation: opts.recommendation,
|
|
337
|
-
docsUrl,
|
|
338
|
+
docsUrl: docsUrl6,
|
|
338
339
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
339
340
|
});
|
|
340
341
|
}
|
|
@@ -373,10 +374,24 @@ var perf002ImageLoading = imageRule({
|
|
|
373
374
|
},
|
|
374
375
|
ok: (img) => img.hasLoading
|
|
375
376
|
});
|
|
377
|
+
var perf006ResponsiveImage = imageRule({
|
|
378
|
+
id: "PERF006",
|
|
379
|
+
title: "Responsive image",
|
|
380
|
+
severity: "info",
|
|
381
|
+
label: "<img> srcset",
|
|
382
|
+
recommendation: "Provide a srcset (and sizes) so the browser can pick a right-sized image per viewport.",
|
|
383
|
+
rationale: "An <img> without srcset ships one fixed-size asset to every device, wasting bytes on small screens. Static analysis cannot measure intended display size, so this is advisory.",
|
|
384
|
+
fix: {
|
|
385
|
+
description: "Add a srcset (and sizes) to the <img> for responsive delivery.",
|
|
386
|
+
snippet: '<img src="/hero.jpg" srcset="/hero-800.jpg 800w, /hero-1600.jpg 1600w" sizes="100vw" width="1600" height="900" alt="\u2026" />',
|
|
387
|
+
lang: "svelte"
|
|
388
|
+
},
|
|
389
|
+
ok: (img) => img.hasSrcset
|
|
390
|
+
});
|
|
376
391
|
|
|
377
392
|
// src/rules/perf/link-rule.ts
|
|
378
393
|
function linkRule(opts) {
|
|
379
|
-
const
|
|
394
|
+
const docsUrl6 = docsUrlFor(opts.id);
|
|
380
395
|
return {
|
|
381
396
|
id: opts.id,
|
|
382
397
|
title: opts.title,
|
|
@@ -400,7 +415,7 @@ function linkRule(opts) {
|
|
|
400
415
|
route: head.route,
|
|
401
416
|
message: opts.label,
|
|
402
417
|
recommendation: opts.recommendation,
|
|
403
|
-
docsUrl
|
|
418
|
+
docsUrl: docsUrl6
|
|
404
419
|
});
|
|
405
420
|
continue;
|
|
406
421
|
}
|
|
@@ -417,7 +432,7 @@ function linkRule(opts) {
|
|
|
417
432
|
location: tag.file ?? head.file,
|
|
418
433
|
message: `Missing ${opts.label}`,
|
|
419
434
|
recommendation: opts.recommendation,
|
|
420
|
-
docsUrl,
|
|
435
|
+
docsUrl: docsUrl6,
|
|
421
436
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
422
437
|
});
|
|
423
438
|
}
|
|
@@ -459,6 +474,175 @@ var perf004FontPreloadCrossorigin = linkRule({
|
|
|
459
474
|
ok: (t) => t.hasCrossorigin === true
|
|
460
475
|
});
|
|
461
476
|
|
|
477
|
+
// src/rules/perf/perf005-lcp-image.ts
|
|
478
|
+
var docsUrl = docsUrlFor("PERF005");
|
|
479
|
+
var recommendation = 'Remove loading="lazy" from the LCP/first image and consider fetchpriority="high" so it loads as early as possible.';
|
|
480
|
+
var perf005LcpImage = {
|
|
481
|
+
id: "PERF005",
|
|
482
|
+
title: "LCP image eager loading",
|
|
483
|
+
category: "performance",
|
|
484
|
+
severity: "warning",
|
|
485
|
+
scope: "route",
|
|
486
|
+
rationale: "Lazy-loading the LCP (first/above-the-fold) image delays the largest paint and hurts Core Web Vitals. The first image is the best static proxy for the LCP candidate.",
|
|
487
|
+
fix: {
|
|
488
|
+
description: 'Remove loading="lazy" from the first/LCP image; consider fetchpriority="high".',
|
|
489
|
+
snippet: '<img src="/hero.jpg" width="1200" height="630" fetchpriority="high" alt="\u2026" />',
|
|
490
|
+
lang: "svelte"
|
|
491
|
+
},
|
|
492
|
+
async check(ctx) {
|
|
493
|
+
const out = [];
|
|
494
|
+
for (const route of ctx.images ?? []) {
|
|
495
|
+
const first = route.images[0];
|
|
496
|
+
if (!first) continue;
|
|
497
|
+
out.push(
|
|
498
|
+
first.lazy ? {
|
|
499
|
+
id: "PERF005",
|
|
500
|
+
category: "performance",
|
|
501
|
+
severity: "warning",
|
|
502
|
+
detection: { presence: "none", value: "absent" },
|
|
503
|
+
route: route.route,
|
|
504
|
+
location: first.file,
|
|
505
|
+
...first.line > 0 ? { line: first.line } : {},
|
|
506
|
+
message: 'First image (likely LCP) is loading="lazy"',
|
|
507
|
+
recommendation,
|
|
508
|
+
docsUrl,
|
|
509
|
+
fix: { ...perf005LcpImage.fix }
|
|
510
|
+
} : {
|
|
511
|
+
id: "PERF005",
|
|
512
|
+
category: "performance",
|
|
513
|
+
severity: "warning",
|
|
514
|
+
detection: { presence: "own", value: "static" },
|
|
515
|
+
route: route.route,
|
|
516
|
+
message: "LCP image eager loading",
|
|
517
|
+
recommendation,
|
|
518
|
+
docsUrl
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// src/rules/perf/perf007-render-blocking.ts
|
|
527
|
+
var docsUrl2 = docsUrlFor("PERF007");
|
|
528
|
+
var recommendation2 = 'Add defer (or type="module"), or async, to the <script> so it does not block HTML parsing.';
|
|
529
|
+
var perf007RenderBlockingScript = {
|
|
530
|
+
id: "PERF007",
|
|
531
|
+
title: "Render-blocking script",
|
|
532
|
+
category: "performance",
|
|
533
|
+
severity: "warning",
|
|
534
|
+
scope: "route",
|
|
535
|
+
rationale: 'A synchronous <script src> in <head> blocks HTML parsing until it downloads and runs, delaying first paint. defer, async, or type="module" avoids the block.',
|
|
536
|
+
fix: {
|
|
537
|
+
description: 'Add defer (or type="module") / async to the head <script>.',
|
|
538
|
+
snippet: '<script src="/analytics.js" defer></script>',
|
|
539
|
+
lang: "html"
|
|
540
|
+
},
|
|
541
|
+
async check(ctx) {
|
|
542
|
+
const out = [];
|
|
543
|
+
for (const head of ctx.heads) {
|
|
544
|
+
const scripts = head.tags.filter((t) => t.kind === "script");
|
|
545
|
+
if (scripts.length === 0) continue;
|
|
546
|
+
const blocking = scripts.filter((t) => t.blocking);
|
|
547
|
+
if (blocking.length > 0) {
|
|
548
|
+
for (const tag of blocking) {
|
|
549
|
+
out.push({
|
|
550
|
+
id: "PERF007",
|
|
551
|
+
category: "performance",
|
|
552
|
+
severity: "warning",
|
|
553
|
+
detection: { presence: "none", value: "absent" },
|
|
554
|
+
route: head.route,
|
|
555
|
+
// location is a source path (the URL stays in the message), per the rule-engine convention.
|
|
556
|
+
location: tag.file ?? head.file,
|
|
557
|
+
message: `Render-blocking <script>${tag.href ? ` (${tag.href})` : ""} in <head>`,
|
|
558
|
+
recommendation: recommendation2,
|
|
559
|
+
docsUrl: docsUrl2,
|
|
560
|
+
fix: { ...perf007RenderBlockingScript.fix }
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
out.push({
|
|
565
|
+
id: "PERF007",
|
|
566
|
+
category: "performance",
|
|
567
|
+
severity: "warning",
|
|
568
|
+
detection: { presence: "own", value: "static" },
|
|
569
|
+
route: head.route,
|
|
570
|
+
message: "No render-blocking scripts",
|
|
571
|
+
recommendation: recommendation2,
|
|
572
|
+
docsUrl: docsUrl2
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// src/rules/perf/perf008-preconnect.ts
|
|
581
|
+
var docsUrl3 = docsUrlFor("PERF008");
|
|
582
|
+
var recommendation3 = 'Add <link rel="preconnect"> (or dns-prefetch) for the third-party origin so the connection is set up early.';
|
|
583
|
+
var THIRD_PARTY_ORIGINS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
|
|
584
|
+
function hostOf(href) {
|
|
585
|
+
const m = /^(?:https?:)?\/\/([^/?#]+)/i.exec(href);
|
|
586
|
+
return m ? m[1].toLowerCase() : void 0;
|
|
587
|
+
}
|
|
588
|
+
var perf008Preconnect = {
|
|
589
|
+
id: "PERF008",
|
|
590
|
+
title: "Preconnect third-party origin",
|
|
591
|
+
category: "performance",
|
|
592
|
+
severity: "info",
|
|
593
|
+
scope: "route",
|
|
594
|
+
rationale: "Connecting to a third-party origin (DNS + TCP + TLS) is costly; a preconnect/dns-prefetch hint starts it early so the resource arrives sooner.",
|
|
595
|
+
fix: {
|
|
596
|
+
description: "Add a preconnect hint for the third-party origin.",
|
|
597
|
+
snippet: '<link rel="preconnect" href="https://fonts.googleapis.com" />',
|
|
598
|
+
lang: "html"
|
|
599
|
+
},
|
|
600
|
+
async check(ctx) {
|
|
601
|
+
const out = [];
|
|
602
|
+
for (const head of ctx.heads) {
|
|
603
|
+
const referenced = /* @__PURE__ */ new Map();
|
|
604
|
+
const covered = /* @__PURE__ */ new Set();
|
|
605
|
+
for (const tag of head.tags) {
|
|
606
|
+
if (tag.kind !== "link" && tag.kind !== "script" || typeof tag.href !== "string") continue;
|
|
607
|
+
const host = hostOf(tag.href);
|
|
608
|
+
if (!host || !THIRD_PARTY_ORIGINS.has(host)) continue;
|
|
609
|
+
if (tag.kind === "link" && (tag.rel === "preconnect" || tag.rel === "dns-prefetch")) covered.add(host);
|
|
610
|
+
else if (!referenced.has(host)) referenced.set(host, tag.file);
|
|
611
|
+
}
|
|
612
|
+
if (referenced.size === 0) continue;
|
|
613
|
+
const missing = [...referenced].filter(([host]) => !covered.has(host));
|
|
614
|
+
if (missing.length === 0) {
|
|
615
|
+
out.push({
|
|
616
|
+
id: "PERF008",
|
|
617
|
+
category: "performance",
|
|
618
|
+
severity: "info",
|
|
619
|
+
detection: { presence: "own", value: "static" },
|
|
620
|
+
route: head.route,
|
|
621
|
+
message: "Third-party origins are preconnected",
|
|
622
|
+
recommendation: recommendation3,
|
|
623
|
+
docsUrl: docsUrl3
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
for (const [host, file] of missing) {
|
|
628
|
+
out.push({
|
|
629
|
+
id: "PERF008",
|
|
630
|
+
category: "performance",
|
|
631
|
+
severity: "info",
|
|
632
|
+
detection: { presence: "none", value: "absent" },
|
|
633
|
+
route: head.route,
|
|
634
|
+
location: file ?? head.file,
|
|
635
|
+
message: `Third-party origin ${host} used without a preconnect`,
|
|
636
|
+
recommendation: recommendation3,
|
|
637
|
+
docsUrl: docsUrl3,
|
|
638
|
+
fix: { ...perf008Preconnect.fix }
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
462
646
|
// src/rules/seo/seo010-015.ts
|
|
463
647
|
var SEO010_FIX = {
|
|
464
648
|
description: 'If this route should be indexed, drop noindex from its <meta name="robots">.',
|
|
@@ -474,7 +658,7 @@ var seo010Indexability = {
|
|
|
474
658
|
rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
|
|
475
659
|
fix: SEO010_FIX,
|
|
476
660
|
async check(ctx) {
|
|
477
|
-
const
|
|
661
|
+
const docsUrl6 = docsUrlFor("SEO010");
|
|
478
662
|
const out = [];
|
|
479
663
|
for (const head of ctx.heads) {
|
|
480
664
|
const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
|
|
@@ -489,7 +673,7 @@ var seo010Indexability = {
|
|
|
489
673
|
location: head.file,
|
|
490
674
|
message: "Route is noindex \u2014 verify this is intentional",
|
|
491
675
|
recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
|
|
492
|
-
docsUrl,
|
|
676
|
+
docsUrl: docsUrl6,
|
|
493
677
|
fix: { ...SEO010_FIX }
|
|
494
678
|
});
|
|
495
679
|
}
|
|
@@ -587,6 +771,549 @@ var seo015SitemapInRobots = {
|
|
|
587
771
|
}
|
|
588
772
|
};
|
|
589
773
|
|
|
774
|
+
// src/rules/seo/jsonld-engine.ts
|
|
775
|
+
function parseJsonLd(raw) {
|
|
776
|
+
let data;
|
|
777
|
+
try {
|
|
778
|
+
data = JSON.parse(raw);
|
|
779
|
+
} catch {
|
|
780
|
+
return { ok: false, nodes: [] };
|
|
781
|
+
}
|
|
782
|
+
const nodes = [];
|
|
783
|
+
const visit = (v) => {
|
|
784
|
+
if (Array.isArray(v)) {
|
|
785
|
+
v.forEach(visit);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (v && typeof v === "object") {
|
|
789
|
+
const o = v;
|
|
790
|
+
nodes.push(o);
|
|
791
|
+
if (Array.isArray(o["@graph"])) o["@graph"].forEach(visit);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
visit(data);
|
|
795
|
+
return { ok: true, nodes };
|
|
796
|
+
}
|
|
797
|
+
function typeOf(node) {
|
|
798
|
+
const t = node["@type"];
|
|
799
|
+
if (typeof t === "string") return [t];
|
|
800
|
+
if (Array.isArray(t)) return t.filter((x) => typeof x === "string");
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
function collectValues(nodes, keys) {
|
|
804
|
+
const out = [];
|
|
805
|
+
const walk = (v) => {
|
|
806
|
+
if (Array.isArray(v)) {
|
|
807
|
+
v.forEach(walk);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (v && typeof v === "object") {
|
|
811
|
+
for (const [k, val] of Object.entries(v)) {
|
|
812
|
+
if (keys.has(k) && typeof val === "string") out.push(val);
|
|
813
|
+
else if (keys.has(k) && Array.isArray(val)) {
|
|
814
|
+
for (const e of val) if (typeof e === "string") out.push(e);
|
|
815
|
+
}
|
|
816
|
+
walk(val);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
nodes.forEach(walk);
|
|
821
|
+
return out;
|
|
822
|
+
}
|
|
823
|
+
function nodeStringValues(node) {
|
|
824
|
+
const out = [];
|
|
825
|
+
const walk = (v) => {
|
|
826
|
+
if (typeof v === "string") {
|
|
827
|
+
out.push(v);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (Array.isArray(v)) {
|
|
831
|
+
v.forEach(walk);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (v && typeof v === "object") Object.values(v).forEach(walk);
|
|
835
|
+
};
|
|
836
|
+
walk(node);
|
|
837
|
+
return out;
|
|
838
|
+
}
|
|
839
|
+
function isAbsoluteUrl(s) {
|
|
840
|
+
const str = s.trim();
|
|
841
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(str) || str.startsWith("//");
|
|
842
|
+
}
|
|
843
|
+
function isIso8601(s) {
|
|
844
|
+
const str = s.trim();
|
|
845
|
+
if (!/^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/.test(str)) return false;
|
|
846
|
+
const y = Number(str.slice(0, 4));
|
|
847
|
+
const hasMonth = str.length >= 7;
|
|
848
|
+
const hasDay = str.length >= 10;
|
|
849
|
+
const m = hasMonth ? Number(str.slice(5, 7)) : 1;
|
|
850
|
+
if (m < 1 || m > 12) return false;
|
|
851
|
+
if (hasDay) {
|
|
852
|
+
const d = Number(str.slice(8, 10));
|
|
853
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
854
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return false;
|
|
855
|
+
}
|
|
856
|
+
const tm = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(str);
|
|
857
|
+
if (tm) {
|
|
858
|
+
const [hh, mm, ss] = [Number(tm[1]), Number(tm[2]), tm[3] !== void 0 ? Number(tm[3]) : 0];
|
|
859
|
+
if (hh > 23 || mm > 59 || ss > 59) return false;
|
|
860
|
+
}
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
var PLACEHOLDER_RES = [
|
|
864
|
+
/lorem ipsum/i,
|
|
865
|
+
/your company/i,
|
|
866
|
+
/your-?domain/i,
|
|
867
|
+
/example company/i,
|
|
868
|
+
/yourcompany/i,
|
|
869
|
+
/your name here/i
|
|
870
|
+
];
|
|
871
|
+
var PLACEHOLDERS = PLACEHOLDER_RES.map((r) => r.source);
|
|
872
|
+
function hasPlaceholder(s) {
|
|
873
|
+
return PLACEHOLDER_RES.some((re) => re.test(s));
|
|
874
|
+
}
|
|
875
|
+
var URL_KEYS = /* @__PURE__ */ new Set(["url", "image", "logo", "sameAs", "contentUrl", "thumbnailUrl"]);
|
|
876
|
+
var DATE_KEYS = /* @__PURE__ */ new Set([
|
|
877
|
+
"datePublished",
|
|
878
|
+
"dateModified",
|
|
879
|
+
"dateCreated",
|
|
880
|
+
"startDate",
|
|
881
|
+
"endDate",
|
|
882
|
+
"uploadDate",
|
|
883
|
+
"validFrom",
|
|
884
|
+
"expires"
|
|
885
|
+
]);
|
|
886
|
+
var DEPRECATED_TYPES = /* @__PURE__ */ new Set(["HowTo", "FAQPage", "ClaimReview"]);
|
|
887
|
+
function hasNonEmpty(node, key) {
|
|
888
|
+
if (!(key in node)) return false;
|
|
889
|
+
const v = node[key];
|
|
890
|
+
if (v === null || v === void 0) return false;
|
|
891
|
+
if (typeof v === "string") return v.trim().length > 0;
|
|
892
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
var REQUIRED_PROPS = {
|
|
896
|
+
Article: ["headline"],
|
|
897
|
+
BlogPosting: ["headline"],
|
|
898
|
+
NewsArticle: ["headline"],
|
|
899
|
+
Product: ["name", "offers"],
|
|
900
|
+
BreadcrumbList: ["itemListElement"],
|
|
901
|
+
Organization: ["name", "url"],
|
|
902
|
+
WebSite: ["name", "url"],
|
|
903
|
+
Event: ["name", "startDate", "location"],
|
|
904
|
+
Recipe: ["name", "image", "recipeIngredient", "recipeInstructions"],
|
|
905
|
+
Person: ["name"],
|
|
906
|
+
VideoObject: ["name", "description", "thumbnailUrl", "uploadDate"],
|
|
907
|
+
LocalBusiness: ["name", "address"]
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// src/rules/seo/detection.ts
|
|
911
|
+
var PENALIZED = { presence: "none", value: "absent" };
|
|
912
|
+
var PASS = { presence: "own", value: "static" };
|
|
913
|
+
|
|
914
|
+
// src/rules/seo/seo016-021.ts
|
|
915
|
+
function jsonldTags(head) {
|
|
916
|
+
return head.tags.filter((t) => t.kind === "jsonld" && typeof t.jsonld === "string");
|
|
917
|
+
}
|
|
918
|
+
var seo016JsonLdValidity = {
|
|
919
|
+
id: "SEO016",
|
|
920
|
+
title: "JSON-LD validity",
|
|
921
|
+
category: "seo",
|
|
922
|
+
severity: "warning",
|
|
923
|
+
scope: "route",
|
|
924
|
+
rationale: "Invalid JSON-LD \u2014 unparseable, or missing @context/@type \u2014 is silently ignored by search engines, so the structured data does nothing.",
|
|
925
|
+
fix: {
|
|
926
|
+
description: "Make the JSON-LD valid: parseable JSON with both @context (schema.org) and @type.",
|
|
927
|
+
snippet: '<svelte:head>\n <script type="application/ld+json">\n {"@context":"https://schema.org","@type":"WebPage","name":"\u2026"}\n </script>\n</svelte:head>',
|
|
928
|
+
lang: "svelte"
|
|
929
|
+
},
|
|
930
|
+
async check(ctx) {
|
|
931
|
+
const docsUrl6 = docsUrlFor("SEO016");
|
|
932
|
+
const out = [];
|
|
933
|
+
for (const head of ctx.heads) {
|
|
934
|
+
for (const tag of jsonldTags(head)) {
|
|
935
|
+
const parsed = parseJsonLd(tag.jsonld);
|
|
936
|
+
let problem;
|
|
937
|
+
if (!parsed.ok) problem = "JSON-LD is not valid JSON";
|
|
938
|
+
else if (!parsed.nodes.some((n) => "@context" in n)) problem = "JSON-LD is missing @context";
|
|
939
|
+
else if (!parsed.nodes.some((n) => typeOf(n).length > 0)) problem = "JSON-LD is missing @type";
|
|
940
|
+
out.push(
|
|
941
|
+
problem ? {
|
|
942
|
+
id: "SEO016",
|
|
943
|
+
category: "seo",
|
|
944
|
+
severity: "warning",
|
|
945
|
+
detection: PENALIZED,
|
|
946
|
+
route: head.route,
|
|
947
|
+
location: head.file,
|
|
948
|
+
message: problem,
|
|
949
|
+
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
950
|
+
docsUrl: docsUrl6,
|
|
951
|
+
fix: { ...seo016JsonLdValidity.fix }
|
|
952
|
+
} : {
|
|
953
|
+
id: "SEO016",
|
|
954
|
+
category: "seo",
|
|
955
|
+
severity: "warning",
|
|
956
|
+
detection: PASS,
|
|
957
|
+
route: head.route,
|
|
958
|
+
message: "JSON-LD validity",
|
|
959
|
+
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
960
|
+
docsUrl: docsUrl6
|
|
961
|
+
}
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
function jsonldRule(opts) {
|
|
969
|
+
const docsUrl6 = docsUrlFor(opts.id);
|
|
970
|
+
return {
|
|
971
|
+
id: opts.id,
|
|
972
|
+
title: opts.title,
|
|
973
|
+
category: "seo",
|
|
974
|
+
severity: opts.severity,
|
|
975
|
+
scope: "route",
|
|
976
|
+
rationale: opts.rationale,
|
|
977
|
+
...opts.fix ? { fix: opts.fix } : {},
|
|
978
|
+
async check(ctx) {
|
|
979
|
+
const out = [];
|
|
980
|
+
for (const head of ctx.heads) {
|
|
981
|
+
for (const tag of jsonldTags(head)) {
|
|
982
|
+
const parsed = parseJsonLd(tag.jsonld);
|
|
983
|
+
if (!parsed.ok) continue;
|
|
984
|
+
if (!parsed.nodes.some((n) => "@context" in n) || !parsed.nodes.some((n) => typeOf(n).length > 0)) continue;
|
|
985
|
+
const problem = opts.problem(parsed.nodes);
|
|
986
|
+
if (problem === false) continue;
|
|
987
|
+
out.push(
|
|
988
|
+
problem ? {
|
|
989
|
+
id: opts.id,
|
|
990
|
+
category: "seo",
|
|
991
|
+
severity: opts.severity,
|
|
992
|
+
detection: PENALIZED,
|
|
993
|
+
route: head.route,
|
|
994
|
+
location: head.file,
|
|
995
|
+
message: problem,
|
|
996
|
+
recommendation: opts.recommendation,
|
|
997
|
+
docsUrl: docsUrl6,
|
|
998
|
+
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
999
|
+
} : {
|
|
1000
|
+
id: opts.id,
|
|
1001
|
+
category: "seo",
|
|
1002
|
+
severity: opts.severity,
|
|
1003
|
+
detection: PASS,
|
|
1004
|
+
route: head.route,
|
|
1005
|
+
message: opts.label,
|
|
1006
|
+
recommendation: opts.recommendation,
|
|
1007
|
+
docsUrl: docsUrl6
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return out;
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
var seo017DeprecatedType = jsonldRule({
|
|
1017
|
+
id: "SEO017",
|
|
1018
|
+
title: "Deprecated structured-data type",
|
|
1019
|
+
severity: "info",
|
|
1020
|
+
label: "Structured-data type",
|
|
1021
|
+
recommendation: "Verify the rich-result status of this @type; Google dropped or restricted some (e.g. HowTo, FAQPage).",
|
|
1022
|
+
rationale: "Some schema types no longer produce rich results, so the markup adds weight without the SERP benefit.",
|
|
1023
|
+
problem: (nodes) => {
|
|
1024
|
+
const dep = nodes.flatMap(typeOf).find((t) => DEPRECATED_TYPES.has(t));
|
|
1025
|
+
return dep ? `@type "${dep}" no longer reliably produces a Google rich result` : void 0;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
var seo018RelativeUrl = jsonldRule({
|
|
1029
|
+
id: "SEO018",
|
|
1030
|
+
title: "JSON-LD relative URL",
|
|
1031
|
+
severity: "warning",
|
|
1032
|
+
label: "JSON-LD URLs",
|
|
1033
|
+
recommendation: "Use absolute URLs (http/https) for url/@id/image/logo/sameAs/contentUrl/thumbnailUrl in JSON-LD.",
|
|
1034
|
+
rationale: "Search engines need absolute URLs in structured data; a relative URL cannot be resolved reliably.",
|
|
1035
|
+
fix: {
|
|
1036
|
+
description: "Replace relative URLs in JSON-LD with absolute URLs.",
|
|
1037
|
+
snippet: '"image": "https://example.com/logo.png"',
|
|
1038
|
+
lang: "json"
|
|
1039
|
+
},
|
|
1040
|
+
problem: (nodes) => {
|
|
1041
|
+
const bad = collectValues(nodes, URL_KEYS).find((v) => !isAbsoluteUrl(v));
|
|
1042
|
+
return bad ? `Relative URL in JSON-LD: "${bad}" \u2014 use an absolute URL` : void 0;
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
var seo019DateFormat = jsonldRule({
|
|
1046
|
+
id: "SEO019",
|
|
1047
|
+
title: "JSON-LD date format",
|
|
1048
|
+
severity: "info",
|
|
1049
|
+
label: "JSON-LD dates",
|
|
1050
|
+
recommendation: "Use ISO-8601 dates (e.g. 2026-06-26 or 2026-06-26T10:00:00Z) in JSON-LD.",
|
|
1051
|
+
rationale: "Schema.org date properties expect ISO-8601; other formats may be ignored or misparsed.",
|
|
1052
|
+
fix: {
|
|
1053
|
+
description: "Format JSON-LD date properties as ISO-8601.",
|
|
1054
|
+
snippet: '"datePublished": "2026-06-26"',
|
|
1055
|
+
lang: "json"
|
|
1056
|
+
},
|
|
1057
|
+
problem: (nodes) => {
|
|
1058
|
+
const bad = collectValues(nodes, DATE_KEYS).find((v) => !isIso8601(v));
|
|
1059
|
+
return bad ? `Non-ISO-8601 date in JSON-LD: "${bad}"` : void 0;
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
var seo020Placeholder = jsonldRule({
|
|
1063
|
+
id: "SEO020",
|
|
1064
|
+
title: "JSON-LD placeholder text",
|
|
1065
|
+
severity: "info",
|
|
1066
|
+
label: "JSON-LD content",
|
|
1067
|
+
recommendation: "Replace placeholder/boilerplate text in JSON-LD with real values.",
|
|
1068
|
+
rationale: 'Leftover placeholder text (e.g. "Your Company Name", "lorem ipsum") ships misleading structured data.',
|
|
1069
|
+
problem: (nodes) => {
|
|
1070
|
+
const bad = nodes.flatMap(nodeStringValues).find(hasPlaceholder);
|
|
1071
|
+
return bad ? `Placeholder text in JSON-LD: "${bad}"` : void 0;
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
var seo021RequiredProps = jsonldRule({
|
|
1075
|
+
id: "SEO021",
|
|
1076
|
+
title: "JSON-LD required properties",
|
|
1077
|
+
severity: "warning",
|
|
1078
|
+
label: "JSON-LD required properties",
|
|
1079
|
+
recommendation: "Add the properties Google requires for this @type's rich result.",
|
|
1080
|
+
rationale: "A recognized @type missing its required properties is ineligible for the corresponding rich result.",
|
|
1081
|
+
problem: (nodes) => {
|
|
1082
|
+
let hasKnownType = false;
|
|
1083
|
+
for (const node of nodes) {
|
|
1084
|
+
for (const t of typeOf(node)) {
|
|
1085
|
+
const required = REQUIRED_PROPS[t];
|
|
1086
|
+
if (!required) continue;
|
|
1087
|
+
hasKnownType = true;
|
|
1088
|
+
const missing = required.filter((p) => !hasNonEmpty(node, p));
|
|
1089
|
+
if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(", ")}`;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return hasKnownType ? void 0 : false;
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// src/rules/seo/text-metrics.ts
|
|
1097
|
+
var segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter() : void 0;
|
|
1098
|
+
function visibleLength(s) {
|
|
1099
|
+
const collapsed = s.trim().replace(/\s+/g, " ");
|
|
1100
|
+
if (!segmenter) return [...collapsed].length;
|
|
1101
|
+
return [...segmenter.segment(collapsed)].length;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/rules/seo/seo022-023.ts
|
|
1105
|
+
function lengthRule(opts) {
|
|
1106
|
+
const docsUrl6 = docsUrlFor(opts.id);
|
|
1107
|
+
return {
|
|
1108
|
+
id: opts.id,
|
|
1109
|
+
title: opts.title,
|
|
1110
|
+
category: "seo",
|
|
1111
|
+
severity: "info",
|
|
1112
|
+
scope: "route",
|
|
1113
|
+
rationale: opts.rationale,
|
|
1114
|
+
async check(ctx) {
|
|
1115
|
+
const out = [];
|
|
1116
|
+
for (const head of ctx.heads) {
|
|
1117
|
+
const tag = head.tags.find(opts.match);
|
|
1118
|
+
if (!tag || typeof tag.text !== "string") continue;
|
|
1119
|
+
const len = visibleLength(tag.text);
|
|
1120
|
+
let problem;
|
|
1121
|
+
if (len < opts.min) problem = `${opts.noun} is too short (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
|
|
1122
|
+
else if (len > opts.max) problem = `${opts.noun} is too long (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
|
|
1123
|
+
out.push(
|
|
1124
|
+
problem ? {
|
|
1125
|
+
id: opts.id,
|
|
1126
|
+
category: "seo",
|
|
1127
|
+
severity: "info",
|
|
1128
|
+
detection: PENALIZED,
|
|
1129
|
+
route: head.route,
|
|
1130
|
+
location: tag.file ?? head.file,
|
|
1131
|
+
message: problem,
|
|
1132
|
+
recommendation: opts.recommendation,
|
|
1133
|
+
docsUrl: docsUrl6
|
|
1134
|
+
} : {
|
|
1135
|
+
id: opts.id,
|
|
1136
|
+
category: "seo",
|
|
1137
|
+
severity: "info",
|
|
1138
|
+
detection: PASS,
|
|
1139
|
+
route: head.route,
|
|
1140
|
+
message: opts.label,
|
|
1141
|
+
recommendation: opts.recommendation,
|
|
1142
|
+
docsUrl: docsUrl6
|
|
1143
|
+
}
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
return out;
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
var seo022TitleLength = lengthRule({
|
|
1151
|
+
id: "SEO022",
|
|
1152
|
+
title: "Title length",
|
|
1153
|
+
label: "Title length",
|
|
1154
|
+
noun: "Title",
|
|
1155
|
+
match: (t) => t.kind === "title",
|
|
1156
|
+
min: 30,
|
|
1157
|
+
max: 60,
|
|
1158
|
+
recommendation: "Aim for a title of 30\u201360 characters so it is not truncated in search results.",
|
|
1159
|
+
rationale: "A title that is too short wastes the strongest on-page signal; one that is too long is truncated in the SERP."
|
|
1160
|
+
});
|
|
1161
|
+
var seo023DescriptionLength = lengthRule({
|
|
1162
|
+
id: "SEO023",
|
|
1163
|
+
title: "Description length",
|
|
1164
|
+
label: "Description length",
|
|
1165
|
+
noun: "Description",
|
|
1166
|
+
match: (t) => t.kind === "meta" && t.name === "description",
|
|
1167
|
+
min: 70,
|
|
1168
|
+
max: 160,
|
|
1169
|
+
recommendation: "Aim for a meta description of 70\u2013160 characters so it is not truncated in search results.",
|
|
1170
|
+
rationale: "A description that is too short under-uses the SERP snippet; one that is too long is truncated by search engines."
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// src/rules/seo/seo024-charset.ts
|
|
1174
|
+
var seo024Charset = headTagRule({
|
|
1175
|
+
id: "SEO024",
|
|
1176
|
+
title: "Character encoding",
|
|
1177
|
+
severity: "warning",
|
|
1178
|
+
match: (t) => t.kind === "meta" && t.name === "charset",
|
|
1179
|
+
label: "<meta charset>",
|
|
1180
|
+
appliesTo: (head) => head.source === "rendered",
|
|
1181
|
+
recommendation: 'Add <meta charset="utf-8"> (usually the first line of <head> in src/app.html).',
|
|
1182
|
+
rationale: 'Without a declared character encoding the browser must guess, which can render text as mojibake; <meta charset="utf-8"> is the standard declaration.',
|
|
1183
|
+
fix: {
|
|
1184
|
+
description: "Add the charset meta tag (typically the first line of <head> in src/app.html).",
|
|
1185
|
+
snippet: '<meta charset="utf-8" />',
|
|
1186
|
+
lang: "html"
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// src/rules/seo/seo025-image-alt.ts
|
|
1191
|
+
var seo025ImageAlt = imageRule({
|
|
1192
|
+
id: "SEO025",
|
|
1193
|
+
title: "Image alt text",
|
|
1194
|
+
category: "seo",
|
|
1195
|
+
severity: "warning",
|
|
1196
|
+
label: "<img> alt text",
|
|
1197
|
+
recommendation: 'Add an alt attribute to every <img> (use alt="" only for purely decorative images).',
|
|
1198
|
+
rationale: "An <img> with no alt attribute is invisible to image search and assistive technology; a descriptive alt is an image-SEO signal.",
|
|
1199
|
+
fix: {
|
|
1200
|
+
description: 'Add a descriptive alt attribute to the <img> (or alt="" if purely decorative).',
|
|
1201
|
+
snippet: '<img src="/photo.jpg" width="800" height="600" alt="Description of the image" />',
|
|
1202
|
+
lang: "svelte"
|
|
1203
|
+
},
|
|
1204
|
+
ok: (img) => img.hasAlt
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// src/rules/seo/seo026-hreflang.ts
|
|
1208
|
+
var docsUrl4 = docsUrlFor("SEO026");
|
|
1209
|
+
var recommendation4 = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
|
|
1210
|
+
var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
1211
|
+
function isValidHreflang(v) {
|
|
1212
|
+
return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
|
|
1213
|
+
}
|
|
1214
|
+
var seo026Hreflang = {
|
|
1215
|
+
id: "SEO026",
|
|
1216
|
+
title: "hreflang validity",
|
|
1217
|
+
category: "seo",
|
|
1218
|
+
severity: "warning",
|
|
1219
|
+
scope: "route",
|
|
1220
|
+
rationale: "A malformed hreflang code or a missing x-default breaks international targeting, so search engines may serve the wrong language version.",
|
|
1221
|
+
async check(ctx) {
|
|
1222
|
+
const out = [];
|
|
1223
|
+
for (const head of ctx.heads) {
|
|
1224
|
+
const alternates = head.tags.filter(
|
|
1225
|
+
(t) => t.kind === "link" && t.rel === "alternate" && typeof t.hreflang === "string"
|
|
1226
|
+
);
|
|
1227
|
+
if (alternates.length === 0) continue;
|
|
1228
|
+
const values = alternates.map((t) => t.hreflang);
|
|
1229
|
+
const badTag = alternates.find((t) => !isValidHreflang(t.hreflang));
|
|
1230
|
+
let problem;
|
|
1231
|
+
let location = head.file;
|
|
1232
|
+
if (badTag) {
|
|
1233
|
+
problem = `Invalid hreflang value "${badTag.hreflang}"`;
|
|
1234
|
+
location = badTag.file ?? head.file;
|
|
1235
|
+
} else if (values.length >= 2 && !values.some((v) => v.toLowerCase() === "x-default")) {
|
|
1236
|
+
problem = "Multiple hreflang alternates without an x-default";
|
|
1237
|
+
}
|
|
1238
|
+
out.push(
|
|
1239
|
+
problem ? {
|
|
1240
|
+
id: "SEO026",
|
|
1241
|
+
category: "seo",
|
|
1242
|
+
severity: "warning",
|
|
1243
|
+
detection: PENALIZED,
|
|
1244
|
+
route: head.route,
|
|
1245
|
+
location,
|
|
1246
|
+
message: problem,
|
|
1247
|
+
recommendation: recommendation4,
|
|
1248
|
+
docsUrl: docsUrl4
|
|
1249
|
+
} : {
|
|
1250
|
+
id: "SEO026",
|
|
1251
|
+
category: "seo",
|
|
1252
|
+
severity: "warning",
|
|
1253
|
+
detection: PASS,
|
|
1254
|
+
route: head.route,
|
|
1255
|
+
message: "hreflang",
|
|
1256
|
+
recommendation: recommendation4,
|
|
1257
|
+
docsUrl: docsUrl4
|
|
1258
|
+
}
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
return out;
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// src/rules/seo/seo027-heading.ts
|
|
1266
|
+
var docsUrl5 = docsUrlFor("SEO027");
|
|
1267
|
+
var recommendation5 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
1268
|
+
var seo027Heading = {
|
|
1269
|
+
id: "SEO027",
|
|
1270
|
+
title: "Heading hierarchy",
|
|
1271
|
+
category: "seo",
|
|
1272
|
+
severity: "warning",
|
|
1273
|
+
scope: "route",
|
|
1274
|
+
rationale: "Each page should have exactly one <h1> naming its main topic; none leaves the page without a primary heading, and several dilute the topic signal.",
|
|
1275
|
+
async check(ctx) {
|
|
1276
|
+
const out = [];
|
|
1277
|
+
for (const route of ctx.headings ?? []) {
|
|
1278
|
+
const h1 = route.headings.filter((h) => h.level === 1);
|
|
1279
|
+
let problem;
|
|
1280
|
+
let where = {};
|
|
1281
|
+
if (h1.length === 0) {
|
|
1282
|
+
problem = "Missing <h1>";
|
|
1283
|
+
const first = route.headings[0];
|
|
1284
|
+
if (first) where = { location: first.file, ...first.line > 0 ? { line: first.line } : {} };
|
|
1285
|
+
} else if (h1.length > 1) {
|
|
1286
|
+
problem = `Multiple <h1> (${h1.length}); use exactly one`;
|
|
1287
|
+
const extra = h1[1];
|
|
1288
|
+
where = { location: extra.file, ...extra.line > 0 ? { line: extra.line } : {} };
|
|
1289
|
+
}
|
|
1290
|
+
out.push(
|
|
1291
|
+
problem ? {
|
|
1292
|
+
id: "SEO027",
|
|
1293
|
+
category: "seo",
|
|
1294
|
+
severity: "warning",
|
|
1295
|
+
detection: PENALIZED,
|
|
1296
|
+
route: route.route,
|
|
1297
|
+
...where,
|
|
1298
|
+
message: problem,
|
|
1299
|
+
recommendation: recommendation5,
|
|
1300
|
+
docsUrl: docsUrl5
|
|
1301
|
+
} : {
|
|
1302
|
+
id: "SEO027",
|
|
1303
|
+
category: "seo",
|
|
1304
|
+
severity: "warning",
|
|
1305
|
+
detection: PASS,
|
|
1306
|
+
route: route.route,
|
|
1307
|
+
message: "Heading hierarchy",
|
|
1308
|
+
recommendation: recommendation5,
|
|
1309
|
+
docsUrl: docsUrl5
|
|
1310
|
+
}
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
return out;
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
|
|
590
1317
|
// src/rules/index.ts
|
|
591
1318
|
var allRules = [
|
|
592
1319
|
seo001Title,
|
|
@@ -607,7 +1334,23 @@ var allRules = [
|
|
|
607
1334
|
seo012OgDescription,
|
|
608
1335
|
seo013OgUrl,
|
|
609
1336
|
seo014Viewport,
|
|
610
|
-
seo015SitemapInRobots
|
|
1337
|
+
seo015SitemapInRobots,
|
|
1338
|
+
seo016JsonLdValidity,
|
|
1339
|
+
seo017DeprecatedType,
|
|
1340
|
+
seo018RelativeUrl,
|
|
1341
|
+
seo019DateFormat,
|
|
1342
|
+
seo020Placeholder,
|
|
1343
|
+
seo021RequiredProps,
|
|
1344
|
+
seo022TitleLength,
|
|
1345
|
+
seo023DescriptionLength,
|
|
1346
|
+
seo024Charset,
|
|
1347
|
+
seo025ImageAlt,
|
|
1348
|
+
seo026Hreflang,
|
|
1349
|
+
seo027Heading,
|
|
1350
|
+
perf005LcpImage,
|
|
1351
|
+
perf006ResponsiveImage,
|
|
1352
|
+
perf007RenderBlockingScript,
|
|
1353
|
+
perf008Preconnect
|
|
611
1354
|
];
|
|
612
1355
|
function explainRule(id) {
|
|
613
1356
|
const target = id.toUpperCase();
|
|
@@ -1220,6 +1963,10 @@ export {
|
|
|
1220
1963
|
perf002ImageLoading,
|
|
1221
1964
|
perf003PreloadAs,
|
|
1222
1965
|
perf004FontPreloadCrossorigin,
|
|
1966
|
+
perf005LcpImage,
|
|
1967
|
+
perf006ResponsiveImage,
|
|
1968
|
+
perf007RenderBlockingScript,
|
|
1969
|
+
perf008Preconnect,
|
|
1223
1970
|
runRules,
|
|
1224
1971
|
safeHref,
|
|
1225
1972
|
scoreBand,
|
|
@@ -1240,5 +1987,17 @@ export {
|
|
|
1240
1987
|
seo013OgUrl,
|
|
1241
1988
|
seo014Viewport,
|
|
1242
1989
|
seo015SitemapInRobots,
|
|
1990
|
+
seo016JsonLdValidity,
|
|
1991
|
+
seo017DeprecatedType,
|
|
1992
|
+
seo018RelativeUrl,
|
|
1993
|
+
seo019DateFormat,
|
|
1994
|
+
seo020Placeholder,
|
|
1995
|
+
seo021RequiredProps,
|
|
1996
|
+
seo022TitleLength,
|
|
1997
|
+
seo023DescriptionLength,
|
|
1998
|
+
seo024Charset,
|
|
1999
|
+
seo025ImageAlt,
|
|
2000
|
+
seo026Hreflang,
|
|
2001
|
+
seo027Heading,
|
|
1243
2002
|
summarize
|
|
1244
2003
|
};
|