svelte-vitals 0.13.0 → 0.15.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/bin.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  knownRuleIds,
7
7
  readPackageVersion,
8
8
  run
9
- } from "./chunk-Y4JY3ODE.js";
9
+ } from "./chunk-2NZBWTKF.js";
10
10
 
11
11
  // src/bin.ts
12
12
  import mri from "mri";
@@ -77,8 +77,11 @@ async function detectProject(rt, cwd) {
77
77
  );
78
78
  }
79
79
  async function enumerateRoutePages(rt, cwd) {
80
- const pages = await rt.glob(`${ROUTES_DIR}/**/+page.svelte`, cwd);
81
- return pages.sort();
80
+ const [plain, breakout] = await Promise.all([
81
+ rt.glob(`${ROUTES_DIR}/**/+page.svelte`, cwd),
82
+ rt.glob(`${ROUTES_DIR}/**/+page@*.svelte`, cwd)
83
+ ]);
84
+ return [.../* @__PURE__ */ new Set([...plain, ...breakout])].sort();
82
85
  }
83
86
  async function existsAny(rt, cwd, paths) {
84
87
  const found = await Promise.all(paths.map((p) => rt.exists(rt.join(cwd, p))));
@@ -247,6 +250,7 @@ function tagsFromHead(head) {
247
250
  const asLiteral = attrText(node.attributes, "as");
248
251
  const hasCrossorigin = findAttr(node.attributes, "crossorigin") !== void 0;
249
252
  const hreflang = attrText(node.attributes, "hreflang");
253
+ const href = attrText(node.attributes, "href");
250
254
  tags.push({
251
255
  kind: "link",
252
256
  ...rel ? { rel } : {},
@@ -255,12 +259,22 @@ function tagsFromHead(head) {
255
259
  ...asLiteral ? { as: asLiteral } : {},
256
260
  ...hasCrossorigin ? { hasCrossorigin: true } : {},
257
261
  // Keep a literal empty hreflang="" (present-but-invalid) so SEO026 can flag it.
258
- ...hreflang !== void 0 ? { hreflang } : {}
262
+ ...hreflang !== void 0 ? { hreflang } : {},
263
+ ...href ? { href } : {}
259
264
  });
260
- } else if (node.name === "script" && attrText(node.attributes, "type") === "application/ld+json") {
261
- const nodes = node.fragment?.nodes ?? [];
262
- const raw = textFromNodes(nodes);
263
- tags.push({ kind: "jsonld", value: valueFromNodes(nodes), ...raw !== void 0 ? { jsonld: raw } : {} });
265
+ } else if (node.name === "script") {
266
+ const type = attrText(node.attributes, "type");
267
+ if (type === "application/ld+json") {
268
+ const nodes = node.fragment?.nodes ?? [];
269
+ const raw = textFromNodes(nodes);
270
+ tags.push({ kind: "jsonld", value: valueFromNodes(nodes), ...raw !== void 0 ? { jsonld: raw } : {} });
271
+ } else {
272
+ const src = attrText(node.attributes, "src");
273
+ if (src) {
274
+ const blocking = findAttr(node.attributes, "defer") === void 0 && findAttr(node.attributes, "async") === void 0 && type !== "module";
275
+ tags.push({ kind: "script", value: "static", href: src, ...blocking ? { blocking: true } : {} });
276
+ }
277
+ }
264
278
  }
265
279
  }
266
280
  return tags;
@@ -310,6 +324,9 @@ function collectImages(node, source, acc) {
310
324
  hasHeight: hasSpread || Boolean(findAttr(attrs, "height")),
311
325
  hasLoading: hasSpread || Boolean(findAttr(attrs, "loading")),
312
326
  hasAlt: hasSpread || Boolean(findAttr(attrs, "alt")),
327
+ // A literal loading="lazy" only — a spread or dynamic loading={…} must not be flagged.
328
+ lazy: attrText(attrs, "loading") === "lazy",
329
+ hasSrcset: hasSpread || Boolean(findAttr(attrs, "srcset")),
313
330
  line: lineOf(source, node.start)
314
331
  });
315
332
  }
@@ -349,6 +366,109 @@ function parseFile(source, filename) {
349
366
  headings
350
367
  };
351
368
  }
