@svelte-vitals/core 0.15.0 → 0.18.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' | 'architecture';
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. */
@@ -198,6 +198,60 @@ interface ResolvedHeadings {
198
198
  headings: HeadingInfo[];
199
199
  }
200
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
+ /** True when this $effect has a NON-EMPTY body that reads no reactive value and makes no bare call — it never re-runs, so it should be onMount (CORRECT003). */
220
+ mountOnly: boolean;
221
+ }
222
+ /** A flagged source position in a component (e.g. an `{@html}` tag or a `javascript:` URL). */
223
+ interface SourceSpan {
224
+ /** 1-based source line, or 0 if unknown. */
225
+ line: number;
226
+ }
227
+ /** Reactivity/correctness + security + architecture facts parsed from one `.svelte` component. */
228
+ interface ComponentFacts {
229
+ /** Source file the component came from. */
230
+ file: string;
231
+ eachBlocks: EachBlockFact[];
232
+ effects: EffectFact[];
233
+ /** `{@html …}` occurrences — raw-HTML render surfaces (Security SEC001). */
234
+ htmlTags: SourceSpan[];
235
+ /** Element attributes with a literal `javascript:` URL (Security SEC002). */
236
+ javascriptUrls: SourceSpan[];
237
+ /** Source line count of the component file (Architecture ARCH001). */
238
+ loc: number;
239
+ /** Named props destructured from `$props()`; 0 when unknowable (rest / non-destructured) (Architecture ARCH002). */
240
+ propCount: number;
241
+ /** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */
242
+ imports: string[];
243
+ /** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — Bundle PERF010. */
244
+ namespaceImports: {
245
+ source: string;
246
+ line: number;
247
+ }[];
248
+ /** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */
249
+ constableStates: {
250
+ name: string;
251
+ line: number;
252
+ }[];
253
+ }
254
+
201
255
  /**
202
256
  * Source-file locations that satisfy the project-scope rules, shared by every
203
257
  * mode so the static (CLI) and rendered (plugin) collectors never drift. This
@@ -215,6 +269,8 @@ interface RuleContext {
215
269
  images?: ResolvedImages[];
216
270
  /** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
217
271
  headings?: ResolvedHeadings[];
272
+ /** Per-file component-body facts for Correctness rules (static/CLI mode only). */
273
+ components?: ComponentFacts[];
218
274
  project: Project;
219
275
  config: Config;
220
276
  }
@@ -282,9 +338,9 @@ declare const perf004FontPreloadCrossorigin: Rule;
282
338
 
283
339
  /**
284
340
  * 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>).
341
+ * image delays it. Analysis approximates the LCP as the first <img> in document
342
+ * order for the route; if that image is loading="lazy", flag it. Runs in both
343
+ * static (CLI) and rendered (vite) mode, since both providers collect <img>.
288
344
  */
289
345
  declare const perf005LcpImage: Rule;
290
346
 
@@ -330,8 +386,8 @@ declare const seo023DescriptionLength: Rule;
330
386
  declare const seo024Charset: Rule;
331
387
 
332
388
  /**
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).
389
+ * SEO025 — Image alt text. Reuses the <img> collection from both providers — the
390
+ * static (CLI) source parser and the rendered (vite) HTML parser — like PERF001/002.
335
391
  * Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
336
392
  * passes; a spread `{...rest}` may supply alt, so it is not flagged.
337
393
  */
@@ -353,6 +409,33 @@ declare const seo026Hreflang: Rule;
353
409
  */
354
410
  declare const seo027Heading: Rule;
355
411
 
412
+ declare const seo028TitleUnique: Rule;
413
+ declare const seo029DescriptionUnique: Rule;
414
+
415
+ /**
416
+ * SEO030 — Skipped heading level. Walking a route's body headings in document
417
+ * order, a level that jumps more than +1 over the previous heading (e.g. h2 → h4)
418
+ * breaks the outline. The first heading has no predecessor (missing/multiple
419
+ * <h1> stays SEO027's concern). A route with no headings emits nothing.
420
+ */
421
+ declare const seo030HeadingOrder: Rule;
422
+
423
+ declare const correct001EachKey: Rule;
424
+ declare const correct002EffectDerived: Rule;
425
+ declare const correct003EffectAsOnMount: Rule;
426
+
427
+ declare const correct004UnmutatedState: Rule;
428
+
429
+ declare const sec001Html: Rule;
430
+ declare const sec002JavascriptUrl: Rule;
431
+
432
+ declare const arch001ComponentSize: Rule;
433
+ declare const arch002PropCount: Rule;
434
+
435
+ declare const perf009HeavyImport: Rule;
436
+
437
+ declare const perf010NamespaceImport: Rule;
438
+
356
439
  declare const allRules: Rule[];
