@barefootjs/go-template 0.18.3 → 0.18.5

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.
@@ -66,9 +66,10 @@ import {
66
66
  prepareLoweringMatchers,
67
67
  envSignalReaderFor,
68
68
  computeSsrSeedPlan,
69
+ isStringConcatBinary,
69
70
  } from '@barefootjs/jsx'
70
71
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
71
- import { BF_REGION } from '@barefootjs/shared'
72
+ import { BF_REGION, escapeHtml } from '@barefootjs/shared'
72
73
 
73
74
  import {
74
75
  GO_IDENTIFIER,
@@ -126,6 +127,7 @@ import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
126
127
  import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
127
128
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
128
129
  import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
130
+ import { collectStringValueNames } from "./props/prop-classes.ts"
129
131
 
130
132
  export type { GoTemplateAdapterOptions } from "./lib/types.ts"
131
133
 
@@ -225,6 +227,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
225
227
 
226
228
  private inLoop: boolean = false
227
229
  private loopParamStack: string[] = []
230
+ /**
231
+ * Stack of `IRLoop.depth` values (innermost last), pushed/popped around
232
+ * `renderChildren(loop.children)` in `renderLoop`. `renderAttributes`
233
+ * reads the top of this stack to derive the `key` → `data-key`/
234
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
235
+ * re-derived here; this stack only threads that value down to the
236
+ * loop body's root element (#2168 nested-loop-outer-binding).
237
+ */
238
+ private loopKeyDepthStack: number[] = []
228
239
  /**
229
240
  * Per-loop: true when the body renders the bare range value (scalar-item
230
241
  * inline-literal loop), so the `bf_tmpl` companion is fed `.BfLoopItem` (the
@@ -331,6 +342,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
331
342
  this.state.pendingChildrenDefines = []
332
343
  this.primeCompileState(ir)
333
344
  this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
345
+ this.state.stringValueNames = collectStringValueNames(ir)
334
346
 
335
347
  // Surface loop-body usages of sibling-imported components (see
336
348
  // `checkImportedLoopChildComponents`). The barefoot CLI compiles a
@@ -1369,6 +1381,35 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1369
1381
  break
1370
1382
  }
1371
1383
  }
1384
+ // A number/boolean JSX-EXPRESSION literal (`count={5}`,
1385
+ // `active={true}`) — as opposed to a plain quoted string attr
1386
+ // (`label="mail"`, which is `case 'literal':` above, an entirely
1387
+ // different `AttrValue` kind) — is still `kind: 'expression'`
1388
+ // here, since curly braces always parse to an expression
1389
+ // container regardless of what's inside them. #2168
1390
+ // child-primitive-props: `resolveDynamicPropValue` below only
1391
+ // recognizes a getter call or a comparison against one; a bare
1392
+ // `5`/`true` matches neither, so the field was silently OMITTED
1393
+ // and the Badge's `Count`/`Active` fields defaulted to Go's zero
1394
+ // value (`0`/`false`) regardless of the actual literal.
1395
+ //
1396
+ // Route through `parsedLiteralToGo` rather than hand-rolling a
1397
+ // switch on `.value`/`.literalType`: a numeric literal needs its
1398
+ // exact source `raw` token (Copilot review — `String(value)` can
1399
+ // change spelling/precision, e.g. a large integer or `-0`), and
1400
+ // `parsedLiteralToGo` also covers the LEADING-UNARY-MINUS shape
1401
+ // (`count={-5}` parses as `kind: 'unary'` wrapping the literal,
1402
+ // not `kind: 'literal'` itself — a case this branch's earlier
1403
+ // `parsedValue?.kind === 'literal'` gate missed entirely and
1404
+ // would have silently reintroduced the same omitted-field bug
1405
+ // for a negative numeric literal).
1406
+ if (parsedValue) {
1407
+ const goVal = parsedLiteralToGo(this.emitCtx, parsedValue)
1408
+ if (goVal !== null) {
1409
+ emitChildField(prop.name, goVal)
1410
+ break
1411
+ }
1412
+ }
1372
1413
  const resolvedValue = this.resolveDynamicPropValue(
1373
1414
  exprText,
1374
1415
  ir.metadata.signals,
@@ -1381,7 +1422,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1381
1422
  break
1382
1423
  }
1383
1424
  case 'jsx-children':
1384
- // Handled below via `child.childrenText` / `child.childrenHtml`.
1425
+ // The RESERVED children slot (`comp.children.length > 0 ? comp.children
1426
+ // : jsxChildrenPropNodes(...)`) is handled below via
1427
+ // `child.childrenText` / `child.childrenHtml` and must not be
1428
+ // re-emitted here. A JSX-valued prop under any OTHER name
1429
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) is a
1430
+ // named slot — bake it the same way real children are baked
1431
+ // (`extractTextChildren` / `extractHtmlChildren`) and emit it as
1432
+ // its own struct field, mirroring the `Children` field's
1433
+ // text-vs-HTML branching just below.
1434
+ if (prop.name !== 'children') {
1435
+ const text = this.extractTextChildren(prop.value.children)
1436
+ if (text !== null) {
1437
+ emitChildField(prop.name, JSON.stringify(text))
1438
+ } else {
1439
+ const html = this.extractHtmlChildren(prop.value.children)
1440
+ if (html !== null) {
1441
+ this.state.usesHtmlTemplate = true
1442
+ emitChildField(prop.name, `template.HTML(${JSON.stringify(html)})`)
1443
+ } else {
1444
+ // The value's root needs the PARENT's runtime scope id
1445
+ // (`<strong>` hoisted from the call site inherits the
1446
+ // caller's `bf-s`, not a bake-time constant) — same
1447
+ // needsScope case `childrenScopedHtmlExpr` handles for
1448
+ // the reserved children slot. The returned string is
1449
+ // already a Go concatenation expression (`"..." +
1450
+ // scopeID + "..."`), not a literal to re-quote.
1451
+ const scopedHtml = this.extractScopedHtmlChildren(prop.value.children)
1452
+ if (scopedHtml !== null) {
1453
+ this.state.usesHtmlTemplate = true
1454
+ emitChildField(prop.name, `template.HTML(${scopedHtml})`)
1455
+ }
1456
+ }
1457
+ }
1458
+ }
1385
1459
  break
1386
1460
  }
1387
1461
  }
@@ -2371,6 +2445,66 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2371
2445
  }
2372
2446
  }
2373
2447
 
2448
+ // A bare passthrough of the CALLER's own prop — `text={props.label}`
2449
+ // forwarding into a nested child-component invocation (or the
2450
+ // destructured form, `text={label}`) — #2168 grandchild-composition: a
2451
+ // prop re-forwarded through a second component layer matched neither
2452
+ // pattern above (not a getter call, not a literal — that's `case
2453
+ // 'literal'` above this function entirely) and fell through to `null`,
2454
+ // silently OMITTING the field so Go's zero value applied instead of the
2455
+ // threaded value. Only an EXACT `props.<name>` / bare `<name>` — no `??`
2456
+ // /`||` suffix — qualifies; a fallback-bearing expression isn't a pure
2457
+ // passthrough and must keep falling through to `null` rather than
2458
+ // silently dropping the fallback.
2459
+ //
2460
+ // Guarded against a LOCAL const/helper shadowing a propsParam's name
2461
+ // (`const label = compute(); ... text={label}` inside a component whose
2462
+ // OWN prop is also called `label`) — that bare `label` means the local,
2463
+ // not the prop, and must keep falling through to `null` rather than
2464
+ // being misresolved to `in.Label`.
2465
+ const propsObjectName = this.state.propsObjectName
2466
+ // `$` is a valid JS/TS identifier character (Copilot review, #2198) —
2467
+ // without it, a passthrough like `text={$label}` or `text={props.$label}`
2468
+ // never matches either shape below and silently falls through to `null`.
2469
+ const identifierPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
2470
+ const bareIdentifier = identifierPattern.test(expr) ? expr : null
2471
+ // Plain prefix/suffix check rather than an `expr`-interpolated `RegExp`:
2472
+ // `propsObjectName` is an arbitrary identifier and could itself contain
2473
+ // regex metacharacters (e.g. `$props`), which would silently build a
2474
+ // malformed pattern instead of erroring.
2475
+ const barePropAccess =
2476
+ propsObjectName &&
2477
+ expr.startsWith(`${propsObjectName}.`) &&
2478
+ identifierPattern.test(expr.slice(propsObjectName.length + 1))
2479
+ ? expr.slice(propsObjectName.length + 1)
2480
+ : null
2481
+ const passthroughName = bareIdentifier ?? barePropAccess
2482
+ // A `localConstants` entry sharing this name is a genuine SHADOW only
2483
+ // when it's an independent local — NOT when it's the analyzer's own
2484
+ // body-level destructure alias (`const { label } = props`, expanded to
2485
+ // a `localConstants` entry named `label` valued exactly `props.label`,
2486
+ // #1138/analyzer.ts `collectConstant`). That alias IS the same prop
2487
+ // value, so a bare `label` reference is still a pure passthrough
2488
+ // (Copilot review, #2198 — the earlier blanket "any `localConstants`
2489
+ // match blocks it" check wrongly reintroduced the omitted-field bug for
2490
+ // exactly this common destructured-body pattern). A `?? default`-bearing
2491
+ // alias value does NOT match the exact-string check below, so it's
2492
+ // correctly still treated as a shadow (a fallback-bearing local isn't a
2493
+ // pure passthrough either — same reasoning as the outer `??`/`||` guard
2494
+ // above).
2495
+ const localConst = this.state.localConstants.find(c => c.name === passthroughName)
2496
+ const isPropsDestructureAlias =
2497
+ localConst !== undefined &&
2498
+ propsObjectName !== null &&
2499
+ localConst.value === `${propsObjectName}.${passthroughName}`
2500
+ const shadowedByLocal =
2501
+ passthroughName !== null &&
2502
+ ((localConst !== undefined && !isPropsDestructureAlias) ||
2503
+ this.state.localHelperNames.has(passthroughName))
2504
+ if (passthroughName && !shadowedByLocal && propsParams.some(p => p.name === passthroughName)) {
2505
+ return `in.${capitalizeFieldName(passthroughName)}`
2506
+ }
2507
+
2374
2508
  return null
2375
2509
  }
2376
2510
 
@@ -2579,7 +2713,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2579
2713
  }
2580
2714
 
2581
2715
  emitText(node: IRText): string {
2582
- return node.value
2716
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
2717
+ // character references); re-escape for direct HTML emission.
2718
+ return escapeHtml(node.value)
2583
2719
  }
2584
2720
 
2585
2721
  emitExpression(node: IRExpression): string {
@@ -3026,6 +3162,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3026
3162
  object: ParsedExpr,
3027
3163
  property: string,
3028
3164
  _computed: boolean,
3165
+ optional: boolean,
3029
3166
  emit: (e: ParsedExpr) => string,
3030
3167
  ): string {
3031
3168
  // .length on a `.filter(...)` callback call → len (bf_filter ...)
@@ -3116,6 +3253,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3116
3253
 
3117
3254
  const obj = emit(object)
3118
3255
  if (property === 'length') return `len ${obj}`
3256
+ // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
3257
+ // a plain `.Field` dot-chain panics evaluating a field on a nil
3258
+ // interface/pointer (`nil pointer evaluating interface {}.Name`), so
3259
+ // route through the runtime's existing nil-safe reflection-based
3260
+ // getter instead — `bf_get`/`getFieldValue` (bf.go), already used for
3261
+ // map-rooted context chains above, guards the nil case and returns Go
3262
+ // `nil` (which `or`/`bf.truthy?`-style fallbacks treat as falsy) —
3263
+ // unlike that map-rooted call site, this passes the Go-cased field
3264
+ // name (`goFieldNameForKey`), matching `getFieldValue`'s struct-branch
3265
+ // exact-match fast path. Only guards the single written `?.` hop, not
3266
+ // a JS-style whole-chain short-circuit — see the `ParsedExpr` `member`
3267
+ // variant's docstring for the multi-hop caveat.
3268
+ if (optional) {
3269
+ return `bf_get ${wrapIfMultiToken(obj)} ${JSON.stringify(goFieldNameForKey(property))}`
3270
+ }
3119
3271
  return `${obj}.${goFieldNameForKey(property)}`
3120
3272
  }
3121
3273
 
@@ -3158,7 +3310,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3158
3310
  case '<=':
3159
3311
  return `le ${wl} ${wr}`
3160
3312
  case '+':
3161
- return `bf_add ${wl} ${wr}`
3313
+ return this._emitPlus(left, right, wl, wr)
3162
3314
  case '-':
3163
3315
  return `bf_sub ${wl} ${wr}`
3164
3316
  case '*':
@@ -3172,6 +3324,45 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3172
3324
  }
3173
3325
  }
3174
3326
 
3327
+ /**
3328
+ * JS `+` with a string-typed operand is CONCATENATION, not addition — Go's
3329
+ * `bf_add` coerces both sides through `toFloat64` (#2168
3330
+ * string-concat-plus: `'Hello, ' + name` rendered "0", since a string
3331
+ * operand's `toFloat64` is 0). `html/template` has no infix `+` at all, so
3332
+ * both directions are a runtime call; route through `bf_concat_str` when
3333
+ * either operand is string-typed.
3334
+ *
3335
+ * Shared by all THREE `case '+':` sites in this file (`binary()` here, the
3336
+ * filter-predicate emitter's own `binary` case, and the condition-
3337
+ * expression emitter's own `binary` case) — each recurses over its own
3338
+ * `ParsedExpr` tree with its own rendered-operand strings, but the
3339
+ * string-vs-numeric decision itself must stay identical everywhere so the
3340
+ * three never drift out of sync (a Copilot review comment on #2197 flagged
3341
+ * exactly this risk when they were three independent inline checks).
3342
+ * Callers pass their own `leftExpr`/`rightExpr` (the raw `ParsedExpr` nodes
3343
+ * `isStringConcatBinary` inspects) and `leftRendered`/`rightRendered` (the
3344
+ * already-emitted, already-wrapped/parenthesised operand text for THIS
3345
+ * call site's Go form).
3346
+ */
3347
+ private _emitPlus(
3348
+ leftExpr: ParsedExpr,
3349
+ rightExpr: ParsedExpr,
3350
+ leftRendered: string,
3351
+ rightRendered: string,
3352
+ ): string {
3353
+ if (isStringConcatBinary('+', leftExpr, rightExpr, n => this._isStringValueName(n))) {
3354
+ return `bf_concat_str ${leftRendered} ${rightRendered}`
3355
+ }
3356
+ return `bf_add ${leftRendered} ${rightRendered}`
3357
+ }
3358
+
3359
+ /** Whether `name` (a signal getter or prop) holds a string value — drives
3360
+ * `isStringConcatBinary`'s string-vs-numeric `+` decision (#2168
3361
+ * string-concat-plus). */
3362
+ private _isStringValueName(name: string): boolean {
3363
+ return this.state.stringValueNames.has(name)
3364
+ }
3365
+
3175
3366
  unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
3176
3367
  const arg = emit(argument)
3177
3368
  if (op === '!') return `not ${arg}`
@@ -3509,6 +3700,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3509
3700
  const recv = emit(object)
3510
3701
  return `bf_trim ${wrapIfMultiToken(recv)}`
3511
3702
  }