369
+ function isConstantListEach(node) {
370
+ const expr = node?.expression;
371
+ return expr?.type === "ArrayExpression" && Array.isArray(expr.elements) && !expr.elements.some((el) => el?.type === "SpreadElement");
372
+ }
373
+ function collectEachBlocks(node, source, acc) {
374
+ if (Array.isArray(node)) {
375
+ for (const child of node) collectEachBlocks(child, source, acc);
376
+ return;
377
+ }
378
+ if (!node || typeof node !== "object") return;
379
+ if (node.type === "EachBlock" && !isConstantListEach(node)) {
380
+ acc.push({ hasKey: node.key != null, line: lineOf(source, node.start) });
381
+ }
382
+ for (const key of CHILD_NODE_KEYS) {
383
+ if (key in node) collectEachBlocks(node[key], source, acc);
384
+ }
385
+ }
386
+ function walkEstree(node, visit) {
387
+ if (Array.isArray(node)) {
388
+ for (const child of node) walkEstree(child, visit);
389
+ return;
390
+ }
391
+ if (!node || typeof node !== "object" || typeof node.type !== "string") return;
392
+ visit(node);
393
+ for (const key of Object.keys(node)) {
394
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
395
+ walkEstree(node[key], visit);
396
+ }
397
+ }
398
+ function isEffectCall(node) {
399
+ const c = node?.callee;
400
+ if (c?.type === "Identifier") return c.name === "$effect";
401
+ if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$effect") {
402
+ return c.property?.type === "Identifier" && c.property.name === "pre";
403
+ }
404
+ return false;
405
+ }
406
+ function isStateDeclaration(node) {
407
+ const c = node?.callee;
408
+ if (c?.type === "Identifier") return c.name === "$state";
409
+ if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$state") {
410
+ return c.property?.type === "Identifier" && (c.property.name === "raw" || c.property.name === "frozen");
411
+ }
412
+ return false;
413
+ }
414
+ function bodyOnlyAssignsState(fn, stateNames) {
415
+ const isStateAssign = (expr) => expr?.type === "AssignmentExpression" && expr.operator === "=" && expr.left?.type === "Identifier" && stateNames.has(expr.left.name);
416
+ const body = fn?.body;
417
+ if (!body) return false;
418
+ if (body.type !== "BlockStatement") return isStateAssign(body);
419
+ if (body.body.length === 0) return false;
420
+ return body.body.every((s) => s?.type === "ExpressionStatement" && isStateAssign(s.expression));
421
+ }
422
+ var URL_ATTRS = ["href", "src", "action", "formaction"];
423
+ function collectSecurityFacts(node, source, htmlTags, jsUrls) {
424
+ if (Array.isArray(node)) {
425
+ for (const child of node) collectSecurityFacts(child, source, htmlTags, jsUrls);
426
+ return;
427
+ }
428
+ if (!node || typeof node !== "object") return;
429
+ if (node.type === "HtmlTag") htmlTags.push({ line: lineOf(source, node.start) });
430
+ if ((node.type === "RegularElement" || node.type === "SvelteElement") && Array.isArray(node.attributes)) {
431
+ for (const name of URL_ATTRS) {
432
+ const attr = findAttr(node.attributes, name);
433
+ if (!attr) continue;
434
+ const value = attrTextOf(attr);
435
+ if (value !== void 0 && /^\s*javascript:/i.test(value)) {
436
+ jsUrls.push({ line: lineOf(source, attr.start ?? node.start) });
437
+ }
438
+ }
439
+ }
440
+ for (const key of CHILD_NODE_KEYS) {
441
+ if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
442
+ }
443
+ }
444
+ function parseComponentFacts(source, filename) {
445
+ const ast = parse(source, { modern: true, filename });
446
+ const eachBlocks = [];
447
+ collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
448
+ const htmlTags = [];
449
+ const javascriptUrls = [];
450
+ collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
451
+ const effects = [];
452
+ const program = ast.instance?.content;
453
+ if (program) {
454
+ const stateNames = /* @__PURE__ */ new Set();
455
+ walkEstree(program, (n) => {
456
+ if (n.type === "VariableDeclarator" && n.init && isStateDeclaration(n.init) && n.id?.type === "Identifier") {
457
+ stateNames.add(n.id.name);
458
+ }
459
+ });
460
+ walkEstree(program, (n) => {
461
+ if (n.type !== "CallExpression" || !isEffectCall(n)) return;
462
+ const fn = n.arguments?.[0];
463
+ const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
464
+ effects.push({
465
+ line: lineOf(source, n.start),
466
+ assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false
467
+ });
468
+ });
469
+ }
470
+ return { eachBlocks, effects, htmlTags, javascriptUrls };
471
+ }
352
472
 
