@svelte-vitals/core 0.14.0 → 0.16.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 +95 -6
- package/dist/index.js +458 -35
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -54,8 +54,8 @@ interface Result {
|
|
|
54
54
|
/** 1-based source line for element-level findings (e.g. a specific <img>). */
|
|
55
55
|
line?: number;
|
|
56
56
|
}
|
|
57
|
-
type Scope = 'route' | 'project';
|
|
58
|
-
type Category = 'seo' | 'performance';
|
|
57
|
+
type Scope = 'route' | 'project' | 'component';
|
|
58
|
+
type Category = 'seo' | 'performance' | 'correctness' | 'security';
|
|
59
59
|
/** How dynamic (`{data.title}`) values are treated by scoring (design §4, §12). */
|
|
60
60
|
type TreatDynamicAs = 'pass' | 'warn' | 'fail';
|
|
61
61
|
/** Per-rule override: disable, or change severity. */
|
|
@@ -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). */
|
|
@@ -121,6 +121,10 @@ interface HeadTag {
|
|
|
121
121
|
text?: string;
|
|
122
122
|
/** Literal `hreflang` of a `<link rel="alternate">` (e.g. 'en', 'en-US', 'x-default'). Undefined when dynamic/absent. */
|
|
123
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;
|
|
124
128
|
/** Where this tag was set relative to the route. Never 'none' (absence = no tag). */
|
|
125
129
|
presence: Exclude<Presence, 'none'>;
|
|
126
130
|
/** Whether the tag's value is static/dynamic/absent (design §4). */
|
|
@@ -160,6 +164,10 @@ interface ImageInfo {
|
|
|
160
164
|
hasLoading: boolean;
|
|
161
165
|
/** True when the <img> has an `alt` attribute at all (incl. empty `alt=""` decorative; SEO025). */
|
|
162
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;
|
|
163
171
|
/** 1-based source line, or 0 if unknown. */
|
|
164
172
|
line: number;
|
|
165
173
|
/** Source file the <img> came from. */
|
|
@@ -190,6 +198,42 @@ interface ResolvedHeadings {
|
|
|
190
198
|
headings: HeadingInfo[];
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Component-body facts for the Correctness category — the source-analysis boundary
|
|
203
|
+
* (mirrors images.ts / headings.ts). Collected by the static (CLI) provider only;
|
|
204
|
+
* the rendered provider can't see reactivity, so correctness rules no-op there.
|
|
205
|
+
*/
|
|
206
|
+
/** An `{#each}` block in a component template. */
|
|
207
|
+
interface EachBlockFact {
|
|
208
|
+
/** True when the block has a key, e.g. `{#each items as item (item.id)}`. */
|
|
209
|
+
hasKey: boolean;
|
|
210
|
+
/** 1-based source line, or 0 if unknown. */
|
|
211
|
+
line: number;
|
|
212
|
+
}
|
|
213
|
+
/** An `$effect(...)` / `$effect.pre(...)` call in a component's instance script. */
|
|
214
|
+
interface EffectFact {
|
|
215
|
+
/** 1-based source line, or 0 if unknown. */
|
|
216
|
+
line: number;
|
|
217
|
+
/** True when the effect body only assigns to `$state` variables (the "use $derived" smell). */
|
|
218
|
+
assignsOnlyState: boolean;
|
|
219
|
+
}
|
|
220
|
+
/** A flagged source position in a component (e.g. an `{@html}` tag or a `javascript:` URL). */
|
|
221
|
+
interface SourceSpan {
|
|
222
|
+
/** 1-based source line, or 0 if unknown. */
|
|
223
|
+
line: number;
|
|
224
|
+
}
|
|
225
|
+
/** Reactivity/correctness + security facts parsed from one `.svelte` component. */
|
|
226
|
+
interface ComponentFacts {
|
|
227
|
+
/** Source file the component came from. */
|
|
228
|
+
file: string;
|
|
229
|
+
eachBlocks: EachBlockFact[];
|
|
230
|
+
effects: EffectFact[];
|
|
231
|
+
/** `{@html …}` occurrences — raw-HTML render surfaces (Security SEC001). */
|
|
232
|
+
htmlTags: SourceSpan[];
|
|
233
|
+
/** Element attributes with a literal `javascript:` URL (Security SEC002). */
|
|
234
|
+
javascriptUrls: SourceSpan[];
|
|
235
|
+
}
|
|
236
|
+
|
|
193
237
|
/**
|
|
194
238
|
* Source-file locations that satisfy the project-scope rules, shared by every
|
|
195
239
|
* mode so the static (CLI) and rendered (plugin) collectors never drift. This
|
|
@@ -207,6 +251,8 @@ interface RuleContext {
|
|
|
207
251
|
images?: ResolvedImages[];
|
|
208
252
|
/** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
|
|
209
253
|
headings?: ResolvedHeadings[];
|
|
254
|
+
/** Per-file component-body facts for Correctness rules (static/CLI mode only). */
|
|
255
|
+
components?: ComponentFacts[];
|
|
210
256
|
project: Project;
|
|
211
257
|
config: Config;
|
|
212
258
|
}
|
|
@@ -267,10 +313,36 @@ declare const seo009HtmlLang: Rule;
|
|
|
267
313
|
|
|
268
314
|
declare const perf001ImageDimensions: Rule;
|
|
269
315
|
declare const perf002ImageLoading: Rule;
|
|
316
|
+
declare const perf006ResponsiveImage: Rule;
|
|
270
317
|
|
|
271
318
|
declare const perf003PreloadAs: Rule;
|
|
272
319
|
declare const perf004FontPreloadCrossorigin: Rule;
|
|
273
320
|
|
|
321
|
+
/**
|
|
322
|
+
* PERF005 — LCP image not lazy-loaded. Lazy-loading the largest contentful paint
|
|
323
|
+
* image delays it. Analysis approximates the LCP as the first <img> in document
|
|
324
|
+
* order for the route; if that image is loading="lazy", flag it. Runs in both
|
|
325
|
+
* static (CLI) and rendered (vite) mode, since both providers collect <img>.
|
|
326
|
+
*/
|
|
327
|
+
declare const perf005LcpImage: Rule;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* PERF007 — Render-blocking <script> in <head>. A <script src> without
|
|
331
|
+
* defer/async/type=module blocks the parser. SvelteKit's own scripts are
|
|
332
|
+
* module/deferred, so this catches hand-added blocking scripts — in app.html
|
|
333
|
+
* (rendered mode) or in <svelte:head> (static mode). A head with no <script>
|
|
334
|
+
* emits nothing (no signal), like the image rules.
|
|
335
|
+
*/
|
|
336
|
+
declare const perf007RenderBlockingScript: Rule;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* PERF008 — Preconnect for third-party origins. A resource from a well-known
|
|
340
|
+
* third-party origin (e.g. Google Fonts) without a preconnect/dns-prefetch pays a
|
|
341
|
+
* connection-setup round-trip. Opt-in by construction: only origins in the
|
|
342
|
+
* allowlist are checked; routes referencing none emit nothing.
|
|
343
|
+
*/
|
|
344
|
+
declare const perf008Preconnect: Rule;
|
|
345
|
+
|
|
274
346
|
declare const seo010Indexability: Rule;
|
|
275
347
|
declare const seo011TwitterCard: Rule;
|
|
276
348
|
declare const seo012OgDescription: Rule;
|
|
@@ -296,8 +368,8 @@ declare const seo023DescriptionLength: Rule;
|
|
|
296
368
|
declare const seo024Charset: Rule;
|
|
297
369
|
|
|
298
370
|
/**
|
|
299
|
-
* SEO025 — Image alt text. Reuses the <img> collection
|
|
300
|
-
*
|
|
371
|
+
* SEO025 — Image alt text. Reuses the <img> collection from both providers — the
|
|
372
|
+
* static (CLI) source parser and the rendered (vite) HTML parser — like PERF001/002.
|
|
301
373
|
* Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
|
|
302
374
|
* passes; a spread `{...rest}` may supply alt, so it is not flagged.
|
|
303
375
|
*/
|
|
@@ -319,6 +391,23 @@ declare const seo026Hreflang: Rule;
|
|
|
319
391
|
*/
|
|
320
392
|
declare const seo027Heading: Rule;
|
|
321
393
|
|
|
394
|
+
declare const seo028TitleUnique: Rule;
|
|
395
|
+
declare const seo029DescriptionUnique: Rule;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* SEO030 — Skipped heading level. Walking a route's body headings in document
|
|
399
|
+
* order, a level that jumps more than +1 over the previous heading (e.g. h2 → h4)
|
|
400
|
+
* breaks the outline. The first heading has no predecessor (missing/multiple
|
|
401
|
+
* <h1> stays SEO027's concern). A route with no headings emits nothing.
|
|
402
|
+
*/
|
|
403
|
+
declare const seo030HeadingOrder: Rule;
|
|
404
|
+
|
|
405
|
+
declare const correct001EachKey: Rule;
|
|
406
|
+
declare const correct002EffectDerived: Rule;
|
|
407
|
+
|
|
408
|
+
declare const sec001Html: Rule;
|
|
409
|
+
declare const sec002JavascriptUrl: Rule;
|
|
410
|
+
|
|
322
411
|
declare const allRules: Rule[];
|
|
323
412
|
|
|
324
413
|
interface RuleInfo {
|
|
@@ -527,4 +616,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
|
|
|
527
616
|
/** Apply per-rule severity overrides to results (design §6). */
|
|
528
617
|
declare function applyRuleSeverities(results: Result[], config: Config): Result[];
|
|
529
618
|
|
|
530
|
-
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, 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 };
|
|
619
|
+
export { BAND_COLOR, type Category, type Classification, type ComponentFacts, type Config, type ConsoleReportOptions, type Detection, type EachBlockFact, type EffectFact, 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 SourceSpan, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, 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, sec001Html, sec002JavascriptUrl, 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, seo028TitleUnique, seo029DescriptionUnique, seo030HeadingOrder, 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 docsUrl7 = 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: docsUrl7,
|
|
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,7 +296,7 @@ var seo009HtmlLang = {
|
|
|
296
296
|
|
|
297
297
|
// src/rules/perf/image-rule.ts
|
|
298
298
|
function imageRule(opts) {
|
|
299
|
-
const
|
|
299
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
300
300
|
const category = opts.category ?? "performance";
|
|
301
301
|
return {
|
|
302
302
|
id: opts.id,
|
|
@@ -320,7 +320,7 @@ function imageRule(opts) {
|
|
|
320
320
|
route: route.route,
|
|
321
321
|
message: opts.label,
|
|
322
322
|
recommendation: opts.recommendation,
|
|
323
|
-
docsUrl:
|
|
323
|
+
docsUrl: docsUrl7
|
|
324
324
|
});
|
|
325
325
|
continue;
|
|
326
326
|
}
|
|
@@ -335,7 +335,7 @@ function imageRule(opts) {
|
|
|
335
335
|
...img.line > 0 ? { line: img.line } : {},
|
|
336
336
|
message: `Missing ${opts.label}`,
|
|
337
337
|
recommendation: opts.recommendation,
|
|
338
|
-
docsUrl:
|
|
338
|
+
docsUrl: docsUrl7,
|
|
339
339
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
340
340
|
});
|
|
341
341
|
}
|
|
@@ -374,10 +374,24 @@ var perf002ImageLoading = imageRule({
|
|
|
374
374
|
},
|
|
375
375
|
ok: (img) => img.hasLoading
|
|
376
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
|
+
});
|
|
377
391
|
|
|
378
392
|
// src/rules/perf/link-rule.ts
|
|
379
393
|
function linkRule(opts) {
|
|
380
|
-
const
|
|
394
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
381
395
|
return {
|
|
382
396
|
id: opts.id,
|
|
383
397
|
title: opts.title,
|
|
@@ -401,7 +415,7 @@ function linkRule(opts) {
|
|
|
401
415
|
route: head.route,
|
|
402
416
|
message: opts.label,
|
|
403
417
|
recommendation: opts.recommendation,
|
|
404
|
-
docsUrl:
|
|
418
|
+
docsUrl: docsUrl7
|
|
405
419
|
});
|
|
406
420
|
continue;
|
|
407
421
|
}
|
|
@@ -418,7 +432,7 @@ function linkRule(opts) {
|
|
|
418
432
|
location: tag.file ?? head.file,
|
|
419
433
|
message: `Missing ${opts.label}`,
|
|
420
434
|
recommendation: opts.recommendation,
|
|
421
|
-
docsUrl:
|
|
435
|
+
docsUrl: docsUrl7,
|
|
422
436
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
423
437
|
});
|
|
424
438
|
}
|
|
@@ -460,6 +474,175 @@ var perf004FontPreloadCrossorigin = linkRule({
|
|
|
460
474
|
ok: (t) => t.hasCrossorigin === true
|
|
461
475
|
});
|
|
462
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
|
+
|
|
463
646
|
// src/rules/seo/seo010-015.ts
|
|
464
647
|
var SEO010_FIX = {
|
|
465
648
|
description: 'If this route should be indexed, drop noindex from its <meta name="robots">.',
|
|
@@ -475,7 +658,7 @@ var seo010Indexability = {
|
|
|
475
658
|
rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
|
|
476
659
|
fix: SEO010_FIX,
|
|
477
660
|
async check(ctx) {
|
|
478
|
-
const
|
|
661
|
+
const docsUrl7 = docsUrlFor("SEO010");
|
|
479
662
|
const out = [];
|
|
480
663
|
for (const head of ctx.heads) {
|
|
481
664
|
const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
|
|
@@ -490,7 +673,7 @@ var seo010Indexability = {
|
|
|
490
673
|
location: head.file,
|
|
491
674
|
message: "Route is noindex \u2014 verify this is intentional",
|
|
492
675
|
recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
|
|
493
|
-
docsUrl:
|
|
676
|
+
docsUrl: docsUrl7,
|
|
494
677
|
fix: { ...SEO010_FIX }
|
|
495
678
|
});
|
|
496
679
|
}
|
|
@@ -745,7 +928,7 @@ var seo016JsonLdValidity = {
|
|
|
745
928
|
lang: "svelte"
|
|
746
929
|
},
|
|
747
930
|
async check(ctx) {
|
|
748
|
-
const
|
|
931
|
+
const docsUrl7 = docsUrlFor("SEO016");
|
|
749
932
|
const out = [];
|
|
750
933
|
for (const head of ctx.heads) {
|
|
751
934
|
for (const tag of jsonldTags(head)) {
|
|
@@ -764,7 +947,7 @@ var seo016JsonLdValidity = {
|
|
|
764
947
|
location: head.file,
|
|
765
948
|
message: problem,
|
|
766
949
|
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
767
|
-
docsUrl:
|
|
950
|
+
docsUrl: docsUrl7,
|
|
768
951
|
fix: { ...seo016JsonLdValidity.fix }
|
|
769
952
|
} : {
|
|
770
953
|
id: "SEO016",
|
|
@@ -774,7 +957,7 @@ var seo016JsonLdValidity = {
|
|
|
774
957
|
route: head.route,
|
|
775
958
|
message: "JSON-LD validity",
|
|
776
959
|
recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
|
|
777
|
-
docsUrl:
|
|
960
|
+
docsUrl: docsUrl7
|
|
778
961
|
}
|
|
779
962
|
);
|
|
780
963
|
}
|
|
@@ -783,7 +966,7 @@ var seo016JsonLdValidity = {
|
|
|
783
966
|
}
|
|
784
967
|
};
|
|
785
968
|
function jsonldRule(opts) {
|
|
786
|
-
const
|
|
969
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
787
970
|
return {
|
|
788
971
|
id: opts.id,
|
|
789
972
|
title: opts.title,
|
|
@@ -811,7 +994,7 @@ function jsonldRule(opts) {
|
|
|
811
994
|
location: head.file,
|
|
812
995
|
message: problem,
|
|
813
996
|
recommendation: opts.recommendation,
|
|
814
|
-
docsUrl:
|
|
997
|
+
docsUrl: docsUrl7,
|
|
815
998
|
...opts.fix ? { fix: { ...opts.fix } } : {}
|
|
816
999
|
} : {
|
|
817
1000
|
id: opts.id,
|
|
@@ -821,7 +1004,7 @@ function jsonldRule(opts) {
|
|
|
821
1004
|
route: head.route,
|
|
822
1005
|
message: opts.label,
|
|
823
1006
|
recommendation: opts.recommendation,
|
|
824
|
-
docsUrl:
|
|
1007
|
+
docsUrl: docsUrl7
|
|
825
1008
|
}
|
|
826
1009
|
);
|
|
827
1010
|
}
|
|
@@ -912,15 +1095,18 @@ var seo021RequiredProps = jsonldRule({
|
|
|
912
1095
|
|
|
913
1096
|
// src/rules/seo/text-metrics.ts
|
|
914
1097
|
var segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter() : void 0;
|
|
1098
|
+
function collapseWhitespace(s) {
|
|
1099
|
+
return s.trim().replace(/\s+/g, " ");
|
|
1100
|
+
}
|
|
915
1101
|
function visibleLength(s) {
|
|
916
|
-
const collapsed =
|
|
1102
|
+
const collapsed = collapseWhitespace(s);
|
|
917
1103
|
if (!segmenter) return [...collapsed].length;
|
|
918
1104
|
return [...segmenter.segment(collapsed)].length;
|
|
919
1105
|
}
|
|
920
1106
|
|
|
921
1107
|
// src/rules/seo/seo022-023.ts
|
|
922
1108
|
function lengthRule(opts) {
|
|
923
|
-
const
|
|
1109
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
924
1110
|
return {
|
|
925
1111
|
id: opts.id,
|
|
926
1112
|
title: opts.title,
|
|
@@ -947,7 +1133,7 @@ function lengthRule(opts) {
|
|
|
947
1133
|
location: tag.file ?? head.file,
|
|
948
1134
|
message: problem,
|
|
949
1135
|
recommendation: opts.recommendation,
|
|
950
|
-
docsUrl:
|
|
1136
|
+
docsUrl: docsUrl7
|
|
951
1137
|
} : {
|
|
952
1138
|
id: opts.id,
|
|
953
1139
|
category: "seo",
|
|
@@ -956,7 +1142,7 @@ function lengthRule(opts) {
|
|
|
956
1142
|
route: head.route,
|
|
957
1143
|
message: opts.label,
|
|
958
1144
|
recommendation: opts.recommendation,
|
|
959
|
-
docsUrl:
|
|
1145
|
+
docsUrl: docsUrl7
|
|
960
1146
|
}
|
|
961
1147
|
);
|
|
962
1148
|
}
|
|
@@ -1022,8 +1208,8 @@ var seo025ImageAlt = imageRule({
|
|
|
1022
1208
|
});
|
|
1023
1209
|
|
|
1024
1210
|
// src/rules/seo/seo026-hreflang.ts
|
|
1025
|
-
var
|
|
1026
|
-
var
|
|
1211
|
+
var docsUrl4 = docsUrlFor("SEO026");
|
|
1212
|
+
var recommendation4 = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
|
|
1027
1213
|
var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
1028
1214
|
function isValidHreflang(v) {
|
|
1029
1215
|
return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
|
|
@@ -1061,8 +1247,8 @@ var seo026Hreflang = {
|
|
|
1061
1247
|
route: head.route,
|
|
1062
1248
|
location,
|
|
1063
1249
|
message: problem,
|
|
1064
|
-
recommendation,
|
|
1065
|
-
docsUrl
|
|
1250
|
+
recommendation: recommendation4,
|
|
1251
|
+
docsUrl: docsUrl4
|
|
1066
1252
|
} : {
|
|
1067
1253
|
id: "SEO026",
|
|
1068
1254
|
category: "seo",
|
|
@@ -1070,8 +1256,8 @@ var seo026Hreflang = {
|
|
|
1070
1256
|
detection: PASS,
|
|
1071
1257
|
route: head.route,
|
|
1072
1258
|
message: "hreflang",
|
|
1073
|
-
recommendation,
|
|
1074
|
-
docsUrl
|
|
1259
|
+
recommendation: recommendation4,
|
|
1260
|
+
docsUrl: docsUrl4
|
|
1075
1261
|
}
|
|
1076
1262
|
);
|
|
1077
1263
|
}
|
|
@@ -1080,8 +1266,8 @@ var seo026Hreflang = {
|
|
|
1080
1266
|
};
|
|
1081
1267
|
|
|
1082
1268
|
// src/rules/seo/seo027-heading.ts
|
|
1083
|
-
var
|
|
1084
|
-
var
|
|
1269
|
+
var docsUrl5 = docsUrlFor("SEO027");
|
|
1270
|
+
var recommendation5 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
|
|
1085
1271
|
var seo027Heading = {
|
|
1086
1272
|
id: "SEO027",
|
|
1087
1273
|
title: "Heading hierarchy",
|
|
@@ -1113,8 +1299,8 @@ var seo027Heading = {
|
|
|
1113
1299
|
route: route.route,
|
|
1114
1300
|
...where,
|
|
1115
1301
|
message: problem,
|
|
1116
|
-
recommendation:
|
|
1117
|
-
docsUrl:
|
|
1302
|
+
recommendation: recommendation5,
|
|
1303
|
+
docsUrl: docsUrl5
|
|
1118
1304
|
} : {
|
|
1119
1305
|
id: "SEO027",
|
|
1120
1306
|
category: "seo",
|
|
@@ -1122,8 +1308,8 @@ var seo027Heading = {
|
|
|
1122
1308
|
detection: PASS,
|
|
1123
1309
|
route: route.route,
|
|
1124
1310
|
message: "Heading hierarchy",
|
|
1125
|
-
recommendation:
|
|
1126
|
-
docsUrl:
|
|
1311
|
+
recommendation: recommendation5,
|
|
1312
|
+
docsUrl: docsUrl5
|
|
1127
1313
|
}
|
|
1128
1314
|
);
|
|
1129
1315
|
}
|
|
@@ -1131,6 +1317,219 @@ var seo027Heading = {
|
|
|
1131
1317
|
}
|
|
1132
1318
|
};
|
|
1133
1319
|
|
|
1320
|
+
// src/rules/seo/seo028-029-uniqueness.ts
|
|
1321
|
+
function uniquenessRule(opts) {
|
|
1322
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
1323
|
+
return {
|
|
1324
|
+
id: opts.id,
|
|
1325
|
+
title: opts.title,
|
|
1326
|
+
category: "seo",
|
|
1327
|
+
severity: "warning",
|
|
1328
|
+
scope: "route",
|
|
1329
|
+
rationale: opts.rationale,
|
|
1330
|
+
async check(ctx) {
|
|
1331
|
+
const entries = [];
|
|
1332
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1333
|
+
for (const head of ctx.heads) {
|
|
1334
|
+
const tag = head.tags.find(opts.match);
|
|
1335
|
+
if (!tag || typeof tag.text !== "string") continue;
|
|
1336
|
+
const text = collapseWhitespace(tag.text);
|
|
1337
|
+
if (text.length === 0) continue;
|
|
1338
|
+
entries.push({ route: head.route, file: tag.file ?? head.file, text });
|
|
1339
|
+
counts.set(text, (counts.get(text) ?? 0) + 1);
|
|
1340
|
+
}
|
|
1341
|
+
return entries.map((e) => {
|
|
1342
|
+
const n = counts.get(e.text) ?? 1;
|
|
1343
|
+
return n > 1 ? {
|
|
1344
|
+
id: opts.id,
|
|
1345
|
+
category: "seo",
|
|
1346
|
+
severity: "warning",
|
|
1347
|
+
detection: PENALIZED,
|
|
1348
|
+
route: e.route,
|
|
1349
|
+
location: e.file,
|
|
1350
|
+
message: `${opts.noun} is duplicated across ${n} routes`,
|
|
1351
|
+
recommendation: opts.recommendation,
|
|
1352
|
+
docsUrl: docsUrl7
|
|
1353
|
+
} : {
|
|
1354
|
+
id: opts.id,
|
|
1355
|
+
category: "seo",
|
|
1356
|
+
severity: "warning",
|
|
1357
|
+
detection: PASS,
|
|
1358
|
+
route: e.route,
|
|
1359
|
+
message: opts.label,
|
|
1360
|
+
recommendation: opts.recommendation,
|
|
1361
|
+
docsUrl: docsUrl7
|
|
1362
|
+
};
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
var seo028TitleUnique = uniquenessRule({
|
|
1368
|
+
id: "SEO028",
|
|
1369
|
+
title: "Duplicate title",
|
|
1370
|
+
label: "Unique title",
|
|
1371
|
+
noun: "Title",
|
|
1372
|
+
match: (t) => t.kind === "title",
|
|
1373
|
+
recommendation: "Give each route a unique <title> that describes that page specifically.",
|
|
1374
|
+
rationale: "Duplicate titles across pages make them compete in search results and weaken each page\u2019s relevance signal."
|
|
1375
|
+
});
|
|
1376
|
+
var seo029DescriptionUnique = uniquenessRule({
|
|
1377
|
+
id: "SEO029",
|
|
1378
|
+
title: "Duplicate description",
|
|
1379
|
+
label: "Unique description",
|
|
1380
|
+
noun: "Description",
|
|
1381
|
+
match: (t) => t.kind === "meta" && t.name === "description",
|
|
1382
|
+
recommendation: "Write a unique meta description per route so each search snippet is page-specific.",
|
|
1383
|
+
rationale: "Duplicate meta descriptions give search engines no per-page summary, so they are often ignored or rewritten."
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// src/rules/seo/seo030-heading-order.ts
|
|
1387
|
+
var docsUrl6 = docsUrlFor("SEO030");
|
|
1388
|
+
var recommendation6 = "Increase heading levels one step at a time (do not jump, e.g. from <h2> straight to <h4>).";
|
|
1389
|
+
var seo030HeadingOrder = {
|
|
1390
|
+
id: "SEO030",
|
|
1391
|
+
title: "Heading order",
|
|
1392
|
+
category: "seo",
|
|
1393
|
+
severity: "info",
|
|
1394
|
+
scope: "route",
|
|
1395
|
+
rationale: "Skipping a heading level breaks the document outline that search engines and assistive tech rely on to understand page structure.",
|
|
1396
|
+
async check(ctx) {
|
|
1397
|
+
const out = [];
|
|
1398
|
+
for (const route of ctx.headings ?? []) {
|
|
1399
|
+
if (route.headings.length === 0) continue;
|
|
1400
|
+
let prev = route.headings[0].level;
|
|
1401
|
+
let skip;
|
|
1402
|
+
for (let i = 1; i < route.headings.length; i++) {
|
|
1403
|
+
const h = route.headings[i];
|
|
1404
|
+
if (h.level > prev + 1) {
|
|
1405
|
+
skip = { level: h.level, prev, line: h.line, file: h.file };
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
prev = h.level;
|
|
1409
|
+
}
|
|
1410
|
+
out.push(
|
|
1411
|
+
skip ? {
|
|
1412
|
+
id: "SEO030",
|
|
1413
|
+
category: "seo",
|
|
1414
|
+
severity: "info",
|
|
1415
|
+
detection: PENALIZED,
|
|
1416
|
+
route: route.route,
|
|
1417
|
+
location: skip.file,
|
|
1418
|
+
...skip.line > 0 ? { line: skip.line } : {},
|
|
1419
|
+
message: `Heading level skipped (<h${skip.prev}> to <h${skip.level}>)`,
|
|
1420
|
+
recommendation: recommendation6,
|
|
1421
|
+
docsUrl: docsUrl6
|
|
1422
|
+
} : {
|
|
1423
|
+
id: "SEO030",
|
|
1424
|
+
category: "seo",
|
|
1425
|
+
severity: "info",
|
|
1426
|
+
detection: PASS,
|
|
1427
|
+
route: route.route,
|
|
1428
|
+
message: "Heading order",
|
|
1429
|
+
recommendation: recommendation6,
|
|
1430
|
+
docsUrl: docsUrl6
|
|
1431
|
+
}
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return out;
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// src/rules/component-rule.ts
|
|
1439
|
+
var PENALIZED2 = { presence: "none", value: "absent" };
|
|
1440
|
+
var PASS2 = { presence: "own", value: "static" };
|
|
1441
|
+
function componentRule(opts) {
|
|
1442
|
+
const docsUrl7 = docsUrlFor(opts.id);
|
|
1443
|
+
const severity = opts.severity ?? "warning";
|
|
1444
|
+
return {
|
|
1445
|
+
id: opts.id,
|
|
1446
|
+
title: opts.title,
|
|
1447
|
+
category: opts.category,
|
|
1448
|
+
severity,
|
|
1449
|
+
scope: "component",
|
|
1450
|
+
rationale: opts.rationale,
|
|
1451
|
+
async check(ctx) {
|
|
1452
|
+
const out = [];
|
|
1453
|
+
for (const c of ctx.components ?? []) {
|
|
1454
|
+
if (!opts.applies(c)) continue;
|
|
1455
|
+
const bad = opts.bad(c);
|
|
1456
|
+
if (bad.length === 0) {
|
|
1457
|
+
out.push({
|
|
1458
|
+
id: opts.id,
|
|
1459
|
+
category: opts.category,
|
|
1460
|
+
severity,
|
|
1461
|
+
detection: PASS2,
|
|
1462
|
+
route: c.file,
|
|
1463
|
+
message: opts.label,
|
|
1464
|
+
recommendation: opts.recommendation,
|
|
1465
|
+
docsUrl: docsUrl7
|
|
1466
|
+
});
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
for (const b of bad) {
|
|
1470
|
+
out.push({
|
|
1471
|
+
id: opts.id,
|
|
1472
|
+
category: opts.category,
|
|
1473
|
+
severity,
|
|
1474
|
+
detection: PENALIZED2,
|
|
1475
|
+
route: c.file,
|
|
1476
|
+
location: c.file,
|
|
1477
|
+
...b.line > 0 ? { line: b.line } : {},
|
|
1478
|
+
message: b.message,
|
|
1479
|
+
recommendation: opts.recommendation,
|
|
1480
|
+
docsUrl: docsUrl7
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// src/rules/correctness/correct001-002.ts
|
|
1490
|
+
var correct001EachKey = componentRule({
|
|
1491
|
+
id: "CORRECT001",
|
|
1492
|
+
title: "Keyed each block",
|
|
1493
|
+
category: "correctness",
|
|
1494
|
+
label: "Keyed {#each}",
|
|
1495
|
+
recommendation: "Add a key to the {#each} block, e.g. {#each items as item (item.id)}.",
|
|
1496
|
+
rationale: "An unkeyed {#each} destroys and recreates DOM nodes when the list reorders, losing element state/focus and wasting work; a key lets Svelte move nodes instead.",
|
|
1497
|
+
applies: (c) => c.eachBlocks.length > 0,
|
|
1498
|
+
bad: (c) => c.eachBlocks.filter((e) => !e.hasKey).map((e) => ({ line: e.line, message: "{#each} block has no key" }))
|
|
1499
|
+
});
|
|
1500
|
+
var correct002EffectDerived = componentRule({
|
|
1501
|
+
id: "CORRECT002",
|
|
1502
|
+
title: "Effect used to derive state",
|
|
1503
|
+
category: "correctness",
|
|
1504
|
+
label: "$effect usage",
|
|
1505
|
+
recommendation: "Replace the state-syncing $effect with a derived value, e.g. let x = $derived(expr).",
|
|
1506
|
+
rationale: 'An $effect whose body only assigns to $state is the "useEffect \u2192 $effect" anti-pattern: it reruns after render and can cause extra passes or loops. $derived expresses the same dependency declaratively.',
|
|
1507
|
+
applies: (c) => c.effects.length > 0,
|
|
1508
|
+
bad: (c) => c.effects.filter((e) => e.assignsOnlyState).map((e) => ({ line: e.line, message: "$effect only assigns state \u2014 use $derived instead" }))
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
// src/rules/security/sec001-002.ts
|
|
1512
|
+
var sec001Html = componentRule({
|
|
1513
|
+
id: "SEC001",
|
|
1514
|
+
title: "Raw HTML render",
|
|
1515
|
+
category: "security",
|
|
1516
|
+
label: "{@html} usage",
|
|
1517
|
+
recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
|
|
1518
|
+
rationale: "{@html} renders its value as unescaped HTML; if the value can contain user input and is not sanitized, it is a cross-site-scripting (XSS) vector.",
|
|
1519
|
+
applies: (c) => c.htmlTags.length > 0,
|
|
1520
|
+
bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
|
|
1521
|
+
});
|
|
1522
|
+
var sec002JavascriptUrl = componentRule({
|
|
1523
|
+
id: "SEC002",
|
|
1524
|
+
title: "javascript: URL",
|
|
1525
|
+
category: "security",
|
|
1526
|
+
label: "No javascript: URLs",
|
|
1527
|
+
recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
|
|
1528
|
+
rationale: "A javascript: URL in href/src/action executes arbitrary script on activation \u2014 an XSS / unsafe-navigation vector that also breaks under a strict Content-Security-Policy.",
|
|
1529
|
+
applies: (c) => c.javascriptUrls.length > 0,
|
|
1530
|
+
bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1134
1533
|
// src/rules/index.ts
|
|
1135
1534
|
var allRules = [
|
|
1136
1535
|
seo001Title,
|
|
@@ -1163,7 +1562,18 @@ var allRules = [
|
|
|
1163
1562
|
seo024Charset,
|
|
1164
1563
|
seo025ImageAlt,
|
|
1165
1564
|
seo026Hreflang,
|
|
1166
|
-
seo027Heading
|
|
1565
|
+
seo027Heading,
|
|
1566
|
+
perf005LcpImage,
|
|
1567
|
+
perf006ResponsiveImage,
|
|
1568
|
+
perf007RenderBlockingScript,
|
|
1569
|
+
perf008Preconnect,
|
|
1570
|
+
seo028TitleUnique,
|
|
1571
|
+
seo029DescriptionUnique,
|
|
1572
|
+
seo030HeadingOrder,
|
|
1573
|
+
correct001EachKey,
|
|
1574
|
+
correct002EffectDerived,
|
|
1575
|
+
sec001Html,
|
|
1576
|
+
sec002JavascriptUrl
|
|
1167
1577
|
];
|
|
1168
1578
|
function explainRule(id) {
|
|
1169
1579
|
const target = id.toUpperCase();
|
|
@@ -1299,9 +1709,11 @@ var SEVERITY_TITLE = {
|
|
|
1299
1709
|
};
|
|
1300
1710
|
var CATEGORY_LABEL = {
|
|
1301
1711
|
seo: "SEO",
|
|
1302
|
-
performance: "Performance"
|
|
1712
|
+
performance: "Performance",
|
|
1713
|
+
correctness: "Correctness",
|
|
1714
|
+
security: "Security"
|
|
1303
1715
|
};
|
|
1304
|
-
var CATEGORY_ORDER = ["seo", "performance"];
|
|
1716
|
+
var CATEGORY_ORDER = ["seo", "performance", "correctness", "security"];
|
|
1305
1717
|
function scoreLine(label, { score, scoreModel }) {
|
|
1306
1718
|
const parts = [`route avg ${scoreModel.routeAverage}`];
|
|
1307
1719
|
if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
|
|
@@ -1754,6 +2166,8 @@ export {
|
|
|
1754
2166
|
classify,
|
|
1755
2167
|
computeHealth,
|
|
1756
2168
|
computeScore,
|
|
2169
|
+
correct001EachKey,
|
|
2170
|
+
correct002EffectDerived,
|
|
1757
2171
|
defaultConfig,
|
|
1758
2172
|
defaultProject,
|
|
1759
2173
|
defineConfig,
|
|
@@ -1776,10 +2190,16 @@ export {
|
|
|
1776
2190
|
perf002ImageLoading,
|
|
1777
2191
|
perf003PreloadAs,
|
|
1778
2192
|
perf004FontPreloadCrossorigin,
|
|
2193
|
+
perf005LcpImage,
|
|
2194
|
+
perf006ResponsiveImage,
|
|
2195
|
+
perf007RenderBlockingScript,
|
|
2196
|
+
perf008Preconnect,
|
|
1779
2197
|
runRules,
|
|
1780
2198
|
safeHref,
|
|
1781
2199
|
scoreBand,
|
|
1782
2200
|
scoresByCategory,
|
|
2201
|
+
sec001Html,
|
|
2202
|
+
sec002JavascriptUrl,
|
|
1783
2203
|
selectRules,
|
|
1784
2204
|
seo001Title,
|
|
1785
2205
|
seo002Description,
|
|
@@ -1808,5 +2228,8 @@ export {
|
|
|
1808
2228
|
seo025ImageAlt,
|
|
1809
2229
|
seo026Hreflang,
|
|
1810
2230
|
seo027Heading,
|
|
2231
|
+
seo028TitleUnique,
|
|
2232
|
+
seo029DescriptionUnique,
|
|
2233
|
+
seo030HeadingOrder,
|
|
1811
2234
|
summarize
|
|
1812
2235
|
};
|