@svelte-vitals/vite 0.55.1 → 0.55.3

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
@@ -30,13 +30,13 @@ export default {
30
30
  - `outFile`: write the JSON report to a path.
31
31
  - `rules` / `metaComponents` / `treatDynamicAs`: same as the CLI/core config.
32
32
  - `weights`: per-category weight overrides for the combined Health score.
33
- - `prerenderDir`: override the prerendered-pages directory.
33
+ - `prerenderDir`: override the prerendered-pages directory (default: `output/prerendered/pages` under `kit.outDir`).
34
34
  - `cwd`: project root (defaults to the Vite config root).
35
35
  - `ui`: serve the live dev dashboard (default `true`); pass `false` to keep only the build-time gate.
36
36
 
37
37
  A `svelte-vitals.config.*` and a `svelte-vitals-suppressions.json` in the project root are read automatically (explicit options win over the config file). Only source-scan suppression entries apply; route-level entries are skipped on every plugin surface (rendered route findings anchor to the built HTML) and the plugin says how many.
38
38
 
39
- At build time the HTML check covers prerendered routes only; the source scan applies project-wide. Routes that are not prerendered (server-rendered, or dynamic routes without prerendered entries) have no build output, so nothing checks their rendered HTML here. A prerendered route with `ssr = false` has build output, but only the empty app shell, so the HTML check skips it too (a `+layout` with `ssr = false` skips its subtree) and prints one warning listing the skipped routes. The `svelte-vitals` CLI covers them by source analysis, and browsing them during `vite dev` gets the live dashboard a rendered reading.
39
+ At build time the HTML check covers prerendered routes only; the source scan applies project-wide. Routes that are not prerendered (server-rendered, or dynamic routes without prerendered entries) have no build output, so nothing checks their rendered HTML here. A prerendered route with `ssr = false` has build output, but only the empty app shell, so the HTML check skips it too (a `+layout` with `ssr = false` skips its subtree, and `kit.router.type: 'hash'` every route) and prints one warning listing the skipped routes. The `svelte-vitals` CLI covers them by source analysis, and browsing them during `vite dev` gets the live dashboard a rendered reading. When the build prerenders nothing at all (an SPA served from a fallback page, say), the plugin prints one warning that route analysis was skipped and still runs and gates the source scan; `seo/html-lang`, which reads the rendered `<html>`, is skipped with it.
40
40
 
41
41
  See [Live dashboard](https://oekazuma.github.io/svelte-vitals/guides/dev-dashboard/) and [Plugin mode](https://oekazuma.github.io/svelte-vitals/guides/plugin-mode/) for the full picture.
42
42
 
@@ -1,4 +1,4 @@
1
- import { i as toOccurrenceMap, r as parseHtmlHead, t as isLoopbackOrigin } from "../loopback-D9pGJXl6.js";
1
+ import { i as toOccurrenceMap, r as parseHtmlHead, t as isLoopbackOrigin } from "../loopback-DxxU70UA.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";
@@ -49,8 +49,8 @@ async function analyzeAndIngest(html, route, origin, rules, config, queued, infl
49
49
  };
50
50
  const headings = [{
51
51
  route,
52
- headings: levels.map((level) => ({
53
- level,
52
+ headings: levels.map((h) => ({
53
+ ...h,
54
54
  line: 0,
55
55
  file: route
56
56
  }))
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ interface SvelteVitalsOptions {
19
19
  report?: 'console' | 'json' | false;
20
20
  /** Write the JSON report to this path. */
21
21
  outFile?: string;
22
- /** Override the prerendered-pages directory (default: .svelte-kit/output/prerendered/pages). */
22
+ /** Override the prerendered-pages directory (default: `output/prerendered/pages` under SvelteKit's `kit.outDir`). */
23
23
  prerenderDir?: string;
24
24
  /**
25
25
  * Serve a live dashboard at /__svelte-vitals/ during `vite dev` (add
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import { i as toOccurrenceMap, n as isSameOrigin, r as parseHtmlHead, t as isLoopbackOrigin } from "./loopback-D9pGJXl6.js";
1
+ import { i as toOccurrenceMap, n as isSameOrigin, r as parseHtmlHead, t as isLoopbackOrigin } from "./loopback-DxxU70UA.js";
2
+ import { once } from "node:events";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
6
  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, isPlainObject, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, runAnalysis, safeHref, selectRules, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff, withIndexableOff } from "@svelte-vitals/core/internal";
7
- import { CONFIG_FILENAMES, SUPPRESSIONS_FILE, analyzeProject, applySuppressions, createNodeRuntime, findUnknownRuleIds, knownRuleIds, loadConfigFile, loadSuppressions, registryTag, ruleOptionsSpec } from "svelte-vitals";
7
+ import { ROBOTS_SOURCE_PATHS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, addFactsDirectives, allRules, buildJsonReport, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, computeScore, defaultConfig, findKitHashRouterInSvelteConfig, findMinifyDisabled, formatConsoleReport, formatFailedRuleWarning, formatJsonReportChunks, hasFailureAtOrAbove, isPlainObject, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, routeNeverSsr, runAnalysis, safeHref, selectRules, servesRootFile, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff, withIndexableOff, withPackageImports, withWorkspacePackages } from "@svelte-vitals/core/internal";
8
+ import { CONFIG_FILENAMES, SUPPRESSIONS_FILE, analyzeProject, applySuppressions, createNodeRuntime, dropConstantListEachBlocks, findUnknownRuleIds, findWorkspacePackages, knownRuleIds, loadConfigFile, loadSuppressions, registryTag, ruleOptionsSpec } from "svelte-vitals";
8
9
  //#region src/providers/rendered/collect.ts
9
10
  /** Map a prerendered HTML path (relative to pages/, POSIX) to its route. */
10
11
  function deriveRouteFromHtmlPath(relPath) {
@@ -13,14 +14,19 @@ function deriveRouteFromHtmlPath(relPath) {
13
14
  if (p.endsWith("/index")) p = p.slice(0, -6);
14
15
  return "/" + p;
15
16
  }
17
+ /** The whole file SvelteKit's prerender writes for a route that redirected — no document of the page. */
18
+ const REDIRECT_STUB = /^<script>location\.href=.*;<\/script><meta http-equiv="refresh" content="0;url=[^"]*">\s*$/s;
16
19
  /** Read every prerendered HTML page under `prerenderPagesDir` into ResolvedHead[]. */
17
20
  async function collectRenderedHeads(prerenderPagesDir) {
18
21
  const rt = createNodeRuntime();
19
22
  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
- })));
23
+ const parsedFiles = await Promise.all(files.map(async (rel) => {
24
+ const html = await rt.readFile(rt.join(prerenderPagesDir, rel));
25
+ return {
26
+ rel,
27
+ parsed: REDIRECT_STUB.test(html) ? void 0 : parseHtmlHead(html)
28
+ };
29
+ }));
24
30
  const heads = [];
25
31
  const headings = [];
26
32
  const images = [];
@@ -30,6 +36,7 @@ async function collectRenderedHeads(prerenderPagesDir) {
30
36
  value: "absent"
31
37
  };
32
38
  for (const { rel, parsed } of parsedFiles) {
39
+ if (!parsed) continue;
33
40
  if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
34
41
  const route = deriveRouteFromHtmlPath(rel);
35
42
  heads.push({
@@ -40,8 +47,8 @@ async function collectRenderedHeads(prerenderPagesDir) {
40
47
  });
41
48
  headings.push({
42
49
  route,
43
- headings: parsed.headings.map((level) => ({
44
- level,
50
+ headings: parsed.headings.map((h) => ({
51
+ ...h,
45
52
  line: 0,
46
53
  file: rel
47
54
  }))
@@ -102,6 +109,13 @@ async function robotsRefsSitemap(cwd) {
102
109
  return;
103
110
  }
104
111
  }
112
+ async function routeEndpoints(cwd) {
113
+ try {
114
+ return (await readdir(join(cwd, "src/routes"), { recursive: true })).map((e) => `src/routes/${e.split(sep).join("/")}`);
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
105
119
  /** First existing config candidate with its source — same "only the first is loaded" rule as the CLI provider. */
106
120
  async function readFirstConfig(cwd, files) {
107
121
  for (const file of files) {
@@ -119,22 +133,27 @@ async function readFirstConfig(cwd, files) {
119
133
  }
120
134
  /** Project facts for plugin mode: robots/sitemap from source, htmlLang from rendered HTML. */
121
135
  async function collectRenderedProject(cwd, htmlLang) {
122
- const [hasRobotsTxt, hasSitemap, viteConfig, svelteConfig] = await Promise.all([
136
+ const [hasRobotsFile, hasSitemapFile, endpoints, viteConfig, svelteConfig, packageJson] = await Promise.all([
123
137
  existsAny(cwd, ROBOTS_SOURCE_PATHS),
124
138
  existsAny(cwd, SITEMAP_SOURCE_PATHS),
139
+ routeEndpoints(cwd),
125
140
  readFirstConfig(cwd, VITE_CONFIG_FILES),
126
- readFirstConfig(cwd, SVELTE_CONFIG_FILES)
141
+ readFirstConfig(cwd, SVELTE_CONFIG_FILES),
142
+ readFirstConfig(cwd, ["package.json"])
127
143
  ]);
128
144
  const robotsReferencesSitemap = await robotsRefsSitemap(cwd);
129
145
  const kitPathsBase = resolveKitPathsBase(viteConfig, svelteConfig);
130
- const kitAliases = resolveKitAliases(viteConfig, svelteConfig);
146
+ const kitAliases = withPackageImports(resolveKitAliases(viteConfig, svelteConfig), packageJson?.source);
147
+ const componentAliases = withWorkspacePackages(kitAliases, await findWorkspacePackages(createNodeRuntime(), cwd, packageJson?.source));
131
148
  return {
132
- hasRobotsTxt,
133
- hasSitemap,
149
+ hasRobotsTxt: hasRobotsFile || endpoints.some((p) => servesRootFile(p, "robots.txt")),
150
+ hasSitemap: hasSitemapFile || endpoints.some((p) => servesRootFile(p, "sitemap.xml")),
134
151
  htmlLang,
135
152
  ...robotsReferencesSitemap !== void 0 ? { robotsReferencesSitemap } : {},
136
153
  ...kitPathsBase ? { kitPathsBase } : {},
137
- ...kitAliases ? { kitAliases } : {}
154
+ ...svelteConfig && findKitHashRouterInSvelteConfig(svelteConfig.source) ? { kitHashRouter: true } : {},
155
+ ...kitAliases ? { kitAliases } : {},
156
+ ...componentAliases !== kitAliases ? { componentAliases } : {}
138
157
  };
139
158
  }
140
159
  //#endregion
@@ -148,6 +167,15 @@ const nodeRuntime = createNodeRuntime();
148
167
  function collectComponentFacts$1(root) {
149
168
  return collectComponentFacts(nodeRuntime, root);
150
169
  }
170
+ /** `facts` without the `{#each}` blocks over an imported constant list (correctness/each-key, correctness/each-index-key). */
171
+ function dropConstantListEachBlocks$1(root, facts, aliases) {
172
+ return dropConstantListEachBlocks({
173
+ rt: nodeRuntime,
174
+ cwd: root,
175
+ cache: /* @__PURE__ */ new Map(),
176
+ aliases
177
+ }, facts);
178
+ }
151
179
  /** Scan SvelteKit route/hooks files for SSR shared-state facts (security/handler-state-write, security/server-module-state, security/shared-state-import; build mode only). */
152
180
  function collectKitModuleFacts$1(root, aliases) {
153
181
  return collectKitModuleFacts(nodeRuntime, root, aliases);
@@ -246,11 +274,6 @@ async function resolveConfig(cwd, options) {
246
274
  warnings: loaded?.warnings ?? []
247
275
  };
248
276
  }
249
- /**
250
- * `ssr` is a page option: only `+page`/`+layout` modules (universal or server) can set it. Same
251
- * file set as `PAGE_OPTION_FILE_RE` in core's `rules/seo/ssr-disabled.ts`, which reports the fact.
252
- */
253
- const PAGE_OPTION_FILE_RE = /^src\/routes\/(.*?)\/?\+(page|layout)(?:\.server)?\.[jt]s$/;
254
277
  function escapeRegExp(s) {
255
278
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
256
279
  }
@@ -260,27 +283,54 @@ function segmentPattern(segment) {
260
283
  if (/^\[\[[^\]]+\]\]$/.test(segment)) return "(?:/[^/]+)?";
261
284
  return "/" + segment.split(/\[[^\]]+\]/).map(escapeRegExp).join("[^/]+");
262
285
  }
286
+ /** A route's page component, with an optional `@segment` layout reset. */
287
+ const PAGE_FILE_RE = /^src\/routes\/(.*?)\/?\+page(?:@[^/]*)?\.svelte$/;
288
+ /** A module that can set the `ssr` page option (same file set as core's `seo/ssr-disabled`). */
289
+ const PAGE_OPTION_FILE_RE = /^src\/routes\/(.*?)\/?\+(page|layout)(?:\.server)?\.[jt]s$/;
290
+ /** A route directory as a pattern over rendered paths: `(group)` directories are not URL segments. */
291
+ function routePattern(dir, subtree) {
292
+ const path = dir.split("/").filter((s) => s !== "" && !/^\(.+\)$/.test(s)).map(segmentPattern).join("");
293
+ return new RegExp(subtree ? `^${path}(?:/.*)?$` : `^${path}$`);
294
+ }
263
295
  /**
264
296
  * Routes whose prerendered HTML is the app shell rather than the page: SvelteKit still writes a
265
297
  * file for an `ssr = false` route, but its `<head>` and body only exist once the client renders,
266
- * so every rendered head/heading/landmark check would report the page as empty. A `+layout`
267
- * covers its whole subtree, a `+page` only its own route; `(group)` directories are not URL
268
- * segments. Two ceilings: a child route re-enabling `ssr = true` under a disabled layout is not
269
- * tracked, and Kit's route precedence is not modelled, so a `[param]` route also claims a literal
270
- * sibling (`/docs/[slug]` matches a prerendered `/docs/intro`) — the warning lists the skipped
271
- * routes so a wrong skip is visible.
298
+ * so every rendered head/heading/landmark check would report the page as empty. Each page takes the
299
+ * nearest `ssr` export along its own files and its layout chain, resets followed, as the CLI reads it
300
+ * (`routeNeverSsr`), so a page that sets `ssr = true` under a disabled layout is read. Only a literal
301
+ * `true` turns SSR back on here: an option file that did not parse, or an `ssr` export the analyzer
302
+ * cannot read (an imported value), inherits instead, since analyzing a shell would report it empty,
303
+ * and a route no page in `sourceFiles` can match is a shell when any `ssr = false` module's
304
+ * route covers it. Kit's route precedence is not modelled: a route is a shell when any page whose
305
+ * path can match it is, so `/docs/[slug]` with `ssr = false` also claims a prerendered `/docs/intro`
306
+ * — the warning lists the skipped routes so a wrong skip is visible.
272
307
  */
273
- function ssrDisabledRouteMatcher(kitModules) {
274
- const patterns = [];
308
+ function ssrDisabledRouteMatcher(kitModules, sourceFiles) {
309
+ const scope = {
310
+ kitModules: kitModules.map((m) => m.parseFailed || m.ssrEnabled && !m.ssrTrue ? {
311
+ ...m,
312
+ parseFailed: void 0,
313
+ ssrEnabled: void 0
314
+ } : m),
315
+ sourceFiles: [...sourceFiles]
316
+ };
317
+ const pages = [];
318
+ for (const file of sourceFiles) {
319
+ const match = PAGE_FILE_RE.exec(file);
320
+ if (match) pages.push({
321
+ pattern: routePattern(match[1], false),
322
+ shell: routeNeverSsr(file, scope)
323
+ });
324
+ }
325
+ const disabled = [];
275
326
  for (const m of kitModules) {
276
327
  const match = m.ssrDisabled ? PAGE_OPTION_FILE_RE.exec(m.file) : null;
277
- if (!match) continue;
278
- const path = match[1].split("/").filter((s) => s !== "" && !/^\(.+\)$/.test(s)).map(segmentPattern).join("");
279
- patterns.push(new RegExp(match[2] === "layout" ? `^${path}(?:/.*)?$` : `^${path}$`));
328
+ if (match) disabled.push(routePattern(match[1], match[2] === "layout"));
280
329
  }
281
330
  return (route) => {
282
331
  const r = route === "/" ? "" : route;
283
- return patterns.some((p) => p.test(r));
332
+ const matched = pages.filter((p) => p.pattern.test(r));
333
+ return matched.length > 0 ? matched.some((p) => p.shell) : disabled.some((p) => p.test(r));
284
334
  };
285
335
  }
286
336
  /**
@@ -293,7 +343,7 @@ function ssrDisabledRouteMatcher(kitModules) {
293
343
  async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resolved) {
294
344
  const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
295
345
  const suppressions = resolved ? resolved.suppressions : loadSuppressions(cwd);
296
- const [rendered, components, sourceFiles] = await Promise.all([
346
+ const [rendered, componentFacts, sourceFiles] = await Promise.all([
297
347
  collectRenderedHeads(prerenderPagesDir),
298
348
  collectComponentFacts$1(cwd),
299
349
  collectSourceFiles$1(cwd)
@@ -302,19 +352,20 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
302
352
  ...await collectRenderedProject(cwd, rendered.htmlLang),
303
353
  ...extraProjectFacts
304
354
  };
305
- const kitModules = await collectKitModuleFacts$1(cwd, project.kitAliases);
306
- const isShell = ssrDisabledRouteMatcher(kitModules);
355
+ const [kitModules, components] = await Promise.all([collectKitModuleFacts$1(cwd, project.kitAliases), dropConstantListEachBlocks$1(cwd, componentFacts, project.componentAliases ?? project.kitAliases)]);
356
+ const isShell = project.kitHashRouter ? () => true : ssrDisabledRouteMatcher(kitModules, sourceFiles);
357
+ const shellCause = project.kitHashRouter ? "the hash router" : "ssr = false";
307
358
  const keep = (items) => items.filter((i) => !isShell(i.route));
308
359
  const heads = keep(rendered.heads);
309
360
  const headings = keep(rendered.headings);
310
361
  const images = keep(rendered.images);
311
362
  const a11y = keep(rendered.a11y);
312
363
  const shells = rendered.heads.filter((h) => isShell(h.route)).map((h) => h.route).sort();
313
- const selected = selectRules(allRules, config);
364
+ const selected = selectRules(allRules, config).filter((r) => rendered.heads.length > 0 || r.id !== "seo/html-lang");
314
365
  if (shells.length > 0) {
315
366
  const shown = shells.slice(0, 10);
316
367
  const list = shells.length > shown.length ? `${shown.join(", ")}, … and ${shells.length - shown.length} more` : shown.join(", ");
317
- warnings.push(`skipped ${shells.length} prerendered route(s) with ssr = false — their HTML is the app shell, not the page; run \`npx svelte-vitals\` to check them from source: ${list}`);
368
+ warnings.push(`skipped ${shells.length} prerendered route(s) with ${shellCause} — their HTML is the app shell, not the page; run \`npx svelte-vitals\` to check them from source: ${list}`);
318
369
  }
319
370
  if (selected.some((r) => r.id === "a11y/unverified-id-ref")) warnings.push("a11y/unverified-id-ref has no effect in rendered mode — the prerendered document is always fully resolved.");
320
371
  const directives = /* @__PURE__ */ new Map();
@@ -347,16 +398,19 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
347
398
  const { score } = computeScore(results, scoringConfig);
348
399
  const summary = summarize(results, scoringConfig);
349
400
  const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
350
- const coverageNote = `Analyzed ${heads.length} prerendered route(s)${shells.length > 0 ? ` (skipped ${shells.length} with ssr = false)` : ""}. SSR/dynamic routes are not covered — run \`npx svelte-vitals\` for those.
401
+ const coverageNote = `Analyzed ${heads.length} prerendered route(s)${shells.length > 0 ? ` (skipped ${shells.length} with ${shellCause})` : ""}. SSR/dynamic routes are not covered — run \`npx svelte-vitals\` for those.
351
402
  Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Accessibility/Bundle findings.`;
352
403
  const consoleReport = formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n";
353
- const jsonReport = formatJsonReport(results, scoringConfig, { version: readPackageVersion() }, selected.map((r) => r.id), examined);
404
+ const jsonReportChunks = () => formatJsonReportChunks(results, scoringConfig, { version: readPackageVersion() }, selected.map((r) => r.id), examined);
354
405
  return {
355
406
  score,
356
407
  summary,
357
408
  results,
358
409
  consoleReport,
359
- jsonReport,
410
+ get jsonReport() {
411
+ return [...jsonReportChunks()].join("");
412
+ },
413
+ jsonReportChunks,
360
414
  routeCount: rendered.heads.length,
361
415
  failed,
362
416
  failOn: scoringConfig.failOn,
@@ -898,6 +952,8 @@ function svelteVitals(options = {}) {
898
952
  let root = options.cwd ?? process.cwd();
899
953
  let minify;
900
954
  let configFile;
955
+ let ssrBuild = false;
956
+ let prerenderDir = DEFAULT_PRERENDER_DIR;
901
957
  const buildPlugin = {
902
958
  name: "svelte-vitals",
903
959
  apply: "build",
@@ -908,11 +964,14 @@ function svelteVitals(options = {}) {
908
964
  configResolved(config) {
909
965
  if (!options.cwd) root = config.root;
910
966
  configFile = config.configFile;
967
+ ssrBuild = Boolean(config.build?.ssr);
968
+ const out = config.build?.outDir ? resolve(config.root ?? root, config.build.outDir) : "";
969
+ if (/[\\/]output[\\/](?:server|client)$/.test(out)) prerenderDir = join(dirname(out), "prerendered/pages");
911
970
  },
912
971
  async closeBundle() {
913
- const pagesDir = options.prerenderDir ? options.prerenderDir : join(root, DEFAULT_PRERENDER_DIR);
972
+ const pagesDir = options.prerenderDir ? options.prerenderDir : prerenderDir;
914
973
  const resolved = isAbsolute(pagesDir) ? pagesDir : join(root, pagesDir);
915
- if (!existsSync(resolved)) return;
974
+ if (!ssrBuild && !existsSync(resolved)) return;
916
975
  const resolvedConfig = {
917
976
  ...await resolveConfig(root, options),
918
977
  suppressions: loadSuppressions(root)
@@ -926,15 +985,23 @@ function svelteVitals(options = {}) {
926
985
  return;
927
986
  }
928
987
  for (const w of result.warnings) warn(`svelte-vitals: ${w}`);
929
- if (result.routeCount === 0) return;
988
+ if (result.routeCount === 0) {
989
+ if (!ssrBuild) return;
990
+ warn(`svelte-vitals: no prerendered pages found in ${relative(root, resolved) || "."} — route analysis skipped; the source scan still ran. Prerender routes to check their HTML, or run \`npx svelte-vitals\` to check every route from source.`);
991
+ }
930
992
  if (options.report !== false) {
931
- const out = options.report === "json" ? result.jsonReport : result.consoleReport;
932
- console.log(out);
993
+ if (options.report === "json") {
994
+ for (const chunk of result.jsonReportChunks()) {
995
+ if (process.stdout.destroyed) break;
996
+ if (!process.stdout.write(chunk)) await once(process.stdout, "drain");
997
+ }
998
+ process.stdout.write("\n");
999
+ } else console.log(result.consoleReport);
933
1000
  }
934
1001
  if (options.outFile) {
935
1002
  const outPath = isAbsolute(options.outFile) ? options.outFile : join(root, options.outFile);
936
1003
  await mkdir(dirname(outPath), { recursive: true });
937
- await writeFile(outPath, result.jsonReport);
1004
+ await writeFile(outPath, result.jsonReportChunks());
938
1005
  }
939
1006
  if (result.failed) throw new Error(`svelte-vitals: build failed — findings at or above "${result.failOn}".`);
940
1007
  }
@@ -1,6 +1,11 @@
1
- import { ASIDE_DEMOTING_TAGS, IDREF_ATTRS, NAMING_ATTRS, SECTIONING_TAGS, decodeFragmentId, isClassicScriptType, isTopFragment, resolveLandmark, splitTokens, stripTextDirective } from "@svelte-vitals/core/internal";
1
+ import { ASIDE_DEMOTING_TAGS, IDREF_ATTRS, NAMING_ATTRS, SECTIONING_TAGS, decodeFragmentId, isClassicScriptType, isSvgSrc, isTopFragment, resolveLandmark, splitTokens, stripTextDirective } from "@svelte-vitals/core/internal";
2
2
  import { parse } from "node-html-parser";
3
3
  //#region src/providers/rendered/parse-html.ts
4
+ /** The <img> is its `<picture>`'s fallback and a sibling `<source>` carries the srcset. */
5
+ function pictureOffersSrcset(img) {
6
+ const parent = img.parentNode;
7
+ return parent?.rawTagName?.toLowerCase() === "picture" && parent.children.some((c) => c.rawTagName.toLowerCase() === "source" && c.hasAttribute("srcset"));
8
+ }
4
9
  function attrValue(v) {
5
10
  return v !== void 0 && v.trim().length > 0 ? "static" : "absent";
6
11
  }
@@ -84,7 +89,7 @@ function parseHtmlHead(html) {
84
89
  });
85
90
  }
86
91
  for (const meta of head.querySelectorAll("meta")) {
87
- const name = meta.getAttribute("name");
92
+ const name = meta.getAttribute("name")?.toLowerCase();
88
93
  const property = meta.getAttribute("property");
89
94
  const charset = meta.getAttribute("charset");
90
95
  if (charset != null) {
@@ -131,7 +136,8 @@ function parseHtmlHead(html) {
131
136
  ...href ? { href } : {}
132
137
  });
133
138
  }
134
- for (const script of head.querySelectorAll("script")) {
139
+ const bodyJsonLd = ((head === root ? void 0 : root.querySelector("body"))?.querySelectorAll("script") ?? []).filter((s) => s.getAttribute("type") === "application/ld+json");
140
+ for (const script of [...head.querySelectorAll("script"), ...bodyJsonLd]) {
135
141
  const type = script.getAttribute("type");
136
142
  if (type === "application/ld+json") {
137
143
  const raw = script.rawText;
@@ -165,14 +171,23 @@ function parseHtmlHead(html) {
165
171
  presence: "own",
166
172
  value: attrValue(lang)
167
173
  },
168
- headings: (root.querySelector("body") ?? root).querySelectorAll("h1,h2,h3,h4,h5,h6").map((el) => Number(el.rawTagName[1])),
174
+ headings: (root.querySelector("body") ?? root).querySelectorAll("h1,h2,h3,h4,h5,h6,[role]").flatMap((el) => {
175
+ if (/^h[1-6]$/i.test(el.rawTagName)) return [{ level: Number(el.rawTagName[1]) }];
176
+ if (el.getAttribute("role")?.trim().toLowerCase() !== "heading") return [];
177
+ const level = Number(el.getAttribute("aria-level") ?? 2);
178
+ return [{
179
+ level: Number.isInteger(level) && level >= 1 ? level : 0,
180
+ aria: true
181
+ }];
182
+ }),
169
183
  images: (root.querySelector("body") ?? root).querySelectorAll("img").map((img) => ({
170
184
  hasWidth: img.hasAttribute("width"),
171
185
  hasHeight: img.hasAttribute("height"),
172
186
  hasLoading: img.hasAttribute("loading"),
173
187
  hasAlt: img.hasAttribute("alt"),
174
188
  lazy: img.getAttribute("loading") === "lazy",
175
- hasSrcset: img.hasAttribute("srcset"),
189
+ hasSrcset: img.hasAttribute("srcset") || pictureOffersSrcset(img),
190
+ ...isSvgSrc(img.getAttribute("src") ?? "") ? { svg: true } : {},
176
191
  line: 0
177
192
  })),
178
193
  ...collectA11y(root)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.55.1",
3
+ "version": "0.55.3",
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,10 +43,10 @@
43
43
  "node-html-parser": "^9.0.4"
44
44
  },
45
45
  "peerDependencies": {
46
- "@svelte-vitals/core": "^0.55.1",
47
- "@sveltejs/kit": "^2.70.3",
48
- "svelte-vitals": "^0.55.1",
49
- "vite": "^8.3.0"
46
+ "@svelte-vitals/core": "^0.55.3",
47
+ "@sveltejs/kit": "^2.0.0",
48
+ "svelte-vitals": "^0.55.3",
49
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@sveltejs/kit": {
@@ -54,13 +54,13 @@
54
54
  }
55
55
  },
56
56
  "devDependencies": {
57
- "@svelte-vitals/core": "0.55.1",
57
+ "@svelte-vitals/core": "0.55.3",
58
58
  "@sveltejs/kit": "^2.70.3",
59
- "@types/node": "^24.13.5",
59
+ "@types/node": "^24.19.1",
60
60
  "happy-dom": "^20.14.5",
61
- "svelte": "^5.57.0",
62
- "svelte-vitals": "0.55.1",
63
- "vite": "^8.3.0"
61
+ "svelte": "^5.57.2",
62
+ "svelte-vitals": "0.55.3",
63
+ "vite": "^8.3.4"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",