svelte-shaker 0.15.3 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,17 +47,21 @@ 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 automatically (see
51
+ [Options](#options)): if a **native (napi) Rust** binary loads it runs there —
52
+ parsing with rsvelte in process, fastest, no size ceiling; otherwise a small/medium
53
+ app runs on the **WASM Rust engine** (parsing with **rsvelte** from
54
+ `@rsvelte/compiler`, a bundled WASM dependency — no peer, no platform-specific
55
+ binary) and a large app on the **JS engine** with **svelte/compiler**. `engine` and
56
+ `parser` let you pin the choice.
54
57
 
55
58
  ## Usage (Vite)
56
59
 
57
60
  Add the plugin **before** `svelte()`. By default it runs only in `vite build` —
58
61
  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)).
62
+ [Options](#options)). The engine is chosen automatically (native Rust if a binary
63
+ loads, else WASM Rust for small/medium or JS for large), and every path falls back
64
+ cleanly (see [Options](#options)).
61
65
 
62
66
  ```ts
63
67
  // vite.config.ts
@@ -82,8 +86,8 @@ that monomorphization additionally needs the `?shaker_variant` requests routed
82
86
  through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
83
87
  fold / value-set narrowing shake only needs the `transform` swap. The
84
88
  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.
89
+ — the Vite plugin's rsvelte selection is a plugin concern (it loads a Node-only
90
+ WASM module); the engine takes an optional `parse` argument if you want to swap it.
87
91
 
88
92
  ### Options
89
93
 
@@ -95,14 +99,17 @@ shaker({
95
99
  devOnly: [...], // glob patterns of files that never ship (tests, stories); they
96
100
  // stop counting as call sites. Defaults to tests/mocks/stories; replaces, spread
97
101
  // to extend.
102
+ exclude: [], // build-output dirs to skip walking (a SvelteKit adapter's `build/`,
103
+ // a `dist/`). The Vite `build.outDir` is always skipped; add other generated
104
+ // output here. Not source — see below.
98
105
  monomorphize: true, // default on; `false` disables it for faster builds,
99
106
  // or { maxVariants: 16, minSavings: 0.05 } to tune
100
107
  verbose: false, // true = per-file size breakdown after the build
101
108
 
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)
109
+ // Engine is auto-selected; set these only to pin.
110
+ engine: 'auto', // 'auto' (native Rust if it loads, else WASM if <=~300, else JS) | 'js' | 'rust'
111
+ parser: undefined, // JS/WASM parse only (native always uses in-process rsvelte);
112
+ // default follows the engine (WASM->rsvelte, JS->svelte); set 'rsvelte' | 'svelte' to pin
106
113
 
107
114
  dev: false, // default off: dev is a pass-through. 'incremental' (re-parse only
108
115
  // changed files) | 'coarse' (re-analyze everything) opts in; never monomorphizes
@@ -113,17 +120,33 @@ That list is exhaustive: any other key **fails the build**, naming the key and t
113
120
  options that do exist. A typo would otherwise be ignored — and a misspelled
114
121
  `preserve` ships the component you meant to protect, over-shaken.
115
122
 
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
118
- [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
119
- dependencynothing 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.
123
+ - **`engine`** which engine runs the shake. There are two Rust engines (the same
124
+ analysis behind two frontends) plus the JS engine. The **native (napi)** engine
125
+ parses with rsvelte **in process** and keeps the ASTs Rust-side, so no
126
+ whole-program AST crosses a boundary fastest, no size ceiling but it ships as
127
+ a per-platform prebuilt binary that may not exist for every install. The **WASM**
128
+ engine is the same Rust engine, but the whole-program AST must cross the JS↔WASM
129
+ boundary as JSON (tens of MB past a few hundred components), so it only wins for
130
+ small/medium apps; the **JS** engine needs no boundary crossing, so it wins for a
131
+ large app when the native binary isn't available. **`'auto'`** (default) uses the
132
+ native engine if a binary loads (no size gate), else the WASM engine for a
133
+ small/medium app or the JS engine for a large one. **`'rust'`** forces a Rust
134
+ engine — native if it loads, else WASM (throwing if `@rsvelte/compiler` can't load
135
+ for the WASM fallback); **`'js'`** forces the JS engine. All three are
136
+ differentially tested to shake **byte-identically**, so this is **speed-only** — it
137
+ never changes what ships.
138
+ - **`parser`** — how the **JS / WASM** engines parse `.svelte`. It does **not** apply
139
+ to the native engine, which always parses with rsvelte in process. The default
140
+ **follows the engine**: **rsvelte** on the WASM engine (its AST crosses into Rust
141
+ directly), **svelte/compiler** on the JS engine (where rsvelte's parse is ~2× slower
142
+ with no downstream benefit). The choice is **soundness-neutral** — the engine reads
143
+ only UTF-16 `start`/`end`, so both parsers are differentially tested to produce
144
+ **byte-identical** output, never changing what renders. `parser: 'svelte'` also
145
+ forces the native engine **off** (it can't honor svelte/compiler), so the shake uses
146
+ a JS/WASM engine with svelte/compiler. When rsvelte is the resolved JS-side parser
147
+ (`parser: 'rsvelte'`, or the WASM engine's default) and `@rsvelte/compiler` can't
148
+ load, the plugin **throws** rather than silently swapping (so the same source can't
149
+ shake differently on another machine); `parser: 'svelte'` is the explicit opt-out.
127
150
  - **`monomorphize`** — the one shaking knob, **on** by default. A measured
128
151
  net-win gate only specializes a component when that strictly shrinks the whole
129
152
  program, so monomorphization **never bloats**: whatever the knobs are set to,
@@ -146,13 +169,6 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
146
169
  monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
147
170
  // design system, while skipping specializations that save under 5%
148
171
  ```
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
172
  - **`dev`** — whether to shake in `vite dev` too. **Off** by default: dev is a
157
173
  pass-through, which is always correct and keeps HMR simple. Opt in with
158
174
  `dev: 'incremental'` — re-parses only the changed files and re-runs the
@@ -212,6 +228,28 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
212
228
  [`docs/ARCHITECTURE.md` §8.1.1](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
213
229
  for the full argument.
214
230
 
231
+ - **`exclude`** — directories the scans must **not walk at all**: a compiled,
232
+ generated tree that is **not source**. Each entry is a Vite-root-relative or
233
+ absolute path naming a directory, matched on a plain path-prefix basis (like
234
+ `entries`, no glob). The resolved Vite **`build.outDir` is always excluded
235
+ automatically** — it is the destination the build overwrites, so it holds no
236
+ source the app depends on. Use this option for output dirs the plugin can't infer,
237
+ most importantly a **SvelteKit adapter's `build/`** (adapter-static): it sits
238
+ _outside_ `build.outDir`, and left unpruned the escape scan parses megabytes of
239
+ minified output looking for call sites it can never contain, which can dominate
240
+ the crawl.
241
+
242
+ ```ts
243
+ shaker({ entries: ['.'], exclude: ['build'] }); // skip adapter-static output
244
+ ```
245
+
246
+ Distinct from `devOnly`: that marks non-shipping **source** files (tests, stories)
247
+ by glob; `exclude` prunes whole generated-**output** directories that are not
248
+ source at all. Like `entries`, **over-listing errs unsafe** — a pruned directory's
249
+ call sites stop counting, exactly as if it were outside the crawl — so name **only
250
+ generated output, never source**. That is why there is no default beyond the
251
+ always-safe `build.outDir`.
252
+
215
253
  ## What it removes
216
254
 
217
255
  | Pass | What it removes | Default |
package/dist/analyze.d.ts CHANGED
@@ -237,14 +237,32 @@ export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<C
237
237
  * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
238
238
  * value set), keeping the produced model set — and thus the output — identical.
239
239
  */
240
- export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
240
+ /**
241
+ * The three per-file facts the crawl needs to resolve edges — import specifiers,
242
+ * rendered `<Local>` tag names, and `<ns.X>` member tag names. `null` when the file
243
+ * has no instance script (nothing to attribute), matching the crawl's skip.
244
+ */
245
+ export interface CrawlFacts {
246
+ imports: ImportInfo[];
247
+ renderedTags: Set<string>;
248
+ memberTags: Set<string>;
249
+ }
250
+ /**
251
+ * Source of {@link CrawlFacts} for one `(id, code)`. The default is a JS parse +
252
+ * extraction ({@link jsCrawlFacts}); the native engine passes a provider backed by
253
+ * `ShakeSession` facts, so the crawl resolves the SAME edges without the JS parse.
254
+ */
255
+ export type FactsProvider = (id: ComponentId, code: string) => CrawlFacts | null;
256
+ /** The default facts provider: parse (svelte/compiler or rsvelte) and extract. */
257
+ export declare function jsCrawlFacts(id: ComponentId, code: string, parseCache?: ParseCache, parse?: Parse): CrawlFacts | null;
258
+ export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[], factsProvider?: FactsProvider): Promise<AnalyzeInput>;
241
259
  /**
242
260
  * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
243
261
  * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
244
262
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
245
263
  * pins it identical to the async path, so keep the two bodies in lockstep.
246
264
  */
247
- export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
265
+ export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[], factsProvider?: FactsProvider): AnalyzeInput;
248
266
  /**
249
267
  * Dead `{#if}` spans per component implied by `plans`, via the SAME shared
250
268
  * predicate the transform uses ({@link computeDeadSpans}). A bailed component
@@ -299,6 +317,22 @@ export declare function findNeverPassedProps(input: AnalyzeInput): Map<Component
299
317
  * maps cleanly; an external name with no matching declared local is dropped.
300
318
  */
