@barefootjs/go-template 0.14.0 → 0.15.1

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.
@@ -11,7 +11,7 @@ import {
11
11
  TemplatePrimitiveCaseId,
12
12
  } from '@barefootjs/adapter-tests'
13
13
  import { renderGoTemplateComponent, GoNotAvailableError } from '@barefootjs/go-template/test-render'
14
- import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
14
+ import { compileJSX, type ComponentIR, type IRExpression } from '@barefootjs/jsx'
15
15
 
16
16
  runAdapterConformanceTests({
17
17
  name: 'go-template',
@@ -34,9 +34,27 @@ runAdapterConformanceTests({
34
34
  // below, asserting that the adapter emits `BF101` / `BF103` /
35
35
  // `BF104` at build time instead of silently emitting invalid
36
36
  // template syntax (#1266).
37
- // No JSX-render skips: every shared conformance fixture renders to Hono
38
- // parity on real Go. Shapes the adapter intentionally refuses at build time
39
- // are pinned in `expectedDiagnostics` below.
37
+ // JSX-render skips: every other shared conformance fixture renders to
38
+ // Hono parity on real Go including the composed `site/ui` demo
39
+ // corpus (#1467 / #1896): JSX children passed to imported components
40
+ // now render through per-call-site companion defines executed via
41
+ // `bf_tmpl` + `bf_with_children` (see the bf runtime's
42
+ // `TemplateFuncMap`). Shapes the adapter intentionally refuses at
43
+ // build time are pinned in `expectedDiagnostics` below.
44
+ //
45
+ // `data-table` is the one remaining #1896 gap: its table body is a
46
+ // keyed `.map` over a `/* @client */`-sorted MEMO whose data is a
47
+ // module-const object array — a memo-derived dynamic loop of imported
48
+ // components, which the nested-component slice constructor can't bake
49
+ // yet (`item.ID` is on the loop datum, not the child's Input). Full
50
+ // coverage remains at the Hono SSR + fixture-hydrate layers.
51
+ //
52
+ // `search-params` (router v0.5) now renders on Go: `logical()` parenthesises
53
+ // its multi-token operands so `searchParams().get(k) ?? d` lowers to
54
+ // `{{or (.SearchParams.Get "sort") "none"}}`, and the generated structs carry
55
+ // a `SearchParams bf.SearchParams` binding (zero value → empty query → the
56
+ // author's default). See #1922; Mojo / Xslate stay skipped pending their own
57
+ // env-signal lowering + per-request Perl reader.
40
58
  skipJsx: [],
41
59
  // Per-fixture build-time contracts for shapes the Go template
42
60
  // adapter intentionally refuses to lower. Lives here (not on the
@@ -156,6 +174,8 @@ runAdapterConformanceTests({
156
174
  // the IR still declares (s6). See hono-adapter.test for the
157
175
  // contract.
158
176
  'todo-app',
177
+ // (#1897) data-table no longer skipped — loop body children + wrapper
178
+ // struct + block-body memo baking render correctly on Go.
159
179
  ]),
160
180
  onRenderError: (err, id) => {
161
181
  if (err instanceof GoNotAvailableError) {
@@ -196,6 +216,165 @@ function compileAndGenerate(source: string, adapter?: GoTemplateAdapter) {
196
216
  // Go-Template-Specific Tests
197
217
  // =============================================================================
198
218
 
219
+ describe('GoTemplateAdapter - searchParams() env-signal lowering (#1922)', () => {
220
+ // `searchParams().get(k)` lowers to a method call on the canonical
221
+ // `.SearchParams` struct field, parenthesised inside `or` so the nullish
222
+ // fallback groups correctly.
223
+ test('lowers searchParams().get(k) to .SearchParams.Get and emits the struct binding', () => {
224
+ const adapter = new GoTemplateAdapter()
225
+ const ir = compileToIR(`
226
+ import { searchParams } from '@barefootjs/client'
227
+ export function SortLabel() {
228
+ return <p>{searchParams().get('sort') ?? 'none'}</p>
229
+ }
230
+ `, adapter)
231
+ const { template, types } = adapter.generate(ir)
232
+ expect(template).toContain('{{or (.SearchParams.Get "sort") "none"}}')
233
+ expect(types).toContain('SearchParams bf.SearchParams')
234
+ expect(types).toContain('SearchParams: in.SearchParams')
235
+ })
236
+
237
+ // An aliased import binds the env signal to a different local name; the
238
+ // call `sp()` still resolves to the canonical `.SearchParams` field (the
239
+ // generated struct field name is fixed, not derived from the JS alias).
240
+ test('aliased import (`searchParams as sp`) resolves sp() to canonical .SearchParams', () => {
241
+ const adapter = new GoTemplateAdapter()
242
+ const ir = compileToIR(`
243
+ import { searchParams as sp } from '@barefootjs/client'
244
+ export function SortLabel() {
245
+ return <p>{sp().get('sort') ?? 'none'}</p>
246
+ }
247
+ `, adapter)
248
+ const { template, types } = adapter.generate(ir)
249
+ expect(template).toContain('{{or (.SearchParams.Get "sort") "none"}}')
250
+ expect(template).not.toContain('.Sp.Get')
251
+ expect(types).toContain('SearchParams bf.SearchParams')
252
+ })
253
+ })
254
+
255
+ describe('GoTemplateAdapter - template-literal text lowering (#1933)', () => {
256
+ // A dynamic text node whose expression is a template literal lowers to a
257
+ // MIX of literal text + `{{...}}` actions (e.g. ` · #${tag}` →
258
+ // ` · #{{.Tag}}`). `renderExpression` must emit that mixed string as-is
259
+ // between bfTextStart/bfTextEnd — wrapping the whole thing in another
260
+ // `{{...}}` produces `{{ · #{{.Tag}}}}`, which `html/template` rejects at
261
+ // parse time with `unrecognized character in action: U+00B7 '·'`. This is
262
+ // the blog PostList status-line shape (the `· #${params().tag}` branch).
263
+ test('template literal in a conditional text branch keeps literal text outside the action', () => {
264
+ const adapter = new GoTemplateAdapter()
265
+ const ir = compileToIR(`
266
+ 'use client'
267
+ import { createMemo, searchParams } from '@barefootjs/client'
268
+ export function StatusLine() {
269
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
270
+ return (
271
+ <div className="status">
272
+ {tag() ? \` · #\${tag()}\` : ''}
273
+ </div>
274
+ )
275
+ }
276
+ `, adapter)
277
+ const { template } = adapter.generate(ir)
278
+ // The literal text ` · #` must sit OUTSIDE the action, with only the
279
+ // interpolation as `{{...}}`. The broken form double-wraps it.
280
+ expect(template).not.toContain('{{ · #')
281
+ expect(template).toContain(' · #{{.Tag}}')
282
+ })
283
+
284
+ // Generalised: a template literal with a trailing interpolation in plain
285
+ // dynamic-text position (no conditional) must not be double-wrapped either.
286
+ test('template literal in plain dynamic text is not double-wrapped', () => {
287
+ const adapter = new GoTemplateAdapter()
288
+ const ir = compileToIR(`
289
+ 'use client'
290
+ import { createSignal } from '@barefootjs/client'
291
+ export function Label() {
292
+ const [n, setN] = createSignal(0)
293
+ return <span>{\`count: \${n()}\`}</span>
294
+ }
295
+ `, adapter)
296
+ const { template } = adapter.generate(ir)
297
+ expect(template).not.toContain('{{count: ')
298
+ expect(template).toContain('count: {{.N}}')
299
+ })
300
+
301
+ // A bare string literal that merely *contains* `{{` (NOT a template literal)
302
+ // must still be WRAPPED in an action so html/template evaluates and escapes
303
+ // the string. Emitting the raw Go expression `"{{"` would print the literal
304
+ // quotes and bypass escaping. The skip-wrapping path is reserved for template
305
+ // literals + `{{`-leading action chains, so a substring `includes('{{')` check
306
+ // would wrongly treat this string as template text (#1937 review).
307
+ test('string literal containing "{{" is wrapped, not treated as template text', () => {
308
+ const adapter = new GoTemplateAdapter()
309
+ // Drive renderExpression directly: a JSX `{'{{'}` lowers `expr.expr` to the
310
+ // Go string literal `"{{"`, which contains `{{` but is not a template expr.
311
+ const out = adapter.renderExpression({ expr: `'{{'` } as IRExpression)
312
+ expect(out).toBe('{{"{{"}}')
313
+ })
314
+
315
+ // Control: a real template literal IS emitted as-is (mixed text + action),
316
+ // exercising the same code path with the opposite outcome.
317
+ test('template literal expression is emitted as-is via renderExpression', () => {
318
+ const adapter = new GoTemplateAdapter()
319
+ const out = adapter.renderExpression({ expr: '`a #${tag}`' } as IRExpression)
320
+ expect(out).toBe('a #{{.Tag}}')
321
+ })
322
+
323
+ // Attribute context: when a `${...}` interpolation lowers to a template
324
+ // literal, its literal text sits OUTSIDE the `{{...}}` actions and so bypasses
325
+ // html/template's attribute escaping. A `"` in a UnoCSS arbitrary value would
326
+ // break the surrounding `class="..."`. The literal parts must be escaped while
327
+ // interpolations stay as actions (#1937 review).
328
+ test('attribute-context template-literal interpolation escapes its literal text', () => {
329
+ const adapter = new GoTemplateAdapter()
330
+ const out = (adapter as unknown as {
331
+ substituteJsInterpolations(s: string): string
332
+ }).substituteJsInterpolations('content-["x"] ${`a-["y"] ${tag}`} z')
333
+ // The `"` from the nested template literal's literal part is escaped, not raw.
334
+ expect(out).toContain('a-[&quot;y&quot;] {{.Tag}}')
335
+ expect(out).not.toContain('a-["y"]')
336
+ })
337
+
338
+ // A template literal with an UNSUPPORTED interpolation lowers to the BF101
339
+ // sentinel `""` (the whole expression, not template text). It must still be
340
+ // WRAPPED (`{{""}}`) so the sentinel sits inside an action — not emitted raw,
341
+ // which would render literal quotes into the HTML. The template-literal
342
+ // classification must therefore be reported to `renderExpression` only for a
343
+ // *supported* parse, never for the error sentinel (#1937 review).
344
+ test('unsupported template-literal interpolation is wrapped, not emitted raw', () => {
345
+ const adapter = new GoTemplateAdapter()
346
+ const out = adapter.renderExpression({ expr: '`x ${new Date()}`' } as IRExpression)
347
+ expect(out).toBe('{{""}}')
348
+ })
349
+ })
350
+
351
+ // The wrap-or-not decision (`isTemplateFragment`) treats a leading `{{` as the
352
+ // structural marker for "already a self-contained action block", and keys ONLY
353
+ // template literals off their parsed kind. That is correct because of a
354
+ // load-bearing invariant: a template literal is the *only* expression form that
355
+ // interleaves author literal text with `{{...}}` actions, so every OTHER
356
+ // fragment producer (ternary, find().prop, filter().length, …) emits a pure
357
+ // action block that begins with `{{`. These tests pin that invariant: if a
358
+ // future emitter prepends literal text to an action block, its output stops
359
+ // starting with `{{`, this fails, and the fix is to give that shape a parsed
360
+ // kind `isTemplateFragment` can detect (as template literals are handled) —
361
+ // NOT to fall back to a fragile `{{` substring scan.
362
+ describe('GoTemplateAdapter - template-fragment invariant (#1937)', () => {
363
+ const adapter = new GoTemplateAdapter()
364
+ const blockProducers: [string, string][] = [
365
+ ['ternary', "flag ? 'a' : 'b'"],
366
+ ['find().prop', 'items.find(i => i.active).name'],
367
+ ['findLast().prop', 'items.findLast(i => i.active).name'],
368
+ ['filter().length', 'items.filter(i => i.active).length'],
369
+ ]
370
+ for (const [label, expr] of blockProducers) {
371
+ test(`${label} lowers to a {{-leading action block (no leading literal text)`, () => {
372
+ const out = adapter.renderExpression({ expr } as IRExpression)
373
+ expect(out.startsWith('{{')).toBe(true)
374
+ })
375
+ }
376
+ })
377
+
199
378
  describe('GoTemplateAdapter - Adapter Specific', () => {
200
379
  describe('generate - Go struct types', () => {
201
380
  test('deduplicates struct field when signal name matches prop name (#461)', () => {
@@ -1963,13 +2142,74 @@ export function Tagged(props: { className?: string }) {
1963
2142
  expect(numberResult.template).toContain('bf_number .V')
1964
2143
  })
1965
2144
 
2145
+ test('Math.min(a, b) emits bf_min and parenthesises compound operands', () => {
2146
+ // The blog's NowPlaying progress bar: `Math.min(100, (elapsed / TRACK) *
2147
+ // 100)`. Math.min must lower to bf_min, the nested arithmetic must keep
2148
+ // its inner parens (so bf_mul / bf_div get exactly two args each), and the
2149
+ // module const TRACK must inline to its literal value.
2150
+ const result = compileAndGenerate(`
2151
+ 'use client'
2152
+ const TRACK = 8
2153
+ export function Foo(props: { elapsed: number }) {
2154
+ return <div data-x={Math.min(100, (props.elapsed / TRACK) * 100)}>hi</div>
2155
+ }
2156
+ `)
2157
+ expect(result.template).toContain('bf_min 100 (bf_mul (bf_div .Elapsed 8) 100)')
2158
+ })
2159
+
2160
+ test('Math.max(a, b) emits bf_max', () => {
2161
+ const result = compileAndGenerate(`
2162
+ 'use client'
2163
+ export function Foo(props: { a: number; b: number }) {
2164
+ return <div data-x={Math.max(props.a, props.b)}>hi</div>
2165
+ }
2166
+ `)
2167
+ expect(result.template).toContain('bf_max .A .B')
2168
+ })
2169
+
2170
+ test('nested arithmetic parenthesises a compound operand', () => {
2171
+ // Without wrapping, `(a / b) * c` would emit `bf_mul bf_div .A .B .C`,
2172
+ // handing bf_mul four args. Each compound operand must be parenthesised.
2173
+ const result = compileAndGenerate(`
2174
+ 'use client'
2175
+ export function Foo(props: { a: number; b: number; c: number }) {
2176
+ return <div data-x={(props.a / props.b) * props.c}>hi</div>
2177
+ }
2178
+ `)
2179
+ expect(result.template).toContain('bf_mul (bf_div .A .B) .C')
2180
+ })
2181
+
2182
+ test('module numeric const inlines its literal value', () => {
2183
+ const result = compileAndGenerate(`
2184
+ 'use client'
2185
+ const SIZE = 12
2186
+ export function Foo(props: { n: number }) {
2187
+ return <div data-x={props.n + SIZE}>hi</div>
2188
+ }
2189
+ `)
2190
+ // SIZE inlines to 12 rather than emitting a bogus `.SIZE` Props field.
2191
+ expect(result.template).toContain('bf_add .N 12')
2192
+ })
2193
+
2194
+ test('module numeric const with separators inlines the stripped value', () => {
2195
+ const result = compileAndGenerate(`
2196
+ 'use client'
2197
+ const GAP = 100_000
2198
+ export function Foo(props: { n: number }) {
2199
+ return <div data-x={props.n + GAP}>hi</div>
2200
+ }
2201
+ `)
2202
+ // 100_000 (TS numeric separator) → 100000; Go template literals reject "_".
2203
+ expect(result.template).toContain('bf_add .N 100000')
2204
+ })
2205
+
1966
2206
  test('registry exposes the expected V1 callees', () => {
1967
2207
  // Pin the V1 surface so a future refactor doesn't accidentally
1968
2208
  // drop a primitive. New entries are additive — extend this
1969
2209
  // list rather than replace.
1970
2210
  const a = new GoTemplateAdapter()
1971
2211
  const keys = Object.keys(a.templatePrimitives ?? {}).sort()
1972
- expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
2212
+ expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.max', 'Math.min', 'Math.round', 'Number', 'String'])
1973
2213
  })
1974
2214
 
1975
2215
  test('unregistered identifier-path callee is NOT accepted by the registry', () => {
@@ -2116,13 +2356,33 @@ export function V({ variant }: { variant: 'a' | 'b' }) {
2116
2356
  // The ClassName field MUST be set on the SlotInput literal.
2117
2357
  expect(goCode).toContain('ClassName:')
2118
2358
  // The IIFE shape: a self-invoking func that switches on the
2119
- // variant key and returns the matching case.
2359
+ // variant key and returns the matching case. The switch key goes
2360
+ // through `fmt.Sprint` (not a `.(string)` assertion) so it
2361
+ // compiles for both `interface{}`-typed fields and the `string`
2362
+ // fields the shared inherited-prop augmentation synthesises
2363
+ // (#1896).
2120
2364
  expect(goCode).toContain('func() string {')
2121
- expect(goCode).toContain('in.Variant.(string)')
2365
+ expect(goCode).toContain('switch fmt.Sprint(in.Variant)')
2122
2366
  expect(goCode).toContain('case "a": return "class-a"')
2123
2367
  expect(goCode).toContain('case "b": return "class-b"')
2124
2368
  })
2125
2369
 
2370
+ test('string-tolerant eq keeps a compound operand grouped (#1903 review)', () => {
2371
+ const adapter = new GoTemplateAdapter()
2372
+ const ir = compileToIR(`
2373
+ export function T(props: { placement?: 'top' | 'left' }) {
2374
+ return <div data-side={(props.placement ?? 'top') === 'left' ? 'l' : 'o'}>x</div>
2375
+ }
2376
+ `, adapter)
2377
+ const out = adapter.generate(ir)
2378
+ // The non-literal side routes through bf_string as ONE argument:
2379
+ // `(bf_string (or .Placement "top"))`. Stripping the inner parens
2380
+ // would hand the parser three arguments and fail at runtime with
2381
+ // `bf_string: want 1 got 3`.
2382
+ expect(out.template).toContain('eq (bf_string (or .Placement "top")) "left"')
2383
+ expect(out.template).not.toContain('bf_string or ')
2384
+ })
2385
+
2126
2386
  test('intermediate-const composition (Button shape) carries through', () => {
2127
2387
  const adapter = new GoTemplateAdapter()
2128
2388
  const ir = compileToIR(`