@svelte-vitals/core 0.11.0 → 0.13.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
@@ -21,6 +21,8 @@ interface Project {
21
21
  hasSitemap: boolean;
22
22
  /** <html lang> from app.html: presence 'own' when the attribute exists ('none' otherwise); value 'static' if non-empty, 'absent' if empty. */
23
23
  htmlLang: Detection;
24
+ /** Whether the static static/robots.txt references a sitemap (`Sitemap:` line). Undefined for a +server endpoint / absent / unreadable. */
25
+ robotsReferencesSitemap?: boolean;
24
26
  }
25
27
  declare const defaultProject: Project;
26
28
  /** A concrete, agent-actionable remediation for a finding (design §10, issue #18). */
@@ -105,6 +107,14 @@ interface HeadTag {
105
107
  property?: string;
106
108
  /** <link rel="...">. */
107
109
  rel?: string;
110
+ /** <link as="..."> keyword (e.g. 'font') when statically literal; undefined when absent or dynamically bound. */
111
+ as?: string;
112
+ /** True when a <link> has an `as` attribute at all (literal or dynamic). Distinguishes "no as" from "dynamic as". */
113
+ hasAs?: boolean;
114
+ /** True when a <link> has a `crossorigin` attribute (presence only; value is irrelevant to the checks). */
115
+ hasCrossorigin?: boolean;
116
+ /** True when a <meta name="robots"> literal content contains `noindex`/`none`. Undefined when dynamic or absent. */
117
+ noindex?: boolean;
108
118
  /** Where this tag was set relative to the route. Never 'none' (absence = no tag). */
109
119
  presence: Exclude<Presence, 'none'>;
110
120
  /** Whether the tag's value is static/dynamic/absent (design §4). */
@@ -229,6 +239,16 @@ declare const seo009HtmlLang: Rule;
229
239
  declare const perf001ImageDimensions: Rule;
230
240
  declare const perf002ImageLoading: Rule;
231
241
 
242
+ declare const perf003PreloadAs: Rule;
243
+ declare const perf004FontPreloadCrossorigin: Rule;
244
+
245
+ declare const seo010Indexability: Rule;
246
+ declare const seo011TwitterCard: Rule;
247
+ declare const seo012OgDescription: Rule;
248
+ declare const seo013OgUrl: Rule;
249
+ declare const seo014Viewport: Rule;
250
+ declare const seo015SitemapInRobots: Rule;
251
+
232
252
  declare const allRules: Rule[];
233
253
 
234
254
  interface RuleInfo {
@@ -256,6 +276,13 @@ interface HeadTagRuleOptions {
256
276
  rationale: string;
257
277
  /** Agent-actionable remediation attached to every finding (issue #18). */
258
278
  fix?: Fix;
279
+ /**
280
+ * When set, only heads for which this returns true are evaluated; others emit
281
+ * nothing. Use for tags whose canonical location is invisible to a given mode
282
+ * (e.g. viewport lives in app.html → only checkable in rendered mode), so the
283
+ * rule stays silent instead of false-flagging "missing".
284
+ */
285
+ appliesTo?: (head: ResolvedHead) => boolean;
259
286
  }
260
287
  /** Build a route-scope rule asserting the presence of a single head tag (design §11). */
261
288
  declare function headTagRule(opts: HeadTagRuleOptions): Rule;
@@ -275,6 +302,23 @@ interface ImageRuleOptions {
275
302
  /** Build a route-scoped Performance rule that checks each <img> against `ok` (issue #10). */
276
303
  declare function imageRule(opts: ImageRuleOptions): Rule;
277
304
 
305
+ interface LinkRuleOptions {
306
+ id: string;
307
+ title: string;
308
+ severity: Severity;
309
+ /** Noun phrase for messages, e.g. '`as` on a preloaded `<link>`'. */
310
+ label: string;
311
+ recommendation: string;
312
+ rationale: string;
313
+ fix?: Fix;
314
+ /** Which link tags this rule evaluates (e.g. rel === 'preload'). */
315
+ relevant: (tag: HeadTag) => boolean;
316
+ /** Returns true when a relevant link satisfies the rule (passes). */
317
+ ok: (tag: HeadTag) => boolean;
318
+ }
319
+ /** Build a route-scoped Performance rule that checks each relevant <link> in the effective head. */
320
+ declare function linkRule(opts: LinkRuleOptions): Rule;
321
+
278
322
  interface Summary {
279
323
  critical: number;
280
324
  warning: number;
@@ -411,4 +455,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
411
455
  /** Apply per-rule severity overrides to results (design §6). */
412
456
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
413
457
 
414
- export { BAND_COLOR, 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, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, perf001ImageDimensions, perf002ImageLoading, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, summarize };
458
+ export { BAND_COLOR, 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, buildHtmlDocument, buildJsonReport, classify, computeHealth, computeScore, defaultConfig, defaultProject, defineConfig, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, formatAgentReport, formatConsoleReport, formatGithubReport, formatHtmlReport, formatJsonReport, formatSarifReport, hasFailureAtOrAbove, headTagRule, imageRule, isPenalized, linkRule, perf001ImageDimensions, perf002ImageLoading, perf003PreloadAs, perf004FontPreloadCrossorigin, runRules, safeHref, scoreBand, scoresByCategory, selectRules, seo001Title, seo002Description, seo003Canonical, seo004OgImage, seo005OgTitle, seo006Robots, seo007Sitemap, seo008JsonLd, seo009HtmlLang, seo010Indexability, seo011TwitterCard, seo012OgDescription, seo013OgUrl, seo014Viewport, seo015SitemapInRobots, summarize };
package/dist/index.js CHANGED
@@ -104,7 +104,8 @@ function headTagRule(opts) {
104
104
  rationale: opts.rationale,
105
105
  ...opts.fix ? { fix: opts.fix } : {},
106
106
  async check(ctx) {
107
- return ctx.heads.map((head) => {
107
+ const heads = opts.appliesTo ? ctx.heads.filter(opts.appliesTo) : ctx.heads;
108
+ return heads.map((head) => {
108
109
  const detection = detect(head, opts.match);
109
110
  const message = detection.presence === "none" ? `Missing ${opts.label}` : detection.value === "absent" ? `Empty ${opts.label}` : opts.label;
110
111
  return {
@@ -373,6 +374,219 @@ var perf002ImageLoading = imageRule({
373
374
  ok: (img) => img.hasLoading
374
375
  });
375
376
 
377
+ // src/rules/perf/link-rule.ts
378
+ function linkRule(opts) {
379
+ const docsUrl = docsUrlFor(opts.id);
380
+ return {
381
+ id: opts.id,
382
+ title: opts.title,
383
+ category: "performance",
384
+ severity: opts.severity,
385
+ scope: "route",
386
+ rationale: opts.rationale,
387
+ ...opts.fix ? { fix: opts.fix } : {},
388
+ async check(ctx) {
389
+ const out = [];
390
+ for (const head of ctx.heads) {
391
+ const links = head.tags.filter((t) => t.kind === "link" && opts.relevant(t));
392
+ if (links.length === 0) continue;
393
+ const bad = links.filter((t) => !opts.ok(t));
394
+ if (bad.length === 0) {
395
+ out.push({
396
+ id: opts.id,
397
+ category: "performance",
398
+ severity: opts.severity,
399
+ detection: { presence: "own", value: "static" },
400
+ route: head.route,
401
+ message: opts.label,
402
+ recommendation: opts.recommendation,
403
+ docsUrl
404
+ });
405
+ continue;
406
+ }
407
+ for (const tag of bad) {
408
+ out.push({
409
+ id: opts.id,
410
+ category: "performance",
411
+ severity: opts.severity,
412
+ detection: { presence: "none", value: "absent" },
413
+ route: head.route,
414
+ // Point at the file the link actually came from (a layout in static
415
+ // mode); fall back to the route's representative file when the tag
416
+ // carries no file (rendered mode).
417
+ location: tag.file ?? head.file,
418
+ message: `Missing ${opts.label}`,
419
+ recommendation: opts.recommendation,
420
+ docsUrl,
421
+ ...opts.fix ? { fix: { ...opts.fix } } : {}
422
+ });
423
+ }
424
+ }
425
+ return out;
426
+ }
427
+ };
428
+ }
429
+
430
+ // src/rules/perf/resource-hints.ts
431
+ var perf003PreloadAs = linkRule({
432
+ id: "PERF003",
433
+ title: "Preload missing as",
434
+ severity: "warning",
435
+ label: "`as` on a preloaded `<link>`",
436
+ recommendation: 'Add an `as` attribute to every `<link rel="preload">` so the browser knows the resource type and can prioritize it.',
437
+ rationale: 'A `<link rel="preload">` without an `as` attribute is ignored by the browser (or fetched a second time), wasting the preload.',
438
+ fix: {
439
+ description: "Add an `as` attribute matching the resource type to the preload link.",
440
+ snippet: '<link rel="preload" href="/app.css" as="style" />',
441
+ lang: "html"
442
+ },
443
+ relevant: (t) => t.rel === "preload",
444
+ ok: (t) => t.hasAs === true
445
+ });
446
+ var perf004FontPreloadCrossorigin = linkRule({
447
+ id: "PERF004",
448
+ title: "Font preload missing crossorigin",
449
+ severity: "warning",
450
+ label: "`crossorigin` on a font preload",
451
+ recommendation: 'Add `crossorigin` to `<link rel="preload" as="font">` \u2014 fonts are fetched in CORS mode, so without it the preload fetches a second, unused copy.',
452
+ rationale: "A font preload without `crossorigin` does not match the actual (CORS) font request, so the preloaded file is never used and the font downloads twice.",
453
+ fix: {
454
+ description: "Add the `crossorigin` attribute to the font preload link.",
455
+ snippet: '<link rel="preload" href="/inter.woff2" as="font" type="font/woff2" crossorigin />',
456
+ lang: "html"
457
+ },
458
+ relevant: (t) => t.rel === "preload" && t.as === "font",
459
+ ok: (t) => t.hasCrossorigin === true
460
+ });
461
+
462
+ // src/rules/seo/seo010-015.ts
463
+ var SEO010_FIX = {
464
+ description: 'If this route should be indexed, drop noindex from its <meta name="robots">.',
465
+ snippet: '<svelte:head>\n <meta name="robots" content="index, follow" />\n</svelte:head>',
466
+ lang: "svelte"
467
+ };
468
+ var seo010Indexability = {
469
+ id: "SEO010",
470
+ title: "Indexability",
471
+ category: "seo",
472
+ severity: "info",
473
+ scope: "route",
474
+ rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
475
+ fix: SEO010_FIX,
476
+ async check(ctx) {
477
+ const docsUrl = docsUrlFor("SEO010");
478
+ const out = [];
479
+ for (const head of ctx.heads) {
480
+ const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
481
+ if (!noindexed) continue;
482
+ out.push({
483
+ id: "SEO010",
484
+ category: "seo",
485
+ severity: "info",
486
+ detection: { presence: "none", value: "absent" },
487
+ // surfaced as an issue (isPenalized)
488
+ route: head.route,
489
+ location: head.file,
490
+ message: "Route is noindex \u2014 verify this is intentional",
491
+ recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
492
+ docsUrl,
493
+ fix: { ...SEO010_FIX }
494
+ });
495
+ }
496
+ return out;
497
+ }
498
+ };
499
+ var seo011TwitterCard = headTagRule({
500
+ id: "SEO011",
501
+ title: "Twitter Card",
502
+ severity: "info",
503
+ match: (t) => t.kind === "meta" && t.name === "twitter:card",
504
+ label: '<meta name="twitter:card">',
505
+ recommendation: 'Add <meta name="twitter:card" content="summary_large_image"> so X/Twitter renders a rich card.',
506
+ rationale: "twitter:card selects how the page renders when shared on X/Twitter; without it the platform falls back to a basic link (Open Graph tags are used as fallbacks for the rest).",
507
+ fix: {
508
+ description: "Add a twitter:card meta tag in <svelte:head>.",
509
+ snippet: '<svelte:head>\n <meta name="twitter:card" content="summary_large_image" />\n</svelte:head>',
510
+ lang: "svelte"
511
+ }
512
+ });
513
+ var seo012OgDescription = headTagRule({
514
+ id: "SEO012",
515
+ title: "Open Graph description",
516
+ severity: "warning",
517
+ match: (t) => t.kind === "meta" && t.property === "og:description",
518
+ label: '<meta property="og:description">',
519
+ recommendation: 'Add <meta property="og:description">, or set openGraph.description on your meta component.',
520
+ rationale: "og:description is the summary shown under the title in social previews; without it platforms guess or show nothing, lowering click-through.",
521
+ fix: {
522
+ description: "Add an og:description meta tag in <svelte:head>.",
523
+ snippet: '<svelte:head>\n <meta property="og:description" content="A concise page summary." />\n</svelte:head>',
524
+ lang: "svelte"
525
+ }
526
+ });
527
+ var seo013OgUrl = headTagRule({
528
+ id: "SEO013",
529
+ title: "Open Graph URL",
530
+ severity: "info",
531
+ match: (t) => t.kind === "meta" && t.property === "og:url",
532
+ label: '<meta property="og:url">',
533
+ recommendation: 'Add <meta property="og:url"> with the canonical URL, or set openGraph.url on your meta component.',
534
+ rationale: "og:url tells social platforms the canonical address to attribute shares and likes to, consolidating engagement on one URL.",
535
+ fix: {
536
+ description: "Add an og:url meta tag in <svelte:head>.",
537
+ snippet: '<svelte:head>\n <meta property="og:url" content="https://example.com/this-page" />\n</svelte:head>',
538
+ lang: "svelte"
539
+ }
540
+ });
541
+ var seo014Viewport = headTagRule({
542
+ id: "SEO014",
543
+ title: "Viewport",
544
+ severity: "warning",
545
+ match: (t) => t.kind === "meta" && t.name === "viewport",
546
+ label: '<meta name="viewport">',
547
+ // Viewport canonically lives in app.html, which static (CLI) mode does not
548
+ // resolve into head tags — only evaluate rendered heads so the rule stays
549
+ // silent there instead of false-flagging "missing" on every route.
550
+ appliesTo: (head) => head.source === "rendered",
551
+ recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> (usually in app.html).',
552
+ rationale: "Without a viewport meta tag the page is not mobile-responsive, which Google penalizes under mobile-first indexing.",
553
+ fix: {
554
+ description: "Add the viewport meta tag (typically in src/app.html <head>).",
555
+ snippet: '<meta name="viewport" content="width=device-width, initial-scale=1" />',
556
+ lang: "html"
557
+ }
558
+ });
559
+ var SEO015_FIX = {
560
+ description: "Add a Sitemap: line to static/robots.txt.",
561
+ snippet: "User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap.xml",
562
+ lang: "text"
563
+ };
564
+ var seo015SitemapInRobots = {
565
+ id: "SEO015",
566
+ title: "Sitemap referenced in robots.txt",
567
+ category: "seo",
568
+ severity: "info",
569
+ scope: "project",
570
+ rationale: "A Sitemap: line in robots.txt helps crawlers discover your sitemap; without it discovery relies on manual submission.",
571
+ fix: SEO015_FIX,
572
+ async check(ctx) {
573
+ const { hasRobotsTxt, hasSitemap, robotsReferencesSitemap } = ctx.project;
574
+ if (!(hasRobotsTxt && hasSitemap && robotsReferencesSitemap === false)) return [];
575
+ return [
576
+ {
577
+ id: "SEO015",
578
+ category: "seo",
579
+ severity: "info",
580
+ detection: { presence: "none", value: "absent" },
581
+ message: "robots.txt does not reference your sitemap",
582
+ recommendation: "Add a Sitemap: line to static/robots.txt pointing at your sitemap.xml.",
583
+ docsUrl: docsUrlFor("SEO015"),
584
+ fix: { ...SEO015_FIX }
585
+ }
586
+ ];
587
+ }
588
+ };
589
+
376
590
  // src/rules/index.ts
377
591
  var allRules = [
378
592
  seo001Title,
@@ -385,7 +599,15 @@ var allRules = [
385
599
  seo008JsonLd,
386
600
  seo009HtmlLang,
387
601
  perf001ImageDimensions,
388
- perf002ImageLoading
602
+ perf002ImageLoading,
603
+ perf003PreloadAs,
604
+ perf004FontPreloadCrossorigin,
605
+ seo010Indexability,
606
+ seo011TwitterCard,
607
+ seo012OgDescription,
608
+ seo013OgUrl,
609
+ seo014Viewport,
610
+ seo015SitemapInRobots
389
611
  ];
390
612
  function explainRule(id) {
391
613
  const target = id.toUpperCase();
@@ -993,8 +1215,11 @@ export {
993
1215
  headTagRule,
994
1216
  imageRule,
995
1217
  isPenalized,
1218
+ linkRule,
996
1219
  perf001ImageDimensions,
997
1220
  perf002ImageLoading,
1221
+ perf003PreloadAs,
1222
+ perf004FontPreloadCrossorigin,
998
1223
  runRules,
999
1224
  safeHref,
1000
1225
  scoreBand,
@@ -1009,5 +1234,11 @@ export {
1009
1234
  seo007Sitemap,
1010
1235
  seo008JsonLd,
1011
1236
  seo009HtmlLang,
1237
+ seo010Indexability,
1238
+ seo011TwitterCard,
1239
+ seo012OgDescription,
1240
+ seo013OgUrl,
1241
+ seo014Viewport,
1242
+ seo015SitemapInRobots,
1012
1243
  summarize
1013
1244
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",