@barefootjs/go-template 0.1.2 → 0.2.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.
@@ -71,6 +71,7 @@ type GoRenderCtx = {
71
71
  */
72
72
  interface NestedComponentInfo extends IRLoopChildComponent {
73
73
  isDynamic: boolean
74
+ isPropDerived: boolean
74
75
  }
75
76
 
76
77
  interface StaticChildInstance {
@@ -298,6 +299,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
298
299
  private options: Required<GoTemplateAdapterOptions>
299
300
  private inLoop: boolean = false
300
301
  private loopParamStack: string[] = []
302
+ private loopVarRefCount: Map<string, number> = new Map()
301
303
  private errors: CompilerError[] = []
302
304
  private propsObjectName: string | null = null
303
305
  /**
@@ -308,6 +310,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
308
310
  * follow-up).
309
311
  */
310
312
  private restPropsName: string | null = null
313
+ private templateVarCounter: number = 0
311
314
  /** Local type names resolved from typeDefinitions (populated during generateTypes) */
312
315
  private localTypeNames: Set<string> = new Set()
313
316
  /** Local type aliases mapping type name to base type (e.g., Filter → 'string') */
@@ -334,6 +337,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
334
337
  generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
335
338
  this.componentName = ir.metadata.componentName
336
339
  this.errors = []
340
+ this.templateVarCounter = 0
337
341
  this.propsObjectName = ir.metadata.propsObjectName
338
342
  this.restPropsName = ir.metadata.restPropsName ?? null
339
343
 
@@ -807,8 +811,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
807
811
  lines.push('\tBfParent string // Optional: parent scope id')
808
812
  lines.push('\tBfMount string // Optional: slot id in parent')
809
813
 
810
- // Static nested components appear in Input; dynamic ones are template-only
811
- const staticNested = nestedComponents.filter(n => !n.isDynamic)
814
+ // Static + prop-derived nested components appear in Input;
815
+ // signal-backed dynamic ones are template-only
816
+ const inputNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
812
817
 
813
818
  // Collect nested component array field names to skip from propsParams
814
819
  const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
@@ -821,8 +826,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
821
826
  lines.push(`\t${fieldName} ${goType}`)
822
827
  }
823
828
 
824
- // Add nested component input arrays (static only)
825
- for (const nested of staticNested) {
829
+ // Add nested component input arrays
830
+ for (const nested of inputNested) {
826
831
  lines.push(`\t${nested.name}s []${nested.name}Input`)
827
832
  }
828
833
 
@@ -953,10 +958,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
953
958
 
954
959
  // Add array fields for nested components (for template rendering)
955
960
  for (const nested of nestedComponents) {
956
- if (nested.isDynamic) {
957
- // Dynamic (signal) array loops: template-only, not in JSON
961
+ if (nested.isDynamic && !nested.isPropDerived) {
962
+ // Dynamic signal array loops: template-only, not in JSON
958
963
  lines.push(`\t${nested.name}s []${nested.name}Props \`json:"-"\``)
959
964
  } else {
965
+ // Static arrays and prop-derived dynamic arrays: include in JSON
966
+ // so the client can hydrate via mapArray or forEach
960
967
  const jsonTag = this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
961
968
  lines.push(`\t${nested.name}s []${nested.name}Props \`json:"${jsonTag}"\``)
962
969
  }
@@ -1005,9 +1012,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1005
1012
  // field, the SSR template iterates over it, but
1006
1013
  // `NewTodoAppProps(TodoAppInput{Initial: ...})` returns it empty
1007
1014
  // and the page renders a blank list (#1442 echo TodoApp repro).
1008
- const dynamicNested = nestedComponents.filter(n => n.isDynamic)
1015
+ const signalDynamicNested = nestedComponents.filter(n => n.isDynamic && !n.isPropDerived)
1009
1016
  lines.push(`// New${componentName}Props creates ${propsTypeName} from ${inputTypeName}.`)
1010
- for (const nested of dynamicNested) {
1017
+ for (const nested of signalDynamicNested) {
1011
1018
  const arrayField = `${nested.name}s`
1012
1019
  lines.push(`//`)
1013
1020
  lines.push(`// NOTE: \`${arrayField}\` is populated by the route handler, not by`)
@@ -1030,8 +1037,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1030
1037
  lines.push('\t}')
1031
1038
  lines.push('')
1032
1039
 
1033
- // Static nested components only dynamic ones are set manually by the handler
1034
- const staticNested = nestedComponents.filter(n => !n.isDynamic)
1040
+ // Static + prop-derived nested components: auto-populate from input.
1041
+ // Signal-backed dynamic arrays are set manually by the handler.
1042
+ const staticNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
1035
1043
 
1036
1044
  // Handle nested components
1037
1045
  if (staticNested.length > 0) {
@@ -1266,6 +1274,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1266
1274
  result.push({
1267
1275
  ...loop.childComponent,
1268
1276
  isDynamic: !loop.isStaticArray,
1277
+ isPropDerived: !!loop.isPropDerivedArray,
1269
1278
  })
1270
1279
  }
1271
1280
  }
@@ -2466,12 +2475,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2466
2475
  // ===========================================================================
2467
2476
 
2468
2477
  identifier(name: string): string {
2478
+ const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2479
+ if (currentLoopParam && name === currentLoopParam) return '.'
2480
+ if (this.loopVarRefCount.has(name)) return `$${name}`
2469
2481
  return `.${this.capitalizeFieldName(name)}`
2470
2482
  }
2471
2483
 
2472
2484
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
2473
2485
  if (literalType === 'string') return `"${value}"`
2474
- if (literalType === 'null') return '""'
2486
+ if (literalType === 'null') return 'nil'
2475
2487
  return String(value)
2476
2488
  }
2477
2489
 
@@ -2530,8 +2542,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2530
2542
  if (result) return result
2531
2543
  }
2532
2544
 
2533
- // find().property → {{with bf_find ...}}{{.Property}}{{end}}
2534
- if (object.kind === 'higher-order' && object.method === 'find') {
2545
+ // find().property / findLast().property → {{with bf_find ...}}{{.Property}}{{end}}
2546
+ if (object.kind === 'higher-order' && (object.method === 'find' || object.method === 'findLast')) {
2535
2547
  const findResult = this.renderHigherOrderExpr(object, emit)
2536
2548
  if (findResult) {
2537
2549
  return `{{with ${findResult}}}{{.${this.capitalizeFieldName(property)}}}{{end}}`
@@ -2613,6 +2625,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2613
2625
  return `or ${wrapLeft} ${wrapRight}`
2614
2626
  }
2615
2627
 
2628
+ // Note: JSX-level ternaries (`{expr ? a : b}`) are handled at the
2629
+ // IR level as IRConditional, which goes through convertConditionToGo
2630
+ // → renderConditionExpr (preamble-aware). This emitter method is
2631
+ // only reached for ternaries nested inside other ParsedExpr trees
2632
+ // (e.g. template-literal interpolation), where the test is always a
2633
+ // simple pipeline expression (runtime helpers, not template blocks).
2616
2634
  conditional(
2617
2635
  test: ParsedExpr,
2618
2636
  consequent: ParsedExpr,
@@ -2620,8 +2638,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2620
2638
  emit: (e: ParsedExpr) => string,
2621
2639
  ): string {
2622
2640
  const t = emit(test)
2623
- // Nested conditionals already return complete {{if}}...{{end}} blocks;
2624
- // literals return bare text (used within attributes).
2625
2641
  const c = this.renderConditionalBranch(consequent)
2626
2642
  const a = this.renderConditionalBranch(alternate)
2627
2643
  return `{{if ${t}}}${c}{{else}}${a}{{end}}`
@@ -2672,7 +2688,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2672
2688
  const reconstructed = { kind: 'higher-order' as const, method, object, param, predicate }
2673
2689
  const result = this.renderHigherOrderExpr(reconstructed, emit)
2674
2690
  if (result) return result
2675
- if (method === 'find' || method === 'findIndex') {
2691
+ if (method === 'find' || method === 'findIndex' || method === 'findLast' || method === 'findLastIndex') {
2676
2692
  const templateBlock = this.renderFindTemplateBlock(reconstructed, emit)
2677
2693
  if (templateBlock) return templateBlock
2678
2694
  }
@@ -2941,26 +2957,29 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2941
2957
  return `bf_filter ${arrayExpr} "${field}" ${value}`
2942
2958
  }
2943
2959
 
2944
- if (expr.method === 'find' || expr.method === 'findIndex') {
2960
+ if (expr.method === 'find' || expr.method === 'findIndex' || expr.method === 'findLast' || expr.method === 'findLastIndex') {
2945
2961
  const eqPred = this.extractEqualityPredicate(
2946
2962
  expr.predicate, expr.param, e => this.renderParsedExpr(e)
2947
2963
  )
2948
2964
  if (!eqPred) return null
2949
- const func = expr.method === 'find' ? 'bf_find' : 'bf_find_index'
2950
- return `${func} ${arrayExpr} "${eqPred.field}" ${eqPred.value}`
2965
+ const funcMap: Record<string, string> = {
2966
+ find: 'bf_find', findIndex: 'bf_find_index',
2967
+ findLast: 'bf_find_last', findLastIndex: 'bf_find_last_index',
2968
+ }
2969
+ return `${funcMap[expr.method]} ${arrayExpr} "${eqPred.field}" ${eqPred.value}`
2951
2970
  }
2952
2971
 
2953
2972
  return null
2954
2973
  }
2955
2974
 
2956
2975
  /**
2957
- * Render find()/findIndex() with complex predicates using {{range}}{{if}}...{{break}} blocks.
2958
- * Falls back from bf_find/bf_find_index when extractEqualityPredicate returns null.
2959
- * Reuses renderFilterExpr for condition rendering.
2976
+ * Render find/findIndex/findLast/findLastIndex with complex predicates
2977
+ * using range/if blocks. Falls back from bf_find/bf_find_last helpers
2978
+ * when extractEqualityPredicate returns null.
2960
2979
  *
2961
- * @param expr - The higher-order find/findIndex expression
2962
- * @param renderArray - Function to render the array expression
2963
- * @param propertyAccess - Optional property to access on the found element (for find().property)
2980
+ * find/findIndex use break on first match (forward scan).
2981
+ * findLast/findLastIndex iterate forward and keep overwriting a result
2982
+ * variable; the final value is the last match.
2964
2983
  */
2965
2984
  private renderFindTemplateBlock(
2966
2985
  expr: Extract<ParsedExpr, { kind: 'higher-order' }>,
@@ -2980,6 +2999,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2980
2999
  return `{{range $i, $_ := ${arrayExpr}}}{{if ${condition}}}{{$i}}{{break}}{{end}}{{end}}`
2981
3000
  }
2982
3001
 
3002
+ if (expr.method === 'findLast') {
3003
+ const v = `$bf_r${this.templateVarCounter++}`
3004
+ const capture = propertyAccess ? `.${propertyAccess}` : '.'
3005
+ return `{{${v} := ""}}{{range ${arrayExpr}}}{{if ${condition}}}{{${v} = ${capture}}}{{end}}{{end}}{{${v}}}`
3006
+ }
3007
+
3008
+ if (expr.method === 'findLastIndex') {
3009
+ const v = `$bf_r${this.templateVarCounter++}`
3010
+ return `{{${v} := -1}}{{range $i, $_ := ${arrayExpr}}}{{if ${condition}}}{{${v} = $i}}{{end}}{{end}}{{${v}}}`
3011
+ }
3012
+
2983
3013
  return null
2984
3014
  }
2985
3015
 
@@ -3003,13 +3033,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3003
3033
  if (condition.includes('[UNSUPPORTED')) return null
3004
3034
 
3005
3035
  if (expr.method === 'every') {
3006
- // Negate condition: if NOT condition, set false and break
3036
+ const v = `$bf_r${this.templateVarCounter++}`
3007
3037
  const negated = this.negateGoCondition(condition)
3008
- return `{{$bf_result := true}}{{range ${arrayExpr}}}{{if ${negated}}}{{$bf_result = false}}{{break}}{{end}}{{end}}{{$bf_result}}`
3038
+ return `{{${v} := true}}{{range ${arrayExpr}}}{{if ${negated}}}{{${v} = false}}{{break}}{{end}}{{end}}{{${v}}}`
3009
3039
  }
3010
3040
 
3011
3041
  if (expr.method === 'some') {
3012
- return `{{$bf_result := false}}{{range ${arrayExpr}}}{{if ${condition}}}{{$bf_result = true}}{{break}}{{end}}{{end}}{{$bf_result}}`
3042
+ const v = `$bf_r${this.templateVarCounter++}`
3043
+ return `{{${v} := false}}{{range ${arrayExpr}}}{{if ${condition}}}{{${v} = true}}{{break}}{{end}}{{end}}{{${v}}}`
3013
3044
  }
3014
3045
 
3015
3046
  return null
@@ -3068,6 +3099,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3068
3099
  return expr.kind === 'logical' || expr.kind === 'unary' || expr.kind === 'conditional'
3069
3100
  }
3070
3101
 
3102
+ /**
3103
+ * Split a rendered template block into preamble + final expression.
3104
+ * The last `{{...}}` must be a variable reference (`$bf_rN` or
3105
+ * `$bf_result`). Control tokens like `{{end}}` or `{{break}}` are
3106
+ * rejected — those template blocks (e.g. find's range/break form)
3107
+ * can't be composed in binary/logical expressions.
3108
+ */
3109
+ private splitPreamble(rendered: string): { preamble: string; expr: string } | null {
3110
+ if (!rendered.includes('{{')) return null
3111
+ const lastOpen = rendered.lastIndexOf('{{')
3112
+ const lastClose = rendered.lastIndexOf('}}')
3113
+ if (lastOpen >= 0 && lastClose > lastOpen) {
3114
+ const candidate = rendered.substring(lastOpen + 2, lastClose)
3115
+ if (!candidate.startsWith('$')) return null
3116
+ return {
3117
+ preamble: rendered.substring(0, lastOpen),
3118
+ expr: candidate,
3119
+ }
3120
+ }
3121
+ return null
3122
+ }
3123
+
3071
3124
  // =============================================================================
3072
3125
  // Block Body Condition Rendering
3073
3126
  // =============================================================================
@@ -3310,7 +3363,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3310
3363
  return `"${expr.value}"`
3311
3364
  }
3312
3365
  if (expr.literalType === 'null') {
3313
- return '""'
3366
+ return 'nil'
3314
3367
  }
3315
3368
  return String(expr.value)
3316
3369
 
@@ -3682,196 +3735,152 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3682
3735
  return { condition: `false`, preamble: '' }
3683
3736
  }
3684
3737
 
3685
- const rendered = this.renderConditionExpr(parsed)
3686
-
3687
- // Detect template blocks (e.g., from every/some with complex predicates).
3688
- // These cannot be placed inside {{if ...}} directly.
3689
- // Split into preamble (template block) + condition variable.
3690
- if (rendered.startsWith('{{')) {
3691
- const lastOpen = rendered.lastIndexOf('{{')
3692
- const lastClose = rendered.lastIndexOf('}}')
3693
- if (lastOpen >= 0 && lastClose > lastOpen) {
3694
- const preamble = rendered.substring(0, lastOpen)
3695
- const condition = rendered.substring(lastOpen + 2, lastClose)
3696
- return { condition, preamble }
3697
- }
3698
- }
3699
-
3700
- return { condition: rendered, preamble: '' }
3738
+ const { preamble, expr: condition } = this.renderConditionExpr(parsed)
3739
+ return { condition, preamble }
3701
3740
  }
3702
3741
 
3703
- /**
3704
- * Render a ParsedExpr as a Go template condition.
3705
- */
3706
- private renderConditionExpr(expr: ParsedExpr): string {
3742
+ private renderConditionExpr(expr: ParsedExpr): { preamble: string; expr: string } {
3743
+ const plain = (e: string) => ({ preamble: '', expr: e })
3744
+
3707
3745
  switch (expr.kind) {
3708
3746
  case 'identifier':
3709
- // Inside a `{{range $_, $todo := .Todos}}` loop, a bare reference
3710
- // to the loop variable (`todo`) is just Go template's dot. The
3711
- // `ParsedExprEmitter` path already handles this at memberAccess
3712
- // (line ~2449); this condition-expression path needs the same
3713
- // normalization or `todo.done` ends up as `.Todo.Done` — a
3714
- // non-existent field that Go template silently expands to ""
3715
- // and then aborts the surrounding `{{if}}`/template execution
3716
- // (echo logs it as a 200 with truncated bytes; #1442 repro).
3717
3747
  {
3718
3748
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
3719
3749
  if (currentLoopParam && expr.name === currentLoopParam) {
3720
- return '.'
3750
+ return plain('.')
3751
+ }
3752
+ if (this.loopVarRefCount.has(expr.name)) {
3753
+ return plain(`$${expr.name}`)
3721
3754
  }
3722
3755
  }
3723
- return `.${this.capitalizeFieldName(expr.name)}`
3756
+ return plain(`.${this.capitalizeFieldName(expr.name)}`)
3724
3757
 
3725
3758
  case 'literal':
3726
- if (expr.literalType === 'string') {
3727
- return `"${expr.value}"`
3728
- }
3729
- if (expr.literalType === 'null') {
3730
- return '""'
3731
- }
3732
- return String(expr.value)
3759
+ if (expr.literalType === 'string') return plain(`"${expr.value}"`)
3760
+ if (expr.literalType === 'null') return plain('nil')
3761
+ return plain(String(expr.value))
3733
3762
 
3734
3763
  case 'call': {
3735
- // Signal call: count() -> .Count
3736
3764
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
3737
- return `.${this.capitalizeFieldName(expr.callee.name)}`
3765
+ return plain(`.${this.capitalizeFieldName(expr.callee.name)}`)
3738
3766
  }
3739
- return this.renderParsedExpr(expr)
3767
+ return plain(this.renderParsedExpr(expr))
3740
3768
  }
3741
3769
 
3742
3770
  case 'member': {
3743
- // Handle .length with higher-order filter → len (bf_filter ...)
3744
3771
  if (expr.property === 'length' && expr.object.kind === 'higher-order') {
3745
- const result = this.renderFilterLengthExpr(expr.object, e => this.renderConditionExpr(e))
3746
- if (result) {
3747
- return result
3748
- }
3772
+ // renderFilterLengthExpr uses bf_filter runtime helpers (not
3773
+ // template blocks), so .preamble is always empty here today.
3774
+ // If a future higher-order method produces preambles through
3775
+ // this path, the callback would need to propagate them.
3776
+ const result = this.renderFilterLengthExpr(expr.object, e => this.renderConditionExpr(e).expr)
3777
+ if (result) return plain(result)
3749
3778
  }
3750
3779
 
3751
- // Handle SolidJS-style props pattern: props.xxx -> .Xxx
3752
3780
  if (expr.object.kind === 'identifier' && this.propsObjectName && expr.object.name === this.propsObjectName) {
3753
- return `.${this.capitalizeFieldName(expr.property)}`
3781
+ return plain(`.${this.capitalizeFieldName(expr.property)}`)
3754
3782
  }
3755
3783
 
3756
- // Loop-param member access: `todo.done` inside
3757
- // `{{range $_, $todo := .Todos}}` is `.Done` (Go template's dot
3758
- // is the current item). The `ParsedExprEmitter` already does
3759
- // this for renderParsedExpr; mirror it here so condition-only
3760
- // positions like boolean attributes (`checked={todo.done}`)
3761
- // and `{{if}}` operands don't fall through to the generic
3762
- // `.Todo.Done` shape, which references a non-existent field.
3763
3784
  {
3764
3785
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
3765
3786
  if (expr.object.kind === 'identifier' && currentLoopParam && expr.object.name === currentLoopParam) {
3766
- return `.${this.capitalizeFieldName(expr.property)}`
3787
+ return plain(`.${this.capitalizeFieldName(expr.property)}`)
3767
3788
  }
3768
3789
  }
3769
3790
 
3770
3791
  const obj = this.renderConditionExpr(expr.object)
3771
3792
  if (expr.property === 'length') {
3772
- return `len ${obj}`
3793
+ return { preamble: obj.preamble, expr: `len ${obj.expr}` }
3773
3794
  }
3774
- return `${obj}.${this.capitalizeFieldName(expr.property)}`
3795
+ return { preamble: obj.preamble, expr: `${obj.expr}.${this.capitalizeFieldName(expr.property)}` }
3775
3796
  }
3776
3797
 
3777
3798
  case 'binary': {
3778
- // Check if left operand needs parentheses (e.g., function calls in Go template)
3779
3799
  const leftNeedsParens = this.needsParensInGoTemplate(expr.left)
3780
- let left = this.renderConditionExpr(expr.left)
3781
- if (leftNeedsParens) {
3782
- left = `(${left})`
3783
- }
3800
+ const leftResult = this.renderConditionExpr(expr.left)
3801
+ const left = leftNeedsParens ? `(${leftResult.expr})` : leftResult.expr
3784
3802
 
3785
3803
  const rightNeedsParens = this.needsParensInGoTemplate(expr.right)
3786
- let right = this.renderConditionExpr(expr.right)
3787
- if (rightNeedsParens) {
3788
- right = `(${right})`
3789
- }
3804
+ const rightResult = this.renderConditionExpr(expr.right)
3805
+ const right = rightNeedsParens ? `(${rightResult.expr})` : rightResult.expr
3806
+
3807
+ const preamble = leftResult.preamble + rightResult.preamble
3790
3808
 
3809
+ let result: string
3791
3810
  switch (expr.op) {
3792
3811
  case '===':
3793
3812
  case '==':
3794
- return `eq ${left} ${right}`
3813
+ result = `eq ${left} ${right}`; break
3795
3814
  case '!==':
3796
3815
  case '!=':
3797
- return `ne ${left} ${right}`
3816
+ result = `ne ${left} ${right}`; break
3798
3817
  case '>':
3799
- return `gt ${left} ${right}`
3818
+ result = `gt ${left} ${right}`; break
3800
3819
  case '<':
3801
- return `lt ${left} ${right}`
3820
+ result = `lt ${left} ${right}`; break
3802
3821
  case '>=':
3803
- return `ge ${left} ${right}`
3822
+ result = `ge ${left} ${right}`; break
3804
3823
  case '<=':
3805
- return `le ${left} ${right}`
3806
- // Arithmetic in conditions
3824
+ result = `le ${left} ${right}`; break
3807
3825
  case '+':
3808
- return `bf_add ${left} ${right}`
3826
+ result = `bf_add ${left} ${right}`; break
3809
3827
  case '-':
3810
- return `bf_sub ${left} ${right}`
3828
+ result = `bf_sub ${left} ${right}`; break
3811
3829
  case '*':
3812
- return `bf_mul ${left} ${right}`
3830
+ result = `bf_mul ${left} ${right}`; break
3813
3831
  case '/':
3814
- return `bf_div ${left} ${right}`
3832
+ result = `bf_div ${left} ${right}`; break
3815
3833
  default:
3816
- return `${left} ${expr.op} ${right}`
3834
+ result = `${left} ${expr.op} ${right}`
3817
3835
  }
3836
+ return { preamble, expr: result }
3818
3837
  }
3819
3838
 
3820
3839
  case 'unary': {
3821
3840
  const arg = this.renderConditionExpr(expr.argument)
3822
- if (expr.op === '!') {
3823
- return `not ${arg}`
3824
- }
3825
- if (expr.op === '-') {
3826
- return `bf_neg ${arg}`
3827
- }
3841
+ if (expr.op === '!') return { preamble: arg.preamble, expr: `not ${arg.expr}` }
3842
+ if (expr.op === '-') return { preamble: arg.preamble, expr: `bf_neg ${arg.expr}` }
3828
3843
  return arg
3829
3844
  }
3830
3845
 
3831
3846
  case 'logical': {
3832
- const left = this.renderConditionExpr(expr.left)
3833
- const right = this.renderConditionExpr(expr.right)
3834
- // Wrap in parentheses if needed
3835
- const wrapLeft = this.needsParens(expr.left) ? `(${left})` : left
3836
- const wrapRight = this.needsParens(expr.right) ? `(${right})` : right
3837
- if (expr.op === '&&') {
3838
- return `and ${wrapLeft} ${wrapRight}`
3839
- }
3840
- return `or ${wrapLeft} ${wrapRight}`
3847
+ const leftResult = this.renderConditionExpr(expr.left)
3848
+ const rightResult = this.renderConditionExpr(expr.right)
3849
+ const preamble = leftResult.preamble + rightResult.preamble
3850
+ const wrapLeft = this.needsParens(expr.left) ? `(${leftResult.expr})` : leftResult.expr
3851
+ const wrapRight = this.needsParens(expr.right) ? `(${rightResult.expr})` : rightResult.expr
3852
+ const result = expr.op === '&&'
3853
+ ? `and ${wrapLeft} ${wrapRight}`
3854
+ : `or ${wrapLeft} ${wrapRight}`
3855
+ return { preamble, expr: result }
3841
3856
  }
3842
3857
 
3843
3858
  case 'conditional': {
3844
- // Ternary in condition: (cond ? a : b) is unusual but handle it
3845
3859
  const test = this.renderConditionExpr(expr.test)
3846
- return test // Just return the test part for condition context
3860
+ return test
3847
3861
  }
3848
3862
 
3849
3863
  case 'template-literal':
3850
- // Template literals as conditions are unusual
3851
- return this.renderParsedExpr(expr)
3864
+ return plain(this.renderParsedExpr(expr))
3852
3865
 
3853
3866
  case 'arrow-fn':
3854
- // Arrow functions shouldn't appear in conditions
3855
- return '[ARROW-FN]'
3867
+ return plain('[ARROW-FN]')
3856
3868
 
3857
- case 'higher-order':
3858
- // Higher-order methods in conditions need special handling
3859
- return this.renderParsedExpr(expr)
3869
+ case 'higher-order': {
3870
+ const rendered = this.renderParsedExpr(expr)
3871
+ const split = this.splitPreamble(rendered)
3872
+ if (split) return split
3873
+ return plain(rendered)
3874
+ }
3860
3875
 
3861
3876
  case 'array-literal':
3862
- // Array literals in conditions have no Go template form —
3863
- // delegate to renderParsedExpr so the `arrayLiteral` BF101
3864
- // gate fires consistently with non-condition positions.
3865
- return this.renderParsedExpr(expr)
3877
+ return plain(this.renderParsedExpr(expr))
3866
3878
 
3867
3879
  case 'array-method':
3868
- // Same delegation pattern — `arrayMethod` records the
3869
- // refusal diagnostic at one site rather than duplicating it
3870
- // for condition-position emission.
3871
- return this.renderParsedExpr(expr)
3880
+ return plain(this.renderParsedExpr(expr))
3872
3881
 
3873
3882
  case 'unsupported':
3874
- return expr.raw
3883
+ return plain(expr.raw)
3875
3884
  }
3876
3885
  }
3877
3886
 
@@ -3914,7 +3923,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3914
3923
 
3915
3924
  let goArray = this.convertExpressionToGo(loop.array)
3916
3925
  const param = loop.param
3917
- const index = loop.index || '_'
3926
+ let index = loop.index || '_'
3927
+
3928
+ // `.keys().map(k => ...)` — the callback param is the *index*, not
3929
+ // the value. Swap into the Go range's first binding slot so
3930
+ // `{{range $k, $_ := .Arr}}` makes `$k` the 0-based index.
3931
+ let rangeIndex = index
3932
+ let rangeValue = param
3933
+ if (loop.iterationShape === 'keys') {
3934
+ rangeIndex = param
3935
+ rangeValue = '_'
3936
+ }
3918
3937
 
3919
3938
  // Check if the loop contains a component child
3920
3939
  // If so, use .{ComponentName}s which has ScopeID for each item
@@ -3925,8 +3944,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3925
3944
  }
