svelte-shaker 0.15.0 → 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
@@ -103,6 +103,9 @@ shaker({
103
103
  // out (e.g. if you ever hit a bug in it).
104
104
  engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
105
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
106
109
  });
107
110
  ```
108
111
 
@@ -150,6 +153,14 @@ options that do exist. A typo would otherwise be ignored — and a misspelled
150
153
  can't (a broken install), the plugin **throws** rather than silently falling
151
154
  back — so the same source always shakes the same on every machine. Reinstall
152
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.
153
164
  - **`preserve`** — keep a component's **prop interface** exactly as written, because
154
165
  something the shake can't see passes props to it. What is preserved is the props,
155
166
  **not** the file's presence in the bundle: this is unrelated to Rollup/Vite's
@@ -236,8 +247,9 @@ The whole point is to **never change observable behavior**.
236
247
  scope.
237
248
  - **Needs `.svelte` source** — libraries shipping compiled JS pass through
238
249
  unshaken; distribute via `svelte-package`.
239
- - **Build only** — whole-program analysis is incompatible with dev/HMR locality,
240
- 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.
241
253
  - **`entries` must cover the whole app** — the crawl starts there, and every
242
254
  `.svelte` file it finds is a call-site source. A call site outside those roots
243
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
@@ -7,7 +7,7 @@
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
11
  import { compileDevOnly } from './dev-only.js';
12
12
  /**
13
13
  * The non-`.svelte` module extensions we scan for `.svelte` call sites (docs
@@ -58,25 +58,17 @@ function collectNonSvelteModules(dir, devOnly, out) {
58
58
  * Every module specifier a JS/TS module statically references: `import … from`,
59
59
  * `export … from` / `export *`, and a dynamic `import('…')` whose argument is a
60
60
  * STRING LITERAL. A computed dynamic import (`import(expr)`) is unknowable here —
61
- * the `preserve` option (docs §4.2) is the sound fallback for it. Parsed via the
62
- * Svelte parser's `<script module lang="ts">` wrapper (the same TS-capable parse
63
- * the engine's barrel-following uses). Returns `null` when the module does NOT
64
- * parse the wrapper handles TS but not JSX, and rejects some exotic syntax
65
- * (`.jsx`/`.tsx` bodies, bleeding-edge TS), and a parse failure hides any call site
66
- * inside, so the caller must surface it (a mounted component would go un-escaped);
67
- * `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.
68
67
  */
69
68
  function moduleImportSpecifiers(code, id) {
70
- let module;
71
- try {
72
- module = parseSvelte(`<script module lang="ts">\n${code}\n</script>`, id).module;
73
- }
74
- catch {
75
- return null; // unparseable — the caller reports it (soundness hole, `preserve` fixes it)
76
- }
77
- const program = module?.content;
78
- if (!program)
79
- 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)
80
72
  const specs = [];
81
73
  const literalSource = (node) => node?.type === 'Literal' && typeof node.value === 'string' ? node.value : undefined;
82
74
  walk(program, null, {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",