357
440
 
358
441
  interface RuleInfo {
@@ -443,10 +526,26 @@ declare function summarize(results: Result[], config: Config): Summary;
443
526
  /** Whether the run should fail the build/CI per the minimum failing severity. */
444
527
  declare function hasFailureAtOrAbove(summary: Summary, min: Severity): boolean;
445
528
 
529
+ /** String decorators for the console reporter. Injected so core stays pure/dep-free. */
530
+ interface Palette {
531
+ bold: (s: string) => string;
532
+ dim: (s: string) => string;
533
+ red: (s: string) => string;
534
+ yellow: (s: string) => string;
535
+ green: (s: string) => string;
536
+ cyan: (s: string) => string;
537
+ }
538
+ /** Default: no decoration (identity) — output is byte-identical to plain text. */
539
+ declare const noColorPalette: Palette;
540
+ /** Green ≥ 90, yellow ≥ 70, red otherwise — for a 0–100 score. */
541
+ declare function scoreColor(p: Palette, score: number): (s: string) => string;
542
+
446
543
  interface ConsoleReportOptions {
447
544
  byRoute?: boolean;
448
545
  /** Mode label shown in the header (default 'static mode'). */
449
546
  mode?: string;
547
+ /** Color decorators; defaults to no color. */
548
+ palette?: Palette;
450
549
  }
451
550
  /**
452
551
  * Render results as a console report string (design §7). Pure: returns a string,
@@ -561,4 +660,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
561
660
  /** Apply per-rule severity overrides to results (design §6). */
562
661
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
563
662
 
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 };
663
+ 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 Palette, 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, arch001ComponentSize, arch002PropCount, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, correct004UnmutatedState, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, noColorPalette, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, perf005LcpImage, perf006ResponsiveImage, perf007RenderBlockingScript, perf008Preconnect, perf009HeavyImport, perf010NamespaceImport, runRules, safeHref, scoreBand, scoreColor, 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 docsUrl6 = 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: docsUrl6,
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 docsUrl6 = 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: docsUrl6
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: docsUrl6,
338
+ docsUrl: docsUrl7,
339
339
  ...opts.fix ? { fix: { ...opts.fix } } : {}
340
340
  });
341
341
  }
@@ -391,7 +391,7 @@ var perf006ResponsiveImage = imageRule({
391
391
 
392
392
  // src/rules/perf/link-rule.ts
393
393
  function linkRule(opts) {
394
- const docsUrl6 = docsUrlFor(opts.id);
394
+ const docsUrl7 = docsUrlFor(opts.id);
395
395
  return {
396
396
  id: opts.id,
397
397
  title: opts.title,
@@ -415,7 +415,7 @@ function linkRule(opts) {
415
415
  route: head.route,
416
416
  message: opts.label,
417
417
  recommendation: opts.recommendation,
418
- docsUrl: docsUrl6
418
+ docsUrl: docsUrl7
419
419
  });
420
420
  continue;
421
421
  }
@@ -432,7 +432,7 @@ function linkRule(opts) {
432
432
  location: tag.file ?? head.file,
433
433
  message: `Missing ${opts.label}`,
434
434
  recommendation: opts.recommendation,
435
- docsUrl: docsUrl6,
435
+ docsUrl: docsUrl7,
436
436
  ...opts.fix ? { fix: { ...opts.fix } } : {}
437
437
  });
438
438
  }
