svelte-shaker 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,17 +47,19 @@ reachable value set of `variant` and removes the `.btn-danger` rule.
47
47
  npm i -D svelte-shaker # requires svelte@^5
48
48
  ```
49
49
 
50
- Nothing else to install. By default the plugin parses with rsvelte, loaded from
51
- `@rsvelte/compiler` (a bundled WASM dependency no peer, no platform-specific
52
- binary). `parser: 'svelte'` falls back to svelte/compiler if you ever need it
53
- (see [Options](#options)).
50
+ Nothing else to install. The plugin picks its engine and parser automatically
51
+ (see [Options](#options)): a small/medium app runs on the native **Rust (WASM)
52
+ engine** and parses with **rsvelte** (loaded from `@rsvelte/compiler`, a bundled
53
+ WASM dependency — no peer, no platform-specific binary); a large app runs on the
54
+ **JS engine** with **svelte/compiler**. `engine` and `parser` let you pin either.
54
55
 
55
56
  ## Usage (Vite)
56
57
 
57
58
  Add the plugin **before** `svelte()`. By default it runs only in `vite build` —
58
59
  dev/HMR is a pass-through (opt into dev shaking with the `dev` option, see
59
- [Options](#options)). Out of the box it runs the native **Rust (WASM) engine**
60
- and parses with **rsvelte**; both fall back cleanly (see [Options](#options)).
60
+ [Options](#options)). The engine and parser are chosen automatically per app size
61
+ (Rust + rsvelte for small/medium, JS + svelte/compiler for large), and both fall
62
+ back cleanly (see [Options](#options)).
61
63
 
62
64
  ```ts
63
65
  // vite.config.ts
@@ -82,8 +84,8 @@ that monomorphization additionally needs the `?shaker_variant` requests routed
82
84
  through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
83
85
  fold / value-set narrowing shake only needs the `transform` swap. The
84
86
  environment-free engine and the in-browser playground parse with svelte/compiler
85
- — the rsvelte default is a Vite-plugin concern (it loads a Node-only WASM
86
- module); the engine takes an optional `parse` argument if you want to swap it.
87
+ — the Vite plugin's rsvelte selection is a plugin concern (it loads a Node-only
88
+ WASM module); the engine takes an optional `parse` argument if you want to swap it.
87
89
 
88
90
  ### Options
89
91
 
