@svelte-vitals/core 0.7.0 → 0.8.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';
@@ -127,6 +131,26 @@ interface HeadProvider {
127
131
  collect(rt: Runtime, cwd: string, config?: Config): Promise<ResolvedHead[]>;
128
132
  }
129
133
 
134
+ /**
135
+ * A normalized <img> occurrence — the mode-independent boundary for Performance
136
+ * rules (mirrors head.ts). Attribute presence only: a dynamically-bound attribute
137
+ * (width={w}) still counts as present, so dynamic values are never flagged.
138
+ */
139
+ interface ImageInfo {
140
+ hasWidth: boolean;
141
+ hasHeight: boolean;
142
+ hasLoading: boolean;
143
+ /** 1-based source line, or 0 if unknown. */
144
+ line: number;
145
+ /** Source file the <img> came from. */
146
+ file: string;
147
+ }
148
+ /** Resolved <img> elements for a single route (page + layout chain). */
149
+ interface ResolvedImages {
150
+ route: string;
151
+ images: ImageInfo[];
152
+ }
153
+
130
154
  /**
131
155
  * Source-file locations that satisfy the project-scope rules, shared by every
132
156
  * mode so the static (CLI) and rendered (plugin) collectors never drift. This
@@ -140,6 +164,8 @@ declare const SITEMAP_SOURCE_PATHS: readonly ["static/sitemap.xml", "src/routes/
140
164
  /** Input given to every rule. Mode-independent: rules see only ResolvedHead[] (design §8, §10). */
141
165
  interface RuleContext {
142
166
  heads: ResolvedHead[];
167
+ /** Per-route <img> elements for Performance rules (absent in modes that don't collect them). */
168
+ images?: ResolvedImages[];
143
169
  project: Project;
144
170
  config: Config;
145
171
  }
@@ -198,6 +224,9 @@ declare const seo006Robots: Rule;
198
224
  declare const seo007Sitemap: Rule;
199
225
  declare const seo009HtmlLang: Rule;
200
226
 
227
+ declare const perf001ImageDimensions: Rule;
228
+ declare const perf002ImageLoading: Rule;
229
+
201
230
  declare const allRules: Rule[];
202
231
 
