@barefootjs/jsx 0.1.3 → 1.0.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 (37) hide show
  1. package/dist/debug.d.ts +66 -1
  2. package/dist/debug.d.ts.map +1 -1
  3. package/dist/html-constants.d.ts +4 -9
  4. package/dist/html-constants.d.ts.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8628 -8071
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/html-template.d.ts +15 -0
  11. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/types.d.ts +5 -0
  13. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/utils.d.ts +2 -8
  15. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  16. package/dist/prop-rewrite.d.ts.map +1 -1
  17. package/dist/types.d.ts +20 -0
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +3 -3
  20. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +135 -0
  21. package/src/__tests__/boolean-attributes.test.ts +2 -1
  22. package/src/__tests__/conditional-branch-reactive-text.test.ts +108 -0
  23. package/src/__tests__/debug.test.ts +422 -9
  24. package/src/__tests__/doc-examples.test.ts +7 -0
  25. package/src/__tests__/ir-provider.test.ts +98 -0
  26. package/src/__tests__/rewrite-destructured-props.test.ts +73 -0
  27. package/src/debug.ts +637 -32
  28. package/src/html-constants.ts +4 -27
  29. package/src/index.ts +6 -1
  30. package/src/ir-to-client-js/collect-elements.ts +3 -0
  31. package/src/ir-to-client-js/emit-reactive.ts +5 -5
  32. package/src/ir-to-client-js/html-template.ts +97 -11
  33. package/src/ir-to-client-js/types.ts +6 -0
  34. package/src/ir-to-client-js/utils.ts +4 -65
  35. package/src/jsx-to-ir.ts +92 -17
  36. package/src/prop-rewrite.ts +6 -2
  37. package/src/types.ts +21 -0
@@ -1,29 +1,6 @@
1
1
  /**
2
- * HTML boolean attributes that should be rendered without values.
3
- *
4
- * When true: render as attribute name only (e.g., `checked`)
5
- * When false/null/undefined: omit from output entirely
2
+ * HTML boolean attributes re-exported from the shared classifier so the
3
+ * compiler keeps its existing import path while the source of truth lives
4
+ * in @barefootjs/shared/dom-prop.
6
5
  */
7
- export const BOOLEAN_ATTRS = new Set([
8
- 'checked',
9
- 'disabled',
10
- 'readonly',
11
- 'selected',
12
- 'required',
13
- 'hidden',
14
- 'autofocus',
15
- 'autoplay',
16
- 'controls',
17
- 'loop',
18
- 'muted',
19
- 'open',
20
- 'multiple',
21
- 'novalidate',
22
- ])
23
-
24
- /**
25
- * Check if an attribute name is a boolean attribute.
26
- */
27
- export function isBooleanAttr(name: string): boolean {
28
- return BOOLEAN_ATTRS.has(name.toLowerCase())
29
- }
6
+ export { BOOLEAN_ATTRS, isBooleanAttr } from '@barefootjs/shared'
package/src/index.ts CHANGED
@@ -246,15 +246,20 @@ export type { LoopChainInputs } from './loop-chain'
246
246
  // Debug analysis
247
247
  export {
248
248
  buildComponentGraph,
249
+ buildComponentAnalysis,
249
250
  buildGraphFromIR,
251
+ buildEventSummary,
252
+ buildLoopSummary,
250
253
  traceUpdatePath,
251
254
  formatComponentGraph,
252
255
  formatUpdatePath,
256
+ formatEventSummary,
257
+ formatLoopSummary,
253
258
  formatSignalTrace,
254
259
  generateStaticTrace,
255
260
  graphToJSON,
256
261
  } from './debug'
257
- export type { ComponentGraph, SignalNode, MemoNode, EffectNode, DomBinding, UpdatePath, SignalTrace } from './debug'
262
+ export type { ComponentGraph, ComponentAnalysis, SignalNode, MemoNode, EffectNode, DomBinding, UpdatePath, SignalTrace, EventBinding, SetterRef, EventSummary, LoopInfo, LoopChildBinding, LoopSummary } from './debug'
258
263
  export type { WrapReason } from './ir-to-client-js/reactivity'
