svelte-shaker 0.14.1 → 0.15.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
@@ -54,10 +54,10 @@ binary). `parser: 'svelte'` falls back to svelte/compiler if you ever need it
54
54
 
55
55
  ## Usage (Vite)
56
56
 
57
- Add the plugin **before** `svelte()`. It runs only in `vite build` — dev/HMR is
58
- a pass-through by design. Out of the box it runs the native **Rust (WASM)
59
- engine** and parses with **rsvelte**; both fall back cleanly (see
60
- [Options](#options)).
57
+ Add the plugin **before** `svelte()`. By default it runs only in `vite build` —
58
+ 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)).
61
61
 
62
62
  ```ts
63
63
  // vite.config.ts
@@ -92,6 +92,9 @@ shaker({
92
92
  entries: ['src'], // dirs (relative to root) the crawl starts from; they must
93
93
  // hold every .svelte call site in the app. Not a glob, not a filter.
94
94
  preserve: [], // components whose props must never be folded (see below)
95
+ devOnly: [...], // glob patterns of files that never ship (tests, stories); they
96
+ // stop counting as call sites. Defaults to tests/mocks/stories; replaces, spread
97
+ // to extend.
95
98
  monomorphize: true, // default on; `false` disables it for faster builds,
96
99
  // or { maxVariants: 16, minSavings: 0.05 } to tune
97
100
  verbose: false, // true = per-file size breakdown after the build
@@ -100,6 +103,9 @@ shaker({
100
103
  // out (e.g. if you ever hit a bug in it).
101
104
  engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
102
105
  parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
106
+
107
+ dev: false, // default off: dev is a pass-through. 'incremental' (re-parse only
108
+ // changed files) | 'coarse' (re-analyze everything) opts in; never monomorphizes
103
109
  });
104
110
  ```
105
111
 
@@ -147,6 +153,14 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
147
153
  can't (a broken install), the plugin **throws** rather than silently falling
148
154
  back — so the same source always shakes the same on every machine. Reinstall
149
155
  dependencies, or set `parser: 'svelte'`.
156
+ - **`dev`** — whether to shake in `vite dev` too. **Off** by default: dev is a
157
+ pass-through, which is always correct and keeps HMR simple. Opt in with
158
+ `dev: 'incremental'` — re-parses only the changed files and re-runs the
159
+ whole-program fixpoint over a long-lived incremental engine (the intended mode)
160
+ — or `dev: 'coarse'`, which re-analyzes the whole program on every change (the
161
+ slow but trivially-correct safety valve). Monomorphization is **never** applied
162
+ in dev; only the always-on passes (unused-prop fold / constant fold / value-set
163
+ narrowing) run.
150
164
  - **`preserve`** — keep a component's **prop interface** exactly as written, because
151
165
  something the shake can't see passes props to it. What is preserved is the props,
152
166
  **not** the file's presence in the bundle: this is unrelated to Rollup/Vite's
@@ -171,6 +185,32 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
171
185
  The build **warns** (with the file path) about any module the scan couldn't
172
186
  parse — so a mounted component isn't silently left unprotected — and about
173
187
  `preserve` entries that matched no component.
