@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 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 (CLI/static only; rendered
300
- * mode does not collect images, so the rule no-ops there, like PERF001/002).
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 docsUrl3 = docsUrlFor(opts.id);
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: docsUrl3,
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 docsUrl3 = docsUrlFor(opts.id);
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: docsUrl3
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: docsUrl3,
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 docsUrl3 = docsUrlFor(opts.id);
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: docsUrl3
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: docsUrl3,
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 docsUrl3 = docsUrlFor("SEO010");
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: docsUrl3,
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 docsUrl3 = docsUrlFor("SEO016");
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: docsUrl3,
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: docsUrl3
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 docsUrl3 = docsUrlFor(opts.id);
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: docsUrl3,
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: docsUrl3
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 = s.trim().replace(/\s+/g, " ");
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 docsUrl3 = docsUrlFor(opts.id);
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: docsUrl3
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: docsUrl3
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 docsUrl = docsUrlFor("SEO026");
1026
- var recommendation = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
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 docsUrl2 = docsUrlFor("SEO027");
1084
- var recommendation2 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
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: recommendation2,
1117
- docsUrl: docsUrl2
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: recommendation2,
1126
- docsUrl: docsUrl2
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",