@svelte-vitals/vite 0.29.7 → 0.30.1

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Vite/SvelteKit plugin for [svelte-vitals](https://github.com/oekazuma/svelte-vitals). Two things in one package:
6
6
 
7
- - **Build-time gate** — piggybacks on `vite build`, parses the **prerendered HTML's `<head>`**, and runs the same rule engine as the CLI (SEO, Performance, Correctness, Security, Architecture) — library-agnostic, because it inspects the real output. Fails the build when findings reach `failOn`.
7
+ - **Build-time gate** — piggybacks on `vite build`, parses the **prerendered HTML** (head metadata, images, headings, landmarks and ids), and runs the same rule engine as the CLI (SEO, Performance, Correctness, Security, Architecture, Accessibility) — library-agnostic, because it inspects the real output. Fails the build when findings reach `failOn`.
8
8
  - **Live dev dashboard** — on by default during `vite dev`: a searchable, filterable code-health dashboard at `/__svelte-vitals/`, covering the whole project from startup via static analysis, refined to real rendered values as you browse.
9
9
 
10
10
  > **ESM-only** (Node 22.13+). Ships ES modules only; `require()` is unsupported by design.
@@ -1,5 +1,6 @@
1
1
  // src/providers/rendered/parse-html.ts
2
2
  import { parse } from "node-html-parser";
3
+ import { decodeFragmentId, splitTokens, isTopFragment, LANDMARK_ROLES, IDREF_ATTRS } from "@svelte-vitals/core";
3
4
  function attrValue(v) {
4
5
  return v !== void 0 && v.trim().length > 0 ? "static" : "absent";
5
6
  }
@@ -26,6 +27,49 @@ function isClassicScriptType(type) {
26
27
  const normalized = type.trim().toLowerCase();
27
28
  return normalized === "" || JS_MIME_TYPES.has(normalized);
28
29
  }
30
+ var SECTIONING_TAGS = /* @__PURE__ */ new Set(["article", "aside", "main", "nav", "section"]);
31
+ function collectA11y(root) {
32
+ const landmarks = [];
33
+ const nestedLandmarks = [];
34
+ const ids = [];
35
+ const idRefs = [];
36
+ const walk = (el, ctx) => {
37
+ const tag = el.rawTagName?.toLowerCase();
38
+ const roleAttr = el.getAttribute("role");
39
+ let landmark;
40
+ if (roleAttr !== void 0) {
41
+ const role = splitTokens(roleAttr)[0];
42
+ landmark = role && LANDMARK_ROLES.has(role) ? role : void 0;
43
+ } else if (tag === "main") {
44
+ landmark = "main";
45
+ } else if ((tag === "header" || tag === "footer") && ctx.sectioning === 0) {
46
+ landmark = tag === "header" ? "banner" : "contentinfo";
47
+ }
48
+ if (landmark) {
49
+ landmarks.push(landmark);
50
+ const within = ctx.landmarks.at(-1);
51
+ if (within) nestedLandmarks.push({ kind: landmark, within });
52
+ }
53
+ const id = el.getAttribute("id");
54
+ if (id && id.trim()) ids.push(id);
55
+ const href = el.getAttribute("href");
56
+ if (href && href.startsWith("#") && href.length > 1) {
57
+ const fragment = decodeFragmentId(href.slice(1));
58
+ if (!isTopFragment(fragment)) idRefs.push({ id: fragment, attr: "href" });
59
+ }
60
+ for (const attr of IDREF_ATTRS) {
61
+ for (const token of splitTokens(el.getAttribute(attr) ?? void 0)) idRefs.push({ id: token, attr });
62
+ }
63
+ if (tag === "template") return;
64
+ const nextCtx = {
65
+ sectioning: ctx.sectioning + (SECTIONING_TAGS.has(tag) ? 1 : 0),
66
+ landmarks: landmark ? [...ctx.landmarks, landmark] : ctx.landmarks
67
+ };
68
+ for (const child of el.children) walk(child, nextCtx);
69
+ };
70
+ for (const child of root.children) walk(child, { sectioning: 0, landmarks: [] });
71
+ return { landmarks, nestedLandmarks, ids, idRefs };
72
+ }
29
73
  function parseHtmlHead(html) {
30
74
  const root = parse(html);
31
75
  const head = root.querySelector("head") ?? root;
@@ -63,9 +107,9 @@ function parseHtmlHead(html) {
63
107
  });
64
108
  }
