svelte-shaker 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -171,6 +174,32 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
171
174
  The build **warns** (with the file path) about any module the scan couldn't
172
175
  parse — so a mounted component isn't silently left unprotected — and about
173
176
  `preserve` entries that matched no component.
177
+ - **`devOnly`** — glob patterns (matched with
178
+ [`picomatch`](https://github.com/micromatch/picomatch) against each file's path
179
+ relative to the Vite root) naming files that **never ship in the production
180
+ bundle** — colocated tests, mocks, Storybook stories. A matched file **stops
181
+ counting as a component consumer** in **both directory scans** (the `.svelte` seed
182
+ scan and the non-`.svelte` escape scan), so a `Foo.test.svelte` or a
183
+ `Button.test.ts` can no longer pessimize the shake. It defaults to:
184
+
185
+ ```ts
186
+ // the built-in default (import DEFAULT_DEV_ONLY to extend it)
187
+ devOnly: ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/__mocks__/**', '**/*.stories.*'];
188
+ ```
189
+
190
+ Passing `devOnly` **replaces** this list (predictable semantics) — spread it to
191
+ extend: `devOnly: [...DEFAULT_DEV_ONLY, 'src/dev/**']` (import `DEFAULT_DEV_ONLY`
192
+ from `svelte-shaker/vite`). Pass `devOnly: []` to count every file (the pre-`devOnly`
193
+ behavior).
194
+
195
+ **List only files that never ship.** A matched file isn't excluded from the shake —
196
+ one the app actually imports is still crawled and shaken; it just stops _counting_
197
+ as a call site. So a file that really ships but matches a pattern (a `+page.svelte`
198
+ under a route dir named `__tests__`) has its distinct prop values stop blocking
199
+ folds, the same failure mode as leaving it out of `entries` — which is why the
200
+ defaults are narrow. See
201
+ [`docs/ARCHITECTURE.md` §8.1.1](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
202
+ for the full argument.
174
203
 
175
204
  ## What it removes
176
205
 
@@ -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>;
@@ -8,6 +8,7 @@
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import { parseSvelte, 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,27 +32,27 @@ 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`,
@@ -181,7 +182,11 @@ export function matchPreserve(preserve, root, components) {
181
182
  * `svelte-shaker/node`).
182
183
  */
183
184
  export async function computeEscapedComponents(opts) {
184
- const { escaped, unscannable } = await collectModuleEscapes(opts.entryDirs.flatMap(collectNonSvelteModules), opts.resolve, opts.readFile);
185
+ const devOnly = opts.devOnly ?? compileDevOnly(opts.root);
186
+ const modules = [];
187
+ for (const dir of opts.entryDirs)
188
+ collectNonSvelteModules(dir, devOnly, modules);
189
+ const { escaped, unscannable } = await collectModuleEscapes(modules, opts.resolve, opts.readFile);
185
190
  const { matched, unmatched } = partitionPreserve(opts.preserve, opts.root, opts.components);
186
191
  for (const id of matched)
187
192
  escaped.add(id);
@@ -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.0",
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
  },