@@ -95,14 +97,17 @@ shaker({
95
97
  devOnly: [...], // glob patterns of files that never ship (tests, stories); they
96
98
  // stop counting as call sites. Defaults to tests/mocks/stories; replaces, spread
97
99
  // to extend.
100
+ exclude: [], // build-output dirs to skip walking (a SvelteKit adapter's `build/`,
101
+ // a `dist/`). The Vite `build.outDir` is always skipped; add other generated
102
+ // output here. Not source — see below.
98
103
  monomorphize: true, // default on; `false` disables it for faster builds,
99
104
  // or { maxVariants: 16, minSavings: 0.05 } to tune
100
105
  verbose: false, // true = per-file size breakdown after the build
101
106
 
102
- // Escape hatches the defaults ARE the Rust path; set these only to opt
103
- // out (e.g. if you ever hit a bug in it).
104
- engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
105
- parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
107
+ // Engine + parser are auto-selected by app size; set these only to pin.
108
+ engine: 'auto', // 'auto' (Rust/WASM if <=~300 components, else JS) | 'js' | 'rust'
109
+ parser: undefined, // default follows the engine (Rust->rsvelte, JS->svelte);
110
+ // set 'rsvelte' | 'svelte' to pin one
106
111
 
107
112
  dev: false, // default off: dev is a pass-through. 'incremental' (re-parse only
108
113
  // changed files) | 'coarse' (re-analyze everything) opts in; never monomorphizes
@@ -113,17 +118,27 @@ That list is exhaustive: any other key **fails the build**, naming the key and t
113
118
  options that do exist. A typo would otherwise be ignored — and a misspelled
114
119
  `preserve` ships the component you meant to protect, over-shaken.
115
120
 
116
- - **The defaults are the Rust path.** Out of the box svelte-shaker runs the
117
- native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
121
+ - **`engine`** which engine runs the shake. **`'auto'`** (default) picks by app
122
+ size: a small/medium app (up to a few hundred components) uses the native
123
+ **Rust (WASM) engine**, loaded from
118
124
  [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
119
- dependency — nothing to install). The Rust (WASM) engine is differentially
120
- tested to shake **byte-identically** to the JS engine. The parser choice is
121
- **soundness-neutral**: the engine reads only UTF-16 `start`/`end`, so
122
- svelte/compiler and rsvelte are differentially tested to produce
123
- **SSR-equivalent** output the choice never changes what renders. rsvelte is
124
- the default as the Rust parser the pipeline is standardizing on (end-to-end
125
- Rust with the WASM engine); `parser: 'svelte'` stays available as the
126
- fallback.
125
+ dependency — nothing to install); a **large** app stays on the **JS engine**,
126
+ because the native engine's speed-up comes from a fast parse, but it must ship
127
+ the whole-program AST across the JS↔WASM boundary as JSON, and past a few
128
+ hundred components that round-trip (tens of MB) costs more than it saves.
129
+ `'rust'` forces the native engine (throwing if `@rsvelte/compiler` can't load);
130
+ `'js'` forces the JS engine. The two engines are differentially tested to shake
131
+ **byte-identically**, so this is **speed-only** it never changes what ships.
132
+ - **`parser`** — which parser feeds the engine. The default **follows the
133
+ engine**: **rsvelte** on the native engine (its AST crosses into Rust directly),
134
+ **svelte/compiler** on the JS engine (where rsvelte's parse is ~2× slower with
135
+ no downstream benefit). Set `'rsvelte'` or `'svelte'` to pin one regardless of
136
+ engine. The choice is **soundness-neutral** — the engine reads only UTF-16
137
+ `start`/`end`, so both parsers are differentially tested to produce
138
+ **byte-identical** output, never changing what renders. When rsvelte is the
139
+ resolved parser and `@rsvelte/compiler` can't load, the plugin **throws** rather
140
+ than silently swapping (so the same source can't shake differently on another
141
+ machine); `parser: 'svelte'` is the explicit opt-out.
127
142
  - **`monomorphize`** — the one shaking knob, **on** by default. A measured
128
143
  net-win gate only specializes a component when that strictly shrinks the whole
129
144
  program, so monomorphization **never bloats**: whatever the knobs are set to,
@@ -146,13 +161,6 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
146
161
  monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
147
162
  // design system, while skipping specializations that save under 5%
148
163
  ```
149
- - **Escape hatches (`engine` / `parser`).** If you ever hit a bug in the Rust
150
- path, opt out per axis: `engine: 'js'` forces the JS engine, `parser: 'svelte'`
151
- forces svelte/compiler (the previous default). `@rsvelte/compiler` is a bundled
152
- dependency, so the default parser normally just loads; in the unlikely event it
153
- can't (a broken install), the plugin **throws** rather than silently falling
154
- back — so the same source always shakes the same on every machine. Reinstall
155
- dependencies, or set `parser: 'svelte'`.
156
164
  - **`dev`** — whether to shake in `vite dev` too. **Off** by default: dev is a
157
165
  pass-through, which is always correct and keeps HMR simple. Opt in with
158
166
  `dev: 'incremental'` — re-parses only the changed files and re-runs the
@@ -212,6 +220,28 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
212
220
  [`docs/ARCHITECTURE.md` §8.1.1](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
213
221
  for the full argument.
214
222
 
223
+ - **`exclude`** — directories the scans must **not walk at all**: a compiled,
224
+ generated tree that is **not source**. Each entry is a Vite-root-relative or
225
+ absolute path naming a directory, matched on a plain path-prefix basis (like
226
+ `entries`, no glob). The resolved Vite **`build.outDir` is always excluded
227
+ automatically** — it is the destination the build overwrites, so it holds no
228
+ source the app depends on. Use this option for output dirs the plugin can't infer,
229
+ most importantly a **SvelteKit adapter's `build/`** (adapter-static): it sits
230
+ _outside_ `build.outDir`, and left unpruned the escape scan parses megabytes of
231
+ minified output looking for call sites it can never contain, which can dominate
232
+ the crawl.
233
+
234
+ ```ts
235
+ shaker({ entries: ['.'], exclude: ['build'] }); // skip adapter-static output
236
+ ```
237
+
238
+ Distinct from `devOnly`: that marks non-shipping **source** files (tests, stories)
239
+ by glob; `exclude` prunes whole generated-**output** directories that are not
240
+ source at all. Like `entries`, **over-listing errs unsafe** — a pruned directory's
241
+ call sites stop counting, exactly as if it were outside the crawl — so name **only
242
+ generated output, never source**. That is why there is no default beyond the
243
+ always-safe `build.outDir`.
244
+
215
245
  ## What it removes
216
246
 
217
247
  | Pass | What it removes | Default |
package/dist/analyze.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseCached, parseModuleProgram, walk, } from './parse.js';
2
- import { emptyPlan, } from './ir.js';
2
+ import { emptyPlan, isFoldableValue, } from './ir.js';
3
3
  import { computeDeadSpans, inSpans } from './dead.js';
4
- import { evaluate, setVar, unwrapTsAssertions } from './eval.js';
4
+ import { evaluate, literalValue, setVar, unwrapTsAssertions } from './eval.js';
5
5
  const isSvelte = (source) => source.endsWith('.svelte');
6
6
  /** Floor for the fixpoint iteration bound (see {@link fixpointIterationBound}). */
7
7
  const MIN_FIXPOINT_ITERATIONS = 10;
@@ -169,6 +169,9 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
169
169
  const edges = [];
170
170
  const queue = [...entryList];
171
171
  const seen = new Set(queue);
172
+ // Parse each `.js`/`.ts` barrel at most once across the whole crawl (a shared
173
+ // design-system `index.ts` is re-imported hundreds of times).
174
+ const barrelCache = new Map();
172
175
  while (queue.length > 0) {
173
176
  const id = queue.shift();
174
177
  const code = await readFile(id);
@@ -206,7 +209,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
206
209
  // Not rendered as `<imp.local>` -> not a call site -> skip the costly barrel read.
207
210
  if (!renderedTags.has(imp.local))
208
211
  continue;
209
- const childId = await resolveThroughBarrel(imp.value, imp.imported, id, resolve, readFile);
212
+ const childId = await resolveThroughBarrel(imp.value, imp.imported, id, resolve, readFile, barrelCache);
210
213
  if (childId) {
211
214
  edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
212
215
  barrelLocals.set(imp.local, childId);
@@ -225,7 +228,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
225
228
  const source = namespaceSources.get(tag.slice(0, dot));
226
229
  if (source == null)
227
230
  continue;
228
- const childId = await resolveThroughBarrel(source, tag.slice(dot + 1), id, resolve, readFile);
231
+ const childId = await resolveThroughBarrel(source, tag.slice(dot + 1), id, resolve, readFile, barrelCache);
229
232
  if (childId) {
230
233
  edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
231
234
  nsChildren.push(childId);
@@ -256,6 +259,7 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
256
259
  const edges = [];
257
260
  const queue = [...entryList];
258
261
  const seen = new Set(queue);
262
+ const barrelCache = new Map();
259
263
  while (queue.length > 0) {
260
264
  const id = queue.shift();
261
265
  const code = readFile(id);
@@ -286,7 +290,7 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
286
290
  }
287
291
  if (!renderedTags.has(imp.local))
288
292
  continue;
289
- const childId = resolveThroughBarrelSync(imp.value, imp.imported, id, resolve, readFile);
293
+ const childId = resolveThroughBarrelSync(imp.value, imp.imported, id, resolve, readFile, barrelCache);
290
294
  if (childId) {
291
295
  edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
292
296
  barrelLocals.set(imp.local, childId);
@@ -299,7 +303,7 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
299
303
  const source = namespaceSources.get(tag.slice(0, dot));
300
304
  if (source == null)
301
305
  continue;
302
- const childId = resolveThroughBarrelSync(source, tag.slice(dot + 1), id, resolve, readFile);
306
+ const childId = resolveThroughBarrelSync(source, tag.slice(dot + 1), id, resolve, readFile, barrelCache);
303
307
  if (childId) {
304
308
  edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
305
309
  nsChildren.push(childId);
@@ -1488,7 +1492,7 @@ function literalAttrValue(value) {
1488
1492
  // `dynamic` flag itself must match — mono's `specializableShape` reads it.
1489
1493
  const expr = unwrapTsAssertions(part.expression);
1490
1494
  if (expr?.type === 'Literal')
1491
- return { known: true, value: expr.value };
1495
+ return literalValue(expr);
1492
1496
  }
1493
1497
  return { known: false };
1494
1498
  }
@@ -1549,7 +1553,9 @@ function buildPlan(model, u, ownerEnv) {
1549
1553
  continue;
1550
1554
  // constant fold: a clean singleton value set is the foldable case.
1551
1555
  if (set.values.length === 1) {
1552
- plan.constFold.set(decl.name, set.values[0]);
1556
+ const only = set.values[0];
1557
+ if (isFoldableValue(only))
1558
+ plan.constFold.set(decl.name, only);
1553
1559
  continue;
1554
1560
  }
1555
1561
  // value-set narrowing: >= 2 distinct literals with no dynamic/⊤ contribution is a fully
@@ -1633,8 +1639,13 @@ function literalDefault(expr) {
1633
1639
  expr = unwrapTsAssertions(expr) ?? undefined;
1634
1640
  if (!expr)
1635
1641
  return { known: true, value: undefined }; // omitted default -> undefined
1642
+ // `literalValue` is the same gate `literalAttrValue` and `evaluate` use: a
1643
+ // BigInt/RegExp default (no faithful source form) or a `null` that is really
1644
+ // `1e999` surviving JSON transport must stay UNKNOWN here too, or a call site
1645
+ // that merely omits the prop reintroduces the crash/misfold this default path
1646
+ // exists to prevent.
1636
1647
  if (expr.type === 'Literal')
1637
- return { known: true, value: expr.value };
1648
+ return literalValue(expr);
1638
1649
  if (expr.type === 'Identifier' && expr.name === 'undefined')
1639
1650
  return { known: true, value: undefined };
1640
1651
  return { known: false };
@@ -1701,7 +1712,7 @@ const MAX_BARREL_HOPS = 8;
1701
1712
  * we cannot follow returns `null` — sound, because a child we never resolve is
1702
1713
  * never planned (a pure-barrel `.js` component is simply out of scope).
1703
1714
  */
1704
- async function resolveThroughBarrel(source, imported, importer, resolve, readFile, hops = 0) {
1715
+ async function resolveThroughBarrel(source, imported, importer, resolve, readFile, cache, hops = 0) {
1705
1716
  if (hops > MAX_BARREL_HOPS)
1706
1717
  return null;
1707
1718
  const targetId = await resolve(source, importer);
@@ -1713,15 +1724,20 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1713
1724
  if (isSvelte(source) || isSvelte(targetId)) {
1714
1725
  return imported === 'default' || imported === '*' ? targetId : null;
1715
1726
  }
1716
- // A `.js`/`.ts` barrel: read it and chase the matching re-export.
1717
- let code;
1718
- try {
1719
- code = await readFile(targetId);
1720
- }
1721
- catch {
1722
- return null;
1727
+ // A `.js`/`.ts` barrel: read + parse it (once per crawl, memoized) and chase the
1728
+ // matching re-export.
1729
+ let body = cache.get(targetId);
1730
+ if (body === undefined) {
1731
+ let code;
1732
+ try {
1733
+ code = await readFile(targetId);
1734
+ }
1735
+ catch {
1736
+ code = null;
1737
+ }
1738
+ body = code === null ? null : parseModuleBody(code, targetId);
1739
+ cache.set(targetId, body);
1723
1740
  }
1724
- const body = parseModuleBody(code, targetId);
1725
1741
  if (!body)
1726
1742
  return null;
1727
1743
  for (const stmt of body) {
@@ -1730,7 +1746,7 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1730
1746
  for (const spec of stmt.specifiers ?? []) {
1731
1747
  if (specName(spec.exported) !== imported)
1732
1748
  continue;
1733
- return resolveThroughBarrel(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
1749
+ return resolveThroughBarrel(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, cache, hops + 1);
1734
1750
  }
1735
1751
  continue;
1736
1752
  }
@@ -1745,13 +1761,13 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1745
1761
  const found = followLocalImport(body, localName);
1746
1762
  if (!found)
1747
1763
  return null;
1748
- return resolveThroughBarrel(found.value, found.imported, targetId, resolve, readFile, hops + 1);
1764
+ return resolveThroughBarrel(found.value, found.imported, targetId, resolve, readFile, cache, hops + 1);
1749
1765
  }
1750
1766
  continue;
1751
1767
  }
1752
1768
  // `export * from './x'` — the name may live behind the wildcard.
1753
1769
  if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
1754
- const via = await resolveThroughBarrel(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
1770
+ const via = await resolveThroughBarrel(String(stmt.source.value), imported, targetId, resolve, readFile, cache, hops + 1);
1755
1771
  if (via)
1756
1772
  return via;
1757
1773
  }
@@ -1760,7 +1776,7 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1760
1776
  }
1761
1777
  /** Synchronous twin of {@link resolveThroughBarrel} (see {@link
1762
1778
  * buildAnalyzeInputSync}). Keep in lockstep with the async body above. */
1763
- function resolveThroughBarrelSync(source, imported, importer, resolve, readFile, hops = 0) {
1779
+ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile, cache, hops = 0) {
1764
1780
  if (hops > MAX_BARREL_HOPS)
1765
1781
  return null;
1766
1782
  const targetId = resolve(source, importer);
@@ -1769,14 +1785,18 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1769
1785
  if (isSvelte(source) || isSvelte(targetId)) {
1770
1786
  return imported === 'default' || imported === '*' ? targetId : null;
1771
1787
  }
1772
- let code;
1773
- try {
1774
- code = readFile(targetId);
1775
- }
1776
- catch {
1777
- return null;
1788
+ let body = cache.get(targetId);
1789
+ if (body === undefined) {
1790
+ let code;
1791
+ try {
1792
+ code = readFile(targetId);
1793
+ }
1794
+ catch {
1795
+ code = null;
1796
+ }
1797
+ body = code === null ? null : parseModuleBody(code, targetId);
1798
+ cache.set(targetId, body);
1778
1799
  }
1779
- const body = parseModuleBody(code, targetId);
1780
1800
  if (!body)
1781
1801
  return null;
1782
1802
  for (const stmt of body) {
@@ -1784,7 +1804,7 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1784
1804
  for (const spec of stmt.specifiers ?? []) {
1785
1805
  if (specName(spec.exported) !== imported)
1786
1806
  continue;
1787
- return resolveThroughBarrelSync(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
1807
+ return resolveThroughBarrelSync(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, cache, hops + 1);
1788
1808
  }
1789
1809
  continue;
1790
1810
  }
@@ -1798,12 +1818,12 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1798
1818
  const found = followLocalImport(body, localName);
1799
1819
  if (!found)
1800
1820
  return null;
1801
- return resolveThroughBarrelSync(found.value, found.imported, targetId, resolve, readFile, hops + 1);
1821
+ return resolveThroughBarrelSync(found.value, found.imported, targetId, resolve, readFile, cache, hops + 1);
1802
1822
  }
1803
1823
  continue;
1804
1824
  }
1805
1825
  if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
1806
- const via = resolveThroughBarrelSync(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
1826
+ const via = resolveThroughBarrelSync(String(stmt.source.value), imported, targetId, resolve, readFile, cache, hops + 1);
1807
1827
  if (via)
1808
1828
  return via;
1809
1829
  }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentId } from './ir.js';
2
2
  import type { Resolve, ReadFile } from './analyze.js';
3
3
  import { type DevOnlyFilter } from './dev-only.js';
4
+ import { type ExcludeFilter } from './exclude.js';
4
5
  /**
5
6
  * Is `file` a module the escape scan reads — a non-`.svelte` JS/TS source
6
7
  * ({@link NON_SVELTE_MODULE_EXTS}), excluding `.d.ts` declaration files (types-only,
@@ -53,6 +54,13 @@ export declare function computeEscapedComponents(opts: {
53
54
  * matched relative to `root`. Pass `compileDevOnly(root, [])` to scan everything.
54
55
  */
55
56
  devOnly?: DevOnlyFilter | undefined;
57
+ /**
58
+ * Build-output directories to prune from the escape scan (docs §8.1.1) — the SAME
59
+ * {@link ExcludeFilter} the seed scan applies, so a compiled-output tree is
60
+ * skipped by both. Omitted, nothing is excluded ({@link excludeNothing}); the
61
+ * Vite plugin seeds it with `build.outDir` plus the user's `exclude`.
62
+ */
63
+ exclude?: ExcludeFilter | undefined;
56
64
  resolve: Resolve;
57
65
  readFile: ReadFile;
58
66
  }): Promise<EscapeScanResult>;
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import { parseModuleProgram, walk } from './parse.js';
11
11
  import { compileDevOnly } from './dev-only.js';
12
+ import { excludeNothing } from './exclude.js';
12
13
  /**
13
14
  * The non-`.svelte` module extensions we scan for `.svelte` call sites (docs
14
15
  * §4.2). A component imported by any of these has a consumer the `.svelte`-only
@@ -30,13 +31,15 @@ export function isScannableModule(file) {
30
31
  }
31
32
  /**
32
33
  * Recursively collect every non-`.svelte` module under `dir` (skipping
33
- * `node_modules` and dot-directories, mirroring `collectSvelteFiles`). Same
34
- * include scope as the seed scan — `.ts` inside `node_modules` is deliberately NOT
35
- * scanned (docs §4.2). `devOnly` drops modules that never ship (a `Button.test.ts`)
36
- * so a colocated test does not mark the component it imports escaped (docs §8.1.1);
37
- * it is the SAME predicate the seed scan uses, so both scans discount the same files.
34
+ * `node_modules`, dot-directories, and any `exclude`d build-output tree, mirroring
35
+ * `collectSvelteFiles`). Same include scope as the seed scan — `.ts` inside
36
+ * `node_modules` is deliberately NOT scanned (docs §4.2). `devOnly` drops modules
37
+ * that never ship (a `Button.test.ts`) so a colocated test does not mark the
38
+ * component it imports escaped (docs §8.1.1); `exclude` prunes a compiled-output
39
+ * directory (`build.outDir`, an adapter's `build/`). Both are the SAME predicates
40
+ * the seed scan uses, so both scans discount the same files.
38
41
  */
39
- function collectNonSvelteModules(dir, devOnly, out) {
42
+ function collectNonSvelteModules(dir, devOnly, exclude, out) {
40
43
  let entries;
41
44
  try {
42
45
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -48,8 +51,11 @@ function collectNonSvelteModules(dir, devOnly, out) {
48
51
  if (entry.name === 'node_modules' || entry.name.startsWith('.'))
49
52
  continue;
50
53
  const full = path.join(dir, entry.name);
51
- if (entry.isDirectory())
52
- collectNonSvelteModules(full, devOnly, out);
54
+ if (entry.isDirectory()) {
55
+ if (exclude(full))
56
+ continue; // a build-output tree — pruned from the escape scan
57
+ collectNonSvelteModules(full, devOnly, exclude, out);
58
+ }
53
59
  else if (entry.isFile() && isScannableModule(entry.name) && !devOnly(full))
54
60
  out.push(full);
55
61
  }
@@ -175,9 +181,10 @@ export function matchPreserve(preserve, root, components) {
175
181
  */
176
182
  export async function computeEscapedComponents(opts) {
177
183
  const devOnly = opts.devOnly ?? compileDevOnly(opts.root);
184
+ const exclude = opts.exclude ?? excludeNothing;
178
185
  const modules = [];
179
186
  for (const dir of opts.entryDirs)
180
- collectNonSvelteModules(dir, devOnly, modules);
187
+ collectNonSvelteModules(dir, devOnly, exclude, modules);
181
188
  const { escaped, unscannable } = await collectModuleEscapes(modules, opts.resolve, opts.readFile);
182
189
  const { matched, unmatched } = partitionPreserve(opts.preserve, opts.root, opts.components);
183
190
  for (const id of matched)
package/dist/eval.d.ts CHANGED
@@ -31,6 +31,21 @@ export declare function unwrapTsAssertions(node: AnyNode | null | undefined): An
31
31
  * unknown on non-distributive ops), just without the interprocedural lattice.
32
32
  */
33
33
  export declare function evaluate(node: AnyNode | null | undefined, env: ReadonlyMap<string, Literal>): EvalResult;
34
+ /**
35
+ * The `Literal` value of an ESTree `Literal` node, or unknown when the node
36
+ * carries a value outside that union. This is the ONLY door through which a
37
+ * parsed value enters the engine, so it is where the union is enforced:
38
+ *
39
+ * - **RegExp / BigInt literals** (`/x/g`, `1n`) hold a `RegExp` / `bigint` in
40
+ * `value` — no faithful literal source form, and `JSON.stringify` throws on
41
+ * the latter and flattens the former to `{}`. Both carry a discriminator.
42
+ * - **`value: null`** is ambiguous: an AST that reached us as JSON (the rsvelte
43
+ * parser crosses a WASM boundary that way) cannot represent `Infinity`, so
44
+ * `1e999` arrives indistinguishable from a genuine `null` except by `raw`.
45
+ *
46
+ * Anything unproven stays unknown, which costs one fold — never a wrong one.
47
+ */
48
+ export declare function literalValue(node: AnyNode): EvalResult;
34
49
  /**
35
50
  * Sound set-aware predicate. `constEnv` holds props collapsed to a single
36
51
  * literal (`constFold`); `setEnv` holds props whose reachable value set is known
package/dist/eval.js CHANGED
@@ -37,7 +37,7 @@ export function evaluate(node, env) {
37
37
  return UNKNOWN;
38
38
  switch (node.type) {
39
39
  case 'Literal':
40
- return { known: true, value: node.value };
40
+ return literalValue(node);
41
41
  case 'Identifier': {
42
42
  const name = node.name ?? '';
43
43
  if (name === 'undefined')
@@ -132,6 +132,30 @@ export function evaluate(node, env) {
132
132
  return UNKNOWN;
133
133
  }
134
134
  }
135
+ /**
136
+ * The `Literal` value of an ESTree `Literal` node, or unknown when the node
137
+ * carries a value outside that union. This is the ONLY door through which a
138
+ * parsed value enters the engine, so it is where the union is enforced:
139
+ *
140
+ * - **RegExp / BigInt literals** (`/x/g`, `1n`) hold a `RegExp` / `bigint` in
141
+ * `value` — no faithful literal source form, and `JSON.stringify` throws on
142
+ * the latter and flattens the former to `{}`. Both carry a discriminator.
143
+ * - **`value: null`** is ambiguous: an AST that reached us as JSON (the rsvelte
144
+ * parser crosses a WASM boundary that way) cannot represent `Infinity`, so
145
+ * `1e999` arrives indistinguishable from a genuine `null` except by `raw`.
146
+ *
147
+ * Anything unproven stays unknown, which costs one fold — never a wrong one.
148
+ */
149
+ export function literalValue(node) {
150
+ if (node.regex !== undefined || node.bigint !== undefined)
151
+ return UNKNOWN;
152
+ const v = node.value;
153
+ if (v === null)
154
+ return node.raw === 'null' ? { known: true, value: null } : UNKNOWN;
155
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
156
+ return { known: true, value: v };
157
+ return UNKNOWN;
158
+ }
135
159
  /**
136
160
  * Sound set-aware predicate. `constEnv` holds props collapsed to a single
137
161
  * literal (`constFold`); `setEnv` holds props whose reachable value set is known
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A directory the scans must NOT descend into: a build-output tree. Returns
3
+ * `true` for an absolute path that is, or lives under, one of the declared roots.
4
+ * Both directory walks ({@link import('./scan.js').collectSvelteFiles} and the
5
+ * escape scan's `collectNonSvelteModules`) consult it, so a compiled-output dir is
6
+ * pruned from BOTH — the same predicate feeds both scans, mirroring `devOnly`.
7
+ */
8
+ export type ExcludeFilter = (absPath: string) => boolean;
9
+ /** Never excludes anything — the default when no build-output roots are declared. */
10
+ export declare const excludeNothing: ExcludeFilter;
11
+ /**
12
+ * Compile an {@link ExcludeFilter} from build-output directory roots (the Vite
13
+ * plugin's resolved `build.outDir` plus any user-declared `exclude`). Each entry
14
+ * is resolved against `root` (an absolute entry is left as-is), then matched on a
15
+ * plain path-prefix basis — the same "directory or file prefix" basis as `entries`
16
+ * / `preserve`, no glob. Empty / omitted -> {@link excludeNothing}.
17
+ *
18
+ * Unlike `entries`, over-listing here errs UNSAFE (a pruned directory's call sites
19
+ * stop counting, exactly as if it were outside the crawl), so it must name ONLY
20
+ * generated build output, never source. That is why nothing is excluded by
21
+ * default: the plugin only ever seeds this with `build.outDir` (unconditionally
22
+ * safe — it is the destination the current build overwrites) and whatever the user
23
+ * explicitly declares via `exclude`.
24
+ */
25
+ export declare function compileExclude(root: string, exclude?: string[]): ExcludeFilter;
@@ -0,0 +1,29 @@
1
+ // ----------------------------------------------------------------------
2
+ // Build-output exclusion for the directory scans (docs/ARCHITECTURE.md §8.1.1).
3
+ // Shell-side glue, kept in its own module (like `dev-only.ts`) so both the
4
+ // `.svelte` seed scan (`scan.ts`) and the non-`.svelte` escape scan
5
+ // (`escape-scan.ts`) can share it without an import cycle.
6
+ // ----------------------------------------------------------------------
7
+ import * as path from 'node:path';
8
+ /** Never excludes anything — the default when no build-output roots are declared. */
9
+ export const excludeNothing = () => false;
10
+ /**
11
+ * Compile an {@link ExcludeFilter} from build-output directory roots (the Vite
12
+ * plugin's resolved `build.outDir` plus any user-declared `exclude`). Each entry
13
+ * is resolved against `root` (an absolute entry is left as-is), then matched on a
14
+ * plain path-prefix basis — the same "directory or file prefix" basis as `entries`
15
+ * / `preserve`, no glob. Empty / omitted -> {@link excludeNothing}.
16
+ *
17
+ * Unlike `entries`, over-listing here errs UNSAFE (a pruned directory's call sites
18
+ * stop counting, exactly as if it were outside the crawl), so it must name ONLY
19
+ * generated build output, never source. That is why nothing is excluded by
20
+ * default: the plugin only ever seeds this with `build.outDir` (unconditionally
21
+ * safe — it is the destination the current build overwrites) and whatever the user
22
+ * explicitly declares via `exclude`.
23
+ */
24
+ export function compileExclude(root, exclude) {
25
+ if (!exclude || exclude.length === 0)
26
+ return excludeNothing;
27
+ const prefixes = exclude.map((e) => path.resolve(root, e));
28
+ return (absPath) => prefixes.some((p) => absPath === p || absPath.startsWith(p + path.sep));
29
+ }
package/dist/ir.d.ts CHANGED
@@ -131,4 +131,18 @@ export interface ComponentPlan {
131
131
  */
132
132
  valueSets: Map<string, PropValueSet>;
133
133
  }
134
+ /**
135
+ * Whether a proven value may be SUBSTITUTED into the residual as a constant.
136
+ *
137
+ * Every member of {@link Literal} has a faithful source form except `-0`: the
138
+ * Svelte compiler constant-folds the expression it is spliced into and loses the
139
+ * sign of zero there (`{1 / n}` renders `-Infinity` with `n = -0` at runtime,
140
+ * but the folded `{1 / (-0)}` compiles to a literal `Infinity`). Emitting it
141
+ * would therefore change what renders even though the value we proved is right,
142
+ * so a `-0`-valued prop is simply left alone — one missed fold, no risk.
143
+ *
144
+ * Narrowing is unaffected: a narrowed prop stays dynamic and is never
145
+ * substituted, and the comparisons it feeds use real JS semantics.
146
+ */
147
+ export declare function isFoldableValue(value: Literal): boolean;
134
148
  export declare function emptyPlan(id: ComponentId): ComponentPlan;
package/dist/ir.js CHANGED
@@ -3,6 +3,22 @@
3
3
  // See docs/ARCHITECTURE.md §5.1. This is the M0 (walking-skeleton) subset:
4
4
  // only the pieces basic1 exercises, but shaped so later levels slot in.
5
5
  // ----------------------------------------------------------------------
6
+ /**
7
+ * Whether a proven value may be SUBSTITUTED into the residual as a constant.
8
+ *
9
+ * Every member of {@link Literal} has a faithful source form except `-0`: the
10
+ * Svelte compiler constant-folds the expression it is spliced into and loses the
11
+ * sign of zero there (`{1 / n}` renders `-Infinity` with `n = -0` at runtime,
12
+ * but the folded `{1 / (-0)}` compiles to a literal `Infinity`). Emitting it
13
+ * would therefore change what renders even though the value we proved is right,
14
+ * so a `-0`-valued prop is simply left alone — one missed fold, no risk.
15
+ *
16
+ * Narrowing is unaffected: a narrowed prop stays dynamic and is never
17
+ * substituted, and the comparisons it feeds use real JS semantics.
18
+ */
19
+ export function isFoldableValue(value) {
20
+ return !Object.is(value, -0);
21
+ }
6
22
  export function emptyPlan(id) {
7
23
  return {
8
24
  id,
package/dist/mono.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type FileModel } from './analyze.js';
2
2
  import { type AnyNode } from './parse.js';
3
- import type { ComponentId, ComponentPlan, Literal } from './ir.js';
3
+ import { type ComponentId, type ComponentPlan, type Literal } from './ir.js';
4
4
  /** Tuning knobs for monomorphization (docs §8.1, §13.2). All have sound defaults. */
5
5
  export interface MonomorphizeOptions {
6
6
  /** Master switch. Default OFF — every existing behavior is unchanged. */
package/dist/mono.js CHANGED
@@ -60,6 +60,7 @@ import { inSpans } from './dead.js';
60
60
  import { shakeBody } from './transform.js';
61
61
  import { readCallSite, deadSpansForPlans, isFoldBlockedName, } from './analyze.js';
62
62
  import { parseSvelte, walk } from './parse.js';
63
+ import { isFoldableValue } from './ir.js';
63
64
  export const DEFAULT_MONO_OPTIONS = {
64
65
  enabled: false,
65
66
  maxVariants: 8,
@@ -127,14 +128,29 @@ export function monomorphize(models, plans, options = DEFAULT_MONO_OPTIONS, entr
127
128
  liveSitesByChild.set(call.childId, [{ owner: owner.id, node: call.node, shape, code }]);
128
129
  }
129
130
  }
131
+ // No child folds a non-base residual at EVERY live site -> nothing is
132
+ // specializable, so skip the whole-program base-size setup entirely (the step-3
133
+ // loop would emit nothing anyway). Byte-identical to running it, just cheaper.
134
+ let anyCandidate = false;
135
+ for (const childId of liveSitesByChild.keys())
136
+ if (!ineligible.has(childId)) {
137
+ anyCandidate = true;
138
+ break;
139
+ }
140
+ if (!anyCandidate)
141
+ return { variants, bindings };
130
142
  // (2) Build the whole-program base render graph + the base module sizes, and
131
143
  // the set of components reachable from the shake entries. `ownSize` is
132
144
  // memoized (compile is the hot cost) and a compile error makes a component
133
145
  // non-specializable (we treat it as un-sizable -> skip any child involved).
134
146
  const baseSource = baseSourceMap(models, plans);
135
147
  const baseChildrenOf = new Map();
148
+ // Parse each DISTINCT residual at most once across the whole gate (base sources
149
+ // + variant residuals). An UNCHANGED residual reuses the AST the analysis
150
+ // already produced ({@link liveChildIds}), so only genuinely-folded files parse.
151
+ const astCache = new Map();
136
152
  for (const model of models.values())
137
- baseChildrenOf.set(model.id, liveChildIds(baseSource.get(model.id), model));
153
+ baseChildrenOf.set(model.id, liveChildIds(baseSource.get(model.id), model, astCache));
138
154
  // Reachability roots = the shake entries (docs §3 monomorphization, §13.2), narrowed to the
139
155
  // TRUE import-graph roots. The Shell seeds the crawl with EVERY `.svelte` file
140
156
  // (so it can attribute every call site), which would make every module its own
@@ -211,7 +227,7 @@ export function monomorphize(models, plans, options = DEFAULT_MONO_OPTIONS, entr
211
227
  continue; // exceeding the cap means we cannot specialize all-sites
212
228
  // Measure: does replacing the base child with its variants strictly shrink
213
229
  // the whole-program reachable module bytes?
214
- if (!netWin(childId, variantSources, models, baseSource, baseChildrenOf, roots, ownSize, options.minSavings))
230
+ if (!netWin(childId, variantSources, models, baseSource, baseChildrenOf, roots, ownSize, options.minSavings, astCache))
215
231
  continue;
216
232
  // The gate passed: emit the variants and bind every live site.
217
233
  for (const v of variantSources) {
@@ -250,13 +266,13 @@ export function monomorphize(models, plans, options = DEFAULT_MONO_OPTIONS, entr
250
266
  * Specialize IFF Sigma_spec < Sigma_base * (1 - minSavings). Any compile error
251
267
  * (un-sizable module) makes us decline — never bloat.
252
268
  */
253
- function netWin(childId, variantSources, models, baseSource, baseChildrenOf, roots, ownSize, minSavings) {
269
+ function netWin(childId, variantSources, models, baseSource, baseChildrenOf, roots, ownSize, minSavings, astCache) {
254
270
  // The variants' OWN live children, parsed from each variant residual via the
255
271
  // child's import map (variants never add imports — they only fold/remove).
256
272
  const childModel = models.get(childId);
257
273
  const variantChildren = new Map();
258
274
  for (const v of variantSources)
259
- variantChildren.set(v.id, liveChildIds(v.code, childModel));
275
+ variantChildren.set(v.id, liveChildIds(v.code, childModel, astCache));
260
276
  // --- Reachability is graph-only (NO compile): the costly `ownSize` is deferred
261
277
  // until we know which modules actually differ between the two scenarios.
262
278
  // BASE scenario: components reachable from the roots through the base graph.
@@ -366,14 +382,34 @@ function netWin(childId, variantSources, models, baseSource, baseChildrenOf, roo
366
382
  * residual that fails to parse would also fail to compile in {@link ownSize},
367
383
  * declining the child).
368
384
  */
369
- function liveChildIds(source, model) {
385
+ function liveChildIds(source, model, astCache) {
386
+ // An unchanged residual (no fold touched this file) is byte-identical to the
387
+ // source the analysis already parsed, so reuse `model.ast` instead of parsing
388
+ // again — the common case (most components fold nothing). A changed residual is
389
+ // parsed once and memoized by its source, so a variant shared across candidates
390
+ // never re-parses. The filename only colors parse-error messages (swallowed), so
391
+ // a shared source yields the same fragment regardless of which model asks.
370
392
  let ast;
371
- try {
372
- ast = parseSvelte(source, model.id);
393
+ if (source === model.code) {
394
+ ast = model.ast;
373
395
  }
374
- catch {
375
- return [];
396
+ else {
397
+ const cached = astCache?.get(source);
398
+ if (cached !== undefined) {
399
+ ast = cached;
400
+ }
401
+ else {
402
+ try {
403
+ ast = parseSvelte(source, model.id);
404
+ }
405
+ catch {
406
+ ast = null;
407
+ }
408
+ astCache?.set(source, ast);
409
+ }
376
410
  }
411
+ if (!ast)
412
+ return [];
377
413
  const ids = [];
378
414
  walk(ast.fragment, null, {
379
415
  Component(node, { next }) {
@@ -418,6 +454,8 @@ function specializableShape(node, child, plan) {
418
454
  // override — exactly the analysis's "safely explicit" condition.
419
455
  if (explicit.dynamic || !explicit.afterLastSpread)
420
456
  continue;
457
+ if (!isFoldableValue(explicit.value))
458
+ continue; // no safe source form — exactly as constant fold
421
459
  shape.set(name, explicit.value);
422
460
  }
423
461
  return shape;
package/dist/parse.d.ts CHANGED
@@ -75,6 +75,13 @@ export interface AnyNode {
75
75
  kind?: string | undefined;
76
76
  /** ObjectExpression `Property` shorthand-method flag (`{ m() {} }`). */
77
77
  method?: boolean | undefined;
78
+ /** ESTree `Literal` discriminators, present only on a RegExp (`/x/g`) or
79
+ * BigInt (`1n`) literal — neither of which carries a foldable `value`. */
80
+ regex?: {
81
+ pattern: string;
82
+ flags: string;
83
+ } | undefined;
84
+ bigint?: string | undefined;
78
85
  value?: unknown;
79
86
  }
80
87
  export interface Root extends AnyNode {
package/dist/scan.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ComponentId } from './ir.js';
2
2
  import type { Resolve, ReadFile } from './analyze.js';
3
3
  import { type DevOnlyFilter } from './dev-only.js';
4
+ import { type ExcludeFilter } from './exclude.js';
4
5
  export { computeEscapedComponents, type EscapeScanResult } from './escape-scan.js';
5
6
  export { DEFAULT_DEV_ONLY, compileDevOnly, type DevOnlyFilter } from './dev-only.js';
7
+ export { compileExclude, excludeNothing, type ExcludeFilter } from './exclude.js';
6
8
  /** Default filesystem resolver: resolve `source` relative to its importer. */
7
9
  export declare const fsResolve: Resolve;
8
10
  /** Default filesystem reader. */
@@ -21,4 +23,4 @@ export declare const fsReadFile: ReadFile;
21
23
  * custom pattern is root-relative there. Pass `compileDevOnly(dir, [])` to seed
22
24
  * every file.
23
25
  */
24
- export declare function collectSvelteFiles(dir: string, devOnly?: DevOnlyFilter): ComponentId[];
26
+ export declare function collectSvelteFiles(dir: string, devOnly?: DevOnlyFilter, exclude?: ExcludeFilter): ComponentId[];
package/dist/scan.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import { compileDevOnly } from './dev-only.js';
9
+ import { excludeNothing } from './exclude.js';
9
10
  // The escape-scan machinery lives in `./escape-scan.js` (internal); only the single
10
11
  // entry helper is part of the public `svelte-shaker/node` surface.
11
12
  export { computeEscapedComponents } from './escape-scan.js';
@@ -13,6 +14,10 @@ export { computeEscapedComponents } from './escape-scan.js';
13
14
  // `svelte-shaker/node` surface, so a plain-Rollup pipeline can compile the same
14
15
  // predicate the Vite plugin does and feed it to both scans.
15
16
  export { DEFAULT_DEV_ONLY, compileDevOnly } from './dev-only.js';
17
+ // Build-output exclusion (docs §8.1.1): the same "compile a predicate, feed both
18
+ // scans" shape as `devOnly`, exposed on `svelte-shaker/node` so a plain-Rollup
19
+ // pipeline can prune a compiled-output tree exactly as the Vite plugin does.
20
+ export { compileExclude, excludeNothing } from './exclude.js';
16
21
  /** Default filesystem resolver: resolve `source` relative to its importer. */
17
22
  export const fsResolve = (source, importer) => {
18
23
  if (!source.startsWith('.'))
@@ -35,13 +40,13 @@ export const fsReadFile = (id) => fs.readFileSync(id, 'utf-8');
35
40
  * custom pattern is root-relative there. Pass `compileDevOnly(dir, [])` to seed
36
41
  * every file.
37
42
  */
38
- export function collectSvelteFiles(dir, devOnly = compileDevOnly(dir)) {
43
+ export function collectSvelteFiles(dir, devOnly = compileDevOnly(dir), exclude = excludeNothing) {
39
44
  const out = [];
40
- collectSvelteFilesInto(dir, devOnly, out);
45
+ collectSvelteFilesInto(dir, devOnly, exclude, out);
41
46
  return out;
42
47
  }
43
- /** Recursive worker: the compiled `devOnly` predicate is threaded, never recompiled. */
44
- function collectSvelteFilesInto(dir, devOnly, out) {
48
+ /** Recursive worker: the compiled `devOnly` / `exclude` predicates are threaded, never recompiled. */
49
+ function collectSvelteFilesInto(dir, devOnly, exclude, out) {
45
50
  let entries;
46
51
  try {
47
52
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -53,8 +58,11 @@ function collectSvelteFilesInto(dir, devOnly, out) {
53
58
  if (entry.name === 'node_modules' || entry.name.startsWith('.'))
54
59
  continue;
55
60
  const full = path.join(dir, entry.name);
56
- if (entry.isDirectory())
57
- collectSvelteFilesInto(full, devOnly, out);
61
+ if (entry.isDirectory()) {
62
+ if (exclude(full))
63
+ continue; // a build-output tree — pruned from the scan
64
+ collectSvelteFilesInto(full, devOnly, exclude, out);
65
+ }
58
66
  else if (entry.isFile() && entry.name.endsWith('.svelte') && !devOnly(full))
59
67
  out.push(full);
60
68
  }
Binary file
package/dist/transform.js CHANGED
@@ -349,9 +349,8 @@ extraDrops) {
349
349
  // are removed), so a constFold prop used inside a surviving arm is handled.
350
350
  const refs = collectPropRefs(model, localEnv, dead);
351
351
  for (const [name, value] of localEnv) {
352
- const lit = literalSource(value);
353
352
  for (const ref of refs.get(name) ?? [])
354
- s.overwrite(ref.start, ref.end, ref.head + lit + ref.tail);
353
+ s.overwrite(ref.start, ref.end, foldReplacement(ref, value));
355
354
  }
356
355
  // (3) Drop the folded (constFold) props from the `$props()` signature, together
357
356
  // with any unread declared props (docs §PR7) — one {@link dropProps} call so
@@ -721,12 +720,31 @@ function substitutedSlice(from, to, roots, env, code) {
721
720
  let cursor = from;
722
721
  for (const ref of refs) {
723
722
  out += code.slice(cursor, ref.start);
724
- out += ref.head + literalSource(env.get(ref.name)) + ref.tail;
723
+ out += foldReplacement(ref, env.get(ref.name));
725
724
  cursor = ref.end;
726
725
  }
727
726
  out += code.slice(cursor, to);
728
727
  return out;
729
728
  }
729
+ /**
730
+ * The replacement text for a folded reference: `head + <literal> + tail`, but a
731
+ * NUMBER used as the object of a member access is parenthesized. `count.toFixed()`
732
+ * with `count` = 5000 would otherwise emit `5000.toFixed()`, where the parser reads
733
+ * `5000.` as a float literal and then hits `toFixed` — "Identifier directly after
734
+ * number". `(5000)` disambiguates.
735
+ *
736
+ * Strictly, only a decimal INTEGER literal is ambiguous here: `5.5.toFixed()` and
737
+ * `5e3.toFixed()` already parse (the number token ends before the `.`), and a
738
+ * `Literal` never carries a BigInt. We wrap EVERY number uniformly anyway — the
739
+ * parens are always valid and the rule is simpler than sniffing the numeric form.
740
+ * Non-number literals (string / boolean / `null`) are never wrapped: they need no
741
+ * disambiguation, so existing golden output is unchanged.
742
+ */
743
+ function foldReplacement(ref, value) {
744
+ const lit = literalSource(value);
745
+ const body = ref.memberObject === true && typeof value === 'number' ? `(${lit})` : lit;
746
+ return ref.head + body + ref.tail;
747
+ }
730
748
  /** Find every folded-prop reference in `model`, outside dead spans, by name. */
731
749
  function collectPropRefs(model, env, dead) {
732
750
  const refs = new Map();
@@ -823,7 +841,11 @@ function foldRefFor(node, parent, grandparent, code) {
823
841
  const name = code.slice(node.start, node.end);
824
842
  return { start: node.start, end: node.end, head: `${name}: `, tail: '' };
825
843
  }
826
- return { start: node.start, end: node.end, head: '', tail: '' };
844
+ // Plain expression read. Flag a member-access object (`NAME.foo`) so a folded
845
+ // number is parenthesized ({@link foldReplacement}); a computed access
846
+ // (`NAME[i]`) needs no wrapping (`5000[i]` parses), so it stays unflagged.
847
+ const memberObject = parent?.type === 'MemberExpression' && parent.object === node && parent.computed !== true;
848
+ return { start: node.start, end: node.end, head: '', tail: '', memberObject };
827
849
  }
828
850
  /** True when an Identifier is a property key / member name, not a value read. */
829
851
  function isNonReference(node, parent) {
@@ -1014,8 +1036,29 @@ function setDefault(map, key) {
1014
1036
  function isSpace(ch) {
1015
1037
  return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
1016
1038
  }
1039
+ /** Source text for a folded value, faithful for every member of {@link Literal}
1040
+ * (the union {@link evaluate} admits). Always an expression, so it drops into
1041
+ * both substitution positions unchanged. */
1017
1042
  function literalSource(value) {
1018
1043
  if (value === undefined)
1019
1044
  return 'undefined';
1045
+ if (typeof value === 'number')
1046
+ return numberSource(value);
1047
+ return JSON.stringify(value);
1048
+ }
1049
+ /**
1050
+ * `JSON.stringify` flattens `Infinity`/`-Infinity`/`NaN` to `null`, so those get
1051
+ * an explicit form. Written as arithmetic rather than the `Infinity`/`NaN`
1052
+ * globals because the substituted text lands in the CALLEE's scope, where a
1053
+ * local of either name would silently capture it; `(0/0)` cannot be shadowed.
1054
+ * (`-0`, the fourth lossy case, never reaches here — see `isFoldableValue`.)
1055
+ */
1056
+ function numberSource(value) {
1057
+ if (Number.isNaN(value))
1058
+ return '(0/0)';
1059
+ if (value === Infinity)
1060
+ return '(1/0)';
1061
+ if (value === -Infinity)
1062
+ return '(-1/0)';
1020
1063
  return JSON.stringify(value);
1021
1064
  }
package/dist/vite.d.ts CHANGED
@@ -82,6 +82,28 @@ export interface ShakerOptions {
82
82
  * count every file (the pre-`devOnly` behavior).
83
83
  */
84
84
  devOnly?: string[];
85
+ /**
86
+ * Build-output directories the scans must NOT walk (docs/ARCHITECTURE.md §8.1.1) —
87
+ * a compiled/generated tree that is not source: a SvelteKit adapter's `build/`,
88
+ * a `dist/`, any prior build artifact. Each entry is a Vite-root-relative or
89
+ * absolute path naming a directory, matched on a plain path-prefix basis — the
90
+ * same "directory prefix" basis as {@link entries}, no glob.
91
+ *
92
+ * The resolved Vite `build.outDir` is ALWAYS excluded automatically (it is the
93
+ * destination this build overwrites, so it can hold no source the app depends
94
+ * on); this option is for output dirs the plugin cannot infer — most importantly
95
+ * a SvelteKit adapter's `build/`, which sits outside `build.outDir`. Excluding it
96
+ * skips parsing megabytes of minified output the escape scan would otherwise read
97
+ * (issue: adapter-static `build/` dominated the crawl).
98
+ *
99
+ * Distinct from {@link devOnly}: `devOnly` marks non-shipping SOURCE files (tests,
100
+ * stories) by glob; `exclude` prunes whole generated-OUTPUT directories that are
101
+ * not source at all. Like {@link entries}, over-listing errs UNSAFE — a pruned
102
+ * directory's call sites stop counting, exactly as if it were outside the crawl —
103
+ * so name ONLY generated output, never source. That is why there is no default
104
+ * beyond the always-safe `build.outDir`.
105
+ */
106
+ exclude?: string[];
85
107
  /**
86
108
  * Per-call-site monomorphization tuning (docs §13.2). Monomorphization is ON
87
109
  * by default because it is bail-safe and never bloats (the measured net-win
@@ -100,8 +122,11 @@ export interface ShakerOptions {
100
122
  * implements every pass — for monomorphization it calls back into JS only for
101
123
  * the per-module compiled-size proxy the net-win gate needs — so it is the
102
124
  * default fast path:
103
- * - `'auto'` — use the native Rust engine when it can be loaded; otherwise fall
104
- * back to the JS engine.
125
+ * - `'auto'` — use the native Rust engine for small/medium apps; a large app
126
+ * (more than a few hundred components) stays on the JS engine, because the
127
+ * whole-program AST that must cross the JS<->WASM boundary as JSON grows to
128
+ * tens of MB and the round-trip then costs more than the faster parse saves.
129
+ * Also falls back to JS if the WASM module can't be loaded.
105
130
  * - `'rust'` — force the Rust engine; throws if the WASM module can't be loaded.
106
131
  * - `'js'` — force the JS engine.
107
132
  * Both engines are differentially tested to produce byte-identical output, so the
@@ -121,17 +146,20 @@ export interface ShakerOptions {
121
146
  */
122
147
  dev?: false | DevMode;
123
148
  /**
124
- * Which parser feeds the engine. Default `'rsvelte'` — rsvelte's parser,
125
- * loaded from `@rsvelte/compiler` (a bundled WASM dependency, so there is
126
- * nothing extra to install and no platform-specific binary). `'svelte'` is
127
- * the escape hatch: it uses svelte/compiler (the previous default) and is the
128
- * fallback to reach for if you ever hit an rsvelte parser bug. The engine
129
- * reads only UTF-16 `start`/`end`, never `loc`, so the choice can never affect
130
- * what renders — it is soundness-neutral, differentially tested to produce
131
- * SSR-equivalent output either way. With the default `'rsvelte'`, if
132
- * `@rsvelte/compiler` can't be loaded (a broken install) the plugin THROWS
133
- * rather than silently falling back to svelte/compiler, so the output stays
134
- * deterministic across machines: set `parser: 'svelte'` to opt out.
149
+ * Which parser feeds the engine. Defaults to FOLLOW THE ENGINE: `'rsvelte'` on
150
+ * the native (Rust) engine — its AST crosses the WASM boundary directly and
151
+ * `'svelte'` (svelte/compiler) on the JS engine, where rsvelte's parse is pure
152
+ * overhead (~2x slower) with no downstream benefit. Set this to pin one parser
153
+ * regardless of engine. rsvelte loads from `@rsvelte/compiler` (a bundled WASM
154
+ * dependency nothing extra to install, no platform-specific binary).
155
+ *
156
+ * The engine reads only UTF-16 `start`/`end`, never `loc`, so the choice can
157
+ * never affect what renders it is soundness-neutral, differentially tested to
158
+ * produce byte-identical output either way. When rsvelte IS the resolved parser
159
+ * (the native engine, or an explicit `parser: 'rsvelte'`) and `@rsvelte/compiler`
160
+ * can't be loaded, the plugin THROWS rather than silently swapping to
161
+ * svelte/compiler, so the same source can't shake differently on another machine:
162
+ * set `parser: 'svelte'` to opt out.
135
163
  */
136
164
  parser?: 'svelte' | 'rsvelte';
137
165
  /**
package/dist/vite.js CHANGED
@@ -6,6 +6,7 @@ import { DevShaker } from './engine.js';
6
6
  import { collectSvelteFiles, fsResolve } from './scan.js';
7
7
  import { computeEscapedComponents, isScannableModule, } from './escape-scan.js';
8
8
  import { compileDevOnly } from './dev-only.js';
9
+ import { compileExclude } from './exclude.js';
9
10
  // Re-export so a user can extend the default dev-only list: `devOnly: [...DEFAULT_DEV_ONLY, '…']`.
10
11
  export { DEFAULT_DEV_ONLY } from './dev-only.js';
11
12
  import { DEFAULT_MONO_OPTIONS } from './mono.js';
@@ -90,6 +91,16 @@ function reportSizes(shaken, read, root, verbose, log) {
90
91
  }
91
92
  /** Query flag a specialized-variant `.svelte` request carries (see below). */
92
93
  const VARIANT_QUERY = 'shaker_variant';
94
+ /**
95
+ * Above this many seed components, `auto` uses the JS engine instead of the native
96
+ * one. The native engine's parse is faster, but it marshals the whole-program AST
97
+ * across the JS<->WASM boundary as JSON; for a large app (hundreds of components,
98
+ * tens of MB of AST) that round-trip outweighs the parse saving, so the
99
+ * boundary-free JS engine wins. A round proxy for "the AST is big enough that the
100
+ * boundary tax dominates", not a measured knife-edge — the choice is speed-only
101
+ * (both engines emit byte-identical output), so being approximate is fine.
102
+ */
103
+ const RUST_ENGINE_MAX_COMPONENTS = 300;
93
104
  /**
94
105
  * Resolve the {@link MonomorphizeOptions} from the public option surface.
95
106
  * Monomorphization is ON by default (it is bail-safe and never bloats); it is
@@ -130,6 +141,7 @@ const KNOWN_OPTIONS = {
130
141
  entries: true,
131
142
  preserve: true,
132
143
  devOnly: true,
144
+ exclude: true,
133
145
  monomorphize: true,
134
146
  engine: true,
135
147
  dev: true,
@@ -185,6 +197,10 @@ export function shaker(options = {}) {
185
197
  /** Variant request id (`<childPath>?shaker_variant=<n>`) -> residual source. */
186
198
  let variantSources = new Map();
187
199
  let root = process.cwd();
200
+ // Build-output dirs pruned from both scans (docs §8.1.1): the resolved
201
+ // `build.outDir` (when safe) plus the user's `exclude`. Compiled in
202
+ // `configResolved`, once `build.outDir` is known.
203
+ let exclude = compileExclude(root, options.exclude);
188
204
  // Vite's logger, captured in `configResolved`; until then fall back to console
189
205
  // so the size report still surfaces if `buildStart` somehow runs first.
190
206
  let log = (msg) => console.info(`[svelte-shaker] ${msg}`);
@@ -215,29 +231,40 @@ export function shaker(options = {}) {
215
231
  /** The long-lived incremental engine, created in `configureServer` (serve only). */
216
232
  let devShaker = null;
217
233
  // Resolve the parser ONCE (lazily, so the rsvelte wasm is only loaded when a
218
- // parser is actually needed). The default is `'rsvelte'`; `undefined` (returned
219
- // for the `parser: 'svelte'` opt-out) means svelte/compiler. `@rsvelte/compiler`
220
- // is a bundled dependency, so this normally just works; if it somehow can't be
221
- // loaded (a broken install, an environment that can't instantiate the wasm) the
222
- // default THROWS rather than silently falling back to svelte/compiler a silent
223
- // swap would make the same source shake differently depending on the machine, a
224
- // reproducibility footgun. Failing loudly keeps the output deterministic;
225
- // `parser: 'svelte'` is the explicit opt-out.
226
- let parseResolved = false;
227
- let parse;
228
- const getParse = () => {
229
- if (parseResolved)
230
- return parse;
231
- parseResolved = true;
232
- if ((options.parser ?? 'rsvelte') === 'rsvelte') {
233
- parse = tryLoadRsvelteParser() ?? undefined;
234
- if (!parse)
235
- throw new Error('[vite-plugin-svelte-shaker] the default parser "rsvelte" could not load its ' +
234
+ // parser is actually needed). `undefined` means svelte/compiler.
235
+ //
236
+ // The default FOLLOWS THE ENGINE: rsvelte feeds the native (Rust) engine its
237
+ // AST crosses the WASM boundary directly but on the JS engine rsvelte's parse
238
+ // is pure overhead (~2x slower than svelte/compiler here) with no downstream
239
+ // benefit, so the JS path defaults to svelte/compiler. The two parsers are
240
+ // differentially tested to produce byte-identical output, so this is speed-only.
241
+ //
242
+ // When rsvelte IS the resolved parser (the native engine, or explicit
243
+ // `parser: 'rsvelte'`) and `@rsvelte/compiler` can't be loaded, the plugin THROWS
244
+ // rather than silently swapping to svelte/compiler — a silent swap would make the
245
+ // same source shake on one machine and not another. `parser: 'svelte'` is the
246
+ // explicit opt-out; `parser: 'rsvelte'` forces rsvelte even on the JS engine.
247
+ // Memoized PER ENGINE: `vite build --watch` reuses this plugin instance across
248
+ // rebuilds, and `auto` can flip engine as the app grows/shrinks past the size
249
+ // threshold so the parser must be able to follow. Keyed by `useRust` (a
250
+ // present key is a resolved slot, whose value may legitimately be `undefined` =
251
+ // svelte/compiler).
252
+ const parseByEngine = new Map();
253
+ const getParse = (useRust) => {
254
+ if (parseByEngine.has(useRust))
255
+ return parseByEngine.get(useRust);
256
+ const parser = options.parser ?? (useRust ? 'rsvelte' : 'svelte');
257
+ let resolved;
258
+ if (parser === 'rsvelte') {
259
+ resolved = tryLoadRsvelteParser() ?? undefined;
260
+ if (!resolved)
261
+ throw new Error('[vite-plugin-svelte-shaker] the "rsvelte" parser could not load its ' +
236
262
  'bundled dependency `@rsvelte/compiler` (a broken install, or an environment that ' +
237
263
  'can\'t instantiate its wasm). Reinstall dependencies, or set `parser: "svelte"` ' +
238
264
  'to use svelte/compiler (the fallback parser).');
239
265
  }
240
- return parse;
266
+ parseByEngine.set(useRust, resolved);
267
+ return resolved;
241
268
  };
242
269
  /** The module specifier the rewritten owner imports a given variant from. */
243
270
  const variantSpecifier = (variantId) => {
@@ -259,6 +286,25 @@ export function shaker(options = {}) {
259
286
  root = config.root;
260
287
  log = (msg) => config.logger.info(`[svelte-shaker] ${msg}`);
261
288
  warn = (msg) => config.logger.warn(`[svelte-shaker] ${msg}`);
289
+ // Prune the resolved `build.outDir` (the destination this build overwrites —
290
+ // normally it can hold no source the app depends on) plus any user-declared
291
+ // `exclude` (an adapter's `build/`, a `dist/`). docs §8.1.1.
292
+ //
293
+ // But a misconfigured `outDir` that is the crawl root itself or an ANCESTOR
294
+ // of an entry dir (e.g. `outDir: '.'`) would prune real source — a silent
295
+ // over-shake. Sound-first: in that case skip the automatic `outDir`
296
+ // exclusion and warn, keeping only what the user explicitly listed.
297
+ const outDir = path.resolve(root, config.build.outDir);
298
+ const entryDirs = (options.entries ?? ['.']).map((p) => path.resolve(root, p));
299
+ const outDirCoversSource = entryDirs.some((d) => d === outDir || d.startsWith(outDir + path.sep));
300
+ if (outDirCoversSource) {
301
+ warn(`build.outDir (${path.relative(root, outDir) || '.'}) is the crawl root or ` +
302
+ `contains an entry directory, so excluding it would prune source; skipping the ` +
303
+ `automatic build-output exclusion. Set an outDir outside your source, or list the ` +
304
+ `real output dir in the \`exclude\` option.`);
305
+ }
306
+ const autoExclude = outDirCoversSource ? [] : [outDir];
307
+ exclude = compileExclude(root, [...autoExclude, ...(options.exclude ?? [])]);
262
308
  },
