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 +59 -29
- package/dist/analyze.js +52 -32
- package/dist/escape-scan.d.ts +8 -0
- package/dist/escape-scan.js +16 -9
- package/dist/eval.d.ts +15 -0
- package/dist/eval.js +25 -1
- package/dist/exclude.d.ts +25 -0
- package/dist/exclude.js +29 -0
- package/dist/ir.d.ts +14 -0
- package/dist/ir.js +16 -0
- package/dist/mono.d.ts +1 -1
- package/dist/mono.js +47 -9
- package/dist/parse.d.ts +7 -0
- package/dist/scan.d.ts +3 -1
- package/dist/scan.js +14 -6
- package/dist/svelte_shaker_engine_bg.wasm +0 -0
- package/dist/transform.js +47 -4
- package/dist/vite.d.ts +41 -13
- package/dist/vite.js +82 -27
- package/package.json +1 -1
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.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)).
|
|
60
|
-
|
|
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
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
-
|
|
117
|
-
|
|
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)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
|
1773
|
-
|
|
1774
|
-
code
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
}
|
package/dist/escape-scan.d.ts
CHANGED
|
@@ -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>;
|
package/dist/escape-scan.js
CHANGED
|
@@ -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
|
|
34
|
-
* include scope as the seed scan — `.ts` inside
|
|
35
|
-
* scanned (docs §4.2). `devOnly` drops modules
|
|
36
|
-
* so a colocated test does not mark the
|
|
37
|
-
* it
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/exclude.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
372
|
-
ast =
|
|
393
|
+
if (source === model.code) {
|
|
394
|
+
ast = model.ast;
|
|
373
395
|
}
|
|
374
|
-
|
|
375
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
104
|
-
*
|
|
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.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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).
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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())
|