301
319
  export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileModel): Map<string, V>;
320
+ /**
321
+ * The bare component tag names this file RENDERS (`<Local/>`, excluding dotted
322
+ * `<ns.X/>` member tags). The Shell crawl uses this to resolve a barrel (a
323
+ * `.js`/`.ts` re-export, which costs a module read+parse) only for named imports
324
+ * actually rendered as a component — a value-only named import (a helper / type)
325
+ * is never a `<Local>` call site, so following it would read+parse a module for
326
+ * nothing. Skipping it only ever drops a non-call-site, so attribution (and the
327
+ * resulting models) are unchanged.
328
+ */
329
+ export declare function renderedComponentTagNames(ast: Root): Set<string>;
330
+ /**
331
+ * Every dotted component tag a file renders (`<ns.Child/>` -> `"ns.Child"`). The
332
+ * Shell resolves each through its namespace import's barrel; bare `<Child/>` tags
333
+ * have no dot and are bound by the plain import maps instead.
334
+ */
335
+ export declare function memberComponentTags(ast: Root): Set<string>;
302
336
  /**
303
337
  * Read one `<Child .../>` into a {@link CallSite}. Attributes are in source
304
338
  * order, so we resolve last-write-wins (a later `a={…}` overrides an earlier
@@ -323,3 +357,11 @@ export declare function readCallSite(component: AnyNode, owner?: ComponentId): C
323
357
  * monomorphization specialization (mono.ts) must honor this identically.
324
358
  */