353
473
  // src/providers/source/adapters/svelte-meta-tags.ts
354
474
  function findAttr2(attributes, name) {
@@ -443,6 +563,8 @@ function tagKey(tag) {
443
563
  return `link:${tag.rel ?? "?"}`;
444
564
  case "jsonld":
445
565
  return "jsonld";
566
+ case "script":
567
+ return `script:${tag.href ?? "?"}`;
446
568
  }
447
569
  }
448
570
  function resolveComponentPath(source, fromFileRel) {
@@ -503,27 +625,68 @@ var MAX_DEPTH = 5;
503
625
  function isGroupSegment(segment) {
504
626
  return /^\(.+\)$/.test(segment);
505
627
  }
628
+ function dirOf(rel) {
629
+ const i = rel.lastIndexOf("/");
630
+ return i >= 0 ? rel.slice(0, i) : "";
631
+ }
632
+ function dirSegments(dir) {
633
+ const extra = dir.slice(ROUTES_DIR2.length);
634
+ return extra.length === 0 ? [] : extra.split("/").filter(Boolean);
635
+ }
636
+ function dirKey(segs) {
637
+ return segs.length === 0 ? ROUTES_DIR2 : `${ROUTES_DIR2}/${segs.join("/")}`;
638
+ }
639
+ function parseAt(rel) {
640
+ const file = rel.slice(rel.lastIndexOf("/") + 1);
641
+ const m = /^\+(?:page|layout)@(.*)\.svelte$/.exec(file);
642
+ return m ? m[1] : null;
643
+ }
644
+ function atTarget(rel, dirSegs, strictAncestor = false) {
645
+ const at = parseAt(rel);
646
+ if (at === null) return null;
647
+ if (at === "") return [];
648
+ const haystack = strictAncestor ? dirSegs.slice(0, -1) : dirSegs;
649
+ const i = haystack.lastIndexOf(at);
650
+ return i >= 0 ? dirSegs.slice(0, i + 1) : null;
651
+ }
506
652
  function deriveRoute(pageRel) {
507
- const inner = pageRel.slice(`${ROUTES_DIR2}/`.length, -"/+page.svelte".length);
508
- const segments = inner.length === 0 ? [] : inner.split("/").filter((s) => !isGroupSegment(s));
653
+ const segments = dirSegments(dirOf(pageRel)).filter((s) => !isGroupSegment(s));
509
654
  return "/" + segments.join("/");
510
655
  }
511
- async function chainFiles(rt, cwd, pageRel) {
512
- const dir = pageRel.slice(0, -"/+page.svelte".length);
513
- const extra = dir.slice(ROUTES_DIR2.length);
514
- const segments = extra.length === 0 ? [] : extra.split("/").filter(Boolean);
515
- const files = [];
516
- let prefix = ROUTES_DIR2;
517
- for (let i = 0; i <= segments.length; i++) {
518
- if (i > 0) prefix = `${prefix}/${segments[i - 1]}`;
519
- const layout = `${prefix}/+layout.svelte`;
520
- if (await rt.exists(rt.join(cwd, layout))) files.push({ rel: layout, isPage: false });
521
- }
522
- files.push({ rel: pageRel, isPage: true });
523
- return files;
524
- }
525
- async function resolveRoute(rt, cwd, pageRel, config) {
526
- const files = await chainFiles(rt, cwd, pageRel);
656
+ async function collectLayouts(rt, cwd) {
657
+ const [plain, breakout] = await Promise.all([
658
+ rt.glob(`${ROUTES_DIR2}/**/+layout.svelte`, cwd),
659
+ rt.glob(`${ROUTES_DIR2}/**/+layout@*.svelte`, cwd)
660
+ ]);
661
+ const map = /* @__PURE__ */ new Map();
662
+ for (const rel of [...plain, ...breakout]) map.set(dirOf(rel), rel);
663
+ return map;
664
+ }
665
+ function layoutAtOrAbove(segs, layouts) {
666
+ for (let j = segs.length; j >= 0; j--) {
667
+ const rel = layouts.get(dirKey(segs.slice(0, j)));
668
+ if (rel) return { segs: segs.slice(0, j), rel };
669
+ }
670
+ return null;
671
+ }
672
+ function chainFiles(pageRel, layouts) {
673
+ const pageSegs = dirSegments(dirOf(pageRel));
674
+ let dir = atTarget(pageRel, pageSegs) ?? pageSegs;
675
+ const chain = [];
676
+ const seen = /* @__PURE__ */ new Set();
677
+ while (dir !== null) {
678
+ const found = layoutAtOrAbove(dir, layouts);
679
+ if (!found || seen.has(found.rel)) break;
680
+ seen.add(found.rel);
681
+ chain.unshift(found.rel);
682
+ const reset = atTarget(found.rel, found.segs, true);
683
+ dir = reset !== null && reset.length < found.segs.length ? reset : found.segs.slice(0, -1);
684
+ if (dir.length === 0 && found.segs.length === 0) dir = null;
685
+ }
686
+ return [...chain.map((rel) => ({ rel, isPage: false })), { rel: pageRel, isPage: true }];
687
+ }
688
+ async function resolveRoute(rt, cwd, pageRel, config, layouts) {
689
+ const files = chainFiles(pageRel, layouts);
527
690
  const composed = /* @__PURE__ */ new Map();
528
691
  let broadOwn = false;
529
692
  let broadInherited = false;
@@ -562,8 +725,8 @@ async function resolveRoute(rt, cwd, pageRel, config) {
562
725
  };
563
726
  }
564
727
  async function collectRoutes(rt, cwd, config = defaultConfig) {
565
- const pages = await enumerateRoutePages(rt, cwd);
566
- const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config)));
728
+ const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
729
+ const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts)));
567
730
  return {
568
731
  heads: facts.map((f) => f.head),
569
732
  images: facts.map((f) => f.images),
@@ -571,6 +734,21 @@ async function collectRoutes(rt, cwd, config = defaultConfig) {
571
734
  };
572
735
  }
