@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.
- package/dist/hooks/index.js +35 -11
- package/dist/index.js +159 -38
- package/dist/{loopback-OZjFnUGp.js → loopback-D9pGJXl6.js} +22 -106
- package/package.json +6 -6
package/dist/hooks/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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))
|
|
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 {
|
|
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.
|
|
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.
|
|
46
|
+
"@svelte-vitals/core": "^0.51.2",
|
|
47
47
|
"@sveltejs/kit": "^2.70.3",
|
|
48
|
-
"svelte-vitals": "^0.54.
|
|
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.
|
|
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.
|
|
60
|
+
"happy-dom": "^20.13.2",
|
|
61
61
|
"svelte": "^5.57.0",
|
|
62
|
-
"svelte-vitals": "0.54.
|
|
62
|
+
"svelte-vitals": "0.54.4",
|
|
63
63
|
"vite": "^8.2.2"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|