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.
- package/dist/{types/src/analyze.d.ts → analyze.d.ts} +23 -10
- package/dist/analyze.js +1140 -0
- package/dist/{types/src/css.d.ts → css.d.ts} +2 -2
- package/dist/css.js +256 -0
- package/dist/{types/src/dead.d.ts → dead.d.ts} +2 -2
- package/dist/dead.js +195 -0
- package/dist/{types/src/engine.d.ts → engine.d.ts} +3 -3
- package/dist/engine.js +109 -0
- package/dist/{types/src/eval.d.ts → eval.d.ts} +2 -2
- package/dist/eval.js +222 -0
- package/dist/{types/src/index.d.ts → index.d.ts} +11 -11
- package/dist/index.js +86 -1
- package/dist/ir.js +15 -0
- package/dist/{types/src/mono.d.ts → mono.d.ts} +3 -3
- package/dist/mono.js +451 -0
- package/dist/{types/src/parse.d.ts → parse.d.ts} +4 -0
- package/dist/parse.js +19 -0
- package/dist/{types/src/rsvelte-parse.d.ts → rsvelte-parse.d.ts} +1 -1
- package/dist/rsvelte-parse.js +31 -0
- package/dist/{types/src/scan.d.ts → scan.d.ts} +2 -2
- package/dist/scan.js +40 -1
- package/dist/{types/src/transform.d.ts → transform.d.ts} +4 -4
- package/dist/transform.js +825 -0
- package/dist/{types/src/vite.d.ts → vite.d.ts} +10 -2
- package/dist/vite.js +328 -1
- package/package.json +9 -18
- package/dist/index.cjs +0 -1
- /package/dist/{types/src/ir.d.ts → ir.d.ts} +0 -0
|
@@ -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
|
+
}
|