3703
+ case 'trimStart':
3704
+ case 'trimEnd': {
3705
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of `.trim()`
3706
+ // (#2183 follow-up). Dedicated `bf_trim_start` / `bf_trim_end`
3707
+ // helpers, not `bf_trim` with a flag.
3708
+ const fn = method === 'trimStart' ? 'bf_trim_start' : 'bf_trim_end'
3709
+ const recv = emit(object)
3710
+ return `${fn} ${wrapIfMultiToken(recv)}`
3711
+ }
3512
3712
  case 'toFixed': {
3513
3713
  // `.toFixed(digits?)` → `bf_to_fixed` (`fmt.Sprintf("%.*f", …)`); default
3514
3714
  // 0 digits when the argument is omitted.
@@ -3555,6 +3755,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3555
3755
  const newS = emit(args[1])
3556
3756
  return `bf_replace ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(oldS)} ${wrapIfMultiToken(newS)}`
3557
3757
  }
3758
+ case 'replaceAll': {
3759
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence, via
3760
+ // `bf_replace_all` (`strings.ReplaceAll`). A dedicated helper, not
3761
+ // `bf_replace` with a different n — the regex-pattern form is refused
3762
+ // upstream at the parser, same as `.replace`.
3763
+ const recv = emit(object)
3764
+ const oldS = emit(args[0])
3765
+ const newS = emit(args[1])
3766
+ return `bf_replace_all ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(oldS)} ${wrapIfMultiToken(newS)}`
3767
+ }
3558
3768
  case 'repeat': {
3559
3769
  // `.repeat(n)` — string repeated `n` times. `bf_repeat` clamps a negative
3560
3770
  // count to "" instead of letting `strings.Repeat` panic. No argument is
@@ -4063,7 +4273,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4063
4273
  case '<=':
4064
4274
  return `le ${left} ${right}`
4065
4275
  case '+':
4066
- return `bf_add ${left} ${right}`
4276
+ return this._emitPlus(expr.left, expr.right, left, right)
4067
4277
  case '-':
4068
4278
  return `bf_sub ${left} ${right}`
4069
4279
  case '*':
@@ -4627,7 +4837,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4627
4837
  case '<=':
4628
4838
  result = `le ${left} ${right}`; break
4629
4839
  case '+':
4630
- result = `bf_add ${left} ${right}`; break
4840
+ result = this._emitPlus(expr.left, expr.right, left, right); break
4631
4841
  case '-':
4632
4842
  result = `bf_sub ${left} ${right}`; break
4633
4843
  case '*':
@@ -4920,7 +5130,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4920
5130
  this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null,
4921
5131
  )
