@barefootjs/go-template 0.35.0 → 0.35.2

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.
@@ -376,6 +376,161 @@ describe('GoTemplateAdapter - bf_ternary value-position lowering (#2335)', () =>
376
376
  test('emits no {{if}} action fragment for a value-position ternary', () => {
377
377
  expect(render("flag ? 'a' : 'b'")).not.toContain('{{if')
378
378
  })
379
+
380
+ // #2863: a multi-part template literal (mixed literal text and MULTIPLE
381
+ // interpolations) as a ternary BRANCH used to lower through the TEXT-
382
+ // position `templateLiteral()` path (`{{.Start}}-{{.End}}`), nesting raw
383
+ // `{{`/`}}` delimiters inside `bf_ternary`'s own argument list — a
384
+ // `html/template` parse error ("unexpected \"{\" in operand") at Go
385
+ // application startup, even though `bf build` itself succeeded. It must
386
+ // instead fold to one pipeline value, chained through the same
387
+ // `bf_concat_str` runtime helper JS string-concat `+` already uses (#2168).
388
+ test('a multi-part template-literal branch folds to a bf_concat_str chain, not a raw fragment (#2863)', () => {
389
+ expect(render('end ? `${start}-${end}` : start')).toBe(
390
+ '{{(bf_ternary (bf_truthy .End) (bf_concat_str (bf_concat_str .Start "-") .End) .Start)}}',
391
+ )
392
+ })
393
+
394
+ test('a template-literal branch never leaks a raw {{ or }} into the surrounding action (#2863)', () => {
395
+ const out = render('end ? `${start}-${end}` : start')
396
+ const inner = out.slice(2, -2) // strip the outer {{ }} the whole expression is wrapped in
397
+ expect(inner).not.toContain('{{')
398
+ expect(inner).not.toContain('}}')
399
+ })
400
+
401
+ test('a single-part template-literal branch reduces to the bare value, no bf_concat_str wrap (#2863)', () => {
402
+ expect(render('end ? `${start}` : end')).toBe('{{(bf_ternary (bf_truthy .End) .Start .End)}}')
403
+ })
404
+
405
+ test('static text in a template-literal branch is an escaped Go string literal (#2863)', () => {
406
+ expect(render('end ? `say "hi" ${start}` : end')).toBe(
407
+ '{{(bf_ternary (bf_truthy .End) (bf_concat_str "say \\"hi\\" " .Start) .End)}}',
408
+ )
409
+ })
410
+
411
+ test('a nested ternary inside a template-literal branch still lowers to bf_ternary, not {{if}} (#2863)', () => {
412
+ const out = render('end ? `x ${flag ? \'a\' : \'b\'}` : end')
413
+ expect(out).toBe(
414
+ '{{(bf_ternary (bf_truthy .End) (bf_concat_str "x " (bf_ternary (bf_truthy .Flag) "a" "b")) .End)}}',
415
+ )
416
+ expect(out).not.toContain('{{if')
417
+ })
418
+ })
419
+
420
+ // #2863 follow-up: the same value-position leak existed for a `+`/`||`
421
+ // operand and a registered-lowering (`queryHref`) argument, not just a
422
+ // `bf_ternary` branch — every such position funnels through the Go
423
+ // adapter's shared `lowerValueOperand` door (`expr/url-builder.ts`).
424
+ describe('GoTemplateAdapter - value-position template literal in other operand positions (#2863)', () => {
425
+ const adapter = new GoTemplateAdapter()
426
+ const render = (expr: string) => adapter.renderExpression({ expr } as IRExpression)
427
+
428
+ test('a template-literal operand of `+` folds through bf_concat_str, not a raw fragment', () => {
429
+ const out = render('`${a}-${b}` + a')
430
+ const inner = out.slice(2, -2)
431
+ expect(inner).not.toContain('{{')
432
+ expect(inner).toContain('bf_concat_str')
433
+ })
434
+
435
+ test('a template-literal operand of `||` folds through bf_concat_str, not a raw fragment', () => {
436
+ const out = render('a || `${a}-${b}`')
437
+ const inner = out.slice(2, -2)
438
+ expect(inner).not.toContain('{{')
439
+ })
440
+
441
+ // The issue's exact repro shape: a local, expression-bodied helper arrow
442
+ // (`eventTime`) whose body is the ternary+template-literal, called from a
443
+ // `.map()` row. A ternary written DIRECTLY inline in JSX instead compiles
444
+ // to a text-position `IRConditional` (`{{if}}…{{else}}…{{end}}`, correct
445
+ // and unaffected by this bug) — the buggy VALUE-position path is only
446
+ // reached once `inlineLocalHelperCall` substitutes the helper's body into
447
+ // the `eventTime(event)` call site and the Go adapter re-parses the
448
+ // result as a plain expression, not a JSX conditional.
449
+ test('the issue #2863 repro: a local-helper ternary+template-literal inlined in a .map() row lowers to valid Go source', () => {
450
+ const { template } = compileAndGenerate(`
451
+ 'use client'
452
+ import { createSignal } from '@barefootjs/client'
453
+ type Event = { id: string; time: string; endTime: string }
454
+ type Dashboard = { events: Event[] }
455
+ const empty: Dashboard = { events: [] }
456
+ export function Schedule() {
457
+ const [data] = createSignal<Dashboard>(empty)
458
+ const eventTime = (event: Event) =>
459
+ event.endTime ? \`\${event.time}–\${event.endTime}\` : event.time
460
+ return (
461
+ <ol>
462
+ {data().events.map((event) => (
463
+ <li key={event.id}><time>{eventTime(event)}</time></li>
464
+ ))}
465
+ </ol>
466
+ )
467
+ }
468
+ `)
469
+ expect(template).toContain(
470
+ '(bf_ternary (bf_truthy .EndTime) (bf_concat_str (bf_concat_str .Time "–") .EndTime) .Time)',
471
+ )
472
+ expect(template).not.toContain('{{.Time}}–{{.EndTime}}')
473
+ })
474
+ })
475
+
476
+ // #2863 (pullfrog review on #2877): three more argument/operand positions
477
+ // that reach a template literal without going through `lowerValueOperand`/
478
+ // `emitOperand` — verified to reproduce the identical `unexpected "{" in
479
+ // operand` class of bug before this fix, via a standalone script driving
480
+ // `html/template.Parse` on the pre-fix output.
481
+ describe('GoTemplateAdapter - value-position template literal in condition/array-method/index positions (#2863 follow-up)', () => {
482
+ // `renderConditionExpr`'s own `binary`/`unary`/`logical` recursion is a
483
+ // SEPARATE walker from the generic `emit()` dispatcher (it threads a
484
+ // `preamble` string `emitOperand` doesn't track) — reached whenever a
485
+ // ternary/`&&`/`||`/`!` TEST is a comparison against (or otherwise
486
+ // contains) a template literal, since `lowerTernaryTest`/`lowerUrlGuard`'s
487
+ // "bool-shape" branch routes through `convertConditionToGo` instead of
488
+ // `lowerValueOperand`.
489
+ test('a ternary TEST comparing to a template literal folds through bf_concat_str inside the {{if}}', () => {
490
+ const { template } = compileAndGenerate(`
491
+ 'use client'
492
+ import { createSignal } from '@barefootjs/client'
493
+ export function T() {
494
+ const [x] = createSignal('x')
495
+ const [y] = createSignal('y')
496
+ const [z] = createSignal('z')
497
+ return <span>{(x() === \`\${y()}-\${z()}\`) ? 'a' : 'b'}</span>
498
+ }
499
+ `)
500
+ expect(template).toContain('{{if eq .X (bf_concat_str (bf_concat_str .Y "-") .Z)}}')
501
+ })
502
+
503
+ // `arrayMethod()`'s per-method cases (`join`, `includes`, `indexOf`, …) all
504
+ // lower their receiver/args via the dispatcher's plain `emit`, not
505
+ // `emitOperand` — no ternary or helper-inlining needed to reach the bug.
506
+ test('Array.join with a template-literal separator folds through bf_concat_str', () => {
507
+ const { template } = compileAndGenerate(`
508
+ 'use client'
509
+ import { createSignal } from '@barefootjs/client'
510
+ export function T() {
511
+ const [items] = createSignal<string[]>([])
512
+ const [y] = createSignal('y')
513
+ const [z] = createSignal('z')
514
+ return <span>{items().join(\`\${y()}-\${z()}\`)}</span>
515
+ }
516
+ `)
517
+ expect(template).toContain('bf_join (.Items) (bf_concat_str (bf_concat_str .Y "-") .Z)')
518
+ })
519
+
520
+ // `indexAccess()` lowers both the object and the index via plain `emit`.
521
+ test('a computed index-access with a template-literal key folds through bf_concat_str', () => {
522
+ const { template } = compileAndGenerate(`
523
+ 'use client'
524
+ import { createSignal } from '@barefootjs/client'
525
+ export function T() {
526
+ const [obj] = createSignal<Record<string, string>>({})
527
+ const [y] = createSignal('y')
528
+ const [z] = createSignal('z')
529
+ return <span>{obj()[\`\${y()}-\${z()}\`]}</span>
530
+ }
531
+ `)
532
+ expect(template).toContain('bf_get .Obj (bf_concat_str (bf_concat_str .Y "-") .Z)')
533
+ })
379
534
  })
