@svelte-vitals/vite 0.36.2 → 0.36.4

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.
@@ -1,4 +1,4 @@
1
- import { a as parseHtmlHead, r as toOccurrenceMap, t as isLoopbackOrigin } from "../loopback-OZjFnUGp.js";
1
+ import { i as toOccurrenceMap, r as parseHtmlHead, t as isLoopbackOrigin } from "../loopback-D9pGJXl6.js";
2
2
  import { defineConfig } from "@svelte-vitals/core";
3
3
  import { allRules, defaultProject, effectiveSeverity, formatFailedRuleWarning, isPenalized, runAnalysis, selectRules, terminalSafe } from "@svelte-vitals/core/internal";
4
4
  import { DEV } from "esm-env";
@@ -8,24 +8,37 @@ function findingSignature(results, config) {
8
8
  return results.filter((r) => isPenalized(r.detection, config.treatDynamicAs)).map((r) => `${r.id}:${effectiveSeverity(r, config)}:${r.detection.presence}:${r.detection.value}`).sort().join("|");
9
9
  }
10
10
  const warn = (line) => console.warn(terminalSafe(line));
11
+ /** A dashboard that never answers must not block the route's later ingests forever. */
12
+ const INGEST_TIMEOUT_MS = 1e4;
13
+ /** True only when the dashboard acknowledged the POST. */
11
14
  async function postIngest(origin, route, results, failedRuleIds) {
12
15
  if (!isLoopbackOrigin(origin)) {
13
16
  if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) warn(`svelte-vitals: live UI ingest skipped for non-loopback origin ${origin} — open the dashboard via localhost`);
14
- return;
17
+ return false;
15
18
  }
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), INGEST_TIMEOUT_MS);
16
21
  try {
17
- await fetch(`${origin}/__svelte-vitals/ingest`, {
22
+ const res = await fetch(`${origin}/__svelte-vitals/ingest`, {
18
23
  method: "POST",
19
24
  headers: { "content-type": "application/json" },
20
25
  body: JSON.stringify({
21
26
  route,
22
27
  results,
23
28
  failedRuleIds
24
- })
29
+ }),
30
+ signal: controller.signal
25
31
  });
26
- } catch {}
32
+ if (!res.ok && globalThis.process?.env?.SVELTE_VITALS_DEBUG) warn(`svelte-vitals: live UI ingest for ${route} rejected with HTTP ${res.status}`);
33
+ return res.ok;
34
+ } catch (err) {
35
+ if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) warn(`svelte-vitals: live UI ingest for ${route} failed: ${err instanceof Error ? err.message : String(err)}`);
36
+ return false;
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
27
40
  }
28
- async function analyzeAndIngest(html, route, origin, rules, config, lastSignature) {
41
+ async function analyzeAndIngest(html, route, origin, rules, config, queued, inflight) {
29
42
  try {
30
43
  const { tags, headings: levels, images: imgs, landmarks, nestedLandmarks, ids, idRefs } = parseHtmlHead(html);
31
44
  const head = {
@@ -76,9 +89,19 @@ async function analyzeAndIngest(html, route, origin, rules, config, lastSignatur
76
89
  }, /* @__PURE__ */ new Map());
77
90
  if (failedRules.length > 0 && globalThis.process?.env?.SVELTE_VITALS_DEBUG) for (const f of failedRules) warn(formatFailedRuleWarning(f));
78
91
  const signature = `${findingSignature(results, config)}|failed:${[...failedRuleIds].sort().join(",")}`;
79
- if (lastSignature.get(route) === signature) return;
80
- lastSignature.set(route, signature);
81
- if (globalThis.process?.env?.SVELTE_VITALS_UI) postIngest(origin, route, results, failedRuleIds);
92
+ if (queued.get(route)?.signature === signature) return;
93
+ if (!globalThis.process?.env?.SVELTE_VITALS_UI) return;
94
+ const entry = { signature };
95
+ queued.set(route, entry);
96
+ const next = (inflight.get(route) ?? Promise.resolve()).then(async () => {
97
+ if (!await postIngest(origin, route, results, failedRuleIds) && queued.get(route) === entry) queued.delete(route);
98
+ });
99
+ inflight.set(route, next);
100
+ try {
101
+ await next;
102
+ } finally {
103
+ if (inflight.get(route) === next) inflight.delete(route);
104
+ }
82
105
  } catch (err) {
83
106
  if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) warn(`svelte-vitals: dev analysis failed: ${err instanceof Error ? err.message : String(err)}`);
84
107
  }
@@ -96,12 +119,13 @@ function svelteVitalsHandle(options = {}) {
96
119
  rules: options.rules ?? {}
97
120
  });