259
264
 
260
265
  // HTML constants
@@ -231,6 +231,7 @@ export function collectInnerLoops(
231
231
  key: n.key,
232
232
  markerId: n.markerId,
233
233
  bodyIsMultiRoot: n.bodyIsMultiRoot,
234
+ iterationShape: n.iterationShape,
234
235
  containerSlotId: scope.parentSlotId,
235
236
  template,
236
237
  mapPreamble: n.mapPreamble,
@@ -563,6 +564,7 @@ export function collectElements(
563
564
  key: l.key,
564
565
  markerId: l.markerId,
565
566
  bodyIsMultiRoot: l.bodyIsMultiRoot,
567
+ iterationShape: l.iterationShape,
566
568
  template,
567
569
  staticItemTemplate,
568
570
  childEventHandlers: childHandlers,
@@ -896,6 +898,7 @@ function collectBranchLoops(
896
898
  key: n.key,
897
899
  markerId: n.markerId,
898
900
  bodyIsMultiRoot: n.bodyIsMultiRoot,
901
+ iterationShape: n.iterationShape,
899
902
  template: childTemplate,
900
903
  containerSlotId: containerSlot,
901
904
  mapPreamble: n.mapPreamble ?? null,
@@ -8,6 +8,7 @@ import type { AttrMeta } from '../types'
8
8
  import { isBooleanAttr } from '../html-constants'
9
9
  import type { ClientJsContext } from './types'
10
10
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils'
11
+ import { createTemplateAwareStringProtector } from './html-template'
11
12
 
12
13
  /**
13
14
  * Generate JS statements to update a DOM attribute reactively.
@@ -53,10 +54,11 @@ export function emitAttrUpdate(target: string, attrName: string, expression: str
53
54
  * Only applies when the component uses destructured props (not props.xxx style).
54
55
  */
55
56
  export function rewriteDestructuredPropsInExpr(expr: string, ctx: ClientJsContext): string {
56
- // Skip if the component already uses props object access (not destructuring)
57
57
  if (ctx.propsObjectName) return expr
58
58
 
59
- let result = expr
59
+ const { protect, restore } = createTemplateAwareStringProtector()
60
+ let result = protect(expr)
61
+
60
62
  for (const prop of ctx.propsParams) {
61
63
  if (prop.name === 'children') continue
62
64
  const pattern = new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g')
@@ -69,7 +71,7 @@ export function rewriteDestructuredPropsInExpr(expr: string, ctx: ClientJsContex
69
71
  result = result.replace(new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g'), replacement)
70
72
  }
71
73
 
72
- return result
74
+ return restore(result)
73
75
  }
74
76
 
75
77
  /** Emit createEffect blocks that update text nodes for reactive expressions. */
@@ -152,8 +154,6 @@ export function emitReactiveAttributeUpdates(lines: string[], ctx: ClientJsConte
152
154
  lines.push(` createEffect(() => {`)
153
155
  lines.push(` if (_${v}) {`)
154
156
  for (const attr of attrs) {
155
- // Rewrite destructured prop references to props.xxx for live reactivity.
156
- // Destructured props are const-captured once; effects must read from props object.
157
157
  const expression = rewriteDestructuredPropsInExpr(attr.expression, ctx)
158
158
  for (const stmt of emitAttrUpdate(`_${v}`, attr.attrName, expression, attr)) {
159
159
  lines.push(` ${stmt}`)
@@ -36,6 +36,62 @@ export function createStringProtector(): {
36
36
  return { protect, restore }
37
37
  }
38
38
 
39
+ /**
40
+ * Split a template literal body into static segments and `${...}` interpolations,
41
+ * correctly handling nested braces (e.g. object literals inside interpolations).
42
+ */
43
+ export function splitTemplateInterpolations(inner: string): string[] {
44
+ const parts: string[] = []
45
+ let i = 0
46
+ let segStart = 0
47
+ while (i < inner.length) {
48
+ if (inner[i] === '$' && inner[i + 1] === '{') {
49
+ if (i > segStart) parts.push(inner.slice(segStart, i))
50
+ let depth = 1
51
+ let j = i + 2
52
+ while (j < inner.length && depth > 0) {
53
+ if (inner[j] === '{') depth++
54
+ else if (inner[j] === '}') depth--
55
+ if (depth > 0) j++
56
+ }
57
+ j++
58
+ parts.push(inner.slice(i, j))
59
+ i = j
60
+ segStart = j
61
+ } else {
62
+ i++
63
+ }
64
+ }
65
+ if (segStart < inner.length) parts.push(inner.slice(segStart))
66
+ return parts
67
+ }
68
+
69
+ /**
70
+ * Protect both template-literal static segments AND quoted string literals
71
+ * from regex-based prop substitution. Used by prop-rewrite.ts (Phase 1)
72
+ * and emit-reactive.ts (Phase 2) to avoid corrupting CSS selectors and
73
+ * class values during prop name replacement.
74
+ */
75
+ export function createTemplateAwareStringProtector(): {
76
+ protect: (s: string) => string
77
+ restore: (s: string) => string
78
+ } {
79
+ const stash: string[] = []
80
+ const save = (s: string) => { const i = stash.length; stash.push(s); return `__STRLIT_${i}__` }
81
+ const protect = (s: string): string => {
82
+ s = s.replace(/`([^`]*)`/g, (_full, inner: string) => {
83
+ const parts = splitTemplateInterpolations(inner)
84
+ return '`' + parts.map(p => p.startsWith('${') ? p : save(p)).join('') + '`'
85
+ })
86
+ s = s.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, m => save(m))
87
+ return s
88
+ }
89
+ const restore = (s: string): string => {
90
+ return s.replace(/__STRLIT_(\d+)__/g, (_, i) => stash[Number(i)])
91
+ }
92
+ return { protect, restore }
93
+ }
94
+
39
95
  const VOID_ELEMENTS = new Set([
40
96
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
41
97
  'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
@@ -67,6 +123,31 @@ function applyLoopChain(loop: import('../types').IRLoop, base: string = loop.arr
67
123
  })
68
124
  }
69
125
 
126
+ /**
127
+ * Wrap an array expression with the iterator shape and build the
128
+ * `.map()` callback parameter for `entries` / `keys` iteration.
129
+ * Returns the (possibly wrapped) array and the callback param string.
130
+ */
131
+ function applyIterationShape(
132
+ node: import('../types').IRLoop,
133
+ arrayExpr: string,
134
+ indexParam: string,
135
+ ): { array: string; callbackParam: string } {
136
+ if (node.iterationShape === 'entries' && node.index) {
137
+ return {
138
+ array: `[...${arrayExpr}.entries()]`,
139
+ callbackParam: `([${node.index}, ${node.param}])`,
140
+ }
141
+ }
142
+ if (node.iterationShape === 'keys') {
143
+ return {
144
+ array: `[...${arrayExpr}.keys()]`,
145
+ callbackParam: `(${node.param})`,
146
+ }
147
+ }
148
+ return { array: arrayExpr, callbackParam: `(${node.param}${indexParam})` }
149
+ }
150
+
70
151
  function childrenPropEntry(
71
152
  children: IRNode[],
72
153
  recurse: (n: IRNode) => string,
@@ -488,7 +569,9 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
488
569
  // where the template is the only source of truth. The chain
489
570
  // mirrors `buildChainedArrayExpr` so reconcileList sees the
490
571
  // same array shape this template emits.
491
- const wrappedArray = wrapExpr(applyLoopChain(node))
572
+ const rawChainedArray = applyLoopChain(node)
573
+ const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam)
574
+ const wrappedArray = wrapExpr(iterArray)
492
575
  const iterMethod = node.method ?? 'map'
493
576
  let mapExpr: string
494
577
 
@@ -501,9 +584,9 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
501
584
  }
502
585
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
503
586
  } else if (node.mapPreamble) {
504
- mapExpr = `\${${wrappedArray}.${iterMethod}((${node.param}${indexParam}) => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
587
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
505
588
  } else {
506
- mapExpr = `\${${wrappedArray}.${iterMethod}((${node.param}${indexParam}) => \`${childTemplate}\`).join('')}`
589
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
507
590
  }
508
591
  // Wrap with loop boundary markers so reconciliation doesn't affect siblings
509
592
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
@@ -600,7 +683,9 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
600
683
  const indexParam = node.index ? `, ${node.index}` : ''
601
684
  // Apply sort / filter chain (#1448 Tier B) — same shape as the
602
685
  // `irToHtmlTemplate` loop case above.
603
- const wrappedArray = wrapExpr(applyLoopChain(node))
686
+ const rawChainedArray = applyLoopChain(node)
687
+ const { array: iterArray, callbackParam } = applyIterationShape(node, rawChainedArray, indexParam)
688
+ const wrappedArray = wrapExpr(iterArray)
604
689
  const iterMethod = node.method ?? 'map'
605
690
  let mapExpr: string
606
691
  if (node.flatMapCallback) {
@@ -611,9 +696,9 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
611
696
  }
612
697
  mapExpr = `\${${wrappedArray}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
613
698
  } else if (node.mapPreamble) {
614
- mapExpr = `\${${wrappedArray}.${iterMethod}((${node.param}${indexParam}) => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
699
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => { ${node.mapPreamble} return \`${childTemplate}\` }).join('')}`
615
700
  } else {
616
- mapExpr = `\${${wrappedArray}.${iterMethod}((${node.param}${indexParam}) => \`${childTemplate}\`).join('')}`
701
+ mapExpr = `\${${wrappedArray}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
617
702
  }
618
703
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
619
704
  }
@@ -1417,8 +1502,9 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1417
1502
  const chainedTemplateArray = node.sortComparator || node.filterPredicate
1418
1503
  ? applyLoopChain(node, node.templateArray)
1419
1504
  : node.templateArray
1420
- const arrayExpr = transformExpr(node.array, chainedTemplateArray)
1421
- const safeArrayExpr = arrayExpr === UNSAFE_TEMPLATE_EXPR ? '[]' : arrayExpr
1505
+ const rawArrayExpr = transformExpr(node.array, chainedTemplateArray)
1506
+ const safeRawArrayExpr = rawArrayExpr === UNSAFE_TEMPLATE_EXPR ? '[]' : rawArrayExpr
1507
+ const { array: iterArrayExpr, callbackParam } = applyIterationShape(node, safeRawArrayExpr, indexParam)
1422
1508
  const iterMethod = node.method ?? 'map'
1423
1509
  let mapExpr: string
1424
1510
  if (node.flatMapCallback) {
@@ -1428,13 +1514,13 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1428
1514
  body = body.replace(frag.placeholder, `\`${renderedIr}\``)
1429
1515
  }
1430
1516
  body = applyPropsRewrite(body, propsObjectName ?? null)
1431
- mapExpr = `\${${safeArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1517
+ mapExpr = `\${${iterArrayExpr}.flatMap(${node.flatMapCallback.params} => ${body}).join('')}`
1432
1518
  } else if (node.mapPreamble) {
1433
1519
  const rawPreamble = node.templateMapPreamble ?? node.mapPreamble
1434
1520
  const preamble = applyPropsRewrite(rawPreamble, propsObjectName ?? null)
1435
- mapExpr = `\${${safeArrayExpr}.${iterMethod}((${node.param}${indexParam}) => { ${preamble} return \`${childTemplate}\` }).join('')}`
1521
+ mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => { ${preamble} return \`${childTemplate}\` }).join('')}`
1436
1522
  } else {
1437
- mapExpr = `\${${safeArrayExpr}.${iterMethod}((${node.param}${indexParam}) => \`${childTemplate}\`).join('')}`
1523
+ mapExpr = `\${${iterArrayExpr}.${iterMethod}(${callbackParam} => \`${childTemplate}\`).join('')}`
1438
1524
  }
1439
1525
  return `<!--${loopStartMarker(node.markerId)}-->${mapExpr}<!--${loopEndMarker(node.markerId)}-->`
1440
1526
  }
@@ -222,6 +222,12 @@ export interface LoopCore {
222
222
  * names as a recurring source of "forgotten variant" defects.
223
223
  */
224
224
  bindings: LoopChildBindings
225
+
226
+ /**
227
+ * Iteration shape from `.entries()` / `.keys()` / `.values()` chain
228
+ * (#1448 Tier B). Threaded from `IRLoop.iterationShape`.
229
+ */
230
+ iterationShape?: 'entries' | 'keys'
225
231
  }
226
232
 
227
233
  /**
@@ -16,9 +16,10 @@ import {
16
16
  BF_LOOP_END,
17
17
  loopStartMarker,
18
18
  loopEndMarker,
19
+ toHTMLAttrName as toHtmlAttrName,
19
20
  } from '@barefootjs/shared'
20
21
 
21
- export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker }
22
+ export { DATA_KEY, DATA_KEY_PREFIX, DATA_BF_PH, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker, toHtmlAttrName }
22
23
 
23
24
  /**
24
25
  * Parameter name for the props object in generated init/template functions.
@@ -167,70 +168,8 @@ export function quotePropName(name: string): string {
167
168
  return JSON.stringify(name)
168
169
  }
169
170
 
170
- /**
171
- * SVG presentation attribute names that are written camelCase in JSX
172
- * (React-compatible spelling) and must be emitted as kebab-case at the
173
- * DOM/HTML layer.
174
- *
175
- * Why this exists: SSR template output and client-side reactive
176
- * `setAttribute` both flow through `toHtmlAttrName`. If they disagree on
177
- * the spelling, SSR emits `stroke-width="1.5"` while hydration writes
178
- * `setAttribute('strokeWidth', '2.5')`, leaving both attributes on the
179
- * DOM. The SVG renderer reads the kebab-case form, so reactive updates
180
- * become invisible. This map keeps both paths in sync. Surfaced by the
181
- * Graph/DAG Editor block (#135) where edge selection failed to thicken
182
- * the stroke even though `selectedEdgeId()` updated correctly.
183
- *
184
- * Listed names are SVG-only — none of them collide with HTML attributes.
185
- */
186
- const SVG_CAMEL_TO_KEBAB: Record<string, string> = {
187
- // stroke
188
- strokeWidth: 'stroke-width',
189
- strokeLinecap: 'stroke-linecap',
190
- strokeLinejoin: 'stroke-linejoin',
191
- strokeDasharray: 'stroke-dasharray',
192
- strokeDashoffset: 'stroke-dashoffset',
193
- strokeMiterlimit: 'stroke-miterlimit',
194
- strokeOpacity: 'stroke-opacity',
195
- // fill
196
- fillOpacity: 'fill-opacity',
197
- fillRule: 'fill-rule',
198
- // gradient stops
199
- stopColor: 'stop-color',
200
- stopOpacity: 'stop-opacity',
201
- // text presentation
202
- textAnchor: 'text-anchor',
203
- dominantBaseline: 'dominant-baseline',
204
- alignmentBaseline: 'alignment-baseline',
205
- fontFamily: 'font-family',
206
- fontSize: 'font-size',
207
- fontWeight: 'font-weight',
208
- fontStyle: 'font-style',
209
- letterSpacing: 'letter-spacing',
210
- wordSpacing: 'word-spacing',
211
- // common presentation / interaction
212
- pointerEvents: 'pointer-events',
213
- vectorEffect: 'vector-effect',
214
- colorInterpolation: 'color-interpolation',
215
- clipPath: 'clip-path',
216
- clipRule: 'clip-rule',
217
- // marker references
218
- markerStart: 'marker-start',
219
- markerMid: 'marker-mid',
220
- markerEnd: 'marker-end',
221
- }
222
-
223
- /**
224
- * Convert JSX attribute name to HTML attribute name.
225
- * Handles React-style naming conventions (e.g., className → class) and
226
- * SVG presentation attributes (e.g., strokeWidth → stroke-width).
227
- */
228
- export function toHtmlAttrName(jsxAttrName: string): string {
229
- if (jsxAttrName === 'className') return 'class'
230
- const svgKebab = SVG_CAMEL_TO_KEBAB[jsxAttrName]
231
- if (svgKebab !== undefined) return svgKebab
232
- return jsxAttrName
233
- }
171
+ // toHtmlAttrName is now re-exported from @barefootjs/shared (classifyDOMProp's
172
+ // toHTMLAttrName), keeping the same public name for downstream consumers.
234
173
 
235
174
  /**
236
175
  * Wrap arrow function handler in block to prevent accidental return false.
package/src/jsx-to-ir.ts CHANGED
@@ -2013,15 +2013,20 @@ function transformConditionalBranch(
2013
2013
  effect: 'pure',
2014
2014
  freeRefs: resolveFreeRefs(node, makeBindingEnv(ctx)),
2015
2015
  }
2016
+ const callsReactive = exprCallsReactiveGetters(node, ctx)
2017
+ const hasCalls = exprHasFunctionCalls(node)
2018
+ const reactive = isReactiveExpression(exprText, ctx, node) || isReactiveOrigin(branchOrigin)
2019
+ const needsSlot = reactive || callsReactive
2020
+ const slotId = needsSlot ? generateSlotId(ctx) : null
2016
2021
  return {
2017
2022
  type: 'expression',
2018
2023
  expr: exprText,
2019
2024
  templateExpr: rewriteBarePropRefs(exprText, node, ctx),
2020
2025
  typeInfo: inferExpressionType(node, ctx),
2021
- reactive: isReactiveExpression(exprText, ctx, node) || isReactiveOrigin(branchOrigin),
2022
- slotId: null,
2023
- callsReactiveGetters: exprCallsReactiveGetters(node, ctx) || undefined,
2024
- hasFunctionCalls: exprHasFunctionCalls(node) || undefined,
2026
+ reactive,
2027
+ slotId,
2028
+ callsReactiveGetters: callsReactive || undefined,
2029
+ hasFunctionCalls: hasCalls || undefined,
2025
2030
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
2026
2031
  origin: branchOrigin,
2027
2032
  }
@@ -2077,6 +2082,23 @@ function isSortCall(node: ts.Expression): { array: ts.Expression; callback: ts.E
2077
2082
  }
2078
2083
  }
2079
2084
 
2085
+ /**
2086
+ * Check if a node is an `.entries()`, `.keys()`, or `.values()` call
2087
+ * (zero-arg, property-access form). Returns the underlying array expression
2088
+ * and the iteration shape so `transformMapCall` can strip the iterator
2089
+ * method and record it on the IRLoop.
2090
+ */
2091
+ function isIteratorShapeCall(
2092
+ node: ts.Expression,
2093
+ ): { array: ts.LeftHandSideExpression; shape: 'entries' | 'keys' | 'values' } | null {
2094
+ if (!ts.isCallExpression(node)) return null
2095
+ if (!ts.isPropertyAccessExpression(node.expression)) return null
2096
+ if (node.arguments.length !== 0) return null
2097
+ const name = node.expression.name.text
2098
+ if (name !== 'entries' && name !== 'keys' && name !== 'values') return null
2099
+ return { array: node.expression.expression, shape: name }
2100
+ }
2101
+
2080
2102
  type SortExtractionResult = {
2081
2103
  result: SortComparator | null
2082
2104
  unsupportedReason?: string
@@ -2672,6 +2694,7 @@ function transformMapCall(
2672
2694
  // 2. filter().map()
2673
2695
  // 3. filter().sort().map() (outermost = sort, inner = filter)
2674
2696
  // 4. sort().filter().map() (outermost = filter, inner = sort)
2697
+ // 5. entries().map() / keys().map() / values().map()
2675
2698
 
2676
2699
  let array: string = ''
2677
2700
  let templateArray: string | undefined
@@ -2686,6 +2709,7 @@ function transformMapCall(
2686
2709
  let mapPreamble: string | undefined
2687
2710
  let templateMapPreamble: string | undefined
2688
2711
  let typedMapPreamble: string | undefined
2712
+ let iterationShape: 'entries' | 'keys' | undefined
2689
2713
 
2690
2714
  // Helper to set both array and templateArray
2691
2715
  const setArray = (node: ts.Expression) => {
@@ -2694,8 +2718,26 @@ function transformMapCall(
2694
2718
  arrayExpr = node
2695
2719
  }
2696
2720
 
2697
- const filterInfo = isFilterCall(mapSource)
2698
- const sortInfo = isSortCall(mapSource)
2721
+ // Detect `.entries()`, `.keys()`, `.values()` as the outermost wrapper
2722
+ // on the map source. Strip the iterator method and record the shape so
2723
+ // adapters emit the right loop variable bindings. `.values()` is a
2724
+ // no-op (same as plain `.map()`) so it's stripped but not recorded.
2725
+ // The inner expression (after stripping) feeds into the standard
2726
+ // filter/sort chain detection below.
2727
+ let chainSource = mapSource
2728
+ const iteratorInfo = isIteratorShapeCall(mapSource)
2729
+ if (iteratorInfo) {
2730
+ chainSource = iteratorInfo.array
2731
+ if (iteratorInfo.shape === 'entries') {
2732
+ iterationShape = 'entries'
2733
+ } else if (iteratorInfo.shape === 'keys') {
2734
+ iterationShape = 'keys'
2735
+ }
2736
+ // 'values' is a no-op — same as plain .map()
2737
+ }
2738
+
2739
+ const filterInfo = isFilterCall(chainSource)
2740
+ const sortInfo = isSortCall(chainSource)
2699
2741
 
2700
2742
  if (sortInfo) {
2701
2743
  // Outermost is sort: could be sort().map() or filter().sort().map()
@@ -2810,8 +2852,8 @@ function transformMapCall(
2810
2852
  }
2811
2853
  }
2812
2854
  } else {
2813
- array = ctx.getJS(mapSource)
2814
- arrayExpr = mapSource
2855
+ array = ctx.getJS(chainSource)
2856
+ arrayExpr = chainSource
2815
2857
  }
2816
2858
 
2817
2859
  // Get callback function
@@ -2839,18 +2881,50 @@ function transformMapCall(
2839
2881
  // residual-object / `.slice(n)` accessor at each reference. Only
2840
2882
  // computed property keys remain unsupported — those raise `BF025`
2841
2883
  // and the emitter falls back to the #950 body-entry unwrap.
2842
- const bindingResult = extractLoopParamBindings(firstParam.name)
2843
- if (bindingResult && !Array.isArray(bindingResult)) {
2844
- ctx.analyzer.errors.push(
2845
- createError(ErrorCodes.UNSUPPORTED_DESTRUCTURE_REST,
2846
- getSourceLocation(firstParam, ctx.sourceFile, ctx.filePath),
2847
- )
2884
+ // `.entries()` synthesises `[index, value]` — when the callback
2885
+ // destructures exactly two array elements, extract the names into
2886
+ // `index` and `param` so the loop renders with proper bindings and
2887
+ // the BF104 destructure-param refusal doesn't fire.
2888
+ if (iterationShape === 'entries' && ts.isArrayBindingPattern(firstParam.name)) {
2889
+ const elements = firstParam.name.elements.filter(
2890
+ el => !ts.isOmittedExpression(el),
2848
2891
  )
2849
- } else if (Array.isArray(bindingResult)) {
2850
- paramBindings = bindingResult
2892
+ if (elements.length === 2 &&
2893
+ ts.isBindingElement(elements[0]) && ts.isIdentifier(elements[0].name) &&
2894
+ ts.isBindingElement(elements[1]) && ts.isIdentifier(elements[1].name)) {
2895
+ index = elements[0].name.text
2896
+ param = elements[1].name.text
2897
+ // Don't populate paramBindings — the destructure is fully
2898
+ // resolved into index + param by the iteration shape.
2899
+ } else {
2900
+ // Non-2-element destructure with .entries() — fall through to
2901
+ // standard destructure handling (will trigger BF104 on
2902
+ // template adapters).
2903
+ const bindingResult = extractLoopParamBindings(firstParam.name)
2904
+ if (bindingResult && !Array.isArray(bindingResult)) {
2905
+ ctx.analyzer.errors.push(
2906
+ createError(ErrorCodes.UNSUPPORTED_DESTRUCTURE_REST,
2907
+ getSourceLocation(firstParam, ctx.sourceFile, ctx.filePath),
2908
+ )
2909
+ )
2910
+ } else if (Array.isArray(bindingResult)) {
2911
+ paramBindings = bindingResult
2912
+ }
2913
+ }
2914
+ } else {
2915
+ const bindingResult = extractLoopParamBindings(firstParam.name)
2916
+ if (bindingResult && !Array.isArray(bindingResult)) {
2917
+ ctx.analyzer.errors.push(
2918
+ createError(ErrorCodes.UNSUPPORTED_DESTRUCTURE_REST,
2919
+ getSourceLocation(firstParam, ctx.sourceFile, ctx.filePath),
2920
+ )
2921
+ )
2922
+ } else if (Array.isArray(bindingResult)) {
2923
+ paramBindings = bindingResult
2924
+ }
2851
2925
  }
2852
2926
  }
2853
- if (callback.parameters.length > 1) {
2927
+ if (callback.parameters.length > 1 && iterationShape !== 'entries') {
2854
2928
  const secondParam = callback.parameters[1]
2855
2929
  index = secondParam.name.getText(ctx.sourceFile)
2856
2930
  if (secondParam.type) {
@@ -3065,6 +3139,7 @@ function transformMapCall(
3065
3139
  filterPredicate,
3066
3140
  sortComparator,
3067
3141
  chainOrder,
3142
+ iterationShape,
3068
3143
  clientOnly: isClientOnly || undefined,
3069
3144
  mapPreamble,
3070
3145
  templateMapPreamble,
@@ -8,6 +8,7 @@
8
8
 
9
9
  import ts from 'typescript'
10
10
  import { PROPS_PARAM } from './ir-to-client-js/utils'
11
+ import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template'
11
12
 
12
13
  /**
13
14
  * Walk an AST node for destructured-prop value references and add
@@ -47,7 +48,9 @@ export function applyRegexPropRefRewrite(
47
48
  text: string,
48
49
  propRefs: Iterable<string>,
49
50
  ): string {
50
- let result = text
51
+ const { protect, restore } = createTemplateAwareStringProtector()
52
+ let result = protect(text)
53
+
51
54
  for (const propName of propRefs) {
52
55
  const pattern = new RegExp(`(?<!${PROPS_PARAM}\\.)(?<!['"\\w.-])\\b${propName}\\b(?![a-zA-Z0-9_$])`, 'g')
53
56
  result = result.replace(pattern, (match, offset, str) => {
@@ -60,7 +63,8 @@ export function applyRegexPropRefRewrite(
60
63
  return `${PROPS_PARAM}.${propName}`
61
64
  })
62
65
  }
63
- return result
66
+
67
+ return restore(result)
64
68
  }
65
69
 
66
70
  /**
package/src/types.ts CHANGED
@@ -496,6 +496,27 @@ export interface IRLoop {
496
496
  */
497
497
  chainOrder?: 'filter-sort' | 'sort-filter'
498
498
 
499
+ /**
500
+ * Pre-`.map()` iteration shape (#1448 Tier B).
501
+ *
502
+ * When the user writes `arr.entries().map(([i, v]) => ...)`,
503
+ * `arr.keys().map(i => ...)`, or `arr.values().map(v => ...)`,
504
+ * the chain-detection in `transformMapCall` strips the iterator
505
+ * method and records the shape here so adapters can emit the
506
+ * right loop variable bindings:
507
+ *
508
+ * - `'entries'` → both index and value are bound
509
+ * (Go: `$i, $v`; Mojo: `$i` + `$v = $arr->[$i]`)
510
+ * - `'keys'` → only the index is bound
511
+ * (Go: `$i, $_ :=`; Mojo: `$i` with no per-item lookup)
512
+ * - `undefined` / `'values'` → standard iteration (value only)
513
+ *
514
+ * The chain detection also synthesises proper `param` / `index`
515
+ * from the `.entries()` destructure pattern so the BF104
516
+ * destructure-param refusal doesn't fire.
517
+ */
518
+ iterationShape?: 'entries' | 'keys'
519
+
499
520
  /**
500
521
  * When true, loop should be evaluated on client side only.
501
522
  * SSR adapters should skip rendering and output placeholder markers.