@@ -658,7 +658,7 @@ var seo010Indexability = {
658
658
  rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
659
659
  fix: SEO010_FIX,
660
660
  async check(ctx) {
661
- const docsUrl6 = docsUrlFor("SEO010");
661
+ const docsUrl7 = docsUrlFor("SEO010");
662
662
  const out = [];
663
663
  for (const head of ctx.heads) {
664
664
  const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
@@ -673,7 +673,7 @@ var seo010Indexability = {
673
673
  location: head.file,
674
674
  message: "Route is noindex \u2014 verify this is intentional",
675
675
  recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
676
- docsUrl: docsUrl6,
676
+ docsUrl: docsUrl7,
677
677
  fix: { ...SEO010_FIX }
678
678
  });
679
679
  }
@@ -928,7 +928,7 @@ var seo016JsonLdValidity = {
928
928
  lang: "svelte"
929
929
  },
930
930
  async check(ctx) {
931
- const docsUrl6 = docsUrlFor("SEO016");
931
+ const docsUrl7 = docsUrlFor("SEO016");
932
932
  const out = [];
933
933
  for (const head of ctx.heads) {
934
934
  for (const tag of jsonldTags(head)) {
@@ -947,7 +947,7 @@ var seo016JsonLdValidity = {
947
947
  location: head.file,
948
948
  message: problem,
949
949
  recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
950
- docsUrl: docsUrl6,
950
+ docsUrl: docsUrl7,
951
951
  fix: { ...seo016JsonLdValidity.fix }
952
952
  } : {
953
953
  id: "SEO016",
@@ -957,7 +957,7 @@ var seo016JsonLdValidity = {
957
957
  route: head.route,
958
958
  message: "JSON-LD validity",
959
959
  recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
960
- docsUrl: docsUrl6
960
+ docsUrl: docsUrl7
961
961
  }
962
962
  );
963
963
  }
@@ -966,7 +966,7 @@ var seo016JsonLdValidity = {
966
966
  }
967
967
  };
968
968
  function jsonldRule(opts) {
969
- const docsUrl6 = docsUrlFor(opts.id);
969
+ const docsUrl7 = docsUrlFor(opts.id);
970
970
  return {
971
971
  id: opts.id,
972
972
  title: opts.title,
@@ -994,7 +994,7 @@ function jsonldRule(opts) {
994
994
  location: head.file,
995
995
  message: problem,
996
996
  recommendation: opts.recommendation,
997
- docsUrl: docsUrl6,
997
+ docsUrl: docsUrl7,
998
998
  ...opts.fix ? { fix: { ...opts.fix } } : {}
999
999
  } : {
1000
1000
  id: opts.id,
@@ -1004,7 +1004,7 @@ function jsonldRule(opts) {
1004
1004
  route: head.route,
1005
1005
  message: opts.label,
1006
1006
  recommendation: opts.recommendation,
1007
- docsUrl: docsUrl6
1007
+ docsUrl: docsUrl7
1008
1008
  }
1009
1009
  );
1010
1010
  }
@@ -1095,15 +1095,18 @@ var seo021RequiredProps = jsonldRule({
1095
1095
 
1096
1096
  // src/rules/seo/text-metrics.ts
1097
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
+ }
1098
1101
  function visibleLength(s) {
1099
- const collapsed = s.trim().replace(/\s+/g, " ");
1102
+ const collapsed = collapseWhitespace(s);
1100
1103
  if (!segmenter) return [...collapsed].length;
1101
1104
  return [...segmenter.segment(collapsed)].length;
1102
1105
  }
1103
1106
 
1104
1107
  // src/rules/seo/seo022-023.ts
1105
1108
  function lengthRule(opts) {
1106
- const docsUrl6 = docsUrlFor(opts.id);
1109
+ const docsUrl7 = docsUrlFor(opts.id);
1107
1110
  return {
1108
1111
  id: opts.id,
1109
1112
  title: opts.title,
@@ -1130,7 +1133,7 @@ function lengthRule(opts) {
1130
1133
  location: tag.file ?? head.file,
1131
1134
  message: problem,
1132
1135
  recommendation: opts.recommendation,
1133
- docsUrl: docsUrl6
1136
+ docsUrl: docsUrl7
1134
1137
  } : {
1135
1138
  id: opts.id,
1136
1139
  category: "seo",
@@ -1139,7 +1142,7 @@ function lengthRule(opts) {
1139
1142
  route: head.route,
1140
1143
  message: opts.label,
1141
1144
  recommendation: opts.recommendation,
1142
- docsUrl: docsUrl6
1145
+ docsUrl: docsUrl7
1143
1146
  }
1144
1147
  );
1145
1148
  }
@@ -1314,6 +1317,322 @@ var seo027Heading = {
1314
1317
  }
1315
1318
  };
1316
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
+ var correct003EffectAsOnMount = componentRule({
1511
+ id: "CORRECT003",
1512
+ title: "Effect used as onMount",
1513
+ category: "correctness",
1514
+ label: "$effect usage",
1515
+ recommendation: "Move mount-time side effects to onMount (import { onMount } from 'svelte'); reserve $effect for logic that reacts to $state/$derived/$props.",
1516
+ rationale: "An $effect that reads no reactive value runs once after mount and never re-runs \u2014 it is an onMount in disguise, which obscures intent and misuses the reactivity system.",
1517
+ applies: (c) => c.effects.length > 0,
1518
+ bad: (c) => c.effects.filter((e) => e.mountOnly).map((e) => ({ line: e.line, message: "$effect reads no reactive value \u2014 use onMount instead" }))
1519
+ });
1520
+
1521
+ // src/rules/correctness/correct004-unmutated-state.ts
1522
+ var correct004UnmutatedState = componentRule({
1523
+ id: "CORRECT004",
1524
+ title: "Unmutated $state",
1525
+ category: "correctness",
1526
+ severity: "info",
1527
+ label: "$state usage",
1528
+ recommendation: "If a value never changes, use const; if you only ever reassign it wholesale (never mutate its properties), use $state.raw to skip deep proxying.",
1529
+ rationale: "A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.",
1530
+ applies: (c) => c.constableStates.length > 0,
1531
+ bad: (c) => c.constableStates.map((s) => ({
1532
+ line: s.line,
1533
+ message: `$state "${s.name}" is never mutated \u2014 use const (or $state.raw if you only reassign it)`
1534
+ }))
1535
+ });
1536
+
1537
+ // src/rules/security/sec001-002.ts
1538
+ var sec001Html = componentRule({
1539
+ id: "SEC001",
1540
+ title: "Raw HTML render",
1541
+ category: "security",
1542
+ label: "{@html} usage",
1543
+ recommendation: "Sanitize the value before {@html} (e.g. DOMPurify), or render it as text/markup instead.",
1544
+ 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.",
1545
+ applies: (c) => c.htmlTags.length > 0,
1546
+ bad: (c) => c.htmlTags.map((h) => ({ line: h.line, message: "{@html} renders unescaped HTML \u2014 ensure it is sanitized" }))
1547
+ });
1548
+ var sec002JavascriptUrl = componentRule({
1549
+ id: "SEC002",
1550
+ title: "javascript: URL",
1551
+ category: "security",
1552
+ label: "No javascript: URLs",
1553
+ recommendation: "Use an event handler or a real URL instead of a javascript: URL.",
1554
+ 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.",
1555
+ applies: (c) => c.javascriptUrls.length > 0,
1556
+ bad: (c) => c.javascriptUrls.map((u) => ({ line: u.line, message: "javascript: URL in an attribute" }))
1557
+ });
1558
+
1559
+ // src/rules/architecture/arch001-002.ts
1560
+ var MAX_LOC = 400;
1561
+ var MAX_PROPS = 10;
1562
+ var arch001ComponentSize = componentRule({
1563
+ id: "ARCH001",
1564
+ title: "Component size",
1565
+ category: "architecture",
1566
+ severity: "info",
1567
+ label: "Component size",
1568
+ recommendation: `Split components over ${MAX_LOC} lines into smaller, focused pieces.`,
1569
+ rationale: "A very large component is hard to read, test, and reuse, and is a common sign that several responsibilities should be split out.",
1570
+ applies: (c) => c.loc > 0,
1571
+ // skip unanalyzable files (loc 0 = read/parse failure), don't PASS them
1572
+ bad: (c) => c.loc > MAX_LOC ? [{ line: 1, message: `Component is ${c.loc} lines (over ${MAX_LOC})` }] : []
1573
+ });
1574
+ var arch002PropCount = componentRule({
1575
+ id: "ARCH002",
1576
+ title: "Prop count",
1577
+ category: "architecture",
1578
+ severity: "info",
1579
+ label: "Prop count",
1580
+ recommendation: `Group related props into an object, or split the component, when it takes more than ${MAX_PROPS} props.`,
1581
+ rationale: "A component taking many props is usually doing too much; grouping or splitting keeps its API understandable.",
1582
+ applies: (c) => c.propCount > 0,
1583
+ // only components whose props we could count
1584
+ bad: (c) => c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
1585
+ });
1586
+
1587
+ // src/rules/performance/perf009-heavy-import.ts
1588
+ var HEAVY_PACKAGES = {
1589
+ lodash: "import a submodule (lodash/debounce) or use lodash-es for tree-shaking",
1590
+ moment: "use a lighter date library (date-fns or dayjs) \u2014 moment is large and not tree-shakeable"
1591
+ };
1592
+ var perf009HeavyImport = componentRule({
1593
+ id: "PERF009",
1594
+ title: "Heavy dependency import",
1595
+ category: "performance",
1596
+ severity: "info",
1597
+ label: "No heavy imports",
1598
+ recommendation: "Import a submodule or switch to a lighter, tree-shakeable alternative.",
1599
+ rationale: "Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.",
1600
+ applies: (c) => c.imports.length > 0,
1601
+ bad: (c) => {
1602
+ const seen = /* @__PURE__ */ new Set();
1603
+ const out = [];
1604
+ for (const src of c.imports) {
1605
+ if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue;
1606
+ seen.add(src);
1607
+ out.push({ line: 0, message: `Heavy import "${src}" \u2014 ${HEAVY_PACKAGES[src]}` });
1608
+ }
1609
+ return out;
1610
+ }
1611
+ });
1612
+
1613
+ // src/rules/performance/perf010-namespace-import.ts
1614
+ var perf010NamespaceImport = componentRule({
1615
+ id: "PERF010",
1616
+ title: "Namespace import",
1617
+ category: "performance",
1618
+ severity: "info",
1619
+ label: "No namespace imports",
1620
+ recommendation: "Use named imports (import { x } from 'pkg') instead of import * as \u2014 a namespace import keeps the whole module in the bundle.",
1621
+ rationale: "A namespace import (import * as X) forces the bundler to retain the entire module, so unused exports cannot be tree-shaken out.",
1622
+ applies: (c) => c.namespaceImports.length > 0,
1623
+ bad: (c) => {
1624
+ const minLine = /* @__PURE__ */ new Map();
1625
+ for (const ns of c.namespaceImports) {
1626
+ const prev = minLine.get(ns.source);
1627
+ if (prev === void 0 || ns.line < prev) minLine.set(ns.source, ns.line);
1628
+ }
1629
+ return [...minLine.entries()].sort((a, b) => a[1] - b[1]).map(([source, line]) => ({
1630
+ line,
1631
+ message: `Namespace import "* as \u2026 from '${source}'" \u2014 prefer named imports so the bundler can tree-shake`
1632
+ }));
1633
+ }
1634
+ });
1635
+
1317
1636
  // src/rules/index.ts
1318
1637
  var allRules = [
1319
1638
  seo001Title,
@@ -1350,7 +1669,20 @@ var allRules = [
1350
1669
  perf005LcpImage,
1351
1670
  perf006ResponsiveImage,
1352
1671
  perf007RenderBlockingScript,
1353
- perf008Preconnect
1672
+ perf008Preconnect,
1673
+ seo028TitleUnique,
1674
+ seo029DescriptionUnique,
1675
+ seo030HeadingOrder,
1676
+ correct001EachKey,
1677
+ correct002EffectDerived,
1678
+ correct003EffectAsOnMount,
1679
+ correct004UnmutatedState,
1680
+ sec001Html,
1681
+ sec002JavascriptUrl,
1682
+ arch001ComponentSize,
1683
+ arch002PropCount,
1684
+ perf009HeavyImport,
1685
+ perf010NamespaceImport
1354
1686
  ];
1355
1687
  function explainRule(id) {
1356
1688
  const target = id.toUpperCase();
@@ -1477,6 +1809,21 @@ function computeHealth(results, config) {
1477
1809
  return { health, categories, weights };
1478
1810
  }
1479
1811
 
1812
+ // src/reporter/palette.ts
1813
+ var noColorPalette = {
1814
+ bold: (s) => s,
1815
+ dim: (s) => s,
1816
+ red: (s) => s,
1817
+ yellow: (s) => s,
1818
+ green: (s) => s,
1819
+ cyan: (s) => s
1820
+ };
1821
+ function scoreColor(p, score) {
1822
+ if (score >= 90) return p.green;
1823
+ if (score >= 70) return p.yellow;
1824
+ return p.red;
1825
+ }
1826
+
1480
1827
  // src/reporter/console.ts
1481
1828
  var RULE = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500";
1482
1829
  var SEVERITY_TITLE = {
@@ -1486,66 +1833,79 @@ var SEVERITY_TITLE = {
1486
1833
  };
1487
1834
  var CATEGORY_LABEL = {
1488
1835
  seo: "SEO",
1489
- performance: "Performance"
1836
+ performance: "Performance",
1837
+ correctness: "Correctness",
1838
+ security: "Security",
1839
+ architecture: "Architecture"
1490
1840
  };
1491
- var CATEGORY_ORDER = ["seo", "performance"];
1492
- function scoreLine(label, { score, scoreModel }) {
1841
+ var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
1842
+ function scoreLine(p, label, { score, scoreModel }) {
1493
1843
  const parts = [`route avg ${scoreModel.routeAverage}`];
1494
1844
  if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
1495
1845
  if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
1496
- return `${label} Score: ${score}/100 (${parts.join(" \xB7 ")})`;
1846
+ return `${label} Score: ${scoreColor(p, score)(`${score}/100`)} ${p.dim(`(${parts.join(" \xB7 ")})`)}`;
1497
1847
  }
1498
- function byRouteTree(results, config) {
1848
+ function byRouteTree(p, results, config) {
1499
1849
  const routes = /* @__PURE__ */ new Map();
1500
1850
  for (const r of results) {
1501
1851
  if (r.route === void 0) continue;
1502
1852
  if (!routes.has(r.route)) routes.set(r.route, []);
1503
1853
  routes.get(r.route).push(r);
1504
1854
  }
1505
- const lines = ["By route", RULE];
1855
+ const lines = [p.bold("By route"), p.dim(RULE)];
1506
1856
  for (const [route, rs] of [...routes.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1507
1857
  const { score } = computeScore(rs, config, { applyCriticalCap: false });
1508
- lines.push(`${route.padEnd(28)} ${score}`);
1858
+ lines.push(`${route.padEnd(28)} ${scoreColor(p, score)(`${score}`)}`);
1509
1859
  for (const r of rs.filter((x) => classify(x, config) === "fail")) {
1510
- lines.push(` \u2717 ${r.id} ${r.message}`);
1860
+ lines.push(` ${p.red("\u2717")} ${r.id} ${r.message}`);
1511
1861
  }
1512
1862
  }
1513
1863
  lines.push("");
1514
1864
  return lines;
1515
1865
  }
1516
1866
  function formatConsoleReport(results, config, options = {}) {
1867
+ const p = options.palette ?? noColorPalette;
1517
1868
  const summary = summarize(results, config);
1518
1869
  const { health, categories: byCat } = computeHealth(results, config);
1519
1870
  const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
1520
- const header = [`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`, "", `Health: ${health}/100`];
1871
+ const header = [
1872
+ p.bold(`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`),
1873
+ "",
1874
+ `${p.bold("Health:")} ${scoreColor(p, health)(`${health}/100`)}`
1875
+ ];
1521
1876
  for (const c of present2) {
1522
- header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]));
1877
+ header.push(scoreLine(p, CATEGORY_LABEL[c] ?? c, byCat[c]));
1523
1878
  }
1524
1879
  const lines = [...header, ""];
1880
+ const SEVERITY_COLOR = {
1881
+ critical: (s) => p.red(p.bold(s)),
1882
+ warning: (s) => p.yellow(p.bold(s)),
1883
+ info: (s) => p.dim(s)
1884
+ };
1525
1885
  const failures = results.filter((r) => classify(r, config) === "fail");
1526
1886
  for (const severity of ["critical", "warning", "info"]) {
1527
1887
  const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
1528
1888
  if (bucket.length === 0) continue;
1529
- lines.push(`${SEVERITY_TITLE[severity]} (${bucket.length})`, RULE);
1889
+ lines.push(SEVERITY_COLOR[severity](`${SEVERITY_TITLE[severity]} (${bucket.length})`), p.dim(RULE));
1530
1890
  for (const r of bucket) {
1531
- lines.push(`\u2717 ${r.id} ${r.message}`);
1532
- if (r.route) lines.push(` ${r.route}`);
1533
- if (r.location) lines.push(` ${r.location}${r.line ? `:${r.line}` : ""}`);
1891
+ lines.push(`${p.red("\u2717")} ${r.id} ${r.message}`);
1892
+ if (r.route) lines.push(p.dim(` ${r.route}`));
1893
+ if (r.location) lines.push(p.dim(` ${r.location}${r.line ? `:${r.line}` : ""}`));
1534
1894
  }
1535
1895
  lines.push("");
1536
1896
  }
1537
1897
  const passed = results.filter((r) => classify(r, config) !== "fail");
1538
1898
  if (passed.length > 0) {
1539
- lines.push(`Passed (${passed.length})`, RULE);
1899
+ lines.push(p.bold(`Passed (${passed.length})`), p.dim(RULE));
1540
1900
  for (const r of passed) {
1541
- const marker = classify(r, config) === "dynamic" ? " \u21AF dynamic" : "";
1901
+ const marker = classify(r, config) === "dynamic" ? p.cyan(" \u21AF dynamic") : "";
1542
1902
  const route = r.route ? ` ${r.route}` : "";
1543
- lines.push(`\u2713 ${r.id} ${r.message}${marker}${route}`);
1903
+ lines.push(`${p.green("\u2713")} ${r.id} ${r.message}${marker}${route}`);
1544
1904
  }
1545
1905
  lines.push("");
1546
1906
  }
1547
- if (options.byRoute) lines.push(...byRouteTree(results, config));
1548
- if (summary.dynamic > 0) lines.push("\u21AF = set dynamically (verified at runtime).");
1907
+ if (options.byRoute) lines.push(...byRouteTree(p, results, config));
1908
+ if (summary.dynamic > 0) lines.push(p.dim("\u21AF = set dynamically (verified at runtime)."));
1549
1909
  return lines.join("\n").replace(/\n+$/, "\n");
1550
1910
  }
1551
1911
 
@@ -1936,11 +2296,17 @@ export {
1936
2296
  SITEMAP_SOURCE_PATHS,
1937
2297
  allRules,
1938
2298
  applyRuleSeverities,
2299
+ arch001ComponentSize,
2300
+ arch002PropCount,
1939
2301
  buildHtmlDocument,
1940
2302
  buildJsonReport,
1941
2303
  classify,
1942
2304
  computeHealth,
1943
2305
  computeScore,
2306
+ correct001EachKey,
2307
+ correct002EffectDerived,
2308
+ correct003EffectAsOnMount,
2309
+ correct004UnmutatedState,
1944
2310
  defaultConfig,
1945
2311
  defaultProject,
1946
2312
  defineConfig,
@@ -1959,6 +2325,7 @@ export {
1959
2325
  imageRule,
1960
2326
  isPenalized,
1961
2327
  linkRule,
2328
+ noColorPalette,
1962
2329
  perf001ImageDimensions,
1963
2330
  perf002ImageLoading,
1964
2331
  perf003PreloadAs,
@@ -1967,10 +2334,15 @@ export {
1967
2334
  perf006ResponsiveImage,
1968
2335
  perf007RenderBlockingScript,
1969
2336
  perf008Preconnect,
2337
+ perf009HeavyImport,
2338
+ perf010NamespaceImport,
1970
2339
  runRules,
1971
2340
  safeHref,
1972
2341
  scoreBand,
2342
+ scoreColor,
1973
2343
  scoresByCategory,
2344
+ sec001Html,
2345
+ sec002JavascriptUrl,
1974
2346
  selectRules,
1975
2347
  seo001Title,
1976
2348
  seo002Description,
@@ -1999,5 +2371,8 @@ export {
1999
2371
  seo025ImageAlt,
2000
2372
  seo026Hreflang,
2001
2373
  seo027Heading,
2374
+ seo028TitleUnique,
2375
+ seo029DescriptionUnique,
2376
+ seo030HeadingOrder,
2002
2377
  summarize
2003
2378
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "homepage": "https://github.com/oekazuma/svelte-vitals#readme",
23
23
  "engines": {
24
- "node": ">=18"
24
+ "node": ">=18.20.8"
25
25
  },
26
26
  "sideEffects": false,
27
27
  "exports": {