3926
3945
 
3927
3946
  this.inLoop = true
3928
- this.loopParamStack.push(param)
3947
+ // Track Go template loop variables. The range *value* variable
3948
+ // is the dot context (`.`) and goes on `loopParamStack`; the
3949
+ // range *index* variable needs `$name` notation and goes on
3950
+ // `loopVarRefCount`. For `.keys()`, the user's param IS the index
3951
+ // (in the `$k, $_` position), so it needs `$name` — don't push
3952
+ // it to loopParamStack (`.` would resolve to the value, not key).
3953
+ // Push `''` instead — falsy, so the `currentLoopParam &&` guard
3954
+ // in `identifier()` / `renderConditionExpr` short-circuits and
3955
+ // no name ever matches the empty string.
3956
+ // Uses ref-counting (not a flat Set) so nested loops with the
3957
+ // same index var name don't clobber the outer loop's entry on
3958
+ // cleanup.
3959
+ const addedLoopVars: string[] = []
3960
+ if (loop.iterationShape === 'keys') {
3961
+ this.loopParamStack.push('')
3962
+ this.loopVarRefCount.set(param, (this.loopVarRefCount.get(param) ?? 0) + 1)
3963
+ addedLoopVars.push(param)
3964
+ } else {
3965
+ this.loopParamStack.push(param)
3966
+ if (rangeIndex !== '_') {
3967
+ this.loopVarRefCount.set(rangeIndex, (this.loopVarRefCount.get(rangeIndex) ?? 0) + 1)
3968
+ addedLoopVars.push(rangeIndex)
3969
+ }
3970
+ }
3929
3971
  const children = this.renderChildren(loop.children)
3972
+ for (const v of addedLoopVars) {
3973
+ const rc = (this.loopVarRefCount.get(v) ?? 1) - 1
3974
+ if (rc <= 0) this.loopVarRefCount.delete(v)
3975
+ else this.loopVarRefCount.set(v, rc)
3976
+ }
3930
3977
  this.loopParamStack.pop()
3931
3978
  this.inLoop = false
3932
3979
 
@@ -3961,11 +4008,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3961
4008
 
3962
4009
  // Per-item start marker for multi-root Fragment items (#1212).
3963
4010
  const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
3964
- return `{{bfComment "loop:${loop.markerId}"}}{{range $${index}, $${param} := ${goArray}}}{{if ${filterCond}}}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
4011
+ return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}{{if ${filterCond}}}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
3965
4012
  }
3966
4013
 
3967
4014
  const itemMarker = loop.bodyIsMultiRoot ? `{{bfComment "bf-loop-i"}}` : ''
3968
- return `{{bfComment "loop:${loop.markerId}"}}{{range $${index}, $${param} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
4015
+ return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
3969
4016
  }
3970
4017
 
3971
4018
  /**