188
+ - **`devOnly`** — glob patterns (matched with
189
+ [`picomatch`](https://github.com/micromatch/picomatch) against each file's path
190
+ relative to the Vite root) naming files that **never ship in the production
191
+ bundle** — colocated tests, mocks, Storybook stories. A matched file **stops
192
+ counting as a component consumer** in **both directory scans** (the `.svelte` seed
193
+ scan and the non-`.svelte` escape scan), so a `Foo.test.svelte` or a
194
+ `Button.test.ts` can no longer pessimize the shake. It defaults to:
195
+
196
+ ```ts
197
+ // the built-in default (import DEFAULT_DEV_ONLY to extend it)
198
+ devOnly: ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/__mocks__/**', '**/*.stories.*'];
199
+ ```
200
+
201
+ Passing `devOnly` **replaces** this list (predictable semantics) — spread it to
202
+ extend: `devOnly: [...DEFAULT_DEV_ONLY, 'src/dev/**']` (import `DEFAULT_DEV_ONLY`
203
+ from `svelte-shaker/vite`). Pass `devOnly: []` to count every file (the pre-`devOnly`
204
+ behavior).
205
+
206
+ **List only files that never ship.** A matched file isn't excluded from the shake —
207
+ one the app actually imports is still crawled and shaken; it just stops _counting_
208
+ as a call site. So a file that really ships but matches a pattern (a `+page.svelte`
209
+ under a route dir named `__tests__`) has its distinct prop values stop blocking
210
+ folds, the same failure mode as leaving it out of `entries` — which is why the
211
+ defaults are narrow. See
212
+ [`docs/ARCHITECTURE.md` §8.1.1](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
213
+ for the full argument.
174
214
 
175
215
  ## What it removes
176
216
 
@@ -207,8 +247,9 @@ The whole point is to **never change observable behavior**.
207
247
  scope.
208
248
  - **Needs `.svelte` source** — libraries shipping compiled JS pass through
209
249
  unshaken; distribute via `svelte-package`.
210
- - **Build only** — whole-program analysis is incompatible with dev/HMR locality,
211
- so dev is always a pass-through.
250
+ - **Build-first** — whole-program analysis is incompatible with dev/HMR locality,
251
+ so dev is a pass-through by default; opt into incremental dev shaking with the
252
+ `dev` option.
212
253
  - **`entries` must cover the whole app** — the crawl starts there, and every
213
254
  `.svelte` file it finds is a call-site source. A call site outside those roots
214
255
  is invisible, so narrowing `entries` does not shake less, it shakes _wrongly_.
package/dist/analyze.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseCached, parseSvelte, walk, } from './parse.js';
1
+ import { parseCached, parseModuleProgram, walk, } from './parse.js';
2
2
  import { emptyPlan, } from './ir.js';
3
3
  import { computeDeadSpans, inSpans } from './dead.js';
4
4
  import { evaluate, setVar } from './eval.js';
@@ -1809,21 +1809,15 @@ function followLocalImport(body, localName) {
1809
1809
  return null;
1810
1810
  }
1811
1811
  /**
1812
- * Parse a `.js`/`.ts` module's top-level body by reusing the Svelte parser via a
1813
- * `<script module>` wrapper (the engine has no standalone JS parser). `lang="ts"`
1814
- * is required so TypeScript barrels parse — `export type { … }`, type-only
1815
- * specifiers and annotations are the norm for a design-system's `index.ts`, and a
1816
- * plain JS parse throws on them, leaving the whole library unfollowed. Returns
1817
- * `null` if it cannot be parsed — callers then leave the barrel unfollowed.
1812
+ * Parse a `.js`/`.ts` barrel's top-level body via {@link parseModuleProgram}
1813
+ * (the engine has no standalone JS parser; the shared helper wraps the source in
1814
+ * a `<script module lang="ts">` so TypeScript barrels — `export type { … }`,
1815
+ * type-only specifiers parse, and neutralizes any `</script>` in the text so a
1816
+ * valid module that merely mentions it still parses, issue #146). Returns `null`
1817
+ * if it cannot be parsed — callers then leave the barrel unfollowed.
1818
1818
  */
1819
1819
  function parseModuleBody(code, id) {
1820
- try {
1821
- const ast = parseSvelte(`<script module lang="ts">\n${code}\n</script>`, id);
1822
- return ast.module?.content?.body ?? null;
1823
- }
1824
- catch {
1825
- return null;
1826
- }
1820
+ return parseModuleProgram(code, id)?.body ?? null;
1827
1821
  }
1828
1822
  /**
1829
1823
  * Derive the child's {@link ReachableInputs} from its `$props()` shape (docs
@@ -0,0 +1,23 @@
1
+ import type { ComponentId } from './ir.js';
2
+ /**
3
+ * Files treated as DEV-ONLY by default: colocated tests, mocks, and Storybook
4
+ * stories — files that never ship, so their call sites must not count toward the
5
+ * shake. Discounting them is sound precisely because they never reach production;
6
+ * see docs/ARCHITECTURE.md §8.1.1 for the full argument (why a glob is safe here but
7
+ * not for narrowing app coverage, and the shipped-file-matching failure mode).
8
+ * Passing `devOnly` REPLACES this list; `devOnly: []` counts every file.
9
+ */
10
+ export declare const DEFAULT_DEV_ONLY: readonly string[];
11
+ /** Predicate over an ABSOLUTE path: `true` when the file is dev-only (discounted by a scan). */
12
+ export type DevOnlyFilter = (file: ComponentId) => boolean;
13
+ /**
14
+ * Compile dev-only `patterns` into a {@link DevOnlyFilter}. Each candidate path is
15
+ * matched (with `picomatch`) as its `base`-relative, posix-normalized form, so the
16
+ * result is OS-independent (backslashes on Windows are folded to `/`). `base` is
17
+ * the Vite root for the plugin and the scanned dir for a standalone `svelte-shaker/node`
18
+ * caller. Compile ONCE and reuse across the whole walk — never per file.
19
+ *
20
+ * Defaults to {@link DEFAULT_DEV_ONLY}; an empty `patterns` array matches nothing, so
21
+ * `devOnly: []` counts every file (the pre-`devOnly` behavior).
22
+ */
23
+ export declare function compileDevOnly(base: string, patterns?: readonly string[]): DevOnlyFilter;
@@ -0,0 +1,43 @@
1
+ // ----------------------------------------------------------------------
2
+ // Shell-side glob for the two directory scans (docs/ARCHITECTURE.md §4.2, §8.1.1):
3
+ // which files are DEV-ONLY — they never ship in the production bundle, so their
4
+ // call sites must not count toward the shake. Kept OUT of the env-free engine
5
+ // core (docs §5): it depends on `picomatch` and `node:path`. Both scans — the
6
+ // `.svelte` seed scan (`collectSvelteFiles`) and the non-`.svelte` escape scan
7
+ // (`collectNonSvelteModules`) — take the SAME compiled predicate, so a dev-only
8
+ // file is discounted by both.
9
+ // ----------------------------------------------------------------------
10
+ import picomatch from 'picomatch';
11
+ import * as path from 'node:path';
12
+ /**
13
+ * Files treated as DEV-ONLY by default: colocated tests, mocks, and Storybook
14
+ * stories — files that never ship, so their call sites must not count toward the
15
+ * shake. Discounting them is sound precisely because they never reach production;
16
+ * see docs/ARCHITECTURE.md §8.1.1 for the full argument (why a glob is safe here but
17
+ * not for narrowing app coverage, and the shipped-file-matching failure mode).
18
+ * Passing `devOnly` REPLACES this list; `devOnly: []` counts every file.
19
+ */
20
+ export const DEFAULT_DEV_ONLY = [
21
+ '**/*.test.*',
22
+ '**/*.spec.*',
23
+ '**/__tests__/**',
24
+ '**/__mocks__/**',
25
+ '**/*.stories.*',
26
+ ];
27
+ const NONE_DEV_ONLY = () => false;
28
+ /**
29
+ * Compile dev-only `patterns` into a {@link DevOnlyFilter}. Each candidate path is
30
+ * matched (with `picomatch`) as its `base`-relative, posix-normalized form, so the
31
+ * result is OS-independent (backslashes on Windows are folded to `/`). `base` is
32
+ * the Vite root for the plugin and the scanned dir for a standalone `svelte-shaker/node`
33
+ * caller. Compile ONCE and reuse across the whole walk — never per file.
34
+ *
35
+ * Defaults to {@link DEFAULT_DEV_ONLY}; an empty `patterns` array matches nothing, so
36
+ * `devOnly: []` counts every file (the pre-`devOnly` behavior).
37
+ */
38
+ export function compileDevOnly(base, patterns = DEFAULT_DEV_ONLY) {
39
+ if (patterns.length === 0)
40
+ return NONE_DEV_ONLY;
41
+ const isMatch = picomatch([...patterns]);
42
+ return (file) => isMatch(path.relative(base, file).split(path.sep).join('/'));
43
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentId } from './ir.js';
2
2
  import type { Resolve, ReadFile } from './analyze.js';
3
+ import { type DevOnlyFilter } from './dev-only.js';
3
4
  /**
4
5
  * Is `file` a module the escape scan reads — a non-`.svelte` JS/TS source
5
6
  * ({@link NON_SVELTE_MODULE_EXTS}), excluding `.d.ts` declaration files (types-only,
@@ -45,6 +46,13 @@ export declare function computeEscapedComponents(opts: {
45
46
  preserve?: string[] | undefined;
46
47
  /** The crawled `.svelte` component ids, for matching `preserve` prefixes. */
47
48
  components: Iterable<ComponentId>;
49
+ /**
50
+ * Dev-only files to discount in the escape scan (docs §8.1.1) — the SAME predicate
51
+ * the seed scan (`collectSvelteFiles`) applies, so a `Button.test.ts` neither seeds
52
+ * a component nor escapes one. Omitted, it defaults to {@link DEFAULT_DEV_ONLY}
53
+ * matched relative to `root`. Pass `compileDevOnly(root, [])` to scan everything.
54
+ */
55
+ devOnly?: DevOnlyFilter | undefined;
48
56
  resolve: Resolve;
49
57
  readFile: ReadFile;
50
58
  }): Promise<EscapeScanResult>;
