svelte-shaker 0.5.2 → 0.7.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.
@@ -0,0 +1,825 @@
1
+ import MagicString from 'magic-string';
2
+ import { walk } from './parse.js';
3
+ import { remapToLocalNames } from './analyze.js';
4
+ import { decideChain, inSpans } from './dead.js';
5
+ import { evaluate } from './eval.js';
6
+ import { shakeCss } from './css.js';
7
+ /**
8
+ * Apply every plan to every component and return the shaken source per file.
9
+ *
10
+ * Two phases over a shared set of MagicStrings so that a parent's call-site
11
+ * attributes are removed using each child's *actually dropped* props (not just
12
+ * what the plan proposed): a prop only leaves the public signature when every
13
+ * reference to it could be folded or substituted away.
14
+ */
15
+ export function transformAll(models, plans) {
16
+ return emit(models, runBasePhases(models, plans));
17
+ }
18
+ /**
19
+ * Phases 1–2, shared by {@link transformAll} and {@link transformAllWithMono}:
20
+ * fold each component body and drop its folded props (phase 1), then strip the
21
+ * now-pointless attribute at every call site of a dropped prop (phase 2).
22
+ * Returns the per-file MagicStrings, ready for the optional L2 phase 3.
23
+ */
24
+ function runBasePhases(models, plans) {
25
+ const strings = new Map();
26
+ const dropped = new Map();
27
+ /** Regions phase 1 edited per component — phase 2 must not edit inside them. */
28
+ const editedSpans = new Map();
29
+ // Phase 1 — component bodies: fold dead branches, drop folded props.
30
+ for (const model of models.values()) {
31
+ const s = new MagicString(model.code);
32
+ strings.set(model.id, s);
33
+ const plan = plans.get(model.id);
34
+ if (plan.bail) {
35
+ dropped.set(model.id, new Set());
36
+ continue;
37
+ }
38
+ const result = transformBody(model, plan, s);
39
+ dropped.set(model.id, result.dropped);
40
+ editedSpans.set(model.id, result.dead);
41
+ }
42
+ // Phase 2 — call sites: remove attributes for props the child actually dropped,
43
+ // skipping any call site phase 1 folded away (its attributes went with it).
44
+ for (const model of models.values()) {
45
+ removeCallSiteAttributes(model, dropped, strings.get(model.id), editedSpans.get(model.id) ?? []);
46
+ }
47
+ return strings;
48
+ }
49
+ /** Stringify every model's MagicString into the output record. */
50
+ function emit(models, strings) {
51
+ const out = {};
52
+ for (const model of models.values())
53
+ out[model.id] = strings.get(model.id).toString();
54
+ return out;
55
+ }
56
+ /**
57
+ * Like {@link transformAll}, but additionally rewrites the L2-bound call sites in
58
+ * each owner to import a specialized variant from a virtual module. The base
59
+ * phases are unchanged (so files with no binding are byte-identical to
60
+ * {@link transformAll}); phase 3 only edits regions phase 2 never touches — a
61
+ * bound `<Child …>` tag's NAME, and the frozen-prop attributes (which are
62
+ * disjoint from the dropped-prop attributes phase 2 removes, because a frozen
63
+ * prop is by construction NOT in the child's app-wide `constFold`).
64
+ *
65
+ * `variantImport(variantId)` maps a variant id to the module specifier the
66
+ * rewritten `import` should reference (the Shell supplies the virtual id).
67
+ */
68
+ export function transformAllWithMono(models, plans, bindings, variantImport) {
69
+ const strings = runBasePhases(models, plans);
70
+ // Phase 3 — L2: rewrite each bound `<Child …>` site to a specialized variant.
71
+ rewriteBoundCallSites(models, bindings, variantImport, strings);
72
+ return emit(models, strings);
73
+ }
74
+ /**
75
+ * Inject one `import` per (owner, variant) and rewrite each bound site's tag name
76
+ * to the imported local, removing the frozen-prop attributes. A fresh local name
77
+ * `<Child>__shaker_v<n>` is derived from the original tag and the variant index so
78
+ * distinct variants of the same child never collide within one owner.
79
+ */
80
+ function rewriteBoundCallSites(models, bindings, variantImport, strings) {
81
+ // Group bindings by owner; within an owner, assign each variant id a fresh
82
+ // local import name and remember the imports to inject.
83
+ const byOwner = new Map();
84
+ for (const b of bindings) {
85
+ const list = byOwner.get(b.owner);
86
+ if (list)
87
+ list.push(b);
88
+ else
89
+ byOwner.set(b.owner, [b]);
90
+ }
91
+ for (const [ownerId, list] of byOwner) {
92
+ const model = models.get(ownerId);
93
+ const s = strings.get(ownerId);
94
+ if (!model || !s)
95
+ continue;
96
+ const localFor = new Map(); // variantId -> local import name
97
+ const importsToAdd = [];
98
+ let counter = 0;
99
+ for (const b of list) {
100
+ const original = b.node.name ?? 'Cmp';
101
+ let local = localFor.get(b.variantId);
102
+ if (local === undefined) {
103
+ local = `${original}__shaker_v${counter++}`;
104
+ localFor.set(b.variantId, local);
105
+ importsToAdd.push({ local, spec: variantImport(b.variantId) });
106
+ }
107
+ rewriteOneSite(model.code, b.node, local, b.foldedProps, s);
108
+ }
109
+ if (importsToAdd.length > 0)
110
+ injectImports(model, importsToAdd, s);
111
+ }
112
+ }
113
+ /** Rewrite a single `<Child …>` open (and matching close) tag name + strip frozen attrs. */
114
+ function rewriteOneSite(code, node, local, frozen, s) {
115
+ const name = node.name;
116
+ if (!name)
117
+ return;
118
+ // The open tag name sits right after `<` at the node start.
119
+ const openNameStart = node.start + 1;
120
+ if (code.slice(openNameStart, openNameStart + name.length) === name)
121
+ s.overwrite(openNameStart, openNameStart + name.length, local);
122
+ // A non-self-closing component has a `</Name>` whose name we must also rewrite.
123
+ // Find the LAST occurrence of `</name` before node.end (close tags cannot nest
124
+ // for the same element, and the last one is this element's own).
125
+ const closeMarker = `</${name}`;
126
+ const closeIdx = code.lastIndexOf(closeMarker, node.end);
127
+ if (closeIdx >= node.start) {
128
+ const from = closeIdx + 2; // skip `</`
129
+ s.overwrite(from, from + name.length, local);
130
+ }
131
+ // Remove the frozen-prop attributes (the variant hard-codes them). Only
132
+ // static `Attribute`s are frozen (mono required `!dynamic`), so this never
133
+ // drops a side-effecting expression.
134
+ for (const attr of node.attributes ?? []) {
135
+ if (attr.type !== 'Attribute' || !attr.name || !frozen.has(attr.name))
136
+ continue;
137
+ removeAttrWithSpace(code, attr, s);
138
+ }
139
+ }
140
+ /** Delete an attribute's span plus one preceding space/tab, keeping the tag tidy. */
141
+ function removeAttrWithSpace(code, attr, s) {
142
+ let start = attr.start;
143
+ if (code[start - 1] === ' ' || code[start - 1] === '\t')
144
+ start -= 1;
145
+ s.remove(start, attr.end);
146
+ }
147
+ /**
148
+ * Append the variant imports to the owner's instance `<script>` (or prepend a
149
+ * fresh `<script>` block when the component has none). Appending after the last
150
+ * existing statement keeps the original imports intact and positions stable.
151
+ */
152
+ function injectImports(model, imports, s) {
153
+ const lines = imports
154
+ .map((i) => ` import ${i.local} from ${JSON.stringify(i.spec)};`)
155
+ .join('\n');
156
+ const instance = model.ast.instance;
157
+ const body = instance?.content?.body ?? [];
158
+ if (instance && body.length > 0) {
159
+ const last = body[body.length - 1];
160
+ s.appendLeft(last.end, `\n${lines}`);
161
+ return;
162
+ }
163
+ if (instance && instance.content) {
164
+ // Empty `<script>`: insert at the program start.
165
+ s.appendLeft(instance.content.start, `\n${lines}\n`);
166
+ return;
167
+ }
168
+ // No instance script at all: prepend a fresh one before everything.
169
+ s.prepend(`<script>\n${lines}\n</script>\n`);
170
+ }
171
+ function transformBody(model, plan, s) {
172
+ const dead = [];
173
+ const dropped = shakeBody(model, plan.constFold, plan.narrow, plan, s, dead);
174
+ return { dropped, dead };
175
+ }
176
+ /**
177
+ * Slim one component's body against the given fold (`env`) and narrow (`setEnv`)
178
+ * environments, editing `s` in place, and return the set of props that left the
179
+ * `$props()` signature. Factored out of {@link transformBody} so L2
180
+ * monomorphization (see `mono.ts`) can re-run the SAME pipeline with an augmented
181
+ * `env` (a call site's extra literal props) on a fresh MagicString — guaranteeing
182
+ * a specialized residual is produced by exactly the audited L0/L1/L1.5 machinery,
183
+ * never a parallel code path. `cssPlan` carries the value sets CSS removal reads
184
+ * (its `constFold`/`narrow` are overridden by `env`/`setEnv` before use).
185
+ */
186
+ export function shakeBody(model, env, setEnv, cssPlan, s,
187
+ /**
188
+ * If provided, receives every region this body EDITED (dead `{#if}`/ternary arms
189
+ * removed, and collapse spans overwritten whole). Phase 2 (call-site attribute
190
+ * removal) needs these so it never edits inside a region we already changed — a
191
+ * `<Child dropped={…}/>` sitting in a folded-away branch would otherwise produce
192
+ * an overlapping MagicString edit ("Cannot split a chunk that has already been
193
+ * edited"). Mono (L2) does not pass it; it edits fresh strings.
194
+ */
195
+ outDead) {
196
+ // Nothing to fold (L1) and nothing to narrow (L1.5): no branch/prop edits.
197
+ // CSS removal still depends only on the value sets the plan carries, so a
198
+ // component with no foldable/narrowable prop produces an empty class set
199
+ // bound and removes nothing — leave it untouched entirely.
200
+ if (env.size === 0 && setEnv.size === 0)
201
+ return new Set();
202
+ const code = model.code;
203
+ // `env`/`setEnv` arrive keyed by the EXTERNAL prop name (that is what the plan
204
+ // and the L2 call-site shapes carry). Every body/template reference, however,
205
+ // uses the prop's LOCAL binding name (`prop: alias` -> `alias`), and the two can
206
+ // even be different entities (a same-named import). Remap ONCE to local-keyed
207
+ // maps for every name-matched pass below (branch folding, ternaries, reference
208
+ // substitution, CSS); the `$props()` signature drop keeps the external names.
209
+ const localEnv = remapToLocalNames(env, model);
210
+ const localSetEnv = remapToLocalNames(setEnv, model);
211
+ // (1) Fold `{#if <const>}` blocks (L1) and narrow if/else-if chains against
212
+ // the known value sets (L1.5); remember every region we deleted/unwrapped.
213
+ const dead = [];
214
+ foldIfBlocks(model.ast.fragment, localEnv, localSetEnv, code, s, dead);
215
+ // (1b) Fold template ternaries `{cond ? a : b}` whose `cond` is a provable
216
+ // constant down to the taken arm. This runs BEFORE substitution: the taken
217
+ // arm is re-emitted verbatim and the whole ternary span is marked dead, so the
218
+ // substitution pass below leaves identifiers inside it alone (a sub-range
219
+ // overwrite inside an already-overwritten span would conflict in MagicString).
220
+ // Mirrors the `{#if}` "collapse to a kept fragment verbatim" handling.
221
+ foldTernaries(model.ast.fragment, localEnv, code, s, dead);
222
+ // (2) Substitute any surviving references to a folded prop with its literal.
223
+ // Narrowed (set) props are genuinely dynamic and are NOT substituted; we only
224
+ // walk `localEnv` (constFold). Substitution still reaches references inside KEPT
225
+ // narrowed arms because those arms are left as original text (only dead arms
226
+ // are removed), so a constFold prop used inside a surviving arm is handled.
227
+ const refs = collectPropRefs(model, localEnv, dead);
228
+ for (const [name, value] of localEnv) {
229
+ const lit = literalSource(value);
230
+ for (const ref of refs.get(name) ?? [])
231
+ s.overwrite(ref.start, ref.end, ref.head + lit + ref.tail);
232
+ }
233
+ // (3) Drop only the folded (constFold) props from the `$props()` signature.
234
+ // Narrowed props stay in the signature — they are still used/dynamic. The drop
235
+ // matches the destructure KEYS, so it keeps the EXTERNAL names (which is also
236
+ // what phase 2's call-site attribute removal consumes).
237
+ const droppable = new Set(env.keys()); // every surviving ref is an expression position
238
+ dropProps(model, droppable, s);
239
+ // (4) CSS rule removal (docs §3 "L1.5", "CSS (shaker 独自の価値)"): drop
240
+ // `<style>` rules targeting a class the component can provably never produce
241
+ // given the value sets. Sound and independent of the branch edits above:
242
+ // it only reads the possible class set and removes rules no element can match.
243
+ // Svelte's own unused-CSS pruning still runs afterwards on what remains.
244
+ //
245
+ // CSS removal reads the value sets through the plan; rebuild a plan view whose
246
+ // `constFold`/`narrow` are the ENVIRONMENTS we actually folded with (for L2 a
247
+ // call site's extra literals shrink the possible class set further), reusing
248
+ // `cssPlan` for everything else (id, valueSets of untouched props).
249
+ // CSS matches the value-set maps against TEMPLATE identifiers (`class={alias}`),
250
+ // so it too reads the LOCAL-keyed environments.
251
+ const cssView = {
252
+ ...cssPlan,
253
+ constFold: localEnv,
254
+ narrow: localSetEnv,
255
+ };
256
+ shakeCss(model, cssView, s);
257
+ if (outDead)
258
+ outDead.push(...dead);
259
+ return droppable;
260
+ }
261
+ /**
262
+ * Fold `{#if}` blocks and narrow if/else-if chains in one pass. Each chain's
263
+ * decision comes from the shared {@link decideChain} (same predicate the
264
+ * analysis fixpoint uses); here we turn that decision into MagicString edits.
265
+ * `dead` accumulates the deleted regions so later passes skip them.
266
+ *
267
+ * The walk threads each chain's parent fragment (for sibling lookup) and whether
268
+ * it sits in a preserved-whitespace context (`<pre>`/`<textarea>` ancestor, or a
269
+ * component-level `<svelte:options preserveWhitespace>`). {@link applyChain}
270
+ * needs both to keep the RENDERED whitespace unchanged when a chain disappears:
271
+ * Svelte trims a whitespace-only text node at a fragment edge but keeps one
272
+ * between two rendering nodes, so naively deleting a chain that separated two
273
+ * nodes (or splicing in an arm whose own edge whitespace was trimmed) would lose
274
+ * or gain a space.
275
+ */
276
+ function foldIfBlocks(fragment, env, setEnv, code, s, dead) {
277
+ walk(fragment, { parent: null, preserve: hasPreserveWhitespaceOption(fragment) }, {
278
+ _(node, { state, next }) {
279
+ if (node.type !== 'IfBlock') {
280
+ // Descend, recording this node as the children's parent and whether it
281
+ // opens a preserved-whitespace context for them.
282
+ next({ parent: node, preserve: state.preserve || isPreserveElement(node) });
283
+ return;
284
+ }
285
+ // `elseif` IfBlocks are the *continuation* of a chain we already own from
286
+ // its head; skip them so we never edit the same chain twice. Also skip any
287
+ // block already inside a region we removed (a dead arm we descended into).
288
+ if (node.elseif || inSpans(node, dead))
289
+ return;
290
+ const decision = decideChain(node, env, setEnv);
291
+ applyChain(decision, env, code, s, dead, {
292
+ parent: state.parent,
293
+ // `state.parent` is the Fragment that holds this chain (the walk sets a
294
+ // node as its children's parent), so its `nodes` are the chain's siblings.
295
+ index: state.parent?.nodes?.indexOf(node) ?? -1,
296
+ preserve: state.preserve,
297
+ });
298
+ if (decision.recurse)
299
+ next({ parent: node, preserve: state.preserve }); // kept head: descend for nested blocks
300
+ // otherwise the subtree is gone or re-emitted verbatim — do not recurse.
301
+ },
302
+ });
303
+ }
304
+ /** Realize one {@link decideChain} decision as MagicString edits, keeping the
305
+ * rendered whitespace at the chain's seam unchanged (see {@link foldIfBlocks}). */
306
+ function applyChain(decision, env, code, s, dead, ctx) {
307
+ if (decision.kept) {
308
+ // The chain collapses to a single surviving fragment, re-emitted verbatim.
309
+ // Because we overwrite the whole chain span in one shot, the later
310
+ // substitution pass cannot reach folded-prop references *inside* the kept
311
+ // fragment (a sub-range edit in an overwritten span conflicts), and those
312
+ // props are about to be dropped from the signature — so we must substitute
313
+ // them into the emitted text HERE, or they would become dangling
314
+ // references. {@link substitutedSlice} does exactly that.
315
+ let text = fragmentSource(decision.kept, env, code);
316
+ // The arm's own leading/trailing whitespace runs were block-fragment edges
317
+ // (trimmed) in the original, but become INNER once spliced into the parent
318
+ // fragment — keeping them would GAIN a space. Strip them. Under preserved
319
+ // whitespace nothing was trimmed, so splice verbatim.
320
+ if (!ctx.preserve)
321
+ text = text.replace(/^\s+|\s+$/g, '');
322
+ // A kept arm that is empty or pure whitespace renders nothing, exactly like a
323
+ // full chain removal — route through the same seam handling so a separating
324
+ // space is neither lost nor spuriously kept.
325
+ if (text === '' && !ctx.preserve) {
326
+ removeChain([decision.span], decision.span, code, s, dead, ctx);
327
+ return;
328
+ }
329
+ s.overwrite(decision.span[0], decision.span[1], text);
330
+ dead.push(decision.span);
331
+ return;
332
+ }
333
+ // The chain renders nothing (no surviving arm): delete it, compensating the
334
+ // seam so a space that separated two siblings is not lost.
335
+ if (isFullRemoval(decision)) {
336
+ removeChain(decision.removed, decision.span, code, s, dead, ctx);
337
+ return;
338
+ }
339
+ // Otherwise the `{#if}` structure is kept (head survives, or a `{:else if}` is
340
+ // promoted): the chain still renders in place, so the outer seam is unchanged —
341
+ // only delete the dead regions. `removed` ranges and `headerRewrite` are
342
+ // disjoint (the prefix ends exactly where the promoted header begins).
343
+ for (const [a, b] of decision.removed) {
344
+ s.remove(a, b);
345
+ dead.push([a, b]);
346
+ }
347
+ // If a `{:else if}` was promoted to the new head, rewrite its header.
348
+ if (decision.headerRewrite) {
349
+ const { from, to, text } = decision.headerRewrite;
350
+ s.overwrite(from, to, text);
351
+ }
352
+ }
353
+ /** True when a chain folds away entirely (its whole span is the only removal). */
354
+ function isFullRemoval(decision) {
355
+ return (decision.kept === undefined &&
356
+ decision.removed.length === 1 &&
357
+ decision.removed[0][0] === decision.span[0] &&
358
+ decision.removed[0][1] === decision.span[1]);
359
+ }
360
+ /**
361
+ * Delete a chain that renders nothing, compensating the seam so the RENDERED
362
+ * whitespace is unchanged. When the chain separated two rendering siblings via
363
+ * a whitespace-only text node, plain deletion would let that node fall to a
364
+ * fragment edge and be trimmed — losing a space. In that one case we overwrite
365
+ * the whole `L + chain + R` span with `{" "}`: an ExpressionTag is never
366
+ * edge-trimmed, so it renders exactly one space wherever it lands, matching the
367
+ * original. Otherwise plain deletion already preserves space presence (only the
368
+ * run LENGTH can differ, which the SSR oracle normalizes). Never compensate
369
+ * under preserved whitespace — there plain deletion is byte-exact.
370
+ */
371
+ function removeChain(removed, span, code, s, dead, ctx) {
372
+ if (!ctx.preserve && ctx.parent?.nodes && ctx.index >= 0) {
373
+ const seam = analyzeSeam(ctx.parent.nodes, ctx.index, span, code, dead);
374
+ if (seam) {
375
+ s.overwrite(seam[0], seam[1], '{" "}');
376
+ dead.push(seam);
377
+ return;
378
+ }
379
+ }
380
+ for (const [a, b] of removed) {
381
+ s.remove(a, b);
382
+ dead.push([a, b]);
383
+ }
384
+ }
385
+ /**
386
+ * Decide whether removing the chain at `siblings[index]` would lose a separating
387
+ * space, and if so return the `[from, to]` span (covering the adjacent
388
+ * whitespace-only text siblings plus the chain) to overwrite with `{" "}`.
389
+ *
390
+ * Svelte renders a whitespace-only text node as a single space iff it sits
391
+ * between two rendering nodes (element / text / expression tag / block — a
392
+ * comment is transparent and counts as a fragment edge), and trims it at a
393
+ * fragment edge. With `L`/`R` the chain's adjacent whitespace siblings and
394
+ * `P`/`N` whether a rendering sibling lies just beyond them:
395
+ * origSpace = (L && P) || (R && N) // a space rendered originally
396
+ * afterSpace = P && N && (L || R) // … survives plain deletion
397
+ * A space is lost exactly when `origSpace && !afterSpace`. A sibling already
398
+ * consumed by an earlier compensation is treated as absent so two adjacent dead
399
+ * chains never produce overlapping edits.
400
+ */
401
+ function analyzeSeam(siblings, index, span, code, dead) {
402
+ const live = (node) => !!node && !inSpans(node, dead);
403
+ const left = siblings[index - 1];
404
+ const right = siblings[index + 1];
405
+ const L = live(left) && isWhitespaceText(left, code) ? left : undefined;
406
+ const R = live(right) && isWhitespaceText(right, code) ? right : undefined;
407
+ const pIdx = L ? index - 2 : index - 1;
408
+ const nIdx = R ? index + 2 : index + 1;
409
+ const P = pIdx >= 0 && isRenderingSibling(siblings[pIdx], code);
410
+ const N = nIdx < siblings.length && isRenderingSibling(siblings[nIdx], code);
411
+ const origSpace = (!!L && P) || (!!R && N);
412
+ const afterSpace = P && N && (!!L || !!R);
413
+ if (!origSpace || afterSpace)
414
+ return undefined;
415
+ return [L ? L.start : span[0], R ? R.end : span[1]];
416
+ }
417
+ /** A text node whose source is entirely whitespace. */
418
+ function isWhitespaceText(node, code) {
419
+ return node.type === 'Text' && /^\s*$/.test(code.slice(node.start, node.end));
420
+ }
421
+ /**
422
+ * A sibling that adjacent whitespace can "lean on" so it renders a space. A
423
+ * whitespace-only text node is not one (it is the seam whitespace itself), and a
424
+ * `Comment` is transparent to SSR — it acts as a fragment edge for trimming, so
425
+ * it is not a rendering neighbour either.
426
+ */
427
+ function isRenderingSibling(node, code) {
428
+ return node.type !== 'Comment' && !isWhitespaceText(node, code);
429
+ }
430
+ /** An element inside which Svelte preserves whitespace verbatim. */
431
+ function isPreserveElement(node) {
432
+ return node.type === 'RegularElement' && (node.name === 'pre' || node.name === 'textarea');
433
+ }
434
+ /** Does the component opt into preserved whitespace via `<svelte:options>`? */
435
+ function hasPreserveWhitespaceOption(fragment) {
436
+ let preserve = false;
437
+ walk(fragment, null, {
438
+ SvelteOptions(node) {
439
+ for (const a of node.attributes ?? []) {
440
+ if (a.type !== 'Attribute' || a.name !== 'preserveWhitespace')
441
+ continue;
442
+ // `preserveWhitespace` (boolean shorthand) or `={true}` opts in; only an
443
+ // explicit `={false}` opts out. Any other (invalid) form is treated as
444
+ // opting in, since svelte:options requires a static value.
445
+ preserve = !isExplicitFalse(a.value);
446
+ }
447
+ },
448
+ });
449
+ return preserve;
450
+ }
451
+ /** True when an attribute value is the literal `{false}` (or `false`). */
452
+ function isExplicitFalse(value) {
453
+ if (value === false)
454
+ return true;
455
+ const parts = (Array.isArray(value) ? value : [value]);
456
+ return parts.some((p) => p?.type === 'ExpressionTag' &&
457
+ p.expression?.type === 'Literal' &&
458
+ p.expression.value === false);
459
+ }
460
+ /**
461
+ * Fold template ternaries `{cond ? a : b}` to their taken arm when `cond` is a
462
+ * provable constant under `env` (constFold). Only the outer-most foldable
463
+ * ternary in any nesting is rewritten: its taken arm is re-emitted verbatim and
464
+ * its whole span recorded in `dead`, so neither the substitution pass nor an
465
+ * inner fold touches it again (a sub-range edit inside an overwritten span would
466
+ * conflict in MagicString).
467
+ *
468
+ * Soundness: a JS conditional only ever evaluates the taken arm at runtime, so
469
+ * dropping the untaken arm is observation-preserving even if that arm had side
470
+ * effects — they would never have run. We fold only when `evaluate` *proves*
471
+ * the test (no guessing), and we leave value-set (`narrow`) props alone since
472
+ * those are genuinely dynamic.
473
+ */
474
+ function foldTernaries(fragment, env, code, s, dead) {
475
+ if (env.size === 0)
476
+ return; // ternaries fold only against known constants
477
+ walk(fragment, null, {
478
+ ConditionalExpression(node, { next }) {
479
+ // Skip ternaries inside an already-removed/overwritten region (e.g. a dead
480
+ // `{#if}` arm, or an outer ternary we just folded): editing them would
481
+ // conflict, and they no longer appear in the output anyway.
482
+ if (inSpans(node, dead))
483
+ return;
484
+ const test = evaluate(node.test, env);
485
+ if (!test.known) {
486
+ next(); // test not provable: keep this ternary, but inner ones may fold
487
+ return;
488
+ }
489
+ const taken = test.value ? node.consequent : node.alternate;
490
+ // A taken arm always exists for a well-formed ConditionalExpression; guard
491
+ // defensively so a malformed tree never produces a bad slice.
492
+ if (!taken) {
493
+ next();
494
+ return;
495
+ }
496
+ // Emit the taken arm verbatim, but with any folded-prop references inside
497
+ // it already substituted: those props get dropped from the signature, so a
498
+ // raw slice would dangle (see {@link substitutedSlice}).
499
+ s.overwrite(node.start, node.end, substitutedSlice(taken.start, taken.end, [taken], env, code));
500
+ dead.push([node.start, node.end]); // emitted verbatim -> off-limits, no recurse
501
+ },
502
+ });
503
+ }
504
+ function fragmentSource(fragment, env, code) {
505
+ const nodes = fragment?.nodes ?? [];
506
+ if (nodes.length === 0)
507
+ return '';
508
+ return substitutedSlice(nodes[0].start, nodes[nodes.length - 1].end, nodes, env, code);
509
+ }
510
+ /**
511
+ * The source for `[from, to)` with every folded-prop (constFold) reference
512
+ * inside `roots` replaced by its literal. Used when re-emitting a kept `{#if}`
513
+ * arm or ternary arm verbatim: the whole span is overwritten in one shot, so the
514
+ * normal substitution pass cannot reach references inside it, yet those props
515
+ * are about to leave the `$props()` signature. Substituting here keeps the
516
+ * emitted text self-contained (no dangling identifier) and observably identical
517
+ * — every reference is replaced by the exact constant it was proven to equal.
518
+ */
519
+ function substitutedSlice(from, to, roots, env, code) {
520
+ if (env.size === 0)
521
+ return code.slice(from, to);
522
+ // Collect every folded-prop edit in source order (shorthand-aware, see
523
+ // {@link collectFoldRefs}); each is an `[start,end)` overwrite with wrapping.
524
+ const refs = [];
525
+ for (const root of roots) {
526
+ collectFoldRefs(root, env, code, (name, ref) => refs.push({ ...ref, name }));
527
+ }
528
+ if (refs.length === 0)
529
+ return code.slice(from, to);
530
+ refs.sort((a, b) => a.start - b.start);
531
+ let out = '';
532
+ let cursor = from;
533
+ for (const ref of refs) {
534
+ out += code.slice(cursor, ref.start);
535
+ out += ref.head + literalSource(env.get(ref.name)) + ref.tail;
536
+ cursor = ref.end;
537
+ }
538
+ out += code.slice(cursor, to);
539
+ return out;
540
+ }
541
+ /** Find every folded-prop reference in `model`, outside dead spans, by name. */
542
+ function collectPropRefs(model, env, dead) {
543
+ const refs = new Map();
544
+ const scan = (root) => {
545
+ if (!root)
546
+ return;
547
+ collectFoldRefs(root, env, model.code, (name, ref, node) => {
548
+ if (inSpans(node, dead) || node === model.propsPattern)
549
+ return;
550
+ (refs.get(name) ?? setDefault(refs, name)).push(ref);
551
+ });
552
+ };
553
+ scan(model.ast.instance); // only the instance script can reference props
554
+ scan(model.ast.fragment);
555
+ return refs;
556
+ }
557
+ /**
558
+ * Walk `root` and `emit` an edit for every folded-prop reference — both plain
559
+ * expression reads AND the shorthand positions {@link foldRefFor} expands, plus
560
+ * `style:NAME` shorthands (which have no expression node and so are invisible to
561
+ * an identifier walk). `emit` receives the originating node so callers can
562
+ * filter by position (e.g. skip dead spans). Shared by the live substitution
563
+ * pass and the verbatim re-emit ({@link substitutedSlice}) so both fold
564
+ * shorthands identically.
565
+ */
566
+ function collectFoldRefs(root, env, code, emit) {
567
+ walk(root, { parent: null, grandparent: null }, {
568
+ _(node, { state, next }) {
569
+ // `style:NAME` shorthand carries no expression node (its `value` is the
570
+ // boolean `true` marker), so an identifier walk never sees it; expand it
571
+ // to `style:NAME={lit}` or the dropped prop would dangle. Trim trailing
572
+ // whitespace from the span: some parsers (rsvelte) fold the gap before the
573
+ // next attribute into the directive's `end`, and overwriting that gap
574
+ // would glue the expansion onto the next attribute.
575
+ if (node.type === 'StyleDirective' &&
576
+ node.value === true &&
577
+ node.name &&
578
+ env.has(node.name)) {
579
+ let end = node.end;
580
+ while (end > node.start && isSpace(code[end - 1]))
581
+ end -= 1;
582
+ const src = code.slice(node.start, end); // `style:NAME`
583
+ emit(node.name, { start: node.start, end, head: `${src}={`, tail: '}' }, node);
584
+ }
585
+ else if (node.type === 'Identifier' &&
586
+ node.name &&
587
+ env.has(node.name) &&
588
+ !isNonReference(node, state.parent)) {
589
+ emit(node.name, foldRefFor(node, state.parent, state.grandparent, code), node);
590
+ }
591
+ next({ parent: node, grandparent: state.parent });
592
+ },
593
+ });
594
+ }
595
+ /**
596
+ * The edit to substitute a folded prop at the given identifier. A plain
597
+ * expression read overwrites just the identifier (no wrapping). A SHORTHAND
598
+ * syntactic position is expanded to the explicit `name={value}` the long form
599
+ * uses, because overwriting the bare identifier there corrupts the syntax:
600
+ *
601
+ * class:compact -> class:compact={false} (`class:false` is a *different* class)
602
+ * {compact} -> compact={false} (`{false}` is a reserved word)
603
+ *
604
+ * The full forms (`class:compact={compact}`, `compact={compact}`) already place
605
+ * the identifier inside an expression slot, so they fall through to the plain
606
+ * overwrite and are unaffected.
607
+ */
608
+ function foldRefFor(node, parent, grandparent, code) {
609
+ // `class:NAME` shorthand: the identifier sits in the directive-name slot, right
610
+ // after the `:` (the long form puts it inside `={…}`, where the char is `{`).
611
+ if (parent?.type === 'ClassDirective' &&
612
+ parent.expression === node &&
613
+ code[node.start - 1] === ':') {
614
+ const name = code.slice(node.start, node.end);
615
+ return { start: node.start, end: node.end, head: `${name}={`, tail: '}' };
616
+ }
617
+ // `{NAME}` attribute shorthand: the braces belong to the Attribute, not the
618
+ // ExpressionTag, so overwrite the whole attribute (`{NAME}` -> `NAME={lit}`).
619
+ if (parent?.type === 'ExpressionTag' &&
620
+ grandparent?.type === 'Attribute' &&
621
+ grandparent.name &&
622
+ code[grandparent.start] === '{') {
623
+ return {
624
+ start: grandparent.start,
625
+ end: grandparent.end,
626
+ head: `${grandparent.name}={`,
627
+ tail: '}',
628
+ };
629
+ }
630
+ // Object shorthand `{ NAME }`: a `Property` with `shorthand: true` whose single
631
+ // identifier is BOTH key and value. A plain replace yields `{ "lit" }` (invalid);
632
+ // expand to `NAME: lit`.
633
+ if (parent?.type === 'Property' && parent.shorthand === true && parent.value === node) {
634
+ const name = code.slice(node.start, node.end);
635
+ return { start: node.start, end: node.end, head: `${name}: `, tail: '' };
636
+ }
637
+ return { start: node.start, end: node.end, head: '', tail: '' };
638
+ }
639
+ /** True when an Identifier is a property key / member name, not a value read. */
640
+ function isNonReference(node, parent) {
641
+ if (!parent)
642
+ return false;
643
+ if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed)
644
+ return true;
645
+ if (parent.type === 'Property' &&
646
+ parent.key === node &&
647
+ !parent.computed &&
648
+ parent.shorthand !== true)
649
+ return true;
650
+ // TS type-member name (`interface Props { NAME?: T }` / a `{ NAME: T }` type
651
+ // literal / a method signature). The key is a member NAME in a type position,
652
+ // never a value read of a prop, so folding a same-named prop's literal into it
653
+ // would corrupt the type (`width?: number` -> `36?: number`). Type text is erased
654
+ // at compile, so the old behavior was byte-wrong but not a runtime fault — still,
655
+ // the type member must keep its name. (`computed` keys `[expr]` ARE value reads.)
656
+ if ((parent.type === 'TSPropertySignature' || parent.type === 'TSMethodSignature') &&
657
+ parent.key === node &&
658
+ !parent.computed)
659
+ return true;
660
+ // Import / export specifier slots are MODULE-EXPORT names, never a read of a
661
+ // local prop value (`import { count as store }` -> `count` is the module's
662
+ // export, not our prop). Substituting a literal there is invalid syntax, so
663
+ // exclude every specifier identifier position defensively.
664
+ if (parent.type === 'ImportSpecifier' ||
665
+ parent.type === 'ImportDefaultSpecifier' ||
666
+ parent.type === 'ImportNamespaceSpecifier' ||
667
+ parent.type === 'ExportSpecifier')
668
+ return true;
669
+ // Declaration sites are excluded via the shadowing guard in analyze.ts, so
670
+ // anything reaching here in an expression slot is a genuine value read.
671
+ return false;
672
+ }
673
+ function dropProps(model, drop, s) {
674
+ if (!model.props || drop.size === 0)
675
+ return;
676
+ const remaining = model.props.filter((p) => !drop.has(p.name));
677
+ if (remaining.length === 0 && !model.hasRestProp && model.propsDeclaration) {
678
+ removeWholeLine(model.code, model.propsDeclaration, s); // signature is now empty
679
+ return;
680
+ }
681
+ const properties = model.propsPattern?.properties ?? [];
682
+ // Remove each MAXIMAL RUN of consecutive dropped properties as a single range so
683
+ // the separating commas tile cleanly. A per-property removal mishandles a
684
+ // trailing comma on the last property and overlaps on consecutive drops, leaving
685
+ // a dangling `,` (invalid `$props()` destructuring).
686
+ const droppedNodes = new Set(model.props.filter((p) => drop.has(p.name)).map((p) => p.property));
687
+ let i = 0;
688
+ while (i < properties.length) {
689
+ if (!droppedNodes.has(properties[i])) {
690
+ i++;
691
+ continue;
692
+ }
693
+ let hi = i;
694
+ while (hi + 1 < properties.length && droppedNodes.has(properties[hi + 1]))
695
+ hi++;
696
+ removePropertyRun(model.code, properties, i, hi, s);
697
+ i = hi + 1;
698
+ }
699
+ // Type members live in the disjoint `}: { … }` annotation; remove them per-prop.
700
+ if (model.propsPattern) {
701
+ for (const decl of model.props) {
702
+ if (drop.has(decl.name))
703
+ removeTypeMember(model.propsPattern, decl.name, s);
704
+ }
705
+ }
706
+ }
707
+ /**
708
+ * Delete the run of dropped destructuring properties `properties[lo..hi]` together,
709
+ * absorbing the commas/whitespace so the result stays valid. When a surviving
710
+ * property follows the run we eat forward to it; otherwise the run reaches the end,
711
+ * so we eat any trailing comma and reach back to the previous surviving property's
712
+ * separator (leaving it with no dangling comma).
713
+ */
714
+ function removePropertyRun(code, properties, lo, hi, s) {
715
+ const first = properties[lo];
716
+ const last = properties[hi];
717
+ const keptAfter = properties[hi + 1];
718
+ if (keptAfter) {
719
+ s.remove(first.start, keptAfter.start); // run + commas + ws up to the next survivor
720
+ return;
721
+ }
722
+ // Run reaches the end of the pattern: include a trailing comma after the last
723
+ // dropped property if present (so it does not dangle), but NOT the whitespace
724
+ // before `}` when there is none — keep `{ a }` from becoming `{ a}`. Then drop
725
+ // back to the previous survivor's separator.
726
+ let end = last.end;
727
+ let j = end;
728
+ while (j < code.length && /\s/.test(code[j]))
729
+ j++;
730
+ if (code[j] === ',')
731
+ end = j + 1;
732
+ const keptBefore = properties[lo - 1];
733
+ s.remove(keptBefore ? keptBefore.end : first.start, end);
734
+ }
735
+ function removeTypeMember(pattern, name, s) {
736
+ const members = pattern.typeAnnotation?.typeAnnotation?.members ?? [];
737
+ const i = members.findIndex((m) => m.key?.type === 'Identifier' && m.key.name === name);
738
+ if (i === -1)
739
+ return;
740
+ const member = members[i];
741
+ const next = members[i + 1];
742
+ const prev = members[i - 1];
743
+ // Members are separated by `;` or `,`; eat one separator with the member.
744
+ if (next)
745
+ s.remove(member.start, next.start);
746
+ else if (prev)
747
+ s.remove(prev.end, member.end);
748
+ else
749
+ s.remove(member.start, member.end);
750
+ }
751
+ function removeCallSiteAttributes(model, dropped, s, editedSpans) {
752
+ walk(model.ast.fragment, null, {
753
+ Component(node, { next }) {
754
+ // This `<Child/>` sits inside a branch phase 1 already removed/overwrote;
755
+ // its source (attributes included) is gone, so editing it now would overlap
756
+ // that edit ("Cannot split a chunk that has already been edited"). Skip the
757
+ // whole subtree — every nested call site is in the same dead region.
758
+ if (editedSpans.length > 0 && inSpans(node, editedSpans))
759
+ return;
760
+ const childId = node.name ? model.imports.get(node.name) : undefined;
761
+ const drop = childId ? dropped.get(childId) : undefined;
762
+ if (drop && drop.size > 0) {
763
+ for (const attr of node.attributes ?? []) {
764
+ if (attr.type !== 'Attribute' || !attr.name || !drop.has(attr.name))
765
+ continue;
766
+ if (!isSideEffectFree(attr.value))
767
+ continue;
768
+ removeAttrWithSpace(model.code, attr, s);
769
+ }
770
+ }
771
+ next();
772
+ },
773
+ });
774
+ }
775
+ /** A call-site attribute is safe to delete only if its value has no side effects. */
776
+ function isSideEffectFree(value) {
777
+ if (value === true || value == null)
778
+ return true;
779
+ const parts = (Array.isArray(value) ? value : [value]);
780
+ return parts.every((part) => {
781
+ if (part.type === 'Text')
782
+ return true;
783
+ if (part.type === 'ExpressionTag')
784
+ return part.expression?.type === 'Literal';
785
+ return false;
786
+ });
787
+ }
788
+ /**
789
+ * Remove the (now prop-less) `$props()` declaration. When it is alone on its
790
+ * line — the realistic case for every `.svelte` file — eat the whole line so no
791
+ * blank indentation is left behind. But if it shares its line with other code
792
+ * (e.g. a hand-minified `let {x}=$props();</script>`), remove ONLY the
793
+ * declaration (plus a trailing `;`) so we never swallow adjacent source.
794
+ */
795
+ function removeWholeLine(code, node, s) {
796
+ let lineStart = node.start;
797
+ while (lineStart > 0 && code[lineStart - 1] !== '\n')
798
+ lineStart -= 1;
799
+ let lineEnd = node.end;
800
+ while (lineEnd < code.length && code[lineEnd] !== '\n')
801
+ lineEnd += 1;
802
+ const prefix = code.slice(lineStart, node.start);
803
+ const suffix = code.slice(node.end, lineEnd);
804
+ if (/^\s*$/.test(prefix) && /^\s*;?\s*$/.test(suffix)) {
805
+ // Alone on the line: remove the line and its trailing newline.
806
+ s.remove(lineStart, lineEnd < code.length ? lineEnd + 1 : lineEnd);
807
+ }
808
+ else {
809
+ // Shares the line: remove just the declaration (+ a trailing semicolon).
810
+ s.remove(node.start, code[node.end] === ';' ? node.end + 1 : node.end);
811
+ }
812
+ }
813
+ function setDefault(map, key) {
814
+ const arr = [];
815
+ map.set(key, arr);
816
+ return arr;
817
+ }
818
+ function isSpace(ch) {
819
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
820
+ }
821
+ function literalSource(value) {
822
+ if (value === undefined)
823
+ return 'undefined';
824
+ return JSON.stringify(value);
825
+ }