@svelte-vitals/core 0.7.0 → 0.9.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
@@ -47,6 +47,10 @@ interface Result {
47
47
  docsUrl?: string;
48
48
  /** Agent-actionable remediation (issue #18). */
49
49
  fix?: Fix;
50
+ /** Vitals category this finding belongs to (default 'seo' when absent). */
51
+ category?: Category;
52
+ /** 1-based source line for element-level findings (e.g. a specific <img>). */
53
+ line?: number;
50
54
  }
51
55
  type Scope = 'route' | 'project';
52
56
  type Category = 'seo' | 'performance' | 'a11y' | 'maintainability';
@@ -62,6 +66,8 @@ interface Config {
62
66
  rules: Record<string, RuleSetting>;
63
67
  /** Minimum severity that fails the run / CI (design §6). */
64
68
  failOn: Severity;
69
+ /** Per-category weights for the combined Health score (default: equal, 1 each) (#10). */
70
+ weights?: Partial<Record<Category, number>>;
65
71
  }
66
72
  declare const defaultConfig: Config;
67
73
  /** Merge user config over defaults. Identity helper for config files (design §6). */
@@ -127,6 +133,26 @@ interface HeadProvider {
127
133
  collect(rt: Runtime, cwd: string, config?: Config): Promise<ResolvedHead[]>;
128
134
  }
129
135
 
136
+ /**
137
+ * A normalized <img> occurrence — the mode-independent boundary for Performance
138
+ * rules (mirrors head.ts). Attribute presence only: a dynamically-bound attribute
139
+ * (width={w}) still counts as present, so dynamic values are never flagged.
140
+ */
141
+ interface ImageInfo {
142
+ hasWidth: boolean;
143
+ hasHeight: boolean;
144
+ hasLoading: boolean;
145
+ /** 1-based source line, or 0 if unknown. */
146
+ line: number;
147
+ /** Source file the <img> came from. */
148
+ file: string;
149
+ }
150
+ /** Resolved <img> elements for a single route (page + layout chain). */
151
+ interface ResolvedImages {
152
+ route: string;
153
+ images: ImageInfo[];
154
+ }
155
+
130
156
  /**
131
157
  * Source-file locations that satisfy the project-scope rules, shared by every
132
158
  * mode so the static (CLI) and rendered (plugin) collectors never drift. This
@@ -140,6 +166,8 @@ declare const SITEMAP_SOURCE_PATHS: readonly ["static/sitemap.xml", "src/routes/
140
166
  /** Input given to every rule. Mode-independent: rules see only ResolvedHead[] (design §8, §10). */
141
167
  interface RuleContext {
142
168
  heads: ResolvedHead[];
169
+ /** Per-route <img> elements for Performance rules (absent in modes that don't collect them). */
170
+ images?: ResolvedImages[];
143
171
  project: Project;
144
172
  config: Config;
145
173
  }
@@ -198,6 +226,9 @@ declare const seo006Robots: Rule;
198
226
  declare const seo007Sitemap: Rule;
199
227
  declare const seo009HtmlLang: Rule;
200
228
 
229
+ declare const perf001ImageDimensions: Rule;
230
+ declare const perf002ImageLoading: Rule;
231
+
201
232
  declare const allRules: Rule[];
202
233
 
