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 +68 -30
- package/dist/analyze.d.ts +44 -2
- package/dist/analyze.js +72 -57
- package/dist/escape-scan.d.ts +8 -0
- package/dist/escape-scan.js +16 -9
- package/dist/exclude.d.ts +25 -0
- package/dist/exclude.js +29 -0
- package/dist/mono.js +44 -9
- package/dist/native-engine.d.ts +47 -0
- package/dist/native-engine.js +145 -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 +26 -4
- package/dist/vite.d.ts +56 -19
- package/dist/vite.js +113 -31
- package/package.json +4 -1
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.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(
|
|
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)).
|
|
60
|
-
|
|
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
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
177
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
264
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
|
1780
|
-
|
|
1781
|
-
code
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
}
|
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)
|
|
@@ -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;
|