svelte-vitals 0.14.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-YMRN4VPN.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))));
@@ -363,6 +366,109 @@ function parseFile(source, filename) {
363
366
  headings
364
367
  };
365
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
+ }
366
472
 
367
473
  // src/providers/source/adapters/svelte-meta-tags.ts
368
474
  function findAttr2(attributes, name) {
@@ -519,27 +625,68 @@ var MAX_DEPTH = 5;
519
625
  function isGroupSegment(segment) {
520
626
  return /^\(.+\)$/.test(segment);
521
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
+ }
522
652
  function deriveRoute(pageRel) {
523
- const inner = pageRel.slice(`${ROUTES_DIR2}/`.length, -"/+page.svelte".length);
524
- const segments = inner.length === 0 ? [] : inner.split("/").filter((s) => !isGroupSegment(s));
653
+ const segments = dirSegments(dirOf(pageRel)).filter((s) => !isGroupSegment(s));
525
654
  return "/" + segments.join("/");
526
655
  }
527
- async function chainFiles(rt, cwd, pageRel) {
528
- const dir = pageRel.slice(0, -"/+page.svelte".length);
529
- const extra = dir.slice(ROUTES_DIR2.length);
530
- const segments = extra.length === 0 ? [] : extra.split("/").filter(Boolean);
531
- const files = [];
532
- let prefix = ROUTES_DIR2;
533
- for (let i = 0; i <= segments.length; i++) {
534
- if (i > 0) prefix = `${prefix}/${segments[i - 1]}`;
535
- const layout = `${prefix}/+layout.svelte`;
536
- if (await rt.exists(rt.join(cwd, layout))) files.push({ rel: layout, isPage: false });
537
- }
538
- files.push({ rel: pageRel, isPage: true });
539
- return files;
540
- }
541
- async function resolveRoute(rt, cwd, pageRel, config) {
542
- 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);
543
690
  const composed = /* @__PURE__ */ new Map();
544
691
  let broadOwn = false;
545
692
  let broadInherited = false;
@@ -578,8 +725,8 @@ async function resolveRoute(rt, cwd, pageRel, config) {
578
725
  };
579
726
  }
580
727
  async function collectRoutes(rt, cwd, config = defaultConfig) {
581
- const pages = await enumerateRoutePages(rt, cwd);
582
- 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)));
583
730
  return {
584
731
  heads: facts.map((f) => f.head),
585
732
  images: facts.map((f) => f.images),
@@ -587,6 +734,21 @@ async function collectRoutes(rt, cwd, config = defaultConfig) {
587
734
  };
588
735
  }
589
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
+
590
752
  // src/version.ts
591
753
  import { readFileSync } from "fs";
592
754
  function readPackageVersion() {
@@ -668,8 +830,12 @@ async function analyzeProject(opts = {}) {
668
830
  const images = collected.images.filter((i) => matches(i.route));
669
831
  const headings = collected.headings.filter((h) => matches(h.route));
670
832
  const project = await collectProjectFacts(rt, cwd);
833
+ const components = opts.route ? [] : await collectComponentFacts(rt, cwd);
671
834
  const rules = selectRules(allRules2, config);
672
- 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
+ );
673
839
  return { results, config, version: readPackageVersion() };
674
840
  }
675
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-YMRN4VPN.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.14.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.15.0"
45
+ "@svelte-vitals/core": "0.16.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^24.7.0"