98
121
  const rules = selectRules(allRules, config).filter((r) => r.scope === "route" && !r.crossRoute);
99
- const lastSignature = /* @__PURE__ */ new Map();
122
+ const queued = /* @__PURE__ */ new Map();
123
+ const inflight = /* @__PURE__ */ new Map();
100
124
  return ({ event, resolve }) => {
101
125
  let buffer = "";
102
126
  return resolve(event, { transformPageChunk: ({ html, done }) => {
103
127
  buffer += html;
104
- if (done && event.route.id != null) analyzeAndIngest(buffer, event.route.id, event.url.origin, rules, config, lastSignature);
128
+ if (done && event.route.id != null) analyzeAndIngest(buffer, event.route.id, event.url.origin, rules, config, queued, inflight);
105
129
  return html;
106
130
  } });
107
131
  };
package/dist/index.js CHANGED
@@ -1,10 +1,88 @@
1
- import { i as globFiles, n as collectRenderedHeads, t as isLoopbackOrigin } from "./loopback-OZjFnUGp.js";
1
+ import { i as toOccurrenceMap, n as isSameOrigin, r as parseHtmlHead, t as isLoopbackOrigin } from "./loopback-D9pGJXl6.js";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { CATEGORIES, defineConfig } from "@svelte-vitals/core";
6
- import { ROBOTS_SOURCE_PATHS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, addFactsDirectives, allRules, buildJsonReport, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, computeScore, defaultConfig, findMinifyDisabled, formatConsoleReport, formatFailedRuleWarning, formatJsonReport, hasFailureAtOrAbove, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, runAnalysis, safeHref, selectRules, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff, withReadLimit } from "@svelte-vitals/core/internal";
7
- import { CONFIG_FILENAMES, analyzeProject, findUnknownRuleIds, knownRuleIds, loadConfigFile, registryTag, ruleOptionsSpec } from "svelte-vitals";
6
+ import { ROBOTS_SOURCE_PATHS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, addFactsDirectives, allRules, buildJsonReport, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, computeScore, defaultConfig, findMinifyDisabled, formatConsoleReport, formatFailedRuleWarning, formatJsonReport, hasFailureAtOrAbove, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, runAnalysis, safeHref, selectRules, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff } from "@svelte-vitals/core/internal";
7
+ import { CONFIG_FILENAMES, analyzeProject, createNodeRuntime, findUnknownRuleIds, knownRuleIds, loadConfigFile, registryTag, ruleOptionsSpec } from "svelte-vitals";
8
+ //#region src/providers/rendered/collect.ts
9
+ /** Map a prerendered HTML path (relative to pages/, POSIX) to its route. */
10
+ function deriveRouteFromHtmlPath(relPath) {
11
+ let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
12
+ if (p === "index") return "/";
13
+ if (p.endsWith("/index")) p = p.slice(0, -6);
14
+ return "/" + p;
15
+ }
16
+ /** Read every prerendered HTML page under `prerenderPagesDir` into ResolvedHead[]. */
17
+ async function collectRenderedHeads(prerenderPagesDir) {
18
+ const rt = createNodeRuntime();
19
+ const files = (await rt.glob("**/*.html", prerenderPagesDir)).sort();
20
+ const parsedFiles = await Promise.all(files.map(async (rel) => ({
21
+ rel,
22
+ parsed: parseHtmlHead(await rt.readFile(rt.join(prerenderPagesDir, rel)))
23
+ })));
24
+ const heads = [];
25
+ const headings = [];
26
+ const images = [];
27
+ const a11y = [];
28
+ let htmlLang = {
29
+ presence: "none",
30
+ value: "absent"
31
+ };
32
+ for (const { rel, parsed } of parsedFiles) {
33
+ if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
34
+ const route = deriveRouteFromHtmlPath(rel);
35
+ heads.push({
36
+ route,
37
+ source: "rendered",
38
+ tags: parsed.tags,
39
+ file: rel
40
+ });
41
+ headings.push({
42
+ route,
43
+ headings: parsed.headings.map((level) => ({
44
+ level,
45
+ line: 0,
46
+ file: rel
47
+ }))
48
+ });
49
+ images.push({
50
+ route,
51
+ images: parsed.images.map((img) => ({
52
+ ...img,
53
+ file: rel
54
+ }))
55
+ });
56
+ a11y.push({
57
+ route,
58
+ landmarks: toOccurrenceMap(parsed.landmarks, rel),
59
+ nestedLandmarks: parsed.nestedLandmarks.map((n) => ({
60
+ ...n,
61
+ file: rel,
62
+ line: 0
63
+ })),
64
+ ids: toOccurrenceMap(parsed.ids, rel),
65
+ idRefs: parsed.idRefs.map((r) => ({
66
+ ...r,
67
+ file: rel,
68
+ line: 0
69
+ })),
70
+ idCandidates: [...new Set(parsed.ids)],
71
+ fullyResolved: true,
72
+ elementTags: parsed.elementTags,
73
+ elementsClosed: true,
74
+ file: rel
75
+ });
76
+ }
77
+ return {
78
+ heads,
79
+ headings,
80
+ images,
81
+ a11y,
82
+ htmlLang
83
+ };
84
+ }
85
+ //#endregion
8
86
  //#region src/providers/rendered/project.ts