@@ -7,7 +7,8 @@
7
7
  // ----------------------------------------------------------------------
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
- import { parseSvelte, walk } from './parse.js';
10
+ import { parseModuleProgram, walk } from './parse.js';
11
+ import { compileDevOnly } from './dev-only.js';
11
12
  /**
12
13
  * The non-`.svelte` module extensions we scan for `.svelte` call sites (docs
13
14
  * §4.2). A component imported by any of these has a consumer the `.svelte`-only
@@ -31,51 +32,43 @@ export function isScannableModule(file) {
31
32
  * Recursively collect every non-`.svelte` module under `dir` (skipping
32
33
  * `node_modules` and dot-directories, mirroring `collectSvelteFiles`). Same
33
34
  * include scope as the seed scan — `.ts` inside `node_modules` is deliberately NOT
34
- * scanned (docs §4.2).
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.
35
38
  */
36
- function collectNonSvelteModules(dir) {
37
- const out = [];
39
+ function collectNonSvelteModules(dir, devOnly, out) {
38
40
  let entries;
39
41
  try {
40
42
  entries = fs.readdirSync(dir, { withFileTypes: true });
41
43
  }
42
44
  catch {
43
- return out;
45
+ return;
44
46
  }
45
47
  for (const entry of entries) {
46
48
  if (entry.name === 'node_modules' || entry.name.startsWith('.'))
47
49
  continue;
48
50
  const full = path.join(dir, entry.name);
49
51
  if (entry.isDirectory())
50
- out.push(...collectNonSvelteModules(full));
51
- else if (entry.isFile() && isScannableModule(entry.name))
52
+ collectNonSvelteModules(full, devOnly, out);
53
+ else if (entry.isFile() && isScannableModule(entry.name) && !devOnly(full))
52
54
  out.push(full);
53
55
  }
54
- return out;
55
56
  }
56
57
  /**
57
58
  * Every module specifier a JS/TS module statically references: `import … from`,
58
59
  * `export … from` / `export *`, and a dynamic `import('…')` whose argument is a
59
60
  * STRING LITERAL. A computed dynamic import (`import(expr)`) is unknowable here —
60
- * the `preserve` option (docs §4.2) is the sound fallback for it. Parsed via the
61
- * Svelte parser's `<script module lang="ts">` wrapper (the same TS-capable parse
62
- * the engine's barrel-following uses). Returns `null` when the module does NOT
63
- * parse the wrapper handles TS but not JSX, and rejects some exotic syntax
64
- * (`.jsx`/`.tsx` bodies, bleeding-edge TS), and a parse failure hides any call site
65
- * inside, so the caller must surface it (a mounted component would go un-escaped);
66
- * `preserve` is the fix for that file.
61
+ * the `preserve` option (docs §4.2) is the sound fallback for it. Parsed via
62
+ * {@link parseModuleProgram} (the same TS-capable parse the engine's
63
+ * barrel-following uses). Returns `null` when the module does NOT parse — a JSX
64
+ * body or exotic/bleeding-edge TS the wrapper rejects because a parse failure
65
+ * hides any call site inside, so the caller must surface it (a mounted component
66
+ * would go un-escaped); `preserve` is the fix for that file.
67
67
  */
68
68
  function moduleImportSpecifiers(code, id) {
69
- let module;
70
- try {
71
- module = parseSvelte(`<script module lang="ts">\n${code}\n</script>`, id).module;
72
- }
73
- catch {
74
- return null; // unparseable — the caller reports it (soundness hole, `preserve` fixes it)
75
- }
76
- const program = module?.content;
77
- if (!program)
78
- return []; // parsed, no module body — nothing to follow (not a failure)
69
+ const program = parseModuleProgram(code, id);
70
+ if (program === null)
71
+ return null; // unparseable — the caller reports it (`preserve` fixes it)
79
72
  const specs = [];
80
73
  const literalSource = (node) => node?.type === 'Literal' && typeof node.value === 'string' ? node.value : undefined;
81
74
  walk(program, null, {
@@ -181,7 +174,11 @@ export function matchPreserve(preserve, root, components) {
181
174
  * `svelte-shaker/node`).
182
175
  */
183
176
  export async function computeEscapedComponents(opts) {
184
- const { escaped, unscannable } = await collectModuleEscapes(opts.entryDirs.flatMap(collectNonSvelteModules), opts.resolve, opts.readFile);
177
+ const devOnly = opts.devOnly ?? compileDevOnly(opts.root);
178
+ const modules = [];
179
+ for (const dir of opts.entryDirs)
180
+ collectNonSvelteModules(dir, devOnly, modules);
181
+ const { escaped, unscannable } = await collectModuleEscapes(modules, opts.resolve, opts.readFile);
185
182
  const { matched, unmatched } = partitionPreserve(opts.preserve, opts.root, opts.components);
186
183
  for (const id of matched)
187
184
  escaped.add(id);
package/dist/parse.d.ts CHANGED
@@ -81,6 +81,39 @@ export interface Root extends AnyNode {
81
81
  fragment: AnyNode;
82
82
  }
83
83
  export declare function parseSvelte(code: string, filename: string): Root;
84
+ /**
85
+ * Parse a plain `.js`/`.ts` module (NOT a `.svelte` component) and return its
86
+ * top-level ESTree Program, or `null` if it does not parse. The engine has no
87
+ * standalone JS parser, so we reuse the Svelte parser via a `<script module
88
+ * lang="ts">` wrapper: `lang="ts"` is what lets a TypeScript barrel — `export
89
+ * type { … }`, type-only specifiers, annotations, the norm for a design-system
90
+ * `index.ts` — parse where a plain-JS parse would throw.
91
+ *
92
+ * The Svelte parser ends a `<script>` at the first `</script…>` it finds in the
93
+ * RAW text (`read_until(/<\/script\s*>/)`), so a valid module whose text merely
94
+ * MENTIONS `</script>` — in a comment, string, regex or template literal, as an
95
+ * HTML sanitizer or a markdown pipeline routinely does — would close the wrapper
96
+ * early and fail to parse (issue #146). We break every `</script` in the body
97
+ * before wrapping by inserting a space after the `<`. In valid JS/TS `</script`
98
+ * only ever occurs inside a comment or a string/regex/template literal (it is
99
+ * never part of executable syntax), so this alters only inert text — with ONE
100
+ * exception a caller does read: a module SPECIFIER that itself contains
101
+ * `</script` (`import x from './a</script>b.js'`). Rewriting it would silently
102
+ * change which file it resolves to, turning a parse failure into a partial one
103
+ * (a missed escape / an unfollowed export). Rather than claim such a specifier
104
+ * cannot exist, we detect a rewritten one after parsing and fail the whole
105
+ * module (return `null`), the same loud degrade a parse failure gives — the
106
+ * specifier is absurd (`<`/`>` are invalid in real paths and package names), so
107
+ * this only trades one conservative outcome for another. The inserted spaces
108
+ * shift byte offsets, but no caller reads spans off this AST — only specifier
109
+ * names and string values.
110
+ *
111
+ * Returns `null` on a genuine parse failure (a JSX body, exotic/bleeding-edge
112
+ * TS) or a specifier the neutralization would corrupt: a call site hidden inside
113
+ * then goes unfollowed, which each caller handles conservatively (escape scan
114
+ * reports the file as unscannable; barrel-following leaves the barrel unfollowed).
115
+ */
116
+ export declare function parseModuleProgram(code: string, filename: string): AnyNode | null;
84
117
  /**
85
118
  * Content-keyed parse cache: a hit returns the IDENTICAL AST for unchanged
86
119
  * source, so the dev engine re-parses only the files that actually changed
package/dist/parse.js CHANGED
@@ -3,6 +3,93 @@ import { walk as zfWalk } from 'zimmerframe';
3
3
  export function parseSvelte(code, filename) {
4
4
  return parse(code, { modern: true, filename });
5
5
  }
6
+ /** Matches every `</script` in module text (any case), the sequence that would
7
+ * otherwise close the {@link parseModuleProgram} wrapper early. */
8
+ const CLOSING_SCRIPT = /<\/script/gi;
9
+ /** What `CLOSING_SCRIPT` is rewritten to: a space after the `<` breaks the
10
+ * `</script\s*>` the Svelte parser scans for, while staying a recognizable
11
+ * marker so a corrupted specifier can be detected afterwards. */
12
+ const NEUTRALIZED_SCRIPT = '< /script';
13
+ /**
14
+ * Parse a plain `.js`/`.ts` module (NOT a `.svelte` component) and return its
15
+ * top-level ESTree Program, or `null` if it does not parse. The engine has no
16
+ * standalone JS parser, so we reuse the Svelte parser via a `<script module
17
+ * lang="ts">` wrapper: `lang="ts"` is what lets a TypeScript barrel — `export
18
+ * type { … }`, type-only specifiers, annotations, the norm for a design-system
19
+ * `index.ts` — parse where a plain-JS parse would throw.
20
+ *
21
+ * The Svelte parser ends a `<script>` at the first `</script…>` it finds in the
22
+ * RAW text (`read_until(/<\/script\s*>/)`), so a valid module whose text merely
23
+ * MENTIONS `</script>` — in a comment, string, regex or template literal, as an
24
+ * HTML sanitizer or a markdown pipeline routinely does — would close the wrapper
25
+ * early and fail to parse (issue #146). We break every `</script` in the body
26
+ * before wrapping by inserting a space after the `<`. In valid JS/TS `</script`
27
+ * only ever occurs inside a comment or a string/regex/template literal (it is
28
+ * never part of executable syntax), so this alters only inert text — with ONE
29
+ * exception a caller does read: a module SPECIFIER that itself contains
30
+ * `</script` (`import x from './a</script>b.js'`). Rewriting it would silently
31
+ * change which file it resolves to, turning a parse failure into a partial one
32
+ * (a missed escape / an unfollowed export). Rather than claim such a specifier
33
+ * cannot exist, we detect a rewritten one after parsing and fail the whole
34
+ * module (return `null`), the same loud degrade a parse failure gives — the
35
+ * specifier is absurd (`<`/`>` are invalid in real paths and package names), so
36
+ * this only trades one conservative outcome for another. The inserted spaces
37
+ * shift byte offsets, but no caller reads spans off this AST — only specifier
38
+ * names and string values.
39
+ *
40
+ * Returns `null` on a genuine parse failure (a JSX body, exotic/bleeding-edge
41
+ * TS) or a specifier the neutralization would corrupt: a call site hidden inside
42
+ * then goes unfollowed, which each caller handles conservatively (escape scan
43
+ * reports the file as unscannable; barrel-following leaves the barrel unfollowed).
44
+ */
45
+ export function parseModuleProgram(code, filename) {
46
+ const wrapped = `<script module lang="ts">\n${code.replace(CLOSING_SCRIPT, NEUTRALIZED_SCRIPT)}\n</script>`;
47
+ let program;
48
+ try {
49
+ program = parseSvelte(wrapped, filename).module?.content ?? null;
50
+ }
51
+ catch {
52
+ return null; // unparseable — the caller decides how to degrade (see above)
53
+ }
54
+ // A specifier that carried `</script` was rewritten by the neutralization and
55
+ // no longer names the real module; degrade loudly rather than resolve a lie.
56
+ if (program && hasNeutralizedSpecifier(program))
57
+ return null;
58
+ return program;
59
+ }
60
+ /** True when any static import/export/dynamic-`import()` specifier string in
61
+ * `program` contains the {@link NEUTRALIZED_SCRIPT} marker — i.e. the source
62
+ * originally held `</script` and was rewritten by {@link parseModuleProgram}, so
63
+ * it can no longer be trusted to resolve. */
64
+ function hasNeutralizedSpecifier(program) {
65
+ let corrupted = false;
66
+ const check = (source) => {
67
+ if (source?.type === 'Literal' &&
68
+ typeof source.value === 'string' &&
69
+ source.value.includes(NEUTRALIZED_SCRIPT)) {
70
+ corrupted = true;
71
+ }
72
+ };
73
+ walk(program, null, {
74
+ ImportDeclaration(node, { next }) {
75
+ check(node.source);
76
+ next();
77
+ },
78
+ ExportNamedDeclaration(node, { next }) {
79
+ check(node.source);
80
+ next();
81
+ },
82
+ ExportAllDeclaration(node, { next }) {
83
+ check(node.source);
84
+ next();
85
+ },
86
+ ImportExpression(node, { next }) {
87
+ check(node.source);
88
+ next();
89
+ },
90
+ });
91
+ return corrupted;
92
+ }
6
93
  export function parseCached(filename, code, cache, parse = parseSvelte) {
7
94
  if (!cache)
8
95
  return parse(code, filename);
@@ -6,9 +6,9 @@ import type { Parse } from './parse.js';
6
6
  * rsvelte is the default parser, the caller THROWS on `null` rather than silently
7
7
  * using svelte/compiler; `parser: 'svelte'` is the explicit opt-out.
8
8
  *
9
- * The AST carries per-node `loc` (there is no option to drop it), but the engine
10
- * reads only UTF-16 `start`/`end`, never `loc`, so the parser choice can never
11
- * change the shaken output only which parser produced the identical tree.
9
+ * rsvelte's AST positions match svelte/compiler's UTF-16 code-unit offsets so
10
+ * the AST feeds the engine directly (`@rsvelte/compiler` <= 0.6 reported UTF-8
11
+ * *byte* offsets and needed a remap; 0.7 emits UTF-16, so the remap is gone).
12
12
  * A genuine parse error on a specific file is a real failure and PROPAGATES; only
13
13
  * a load/init failure returns `null`.
14
14
  */
@@ -8,6 +8,11 @@ import { createRequire } from 'node:module';
8
8
  // free engine (`index.ts`/`analyze.ts`), so the browser playground build stays
9
9
  // clean.
10
10
  const require = createRequire(import.meta.url);
11
+ /** The wasm-pack `--target web` bytes shipped in `@rsvelte/compiler`, reached via
12
+ * the package's stable `./wasm` subpath export (added upstream in 0.8.1). Using
13
+ * the public subpath keeps the loader independent of the internal crate name that
14
+ * determines the actual artifact basename. */
15
+ const WASM_FILE = '@rsvelte/compiler/wasm';
11
16
  let ready = false;
12
17
  /** Require `@rsvelte/compiler` and initialize its wasm once. Throws if the
13
18
  * package can't be resolved or the wasm can't be instantiated. */
@@ -17,7 +22,7 @@ function loadCompiler() {
17
22
  if (!ready) {
18
23
  // A `wasm-pack --target web` module: init once with the wasm bytes (Node has
19
24
  // no fetch for file URLs), after which `parse_svelte` is callable.
20
- const wasmPath = require.resolve('@rsvelte/compiler/rsvelte_core_bg.wasm');
25
+ const wasmPath = require.resolve(WASM_FILE);
21
26
  compiler.initSync({ module: readFileSync(wasmPath) });
22
27
  ready = true;
23
28
  }
@@ -30,9 +35,9 @@ function loadCompiler() {
30
35
  * rsvelte is the default parser, the caller THROWS on `null` rather than silently
31
36
  * using svelte/compiler; `parser: 'svelte'` is the explicit opt-out.
32
37
  *
33
- * The AST carries per-node `loc` (there is no option to drop it), but the engine
34
- * reads only UTF-16 `start`/`end`, never `loc`, so the parser choice can never
35
- * change the shaken output only which parser produced the identical tree.
38
+ * rsvelte's AST positions match svelte/compiler's UTF-16 code-unit offsets so
39
+ * the AST feeds the engine directly (`@rsvelte/compiler` <= 0.6 reported UTF-8
40
+ * *byte* offsets and needed a remap; 0.7 emits UTF-16, so the remap is gone).
36
41
  * A genuine parse error on a specific file is a real failure and PROPAGATES; only
37
42
  * a load/init failure returns `null`.
38
43
  */
package/dist/scan.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ComponentId } from './ir.js';
2
2
  import type { Resolve, ReadFile } from './analyze.js';
3
+ import { type DevOnlyFilter } from './dev-only.js';
3
4
  export { computeEscapedComponents, type EscapeScanResult } from './escape-scan.js';
5
+ export { DEFAULT_DEV_ONLY, compileDevOnly, type DevOnlyFilter } from './dev-only.js';
4
6
  /** Default filesystem resolver: resolve `source` relative to its importer. */
5
7
  export declare const fsResolve: Resolve;
6
8
  /** Default filesystem reader. */
@@ -9,5 +11,14 @@ export declare const fsReadFile: ReadFile;
9
11
  * Recursively collect every `.svelte` file under `dir` (skipping `node_modules`
10
12
  * and dot-directories). A Shell helper, kept out of the env-free engine core
11
13
  * (docs/ARCHITECTURE.md §5): plugins use it to seed the whole-program crawl.
14
+ *
15
+ * `devOnly` lists files that never ship in the production bundle (tests, stories);
16
+ * a match stops counting as a component consumer, so it is not seeded as an entry
17
+ * and cannot pessimize the shake (docs §8.1.1). A matched file the app actually
18
+ * imports is still crawled and shaken through the normal graph — this only removes
19
+ * it as a SEED. Omitted, it defaults to {@link DEFAULT_DEV_ONLY} matched relative
20
+ * to `dir`; the Vite plugin passes a predicate compiled against the Vite ROOT so a
21
+ * custom pattern is root-relative there. Pass `compileDevOnly(dir, [])` to seed
22
+ * every file.
12
23
  */
13
- export declare function collectSvelteFiles(dir: string): ComponentId[];
24
+ export declare function collectSvelteFiles(dir: string, devOnly?: DevOnlyFilter): ComponentId[];
package/dist/scan.js CHANGED
@@ -5,9 +5,14 @@
5
5
  // ----------------------------------------------------------------------
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
+ import { compileDevOnly } from './dev-only.js';
8
9
  // The escape-scan machinery lives in `./escape-scan.js` (internal); only the single
9
10
  // entry helper is part of the public `svelte-shaker/node` surface.
10
11
  export { computeEscapedComponents } from './escape-scan.js';
12
+ // The dev-only glob support (docs §8.1.1) is Shell-side and part of the public
13
+ // `svelte-shaker/node` surface, so a plain-Rollup pipeline can compile the same
14
+ // predicate the Vite plugin does and feed it to both scans.
15
+ export { DEFAULT_DEV_ONLY, compileDevOnly } from './dev-only.js';
11
16
  /** Default filesystem resolver: resolve `source` relative to its importer. */
12
17
  export const fsResolve = (source, importer) => {
13
18
  if (!source.startsWith('.'))
@@ -20,24 +25,37 @@ export const fsReadFile = (id) => fs.readFileSync(id, 'utf-8');
20
25
  * Recursively collect every `.svelte` file under `dir` (skipping `node_modules`
21
26
  * and dot-directories). A Shell helper, kept out of the env-free engine core
22
27
  * (docs/ARCHITECTURE.md §5): plugins use it to seed the whole-program crawl.
28
+ *
29
+ * `devOnly` lists files that never ship in the production bundle (tests, stories);
30
+ * a match stops counting as a component consumer, so it is not seeded as an entry
31
+ * and cannot pessimize the shake (docs §8.1.1). A matched file the app actually
32
+ * imports is still crawled and shaken through the normal graph — this only removes
33
+ * it as a SEED. Omitted, it defaults to {@link DEFAULT_DEV_ONLY} matched relative
34
+ * to `dir`; the Vite plugin passes a predicate compiled against the Vite ROOT so a
35
+ * custom pattern is root-relative there. Pass `compileDevOnly(dir, [])` to seed
36
+ * every file.
23
37
  */
24
- export function collectSvelteFiles(dir) {
38
+ export function collectSvelteFiles(dir, devOnly = compileDevOnly(dir)) {
25
39
  const out = [];
40
+ collectSvelteFilesInto(dir, devOnly, out);
41
+ return out;
42
+ }
43
+ /** Recursive worker: the compiled `devOnly` predicate is threaded, never recompiled. */
44
+ function collectSvelteFilesInto(dir, devOnly, out) {
26
45
  let entries;
27
46
  try {
28
47
  entries = fs.readdirSync(dir, { withFileTypes: true });
29
48
  }
30
49
  catch {
31
- return out;
50
+ return;
32
51
  }
33
52
  for (const entry of entries) {
34
53
  if (entry.name === 'node_modules' || entry.name.startsWith('.'))
35
54
  continue;
36
55
  const full = path.join(dir, entry.name);
37
56
  if (entry.isDirectory())
38
- out.push(...collectSvelteFiles(full));
39
- else if (entry.isFile() && entry.name.endsWith('.svelte'))
57
+ collectSvelteFilesInto(full, devOnly, out);
58
+ else if (entry.isFile() && entry.name.endsWith('.svelte') && !devOnly(full))
40
59
  out.push(full);
41
60
  }
42
- return out;
43
61
  }
package/dist/vite.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import { type DevMode } from './engine.js';
3
+ export { DEFAULT_DEV_ONLY } from './dev-only.js';
3
4
  import { type MonomorphizeOptions } from './mono.js';
4
5
  /**
5
6
  * Options for the {@link shaker} plugin. The set below is exhaustive: any other
@@ -52,6 +53,35 @@ export interface ShakerOptions {
52
53
  * without needing it is merely shaken less, never wrongly.
53
54
  */
54
55
  preserve?: string[];
56
+ /**
57
+ * Glob patterns naming files that are DEV-ONLY — they never ship in the production
58
+ * bundle (colocated tests, mocks, Storybook stories), so their call sites must not
59
+ * count toward the shake. A matched file stops counting as a component consumer in
60
+ * BOTH directory scans (the `.svelte` seed scan and the non-`.svelte` escape scan);
61
+ * patterns are matched with `picomatch` against the posix-normalized path relative
62
+ * to the Vite root. Defaults to {@link DEFAULT_DEV_ONLY}.
63
+ *
64
+ * List ONLY files that never ship: the option declares a property of the files,
65
+ * which is exactly the soundness contract that makes discounting them safe. A
66
+ * matched file is NOT excluded from the shake — a dev-only `.svelte` file the app
67
+ * actually imports is still crawled and shaken through the normal import graph, so
68
+ * its genuine app call sites still count. This only removes it as a SEED / ESCAPE
69
+ * source; it cannot un-cover reachable app code.
70
+ *
71
+ * The failure mode to avoid: a matched file's call sites simply STOP COUNTING. So
72
+ * if a file that really ships matches a pattern — a `+page.svelte` under a route
73
+ * dir named `__tests__`, a `foo.test.utils.ts` that mounts a component — the
74
+ * distinct prop values it passes no longer block a fold, exactly as if you had left
75
+ * it out of {@link entries}. That is why the defaults are narrow, convention-based
76
+ * patterns rather than an open glob (see docs/ARCHITECTURE.md §8.1.1). It is
77
+ * unrelated to {@link preserve}: that keeps a shipping component's props as written;
78
+ * this declares that a file is not part of the production graph at all.
79
+ *
80
+ * Passing `devOnly` REPLACES the default rather than adding to it (predictable
81
+ * semantics); extend it with `[...DEFAULT_DEV_ONLY, '…']`, and pass `devOnly: []` to
82
+ * count every file (the pre-`devOnly` behavior).
83
+ */
84
+ devOnly?: string[];
55
85
  /**
56
86
  * Per-call-site monomorphization tuning (docs §13.2). Monomorphization is ON
57
87
  * by default because it is bail-safe and never bloats (the measured net-win
package/dist/vite.js CHANGED
@@ -5,6 +5,9 @@ import { svelteShaker, svelteShakerWithMono } from './index.js';
5
5
  import { DevShaker } from './engine.js';
6
6
  import { collectSvelteFiles, fsResolve } from './scan.js';
7
7
  import { computeEscapedComponents, isScannableModule, } from './escape-scan.js';
8
+ import { compileDevOnly } from './dev-only.js';
9
+ // Re-export so a user can extend the default dev-only list: `devOnly: [...DEFAULT_DEV_ONLY, '…']`.
10
+ export { DEFAULT_DEV_ONLY } from './dev-only.js';
8
11
  import { DEFAULT_MONO_OPTIONS } from './mono.js';
9
12
  import { tryLoadRsvelteParser } from './rsvelte-parse.js';
10
13
  import { svelteShakerWasm, svelteShakerWasmWithMono, tryLoadWasmEngine } from './wasm-engine.js';
@@ -126,6 +129,7 @@ const RENAMED_OPTIONS = {
126
129
  const KNOWN_OPTIONS = {
127
130
  entries: true,
128
131
  preserve: true,
132
+ devOnly: true,
129
133
  monomorphize: true,
130
134
  engine: true,
131
135
  dev: true,
@@ -263,9 +267,13 @@ export function shaker(options = {}) {
263
267
  if (!devMode)
264
268
  return;
265
269
  const dirs = (options.entries ?? ['.']).map((p) => path.resolve(root, p));
270
+ // One dev-only predicate for the whole dev session, compiled against the Vite
271
+ // root so custom patterns are root-relative regardless of which entry dir a
272
+ // file sits under. Fed to both scans and the watch guards below (docs §8.1.1).
273
+ const isDevOnly = compileDevOnly(root, options.devOnly);
266
274
  // The engine's entry components, collected from the entry DIRS: `entries`
267
275
  // names the roots to crawl from, these are the `.svelte` files under them.
268
- const entryComponents = dirs.flatMap(collectSvelteFiles);
276
+ const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly));
269
277
  const read = (id) => fs.readFileSync(id, 'utf-8');
270
278
  const underDirs = (file) => dirs.some((d) => file === d || file.startsWith(d + path.sep));
271
279
  // Re-scan the non-`.svelte` modules for `.svelte` call sites and re-apply
@@ -280,6 +288,7 @@ export function shaker(options = {}) {
280
288
  root,
281
289
  preserve: options.preserve,
282
290
  components: entryComponents,
291
+ devOnly: isDevOnly,
283
292
  resolve: fsResolve,
284
293
  readFile: read,
285
294
  });
@@ -301,7 +310,10 @@ export function shaker(options = {}) {
301
310
  // un-shake a child; a removed one can re-shake it — docs §4). Re-shake and
302
311
  // full-reload: over-invalidation is always sound, and add/remove is rare
303
312
  // enough that fine-grained HMR for it is not worth the complexity here.
304
- const isOurs = (file) => file.endsWith('.svelte') && underDirs(file);
313
+ // Dev-only files are discounted by both scans, so an edit to one never moves
314
+ // the shake — skip them here too, or a `Foo.test.svelte` save would retrigger a
315
+ // full re-shake for no effect.
316
+ const isOurs = (file) => file.endsWith('.svelte') && underDirs(file) && !isDevOnly(file);
305
317
  const onGraphChange = async (file, kind) => {
306
318
  if (!devShaker || !isOurs(file))
307
319
  return;
@@ -315,7 +327,7 @@ export function shaker(options = {}) {
315
327
  // we re-shake and full-reload (over-invalidation is sound, but pointless
316
328
  // reloads on unrelated `.ts` edits are avoided).
317
329
  const onModuleChange = async (file) => {
318
- if (!devShaker || !isScannableModule(file) || !underDirs(file))
330
+ if (!devShaker || !isScannableModule(file) || !underDirs(file) || isDevOnly(file))
319
331
  return;
320
332
  const before = escapedKey;
321
333
  devShaker.setEscaped(await currentEscaped());
@@ -363,9 +375,12 @@ export function shaker(options = {}) {
363
375
  if (devShaker)
364
376
  return;
365
377
  const dirs = (options.entries ?? ['.']).map((p) => path.resolve(root, p));
378
+ // One dev-only predicate for both scans, compiled against the Vite root so
379
+ // custom patterns are root-relative regardless of the entry dir (docs §8.1.1).
380
+ const isDevOnly = compileDevOnly(root, options.devOnly);
366
381
  // The engine's entry components, collected from the entry DIRS: `entries`
367
382
  // names the roots to crawl from, these are the `.svelte` files under them.
368
- const entryComponents = dirs.flatMap(collectSvelteFiles);
383
+ const entryComponents = dirs.flatMap((d) => collectSvelteFiles(d, isDevOnly));
369
384
  if (entryComponents.length === 0) {
370
385
  shaken = {};
371
386
  variantSources = new Map();
@@ -404,6 +419,7 @@ export function shaker(options = {}) {
404
419
  root,
405
420
  preserve: options.preserve,
406
421
  components: entryComponents,
422
+ devOnly: isDevOnly,
407
423
  resolve,
408
424
  readFile: read,
409
425
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",
@@ -49,12 +49,14 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@rsvelte/compiler": "0.6.1",
52
+ "@rsvelte/compiler": "0.8.1",
53
53
  "magic-string": "1.0.0",
54
+ "picomatch": "4.0.5",
54
55
  "zimmerframe": "1.1.4"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@sveltejs/vite-plugin-svelte": "^7.2.0",
59
+ "@types/picomatch": "4.0.2",
58
60
  "svelte": "^5.56.6",
59
61
  "vite": "^8.1.5"
60
62
  },