380
535
 
381
536
  // #2335 item 2 (correctness): a ternary used as a boolean CONDITION used to
@@ -1486,7 +1641,7 @@ export function Widget(props: P) {
1486
1641
  // to `nil` — the analyzer types it `unknown` since it never chases an
1487
1642
  // identifier to its declaration, so none of convertInitialValue's typed
1488
1643
  // branches saw it (#2794). Now resolved via the same
1489
- // resolveModuleStringConst/resolveModuleNumericConst the adapter
1644
+ // resolveModuleStringConst/resolveModuleConstAsGo the adapter
1490
1645
  // already used for live template expressions (template-interp.ts).
1491
1646
  test('signal seeded from a module-level const bakes its literal value, not nil', () => {
1492
1647
  const adapter = new GoTemplateAdapter()
@@ -4701,11 +4856,14 @@ export function CompositeRowChildComponent(props: { items: Item[] }) {
4701
4856
  })
4702
4857
 
4703
4858
  // A multi-part template literal (mixed literal text and interpolation,
4704
- // `` `#${row.id} ${row.label}` ``) has no single-pipeline-value reduction
4705
- // the way a single-part ternary doesrefuse it loudly (BF101) instead
4706
- // of silently emitting the stale constructor-only value (the #2445 bug
4707
- // this whole fix exists to close).
4708
- test('a multi-part template-literal per-row prop is refused with BF101, not silently stale', () => {
4859
+ // `` `#${row.id} ${row.label}` ``) folds through the shared value-operand
4860
+ // door (#2863) a left-folded `bf_concat_str` chaininstead of being
4861
+ // refused (BF101) the way it used to be here (this call site had no
4862
+ // reduction for the multi-part case before #2863, only a single-part
4863
+ // unwrap). It must still NOT silently emit the stale constructor-only
4864
+ // value (the #2445 bug this whole fix exists to close) — it now emits the
4865
+ // correct live per-row value instead.
4866
+ test('a multi-part template-literal per-row prop folds through bf_concat_str, not silently stale (#2445, #2863)', () => {
4709
4867
  const result = compileJSX(`
4710
4868
  'use client'
4711
4869
  import { createSignal } from '@barefootjs/client'
@@ -4726,9 +4884,11 @@ export function CompositeRowChildComponent(props: { items: Item[] }) {
4726
4884
  )
4727
4885
  }
4728
4886
  `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4729
- expect((result.errors ?? []).some(e => e.code === 'BF101')).toBe(true)
4887
+ expect(result.errors ?? []).toEqual([])
4730
4888
  const template = result.files.find(f => f.type === 'markedTemplate')!.content
4731
- expect(template).not.toContain('bf_with_props')
4889
+ expect(template).toContain(
4890
+ '{{template "Badge" (bf_with_props $.BadgeSlot0 "Text" (bf_concat_str (bf_concat_str (bf_concat_str "#" .ID) " ") .Label))}}',
4891
+ )
4732
4892
  })
4733
4893
 
4734
4894
  // A prop whose expression `convertExpressionToGo` itself refuses (an
@@ -6048,7 +6208,7 @@ export function Nested({ groups }: { groups: { label: string }[][] }) {
6048
6208
  // goes through `identifier()` (the `ParsedExprEmitter` method), which
6049
6209
  // already carries the loop-shadow guards (`loopParamStack` /
6050
6210
  // `isOuterLoopParam`, mirrored from `resolveModuleStringConst` /
6051
- // `resolveModuleNumericConst`). So a `.map((count) => ...)` callback param
6211
+ // `resolveModuleConstAsGo`). So a `.map((count) => ...)` callback param
6052
6212
  // that shadows an outer `const count = 7` got the OUTER literal inlined at
6053
6213
  // the `data-key` position even though the text position (which DOES go
6054
6214
  // through `identifier()`) correctly resolved to the per-item value.
@@ -14,7 +14,7 @@
14
14
  * genuinely needs it, so the seam documents the real cross-module coupling.
15
15
  */
16
16
 
17
- import type { ParsedExpr } from '@barefootjs/jsx'
17
+ import type { ParsedExpr, TypeInfo } from '@barefootjs/jsx'
18
18
 
19
19
  import type { CompileState } from './lib/compile-state.ts'
20
20
 
@@ -81,16 +81,18 @@ export interface GoEmitContext {
81
81
  resolveModuleStringConst(name: string): string | null
82
82
 
83
83
  /**
84
- * Inline a module numeric const by name as its Go literal text (e.g.
85
- * `8`, `-3.5`), or null when the name is not such a const (loop vars and
86
- * outer-loop params are excluded, same as `resolveModuleStringConst`).
84
+ * Resolve a bare identifier naming a plain module-level const (`const
85
+ * TRACK = 8`, `const OPEN = true`, `const INITIAL: Row[] = [...]`) to its
86
+ * Go literal, dispatched structurally off the const's `ConstantInfo.parsed`
87
+ * — or null when the name is not such a const (loop vars and outer-loop
88
+ * params are excluded, same as `resolveModuleStringConst`). `target.kind
89
+ * === 'go-source'` may bake a composite (array/object) literal against
90
+ * `target.bakeType`; `target.kind === 'template-action'` only resolves a
91
+ * scalar, since a `{{...}}` splice has no valid Go template spelling for a
92
+ * composite literal.
87
93
  */
88
- resolveModuleNumericConst(name: string): string | null
89
-
90
- /**
91
- * Inline a module boolean const by name as its Go literal text
92
- * (`true`/`false`), or null when the name is not such a const (same
93
- * exclusions as `resolveModuleNumericConst`).
94
- */
95
- resolveModuleBooleanConst(name: string): string | null
94
+ resolveModuleConstAsGo(
95
+ name: string,
96
+ target: { kind: 'template-action' } | { kind: 'go-source'; bakeType: TypeInfo },
97
+ ): string | null
96
98
  }
@@ -10,13 +10,14 @@
10
10
  import {
11
11
  type LoweringNode,
12
12
  type ParsedExpr,
13
+ type TemplatePart,
13
14
  parseExpression,
14
15
  stringifyParsedExpr,
15
16
  isValidHelperId,
16
17
  } from '@barefootjs/jsx'
17
18
 
18
19
  import type { GoEmitContext } from '../emit-context.ts'
19
- import { wrapIfMultiToken } from '../lib/go-emit.ts'
20
+ import { escapeGoString, wrapIfMultiToken } from '../lib/go-emit.ts'
20
21
 
21
22
  /**
22
23
  * Logical helper id → Go template helper name. `bf_<helper>` is a formula,
@@ -61,7 +62,7 @@ function lowerUrlGuard(ctx: GoEmitContext, g: ParsedExpr): string {
61
62
  if (isBoolShape) {
62
63
  return ctx.convertConditionToGo(stringifyParsedExpr(g), g).condition
63
64
  }
64
- const valueGo = wrapIfMultiToken(ctx.convertExpressionToGo(stringifyParsedExpr(g), undefined, g))
65
+ const valueGo = lowerValueOperand(ctx, g)
65
66
  return `ne ${valueGo} ""`
66
67
  }
67
68
 
@@ -91,21 +92,63 @@ export function lowerTernary(
91
92
  alternate: ParsedExpr,
92
93
  ): string {
93
94
  const t = lowerTernaryTest(ctx, test)
94
- return `(bf_ternary ${t} ${lowerTernaryOperand(ctx, consequent)} ${lowerTernaryOperand(ctx, alternate)})`
95
+ return `(bf_ternary ${t} ${lowerValueOperand(ctx, consequent)} ${lowerValueOperand(ctx, alternate)})`
95
96
  }
96
97
 
97
98
  /**
98
- * A ternary branch in value position: a nested ternary recurses to another
99
- * `bf_ternary`; anything else lowers to its Go value (parenthesised when
100
- * multi-token so it stays one argument). String branches become quoted,
101
- * html/template-escaped values not the bare unquoted text the old `{{if}}`
102
- * fragment path emitted which is exactly right for the value positions this
103
- * is reached for.
99
+ * Lower a template literal to ONE Go pipeline VALUE (#2863): a left fold of
100
+ * `bf_concat_str` over the parts static text as an escaped Go string
101
+ * literal, each interpolation as its own value operand. The TEXT-position
102
+ * form (`GoTemplateAdapter.templateLiteral()`: literal text interleaved with
103
+ * `{{…}}` actions) is only legal where the result is spliced directly into
104
+ * markup; inside another action's argument list (a `bf_ternary` branch, a
105
+ * helper-call arg, a `bf_query` guard value, …) Go rejects nested `{{`/`}}`
106
+ * delimiters ("unexpected \"{\" in operand"). This is the Go twin of the
107
+ * concatenation every other DSL adapter already emits for the same nested
108
+ * shape (e.g. Jinja/Twig `bf.string(a) ~ '–' ~ bf.string(b)`) — Go was the
109
+ * lone outlier because `text/template` has no expression-level string
110
+ * concatenation operator of its own, only this runtime helper (already used
111
+ * by `binary()`'s string-typed `+`, #2168).
112
+ *
113
+ * A single-part literal (`` `${x}` `` alone) reduces to that one value with
114
+ * no `bf_concat_str` wrapper — mirrors the sibling single-part unwrap at the
115
+ * child-prop call site. An all-static literal (already reduced to a plain
116
+ * `literal` ParsedExpr by the parser in practice, but handled here too)
117
+ * folds to one escaped string.
104
118
  */
105
- function lowerTernaryOperand(ctx: GoEmitContext, n: ParsedExpr): string {
119
+ export function lowerTemplateLiteralValue(ctx: GoEmitContext, parts: readonly TemplatePart[]): string {
120
+ const terms: string[] = []
121
+ for (const part of parts) {
122
+ if (part.type === 'string') {
123
+ if (part.value !== '') terms.push(`"${escapeGoString(part.value)}"`)
124
+ } else {
125
+ terms.push(lowerValueOperand(ctx, part.expr))
126
+ }
127
+ }
128
+ if (terms.length === 0) return '""'
129
+ return terms.slice(1).reduce((acc, t) => `(bf_concat_str ${acc} ${t})`, terms[0])
130
+ }
131
+
132
+ /**
133
+ * The single door for "this ParsedExpr sits in Go pipeline ARGUMENT
134
+ * position" — a `bf_ternary` branch, a ternary/guard test's value form, a
135
+ * helper-call arg, a `bf_query` base/guard/value. A nested ternary recurses
136
+ * to another `bf_ternary`; a template literal folds through
137
+ * `lowerTemplateLiteralValue` (#2863) rather than the generic expression
138
+ * path, which would otherwise return the TEXT-position mixed
139
+ * literal-text-plus-`{{…}}`-actions form; anything else lowers to its Go
140
+ * value (parenthesised when multi-token so it stays one argument). String
141
+ * branches become quoted, html/template-escaped values — not the bare
142
+ * unquoted text the old `{{if}}` fragment path emitted — which is exactly
143
+ * right for the value positions this is reached for.
144
+ */
145
+ export function lowerValueOperand(ctx: GoEmitContext, n: ParsedExpr): string {
106
146
  if (n.kind === 'conditional') {
107
147
  return lowerTernary(ctx, n.test, n.consequent, n.alternate)
108
148
  }
149
+ if (n.kind === 'template-literal') {
150
+ return wrapIfMultiToken(lowerTemplateLiteralValue(ctx, n.parts))
151
+ }
109
152
  return wrapIfMultiToken(ctx.convertExpressionToGo(stringifyParsedExpr(n), undefined, n))
110
153
  }
111
154
 
@@ -120,7 +163,7 @@ function lowerTernaryOperand(ctx: GoEmitContext, n: ParsedExpr): string {
120
163
  * counterpart of `lowerUrlGuard`'s string-only `ne <value> ""`.
121
164
  */
122
165
  function lowerTernaryTest(ctx: GoEmitContext, test: ParsedExpr): string {
123
- const go = wrapIfMultiToken(ctx.convertExpressionToGo(stringifyParsedExpr(test), undefined, test))
166
+ const go = lowerValueOperand(ctx, test)
124
167
  const isBoolShape =
125
168
  (test.kind === 'binary' && BOOL_COMPARISON_OPS.has(test.op)) ||
126
169
  (test.kind === 'unary' && test.op === '!') ||
@@ -280,22 +323,14 @@ function ternaryHasQueryBranch(
280
323
  function renderLoweringNode(ctx: GoEmitContext, node: LoweringNode): string | null {
281
324
  const helper = goHelperName(node.helper)
282
325
  if (!helper) return null
283
- const lowerExpr = (n: ParsedExpr): string =>
284
- ctx.convertExpressionToGo(stringifyParsedExpr(n), undefined, n)
285
- // A `conditional` in ARGUMENT position lowers to the pipeline-position
286
- // `(bf_ternary )` form via the shared `lowerTernary` (#2335) the same
287
- // form the ParsedExpr `conditional()` emitter and the condition-expression
288
- // emitter produce. Routing it here explicitly (rather than leaning on the
289
- // generic `lowerExpr`, whose `conditional()` dispatch now reaches the very
290
- // same helper) keeps the arg lowering self-contained and its intent local;
291
- // `lowerTernary` itself right-folds a nested chain (the #2324 union stage's
292
- // locale→pattern table) into `(bf_ternary <cond> <a> (bf_ternary …))`.
293
- const lowerArg = (n: ParsedExpr): string =>
294
- n.kind === 'conditional'
295
- ? lowerTernary(ctx, n.test, n.consequent, n.alternate)
296
- : wrapIfMultiToken(lowerExpr(n))
326
+ // Every argument here is Go pipeline ARGUMENT position — a `conditional`
327
+ // lowers to `(bf_ternary …)`, a `template-literal` folds through
328
+ // `bf_concat_str` (#2863) rather than leaking the TEXT-position
329
+ // `{{}}`-actions form into this call's argument list, anything else takes
330
+ // the generic value path. `lowerValueOperand` is the one shared door every
331
+ // such position in this file now goes through.
297
332
  if (node.kind === 'helper-call') {
298
- const args = node.args.map(a => lowerArg(a))
333
+ const args = node.args.map(a => lowerValueOperand(ctx, a))
299
334
  return [helper, ...args].join(' ')
300
335
  }
301
336
  // guard-list — `queryHref`-shaped. Inclusion mirrors the client exactly, where
@@ -304,12 +339,12 @@ function renderLoweringNode(ctx: GoEmitContext, node: LoweringNode): string | nu
304
339
  // - plain `key: v` (guard null) → `(true) "key" v`
305
340
  // - conditional `key: cond ? a : <omit>` → `(<cond>) "key" a`, where the
306
341
  // non-empty check is done by bf_query, not folded into the guard.
307
- const parts: string[] = [wrapIfMultiToken(lowerExpr(node.base))]
342
+ const parts: string[] = [lowerValueOperand(ctx, node.base)]
308
343
  for (const t of node.triples) {
309
344
  const includeGo = t.guard === null ? 'true' : lowerUrlGuard(ctx, t.guard)
310
345
  parts.push(`(${includeGo})`)
311
346
  parts.push(JSON.stringify(t.key))
312
- parts.push(wrapIfMultiToken(lowerExpr(t.value)))
347
+ parts.push(lowerValueOperand(ctx, t.value))
313
348
  }
314
349
  return `${helper} ${parts.join(' ')}`
315
350
  }