@svelte-vitals/core 0.15.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. */
@@ -198,6 +198,42 @@ 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
+ }
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
+
201
237
  /**
202
238
  * Source-file locations that satisfy the project-scope rules, shared by every
203
239
  * mode so the static (CLI) and rendered (plugin) collectors never drift. This
@@ -215,6 +251,8 @@ interface RuleContext {
215
251
  images?: ResolvedImages[];
216
252
  /** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
217
253
  headings?: ResolvedHeadings[];
254
+ /** Per-file component-body facts for Correctness rules (static/CLI mode only). */
255
+ components?: ComponentFacts[];
218
256
  project: Project;
219
257
  config: Config;
220
258
  }
@@ -282,9 +320,9 @@ declare const perf004FontPreloadCrossorigin: Rule;
282
320
 
283
321
  /**
284
322
  * 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>).
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>.
288
326
  */
289
327
  declare const perf005LcpImage: Rule;
290
328
 
@@ -330,8 +368,8 @@ declare const seo023DescriptionLength: Rule;
330
368
  declare const seo024Charset: Rule;
331
369
 
332
370
  /**
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).
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.
335
373
  * Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
336
374
  * passes; a spread `{...rest}` may supply alt, so it is not flagged.
337
375
  */
@@ -353,6 +391,23 @@ declare const seo026Hreflang: Rule;
353
391
  */
354
392
  declare const seo027Heading: Rule;
355
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
+
356
411
  declare const allRules: Rule[];
357
412
 
358
413
  interface RuleInfo {
@@ -561,4 +616,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
561
616
  /** Apply per-rule severity overrides to results (design §6). */
562
617
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
563
618
 
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 };
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 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,219 @@ 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
+
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
+
1317
1533
  // src/rules/index.ts
1318
1534
  var allRules = [
1319
1535
  seo001Title,
@@ -1350,7 +1566,14 @@ var allRules = [
1350
1566
  perf005LcpImage,
1351
1567
  perf006ResponsiveImage,
1352
1568
  perf007RenderBlockingScript,
1353
- perf008Preconnect
1569
+ perf008Preconnect,
1570
+ seo028TitleUnique,
1571
+ seo029DescriptionUnique,
1572
+ seo030HeadingOrder,
1573
+ correct001EachKey,
1574
+ correct002EffectDerived,
1575
+ sec001Html,
1576
+ sec002JavascriptUrl
1354
1577
  ];
1355
1578
  function explainRule(id) {
1356
1579
  const target = id.toUpperCase();
@@ -1486,9 +1709,11 @@ var SEVERITY_TITLE = {
1486
1709
  };
1487
1710
  var CATEGORY_LABEL = {
1488
1711
  seo: "SEO",
1489
- performance: "Performance"
1712
+ performance: "Performance",
1713
+ correctness: "Correctness",
1714
+ security: "Security"
1490
1715
  };
1491
- var CATEGORY_ORDER = ["seo", "performance"];
1716
+ var CATEGORY_ORDER = ["seo", "performance", "correctness", "security"];
1492
1717
  function scoreLine(label, { score, scoreModel }) {
1493
1718
  const parts = [`route avg ${scoreModel.routeAverage}`];
1494
1719
  if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
@@ -1941,6 +2166,8 @@ export {
1941
2166
  classify,
1942
2167
  computeHealth,
1943
2168
  computeScore,
2169
+ correct001EachKey,
2170
+ correct002EffectDerived,
1944
2171
  defaultConfig,
1945
2172
  defaultProject,
1946
2173
  defineConfig,
@@ -1971,6 +2198,8 @@ export {
1971
2198
  safeHref,
1972
2199
  scoreBand,
1973
2200
  scoresByCategory,
2201
+ sec001Html,
2202
+ sec002JavascriptUrl,
1974
2203
  selectRules,
1975
2204
  seo001Title,
1976
2205
  seo002Description,
@@ -1999,5 +2228,8 @@ export {
1999
2228
  seo025ImageAlt,
2000
2229
  seo026Hreflang,
2001
2230
  seo027Heading,
2231
+ seo028TitleUnique,
2232
+ seo029DescriptionUnique,
2233
+ seo030HeadingOrder,
2002
2234
  summarize
2003
2235
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.15.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",