203
234
  interface RuleInfo {
@@ -229,6 +260,21 @@ interface HeadTagRuleOptions {
229
260
  /** Build a route-scope rule asserting the presence of a single head tag (design §11). */
230
261
  declare function headTagRule(opts: HeadTagRuleOptions): Rule;
231
262
 
263
+ interface ImageRuleOptions {
264
+ id: string;
265
+ title: string;
266
+ severity: Severity;
267
+ /** Noun phrase for messages, e.g. '<img> width/height'. */
268
+ label: string;
269
+ recommendation: string;
270
+ rationale: string;
271
+ fix?: Fix;
272
+ /** Returns true when the image satisfies the rule (passes). */
273
+ ok: (img: ImageInfo) => boolean;
274
+ }
275
+ /** Build a route-scoped Performance rule that checks each <img> against `ok` (issue #10). */
276
+ declare function imageRule(opts: ImageRuleOptions): Rule;
277
+
232
278
  interface Summary {
233
279
  critical: number;
234
280
  warning: number;
@@ -274,15 +320,28 @@ interface ScoreOptions {
274
320
  }
275
321
  /** Compute the headline score and its breakdown (design §12). */
276
322
  declare function computeScore(results: Result[], config: Config, options?: ScoreOptions): ScoreResult;
323
+ /** Compute an independent score per category present in `results` (issue #10). */
324
+ declare function scoresByCategory(results: Result[], config: Config): Partial<Record<Category, ScoreResult>>;
325
+ interface HealthResult {
326
+ /** Weighted overall score across present categories (0–100). */
327
+ health: number;
328
+ categories: Partial<Record<Category, ScoreResult>>;
329
+ /** Effective weight used per present category. */
330
+ weights: Partial<Record<Category, number>>;
331
+ }
332
+ /** Combined weighted Health score over the categories present in `results` (#10). */
333
+ declare function computeHealth(results: Result[], config: Config): HealthResult;
277
334
 
278
335
  declare function issueOf(result: Result): {
279
336
  fix?: Fix | undefined;
280
337
  docsUrl?: string | undefined;
338
+ recommendation: string | undefined;
339
+ line?: number | undefined;
281
340
  id: string;
341
+ category: Category;
282
342
  title: string;
283
343
  detection: Detection;
284
344
  location: string | undefined;
285
- recommendation: string | undefined;
286
345
  };
287
346
  type JsonIssue = ReturnType<typeof issueOf> & {
288
347
  severity: ReturnType<typeof effectiveSeverity>;
@@ -290,7 +349,11 @@ type JsonIssue = ReturnType<typeof issueOf> & {
290
349
  interface JsonReport {
291
350
  version: string;
292
351
  score: number;
293
- scoreModel: ScoreModel;
352
+ weights: Partial<Record<Category, number>>;
353
+ categories: Record<string, {
354
+ score: number;
355
+ scoreModel: ScoreModel;
356
+ }>;
294
357
  summary: Summary;
295
358
  routes: Array<{
296
359
  route: string;
@@ -327,4 +390,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
327
390
  /** Apply per-rule severity overrides to results (design §6). */
328
391
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
329
392
 
330
- export { type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, 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, buildJsonReport, classify, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, isPenalized, runRules, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, summarize };
393
+ export { type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, type HealthResult, type ImageInfo, type JsonReport, type Presence, type Project, ROBOTS_SOURCE_PATHS, type ResolvedHead, type ResolvedImages, type Result, type Rule, type RuleContext, type RuleInfo, type RuleSetting, type Runtime, SITEMAP_SOURCE_PATHS, type Scope, type ScoreModel, type ScoreOptions, type ScoreResult, type Severity, type Summary, type TreatDynamicAs, type Value, allRules, applyRuleSeverities, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, perf001ImageDimensions, perf002ImageLoading, runRules, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, summarize };
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ var seo001Title = {
74
74
  const detection = detectTitle(head);
75
75
  return {
76
76
  id: "SEO001",
77
+ category: "seo",
77
78
  severity: "critical",
78
79
  detection,
79
80
  route: head.route,
@@ -108,6 +109,7 @@ function headTagRule(opts) {
108
109
  const message = detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
109
110
  return {
110
111
  id: opts.id,
112
+ category: "seo",
111
113
  severity: opts.severity,
112
114
  detection,
113
115
  route: head.route,
@@ -220,6 +222,7 @@ var seo006Robots = {
220
222
  return [
221
223
  {
222
224
  id: "SEO006",
225
+ category: "seo",
223
226
  severity: "warning",
224
227
  detection,
225
228
  message: ctx.project.hasRobotsTxt ? "robots.txt" : "Missing robots.txt",
@@ -248,6 +251,7 @@ var seo007Sitemap = {
248
251
  return [
249
252
  {
250
253
  id: "SEO007",
254
+ category: "seo",
251
255
  severity: "warning",
252
256
  detection,
253
257
  message: ctx.project.hasSitemap ? "sitemap.xml" : "Missing sitemap.xml",
@@ -277,6 +281,7 @@ var seo009HtmlLang = {
277
281
  return [
278
282
  {
279
283
  id: "SEO009",
284
+ category: "seo",
280
285
  severity: "warning",
281
286
  detection,
282
287
  message,
@@ -288,6 +293,86 @@ var seo009HtmlLang = {
288
293
  }
289
294
  };
290
295
 
296
+ // src/rules/perf/image-rule.ts
297
+ function imageRule(opts) {
298
+ const docsUrl = docsUrlFor(opts.id);
299
+ return {
300
+ id: opts.id,
301
+ title: opts.title,
302
+ category: "performance",
303
+ severity: opts.severity,
304
+ scope: "route",
305
+ rationale: opts.rationale,
306
+ ...opts.fix ? { fix: opts.fix } : {},
307
+ async check(ctx) {
308
+ const out = [];
309
+ for (const route of ctx.images ?? []) {
310
+ if (route.images.length === 0) continue;
311
+ const bad = route.images.filter((img) => !opts.ok(img));
312
+ if (bad.length === 0) {
313
+ out.push({
314
+ id: opts.id,
315
+ category: "performance",
316
+ severity: opts.severity,
317
+ detection: { presence: "own", value: "static" },
318
+ route: route.route,
319
+ message: opts.label,
320
+ recommendation: opts.recommendation,
321
+ docsUrl
322
+ });
323
+ continue;
324
+ }
325
+ for (const img of bad) {
326
+ out.push({
327
+ id: opts.id,
328
+ category: "performance",
329
+ severity: opts.severity,
330
+ detection: { presence: "none", value: "absent" },
331
+ route: route.route,
332
+ location: img.file,
333
+ ...img.line > 0 ? { line: img.line } : {},
334
+ message: `Missing ${opts.label}`,
335
+ recommendation: opts.recommendation,
336
+ docsUrl,
337
+ ...opts.fix ? { fix: { ...opts.fix } } : {}
338
+ });
339
+ }
340
+ }
341
+ return out;
342
+ }
343
+ };
344
+ }
345
+
346
+ // src/rules/perf/images.ts
347
+ var perf001ImageDimensions = imageRule({
348
+ id: "PERF001",
349
+ title: "Image dimensions",
350
+ severity: "warning",
351
+ label: "<img> width/height",
352
+ recommendation: "Set explicit width and height on <img> to reserve space and avoid layout shift (CLS).",
353
+ rationale: "An <img> without explicit width and height triggers layout shift (CLS) as it loads, hurting Core Web Vitals and visual stability.",
354
+ fix: {
355
+ description: "Add explicit width and height attributes to the <img>.",
356
+ snippet: '<img src="/hero.jpg" width="1200" height="630" alt="\u2026" />',
357
+ lang: "svelte"
358
+ },
359
+ ok: (img) => img.hasWidth && img.hasHeight
360
+ });
361
+ var perf002ImageLoading = imageRule({
362
+ id: "PERF002",
363
+ title: "Image loading hint",
364
+ severity: "info",
365
+ label: "<img> loading attribute",
366
+ recommendation: 'Set loading="lazy" for offscreen images; keep the LCP image eager (consider fetchpriority="high").',
367
+ rationale: "A loading attribute lets the browser defer offscreen images; without it images load eagerly and can delay more important content. Static analysis cannot tell which image is the LCP, so this is advisory.",
368
+ fix: {
369
+ description: 'Add loading="lazy" to offscreen <img> elements (leave the LCP/hero image eager).',
370
+ snippet: '<img src="/thumb.jpg" width="320" height="240" loading="lazy" alt="\u2026" />',
371
+ lang: "svelte"
372
+ },
373
+ ok: (img) => img.hasLoading
374
+ });
375
+
291
376
  // src/rules/index.ts
292
377
  var allRules = [
293
378
  seo001Title,
@@ -298,7 +383,9 @@ var allRules = [
298
383
  seo006Robots,
299
384
  seo007Sitemap,
300
385
  seo008JsonLd,
301
- seo009HtmlLang
386
+ seo009HtmlLang,
387
+ perf001ImageDimensions,
388
+ perf002ImageLoading
302
389
  ];
303
390
  function explainRule(id) {
304
391
  const target = id.toUpperCase();
@@ -391,6 +478,39 @@ function computeScore(results, config, options = {}) {
391
478
  const score = capBinds ? CRITICAL_CAP : uncapped;
392
479
  return { score: clamp(score), scoreModel: { routeAverage, sitePenalty, criticalCap } };
393
480
  }
481
+ function scoresByCategory(results, config) {
482
+ const byCat = /* @__PURE__ */ new Map();
483
+ for (const r of results) {
484
+ const cat = r.category ?? "seo";
485
+ let bucket = byCat.get(cat);
486
+ if (!bucket) byCat.set(cat, bucket = []);
487
+ bucket.push(r);
488
+ }
489
+ const out = {};
490
+ for (const [cat, rs] of byCat) out[cat] = computeScore(rs, config);
491
+ return out;
492
+ }
493
+ function computeHealth(results, config) {
494
+ const categories = scoresByCategory(results, config);
495
+ const weights = {};
496
+ let weighted = 0;
497
+ let total = 0;
498
+ for (const cat of Object.keys(categories)) {
499
+ const w = config.weights?.[cat] ?? 1;
500
+ if (!Number.isFinite(w) || w < 0) {
501
+ throw new RangeError(`invalid weight for '${cat}'; expected a finite number >= 0.`);
502
+ }
503
+ weights[cat] = w;
504
+ weighted += categories[cat].score * w;
505
+ total += w;
506
+ }
507
+ if (Object.keys(weights).length === 0) return { health: 100, categories, weights };
508
+ if (total === 0) {
509
+ throw new RangeError("Health weights sum to 0; at least one present category must have a positive weight.");
510
+ }
511
+ const health = Math.round(weighted / total);
512
+ return { health, categories, weights };
513
+ }
394
514
 
395
515
  // src/reporter/console.ts
396
516
  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";
@@ -399,12 +519,18 @@ var SEVERITY_TITLE = {
399
519
  warning: "Warnings",
400
520
  info: "Info"
401
521
  };
402
- function scoreHeader(results, config) {
403
- const { score, scoreModel } = computeScore(results, config);
522
+ var CATEGORY_LABEL = {
523
+ seo: "SEO",
524
+ performance: "Performance",
525
+ a11y: "Accessibility",
526
+ maintainability: "Maintainability"
527
+ };
528
+ var CATEGORY_ORDER = ["seo", "performance", "a11y", "maintainability"];
529
+ function scoreLine(label, { score, scoreModel }) {
404
530
  const parts = [`route avg ${scoreModel.routeAverage}`];
405
531
  if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
406
532
  if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
407
- return `SEO Score: ${score}/100 (${parts.join(" \xB7 ")})`;
533
+ return `${label} Score: ${score}/100 (${parts.join(" \xB7 ")})`;
408
534
  }
409
535
  function byRouteTree(results, config) {
410
536
  const routes = /* @__PURE__ */ new Map();
@@ -426,12 +552,13 @@ function byRouteTree(results, config) {
426
552
  }
427
553
  function formatConsoleReport(results, config, options = {}) {
428
554
  const summary = summarize(results, config);
429
- const lines = [
430
- `Svelte Vitals \xB7 SEO (${options.mode ?? "static mode"})`,
431
- "",
432
- scoreHeader(results, config),
433
- ""
434
- ];
555
+ const { health, categories: byCat } = computeHealth(results, config);
556
+ const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
557
+ const header = [`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`, "", `Health: ${health}/100`];
558
+ for (const c of present2) {
559
+ header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]));
560
+ }
561
+ const lines = [...header, ""];
435
562
  const failures = results.filter((r) => classify(r, config) === "fail");
436
563
  for (const severity of ["critical", "warning", "info"]) {
437
564
  const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
@@ -440,7 +567,7 @@ function formatConsoleReport(results, config, options = {}) {
440
567
  for (const r of bucket) {
441
568
  lines.push(`\u2717 ${r.id} ${r.message}`);
442
569
  if (r.route) lines.push(` ${r.route}`);
443
- if (r.location) lines.push(` ${r.location}`);
570
+ if (r.location) lines.push(` ${r.location}${r.line ? `:${r.line}` : ""}`);
444
571
  }
445
572
  lines.push("");
446
573
  }
@@ -463,17 +590,22 @@ function formatConsoleReport(results, config, options = {}) {
463
590
  function issueOf(result) {
464
591
  return {
465
592
  id: result.id,
593
+ category: result.category ?? "seo",
466
594
  title: result.message,
467
595
  detection: result.detection,
468
596
  location: result.location,
597
+ ...result.line !== void 0 ? { line: result.line } : {},
469
598
  recommendation: result.recommendation,
470
599
  ...result.docsUrl ? { docsUrl: result.docsUrl } : {},
471
600
  ...result.fix ? { fix: result.fix } : {}
472
601
  };
473
602
  }
474
603
  function buildJsonReport(results, config, meta) {
475
- const { score, scoreModel } = computeScore(results, config);
604
+ const { health, categories: byCat, weights } = computeHealth(results, config);
476
605
  const summary = summarize(results, config);
606
+ const categories = Object.fromEntries(
607
+ Object.entries(byCat).map(([cat, sr]) => [cat, { score: sr.score, scoreModel: sr.scoreModel }])
608
+ );
477
609
  const routeMap = /* @__PURE__ */ new Map();
478
610
  for (const r of results) {
479
611
  if (r.route === void 0) continue;
@@ -486,7 +618,7 @@ function buildJsonReport(results, config, meta) {
486
618
  issues: rs.filter((r) => isPenalized(r.detection, config.treatDynamicAs)).map((r) => ({ ...issueOf(r), severity: effectiveSeverity(r, config) }))
487
619
  }));
488
620
  const siteIssues = results.filter((r) => r.route === void 0 && isPenalized(r.detection, config.treatDynamicAs)).map((r) => ({ ...issueOf(r), severity: effectiveSeverity(r, config) }));
489
- return { version: meta.version, score, scoreModel, summary, routes, siteIssues };
621
+ return { version: meta.version, score: health, weights, categories, summary, routes, siteIssues };
490
622
  }
491
623
  function formatJsonReport(results, config, meta) {
492
624
  return JSON.stringify(buildJsonReport(results, config, meta), null, 2);
@@ -499,7 +631,8 @@ function mdTags(text) {
499
631
  }
500
632
  function formatAgentReport(results, config) {
501
633
  const failing = results.filter((r) => classify(r, config) === "fail");
502
- const lines = ["# svelte-vitals \u2014 SEO fixes", ""];
634
+ const { health } = computeHealth(results, config);
635
+ const lines = ["# svelte-vitals \u2014 fixes", "", `Health: ${health}/100`, ""];
503
636
  if (failing.length === 0) {
504
637
  lines.push("No issues to fix.", "");
505
638
  return lines.join("\n").replace(/\n+$/, "\n");
@@ -581,10 +714,19 @@ function formatSarifReport(results, config, meta) {
581
714
  ruleIndex: ruleIndex.get(r.id),
582
715
  level: severityToSarifLevel(effectiveSeverity(r, config)),
583
716
  message: { text: messageText(r) },
584
- partialFingerprints: { "svelteVitals/v1": `${r.id}:${r.route ?? "project"}` }
717
+ partialFingerprints: {
718
+ "svelteVitals/v1": `${r.id}:${r.route ?? "project"}${r.line !== void 0 ? `:${r.location ?? ""}:${r.line}` : ""}`
719
+ }
585
720
  };
586
721
  if (r.location) {
587
- result.locations = [{ physicalLocation: { artifactLocation: { uri: r.location } } }];
722
+ result.locations = [
723
+ {
724
+ physicalLocation: {
725
+ artifactLocation: { uri: r.location },
726
+ ...r.line !== void 0 ? { region: { startLine: r.line } } : {}
727
+ }
728
+ }
729
+ ];
588
730
  }
589
731
  return result;
590
732
  });
@@ -622,7 +764,10 @@ function formatGithubReport(results, config) {
622
764
  const meta = ruleMetaById(r.id);
623
765
  const title = meta ? `${r.id}: ${meta.title}` : r.id;
624
766
  const props = [];
625
- if (r.location) props.push(`file=${escapeProp(r.location)}`);
767
+ if (r.location) {
768
+ props.push(`file=${escapeProp(r.location)}`);
769
+ if (r.line !== void 0) props.push(`line=${r.line}`);
770
+ }
626
771
  props.push(`title=${escapeProp(title)}`);
627
772
  return `::${level} ${props.join(",")}::${escapeData(messageText(r))}`;
628
773
  });
@@ -646,6 +791,7 @@ export {
646
791
  applyRuleSeverities,
647
792
  buildJsonReport,
648
793
  classify,
794
+ computeHealth,
649
795
  computeScore,
650
796
  defaultConfig,
651
797
  defaultProject,
@@ -660,8 +806,12 @@ export {
660
806
  formatSarifReport,
661
807
  hasFailureAtOrAbove,
662
808
  headTagRule,
809
+ imageRule,
663
810
  isPenalized,
811
+ perf001ImageDimensions,
812
+ perf002ImageLoading,
664
813
  runRules,
814
+ scoresByCategory,
665
815
  selectRules,
666
816
  seo001Title,
667
817
  seo002Description,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",