@fuzdev/fuz_ui 0.181.1 → 0.182.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,726 @@
1
+ /**
2
+ * Svelte preprocessor that compiles static `Mdz` content to Svelte markup at build time.
3
+ *
4
+ * Detects `Mdz` components with static string `content` props, parses the mdz content,
5
+ * renders each `MdzNode` to equivalent Svelte markup via `mdz_to_svelte`, and replaces
6
+ * the `Mdz` with `MdzPrecompiled` containing pre-rendered children.
7
+ *
8
+ * Also handles ternary chains (`content={a ? 'x' : b ? 'y' : 'z'}`) where all leaf
9
+ * values are statically resolvable strings, emitting `{#if a}markup_x{:else if b}markup_y{:else}markup_z{/if}`
10
+ * as children of a single `MdzPrecompiled`.
11
+ *
12
+ * Truly dynamic `content` props are left untouched.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import type {ImportDeclaration, VariableDeclaration} from 'estree';
18
+ import {parse, type PreprocessorGroup, type AST} from 'svelte/compiler';
19
+ import MagicString from 'magic-string';
20
+ import {walk} from 'zimmerframe';
21
+ import {should_exclude_path} from '@fuzdev/fuz_util/path.js';
22
+ import {
23
+ find_attribute,
24
+ extract_static_string,
25
+ try_extract_conditional_chain,
26
+ build_static_bindings,
27
+ resolve_component_names,
28
+ has_identifier_in_tree,
29
+ find_import_insert_position,
30
+ generate_import_lines,
31
+ remove_variable_declaration,
32
+ remove_import_declaration,
33
+ remove_import_specifier,
34
+ handle_preprocess_error,
35
+ type PreprocessImportInfo,
36
+ type ResolvedComponentImport,
37
+ } from '@fuzdev/fuz_util/svelte_preprocess_helpers.js';
38
+
39
+ import {mdz_parse} from './mdz.js';
40
+ import {mdz_to_svelte} from './mdz_to_svelte.js';
41
+
42
+ /**
43
+ * An estree `ImportDeclaration` augmented with Svelte's position data.
44
+ * Svelte's parser adds `start`/`end` to all AST nodes, but the estree
45
+ * types don't declare them.
46
+ */
47
+ type PositionedImportDeclaration = ImportDeclaration & AST.BaseNode;
48
+
49
+ /**
50
+ * Options for `svelte_preprocess_mdz`.
51
+ */
52
+ export interface SveltePreprocessMdzOptions {
53
+ /** File patterns to exclude. */
54
+ exclude?: Array<string | RegExp>;
55
+
56
+ /**
57
+ * Component import mapping for mdz content.
58
+ * Key: component name as used in mdz (e.g., 'Alert').
59
+ * Value: import path (e.g., '$lib/Alert.svelte').
60
+ *
61
+ * If mdz content references a component not in this map,
62
+ * that Mdz usage is skipped (left as runtime).
63
+ */
64
+ components?: Record<string, string>;
65
+
66
+ /**
67
+ * Allowed HTML elements in mdz content.
68
+ * If mdz content references an element not in this list,
69
+ * that Mdz usage is skipped (left as runtime).
70
+ */
71
+ elements?: Array<string>;
72
+
73
+ /**
74
+ * Import sources that resolve to the Mdz component.
75
+ * Used to verify that `Mdz` in templates refers to fuz_ui's Mdz.svelte.
76
+ *
77
+ * @default ['@fuzdev/fuz_ui/Mdz.svelte']
78
+ */
79
+ component_imports?: Array<string>;
80
+
81
+ /**
82
+ * Import path for the precompiled wrapper component.
83
+ *
84
+ * @default '@fuzdev/fuz_ui/MdzPrecompiled.svelte'
85
+ */
86
+ compiled_component_import?: string;
87
+
88
+ /**
89
+ * How to handle errors during mdz parsing or rendering.
90
+ *
91
+ * @default 'throw' in CI, 'log' otherwise
92
+ */
93
+ on_error?: 'log' | 'throw';
94
+ }
95
+
96
+ const PRECOMPILED_NAME = 'MdzPrecompiled';
97
+
98
+ /**
99
+ * Creates a Svelte preprocessor that compiles static `Mdz` content at build time.
100
+ *
101
+ * @param options Configuration for component/element resolution and file filtering.
102
+ * @returns A Svelte `PreprocessorGroup` for use in `svelte.config.js`.
103
+ */
104
+ export const svelte_preprocess_mdz = (
105
+ options: SveltePreprocessMdzOptions = {},
106
+ ): PreprocessorGroup => {
107
+ const {
108
+ exclude = [],
109
+ components = {},
110
+ elements: elements_array = [],
111
+ component_imports = ['@fuzdev/fuz_ui/Mdz.svelte'],
112
+ compiled_component_import = '@fuzdev/fuz_ui/MdzPrecompiled.svelte',
113
+ on_error = process.env.CI === 'true' ? 'throw' : 'log',
114
+ } = options;
115
+ const elements = new Set(elements_array);
116
+
117
+ return {
118
+ name: 'fuz-mdz',
119
+
120
+ markup: ({content, filename}) => {
121
+ if (should_exclude_path(filename, exclude)) {
122
+ return {code: content};
123
+ }
124
+
125
+ // Quick bail: does file mention any known Mdz import source?
126
+ if (!component_imports.some((source) => content.includes(source))) {
127
+ return {code: content};
128
+ }
129
+
130
+ const ast = parse(content, {filename, modern: true});
131
+
132
+ // Resolve which local names map to the Mdz component
133
+ const mdz_names = resolve_component_names(ast, component_imports);
134
+ if (mdz_names.size === 0) {
135
+ return {code: content};
136
+ }
137
+
138
+ // Check for MdzPrecompiled name collision
139
+ if (has_name_collision(ast, compiled_component_import)) {
140
+ return {code: content};
141
+ }
142
+
143
+ const s = new MagicString(content);
144
+ const bindings = build_static_bindings(ast);
145
+
146
+ // Find and transform Mdz usages with static content
147
+ const {transformations, total_usages, transformed_usages} = find_mdz_usages(ast, mdz_names, {
148
+ components,
149
+ elements,
150
+ filename,
151
+ source: content,
152
+ bindings,
153
+ on_error,
154
+ });
155
+
156
+ if (transformations.length === 0) {
157
+ return {code: content};
158
+ }
159
+
160
+ // Apply transformations
161
+ for (const t of transformations) {
162
+ s.overwrite(t.start, t.end, t.replacement);
163
+ }
164
+
165
+ // Remove dead const bindings that were consumed by transformations
166
+ remove_dead_const_bindings(s, ast, transformations, content);
167
+
168
+ // Determine which Mdz imports can be removed
169
+ const removable_imports = find_removable_mdz_imports(
170
+ ast,
171
+ mdz_names,
172
+ total_usages,
173
+ transformed_usages,
174
+ );
175
+
176
+ // Add required imports and remove unused Mdz imports
177
+ manage_imports(
178
+ s,
179
+ ast,
180
+ transformations,
181
+ removable_imports,
182
+ compiled_component_import,
183
+ content,
184
+ );
185
+
186
+ return {
187
+ code: s.toString(),
188
+ map: s.generateMap({hires: true}),
189
+ };
190
+ },
191
+ };
192
+ };
193
+
194
+ interface MdzTransformation {
195
+ start: number;
196
+ end: number;
197
+ replacement: string;
198
+ required_imports: Map<string, PreprocessImportInfo>;
199
+ /** Const binding names that were resolved to produce this transformation. */
200
+ consumed_bindings: Set<string>;
201
+ /** The original AST component node, used to skip during dead code analysis. */
202
+ component_node: AST.Component;
203
+ }
204
+
205
+ interface FindMdzUsagesResult {
206
+ transformations: Array<MdzTransformation>;
207
+ /** Total template usages per Mdz local name. */
208
+ total_usages: Map<string, number>;
209
+ /** Successfully transformed usages per Mdz local name. */
210
+ transformed_usages: Map<string, number>;
211
+ }
212
+
213
+ interface FindMdzUsagesContext {
214
+ components: Record<string, string>;
215
+ elements: ReadonlySet<string>;
216
+ filename: string | undefined;
217
+ source: string;
218
+ bindings: ReadonlyMap<string, string>;
219
+ on_error: 'log' | 'throw';
220
+ }
221
+
222
+ /**
223
+ * Checks if `MdzPrecompiled` is already imported from a different source.
224
+ * If so, the preprocessor bails to avoid name collisions.
225
+ */
226
+ const has_name_collision = (ast: AST.Root, compiled_component_import: string): boolean => {
227
+ for (const script of [ast.instance, ast.module]) {
228
+ if (!script) continue;
229
+ for (const node of script.content.body) {
230
+ if (node.type !== 'ImportDeclaration') continue;
231
+ const source_path = node.source.value as string;
232
+ for (const spec of node.specifiers) {
233
+ if (spec.local.name === PRECOMPILED_NAME && source_path !== compiled_component_import) {
234
+ return true;
235
+ }
236
+ }
237
+ }
238
+ }
239
+ return false;
240
+ };
241
+
242
+ /**
243
+ * Collects identifiers from an expression that resolved through bindings.
244
+ * Only collects top-level Identifier nodes that appear in the bindings map.
245
+ *
246
+ * TODO: support transitive dead const removal — currently only directly-consumed
247
+ * bindings (identifiers in the expression AST) are tracked. Transitive dependencies
248
+ * (e.g., `const a = 'x'; const b = a; content={b}` — `a` is not removed) are not
249
+ * traced. After removing a dead const, re-check whether identifiers in its
250
+ * initializer became dead too.
251
+ */
252
+ const collect_consumed_bindings = (
253
+ value: AST.Attribute['value'],
254
+ bindings: ReadonlyMap<string, string>,
255
+ ): Set<string> => {
256
+ const consumed: Set<string> = new Set();
257
+ if (value === true || Array.isArray(value)) return consumed;
258
+ const collect_from_expr = (expr: {type: string; [key: string]: any}): void => {
259
+ if (expr.type === 'Identifier' && bindings.has(expr.name)) {
260
+ consumed.add(expr.name);
261
+ } else if (expr.type === 'BinaryExpression' && expr.operator === '+') {
262
+ collect_from_expr(expr.left);
263
+ collect_from_expr(expr.right);
264
+ } else if (expr.type === 'TemplateLiteral') {
265
+ for (const e of expr.expressions) {
266
+ collect_from_expr(e);
267
+ }
268
+ } else if (expr.type === 'ConditionalExpression') {
269
+ collect_from_expr(expr.consequent);
270
+ collect_from_expr(expr.alternate);
271
+ }
272
+ };
273
+ collect_from_expr(value.expression);
274
+ return consumed;
275
+ };
276
+
277
+ /**
278
+ * Walks the AST to find `Mdz` component usages with static `content` props
279
+ * and generates transformations to replace them with `MdzPrecompiled` children.
280
+ */
281
+ const find_mdz_usages = (
282
+ ast: AST.Root,
283
+ mdz_names: Map<string, ResolvedComponentImport>,
284
+ context: FindMdzUsagesContext,
285
+ ): FindMdzUsagesResult => {
286
+ const transformations: Array<MdzTransformation> = [];
287
+ const total_usages: Map<string, number> = new Map();
288
+ const transformed_usages: Map<string, number> = new Map();
289
+
290
+ // zimmerframe types visitors against {type: string}, requiring explicit annotations
291
+ // on the callback parameters for Svelte-specific AST types like AST.Component
292
+ walk(ast.fragment as any, null, {
293
+ Component(node: AST.Component, ctx: {next: () => void}) {
294
+ // Always recurse into children so nested Mdz components are found
295
+ ctx.next();
296
+
297
+ if (!mdz_names.has(node.name)) return;
298
+
299
+ // Track total usages per name
300
+ total_usages.set(node.name, (total_usages.get(node.name) ?? 0) + 1);
301
+
302
+ // Skip if spread attributes present — can't determine content statically
303
+ if (node.attributes.some((attr) => attr.type === 'SpreadAttribute')) return;
304
+
305
+ const content_attr = find_attribute(node, 'content');
306
+ if (!content_attr) return;
307
+
308
+ // Extract static string value
309
+ const content_value = extract_static_string(content_attr.value, context.bindings);
310
+ if (content_value !== null) {
311
+ // Parse mdz content and render to Svelte markup
312
+ let result;
313
+ try {
314
+ const nodes = mdz_parse(content_value);
315
+ result = mdz_to_svelte(nodes, context.components, context.elements);
316
+ } catch (error) {
317
+ handle_preprocess_error(error, '[fuz-mdz]', context.filename, context.on_error);
318
+ return;
319
+ }
320
+
321
+ // If content has unconfigured tags, skip this usage (fall back to runtime)
322
+ if (result.has_unconfigured_tags) return;
323
+
324
+ const consumed = collect_consumed_bindings(content_attr.value, context.bindings);
325
+ const replacement = build_replacement(node, content_attr, result.markup, context.source);
326
+ transformed_usages.set(node.name, (transformed_usages.get(node.name) ?? 0) + 1);
327
+ transformations.push({
328
+ start: node.start,
329
+ end: node.end,
330
+ replacement,
331
+ required_imports: result.imports,
332
+ consumed_bindings: consumed,
333
+ component_node: node,
334
+ });
335
+ return;
336
+ }
337
+
338
+ // Try conditional chain (handles both simple and nested ternaries)
339
+ const chain = try_extract_conditional_chain(
340
+ content_attr.value,
341
+ context.source,
342
+ context.bindings,
343
+ );
344
+ if (chain === null) return;
345
+
346
+ // Parse and render each branch
347
+ const branch_results: Array<{markup: string; imports: Map<string, PreprocessImportInfo>}> =
348
+ [];
349
+ try {
350
+ for (const branch of chain) {
351
+ const nodes = mdz_parse(branch.value);
352
+ const result = mdz_to_svelte(nodes, context.components, context.elements);
353
+ if (result.has_unconfigured_tags) return;
354
+ branch_results.push({markup: result.markup, imports: result.imports});
355
+ }
356
+ } catch (error) {
357
+ handle_preprocess_error(error, '[fuz-mdz]', context.filename, context.on_error);
358
+ return;
359
+ }
360
+
361
+ // Build {#if}/{:else if}/{:else} markup
362
+ let children_markup = '';
363
+ for (let i = 0; i < chain.length; i++) {
364
+ const branch = chain[i]!;
365
+ const result = branch_results[i]!;
366
+ if (i === 0) {
367
+ children_markup += `{#if ${branch.test_source}}${result.markup}`;
368
+ } else if (branch.test_source !== null) {
369
+ children_markup += `{:else if ${branch.test_source}}${result.markup}`;
370
+ } else {
371
+ children_markup += `{:else}${result.markup}`;
372
+ }
373
+ }
374
+ children_markup += '{/if}';
375
+
376
+ const replacement = build_replacement(node, content_attr, children_markup, context.source);
377
+
378
+ // Merge imports from all branches
379
+ const merged_imports: Map<string, PreprocessImportInfo> = new Map();
380
+ for (const result of branch_results) {
381
+ for (const [name, info] of result.imports) {
382
+ merged_imports.set(name, info);
383
+ }
384
+ }
385
+
386
+ const consumed = collect_consumed_bindings(content_attr.value, context.bindings);
387
+ transformed_usages.set(node.name, (transformed_usages.get(node.name) ?? 0) + 1);
388
+ transformations.push({
389
+ start: node.start,
390
+ end: node.end,
391
+ replacement,
392
+ required_imports: merged_imports,
393
+ consumed_bindings: consumed,
394
+ component_node: node,
395
+ });
396
+ },
397
+ });
398
+
399
+ return {transformations, total_usages, transformed_usages};
400
+ };
401
+
402
+ /**
403
+ * Removes dead `const` bindings from the instance script that were consumed
404
+ * by transformations and are no longer referenced anywhere else in the file.
405
+ *
406
+ * Only handles single-declarator `const` statements. Skips `const a = 'x', b = 'y'`
407
+ * and module script variables (which could be exported/imported by other files).
408
+ */
409
+ const remove_dead_const_bindings = (
410
+ s: MagicString,
411
+ ast: AST.Root,
412
+ transformations: Array<MdzTransformation>,
413
+ source: string,
414
+ ): void => {
415
+ // Collect all consumed binding names across transformations
416
+ const all_consumed: Set<string> = new Set();
417
+ for (const t of transformations) {
418
+ for (const name of t.consumed_bindings) {
419
+ all_consumed.add(name);
420
+ }
421
+ }
422
+ if (all_consumed.size === 0) return;
423
+
424
+ // Only remove from instance script (module script variables could be exported)
425
+ const instance = ast.instance;
426
+ if (!instance) return;
427
+
428
+ // Build a skip set of transformed component nodes so their attribute
429
+ // expressions (which still contain the old identifiers) don't false-match
430
+ const skip: Set<unknown> = new Set();
431
+ for (const t of transformations) {
432
+ skip.add(t.component_node);
433
+ }
434
+
435
+ for (const name of all_consumed) {
436
+ // Find the VariableDeclaration containing this binding in instance script
437
+ let declaration_node: (VariableDeclaration & {start: number; end: number}) | null = null;
438
+ for (const node of instance.content.body) {
439
+ if (node.type !== 'VariableDeclaration' || node.kind !== 'const') continue;
440
+ for (const decl of node.declarations) {
441
+ if (decl.id.type === 'Identifier' && decl.id.name === name) {
442
+ declaration_node = node as VariableDeclaration & {start: number; end: number};
443
+ break;
444
+ }
445
+ }
446
+ if (declaration_node) break;
447
+ }
448
+
449
+ if (!declaration_node) continue;
450
+
451
+ // Only handle single-declarator statements
452
+ if (declaration_node.declarations.length !== 1) continue;
453
+
454
+ // Check if the identifier is referenced anywhere else
455
+ const id_skip = new Set([...skip, declaration_node]);
456
+
457
+ // Check instance script (excluding the declaration itself)
458
+ if (has_identifier_in_tree(instance.content, name, id_skip)) continue;
459
+
460
+ // Check module script
461
+ if (ast.module?.content && has_identifier_in_tree(ast.module.content, name)) continue;
462
+
463
+ // Check template — skip transformed component nodes whose attribute expressions
464
+ // still contain the old identifier references in the AST
465
+ if (has_identifier_in_tree(ast.fragment, name, id_skip)) continue;
466
+
467
+ remove_variable_declaration(s, declaration_node, source);
468
+ // Add to skip set so chained dead const checks don't find references to this removed node
469
+ skip.add(declaration_node);
470
+ }
471
+ };
472
+
473
+ /**
474
+ * Builds the replacement string for a transformed Mdz component.
475
+ *
476
+ * Reconstructs the opening tag as `<MdzPrecompiled` with all attributes except `content`,
477
+ * using source text slicing to preserve exact formatting and dynamic expressions.
478
+ */
479
+ const build_replacement = (
480
+ node: AST.Component,
481
+ content_attr: AST.Attribute,
482
+ children_markup: string,
483
+ source: string,
484
+ ): string => {
485
+ // Collect source ranges of all attributes EXCEPT content
486
+ const other_attr_ranges: Array<{start: number; end: number}> = [];
487
+ for (const attr of node.attributes) {
488
+ if (attr === content_attr) continue;
489
+ other_attr_ranges.push({start: attr.start, end: attr.end});
490
+ }
491
+
492
+ // Build opening tag with MdzPrecompiled name
493
+ let opening = `<${PRECOMPILED_NAME}`;
494
+ for (const range of other_attr_ranges) {
495
+ opening += ' ' + source.slice(range.start, range.end);
496
+ }
497
+ opening += '>';
498
+
499
+ return `${opening}${children_markup}</${PRECOMPILED_NAME}>`;
500
+ };
501
+
502
+ /** Result of import removability analysis for a single Mdz name. */
503
+ interface ImportRemovalAction {
504
+ /** The import declaration node. */
505
+ node: PositionedImportDeclaration;
506
+ /** 'full' removes the entire declaration; 'partial' removes only the Mdz specifier. */
507
+ kind: 'full' | 'partial';
508
+ /** For partial removal: the specifier to remove. */
509
+ specifier_to_remove?: ImportDeclaration['specifiers'][number];
510
+ }
511
+
512
+ /**
513
+ * Determines which Mdz import declarations can be safely removed or trimmed.
514
+ *
515
+ * An import is removable when:
516
+ * 1. All template usages of that name were successfully transformed.
517
+ * 2. The identifier is not referenced elsewhere in script or template expressions.
518
+ *
519
+ * For multi-specifier imports, only the Mdz specifier is removed (partial removal).
520
+ * For single-specifier imports, the entire declaration is removed.
521
+ */
522
+ const find_removable_mdz_imports = (
523
+ ast: AST.Root,
524
+ mdz_names: Map<string, ResolvedComponentImport>,
525
+ total_usages: Map<string, number>,
526
+ transformed_usages: Map<string, number>,
527
+ ): Map<string, ImportRemovalAction> => {
528
+ const removable: Map<string, ImportRemovalAction> = new Map();
529
+
530
+ for (const [name, {import_node, specifier}] of mdz_names) {
531
+ const total = total_usages.get(name) ?? 0;
532
+ const transformed = transformed_usages.get(name) ?? 0;
533
+
534
+ // Only remove if ALL template usages were transformed
535
+ if (total === 0 || transformed < total) continue;
536
+
537
+ // Check if identifier is referenced elsewhere in the AST
538
+ const skip: Set<unknown> = new Set([import_node]);
539
+
540
+ // Check instance script body (excluding the import itself)
541
+ if (ast.instance?.content && has_identifier_in_tree(ast.instance.content, name, skip)) {
542
+ continue;
543
+ }
544
+
545
+ // Check module script body (excluding the import itself)
546
+ if (ast.module?.content && has_identifier_in_tree(ast.module.content, name, skip)) {
547
+ continue;
548
+ }
549
+
550
+ // Check template for expression references (Component.name is a string, not Identifier)
551
+ if (has_identifier_in_tree(ast.fragment, name)) {
552
+ continue;
553
+ }
554
+
555
+ const positioned_node = import_node as PositionedImportDeclaration;
556
+ if (import_node.specifiers.length === 1) {
557
+ removable.set(name, {node: positioned_node, kind: 'full'});
558
+ } else {
559
+ removable.set(name, {
560
+ node: positioned_node,
561
+ kind: 'partial',
562
+ specifier_to_remove: specifier,
563
+ });
564
+ }
565
+ }
566
+
567
+ return removable;
568
+ };
569
+
570
+ /**
571
+ * Manages import additions and removals.
572
+ *
573
+ * Adds the `MdzPrecompiled` import and other required imports (DocsLink, Code, resolve).
574
+ * Removes Mdz import declarations that are no longer referenced.
575
+ *
576
+ * Handles both full removal (single-specifier imports) and partial removal
577
+ * (multi-specifier imports where only the Mdz specifier is removed).
578
+ *
579
+ * To avoid MagicString boundary conflicts when the insertion position falls inside
580
+ * a removal range, one removable Mdz import is overwritten with the MdzPrecompiled
581
+ * import line instead of using separate remove + appendLeft.
582
+ */
583
+ const manage_imports = (
584
+ s: MagicString,
585
+ ast: AST.Root,
586
+ transformations: Array<MdzTransformation>,
587
+ removable_imports: Map<string, ImportRemovalAction>,
588
+ compiled_component_import: string,
589
+ source: string,
590
+ ): void => {
591
+ // Collect all required imports across transformations
592
+ const required: Map<string, PreprocessImportInfo> = new Map();
593
+ for (const t of transformations) {
594
+ for (const [name, info] of t.required_imports) {
595
+ required.set(name, info);
596
+ }
597
+ }
598
+
599
+ // Always need MdzPrecompiled when transformations occur
600
+ required.set(PRECOMPILED_NAME, {path: compiled_component_import, kind: 'default'});
601
+
602
+ const script = ast.instance;
603
+
604
+ // Separate full removals from partial removals
605
+ const full_removals: Set<PositionedImportDeclaration> = new Set();
606
+ const partial_removals: Array<ImportRemovalAction> = [];
607
+ for (const [, action] of removable_imports) {
608
+ if (action.kind === 'full') {
609
+ full_removals.add(action.node);
610
+ } else {
611
+ partial_removals.push(action);
612
+ }
613
+ }
614
+
615
+ if (!script) {
616
+ // No instance script — removable_imports won't apply (imports are in module script if any)
617
+ // Just add all required imports
618
+ const lines = generate_import_lines(required);
619
+ if (ast.module) {
620
+ s.appendLeft(ast.module.end, `\n\n<script lang="ts">\n${lines}\n</script>`);
621
+ } else {
622
+ s.prepend(`<script lang="ts">\n${lines}\n</script>\n\n`);
623
+ }
624
+ // Remove Mdz imports from module script if removable
625
+ for (const node of full_removals) {
626
+ remove_import_declaration(s, node, source);
627
+ }
628
+ // Apply partial removals
629
+ for (const action of partial_removals) {
630
+ remove_import_specifier(s, action.node, action.specifier_to_remove!, source);
631
+ }
632
+ return;
633
+ }
634
+
635
+ // Check existing imports to avoid duplicates — tracks both name AND source path
636
+ const existing: Map<string, string> = new Map();
637
+ for (const node of script.content.body) {
638
+ if (node.type === 'ImportDeclaration') {
639
+ const source_path = node.source.value as string;
640
+ for (const spec of node.specifiers) {
641
+ existing.set(spec.local.name, source_path);
642
+ }
643
+ }
644
+ }
645
+
646
+ const to_add: Map<string, PreprocessImportInfo> = new Map();
647
+ for (const [name, info] of required) {
648
+ const existing_path = existing.get(name);
649
+ if (existing_path === undefined) {
650
+ to_add.set(name, info);
651
+ }
652
+ }
653
+
654
+ // Strategy: if we're both adding MdzPrecompiled and removing an Mdz import,
655
+ // overwrite one full-removable import with the MdzPrecompiled line to avoid
656
+ // MagicString boundary conflicts. Other imports use normal appendLeft.
657
+ let overwrite_target: PositionedImportDeclaration | null = null;
658
+ if (to_add.has(PRECOMPILED_NAME) && full_removals.size > 0) {
659
+ // Pick the first removable import to overwrite
660
+ overwrite_target = full_removals.values().next().value ?? null;
661
+ }
662
+
663
+ // Generate the MdzPrecompiled import line separately if using overwrite
664
+ if (overwrite_target) {
665
+ const node_start = overwrite_target.start;
666
+ const node_end = overwrite_target.end;
667
+
668
+ // Find the start of the line (consume leading whitespace)
669
+ let line_start = node_start;
670
+ while (line_start > 0 && (source[line_start - 1] === '\t' || source[line_start - 1] === ' ')) {
671
+ line_start--;
672
+ }
673
+
674
+ // Extract indentation from the original import line rather than hardcoding a tab.
675
+ // The import node's start is after any leading whitespace, so the indentation is
676
+ // the characters between the line start and the node start.
677
+ const original_indent = source.slice(line_start, node_start);
678
+ const precompiled_line = `${original_indent}import ${PRECOMPILED_NAME} from '${compiled_component_import}';`;
679
+
680
+ s.overwrite(line_start, node_end, precompiled_line);
681
+ to_add.delete(PRECOMPILED_NAME);
682
+ }
683
+
684
+ // When there are partial removals but no full-removal overwrite target,
685
+ // the appendLeft at find_import_insert_position can conflict with the
686
+ // partial removal's overwrite (they share the same node.end boundary).
687
+ // Bundle the new imports into the first partial removal's overwrite instead.
688
+ const insert_pos = find_import_insert_position(script);
689
+ let partial_carrier: ImportRemovalAction | null = null;
690
+ if (to_add.size > 0 && partial_removals.length > 0) {
691
+ for (const action of partial_removals) {
692
+ if (action.node.end === insert_pos) {
693
+ partial_carrier = action;
694
+ break;
695
+ }
696
+ }
697
+ }
698
+
699
+ // Add remaining imports — either bundled with a partial removal or via appendLeft
700
+ let carrier_lines = '';
701
+ if (partial_carrier && to_add.size > 0) {
702
+ carrier_lines = '\n' + generate_import_lines(to_add);
703
+ to_add.clear();
704
+ }
705
+ if (to_add.size > 0) {
706
+ const lines = generate_import_lines(to_add);
707
+ s.appendLeft(insert_pos, '\n' + lines);
708
+ }
709
+
710
+ // Remove remaining full Mdz imports (skip the overwrite target)
711
+ for (const node of full_removals) {
712
+ if (node === overwrite_target) continue;
713
+ remove_import_declaration(s, node, source);
714
+ }
715
+
716
+ // Apply partial import removals
717
+ for (const action of partial_removals) {
718
+ remove_import_specifier(
719
+ s,
720
+ action.node,
721
+ action.specifier_to_remove!,
722
+ source,
723
+ action === partial_carrier ? carrier_lines : '',
724
+ );
725
+ }
726
+ };