263
309
  // Dev (serve): drive the long-lived incremental engine instead of the
264
310
  // one-shot build crawl. `configureServer` runs before `buildStart`, so
@@ -273,7 +319,7 @@ export function shaker(options = {}) {
273
319
  const isDevOnly = compileDevOnly(root, options.devOnly);
274
320
  // The engine's entry components, collected from the entry DIRS: `entries`
275
321
  // names the roots to crawl from, these are the `.svelte` files under them.
276
- const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly));
322
+ const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly, exclude));
277
323
  const read = (id) => fs.readFileSync(id, 'utf-8');
278
324
  const underDirs = (file) => dirs.some((d) => file === d || file.startsWith(d + path.sep));
279
325
  // Re-scan the non-`.svelte` modules for `.svelte` call sites and re-apply
@@ -289,6 +335,7 @@ export function shaker(options = {}) {
289
335
  preserve: options.preserve,
290
336
  components: entryComponents,
291
337
  devOnly: isDevOnly,
338
+ exclude,
292
339
  resolve: fsResolve,
293
340
  readFile: read,
294
341
  });
@@ -296,7 +343,7 @@ export function shaker(options = {}) {
296
343
  escapedKey = [...result.escaped].sort().join('\n');
297
344
  return result.escaped;
298
345
  };
