@barefootjs/jsx 0.5.2 → 0.6.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.
Files changed (53) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +1 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/combine-client-js.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.js +330 -70
  7. package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  11. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/html-template.d.ts +30 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/imports.d.ts +2 -2
  17. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  20. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/types.d.ts +36 -4
  22. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/utils.d.ts +19 -1
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  27. package/src/__tests__/child-components-in-map.test.ts +333 -0
  28. package/src/__tests__/combine-client-js.test.ts +47 -0
  29. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  30. package/src/__tests__/expression-parser.test.ts +167 -13
  31. package/src/__tests__/ir-to-client-js/reactivity.test.ts +1 -0
  32. package/src/__tests__/staged-ir/06-multi-stage-soak.test.ts +18 -3
  33. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  34. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  35. package/src/adapters/parsed-expr-emitter.ts +7 -0
  36. package/src/combine-client-js.ts +66 -22
  37. package/src/expression-parser.ts +200 -17
  38. package/src/ir-to-client-js/collect-elements.ts +170 -32
  39. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  41. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  43. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  44. package/src/ir-to-client-js/emit-registration.ts +1 -1
  45. package/src/ir-to-client-js/generate-init.ts +16 -1
  46. package/src/ir-to-client-js/html-template.ts +238 -12
  47. package/src/ir-to-client-js/imports.ts +1 -1
  48. package/src/ir-to-client-js/index.ts +1 -0
  49. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +12 -1
  50. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  51. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  52. package/src/ir-to-client-js/types.ts +37 -4
  53. package/src/ir-to-client-js/utils.ts +41 -1