573
736
 
737
+ // src/providers/source/components.ts
738
+ async function collectComponentFacts(rt, cwd) {
739
+ const files = await rt.glob("src/**/*.svelte", cwd);
740
+ return Promise.all(
741
+ files.sort().map(async (rel) => {
742
+ try {
743
+ const source = await rt.readFile(rt.join(cwd, rel));
744
+ return { file: rel, ...parseComponentFacts(source, rel) };
745
+ } catch {
746
+ return { file: rel, eachBlocks: [], effects: [], htmlTags: [], javascriptUrls: [] };
747
+ }
748
+ })
749
+ );
750
+ }
751
+
574
752
  // src/version.ts
575
753
  import { readFileSync } from "fs";
576
754
  function readPackageVersion() {
@@ -652,8 +830,12 @@ async function analyzeProject(opts = {}) {
652
830
  const images = collected.images.filter((i) => matches(i.route));
653
831
  const headings = collected.headings.filter((h) => matches(h.route));
654
832
  const project = await collectProjectFacts(rt, cwd);
833
+ const components = opts.route ? [] : await collectComponentFacts(rt, cwd);
655
834
  const rules = selectRules(allRules2, config);
656
- const results = applyRuleSeverities(await runRules(rules, { heads, images, headings, project, config }), config);
835
+ const results = applyRuleSeverities(
836
+ await runRules(rules, { heads, images, headings, components, project, config }),
837
+ config
838
+ );
657
839
  return { results, config, version: readPackageVersion() };
658
840
  }
659
841
  async function run(opts = {}) {
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  knownRuleIds,
7
7
  routeMatcher,
8
8
  run
9
- } from "./chunk-Y4JY3ODE.js";
9
+ } from "./chunk-2NZBWTKF.js";
10
10
  export {
11
11
  ProjectError,
12
12
  analyzeProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,7 +42,7 @@
42
42
  "mri": "^1.2.0",
43
43
  "svelte": "^5.56.3",
44
44
  "tinyglobby": "^0.2.17",
45
- "@svelte-vitals/core": "0.14.0"
45
+ "@svelte-vitals/core": "0.16.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^24.7.0"