65
109
  for (const link of head.querySelectorAll("link")) {
66
- const rel = link.getAttribute("rel");
110
+ const rel = link.getAttribute("rel")?.toLowerCase();
67
111
  if (!rel) continue;
68
- const asAttr = link.getAttribute("as");
112
+ const asAttr = link.getAttribute("as")?.toLowerCase();
69
113
  const hasCrossorigin = link.hasAttribute("crossorigin");
70
114
  const hreflang = link.getAttribute("hreflang");
71
115
  const href = link.getAttribute("href");
@@ -118,7 +162,63 @@ function parseHtmlHead(html) {
118
162
  hasSrcset: img.hasAttribute("srcset"),
119
163
  line: 0
120
164
  }));
121
- return { tags, htmlLang, headings, images };
165
+ const a11y = collectA11y(root);
166
+ return { tags, htmlLang, headings, images, ...a11y };
167
+ }
168
+
169
+ // src/providers/rendered/collect.ts
170
+ import { readFile } from "fs/promises";
171
+ import { join } from "path";
172
+ import { glob } from "tinyglobby";
173
+ function toOccurrenceMap(keys, file) {
174
+ const out = /* @__PURE__ */ Object.create(null);
175
+ for (const key of keys) (out[key] ??= []).push({ file, line: 0 });
176
+ return out;
177
+ }
178
+ function deriveRouteFromHtmlPath(relPath) {
179
+ let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
180
+ if (p === "index") return "/";
181
+ if (p.endsWith("/index")) p = p.slice(0, -"/index".length);
182
+ return "/" + p;
183
+ }
184
+ async function collectRenderedHeads(prerenderPagesDir) {
185
+ const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
186
+ const parsedFiles = await Promise.all(
187
+ files.map(async (rel) => ({ rel, parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8")) }))
188
+ );
189
+ const heads = [];
190
+ const headings = [];
191
+ const images = [];
192
+ const a11y = [];
193
+ let htmlLang = { presence: "none", value: "absent" };
194
+ for (const { rel, parsed } of parsedFiles) {
195
+ if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
196
+ const route = deriveRouteFromHtmlPath(rel);
197
+ heads.push({
198
+ route,
199
+ source: "rendered",
200
+ tags: parsed.tags,
201
+ file: rel
202
+ });
203
+ headings.push({
204
+ route,
205
+ // Rendered mode does not track source lines (line 0 = unknown); file is the HTML path.
206
+ headings: parsed.headings.map((level) => ({ level, line: 0, file: rel }))
207
+ });
208
+ images.push({ route, images: parsed.images.map((img) => ({ ...img, file: rel })) });
209
+ a11y.push({
210
+ route,
211
+ landmarks: toOccurrenceMap(parsed.landmarks, rel),
212
+ nestedLandmarks: parsed.nestedLandmarks.map((n) => ({ ...n, file: rel, line: 0 })),
213
+ ids: toOccurrenceMap(parsed.ids, rel),
214
+ idRefs: parsed.idRefs.map((r) => ({ ...r, file: rel, line: 0 })),
215
+ idCandidates: [...new Set(parsed.ids)],
216
+ // The prerendered document IS the closed world: every id/landmark/reference it can ever
217
+ // have is already in it, unlike source mode which may hit an unresolved component.
218
+ fullyResolved: true
219
+ });
220
+ }
221
+ return { heads, headings, images, a11y, htmlLang };
122
222
  }
123
223
 
124
224
  // src/loopback.ts
@@ -133,5 +233,7 @@ function isLoopbackOrigin(origin) {
133
233
 
134
234
  export {
135
235
  parseHtmlHead,
236
+ toOccurrenceMap,
237
+ collectRenderedHeads,
136
238
  isLoopbackOrigin
137
239
  };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  isLoopbackOrigin,
3
- parseHtmlHead
4
- } from "../chunk-EHCC47NO.js";
3
+ parseHtmlHead,
4
+ toOccurrenceMap
5
+ } from "../chunk-6FDRI4Q6.js";
5
6
 
6
7
  // src/hooks/handle.ts
7
8
  import { DEV } from "esm-env";
@@ -42,17 +43,38 @@ async function postIngest(origin, route, results, failedRuleIds) {
42
43
  }
43
44
  async function analyzeAndIngest(html, route, origin, rules, config, lastSignature) {
44
45
  try {
45
- const { tags, htmlLang, headings: levels, images: imgs } = parseHtmlHead(html);
46
+ const {
47
+ tags,
48
+ htmlLang,
49
+ headings: levels,
50
+ images: imgs,
51
+ landmarks,
52
+ nestedLandmarks,
53
+ ids,
54
+ idRefs
55
+ } = parseHtmlHead(html);
46
56
  const head = { route, source: "rendered", tags, file: route };
47
57
  const headings = [
48
58
  { route, headings: levels.map((level) => ({ level, line: 0, file: route })) }
49
59
  ];
50
60
  const images = [{ route, images: imgs.map((img) => ({ ...img, file: route })) }];
61
+ const a11y = [
62
+ {
63
+ route,
64
+ landmarks: toOccurrenceMap(landmarks, route),
65
+ nestedLandmarks: nestedLandmarks.map((n) => ({ ...n, file: route, line: 0 })),
66
+ ids: toOccurrenceMap(ids, route),
67
+ idRefs: idRefs.map((r) => ({ ...r, file: route, line: 0 })),
68
+ idCandidates: [...new Set(ids)],
69
+ fullyResolved: true
70
+ }
71
+ ];
51
72
  const project = { hasRobotsTxt: true, hasSitemap: true, htmlLang };
52
73
  const { results: ruleResults, failedRules } = await runRules(rules, {
53
74
  heads: [head],
54
75
  headings,
55
76
  images,
77
+ a11y,
56
78
  project,
57
79
  config
58
80
  });
@@ -84,15 +106,8 @@ function svelteVitalsHandle(options = {}) {
84
106
  return resolve(event, {
85
107
  transformPageChunk: ({ html, done }) => {
86
108
  buffer += html;
87
- if (done)
88
- void analyzeAndIngest(
89
- buffer,
90
- event.route.id ?? event.url.pathname,
91
- event.url.origin,
92
- rules,
93
- config,
94
- lastSignature
95
- );
109
+ if (done && event.route.id != null)
110
+ void analyzeAndIngest(buffer, event.route.id, event.url.origin, rules, config, lastSignature);
96
111
  return html;
97
112
  }
98
113
  });
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import {
2
- isLoopbackOrigin,
3
- parseHtmlHead
4
- } from "./chunk-EHCC47NO.js";
2
+ collectRenderedHeads,
3
+ isLoopbackOrigin
4
+ } from "./chunk-6FDRI4Q6.js";
5
5
 
6
6
  // src/plugin.ts
7
7
  import { existsSync } from "fs";
8
8
  import { writeFile, mkdir } from "fs/promises";
9
- import { join as join4, isAbsolute, dirname, relative as relative3, basename, sep as sep2 } from "path";
9
+ import { join as join3, isAbsolute, dirname, relative as relative3, basename, sep as sep2 } from "path";
10
10
  import {
11
11
  CATEGORIES as CATEGORIES2,
12
12
  defaultConfig,
@@ -35,47 +35,9 @@ import {
35
35
  } from "@svelte-vitals/core";
36
36
  import { loadConfigFile } from "svelte-vitals";
37
37
 
38
- // src/providers/rendered/collect.ts
39
- import { readFile } from "fs/promises";
40
- import { join } from "path";
41
- import { glob } from "tinyglobby";
42
- function deriveRouteFromHtmlPath(relPath) {
43
- let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
44
- if (p === "index") return "/";
45
- if (p.endsWith("/index")) p = p.slice(0, -"/index".length);
46
- return "/" + p;
47
- }
48
- async function collectRenderedHeads(prerenderPagesDir) {
49
- const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
50
- const parsedFiles = await Promise.all(
51
- files.map(async (rel) => ({ rel, parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8")) }))
52
- );
53
- const heads = [];
54
- const headings = [];
55
- const images = [];
56
- let htmlLang = { presence: "none", value: "absent" };
57
- for (const { rel, parsed } of parsedFiles) {
58
- if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
59
- const route = deriveRouteFromHtmlPath(rel);
60
- heads.push({
61
- route,
62
- source: "rendered",
63
- tags: parsed.tags,
64
- file: rel
65
- });
66
- headings.push({
67
- route,
68
- // Rendered mode does not track source lines (line 0 = unknown); file is the HTML path.
69
- headings: parsed.headings.map((level) => ({ level, line: 0, file: rel }))
70
- });
71
- images.push({ route, images: parsed.images.map((img) => ({ ...img, file: rel })) });
72
- }
73
- return { heads, headings, images, htmlLang };
74
- }
75
-
76
38
  // src/providers/rendered/project.ts
77
- import { access, readFile as readFile2 } from "fs/promises";
78
- import { join as join2 } from "path";
39
+ import { access, readFile } from "fs/promises";
40
+ import { join } from "path";
79
41
  import {
80
42
  ROBOTS_SOURCE_PATHS,
81
43
  SITEMAP_SOURCE_PATHS,
@@ -93,22 +55,22 @@ async function exists(path) {
93
55
  }
94
56
  }
95
57
  async function existsAny(cwd, paths) {
96
- const found = await Promise.all(paths.map((p) => exists(join2(cwd, p))));
58
+ const found = await Promise.all(paths.map((p) => exists(join(cwd, p))));
97
59
  return found.some(Boolean);
98
60
  }
99
61
  async function robotsRefsSitemap(cwd) {
100
62
  try {
101
- return /^\s*sitemap:/im.test(await readFile2(join2(cwd, "static/robots.txt"), "utf8"));
63
+ return /^\s*sitemap:/im.test(await readFile(join(cwd, "static/robots.txt"), "utf8"));
102
64
  } catch {
103
65
  return void 0;
104
66
  }
105
67
  }
106
68
  async function readFirstConfig(cwd, files) {
107
69
  for (const file of files) {
108
- const path = join2(cwd, file);
70
+ const path = join(cwd, file);
109
71
  if (!await exists(path)) continue;
110
72
  try {
111
- return { file, source: await readFile2(path, "utf8") };
73
+ return { file, source: await readFile(path, "utf8") };
112
74
  } catch {
113
75
  return void 0;
114
76
  }
@@ -136,16 +98,16 @@ async function collectRenderedProject(cwd, htmlLang) {
136
98
  }
137
99
 
138
100
  // src/providers/source/components.ts
139
- import { readFile as readFile3, access as access2 } from "fs/promises";
140
- import { join as join3 } from "path";
141
- import { glob as glob2 } from "tinyglobby";
101
+ import { readFile as readFile2, access as access2 } from "fs/promises";
102
+ import { join as join2 } from "path";
103
+ import { glob } from "tinyglobby";
142
104
  import {
143
105
  collectComponentFacts as collect,
144
106
  collectKitModuleFacts as collectKit,
145
107
  collectSourceFiles as collectFiles
146
108
  } from "@svelte-vitals/core";
147
109
  var nodeRuntime = {
148
- readFile: (path) => readFile3(path, "utf8"),
110
+ readFile: (path) => readFile2(path, "utf8"),
149
111
  async exists(path) {
150
112
  try {
151
113
  await access2(path);
@@ -154,8 +116,8 @@ var nodeRuntime = {
154
116
  return false;
155
117
  }
156
118
  },
157
- glob: (pattern, cwd) => glob2(pattern, { cwd, dot: false }),
158
- join: (...parts) => join3(...parts)
119
+ glob: (pattern, cwd) => glob(pattern, { cwd, dot: false }),
120
+ join: (...parts) => join2(...parts)
159
121
  };
160
122
  function collectComponentFacts(root) {
161
123
  return collect(nodeRuntime, root);
@@ -207,7 +169,7 @@ async function resolveConfig(cwd, options) {
207
169
  }
208
170
  async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resolved) {
209
171
  const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
210
- const [{ heads, headings, images, htmlLang }, components, sourceFiles] = await Promise.all([
172
+ const [{ heads, headings, images, a11y, htmlLang }, components, sourceFiles] = await Promise.all([
211
173
  collectRenderedHeads(prerenderPagesDir),
212
174
  collectComponentFacts(cwd),
213
175
  collectSourceFiles(cwd)
@@ -226,6 +188,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
226
188
  heads,
227
189
  headings,
228
190
  images,
191
+ a11y,
229
192
  project,
230
193
  components,
231
194
  config,
@@ -242,7 +205,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
242
205
  const summary = summarize(results, scoringConfig);
243
206
  const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
244
207
  const coverageNote = `Analyzed ${heads.length} prerendered route(s). SSR/dynamic routes are not covered \u2014 run \`npx svelte-vitals\` for those.
245
- Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Bundle findings.`;
208
+ Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Accessibility/Bundle findings.`;
246
209
  const consoleReport = formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n";
247
210
  const jsonReport = formatJsonReport(
248
211
  results,
@@ -265,7 +228,7 @@ Scanned ${components.length} component(s) under src/ for Correctness/Security/Ar
265
228
  }
266
229
 
267
230
  // src/minify-flag.ts
268
- import { readFile as readFile4 } from "fs/promises";
231
+ import { readFile as readFile3 } from "fs/promises";
269
232
  import { relative } from "path";
270
233
  import { findMinifyDisabled } from "@svelte-vitals/core";
271
234
  async function resolveMinifyDisabled(minify, configFile, root) {
@@ -275,7 +238,7 @@ async function resolveMinifyDisabled(minify, configFile, root) {
275
238
  const file = rel === "" ? configFile.split("\\").join("/") : rel;
276
239
  let line;
277
240
  try {
278
- line = findMinifyDisabled(await readFile4(configFile, "utf8"))?.line;
241
+ line = findMinifyDisabled(await readFile3(configFile, "utf8"))?.line;
279
242
  } catch {
280
243
  }
281
244
  return { file, ...line !== void 0 ? { line } : {} };
@@ -626,23 +589,27 @@ function svelteVitals(options = {}) {
626
589
  validateRulesOption(options.rules);
627
590
  validateOverridesOption(options.overrides, options.rules);
628
591
  let root = options.cwd ?? process.cwd();
629
- let minifyFlag;
592
+ let minify;
593
+ let configFile;
630
594
  const buildPlugin = {
631
595
  name: "svelte-vitals",
632
596
  apply: "build",
633
597
  enforce: "post",
598
+ config(userConfig) {
599
+ minify = userConfig.build?.minify;
600
+ },
634
601
  configResolved(config) {
635
602
  if (!options.cwd) root = config.root;
636
- if (!config.build.ssr) minifyFlag = { minify: config.build.minify, configFile: config.configFile };
603
+ configFile = config.configFile;
637
604
  },
638
605
  async closeBundle() {
639
- const pagesDir = options.prerenderDir ? options.prerenderDir : join4(root, DEFAULT_PRERENDER_DIR);
640
- const resolved = isAbsolute(pagesDir) ? pagesDir : join4(root, pagesDir);
606
+ const pagesDir = options.prerenderDir ? options.prerenderDir : join3(root, DEFAULT_PRERENDER_DIR);
607
+ const resolved = isAbsolute(pagesDir) ? pagesDir : join3(root, pagesDir);
641
608
  if (!existsSync(resolved)) return;
642
609
  const resolvedConfig = await resolveConfig(root, options);
643
610
  let result;
644
611
  try {
645
- const viteMinifyDisabled = minifyFlag ? await resolveMinifyDisabled(minifyFlag.minify, minifyFlag.configFile, root) : void 0;
612
+ const viteMinifyDisabled = await resolveMinifyDisabled(minify, configFile, root);
646
613
  result = await analyze(
647
614
  resolved,
648
615
  root,
@@ -661,7 +628,7 @@ function svelteVitals(options = {}) {
661
628
  console.log(out);
662
629
  }
663
630
  if (options.outFile) {
664
- const outPath = isAbsolute(options.outFile) ? options.outFile : join4(root, options.outFile);
631
+ const outPath = isAbsolute(options.outFile) ? options.outFile : join3(root, options.outFile);
665
632
  await mkdir(dirname(outPath), { recursive: true });
666
633
  await writeFile(outPath, result.jsonReport);
667
634
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.29.7",
3
+ "version": "0.30.1",
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",
@@ -42,8 +42,8 @@
42
42
  "esm-env": "^1.2.2",
43
43
  "node-html-parser": "^9.0.1",
44
44
  "tinyglobby": "^0.2.17",
45
- "@svelte-vitals/core": "0.42.0",
46
- "svelte-vitals": "0.46.0"
45
+ "@svelte-vitals/core": "0.43.1",
46
+ "svelte-vitals": "0.47.1"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@sveltejs/kit": "^2.70.2",