299
- devShaker = new DevShaker(entryComponents, fsResolve, read, devMode, getParse(), await currentEscaped());
346
+ devShaker = new DevShaker(entryComponents, fsResolve, read, devMode, getParse(false), await currentEscaped());
300
347
  shaken = await devShaker.init();
301
348
  const applyDelta = (result) => {
302
349
  for (const [id, code] of Object.entries(result.changed))
@@ -380,7 +427,7 @@ export function shaker(options = {}) {
380
427
  const isDevOnly = compileDevOnly(root, options.devOnly);
381
428
  // The engine's entry components, collected from the entry DIRS: `entries`
382
429
  // names the roots to crawl from, these are the `.svelte` files under them.
383
- const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly));
430
+ const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly, exclude));
384
431
  if (entryComponents.length === 0) {
385
432
  shaken = {};
386
433
  variantSources = new Map();
@@ -420,6 +467,7 @@ export function shaker(options = {}) {
420
467
  preserve: options.preserve,
421
468
  components: entryComponents,
422
469
  devOnly: isDevOnly,
470
+ exclude,
423
471
  resolve,
424
472
  readFile: read,
425
473
  });
@@ -439,19 +487,26 @@ export function shaker(options = {}) {
439
487
  throw new Error('[vite-plugin-svelte-shaker] engine: "rust" was requested but the WASM engine ' +
440
488
  'could not be loaded. Remove the option (or use engine: "js") to use the JS engine.');
441
489
  }