203
232
  interface RuleInfo {
@@ -229,6 +258,21 @@ interface HeadTagRuleOptions {
229
258
  /** Build a route-scope rule asserting the presence of a single head tag (design §11). */
230
259
  declare function headTagRule(opts: HeadTagRuleOptions): Rule;
231
260
 
261
+ interface ImageRuleOptions {
262
+ id: string;
263
+ title: string;
264
+ severity: Severity;
265
+ /** Noun phrase for messages, e.g. '<img> width/height'. */
266
+ label: string;
267
+ recommendation: string;
268
+ rationale: string;
269
+ fix?: Fix;
270
+ /** Returns true when the image satisfies the rule (passes). */
271
+ ok: (img: ImageInfo) => boolean;
272
+ }
273
+ /** Build a route-scoped Performance rule that checks each <img> against `ok` (issue #10). */
274
+ declare function imageRule(opts: ImageRuleOptions): Rule;
275
+
232
276
  interface Summary {
233
277
  critical: number;
234
278
  warning: number;
@@ -274,15 +318,19 @@ interface ScoreOptions {
274
318
  }
275
319
  /** Compute the headline score and its breakdown (design §12). */
276
320
  declare function computeScore(results: Result[], config: Config, options?: ScoreOptions): ScoreResult;
321
+ /** Compute an independent score per category present in `results` (issue #10). */
322
+ declare function scoresByCategory(results: Result[], config: Config): Partial<Record<Category, ScoreResult>>;
277
323
 
278
324
  declare function issueOf(result: Result): {
279
325
  fix?: Fix | undefined;
280
326
  docsUrl?: string | undefined;
327
+ recommendation: string | undefined;
328
+ line?: number | undefined;
281
329
  id: string;
330
+ category: Category;
282
331
  title: string;
283
332
  detection: Detection;
284
333
  location: string | undefined;
285
- recommendation: string | undefined;
286
334
  };
287
335
  type JsonIssue = ReturnType<typeof issueOf> & {
288
336
  severity: ReturnType<typeof effectiveSeverity>;
@@ -291,6 +339,10 @@ interface JsonReport {
291
339
  version: string;
292
340
  score: number;
293
341
  scoreModel: ScoreModel;
342
+ categories: Record<string, {
343
+ score: number;
344
+ scoreModel: ScoreModel;
345
+ }>;
294
346
  summary: Summary;
295
347
  routes: Array<{
296
348
  route: string;
@@ -327,4 +379,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
327
379
  /** Apply per-rule severity overrides to results (design §6). */
328
380
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
329
381
 
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 };
382
+ export { type Category, type Classification, type Config, type ConsoleReportOptions, type Detection, type Fix, type HeadProvider, type HeadTag, 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, 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,18 @@ 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
+ }
394
493
 
395
494
  // src/reporter/console.ts
396
495
  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 +498,18 @@ var SEVERITY_TITLE = {
399
498
  warning: "Warnings",
400
499
  info: "Info"
401
500
  };
402
- function scoreHeader(results, config) {
403
- const { score, scoreModel } = computeScore(results, config);
501
+ var CATEGORY_LABEL = {
502
+ seo: "SEO",
503
+ performance: "Performance",
504
+ a11y: "Accessibility",
505
+ maintainability: "Maintainability"
506
+ };
507
+ var CATEGORY_ORDER = ["seo", "performance", "a11y", "maintainability"];
508
+ function scoreLine(label, { score, scoreModel }) {
404
509
  const parts = [`route avg ${scoreModel.routeAverage}`];
405
510
  if (scoreModel.sitePenalty > 0) parts.push(`site \u2212${scoreModel.sitePenalty}`);
406
511
  if (scoreModel.criticalCap !== null) parts.push(`capped at ${scoreModel.criticalCap}: critical present`);
407
- return `SEO Score: ${score}/100 (${parts.join(" \xB7 ")})`;
512
+ return `${label} Score: ${score}/100 (${parts.join(" \xB7 ")})`;
408
513
  }
409
514
  function byRouteTree(results, config) {
410
515
  const routes = /* @__PURE__ */ new Map();
@@ -426,12 +531,13 @@ function byRouteTree(results, config) {
426
531
  }
427
532
  function formatConsoleReport(results, config, options = {}) {
428
533
  const summary = summarize(results, config);
429
- const lines = [
430
- `Svelte Vitals \xB7 SEO (${options.mode ?? "static mode"})`,
431
- "",
432
- scoreHeader(results, config),
433
- ""
434
- ];
534
+ const byCat = scoresByCategory(results, config);
535
+ const present2 = CATEGORY_ORDER.filter((c) => byCat[c] !== void 0);
536
+ const header = [`Svelte Vitals \xB7 ${options.mode ?? "static mode"}`, ""];
537
+ for (const c of present2) {
538
+ header.push(scoreLine(CATEGORY_LABEL[c] ?? c, byCat[c]));
539
+ }
540
+ const lines = [...header, ""];
435
541
  const failures = results.filter((r) => classify(r, config) === "fail");
436
542
  for (const severity of ["critical", "warning", "info"]) {
437
543
  const bucket = failures.filter((r) => effectiveSeverity(r, config) === severity);
@@ -440,7 +546,7 @@ function formatConsoleReport(results, config, options = {}) {
440
546
  for (const r of bucket) {
441
547
  lines.push(`\u2717 ${r.id} ${r.message}`);
442
548
  if (r.route) lines.push(` ${r.route}`);
443
- if (r.location) lines.push(` ${r.location}`);
549
+ if (r.location) lines.push(` ${r.location}${r.line ? `:${r.line}` : ""}`);
444
550
  }
445
551
  lines.push("");
446
552
  }
@@ -463,17 +569,24 @@ function formatConsoleReport(results, config, options = {}) {
463
569
  function issueOf(result) {
464
570
  return {
465
571
  id: result.id,
572
+ category: result.category ?? "seo",
466
573
  title: result.message,
467
574
  detection: result.detection,
468
575
  location: result.location,
576
+ ...result.line !== void 0 ? { line: result.line } : {},
469
577
  recommendation: result.recommendation,
470
578
  ...result.docsUrl ? { docsUrl: result.docsUrl } : {},
471
579
  ...result.fix ? { fix: result.fix } : {}
472
580
  };
473
581
  }
474
582
  function buildJsonReport(results, config, meta) {
475
- const { score, scoreModel } = computeScore(results, config);
583
+ const seoResults = results.filter((r) => (r.category ?? "seo") === "seo");
584
+ const { score, scoreModel } = computeScore(seoResults, config);
476
585
  const summary = summarize(results, config);
586
+ const byCat = scoresByCategory(results, config);
587
+ const categories = Object.fromEntries(
588
+ Object.entries(byCat).map(([cat, sr]) => [cat, { score: sr.score, scoreModel: sr.scoreModel }])
589
+ );
477
590
  const routeMap = /* @__PURE__ */ new Map();
478
591
  for (const r of results) {
479
592
  if (r.route === void 0) continue;
@@ -486,7 +599,7 @@ function buildJsonReport(results, config, meta) {
486
599
  issues: rs.filter((r) => isPenalized(r.detection, config.treatDynamicAs)).map((r) => ({ ...issueOf(r), severity: effectiveSeverity(r, config) }))
487
600
  }));
488
601
  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 };
602
+ return { version: meta.version, score, scoreModel, categories, summary, routes, siteIssues };
490
603
  }
491
604
  function formatJsonReport(results, config, meta) {
492
605
  return JSON.stringify(buildJsonReport(results, config, meta), null, 2);
@@ -499,7 +612,7 @@ function mdTags(text) {
499
612
  }
500
613
  function formatAgentReport(results, config) {
501
614
  const failing = results.filter((r) => classify(r, config) === "fail");
502
- const lines = ["# svelte-vitals \u2014 SEO fixes", ""];
615
+ const lines = ["# svelte-vitals \u2014 fixes", ""];
503
616
  if (failing.length === 0) {
504
617
  lines.push("No issues to fix.", "");
505
618
  return lines.join("\n").replace(/\n+$/, "\n");
@@ -581,10 +694,19 @@ function formatSarifReport(results, config, meta) {
581
694
  ruleIndex: ruleIndex.get(r.id),
582
695
  level: severityToSarifLevel(effectiveSeverity(r, config)),
583
696
  message: { text: messageText(r) },
584
- partialFingerprints: { "svelteVitals/v1": `${r.id}:${r.route ?? "project"}` }
697
+ partialFingerprints: {
698
+ "svelteVitals/v1": `${r.id}:${r.route ?? "project"}${r.line !== void 0 ? `:${r.location ?? ""}:${r.line}` : ""}`
699
+ }
585
700
  };
586
701
  if (r.location) {
587
- result.locations = [{ physicalLocation: { artifactLocation: { uri: r.location } } }];
702
+ result.locations = [
703
+ {
704
+ physicalLocation: {
705
+ artifactLocation: { uri: r.location },
706
+ ...r.line !== void 0 ? { region: { startLine: r.line } } : {}
707
+ }
708
+ }
709
+ ];
588
710
  }
589
711
  return result;
590
712
  });
@@ -622,7 +744,10 @@ function formatGithubReport(results, config) {
622
744
  const meta = ruleMetaById(r.id);
623
745
  const title = meta ? `${r.id}: ${meta.title}` : r.id;
624
746
  const props = [];
625
- if (r.location) props.push(`file=${escapeProp(r.location)}`);
747
+ if (r.location) {
748
+ props.push(`file=${escapeProp(r.location)}`);
749
+ if (r.line !== void 0) props.push(`line=${r.line}`);
750
+ }
626
751
  props.push(`title=${escapeProp(title)}`);
627
752
  return `::${level} ${props.join(",")}::${escapeData(messageText(r))}`;
628
753
  });
@@ -660,8 +785,12 @@ export {
660
785
  formatSarifReport,
661
786
  hasFailureAtOrAbove,
662
787
  headTagRule,
788
+ imageRule,
663
789
  isPenalized,
790
+ perf001ImageDimensions,
791
+ perf002ImageLoading,
664
792
  runRules,
793
+ scoresByCategory,
665
794
  selectRules,
666
795
  seo001Title,
667
796
  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.8.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",