9
87
  async function exists(path) {
10
88
  try {
@@ -61,25 +139,7 @@ async function collectRenderedProject(cwd, htmlLang) {
61
139
  }
62
140
  //#endregion
63
141
  //#region src/providers/source/components.ts
64
- /**
65
- * Node-backed Runtime adapter (design §8). vite always runs in Node, so no
66
- * swappable runtime is needed here — this just satisfies the interface the
67
- * shared core implementation expects.
68
- */
69
- const boundedRead = withReadLimit((path) => readFile(path, "utf8"));
70
- const nodeRuntime = {
71
- readFile: (path) => boundedRead(path),
72
- async exists(path) {
73
- try {
74
- await access(path);
75
- return true;
76
- } catch {
77
- return false;
78
- }
79
- },
80
- glob: (pattern, cwd) => globFiles(pattern, cwd),
81
- join: (...parts) => join(...parts)
82
- };
142
+ const nodeRuntime = createNodeRuntime();
83
143
  /**
84
144
  * Scan every `.svelte` component under `src/` for Correctness/Security/Architecture/
85
145
  * Bundle-Performance/Accessibility facts (build mode only). Implementation lives in
@@ -304,6 +364,7 @@ const SEVERITIES = /* @__PURE__ */ new Set([
304
364
  "info"
305
365
  ]);
306
366
  const CATEGORY_SET = new Set(CATEGORIES);
367
+ const INGEST_BODY_LIMIT = 4194304;
307
368
  function isOptionalString(v) {
308
369
  return v === void 0 || typeof v === "string";
309
370
  }
@@ -329,6 +390,7 @@ function isResultLike(x) {
329
390
  */
330
391
  function installUiMiddleware(server, config, version, store, coreVersion, getStaticFailedRuleIds) {
331
392
  const clients = /* @__PURE__ */ new Set();
393
+ const currentConfig = () => typeof config === "function" ? config() : config;
332
394
  store.subscribe(() => {
333
395
  for (const res of clients) try {
334
396
  res.write("event: update\ndata: {}\n\n");
@@ -344,7 +406,8 @@ function installUiMiddleware(server, config, version, store, coreVersion, getSta
344
406
  const url = req.url ?? "/";
345
407
  const origin = req.headers.origin;
346
408
  const host = req.headers.host;
347
- if (typeof origin === "string" && !isLoopbackOrigin(origin) || host === void 0 || !isLoopbackOrigin(`http://${host}`)) {
409
+ const isTls = Boolean(req.socket?.encrypted);
410
+ if (host === void 0 || !isLoopbackOrigin(`http://${host}`) || typeof origin === "string" && !isSameOrigin(origin, host, isTls)) {
348
411
  req.resume();
349
412
  res.statusCode = 403;
350
413
  res.end("svelte-vitals dev UI is only available from localhost");
@@ -352,8 +415,23 @@ function installUiMiddleware(server, config, version, store, coreVersion, getSta
352
415
  }
353
416
  if (req.method === "POST" && url.startsWith("/ingest")) {
354
417
  const chunks = [];
355
- req.on("data", (c) => chunks.push(c));
418
+ let received = 0;
419
+ let tooLarge = false;
420
+ req.on("data", (c) => {
421
+ if (tooLarge) return;
422
+ received += c.length;
423
+ if (received > INGEST_BODY_LIMIT) {
424
+ tooLarge = true;
425
+ chunks.length = 0;
426
+ res.statusCode = 413;
427
+ res.end();
428
+ req.destroy();
429
+ return;
430
+ }
431
+ chunks.push(c);
432
+ });
356
433
  req.on("end", () => {
434
+ if (tooLarge) return;
357
435
  try {
358
436
  const { route, results, failedRuleIds } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
359
437
  if (typeof route === "string" && Array.isArray(results)) {
@@ -379,7 +457,7 @@ function installUiMiddleware(server, config, version, store, coreVersion, getSta
379
457
  }
380
458
  if (url.startsWith("/data.json")) {
381
459
  try {
382
- const snapshot = buildSnapshot(store, config, {
460
+ const snapshot = buildSnapshot(store, currentConfig(), {
383
461
  version,
384
462
  coreVersion
385
463
  }, getStaticFailedRuleIds?.());
@@ -392,7 +470,7 @@ function installUiMiddleware(server, config, version, store, coreVersion, getSta
392
470
  return;
393
471
  }
394
472
  try {
395
- const html = renderAppShell(buildSnapshot(store, config, {
473
+ const html = renderAppShell(buildSnapshot(store, currentConfig(), {
396
474
  version,
397
475
  coreVersion
398
476
  }, getStaticFailedRuleIds?.()));
@@ -439,6 +517,12 @@ function composeBadges(staticResults, liveByRoute) {
439
517
  for (const route of liveByRoute.keys()) badges[route] = "measured";
440
518
  return badges;
441
519
  }
520
+ /**
521
+ * In-memory findings store for the dev UI. Owned by the ui plugin, shared between the
522
+ * analysis runner (writes the static, whole-project layer) and the ingest middleware
523
+ * (writes the live, per-route layer from rendered pages). `snapshot()`/`badges()` expose
524
+ * the merged view per the design doc (2026-07-08-dev-dashboard-whole-project-design.md §2).
525
+ */
442
526
  function createStore() {
443
527
  let staticResults = [];
444
528
  const liveByRoute = /* @__PURE__ */ new Map();
@@ -451,6 +535,11 @@ function createStore() {
451
535
  for (const fn of subs) fn();
452
536
  }
453
537
  return {
538
+ /**
539
+ * Replace a route's live findings (route stamped onto results missing one) and notify
540
+ * subscribers. `failedRuleIds` replaces that route's live-layer failed-rule set — an
541
+ * omitted or empty array clears it, so a route re-analyzed with no failures recovers.
542
+ */
454
543
  set(route, results, failedRuleIds) {
455
544
  liveByRoute.set(route, results.map((r) => r.route ? r : {
456
545
  ...r,
@@ -464,6 +553,7 @@ function createStore() {
464
553
  staticResults = results;
465
554
  notify();
466
555
  },
556
+ /** Whether a whole-project analysis run is in flight; participates in subscribe/notify like a findings change. */
467
557
  setAnalyzing(next) {
468
558
  analyzing = next;
469
559
  notify();
@@ -471,15 +561,19 @@ function createStore() {
471
561
  isAnalyzing() {
472
562
  return analyzing;
473
563
  },
564
+ /** Composed findings across both layers — feed straight into buildJsonReport. */
474
565
  snapshot() {
475
566
  return composeSnapshot(staticResults, liveByRoute);
476
567
  },
568
+ /** Per-route provenance for the dashboard's badges: 'measured' (live) or 'static'. */
477
569
  badges() {
478
570
  return composeBadges(staticResults, liveByRoute);
479
571
  },
572
+ /** Union of failed rule ids across every live (ingested) route, for `withFailedRulesOff`. */
480
573
  failedRuleIds() {
481
574
  return [...new Set([...liveFailedByRoute.values()].flat())].sort();
482
575
  },
576
+ /** Bumped once per notify() — lets consumers discard stale fetches. */
483
577
  sequence() {
484
578
  return seq;
485
579
  },
@@ -504,12 +598,13 @@ function createAnalysisRunner(opts) {
504
598
  let running = false;
505
599
  let pending = false;
506
600
  let timer;
601
+ let lastWarningsKey;
507
602
  async function runOnce() {
508
603
  if (stopped) return;
509
604
  running = true;
510
605
  opts.onStatusChange?.(true);
511
606
  try {
512
- const { results, failedRuleIds } = await analyze({
607
+ const result = await analyze({
513
608
  cwd: opts.root,
514
609
  treatDynamicAs: opts.treatDynamicAs,
515
610
  metaComponents: opts.metaComponents,
@@ -517,8 +612,13 @@ function createAnalysisRunner(opts) {
517
612
  failOn: opts.failOn,
518
613
  parseCache
519
614
  });
615
+ const { results, failedRuleIds } = result;
520
616
  if (!stopped) if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
521
617
  else opts.onResults(results);
618
+ const warnings = result.warnings ?? [];
619
+ const key = warnings.join("\n");
620
+ if (!stopped && warnings.length > 0 && key !== lastWarningsKey) opts.onWarnings?.(warnings);
621
+ lastWarningsKey = key;
522
622
  } catch (err) {
523
623
  if (!stopped) opts.onError(err);
524
624
  } finally {
@@ -531,10 +631,12 @@ function createAnalysisRunner(opts) {
531
631
  }
532
632
  }
533
633
  return {
634
+ /** Kick off the first whole-project analysis. Fire-and-forget — never blocks the caller. */
534
635
  start() {
535
636
  if (stopped) return;
536
637
  runOnce();
537
638
  },
639
+ /** A relevant source file changed; schedule a debounced re-analysis. */
538
640
  notifyChange(file) {
539
641
  if (stopped) return;
540
642
  const rel = relative(opts.root, file).split(sep).join("/");
@@ -719,15 +821,23 @@ function svelteVitals(options = {}) {
719
821
  process.env.SVELTE_VITALS_UI = "1";
720
822
  const uiRoot = options.cwd ?? server.config.root;
721
823
  let config;
722
- let warnings;
723
- try {
724
- ({config, warnings} = await resolveConfig(uiRoot, options));
725
- } catch (err) {
726
- warn(`svelte-vitals: config file invalid — dashboard using plugin options/defaults: ${err instanceof Error ? err.message : String(err)}`);
727
- config = mergeConfig(options, void 0);
728
- warnings = [];
729
- }
730
- for (const w of warnings) warn(`svelte-vitals: ${w}`);
824
+ let printedConfigWarnings = /* @__PURE__ */ new Set();
825
+ let configGeneration = 0;
826
+ const applyConfig = async () => {
827
+ const generation = ++configGeneration;
828
+ try {
829
+ const resolved = await resolveConfig(uiRoot, options);
830
+ if (generation !== configGeneration) return;
831
+ config = resolved.config;
832
+ printedConfigWarnings = new Set(resolved.warnings);
833
+ for (const w of resolved.warnings) warn(`svelte-vitals: ${w}`);
834
+ } catch (err) {
835
+ if (generation !== configGeneration) return;
836
+ warn(`svelte-vitals: config file invalid — dashboard using ${config ? "the previous config" : "plugin options/defaults"}: ${err instanceof Error ? err.message : String(err)}`);
837
+ config ??= mergeConfig(options, void 0);
838
+ }
839
+ };
840
+ await applyConfig();
731
841
  const store = createStore();
732
842
  let staticFailedRuleIds = [];
733
843
  const runner = createAnalysisRunner({
@@ -741,17 +851,28 @@ function svelteVitals(options = {}) {
741
851
  staticFailedRuleIds = failedRuleIds ?? [];
742
852
  },
743
853
  onError: (err) => warn(`svelte-vitals: dev analysis failed: ${err instanceof Error ? err.message : String(err)}`),
854
+ onWarnings: (warnings) => {
855
+ for (const w of warnings) if (!printedConfigWarnings.has(w)) warn(`svelte-vitals: ${w}`);
856
+ },
744
857
  onStatusChange: (analyzing) => store.setAnalyzing(analyzing)
745
858
  });
746
859
  runner.start();
860
+ let configTimer;
747
861
  server.watcher?.on("all", (_event, file) => {
748
- if (isRelevant(file, uiRoot)) runner.notifyChange(file);
862
+ if (!isRelevant(file, uiRoot)) return;
863
+ if (CONFIG_FILENAMES.includes(basename(file))) {
864
+ clearTimeout(configTimer);
865
+ configTimer = setTimeout(() => void applyConfig().then(() => runner.notifyChange(file)), 500);
866
+ return;
867
+ }
868
+ runner.notifyChange(file);
749
869
  });
750
870
  server.httpServer?.once("close", () => {
751
871
  delete process.env.SVELTE_VITALS_UI;
872
+ clearTimeout(configTimer);
752
873
  runner.stop();
753
874
  });
754
- installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);
875
+ installUiMiddleware(server, () => config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);
755
876
  if (typeof server.printUrls === "function") {
756
877
  const printUrls = server.printUrls.bind(server);
757
878
  server.printUrls = () => {
@@ -1,5 +1,3 @@
1
- import { glob, readFile, stat } from "node:fs/promises";
2
- import { join, relative, sep } from "node:path";
3
1
  import { ASIDE_DEMOTING_TAGS, IDREF_ATTRS, NAMING_ATTRS, SECTIONING_TAGS, decodeFragmentId, isClassicScriptType, isTopFragment, resolveLandmark, splitTokens, stripTextDirective } from "@svelte-vitals/core/internal";
4
2
  import { parse } from "node-html-parser";
5
3
  //#region src/providers/rendered/parse-html.ts
@@ -180,34 +178,6 @@ function parseHtmlHead(html) {
180
178
  ...collectA11y(root)
181
179
  };
182
180
  }
183
- //#endregion
184
- //#region src/glob.ts
185
- /**
186
- * `node:fs` glob constrained to the contract callers rely on: files only (fs.glob also matches
187
- * directories), cwd-relative POSIX-separated paths on every platform (fs.glob returns platform
188
- * separators), dotfiles excluded (fs.glob's default). A symlink's Dirent never reports isFile(),
189
- * so symlinks are stat'd and kept when they point at a file; symlinked *directories* are not
190
- * traversed — fs.glob never descends into them. Duplicated in the CLI package — core purity
191
- * (no `node:` imports in core) rules out the shared home.
192
- */
193
- async function globFiles(pattern, cwd) {
194
- const out = [];
195
- for await (const entry of glob(pattern, {
196
- cwd,
197
- withFileTypes: true
198
- })) {
199
- const path = join(entry.parentPath, entry.name);
200
- if (!entry.isFile()) {
201
- if (!entry.isSymbolicLink()) continue;
202
- if (!(await stat(path).catch(() => null))?.isFile()) continue;
203
- }
204
- const rel = relative(cwd, path);
205
- out.push(sep === "/" ? rel : rel.split(sep).join("/"));
206
- }
207
- return out;
208
- }
209
- //#endregion
210
- //#region src/providers/rendered/collect.ts
211
181
  /** Group raw occurrence keys (one entry per hit, in document order) by key, `file` attached, `line: 0` (rendered mode does not track source lines). */
212
182
  function toOccurrenceMap(keys, file) {
213
183
  const out = Object.create(null);
@@ -217,81 +187,6 @@ function toOccurrenceMap(keys, file) {
217
187
  });
218
188
  return out;
219
189
  }
220
- /** Map a prerendered HTML path (relative to pages/, POSIX) to its route. */
221
- function deriveRouteFromHtmlPath(relPath) {
222
- let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
223
- if (p === "index") return "/";
224
- if (p.endsWith("/index")) p = p.slice(0, -6);
225
- return "/" + p;
226
- }
227
- /** Read every prerendered HTML page under `prerenderPagesDir` into ResolvedHead[]. */
228
- async function collectRenderedHeads(prerenderPagesDir) {
229
- const files = (await globFiles("**/*.html", prerenderPagesDir)).sort();
230
- const parsedFiles = await Promise.all(files.map(async (rel) => ({
231
- rel,
232
- parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8"))
233
- })));
234
- const heads = [];
235
- const headings = [];
236
- const images = [];
237
- const a11y = [];
238
- let htmlLang = {
239
- presence: "none",
240
- value: "absent"
241
- };
242
- for (const { rel, parsed } of parsedFiles) {
243
- if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
244
- const route = deriveRouteFromHtmlPath(rel);
245
- heads.push({
246
- route,
247
- source: "rendered",
248
- tags: parsed.tags,
249
- file: rel
250
- });
251
- headings.push({
252
- route,
253
- headings: parsed.headings.map((level) => ({
254
- level,
255
- line: 0,
256
- file: rel
257
- }))
258
- });
259
- images.push({
260
- route,
261
- images: parsed.images.map((img) => ({
262
- ...img,
263
- file: rel
264
- }))
265
- });
266
- a11y.push({
267
- route,
268
- landmarks: toOccurrenceMap(parsed.landmarks, rel),
269
- nestedLandmarks: parsed.nestedLandmarks.map((n) => ({
270
- ...n,
271
- file: rel,
272
- line: 0
273
- })),
274
- ids: toOccurrenceMap(parsed.ids, rel),
275
- idRefs: parsed.idRefs.map((r) => ({
276
- ...r,
277
- file: rel,
278
- line: 0
279
- })),
280
- idCandidates: [...new Set(parsed.ids)],
281
- fullyResolved: true,
282
- elementTags: parsed.elementTags,
283
- elementsClosed: true,
284
- file: rel
285
- });
286
- }
287
- return {
288
- heads,
289
- headings,
290
- images,
291
- a11y,
292
- htmlLang
293
- };
294
- }
295
190
  //#endregion
296
191
  //#region src/loopback.ts
297
192
  /** Only the local dev server hosts the ingest endpoint, so never POST off-box. */
@@ -303,5 +198,26 @@ function isLoopbackOrigin(origin) {
303
198
  return false;
304
199
  }
305
200
  }
201
+ /**
202
+ * Whether `origin` (an Origin header value) names exactly the server this request reached —
203
+ * `host` is the Host header, `hostname[:port]`, and `secure` is whether the connection is TLS.
204
+ * Compared as hostname + effective port: each side's omitted port resolves to its own scheme's
205
+ * default (the Origin's from its URL scheme, the Host's from `secure`), so `https://localhost`
206
+ * never matches a plain-http server on :80 and `https://localhost:443` does match a TLS server
207
+ * whose Host is `localhost:443`. The Origin's scheme is otherwise not compared: under
208
+ * `server.https` the Origin is https while the Host header is unchanged.
209
+ */
210
+ function isSameOrigin(origin, host, secure = false) {
211
+ try {
212
+ const o = new URL(origin);
213
+ const h = new URL(`${secure ? "https" : "http"}://${host}`);
214
+ return o.hostname === h.hostname && effectivePort(o) === effectivePort(h);
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ function effectivePort(url) {
220
+ return url.port || (url.protocol === "https:" ? "443" : "80");
221
+ }
306
222
  //#endregion
307
- export { parseHtmlHead as a, globFiles as i, collectRenderedHeads as n, toOccurrenceMap as r, isLoopbackOrigin as t };
223
+ export { toOccurrenceMap as i, isSameOrigin as n, parseHtmlHead as r, isLoopbackOrigin as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.36.2",
3
+ "version": "0.36.4",
4
4
  "description": "Vite/SvelteKit plugin for svelte-vitals — analyzes prerendered HTML during vite build, plus a live dev dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,9 +43,9 @@
43
43
  "node-html-parser": "^9.0.2"
44
44
  },
45
45
  "peerDependencies": {
46
- "@svelte-vitals/core": "^0.51.0",
46
+ "@svelte-vitals/core": "^0.51.2",
47
47
  "@sveltejs/kit": "^2.70.3",
48
- "svelte-vitals": "^0.54.2",
48
+ "svelte-vitals": "^0.54.4",
49
49
  "vite": "^8.2.2"
50
50
  },
51
51
  "peerDependenciesMeta": {
@@ -54,12 +54,12 @@
54
54
  }
55
55
  },
56
56
  "devDependencies": {
57
- "@svelte-vitals/core": "0.51.0",
57
+ "@svelte-vitals/core": "0.51.2",
58
58
  "@sveltejs/kit": "^2.70.3",
59
59
  "@types/node": "^24.13.3",
60
- "happy-dom": "^20.11.15",
60
+ "happy-dom": "^20.13.2",
61
61
  "svelte": "^5.57.0",
62
- "svelte-vitals": "0.54.2",
62
+ "svelte-vitals": "0.54.4",
63
63
  "vite": "^8.2.2"
64
64
  },
65
65
  "scripts": {