442
- else if (engineChoice === 'auto') {
490
+ else if (engineChoice === 'auto' && entryComponents.length <= RUST_ENGINE_MAX_COMPONENTS) {
491
+ // The native engine parses fast, but the whole-program AST must cross the
492
+ // JS<->WASM boundary as JSON, and past a few hundred components that AST is
493
+ // tens of MB (this app: ~600 components -> ~60 MB) — the round-trip then
494
+ // costs more than the faster parse saves, so `auto` keeps a large app on the
495
+ // boundary-free JS engine. Speed-only: both engines emit byte-identical
496
+ // output, so a borderline misjudgment only affects build time, never what
497
+ // ships. `engine: 'rust'` overrides this to force the native engine.
443
498
  wasm = tryLoadWasmEngine();
444
499
  }
445
500
  if (wasm) {
446
501
  // Native Rust engine — byte-identical to the JS engine, including
447
502
  // monomorphization.
448
503
  if (mono.enabled) {
449
- const result = await svelteShakerWasmWithMono(wasm, entryComponents, resolve, read, mono, getParse(), escaped);
504
+ const result = await svelteShakerWasmWithMono(wasm, entryComponents, resolve, read, mono, getParse(true), escaped);
450
505
  shaken = result.files;
451
506
  variantSources = result.variants;
452
507
  }
453
508
  else {
454
- shaken = await svelteShakerWasm(wasm, entryComponents, resolve, read, getParse(), escaped);
509
+ shaken = await svelteShakerWasm(wasm, entryComponents, resolve, read, getParse(true), escaped);
455
510
  variantSources = new Map();
456
511
  }
457
512
  reportSizes(shaken, read, root, options.verbose === true, log);
@@ -460,12 +515,12 @@ export function shaker(options = {}) {
460
515
  if (!mono.enabled) {
461
516
  // JS engine, monomorphization off: byte-for-byte the unused-prop fold /
462
517
  // constant fold / value-set narrowing output.
463
- shaken = await svelteShaker(entryComponents, resolve, read, getParse(), escaped);
518
+ shaken = await svelteShaker(entryComponents, resolve, read, getParse(false), escaped);
464
519
  variantSources = new Map();
465
520
  reportSizes(shaken, read, root, options.verbose === true, log);
466
521
  return;
467
522
  }
468
- const result = await svelteShakerWithMono(entryComponents, resolve, read, mono, variantSpecifier, getParse(), escaped);
523
+ const result = await svelteShakerWithMono(entryComponents, resolve, read, mono, variantSpecifier, getParse(false), escaped);
469
524
  shaken = result.files;
470
525
  variantSources = new Map();
471
526
  for (const v of result.mono.variants.values())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",