4922
5132
  this.loopWrapperStack.push(!!loop.childComponent)
5133
+ this.loopKeyDepthStack.push(loop.depth)
4923
5134
  const children = this.renderChildren(loop.children)
5135
+ this.loopKeyDepthStack.pop()
4924
5136
  this.loopWrapperStack.pop()
4925
5137
  this.loopScalarItemStack.pop()
4926
5138
  // Build the per-item anchor marker while the loop param is still on the
@@ -5181,7 +5393,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5181
5393
  * doesn't share a contract with the intrinsic-attribute one.
5182
5394
  */
5183
5395
  private readonly elementAttrEmitter: AttrValueEmitter = {
5184
- emitLiteral: (value, name) => `${name}="${value.value}"`,
5396
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
5185
5397
  emitExpression: (value, name) => {
5186
5398
  // `style={{ … }}` object literal → a CSS string with dynamic values
5187
5399
  // interpolated, instead of refusing the bare object with BF101.
@@ -5365,10 +5577,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5365
5577
  // Rewrite JSX special-prop names to their HTML-attribute counterparts. The
5366
5578
  // Go template adapter has no JSX runtime to strip `key` / emit `data-key`,
5367
5579
  // so the rewrite happens at attribute-emit time. Mirror of the `key`
5368
- // branch in `ir-to-client-js/html-template.ts`.
5580
+ // branch in `ir-to-client-js/html-template.ts`. The depth-suffix (plain
5581
+ // `data-key` at the outermost loop, `data-key-N` N levels deep) comes
5582
+ // from `IRLoop.depth` via `loopKeyDepthStack`, not re-derived here.
5369
5583
  let attrName: string
5370
5584
  if (attr.name === 'className') attrName = 'class'
5371
- else if (attr.name === 'key') attrName = 'data-key'
5585
+ else if (attr.name === 'key') {
5586
+ const depth = this.loopKeyDepthStack.at(-1) ?? 0
5587
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
5588
+ }
5372
5589
  else attrName = attr.name
5373
5590
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
5374
5591
  if (lowered) parts.push(lowered)
@@ -137,6 +137,14 @@ export class CompileState {
137
137
  */
138
138
  nillablePropNames: Set<string> = new Set()
139
139
 
140
+ /**
141
+ * String-typed signal getter / prop names (#2168 string-concat-plus).
142
+ * Feeds `isStringName` for `isStringConcatBinary`, which decides whether a
143
+ * JS `+` operand chain is string concatenation rather than numeric
144
+ * addition — see `collectStringValueNames` (`props/prop-classes.ts`).
145
+ */
146
+ stringValueNames: Set<string> = new Set()
147
+
140
148
  /** Component root scope element(s) — each carries `data-key` for a keyed loop
141
149
  * item. */
142
150
  rootScopeNodes: Set<IRNode> = new Set()
@@ -17,6 +17,7 @@ export const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
17
17
  'Math.floor': { arity: 1, emit: (args) => `bf_floor ${wrapGoArg(args[0])}` },
18
18
  'Math.ceil': { arity: 1, emit: (args) => `bf_ceil ${wrapGoArg(args[0])}` },
19
19
  'Math.round': { arity: 1, emit: (args) => `bf_round ${wrapGoArg(args[0])}` },
20
+ 'Math.abs': { arity: 1, emit: (args) => `bf_abs ${wrapGoArg(args[0])}` },
20
21
  // Two-arg forms only; an N-arg `Math.min(a, b, c)` falls through to the
21
22
  // standard BF101 unsupported-call diagnostic via the arity gate.
22
23
  'Math.min': { arity: 2, emit: (args) => `bf_min ${wrapGoArg(args[0])} ${wrapGoArg(args[1])}` },
@@ -366,13 +366,40 @@ export function memoInitialFromParsedBody(
366
366
  const operator = body.op
367
367
  const operand = String(body.right.value)
368
368
 
369
- // getter() * N — return the signal's Go initial value times N.
369
+ // getter() * N — return the signal's (or, #2168 memo-chain, another
370
+ // memo's) Go initial value times N. `resolveGetterValueAsGo` checks
371
+ // `signals` first (unchanged behavior for a signal-derived memo like
372
+ // `doubled = createMemo(() => count() * 2)`), then falls back to
373
+ // `ctx.state.currentMemos` and recurses — needed for a memo derived from
374
+ // ANOTHER memo (`label = createMemo(() => doubled() + 1)`), which this
375
+ // branch previously couldn't recognize at all (a signals-only lookup),
376
+ // silently folding to the Go zero value instead of "7".
370
377
  const depName = getterCallName(body.left)
371
378
  if (depName) {
372
- const signal = signals.find(s => s.getter === depName)
373
- if (signal) {
374
- const signalInitial = getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
375
- return `${signalInitial} ${operator} ${operand}`
379
+ const depInitial = resolveGetterValueAsGo(ctx, depName, signals, propsParams, propFallbackVars, resolving)
380
+ // `resolveGetterValueAsGo` can return an IIFE (`func() string { ... }()`
381
+ // / `func() interface{} { ... }()`, e.g. a memo that shadows `props.X
382
+ // ?? <lit>`, #2075) rather than a plain atom — none of those return
383
+ // types support Go's `<op>` arithmetic operators, so splicing one in
384
+ // bare would emit invalid Go (Copilot review, #2200: e.g. `operator
385
+ // is not defined on interface{}`/`string`). Bail (fall through to the
386
+ // caller's zero-value default) rather than emit broken arithmetic.
387
+ const isArithmeticSafe = depInitial !== null && !depInitial.startsWith('func(')
388
+ if (isArithmeticSafe) {
389
+ // A signal's own initial value is always a simple atom (a literal,
390
+ // `in.Field`, a hoisted var) — never needs grouping. A MEMO's initial
391
+ // value can itself be a compound expression from this exact branch
392
+ // one level up (`"3 * 2"`), and splicing that in bare under a
393
+ // DIFFERENT outer operator can silently invert precedence (a memo
394
+ // chain shaped `inner = () => count() + 1` then `outer = () =>
395
+ // inner() * 2` would fold to `3 + 1 * 2` = 5 in Go, vs JS's `(3+1)*2`
396
+ // = 8). Parenthesize whenever `depInitial` is compound (contains
397
+ // whitespace — a simple atom never does) so precedence is
398
+ // preserved regardless of which operator combination a memo chain
399
+ // uses. A simple atom is left bare so existing exact-text
400
+ // expectations (`Doubled: 3 * 2,`) don't gain a no-op paren.
401
+ const wrapped = /\s/.test(depInitial) ? `(${depInitial})` : depInitial
402
+ return `${wrapped} ${operator} ${operand}`
376
403
  }
377
404
  }
378
405
 
@@ -408,12 +435,13 @@ export function memoInitialFromParsedBody(
408
435
  }
409
436
  }
410
437
 
411
- // () => getter() — just return the signal's Go initial value.
438
+ // () => getter() — just return the signal's (or another memo's, #2168
439
+ // memo-chain) Go initial value.
412
440
  const simpleDep = getterCallName(body)
413
441
  if (simpleDep) {
414
- const signal = signals.find(s => s.getter === simpleDep)
415
- if (signal) {
416
- return getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
442
+ const depInitial = resolveGetterValueAsGo(ctx, simpleDep, signals, propsParams, propFallbackVars, resolving)
443
+ if (depInitial !== null) {
444
+ return depInitial
417
445
  }
418
446
  }
419
447
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Prop classification for the Go template adapter.
3
+ *
4
+ * Ported from `packages/adapter-blade/src/adapter/props/prop-classes.ts` (itself
5
+ * ported from Jinja) — ONE function: string-typed signal/prop names, needed to
6
+ * decide `+` string-concat vs numeric addition (#2168 string-concat-plus). Pure
7
+ * function over `ir.metadata`; no adapter instance state.
8
+ */
9
+
10
+ import type { ComponentIR, TypeInfo } from '@barefootjs/jsx'
11
+
12
+ /** True when `type` is the `string` primitive. */
13
+ function isStringTypeInfo(type: TypeInfo): boolean {
14
+ return type.kind === 'primitive' && type.primitive === 'string'
15
+ }
16
+
17
+ /** True when `initialValue` is a bare string-literal expression. */
18
+ function isBareStringLiteral(initialValue: string | undefined): boolean {
19
+ if (!initialValue) return false
20
+ const v = initialValue.trim()
21
+ return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
22
+ }
23
+
24
+ /**
25
+ * String-typed signals and props. A signal is string-typed when its inferred
26
+ * type is `string` (or, defensively, when its initial value is a bare string
27
+ * literal); a prop when its annotated type is `string`. Drives `isStringName`
28
+ * for `isStringConcatBinary` — the shared helper (`@barefootjs/jsx`) that
29
+ * decides whether a JS `+` is string concatenation rather than numeric
30
+ * addition (Go's `html/template` has no native `+` at all; `binary()` always
31
+ * emits a runtime call, `bf_add` for addition or `bf_concat_str` for
32
+ * concatenation — see `go-template-adapter.ts`'s `binary()`).
33
+ */
34
+ export function collectStringValueNames(ir: ComponentIR): Set<string> {
35
+ const names = new Set<string>()
36
+ for (const s of ir.metadata.signals) {
37
+ if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
38
+ names.add(s.getter)
39
+ }
40
+ }
41
+ for (const p of ir.metadata.propsParams) {
42
+ if (isStringTypeInfo(p.type)) names.add(p.name)
43
+ }
44
+ return names
45
+ }
@@ -5,7 +5,7 @@
5
5
  * generators and the nillable-field set so they can't drift.
6
6
  */
7
7
 
8
- import type { ComponentIR, IRMetadata } from '@barefootjs/jsx'
8
+ import type { ComponentIR, IRMetadata, IRNode, ParsedExpr } from '@barefootjs/jsx'
9
9
 
10
10
  import type { GoEmitContext } from '../emit-context.ts'
11
11
  import { typeInfoToGo } from '../type/type-codegen.ts'
@@ -35,9 +35,77 @@ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map
35
35
  }
36
36
  }
37
37
  }
38
+
39
+ // A bare `number`-typed prop with no other evidence resolves to Go `int`
40
+ // (`typeInfoToGo`'s blind default) — but `.toFixed()` can only be called on
41
+ // a real JS number, and the runtime value it formats (e.g. a price, 19.5)
42
+ // may be fractional, which a Go `int` struct field can't hold (assigning a
43
+ // fractional untyped constant to it is a compile error: #2168
44
+ // number-tofixed). Unlike a signal's fractional LITERAL initial value
45
+ // (rescued by `typeInfoToGo`'s own `defaultValue` consultation — the
46
+ // math-methods half of the same divergence), a prop with no default has no
47
+ // literal to read the fraction off of; the usage of `.toFixed()` itself is
48
+ // the only available evidence, so it's collected by walking the JSX tree.
49
+ for (const propName of collectToFixedPropNames(ir.root)) {
50
+ const param = ir.metadata.propsParams.find(p => p.name === propName)
51
+ if (!param) continue
52
+ const resolved = overrides.get(propName) ?? typeInfoToGo(ctx, param.type, param.defaultValue)
53
+ if (resolved === 'int') {
54
+ overrides.set(propName, 'float64')
55
+ }
56
+ }
57
+
38
58
  return overrides
39
59
  }
40
60
 
61
+ /**
62
+ * Names of identifiers used as the receiver of `.toFixed(...)` anywhere in
63
+ * the component's JSX tree (text expressions, conditions, and attribute
64
+ * values). Deliberately narrow — `.toFixed()` is the one number-shape usage
65
+ * that needs this rescue (see `buildPropTypeOverrides` above); it isn't a
66
+ * general "infer number-ness from usage" walker.
67
+ *
68
+ * KNOWN LIMITATION: only a DIRECT identifier receiver in the JSX tree is
69
+ * caught (`{price.toFixed(2)}`). A bare `number` prop with no default is
70
+ * still silently typed `int` — and hits the exact same `go run` compile
71
+ * failure (#2168 number-tofixed) on a fractional runtime value — if the
72
+ * fraction only surfaces indirectly (`.toFixed()` inside a signal's
73
+ * initial value or a memo's computation, reached via `ir.metadata` rather
74
+ * than `ir.root`) or via any OTHER fraction-producing operation on the
75
+ * same bare prop (division, `Math.round`/`Math.floor`, etc. — none of
76
+ * which carry the same unambiguous "this must be a real JS number" signal
77
+ * `.toFixed()` does). Widening this walker to those cases is a real,
78
+ * currently-unaddressed gap, not a hypothetical one — flag it rather than
79
+ * treating a future occurrence as a fresh regression.
80
+ */
81
+ function collectToFixedPropNames(root: IRNode): Set<string> {
82
+ const names = new Set<string>()
83
+ const checkExpr = (expr: ParsedExpr | undefined) => {
84
+ if (expr?.kind === 'array-method' && expr.method === 'toFixed' && expr.object.kind === 'identifier') {
85
+ names.add(expr.object.name)
86
+ }
87
+ }
88
+ const walk = (node: IRNode | null | undefined) => {
89
+ if (!node) return
90
+ if (node.type === 'expression') checkExpr(node.parsed)
91
+ if (node.type === 'conditional') {
92
+ checkExpr(node.parsedCondition)
93
+ walk(node.whenTrue)
94
+ walk(node.whenFalse)
95
+ }
96
+ if (node.type === 'element') {
97
+ for (const attr of node.attrs) {
98
+ if (attr.value.kind === 'expression') checkExpr(attr.value.parsed)
99
+ }
100
+ }
101
+ if ('children' in node && Array.isArray(node.children)) {
102
+ node.children.forEach(walk)
103
+ }
104
+ }
105
+ walk(root)
106
+ return names
107
+ }
108
+
41
109
  /**
42
110
  * Resolve a prop param's Go struct-field type using the SAME logic
43
111
  * `generatePropsStruct` / `generateInputStruct` use: a `propTypeOverrides` entry
@@ -18,7 +18,11 @@ import type { GoEmitContext } from '../emit-context.ts'
18
18
  /**
19
19
  * Convert a `TypeInfo` to a Go type string.
20
20
  *
21
- * @param defaultValue used to infer the type when `typeInfo.kind` is `unknown`
21
+ * @param defaultValue used to infer the type when `typeInfo.kind` is
22
+ * `unknown`, and to distinguish `int` vs `float64` when `kind` is
23
+ * `primitive`/`number` (#2168 math-methods/number-tofixed — a bare TS
24
+ * `number` blindly mapped to Go `int`, so a fractional signal initial
25
+ * value like `-7.6` silently truncated to the Go zero value)
22
26
  * @returns the Go type, falling back to `interface{}` when unresolvable
23
27
  */
24
28
  export function typeInfoToGo(
@@ -32,7 +36,7 @@ export function typeInfoToGo(
32
36
  case 'string':
33
37
  return 'string'
34
38
  case 'number':
35
- return 'int'
39
+ return defaultValue !== undefined ? numberPrimitiveGoType(defaultValue) : 'int'
36
40
  case 'boolean':
37
41
  return 'bool'
38
42
  default:
@@ -99,6 +103,19 @@ export function tsTypeStringToGo(ctx: GoEmitContext, tsType: string): string {
99
103
  return 'interface{}'
100
104
  }
101
105
 
106
+ /**
107
+ * Distinguish Go `int` vs `float64` for a `number`-typed field from the
108
+ * literal source text of its default/initial value. Falls back to `int`
109
+ * when `value` isn't recognizably a bare numeric literal (e.g. a
110
+ * destructured default that's itself an expression, `props.initial ?? 0`)
111
+ * — `int` remains the blind fallback for `kind: 'primitive'`; only a
112
+ * literal fractional value (`-7.6`) is positive enough evidence to widen
113
+ * to `float64`.
114
+ */
115
+ function numberPrimitiveGoType(value: string): string {
116
+ return /^-?\d+\.\d+$/.test(value) ? 'float64' : 'int'
117
+ }
118
+
102
119
  /** Infer a Go type from a JS value literal; `interface{}` when unrecognized. */
103
120
  export function inferTypeFromValue(value: string): string {
104
121
  if (value === 'true' || value === 'false') return 'bool'