@svelte-vitals/core 0.13.0 → 0.14.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
@@ -115,6 +115,12 @@ interface HeadTag {
115
115
  hasCrossorigin?: boolean;
116
116
  /** True when a <meta name="robots"> literal content contains `noindex`/`none`. Undefined when dynamic or absent. */
117
117
  noindex?: boolean;
118
+ /** Literal `<script type="application/ld+json">` content, set only when the script is static. Undefined when dynamic. */
119
+ jsonld?: string;
120
+ /** Literal visible text of a static <title> or <meta name="description"> content, set only when static. Undefined when dynamic. */
121
+ text?: string;
122
+ /** Literal `hreflang` of a `<link rel="alternate">` (e.g. 'en', 'en-US', 'x-default'). Undefined when dynamic/absent. */
123
+ hreflang?: string;
118
124
  /** Where this tag was set relative to the route. Never 'none' (absence = no tag). */
119
125
  presence: Exclude<Presence, 'none'>;
120
126
  /** Whether the tag's value is static/dynamic/absent (design §4). */
@@ -152,6 +158,8 @@ interface ImageInfo {
152
158
  hasWidth: boolean;
153
159
  hasHeight: boolean;
154
160
  hasLoading: boolean;
161
+ /** True when the <img> has an `alt` attribute at all (incl. empty `alt=""` decorative; SEO025). */
162
+ hasAlt: boolean;
155
163
  /** 1-based source line, or 0 if unknown. */
156
164
  line: number;
157
165
  /** Source file the <img> came from. */
@@ -163,6 +171,25 @@ interface ResolvedImages {
163
171
  images: ImageInfo[];
164
172
  }
165
173
 
174
+ /**
175
+ * A normalized page-body heading occurrence — the mode-independent boundary for
176
+ * the heading-hierarchy rule (mirrors images.ts). Both providers collect these
177
+ * so SEO027 never needs to know which mode produced them.
178
+ */
179
+ interface HeadingInfo {
180
+ /** Heading level 1–6 (the `n` in <hn>). */
181
+ level: number;
182
+ /** 1-based source line, or 0 if unknown (rendered mode does not track lines). */
183
+ line: number;
184
+ /** Source file the heading came from. */
185
+ file: string;
186
+ }
187
+ /** Resolved page-body headings for a single route (page + layout chain). */
188
+ interface ResolvedHeadings {
189
+ route: string;
190
+ headings: HeadingInfo[];
191
+ }
192
+
166
193
  /**
167
194
  * Source-file locations that satisfy the project-scope rules, shared by every
168
195
  * mode so the static (CLI) and rendered (plugin) collectors never drift. This
@@ -178,6 +205,8 @@ interface RuleContext {
178
205
  heads: ResolvedHead[];
179
206
  /** Per-route <img> elements for Performance rules (absent in modes that don't collect them). */
180
207
  images?: ResolvedImages[];
208
+ /** Per-route page-body headings for SEO027 (absent in modes that don't collect them). */
209
+ headings?: ResolvedHeadings[];
181
210
  project: Project;
182
211
  config: Config;
183
212
  }
@@ -249,6 +278,47 @@ declare const seo013OgUrl: Rule;
249
278
  declare const seo014Viewport: Rule;
250
279
  declare const seo015SitemapInRobots: Rule;
251
280
 
281
+ declare const seo016JsonLdValidity: Rule;
282
+ declare const seo017DeprecatedType: Rule;
283
+ declare const seo018RelativeUrl: Rule;
284
+ declare const seo019DateFormat: Rule;
285
+ declare const seo020Placeholder: Rule;
286
+ declare const seo021RequiredProps: Rule;
287
+
288
+ declare const seo022TitleLength: Rule;
289
+ declare const seo023DescriptionLength: Rule;
290
+
291
+ /**
292
+ * SEO024 — Character encoding. The charset meta lives in `src/app.html`, so it is
293
+ * only visible to rendered analysis (`appliesTo: rendered`), exactly like SEO014
294
+ * (viewport). Static route analysis emits nothing instead of false-flagging it.
295
+ */
296
+ declare const seo024Charset: Rule;
297
+
298
+ /**
299
+ * SEO025 — Image alt text. Reuses the <img> collection (CLI/static only; rendered
300
+ * mode does not collect images, so the rule no-ops there, like PERF001/002).
301
+ * Presence only: an explicit empty `alt=""` is a valid decorative-image signal and
302
+ * passes; a spread `{...rest}` may supply alt, so it is not flagged.
303
+ */
304
+ declare const seo025ImageAlt: Rule;
305
+
306
+ /**
307
+ * SEO026 — hreflang / x-default validity. Opt-in: a route with no
308
+ * `<link rel="alternate" hreflang>` emits nothing (monolingual sites are never
309
+ * flagged). When alternates exist, every code must be well-formed and a set of
310
+ * two or more must declare an x-default. Works in both modes.
311
+ */
312
+ declare const seo026Hreflang: Rule;
313
+
314
+ /**
315
+ * SEO027 — Heading hierarchy (single H1). Reads the per-route page-body headings
316
+ * channel (collected by both providers). Zero <h1> (no primary heading) and two
317
+ * or more (diluted topic) are both flagged; exactly one passes. A route whose
318
+ * headings were not collected (channel unset) emits nothing.
319
+ */
320
+ declare const seo027Heading: Rule;
321
+
252
322
  declare const allRules: Rule[];
253
323
 
254
324
  interface RuleInfo {
@@ -291,6 +361,8 @@ interface ImageRuleOptions {
291
361
  id: string;
292
362
  title: string;
293
363
  severity: Severity;
364
+ /** Vitals category (default 'performance'); SEO025 (alt text) reports under 'seo'. */
365
+ category?: Category;
294
366
  /** Noun phrase for messages, e.g. '<img> width/height'. */
295
367
  label: string;
296
368
  recommendation: string;
@@ -299,7 +371,7 @@ interface ImageRuleOptions {
299
371
  /** Returns true when the image satisfies the rule (passes). */
300
372
  ok: (img: ImageInfo) => boolean;
301
373
  }
302
- /** Build a route-scoped Performance rule that checks each <img> against `ok` (issue #10). */
374
+ /** Build a route-scoped <img> rule that checks each image against `ok` (issue #10). */
303
375
  declare function imageRule(opts: ImageRuleOptions): Rule;
304
376
 
305
377
  interface LinkRuleOptions {
@@ -455,4 +527,4 @@ declare function selectRules(rules: Rule[], config: Config): Rule[];
455
527
  /** Apply per-rule severity overrides to results (design §6). */
456
528
  declare function applyRuleSeverities(results: Result[], config: Config): Result[];
457
529
 
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 };
530
+ 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, 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 };
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 docsUrl = docsUrlFor(opts.id);
97
+ const docsUrl3 = 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,
120
+ docsUrl: docsUrl3,
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,11 +296,12 @@ var seo009HtmlLang = {
296
296
 
297
297
  // src/rules/perf/image-rule.ts
298
298
  function imageRule(opts) {
299
- const docsUrl = docsUrlFor(opts.id);
299
+ const docsUrl3 = docsUrlFor(opts.id);
300
+ const category = opts.category ?? "performance";
300
301
  return {
301
302
  id: opts.id,
302
303
  title: opts.title,
303
- category: "performance",
304
+ category,
304
305
  severity: opts.severity,
305
306
  scope: "route",
306
307
  rationale: opts.rationale,
@@ -313,20 +314,20 @@ function imageRule(opts) {
313
314
  if (bad.length === 0) {
314
315
  out.push({
315
316
  id: opts.id,
316
- category: "performance",
317
+ category,
317
318
  severity: opts.severity,
318
319
  detection: { presence: "own", value: "static" },
319
320
  route: route.route,
320
321
  message: opts.label,
321
322
  recommendation: opts.recommendation,
322
- docsUrl
323
+ docsUrl: docsUrl3
323
324
  });
324
325
  continue;
325
326
  }
326
327
  for (const img of bad) {
327
328
  out.push({
328
329
  id: opts.id,
329
- category: "performance",
330
+ category,
330
331
  severity: opts.severity,
331
332
  detection: { presence: "none", value: "absent" },
332
333
  route: route.route,
@@ -334,7 +335,7 @@ function imageRule(opts) {
334
335
  ...img.line > 0 ? { line: img.line } : {},
335
336
  message: `Missing ${opts.label}`,
336
337
  recommendation: opts.recommendation,
337
- docsUrl,
338
+ docsUrl: docsUrl3,
338
339
  ...opts.fix ? { fix: { ...opts.fix } } : {}
339
340
  });
340
341
  }
@@ -376,7 +377,7 @@ var perf002ImageLoading = imageRule({
376
377
 
377
378
  // src/rules/perf/link-rule.ts
378
379
  function linkRule(opts) {
379
- const docsUrl = docsUrlFor(opts.id);
380
+ const docsUrl3 = docsUrlFor(opts.id);
380
381
  return {
381
382
  id: opts.id,
382
383
  title: opts.title,
@@ -400,7 +401,7 @@ function linkRule(opts) {
400
401
  route: head.route,
401
402
  message: opts.label,
402
403
  recommendation: opts.recommendation,
403
- docsUrl
404
+ docsUrl: docsUrl3
404
405
  });
405
406
  continue;
406
407
  }
@@ -417,7 +418,7 @@ function linkRule(opts) {
417
418
  location: tag.file ?? head.file,
418
419
  message: `Missing ${opts.label}`,
419
420
  recommendation: opts.recommendation,
420
- docsUrl,
421
+ docsUrl: docsUrl3,
421
422
  ...opts.fix ? { fix: { ...opts.fix } } : {}
422
423
  });
423
424
  }
@@ -474,7 +475,7 @@ var seo010Indexability = {
474
475
  rationale: "A noindex directive removes the page from search results; an accidental noindex on a public route silently deindexes it.",
475
476
  fix: SEO010_FIX,
476
477
  async check(ctx) {
477
- const docsUrl = docsUrlFor("SEO010");
478
+ const docsUrl3 = docsUrlFor("SEO010");
478
479
  const out = [];
479
480
  for (const head of ctx.heads) {
480
481
  const noindexed = head.tags.some((t) => t.kind === "meta" && t.name === "robots" && t.noindex === true);
@@ -489,7 +490,7 @@ var seo010Indexability = {
489
490
  location: head.file,
490
491
  message: "Route is noindex \u2014 verify this is intentional",
491
492
  recommendation: 'If this route should be indexed, remove noindex from its <meta name="robots">.',
492
- docsUrl,
493
+ docsUrl: docsUrl3,
493
494
  fix: { ...SEO010_FIX }
494
495
  });
495
496
  }
@@ -587,6 +588,549 @@ var seo015SitemapInRobots = {
587
588
  }
588
589
  };
589
590
 
591
+ // src/rules/seo/jsonld-engine.ts
592
+ function parseJsonLd(raw) {
593
+ let data;
594
+ try {
595
+ data = JSON.parse(raw);
596
+ } catch {
597
+ return { ok: false, nodes: [] };
598
+ }
599
+ const nodes = [];
600
+ const visit = (v) => {
601
+ if (Array.isArray(v)) {
602
+ v.forEach(visit);
603
+ return;
604
+ }
605
+ if (v && typeof v === "object") {
606
+ const o = v;
607
+ nodes.push(o);
608
+ if (Array.isArray(o["@graph"])) o["@graph"].forEach(visit);
609
+ }
610
+ };
611
+ visit(data);
612
+ return { ok: true, nodes };
613
+ }
614
+ function typeOf(node) {
615
+ const t = node["@type"];
616
+ if (typeof t === "string") return [t];
617
+ if (Array.isArray(t)) return t.filter((x) => typeof x === "string");
618
+ return [];
619
+ }
620
+ function collectValues(nodes, keys) {
621
+ const out = [];
622
+ const walk = (v) => {
623
+ if (Array.isArray(v)) {
624
+ v.forEach(walk);
625
+ return;
626
+ }
627
+ if (v && typeof v === "object") {
628
+ for (const [k, val] of Object.entries(v)) {
629
+ if (keys.has(k) && typeof val === "string") out.push(val);
630
+ else if (keys.has(k) && Array.isArray(val)) {
631
+ for (const e of val) if (typeof e === "string") out.push(e);
632
+ }
633
+ walk(val);
634
+ }
635
+ }
636
+ };
637
+ nodes.forEach(walk);
638
+ return out;
639
+ }
640
+ function nodeStringValues(node) {
641
+ const out = [];
642
+ const walk = (v) => {
643
+ if (typeof v === "string") {
644
+ out.push(v);
645
+ return;
646
+ }
647
+ if (Array.isArray(v)) {
648
+ v.forEach(walk);
649
+ return;
650
+ }
651
+ if (v && typeof v === "object") Object.values(v).forEach(walk);
652
+ };
653
+ walk(node);
654
+ return out;
655
+ }
656
+ function isAbsoluteUrl(s) {
657
+ const str = s.trim();
658
+ return /^[a-z][a-z0-9+.-]*:/i.test(str) || str.startsWith("//");
659
+ }
660
+ function isIso8601(s) {
661
+ const str = s.trim();
662
+ if (!/^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/.test(str)) return false;
663
+ const y = Number(str.slice(0, 4));
664
+ const hasMonth = str.length >= 7;
665
+ const hasDay = str.length >= 10;
666
+ const m = hasMonth ? Number(str.slice(5, 7)) : 1;
667
+ if (m < 1 || m > 12) return false;
668
+ if (hasDay) {
669
+ const d = Number(str.slice(8, 10));
670
+ const dt = new Date(Date.UTC(y, m - 1, d));
671
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return false;
672
+ }
673
+ const tm = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(str);
674
+ if (tm) {
675
+ const [hh, mm, ss] = [Number(tm[1]), Number(tm[2]), tm[3] !== void 0 ? Number(tm[3]) : 0];
676
+ if (hh > 23 || mm > 59 || ss > 59) return false;
677
+ }
678
+ return true;
679
+ }
680
+ var PLACEHOLDER_RES = [
681
+ /lorem ipsum/i,
682
+ /your company/i,
683
+ /your-?domain/i,
684
+ /example company/i,
685
+ /yourcompany/i,
686
+ /your name here/i
687
+ ];
688
+ var PLACEHOLDERS = PLACEHOLDER_RES.map((r) => r.source);
689
+ function hasPlaceholder(s) {
690
+ return PLACEHOLDER_RES.some((re) => re.test(s));
691
+ }
692
+ var URL_KEYS = /* @__PURE__ */ new Set(["url", "image", "logo", "sameAs", "contentUrl", "thumbnailUrl"]);
693
+ var DATE_KEYS = /* @__PURE__ */ new Set([
694
+ "datePublished",
695
+ "dateModified",
696
+ "dateCreated",
697
+ "startDate",
698
+ "endDate",
699
+ "uploadDate",
700
+ "validFrom",
701
+ "expires"
702
+ ]);
703
+ var DEPRECATED_TYPES = /* @__PURE__ */ new Set(["HowTo", "FAQPage", "ClaimReview"]);
704
+ function hasNonEmpty(node, key) {
705
+ if (!(key in node)) return false;
706
+ const v = node[key];
707
+ if (v === null || v === void 0) return false;
708
+ if (typeof v === "string") return v.trim().length > 0;
709
+ if (Array.isArray(v)) return v.length > 0;
710
+ return true;
711
+ }
712
+ var REQUIRED_PROPS = {
713
+ Article: ["headline"],
714
+ BlogPosting: ["headline"],
715
+ NewsArticle: ["headline"],
716
+ Product: ["name", "offers"],
717
+ BreadcrumbList: ["itemListElement"],
718
+ Organization: ["name", "url"],
719
+ WebSite: ["name", "url"],
720
+ Event: ["name", "startDate", "location"],
721
+ Recipe: ["name", "image", "recipeIngredient", "recipeInstructions"],
722
+ Person: ["name"],
723
+ VideoObject: ["name", "description", "thumbnailUrl", "uploadDate"],
724
+ LocalBusiness: ["name", "address"]
725
+ };
726
+
727
+ // src/rules/seo/detection.ts
728
+ var PENALIZED = { presence: "none", value: "absent" };
729
+ var PASS = { presence: "own", value: "static" };
730
+
731
+ // src/rules/seo/seo016-021.ts
732
+ function jsonldTags(head) {
733
+ return head.tags.filter((t) => t.kind === "jsonld" && typeof t.jsonld === "string");
734
+ }
735
+ var seo016JsonLdValidity = {
736
+ id: "SEO016",
737
+ title: "JSON-LD validity",
738
+ category: "seo",
739
+ severity: "warning",
740
+ scope: "route",
741
+ rationale: "Invalid JSON-LD \u2014 unparseable, or missing @context/@type \u2014 is silently ignored by search engines, so the structured data does nothing.",
742
+ fix: {
743
+ description: "Make the JSON-LD valid: parseable JSON with both @context (schema.org) and @type.",
744
+ snippet: '<svelte:head>\n <script type="application/ld+json">\n {"@context":"https://schema.org","@type":"WebPage","name":"\u2026"}\n </script>\n</svelte:head>',
745
+ lang: "svelte"
746
+ },
747
+ async check(ctx) {
748
+ const docsUrl3 = docsUrlFor("SEO016");
749
+ const out = [];
750
+ for (const head of ctx.heads) {
751
+ for (const tag of jsonldTags(head)) {
752
+ const parsed = parseJsonLd(tag.jsonld);
753
+ let problem;
754
+ if (!parsed.ok) problem = "JSON-LD is not valid JSON";
755
+ else if (!parsed.nodes.some((n) => "@context" in n)) problem = "JSON-LD is missing @context";
756
+ else if (!parsed.nodes.some((n) => typeOf(n).length > 0)) problem = "JSON-LD is missing @type";
757
+ out.push(
758
+ problem ? {
759
+ id: "SEO016",
760
+ category: "seo",
761
+ severity: "warning",
762
+ detection: PENALIZED,
763
+ route: head.route,
764
+ location: head.file,
765
+ message: problem,
766
+ recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
767
+ docsUrl: docsUrl3,
768
+ fix: { ...seo016JsonLdValidity.fix }
769
+ } : {
770
+ id: "SEO016",
771
+ category: "seo",
772
+ severity: "warning",
773
+ detection: PASS,
774
+ route: head.route,
775
+ message: "JSON-LD validity",
776
+ recommendation: "Make the JSON-LD valid JSON with both @context and @type.",
777
+ docsUrl: docsUrl3
778
+ }
779
+ );
780
+ }
781
+ }
782
+ return out;
783
+ }
784
+ };
785
+ function jsonldRule(opts) {
786
+ const docsUrl3 = docsUrlFor(opts.id);
787
+ return {
788
+ id: opts.id,
789
+ title: opts.title,
790
+ category: "seo",
791
+ severity: opts.severity,
792
+ scope: "route",
793
+ rationale: opts.rationale,
794
+ ...opts.fix ? { fix: opts.fix } : {},
795
+ async check(ctx) {
796
+ const out = [];
797
+ for (const head of ctx.heads) {
798
+ for (const tag of jsonldTags(head)) {
799
+ const parsed = parseJsonLd(tag.jsonld);
800
+ if (!parsed.ok) continue;
801
+ if (!parsed.nodes.some((n) => "@context" in n) || !parsed.nodes.some((n) => typeOf(n).length > 0)) continue;
802
+ const problem = opts.problem(parsed.nodes);
803
+ if (problem === false) continue;
804
+ out.push(
805
+ problem ? {
806
+ id: opts.id,
807
+ category: "seo",
808
+ severity: opts.severity,
809
+ detection: PENALIZED,
810
+ route: head.route,
811
+ location: head.file,
812
+ message: problem,
813
+ recommendation: opts.recommendation,
814
+ docsUrl: docsUrl3,
815
+ ...opts.fix ? { fix: { ...opts.fix } } : {}
816
+ } : {
817
+ id: opts.id,
818
+ category: "seo",
819
+ severity: opts.severity,
820
+ detection: PASS,
821
+ route: head.route,
822
+ message: opts.label,
823
+ recommendation: opts.recommendation,
824
+ docsUrl: docsUrl3
825
+ }
826
+ );
827
+ }
828
+ }
829
+ return out;
830
+ }
831
+ };
832
+ }
833
+ var seo017DeprecatedType = jsonldRule({
834
+ id: "SEO017",
835
+ title: "Deprecated structured-data type",
836
+ severity: "info",
837
+ label: "Structured-data type",
838
+ recommendation: "Verify the rich-result status of this @type; Google dropped or restricted some (e.g. HowTo, FAQPage).",
839
+ rationale: "Some schema types no longer produce rich results, so the markup adds weight without the SERP benefit.",
840
+ problem: (nodes) => {
841
+ const dep = nodes.flatMap(typeOf).find((t) => DEPRECATED_TYPES.has(t));
842
+ return dep ? `@type "${dep}" no longer reliably produces a Google rich result` : void 0;
843
+ }
844
+ });
845
+ var seo018RelativeUrl = jsonldRule({
846
+ id: "SEO018",
847
+ title: "JSON-LD relative URL",
848
+ severity: "warning",
849
+ label: "JSON-LD URLs",
850
+ recommendation: "Use absolute URLs (http/https) for url/@id/image/logo/sameAs/contentUrl/thumbnailUrl in JSON-LD.",
851
+ rationale: "Search engines need absolute URLs in structured data; a relative URL cannot be resolved reliably.",
852
+ fix: {
853
+ description: "Replace relative URLs in JSON-LD with absolute URLs.",
854
+ snippet: '"image": "https://example.com/logo.png"',
855
+ lang: "json"
856
+ },
857
+ problem: (nodes) => {
858
+ const bad = collectValues(nodes, URL_KEYS).find((v) => !isAbsoluteUrl(v));
859
+ return bad ? `Relative URL in JSON-LD: "${bad}" \u2014 use an absolute URL` : void 0;
860
+ }
861
+ });
862
+ var seo019DateFormat = jsonldRule({
863
+ id: "SEO019",
864
+ title: "JSON-LD date format",
865
+ severity: "info",
866
+ label: "JSON-LD dates",
867
+ recommendation: "Use ISO-8601 dates (e.g. 2026-06-26 or 2026-06-26T10:00:00Z) in JSON-LD.",
868
+ rationale: "Schema.org date properties expect ISO-8601; other formats may be ignored or misparsed.",
869
+ fix: {
870
+ description: "Format JSON-LD date properties as ISO-8601.",
871
+ snippet: '"datePublished": "2026-06-26"',
872
+ lang: "json"
873
+ },
874
+ problem: (nodes) => {
875
+ const bad = collectValues(nodes, DATE_KEYS).find((v) => !isIso8601(v));
876
+ return bad ? `Non-ISO-8601 date in JSON-LD: "${bad}"` : void 0;
877
+ }
878
+ });
879
+ var seo020Placeholder = jsonldRule({
880
+ id: "SEO020",
881
+ title: "JSON-LD placeholder text",
882
+ severity: "info",
883
+ label: "JSON-LD content",
884
+ recommendation: "Replace placeholder/boilerplate text in JSON-LD with real values.",
885
+ rationale: 'Leftover placeholder text (e.g. "Your Company Name", "lorem ipsum") ships misleading structured data.',
886
+ problem: (nodes) => {
887
+ const bad = nodes.flatMap(nodeStringValues).find(hasPlaceholder);
888
+ return bad ? `Placeholder text in JSON-LD: "${bad}"` : void 0;
889
+ }
890
+ });
891
+ var seo021RequiredProps = jsonldRule({
892
+ id: "SEO021",
893
+ title: "JSON-LD required properties",
894
+ severity: "warning",
895
+ label: "JSON-LD required properties",
896
+ recommendation: "Add the properties Google requires for this @type's rich result.",
897
+ rationale: "A recognized @type missing its required properties is ineligible for the corresponding rich result.",
898
+ problem: (nodes) => {
899
+ let hasKnownType = false;
900
+ for (const node of nodes) {
901
+ for (const t of typeOf(node)) {
902
+ const required = REQUIRED_PROPS[t];
903
+ if (!required) continue;
904
+ hasKnownType = true;
905
+ const missing = required.filter((p) => !hasNonEmpty(node, p));
906
+ if (missing.length > 0) return `${t} JSON-LD is missing required ${missing.join(", ")}`;
907
+ }
908
+ }
909
+ return hasKnownType ? void 0 : false;
910
+ }
911
+ });
912
+
913
+ // src/rules/seo/text-metrics.ts
914
+ var segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter() : void 0;
915
+ function visibleLength(s) {
916
+ const collapsed = s.trim().replace(/\s+/g, " ");
917
+ if (!segmenter) return [...collapsed].length;
918
+ return [...segmenter.segment(collapsed)].length;
919
+ }
920
+
921
+ // src/rules/seo/seo022-023.ts
922
+ function lengthRule(opts) {
923
+ const docsUrl3 = docsUrlFor(opts.id);
924
+ return {
925
+ id: opts.id,
926
+ title: opts.title,
927
+ category: "seo",
928
+ severity: "info",
929
+ scope: "route",
930
+ rationale: opts.rationale,
931
+ async check(ctx) {
932
+ const out = [];
933
+ for (const head of ctx.heads) {
934
+ const tag = head.tags.find(opts.match);
935
+ if (!tag || typeof tag.text !== "string") continue;
936
+ const len = visibleLength(tag.text);
937
+ let problem;
938
+ if (len < opts.min) problem = `${opts.noun} is too short (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
939
+ else if (len > opts.max) problem = `${opts.noun} is too long (${len} chars; aim for ${opts.min}\u2013${opts.max})`;
940
+ out.push(
941
+ problem ? {
942
+ id: opts.id,
943
+ category: "seo",
944
+ severity: "info",
945
+ detection: PENALIZED,
946
+ route: head.route,
947
+ location: tag.file ?? head.file,
948
+ message: problem,
949
+ recommendation: opts.recommendation,
950
+ docsUrl: docsUrl3
951
+ } : {
952
+ id: opts.id,
953
+ category: "seo",
954
+ severity: "info",
955
+ detection: PASS,
956
+ route: head.route,
957
+ message: opts.label,
958
+ recommendation: opts.recommendation,
959
+ docsUrl: docsUrl3
960
+ }
961
+ );
962
+ }
963
+ return out;
964
+ }
965
+ };
966
+ }
967
+ var seo022TitleLength = lengthRule({
968
+ id: "SEO022",
969
+ title: "Title length",
970
+ label: "Title length",
971
+ noun: "Title",
972
+ match: (t) => t.kind === "title",
973
+ min: 30,
974
+ max: 60,
975
+ recommendation: "Aim for a title of 30\u201360 characters so it is not truncated in search results.",
976
+ rationale: "A title that is too short wastes the strongest on-page signal; one that is too long is truncated in the SERP."
977
+ });
978
+ var seo023DescriptionLength = lengthRule({
979
+ id: "SEO023",
980
+ title: "Description length",
981
+ label: "Description length",
982
+ noun: "Description",
983
+ match: (t) => t.kind === "meta" && t.name === "description",
984
+ min: 70,
985
+ max: 160,
986
+ recommendation: "Aim for a meta description of 70\u2013160 characters so it is not truncated in search results.",
987
+ rationale: "A description that is too short under-uses the SERP snippet; one that is too long is truncated by search engines."
988
+ });
989
+
990
+ // src/rules/seo/seo024-charset.ts
991
+ var seo024Charset = headTagRule({
992
+ id: "SEO024",
993
+ title: "Character encoding",
994
+ severity: "warning",
995
+ match: (t) => t.kind === "meta" && t.name === "charset",
996
+ label: "<meta charset>",
997
+ appliesTo: (head) => head.source === "rendered",
998
+ recommendation: 'Add <meta charset="utf-8"> (usually the first line of <head> in src/app.html).',
999
+ rationale: 'Without a declared character encoding the browser must guess, which can render text as mojibake; <meta charset="utf-8"> is the standard declaration.',
1000
+ fix: {
1001
+ description: "Add the charset meta tag (typically the first line of <head> in src/app.html).",
1002
+ snippet: '<meta charset="utf-8" />',
1003
+ lang: "html"
1004
+ }
1005
+ });
1006
+
1007
+ // src/rules/seo/seo025-image-alt.ts
1008
+ var seo025ImageAlt = imageRule({
1009
+ id: "SEO025",
1010
+ title: "Image alt text",
1011
+ category: "seo",
1012
+ severity: "warning",
1013
+ label: "<img> alt text",
1014
+ recommendation: 'Add an alt attribute to every <img> (use alt="" only for purely decorative images).',
1015
+ rationale: "An <img> with no alt attribute is invisible to image search and assistive technology; a descriptive alt is an image-SEO signal.",
1016
+ fix: {
1017
+ description: 'Add a descriptive alt attribute to the <img> (or alt="" if purely decorative).',
1018
+ snippet: '<img src="/photo.jpg" width="800" height="600" alt="Description of the image" />',
1019
+ lang: "svelte"
1020
+ },
1021
+ ok: (img) => img.hasAlt
1022
+ });
1023
+
1024
+ // src/rules/seo/seo026-hreflang.ts
1025
+ var docsUrl = docsUrlFor("SEO026");
1026
+ var recommendation = 'Use valid hreflang codes (e.g. "en", "en-US", "x-default") and include an x-default when you have multiple language alternates.';
1027
+ var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
1028
+ function isValidHreflang(v) {
1029
+ return v.toLowerCase() === "x-default" || HREFLANG_RE.test(v);
1030
+ }
1031
+ var seo026Hreflang = {
1032
+ id: "SEO026",
1033
+ title: "hreflang validity",
1034
+ category: "seo",
1035
+ severity: "warning",
1036
+ scope: "route",
1037
+ rationale: "A malformed hreflang code or a missing x-default breaks international targeting, so search engines may serve the wrong language version.",
1038
+ async check(ctx) {
1039
+ const out = [];
1040
+ for (const head of ctx.heads) {
1041
+ const alternates = head.tags.filter(
1042
+ (t) => t.kind === "link" && t.rel === "alternate" && typeof t.hreflang === "string"
1043
+ );
1044
+ if (alternates.length === 0) continue;
1045
+ const values = alternates.map((t) => t.hreflang);
1046
+ const badTag = alternates.find((t) => !isValidHreflang(t.hreflang));
1047
+ let problem;
1048
+ let location = head.file;
1049
+ if (badTag) {
1050
+ problem = `Invalid hreflang value "${badTag.hreflang}"`;
1051
+ location = badTag.file ?? head.file;
1052
+ } else if (values.length >= 2 && !values.some((v) => v.toLowerCase() === "x-default")) {
1053
+ problem = "Multiple hreflang alternates without an x-default";
1054
+ }
1055
+ out.push(
1056
+ problem ? {
1057
+ id: "SEO026",
1058
+ category: "seo",
1059
+ severity: "warning",
1060
+ detection: PENALIZED,
1061
+ route: head.route,
1062
+ location,
1063
+ message: problem,
1064
+ recommendation,
1065
+ docsUrl
1066
+ } : {
1067
+ id: "SEO026",
1068
+ category: "seo",
1069
+ severity: "warning",
1070
+ detection: PASS,
1071
+ route: head.route,
1072
+ message: "hreflang",
1073
+ recommendation,
1074
+ docsUrl
1075
+ }
1076
+ );
1077
+ }
1078
+ return out;
1079
+ }
1080
+ };
1081
+
1082
+ // src/rules/seo/seo027-heading.ts
1083
+ var docsUrl2 = docsUrlFor("SEO027");
1084
+ var recommendation2 = "Use exactly one <h1> per page for its main topic; demote extra top-level headings to <h2>+.";
1085
+ var seo027Heading = {
1086
+ id: "SEO027",
1087
+ title: "Heading hierarchy",
1088
+ category: "seo",
1089
+ severity: "warning",
1090
+ scope: "route",
1091
+ rationale: "Each page should have exactly one <h1> naming its main topic; none leaves the page without a primary heading, and several dilute the topic signal.",
1092
+ async check(ctx) {
1093
+ const out = [];
1094
+ for (const route of ctx.headings ?? []) {
1095
+ const h1 = route.headings.filter((h) => h.level === 1);
1096
+ let problem;
1097
+ let where = {};
1098
+ if (h1.length === 0) {
1099
+ problem = "Missing <h1>";
1100
+ const first = route.headings[0];
1101
+ if (first) where = { location: first.file, ...first.line > 0 ? { line: first.line } : {} };
1102
+ } else if (h1.length > 1) {
1103
+ problem = `Multiple <h1> (${h1.length}); use exactly one`;
1104
+ const extra = h1[1];
1105
+ where = { location: extra.file, ...extra.line > 0 ? { line: extra.line } : {} };
1106
+ }
1107
+ out.push(
1108
+ problem ? {
1109
+ id: "SEO027",
1110
+ category: "seo",
1111
+ severity: "warning",
1112
+ detection: PENALIZED,
1113
+ route: route.route,
1114
+ ...where,
1115
+ message: problem,
1116
+ recommendation: recommendation2,
1117
+ docsUrl: docsUrl2
1118
+ } : {
1119
+ id: "SEO027",
1120
+ category: "seo",
1121
+ severity: "warning",
1122
+ detection: PASS,
1123
+ route: route.route,
1124
+ message: "Heading hierarchy",
1125
+ recommendation: recommendation2,
1126
+ docsUrl: docsUrl2
1127
+ }
1128
+ );
1129
+ }
1130
+ return out;
1131
+ }
1132
+ };
1133
+
590
1134
  // src/rules/index.ts
591
1135
  var allRules = [
592
1136
  seo001Title,
@@ -607,7 +1151,19 @@ var allRules = [
607
1151
  seo012OgDescription,
608
1152
  seo013OgUrl,
609
1153
  seo014Viewport,
610
- seo015SitemapInRobots
1154
+ seo015SitemapInRobots,
1155
+ seo016JsonLdValidity,
1156
+ seo017DeprecatedType,
1157
+ seo018RelativeUrl,
1158
+ seo019DateFormat,
1159
+ seo020Placeholder,
1160
+ seo021RequiredProps,
1161
+ seo022TitleLength,
1162
+ seo023DescriptionLength,
1163
+ seo024Charset,
1164
+ seo025ImageAlt,
1165
+ seo026Hreflang,
1166
+ seo027Heading
611
1167
  ];
612
1168
  function explainRule(id) {
613
1169
  const target = id.toUpperCase();
@@ -1240,5 +1796,17 @@ export {
1240
1796
  seo013OgUrl,
1241
1797
  seo014Viewport,
1242
1798
  seo015SitemapInRobots,
1799
+ seo016JsonLdValidity,
1800
+ seo017DeprecatedType,
1801
+ seo018RelativeUrl,
1802
+ seo019DateFormat,
1803
+ seo020Placeholder,
1804
+ seo021RequiredProps,
1805
+ seo022TitleLength,
1806
+ seo023DescriptionLength,
1807
+ seo024Charset,
1808
+ seo025ImageAlt,
1809
+ seo026Hreflang,
1810
+ seo027Heading,
1243
1811
  summarize
1244
1812
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Shared, runtime-agnostic core for svelte-vitals (types, rule engine, scorer, reporter).",
5
5
  "type": "module",
6
6
  "license": "MIT",