325
359
  export declare function isFoldBlockedName(model: FileModel, name: string): boolean;
360
+ export interface ImportInfo {
361
+ value: string;
362
+ local: string;
363
+ /** `default` for a default import, the exported name for a named import, or
364
+ * `*` for a namespace import. */
365
+ imported: string;
366
+ }
367
+ export declare function importSources(instance: AnyNode): Generator<ImportInfo>;
package/dist/analyze.js CHANGED
@@ -150,39 +150,44 @@ function buildModels(input, parseCache) {
150
150
  }
151
151
  return models;
152
152
  }
153
- /**
154
- * The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
155
- * the component graph from `entries`, resolving every import edge and reading
156
- * every reachable `.svelte` file up front, into a batched {@link AnalyzeInput}.
157
- *
158
- * This is the half that STAYS in JS — it owns `this.resolve` / file IO for Vite
159
- * ecosystem compat (docs ARCHITECTURE §5/§9) — so the engine ({@link
160
- * analyzeInput}) consumes its output with no callback across the boundary. The
161
- * traversal mirrors the old crawl exactly: direct default-`.svelte` children and
162
- * the barrel children a file actually RENDERS are followed (an unrendered barrel
163
- * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
164
- * value set), keeping the produced model set — and thus the output — identical.
165
- */
166
- export async function buildAnalyzeInput(entries, resolve, readFile, parseCache, parse, escaped = []) {
153
+ /** The default facts provider: parse (svelte/compiler or rsvelte) and extract. */
154
+ export function jsCrawlFacts(id, code, parseCache, parse) {
155
+ const ast = parseCached(id, code, parseCache, parse);
156
+ const instance = ast.instance;
157
+ if (!instance)
158
+ return null;
159
+ return {
160
+ imports: [...importSources(instance)],
161
+ renderedTags: renderedComponentTagNames(ast),
162
+ memberTags: memberComponentTags(ast),
163
+ };
164
+ }
165
+ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache, parse, escaped = [],
166
+ // The facts source. Defaults to a JS parse + extract; the native engine injects a
167
+ // `ShakeSession`-backed provider (docs M3). Internal — the public crawl is unchanged.
168
+ factsProvider) {
169
+ const getFacts = factsProvider ?? ((id, code) => jsCrawlFacts(id, code, parseCache, parse));
167
170
  const entryList = Array.isArray(entries) ? [...entries] : [entries];
168
171
  const files = [];
169
172
  const edges = [];
170
173
  const queue = [...entryList];
171
174
  const seen = new Set(queue);
175
+ // Parse each `.js`/`.ts` barrel at most once across the whole crawl (a shared
176
+ // design-system `index.ts` is re-imported hundreds of times).
177
+ const barrelCache = new Map();
172
178
  while (queue.length > 0) {
173
179
  const id = queue.shift();
174
180
  const code = await readFile(id);
175
181
  files.push({ id, code });
176
- const ast = parseCached(id, code, parseCache, parse);
177
- const instance = ast.instance;
178
- if (!instance)
182
+ const facts = getFacts(id, code);
183
+ if (!facts)
179
184
  continue;
180
185
  // The bare component tags this file renders (`<Local …>`). Resolving a barrel
181
186
  // (a `.js`/`.ts` re-export) means READING and PARSING the target module to
182
187
  // chase the export, so we do it ONLY for named imports actually rendered as a
183
188
  // component here — a named import used as a value (a helper / type) can never
184
189
  // be a `<Local>` call site, so chasing it is pure waste.
185
- const renderedTags = renderedComponentTagNames(ast);
190
+ const renderedTags = facts.renderedTags;
186
191
  // Resolve this file's imports into the three attributable edge kinds. Direct
187
192
  // default `.svelte` and simple barrel/named imports bind a bare local; a
188
193
  // namespace import (`import * as ns`) binds no single component, so it is
@@ -190,7 +195,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
190
195
  const barrelLocals = new Map();
191
196
  const namespaceSources = new Map();
192
197
  const directChildren = [];
193
- for (const imp of importSources(instance)) {
198
+ for (const imp of facts.imports) {
194
199
  if (imp.imported === '*') {
195
200
  namespaceSources.set(imp.local, imp.value);
196
201
  continue;
@@ -206,7 +211,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
206
211
  // Not rendered as `<imp.local>` -> not a call site -> skip the costly barrel read.
207
212
  if (!renderedTags.has(imp.local))
208
213
  continue;
209
- const childId = await resolveThroughBarrel(imp.value, imp.imported, id, resolve, readFile);
214
+ const childId = await resolveThroughBarrel(imp.value, imp.imported, id, resolve, readFile, barrelCache);
210
215
  if (childId) {
211
216
  edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
212
217
  barrelLocals.set(imp.local, childId);
@@ -220,12 +225,12 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
220
225
  // renders, so the engine attributes `<ns.X .../>` by name lookup.
221
226
  const nsChildren = [];
222
227
  if (namespaceSources.size > 0) {
223
- for (const tag of memberComponentTags(ast)) {
228
+ for (const tag of facts.memberTags) {
224
229
  const dot = tag.indexOf('.');
225
230
  const source = namespaceSources.get(tag.slice(0, dot));
226
231
  if (source == null)
227
232
  continue;
228
- const childId = await resolveThroughBarrel(source, tag.slice(dot + 1), id, resolve, readFile);
233
+ const childId = await resolveThroughBarrel(source, tag.slice(dot + 1), id, resolve, readFile, barrelCache);
229
234
  if (childId) {
230
235
  edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
231
236
  nsChildren.push(childId);
@@ -250,28 +255,29 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
250
255
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
251
256
  * pins it identical to the async path, so keep the two bodies in lockstep.
252
257
  */
253
- export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, parse, escaped = []) {
258
+ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, parse, escaped = [], factsProvider) {
259
+ const getFacts = factsProvider ?? ((id, code) => jsCrawlFacts(id, code, parseCache, parse));
254
260
  const entryList = Array.isArray(entries) ? [...entries] : [entries];
255
261
  const files = [];
256
262
  const edges = [];
257
263
  const queue = [...entryList];
258
264
  const seen = new Set(queue);
265
+ const barrelCache = new Map();
259
266
  while (queue.length > 0) {
260
267
  const id = queue.shift();
261
268
  const code = readFile(id);
262
269
  files.push({ id, code });
263
- const ast = parseCached(id, code, parseCache, parse);
264
- const instance = ast.instance;
265
- if (!instance)
270
+ const facts = getFacts(id, code);
271
+ if (!facts)
266
272
  continue;
267
273
  // See {@link buildAnalyzeInput}: resolve a barrel only for named imports
268
274
  // actually rendered as a `<Local>` component here, to avoid reading+parsing
269
275
  // modules behind value-only named imports.
270
- const renderedTags = renderedComponentTagNames(ast);
276
+ const renderedTags = facts.renderedTags;
271
277
  const barrelLocals = new Map();
272
278
  const namespaceSources = new Map();
273
279
  const directChildren = [];
274
- for (const imp of importSources(instance)) {
280
+ for (const imp of facts.imports) {
275
281
  if (imp.imported === '*') {
276
282
  namespaceSources.set(imp.local, imp.value);
277
283
  continue;
@@ -286,7 +292,7 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
286
292
  }
287
293
  if (!renderedTags.has(imp.local))
288
294
  continue;
289
- const childId = resolveThroughBarrelSync(imp.value, imp.imported, id, resolve, readFile);
295
+ const childId = resolveThroughBarrelSync(imp.value, imp.imported, id, resolve, readFile, barrelCache);
290
296
  if (childId) {
291
297
  edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
292
298
  barrelLocals.set(imp.local, childId);
@@ -294,12 +300,12 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
294
300
  }
295
301
  const nsChildren = [];
296
302
  if (namespaceSources.size > 0) {
297
- for (const tag of memberComponentTags(ast)) {
303
+ for (const tag of facts.memberTags) {
298
304
  const dot = tag.indexOf('.');
299
305
  const source = namespaceSources.get(tag.slice(0, dot));
300
306
  if (source == null)
301
307
  continue;
302
- const childId = resolveThroughBarrelSync(source, tag.slice(dot + 1), id, resolve, readFile);
308
+ const childId = resolveThroughBarrelSync(source, tag.slice(dot + 1), id, resolve, readFile, barrelCache);
303
309
  if (childId) {
304
310
  edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
305
311
  nsChildren.push(childId);
@@ -1265,7 +1271,7 @@ function collectChildCalls(ast, imports) {
1265
1271
  * nothing. Skipping it only ever drops a non-call-site, so attribution (and the
1266
1272
  * resulting models) are unchanged.
1267
1273
  */
1268
- function renderedComponentTagNames(ast) {
1274
+ export function renderedComponentTagNames(ast) {
1269
1275
  const names = new Set();
1270
1276
  walk(ast.fragment, null, {
1271
1277
  Component(node, { next }) {
@@ -1282,7 +1288,7 @@ function renderedComponentTagNames(ast) {
1282
1288
  * Shell resolves each through its namespace import's barrel; bare `<Child/>` tags
1283
1289
  * have no dot and are bound by the plain import maps instead.
1284
1290
  */
1285
- function memberComponentTags(ast) {
1291
+ export function memberComponentTags(ast) {
1286
1292
  const tags = new Set();
1287
1293
  walk(ast.fragment, null, {
1288
1294
  Component(node, { next }) {
@@ -1646,7 +1652,7 @@ function literalDefault(expr) {
1646
1652
  return { known: true, value: undefined };
1647
1653
  return { known: false };
1648
1654
  }
1649
- function* importSources(instance) {
1655
+ export function* importSources(instance) {
1650
1656
  const program = instance.content;
1651
1657
  for (const stmt of program?.body ?? []) {
1652
1658
  if (stmt.type !== 'ImportDeclaration')
@@ -1708,7 +1714,7 @@ const MAX_BARREL_HOPS = 8;
1708
1714
  * we cannot follow returns `null` — sound, because a child we never resolve is
1709
1715
  * never planned (a pure-barrel `.js` component is simply out of scope).
1710
1716
  */
1711
- async function resolveThroughBarrel(source, imported, importer, resolve, readFile, hops = 0) {
1717
+ async function resolveThroughBarrel(source, imported, importer, resolve, readFile, cache, hops = 0) {
1712
1718
  if (hops > MAX_BARREL_HOPS)
1713
1719
  return null;
1714
1720
  const targetId = await resolve(source, importer);
@@ -1720,15 +1726,20 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1720
1726
  if (isSvelte(source) || isSvelte(targetId)) {
1721
1727
  return imported === 'default' || imported === '*' ? targetId : null;
1722
1728
  }
1723
- // A `.js`/`.ts` barrel: read it and chase the matching re-export.
1724
- let code;
1725
- try {
1726
- code = await readFile(targetId);
1727
- }
1728
- catch {
1729
- return null;
1729
+ // A `.js`/`.ts` barrel: read + parse it (once per crawl, memoized) and chase the
1730
+ // matching re-export.
1731
+ let body = cache.get(targetId);
1732
+ if (body === undefined) {
1733
+ let code;
1734
+ try {
1735
+ code = await readFile(targetId);
1736
+ }
1737
+ catch {
1738
+ code = null;
1739
+ }
1740
+ body = code === null ? null : parseModuleBody(code, targetId);
1741
+ cache.set(targetId, body);
1730
1742
  }
1731
- const body = parseModuleBody(code, targetId);
1732
1743
  if (!body)
1733
1744
  return null;
1734
1745
  for (const stmt of body) {
@@ -1737,7 +1748,7 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1737
1748
  for (const spec of stmt.specifiers ?? []) {
1738
1749
  if (specName(spec.exported) !== imported)
1739
1750
  continue;
1740
- return resolveThroughBarrel(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
1751
+ return resolveThroughBarrel(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, cache, hops + 1);
1741
1752
  }
1742
1753
  continue;
1743
1754
  }
@@ -1752,13 +1763,13 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1752
1763
  const found = followLocalImport(body, localName);
1753
1764
  if (!found)
1754
1765
  return null;
1755
- return resolveThroughBarrel(found.value, found.imported, targetId, resolve, readFile, hops + 1);
1766
+ return resolveThroughBarrel(found.value, found.imported, targetId, resolve, readFile, cache, hops + 1);
1756
1767
  }
1757
1768
  continue;
1758
1769
  }
1759
1770
  // `export * from './x'` — the name may live behind the wildcard.
1760
1771
  if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
1761
- const via = await resolveThroughBarrel(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
1772
+ const via = await resolveThroughBarrel(String(stmt.source.value), imported, targetId, resolve, readFile, cache, hops + 1);
1762
1773
  if (via)
1763
1774
  return via;
1764
1775
  }
@@ -1767,7 +1778,7 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1767
1778
  }
1768
1779
  /** Synchronous twin of {@link resolveThroughBarrel} (see {@link
1769
1780
  * buildAnalyzeInputSync}). Keep in lockstep with the async body above. */
1770
- function resolveThroughBarrelSync(source, imported, importer, resolve, readFile, hops = 0) {
1781
+ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile, cache, hops = 0) {
1771
1782
  if (hops > MAX_BARREL_HOPS)
1772
1783
  return null;
1773
1784
  const targetId = resolve(source, importer);
@@ -1776,14 +1787,18 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1776
1787
  if (isSvelte(source) || isSvelte(targetId)) {
1777
1788
  return imported === 'default' || imported === '*' ? targetId : null;
1778
1789
  }
1779
- let code;
1780
- try {
1781
- code = readFile(targetId);
1782
- }
1783
- catch {
1784
- return null;
1790
+ let body = cache.get(targetId);
1791
+ if (body === undefined) {
1792
+ let code;
1793
+ try {
1794
+ code = readFile(targetId);
1795
+ }
1796
+ catch {
1797
+ code = null;
1798
+ }
1799
+ body = code === null ? null : parseModuleBody(code, targetId);
1800
+ cache.set(targetId, body);
1785
1801
  }
1786
- const body = parseModuleBody(code, targetId);
1787
1802
  if (!body)
1788
1803
  return null;
1789
1804
  for (const stmt of body) {
@@ -1791,7 +1806,7 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1791
1806
  for (const spec of stmt.specifiers ?? []) {
1792
1807
  if (specName(spec.exported) !== imported)
1793
1808
  continue;
1794
- return resolveThroughBarrelSync(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
1809
+ return resolveThroughBarrelSync(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, cache, hops + 1);
1795
1810
  }
1796
1811
  continue;
1797
1812
  }
@@ -1805,12 +1820,12 @@ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile,
1805
1820
  const found = followLocalImport(body, localName);
1806
1821
  if (!found)
1807
1822
  return null;
1808
- return resolveThroughBarrelSync(found.value, found.imported, targetId, resolve, readFile, hops + 1);
1823
+ return resolveThroughBarrelSync(found.value, found.imported, targetId, resolve, readFile, cache, hops + 1);
1809
1824
  }
1810
1825
  continue;
1811
1826
  }
1812
1827
  if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
1813
- const via = resolveThroughBarrelSync(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
1828
+ const via = resolveThroughBarrelSync(String(stmt.source.value), imported, targetId, resolve, readFile, cache, hops + 1);
1814
1829
  if (via)
1815
1830
  return via;
1816
1831
  }
@@ -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)
@@ -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;