@@ -398,25 +398,104 @@ describe('expression-parser', () => {
398
398
  const result = parseExpression("items().filter(({name = 'anon'}) => name.startsWith('a'))")
399
399
  expect(result.kind).toBe('higher-order')
400
400
  if (result.kind === 'higher-order') {
401
- // Predicate: `(_t.name ?? 'anon').startsWith('a')`.
402
- expect(result.predicate.kind).toBe('call')
403
- if (result.predicate.kind === 'call') {
404
- expect(result.predicate.callee.kind).toBe('member')
405
- if (result.predicate.callee.kind === 'member') {
406
- expect(result.predicate.callee.property).toBe('startsWith')
407
- expect(result.predicate.callee.object.kind).toBe('logical')
408
- if (result.predicate.callee.object.kind === 'logical') {
409
- expect(result.predicate.callee.object.op).toBe('??')
410
- expect(result.predicate.callee.object.right.kind).toBe('literal')
411
- if (result.predicate.callee.object.right.kind === 'literal') {
412
- expect(result.predicate.callee.object.right.value).toBe('anon')
413
- }
401
+ // Predicate: `(_t.name ?? 'anon').startsWith('a')`. Since #1448
402
+ // Tier B, `.startsWith` lowers to an `array-method` node (it was
403
+ // a generic `call` before), so the receiver is on `.object`.
404
+ expect(result.predicate.kind).toBe('array-method')
405
+ if (result.predicate.kind === 'array-method') {
406
+ expect(result.predicate.method).toBe('startsWith')
407
+ expect(result.predicate.object.kind).toBe('logical')
408
+ if (result.predicate.object.kind === 'logical') {
409
+ expect(result.predicate.object.op).toBe('??')
410
+ expect(result.predicate.object.right.kind).toBe('literal')
411
+ if (result.predicate.object.right.kind === 'literal') {
412
+ expect(result.predicate.object.right.value).toBe('anon')
414
413
  }
415
414
  }
416
415
  }
417
416
  }
418
417
  })
419
418
 
419
+ test('lowers .startsWith(search, position) — full arity (#1448 Tier B)', () => {
420
+ const result = parseExpression(`name().startsWith("world", 6)`)
421
+ expect(result.kind).toBe('array-method')
422
+ if (result.kind === 'array-method') {
423
+ expect(result.method).toBe('startsWith')
424
+ // Both the search string and the position survive as args so the
425
+ // adapter can emit the re-anchored test.
426
+ expect(result.args.length).toBe(2)
427
+ }
428
+ })
429
+
430
+ test('refuses .startsWith() with no search string (#1448 Tier B)', () => {
431
+ // JS coerces the missing argument to the string "undefined" — a
432
+ // degenerate result not worth lowering (mirrors `.includes()`).
433
+ const result = parseExpression(`name().startsWith()`)
434
+ expect(result.kind).toBe('unsupported')
435
+ })
436
+
437
+ test('lowers .replace(pattern, replacement, extra) — ignores 3rd+ arg (#1448 Tier B)', () => {
438
+ const result = parseExpression(`name().replace("o", "0", "extra")`)
439
+ expect(result.kind).toBe('array-method')
440
+ if (result.kind === 'array-method') {
441
+ expect(result.method).toBe('replace')
442
+ }
443
+ })
444
+
445
+ test('refuses .replace(pattern) with no replacement (#1448 Tier B)', () => {
446
+ // JS coerces the missing replacement to the string "undefined".
447
+ const result = parseExpression(`name().replace("o")`)
448
+ expect(result.kind).toBe('unsupported')
449
+ })
450
+
451
+ test('refuses .replace() with no arguments (#1448 Tier B)', () => {
452
+ // The zero-arg form is degenerate too (both pattern and
453
+ // replacement coerce to "undefined") — pin it alongside the
454
+ // one-arg case so neither regresses.
455
+ const result = parseExpression(`name().replace()`)
456
+ expect(result.kind).toBe('unsupported')
457
+ })
458
+
459
+ test('.replace with a non-regex unsupported arg surfaces its own reason (#1448 Tier B)', () => {
460
+ // An object-literal replacement must NOT be mislabelled as the
461
+ // deferred regex form — the guard only special-cases a
462
+ // regex-literal pattern.
463
+ const result = parseExpression(`name().replace("a", {x: 1})`)
464
+ expect(result.kind).toBe('unsupported')
465
+ if (result.kind === 'unsupported') {
466
+ expect(result.reason).not.toContain('regex form is deferred')
467
+ }
468
+ })
469
+
470
+ test('.replace(/re/, repl) refuses the regex form with the deferred reason (#1448 Tier B)', () => {
471
+ const result = parseExpression(`name().replace(/a/g, "b")`)
472
+ expect(result.kind).toBe('unsupported')
473
+ if (result.kind === 'unsupported') {
474
+ expect(result.reason).toContain('regex form is deferred')
475
+ }
476
+ })
477
+
478
+ test('lowers .repeat() and .repeat(n, extra) — full arity (#1448 Tier B)', () => {
479
+ // `.repeat()` is `repeat(0)` → "" in JS (not a RangeError); a
480
+ // second+ argument is ignored. Both stay on the lowering path.
481
+ for (const expr of [`name().repeat()`, `name().repeat(3, 4)`]) {
482
+ const result = parseExpression(expr)
483
+ expect(result.kind).toBe('array-method')
484
+ if (result.kind === 'array-method') {
485
+ expect(result.method).toBe('repeat')
486
+ }
487
+ }
488
+ })
489
+
490
+ test('lowers .padStart() / .padEnd(t, p, extra) — full arity (#1448 Tier B)', () => {
491
+ // `.padStart()` is `padStart(0)` → the receiver unchanged; a
492
+ // third+ argument is ignored. Both stay on the lowering path.
493
+ for (const expr of [`name().padStart()`, `name().padEnd(5, "0", "x")`]) {
494
+ const result = parseExpression(expr)
495
+ expect(result.kind).toBe('array-method')
496
+ }
497
+ })
498
+
420
499
  test('lowers .filter(({label = `untitled-${suffix}`}) => label) — template-literal default (#1531)', () => {
421
500
  const result = parseExpression('items().filter(({label = `untitled-${suffix}`}) => label)')
422
501
  expect(result.kind).toBe('higher-order')
@@ -1358,3 +1437,78 @@ describe('expression-parser', () => {
1358
1437
  })
1359
1438
  })
1360
1439
  })
1440
+
1441
+ // =============================================================================
1442
+ // Full-arity lowering for the array / string methods (#1448)
1443
+ // =============================================================================
1444
+ //
1445
+ // These methods lower at their full JS arity: zero-arg defaults
1446
+ // (`.join()` → `,`, `.slice()` → full copy) and JS-ignored trailing
1447
+ // arguments (`.trim(1)`, `.at(i, extra)`, `.slice(s, e, extra)`) are all
1448
+ // accepted. The only forms still refused are the ones whose EXTRA
1449
+ // argument changes the result and isn't lowered yet — the `fromIndex` of
1450
+ // `.includes`/`.indexOf`/`.lastIndexOf` and the variadic `.concat(a, b)`
1451
+ // — because silently dropping those would make SSR differ from the
1452
+ // client (worse than a build error).
1453
+ describe('expression-parser — array-method full-arity lowering (#1448)', () => {
1454
+ const supported: Array<[string, string]> = [
1455
+ // base forms
1456
+ ['join(sep)', 'arr.join("-")'],
1457
+ ['includes(x)', 'arr.includes(x)'],
1458
+ ['indexOf(x)', 'arr.indexOf(x)'],
1459
+ ['at(i)', 'arr.at(1)'],
1460
+ ['concat(other)', 'arr.concat(b)'],
1461
+ ['slice(start)', 'arr.slice(1)'],
1462
+ ['slice(start, end)', 'arr.slice(1, 2)'],
1463
+ ['trim()', 's.trim()'],
1464
+ // zero-arg defaults (every one of these escaping both its arm AND
1465
+ // the guard is the footgun the relaxation must NOT reintroduce)
1466
+ ['join() → default ","', 'arr.join()'],
1467
+ ['slice() → full copy', 'arr.slice()'],
1468
+ ['at() → at(0)', 'arr.at()'],
1469
+ ['concat() → shallow copy', 'arr.concat()'],
1470
+ ['reverse() base', 'arr.reverse()'],
1471
+ ['toReversed() base', 'arr.toReversed()'],
1472
+ ['toLowerCase() base', 's.toLowerCase()'],
1473
+ ['toUpperCase() base', 's.toUpperCase()'],
1474
+ ['lastIndexOf(x) base', 'arr.lastIndexOf(x)'],
1475
+ // JS-ignored trailing arguments
1476
+ ['slice(s, e, extra)', 'arr.slice(1, 2, 3)'],
1477
+ ['at(i, extra)', 'arr.at(1, 2)'],
1478
+ ['reverse(extra)', 'arr.reverse(1)'],
1479
+ ['toReversed(extra)', 'arr.toReversed(1)'],
1480
+ ['toLowerCase(extra)', 's.toLowerCase("x")'],
1481
+ ['toUpperCase(extra)', 's.toUpperCase("x")'],
1482
+ ['trim(extra)', 's.trim(1)'],
1483
+ ]
1484
+ for (const [label, expr] of supported) {
1485
+ test(`${label} — lowers to array-method`, () => {
1486
+ expect(parseExpression(expr).kind).toBe('array-method')
1487
+ })
1488
+ }
1489
+
1490
+ // Still refused (the extra argument is meaningful and not yet
1491
+ // lowered), plus the degenerate zero-arg search forms (`includes()`
1492
+ // searches for `undefined` — refused rather than guessed).
1493
+ const refused: Array<[string, string]> = [
1494
+ ['includes(x, fromIndex)', 'arr.includes(x, 1)'],
1495
+ ['indexOf(x, fromIndex)', 'arr.indexOf(x, 1)'],
1496
+ ['lastIndexOf(x, fromIndex)', 'arr.lastIndexOf(x, 1)'],
1497
+ ['concat(a, b) variadic', 'arr.concat(a, b)'],
1498
+ ['includes() zero-arg', 'arr.includes()'],
1499
+ ['indexOf() zero-arg', 'arr.indexOf()'],
1500
+ ]
1501
+ for (const [label, expr] of refused) {
1502
+ test(`${label} — refuses (meaningful extra arg, not yet lowered)`, () => {
1503
+ const result = parseExpression(expr)
1504
+ expect(result.kind).toBe('unsupported')
1505
+ if (result.kind === 'unsupported') {
1506
+ // Must NOT push `@client` (wrong remedy; doesn't work in
1507
+ // attribute / condition position), and must explain it's a
1508
+ // not-yet-lowered argument.
1509
+ expect(result.reason).not.toContain('@client')
1510
+ expect(result.reason).toContain('not yet lowered')
1511
+ }
1512
+ })
1513
+ }
1514
+ })
@@ -23,6 +23,7 @@ function makeContext(overrides: Partial<ClientJsContext> = {}): ClientJsContext
23
23
  loopElements: [],
24
24
  refElements: [],
25
25
  childInits: [],
26
+ deferredChildSlots: new Set(),
26
27
  reactiveProps: [],
27
28
  reactiveChildProps: [],
28
29
  reactiveAttrs: [],
@@ -80,9 +80,24 @@ describe('Multi-stage soak (DeskCanvas-shape)', () => {
80
80
  expect(clientJs).toMatch(/import\s+\{[^}]*useYjs[^}]*\}\s+from\s+['"]\.\/useYjs['"]/)
81
81
  })
82
82
 
83
- test('Module → Template: nodeTypes import IS visible to template', () => {
84
- const { templateBody } = compile(DESK_CANVAS_SHAPE, 'DeskCanvas.tsx')
85
- expect(templateBody).toMatch(/nodeTypes/)
83
+ test('Module → Init: nodeTypes is forwarded to the deferred child via init', () => {
84
+ // The `<Flow>` render carries init-scope-only props (`data-yjs={yjs.id}`
85
+ // where `yjs` is an init-local; `nodes={items()}`). The module-scope
86
+ // template lambda can't supply those, so the child render is DEFERRED
87
+ // to a `data-bf-ph` placeholder and `upsertChild` (→ createComponent)
88
+ // creates `Flow` with the COMPLETE getter props. The inlinable
89
+ // `nodeTypes` is therefore forwarded through the init `upsertChild`
90
+ // getter — never dropped — rather than baked into the template lambda.
91
+ // (Pre-fix this child was rendered eagerly via `renderChild('Flow', {
92
+ // nodeTypes })` with the init-scope props silently dropped — the
93
+ // dropped-prop bug.)
94
+ const { templateBody, initBody } = compile(DESK_CANVAS_SHAPE, 'DeskCanvas.tsx')
95
+ // Template defers the child render with a placeholder, not renderChild.
96
+ expect(templateBody).toMatch(/data-bf-ph="s0"/)
97
+ expect(templateBody).not.toMatch(/renderChild/)
98
+ // Init forwards nodeTypes (and every other prop) with complete values.
99
+ expect(initBody).toMatch(/upsertChild\(__scope,\s*'Flow',\s*'s0'/)
100
+ expect(initBody).toMatch(/get nodeTypes\(\)\s*\{\s*return nodeTypes\s*\}/)
86
101
  })
87
102
 
88
103
  // TODO(#1138 P3 5/N): `useYjs(...)` (init-local initializer) leaks into
@@ -203,8 +203,9 @@ describe('#1247 — static-loop CSR self-heal', () => {
203
203
  // the inner `it` param).
204
204
  expect(clientJs).toMatch(/items\.map\(\(it, i\) =>/)
205
205
  // The inner per-item HTML references the inner destructured param
206
- // directly (`${it}` in the template), not via a `__bfItem` accessor.
207
- expect(clientJs).toMatch(/\$\{it\}/)
206
+ // directly (`${escapeText(it)}` in the template text slots are
207
+ // HTML-escaped, #1694), not via a `__bfItem` accessor.
208
+ expect(clientJs).toMatch(/\$\{escapeText\(it\)\}/)
208
209
  expect(clientJs).not.toMatch(/__bfItem\(\)/)
209
210
  })
210
211
 
@@ -335,7 +336,8 @@ describe('#1247 — static-loop CSR self-heal', () => {
335
336
  // Preamble is NOT duplicated inside the materialize branch.
336
337
  expect(materializeBlock).not.toMatch(/const count = users\.length/)
337
338
  // The cloned template still references `count` — now resolved by the
338
- // forEach-scope `const` introduced just above.
339
- expect(materializeBlock).toMatch(/\$\{String\(count\)\}/)
339
+ // forEach-scope `const` introduced just above. Text slots are
340
+ // HTML-escaped (#1694), so it appears as `${escapeText(String(count))}`.
341
+ expect(materializeBlock).toMatch(/\$\{escapeText\(String\(count\)\)\}/)
340
342
  })
341
343
  })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Text-slot HTML-escaping emit shape (#1694 + follow-up).
3
+ *
4
+ * Pins which interpolations the client template wraps in `escapeText`:
5
+ * - a plain text slot (`{stringValue}`) IS escaped — it becomes the
6
+ * slot's text content under `innerHTML`;
7
+ * - a branch-slot expression (Child-position value inside a conditional
8
+ * `template()` arrow) is routed through `__bfSlot` and must NOT be
9
+ * wrapped in `escapeText`. `__bfSlot` returns raw `<!--bf-slot:N-->`
10
+ * markers for live nodes; escaping the whole call corrupts them and
11
+ * drops slotted content (the regression that broke `e2e-site-ui`).
12
+ * `__bfSlot` escapes its own plain-string path internally instead.
13
+ */
14
+
15
+ import { describe, test, expect } from 'bun:test'
16
+ import { compileJSX } from '../compiler'
17
+ import { TestAdapter } from '../adapters/test-adapter'
18
+
19
+ const adapter = new TestAdapter()
20
+
21
+ function getClientJs(source: string, filename: string): string {
22
+ const result = compileJSX(source, filename, { adapter })
23
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
24
+ const clientJs = result.files.find(f => f.type === 'clientJs')
25
+ expect(clientJs).toBeDefined()
26
+ return clientJs!.content
27
+ }
28
+
29
+ describe('text-slot escaping', () => {
30
+ test('a plain text slot is wrapped in escapeText', () => {
31
+ const clientJs = getClientJs(
32
+ `'use client'
33
+ export function Label({ text }: { text: string }) {
34
+ return <span>{text}</span>
35
+ }`,
36
+ 'Label.tsx',
37
+ )
38
+ expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeText\(_p\.text\)\}<!--\/-->/)
39
+ })
40
+
41
+ test('a branch-slot expression is NOT wrapped in escapeText', () => {
42
+ const clientJs = getClientJs(
43
+ `'use client'
44
+ import { createSignal } from '@barefootjs/client'
45
+ export function Branch({ show }: { show: boolean }) {
46
+ const [t] = createSignal('hi')
47
+ return <div>{show ? <span>{t()}</span> : null}</div>
48
+ }`,
49
+ 'Branch.tsx',
50
+ )
51
+ // The branch value goes through __bfSlot (raw markers preserved)…
52
+ expect(clientJs).toMatch(/\$\{__bfSlot\(/)
53
+ // …and must never be double-wrapped by the text escape.
54
+ expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
55
+ })
56
+ })
@@ -66,6 +66,13 @@ export type ArrayMethod =
66
66
  | 'toLowerCase'
67
67
  | 'toUpperCase'
68
68
  | 'trim'
69
+ | 'split'
70
+ | 'startsWith'
71
+ | 'endsWith'
72
+ | 'replace'
73
+ | 'repeat'
74
+ | 'padStart'
75
+ | 'padEnd'
69
76
 
70
77
  /**
71
78
  * Method names handled by the dedicated `sortMethod()` dispatcher
@@ -8,6 +8,8 @@
8
8
  * into the parent's file, eliminating the need for separate HTTP requests.
9
9
  */
10
10
 
11
+ import ts from 'typescript'
12
+
11
13
  const CHILD_PLACEHOLDER_RE = /import '\/\* @bf-child:(\w+) \*\/'/g
12
14
 
13
15
  /**
@@ -101,33 +103,75 @@ function parseAndMerge(
101
103
  otherImports: string[],
102
104
  codeSections: string[]
103
105
  ): void {
104
- const codeLines: string[] = []
105
-
106
- for (const line of content.split('\n')) {
107
- if (line.startsWith('import ')) {
108
- if (line.includes('@bf-child:')) continue
109
-
110
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]/)
111
- if (match) {
112
- const names = match[1].split(',').map(n => n.trim())
113
- const source = match[2]
114
- if (!importsBySource.has(source)) {
115
- importsBySource.set(source, new Set())
116
- }
117
- for (const name of names) {
118
- importsBySource.get(source)!.add(name)
119
- }
120
- } else {
121
- if (!otherImports.includes(line)) {
122
- otherImports.push(line)
123
- }
106
+ // Parse the client JS so we only ever treat *real* top-level
107
+ // `ImportDeclaration` statements as imports. The predecessor matched
108
+ // raw lines beginning with `import `, which also caught `import …`
109
+ // lines that merely live *inside a string / template literal value*
110
+ // (e.g. a data module exporting a code snippet). Tearing such a line
111
+ // out of its string relocated the component's real runtime import into
112
+ // the literal and left `hydrate` undefined at call time. See
113
+ // piconic-ai/barefootjs#1702.
114
+ // Parent pointers aren't needed here — we only read `statements` and each
115
+ // import's `getStart`/`getEnd` — so skip building them to keep the per-chunk
116
+ // parse cheap when combining many files.
117
+ const sourceFile = ts.createSourceFile(
118
+ 'combine.js',
119
+ content,
120
+ ts.ScriptTarget.Latest,
121
+ /*setParentNodes*/ false,
122
+ ts.ScriptKind.JS,
123
+ )
124
+
125
+ // Character spans of the top-level imports to strip from the emitted
126
+ // code, so everything that isn't an import (including literals whose
127
+ // contents look like imports) is preserved verbatim.
128
+ const importSpans: Array<[number, number]> = []
129
+
130
+ for (const stmt of sourceFile.statements) {
131
+ if (!ts.isImportDeclaration(stmt)) continue
132
+ const start = stmt.getStart(sourceFile)
133
+ const end = stmt.getEnd()
134
+ importSpans.push([start, end])
135
+
136
+ const stmtText = content.slice(start, end)
137
+ // `@bf-child:` placeholders are resolved by inlining elsewhere; drop
138
+ // them entirely (neither merged nor kept as code).
139
+ if (stmtText.includes('@bf-child:')) continue
140
+
141
+ const clause = stmt.importClause
142
+ const bindings = clause?.namedBindings
143
+ const specifier = ts.isStringLiteral(stmt.moduleSpecifier)
144
+ ? stmt.moduleSpecifier.text
145
+ : ''
146
+ if (clause && !clause.name && bindings && ts.isNamedImports(bindings)) {
147
+ // Pure named import (`import { a, b as c } from '…'`) — merge by source.
148
+ if (!importsBySource.has(specifier)) {
149
+ importsBySource.set(specifier, new Set())
150
+ }
151
+ const set = importsBySource.get(specifier)!
152
+ for (const el of bindings.elements) {
153
+ const name = el.propertyName
154
+ ? `${el.propertyName.text} as ${el.name.text}`
155
+ : el.name.text
156
+ set.add(name)
124
157
  }
125
158
  } else {
126
- codeLines.push(line)
159
+ // default / namespace / side-effect import — keep verbatim.
160
+ if (!otherImports.includes(stmtText)) {
161
+ otherImports.push(stmtText)
162
+ }
127
163
  }
128
164
  }
129
165
 
130
- const code = codeLines.join('\n').trim()
166
+ // Reconstruct the code with the import spans removed.
167
+ let code = ''
168
+ let cursor = 0
169
+ for (const [start, end] of importSpans) {
170
+ code += content.slice(cursor, start)
171
+ cursor = end
172
+ }
173
+ code += content.slice(cursor)
174
+ code = code.trim()
131
175
  if (code) {
132
176
  codeSections.push(code)
133
177
  }