@barefootjs/go-template 0.1.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.
- package/dist/adapter/go-template-adapter.d.ts +683 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +2672 -0
- package/dist/build.d.ts +53 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +3198 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2672 -0
- package/dist/test-render.d.ts +22 -0
- package/dist/test-render.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/build.test.ts +65 -0
- package/src/__tests__/go-template-adapter.test.ts +1757 -0
- package/src/adapter/go-template-adapter.ts +4316 -0
- package/src/adapter/index.ts +6 -0
- package/src/build.ts +258 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +363 -0
|
@@ -0,0 +1,1757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTemplateAdapter - Tests
|
|
3
|
+
*
|
|
4
|
+
* Conformance tests (shared across adapters) + Go-template-specific tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from 'bun:test'
|
|
8
|
+
import { GoTemplateAdapter } from '../adapter/go-template-adapter'
|
|
9
|
+
import {
|
|
10
|
+
runAdapterConformanceTests,
|
|
11
|
+
TemplatePrimitiveCaseId,
|
|
12
|
+
} from '@barefootjs/adapter-tests'
|
|
13
|
+
import { renderGoTemplateComponent, GoNotAvailableError } from '@barefootjs/go-template/test-render'
|
|
14
|
+
import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
|
|
15
|
+
|
|
16
|
+
runAdapterConformanceTests({
|
|
17
|
+
name: 'go-template',
|
|
18
|
+
factory: () => new GoTemplateAdapter(),
|
|
19
|
+
render: renderGoTemplateComponent,
|
|
20
|
+
// `branch-self-closing` no longer needs a skip — the conditional-
|
|
21
|
+
// marker divergence (Hono `bf-c="sN"` attribute vs Go
|
|
22
|
+
// `<!--bf-cond-start:sN-->` / `<!--bf-cond-end:sN-->` comment pairs)
|
|
23
|
+
// is now collapsed by `normalizeHTML` in adapter-tests (#1266).
|
|
24
|
+
//
|
|
25
|
+
// `nullish-coalescing-jsx` / `return-nullish-coalescing` have a
|
|
26
|
+
// separate semantic divergence: the Go template's `{{if ne .Banner
|
|
27
|
+
// ""}}` condition treats an unset `Banner` (Go nil) as `!= ""` and
|
|
28
|
+
// takes the truthy branch with empty content, while Hono's JS
|
|
29
|
+
// `??` operator falls through to the JSX default. That's a Go-
|
|
30
|
+
// adapter branch-selection bug — fixing it is out of scope for
|
|
31
|
+
// #1266.
|
|
32
|
+
//
|
|
33
|
+
// `return-map` uses a `data-key` serialisation shape that differs
|
|
34
|
+
// between Hono (runtime helper) and Go (template variable) in a
|
|
35
|
+
// way that isn't structural — leaving it on `skipJsx` until a
|
|
36
|
+
// normaliser for the `data-key` shape lands or the fixture splits
|
|
37
|
+
// into per-adapter `expectedHtml`.
|
|
38
|
+
//
|
|
39
|
+
// `style-object-dynamic`, `static-array-children`,
|
|
40
|
+
// `static-array-from-props`, and `static-array-from-props-with-component`
|
|
41
|
+
// are no longer here — they're covered by `expectedDiagnostics`
|
|
42
|
+
// below, asserting that the adapter emits `BF101` / `BF103` /
|
|
43
|
+
// `BF104` at build time instead of silently emitting invalid
|
|
44
|
+
// template syntax (#1266).
|
|
45
|
+
skipJsx: [
|
|
46
|
+
'nullish-coalescing-jsx',
|
|
47
|
+
'return-nullish-coalescing',
|
|
48
|
+
'return-map',
|
|
49
|
+
// #1297 fixed the harness-side IR emission gate (multi-component
|
|
50
|
+
// sources now emit one `ir` file per component, and the harness
|
|
51
|
+
// picks the entry-point IR). The remaining gap is adapter-side:
|
|
52
|
+
// the go-template adapter has no SSR context-propagation
|
|
53
|
+
// mechanism, so `<Ctx.Provider value="dark">` doesn't make
|
|
54
|
+
// `useContext(Ctx)` resolve to `"dark"` at template-eval time —
|
|
55
|
+
// the template emits `.Theme` against a struct that has no
|
|
56
|
+
// `Theme` field. Provider SSR coverage on go-template waits on
|
|
57
|
+
// that adapter feature; see #1297 follow-up.
|
|
58
|
+
'context-provider',
|
|
59
|
+
// #1244 stress catalog (#1326): `children={<span/>}` — the IR
|
|
60
|
+
// hoists the span with `needsScope: true` so the Hono reference
|
|
61
|
+
// emits `bf-s` on the inner `<span>`. The Go adapter renders the
|
|
62
|
+
// span up front as a compile-time HTML fragment containing the
|
|
63
|
+
// `{{bfScopeAttr .}}` action, then passes it via `template.HTML`
|
|
64
|
+
// through the parent's `{{.Children}}` interpolation — but
|
|
65
|
+
// `template.HTML` is marked-as-safe-output, not recursively
|
|
66
|
+
// parsed, so the action survives as literal text in the rendered
|
|
67
|
+
// HTML. Fixing this requires either (a) re-emitting the inner
|
|
68
|
+
// span as its own named template definition the outer template
|
|
69
|
+
// can pass its struct to, or (b) embedding the resolved scope ID
|
|
70
|
+
// at compile time. Neither lands in this PR; the Mojo sibling
|
|
71
|
+
// case is handled by routing the hoisted JSX through the same
|
|
72
|
+
// `begin %>…<% end` capture as nested children (see #1326 fix).
|
|
73
|
+
'children-jsx-expression',
|
|
74
|
+
// #1335: fragment-wrapped form of the same shape. Now that the IR
|
|
75
|
+
// unwraps `<><span/></>` into the bare-element form, the Go adapter
|
|
76
|
+
// hits the identical `template.HTML` interpolation gap as
|
|
77
|
+
// `children-jsx-expression` above.
|
|
78
|
+
'fragment-wrapped-children-jsx-expression',
|
|
79
|
+
// Shared-component multi-component fixtures (#1466). Boolean
|
|
80
|
+
// attribute divergence is now collapsed by `normalizeHTML`, so
|
|
81
|
+
// single-root variants (`conditional-return-*`, `form`, `portal`,
|
|
82
|
+
// `reactive-props`) participate again. These two still diverge
|
|
83
|
+
// because the harness's child renderer pins child `bf-s` to a
|
|
84
|
+
// `test_<sN>` literal rather than `<ChildName>_<id>_<sN>`. Same
|
|
85
|
+
// class of test-harness scope-id plumbing the `componentName`
|
|
86
|
+
// option fixed on the Hono side. Separate follow-up.
|
|
87
|
+
'toggle-shared',
|
|
88
|
+
'props-reactivity-comparison',
|
|
89
|
+
],
|
|
90
|
+
// Per-fixture build-time contracts for shapes the Go template
|
|
91
|
+
// adapter intentionally refuses to lower. Lives here (not on the
|
|
92
|
+
// shared fixtures) so adding a new adapter doesn't require touching
|
|
93
|
+
// any cross-adapter file — every adapter declares its own
|
|
94
|
+
// refusal set against the canonical fixture corpus.
|
|
95
|
+
expectedDiagnostics: {
|
|
96
|
+
// JS object literal in attribute position: `convertExpressionToGo`
|
|
97
|
+
// can't lower into Go template syntax — surfaces as BF101 with an
|
|
98
|
+
// @client suggestion.
|
|
99
|
+
'style-object-dynamic': [{ code: 'BF101', severity: 'error' }],
|
|
100
|
+
// Sibling-imported child component inside a loop body: the adapter
|
|
101
|
+
// emits `{{template "X" .}}` which only resolves if the user has
|
|
102
|
+
// compiled the sibling file and registered the template on the
|
|
103
|
+
// same instance. BF103 makes that requirement loud. (The barefoot
|
|
104
|
+
// CLI passes `siblingTemplatesRegistered: true` so CLI builds
|
|
105
|
+
// suppress the diagnostic — see compileJSX `siblingTemplatesRegistered`.)
|
|
106
|
+
'static-array-children': [{ code: 'BF103', severity: 'error' }],
|
|
107
|
+
// TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
|
|
108
|
+
// call it inside a keyed `.map`. Same BF103 surface as
|
|
109
|
+
// `static-array-children` above — pinned at adapter level so the
|
|
110
|
+
// shared-component corpus stays adapter-neutral.
|
|
111
|
+
'todo-app': [{ code: 'BF103', severity: 'error' }],
|
|
112
|
+
'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
|
|
113
|
+
// Array-destructure loop param (`([k, v]) => ...`): Go's `{{range
|
|
114
|
+
// $a, $b := ...}}` only supports single-name bindings, so the
|
|
115
|
+
// adapter would otherwise emit invalid template syntax.
|
|
116
|
+
'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
|
|
117
|
+
// Same destructure shape with a child component body — fires both
|
|
118
|
+
// BF103 (imported child in loop) and BF104 (destructure param).
|
|
119
|
+
'static-array-from-props-with-component': [
|
|
120
|
+
{ code: 'BF103', severity: 'error' },
|
|
121
|
+
{ code: 'BF104', severity: 'error' },
|
|
122
|
+
],
|
|
123
|
+
// #1244 stress catalog: same `convertExpressionToGo` refusal shape
|
|
124
|
+
// as `style-object-dynamic` above — a JS object literal in
|
|
125
|
+
// attribute position can't lower into Go template syntax, so the
|
|
126
|
+
// adapter surfaces BF101 instead of emitting invalid template.
|
|
127
|
+
'style-3-signals': [{ code: 'BF101', severity: 'error' }],
|
|
128
|
+
// #1244 stress catalog: tagged-template-literal callees
|
|
129
|
+
// (`cn\`base \${tone()}\``) likewise can't lower into Go template
|
|
130
|
+
// syntax — same BF101 refusal.
|
|
131
|
+
'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
|
|
132
|
+
// #1310: rest destructure in .map() callback. Hono / CSR lower
|
|
133
|
+
// these via the inline residual-object accessor (#1309), but the
|
|
134
|
+
// Go template adapter has no analogous lowering — `paramBindings`
|
|
135
|
+
// is non-empty so the generic destructure-refusal at
|
|
136
|
+
// `go-template-adapter.ts` fires BF104 regardless of whether the
|
|
137
|
+
// binding is rest or plain. Pinning the contract here makes the
|
|
138
|
+
// limitation declarative: when the Go adapter grows a native
|
|
139
|
+
// rest-lowering, dropping these entries flips the contract on.
|
|
140
|
+
'rest-destructure-object-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
141
|
+
// #1244 catalog: rest spread back onto the root element. Same
|
|
142
|
+
// refusal shape as the read-only variant above — `paramBindings`
|
|
143
|
+
// is non-empty so BF104 fires regardless of how `rest` is used.
|
|
144
|
+
'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
145
|
+
'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
146
|
+
'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
147
|
+
// #1443: `[a, b].filter(Boolean).join(' ')` (registry Slot) now
|
|
148
|
+
// lowers to `bf_join (bf_filter_truthy (bf_arr ...)) " "`. No
|
|
149
|
+
// BF101 expected — pinned positively by the
|
|
150
|
+
// `branch-local-filter-join-go` template-output test below.
|
|
151
|
+
//
|
|
152
|
+
// #1448 Tier A — JS Array / String methods that the Go template
|
|
153
|
+
// adapter hasn't lowered yet. Each row drops once the
|
|
154
|
+
// corresponding method PR lands. Hono / CSR pass these out of
|
|
155
|
+
// the box (they evaluate JS at runtime) so the pin only applies
|
|
156
|
+
// here.
|
|
157
|
+
//
|
|
158
|
+
// `array-includes` / `string-includes` no longer pinned — both
|
|
159
|
+
// shapes lower via the shared `array-method` IR + the polymorphic
|
|
160
|
+
// `bf_includes` runtime helper that dispatches on
|
|
161
|
+
// `reflect.Kind()` (slice/array → element search, string →
|
|
162
|
+
// substring search). The condition-position lowering picks up
|
|
163
|
+
// the same emit through the `array-method` arm of
|
|
164
|
+
// `renderConditionExpr` (#1448 Tier A first PR).
|
|
165
|
+
//
|
|
166
|
+
// Remaining fixtures land at expression position and surface BF101
|
|
167
|
+
// via `convertExpressionToGo`. Distinct codes for the two paths is
|
|
168
|
+
// pre-existing adapter behaviour, not something this catalog
|
|
169
|
+
// should paper over — pinned literally here.
|
|
170
|
+
// `array-indexOf` / `array-lastIndexOf` no longer pinned —
|
|
171
|
+
// value-equality `bf_index_of` / `bf_last_index_of` Go runtime
|
|
172
|
+
// helpers handle the shape (#1448 Tier A second PR).
|
|
173
|
+
// `array-at` no longer pinned — the pre-existing `bf_at` runtime
|
|
174
|
+
// helper now lowers `.at(i)` (#1448 Tier A third PR).
|
|
175
|
+
// `array-concat` no longer pinned — the new `bf_concat` runtime
|
|
176
|
+
// helper merges two arrays into a single `[]any` (#1448 Tier A
|
|
177
|
+
// fourth PR).
|
|
178
|
+
// `array-slice` no longer pinned — the new `bf_slice` runtime
|
|
179
|
+
// helper carves out a sub-range with JS-compat clamping
|
|
180
|
+
// (#1448 Tier A fifth PR).
|
|
181
|
+
// `array-reverse` / `array-toReversed` no longer pinned —
|
|
182
|
+
// both share the `bf_reverse` helper since SSR templates
|
|
183
|
+
// render a snapshot and the JS mutate-vs-new distinction has
|
|
184
|
+
// no template-level meaning (#1448 Tier A sixth PR).
|
|
185
|
+
// `string-toLowerCase` / `string-toUpperCase` no longer pinned —
|
|
186
|
+
// pre-existing `bf_lower` / `bf_upper` runtime helpers wire to
|
|
187
|
+
// the JS method names at the adapter layer (#1448 Tier A
|
|
188
|
+
// seventh + eighth PRs).
|
|
189
|
+
// `string-trim` no longer pinned — pre-existing `bf_trim`
|
|
190
|
+
// (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
|
|
191
|
+
// ninth PR, closing out Tier A).
|
|
192
|
+
},
|
|
193
|
+
// `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
|
|
194
|
+
// via `GoTemplateAdapter.templatePrimitives` (#1188). The two
|
|
195
|
+
// remaining cases stay skipped because the V1 registry is
|
|
196
|
+
// identifier-path-only and explicit:
|
|
197
|
+
// - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
|
|
198
|
+
// the registry and can't be rendered server-side without
|
|
199
|
+
// user-supplied template-fn mappings.
|
|
200
|
+
// - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
|
|
201
|
+
// too, same reason.
|
|
202
|
+
// Adding new entries to `templatePrimitives` should narrow this
|
|
203
|
+
// skip set; see `templatePrimitives` declaration in
|
|
204
|
+
// `go-template-adapter.ts` for the full V1 surface.
|
|
205
|
+
skipTemplatePrimitives: new Set([
|
|
206
|
+
TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
|
|
207
|
+
TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
|
|
208
|
+
]),
|
|
209
|
+
skipMarkerConformance: new Set<string>([
|
|
210
|
+
// Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
|
|
211
|
+
// `.map` intentionally elide a slot id from the SSR template that
|
|
212
|
+
// the IR still declares (s6). See hono-adapter.test for the
|
|
213
|
+
// contract.
|
|
214
|
+
'todo-app',
|
|
215
|
+
]),
|
|
216
|
+
onRenderError: (err, id) => {
|
|
217
|
+
if (err instanceof GoNotAvailableError) {
|
|
218
|
+
console.log(`Skipping [${id}]: ${err.message}`)
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
return false
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Helpers
|
|
227
|
+
// =============================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compile JSX source to ComponentIR using the GoTemplateAdapter.
|
|
231
|
+
*/
|
|
232
|
+
function compileToIR(source: string, adapter?: GoTemplateAdapter): ComponentIR {
|
|
233
|
+
const result = compileJSX(source.trimStart(), 'test.tsx', {
|
|
234
|
+
adapter: adapter ?? new GoTemplateAdapter(),
|
|
235
|
+
outputIR: true,
|
|
236
|
+
})
|
|
237
|
+
const irFile = result.files.find(f => f.type === 'ir')
|
|
238
|
+
if (!irFile) throw new Error('No IR output')
|
|
239
|
+
return JSON.parse(irFile.content) as ComponentIR
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Compile JSX source and return the generated template output.
|
|
244
|
+
*/
|
|
245
|
+
function compileAndGenerate(source: string, adapter?: GoTemplateAdapter) {
|
|
246
|
+
const a = adapter ?? new GoTemplateAdapter()
|
|
247
|
+
const ir = compileToIR(source, a)
|
|
248
|
+
return a.generate(ir)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// =============================================================================
|
|
252
|
+
// Go-Template-Specific Tests
|
|
253
|
+
// =============================================================================
|
|
254
|
+
|
|
255
|
+
describe('GoTemplateAdapter - Adapter Specific', () => {
|
|
256
|
+
describe('generate - Go struct types', () => {
|
|
257
|
+
test('deduplicates struct field when signal name matches prop name (#461)', () => {
|
|
258
|
+
const adapter = new GoTemplateAdapter()
|
|
259
|
+
const ir = compileToIR(`
|
|
260
|
+
"use client"
|
|
261
|
+
import { createSignal } from "@barefootjs/client"
|
|
262
|
+
|
|
263
|
+
export function Example(props: { label?: string }) {
|
|
264
|
+
const [label, setLabel] = createSignal(props.label ?? 'Default')
|
|
265
|
+
return <div>{label()}</div>
|
|
266
|
+
}
|
|
267
|
+
`)
|
|
268
|
+
const result = adapter.generate(ir)
|
|
269
|
+
|
|
270
|
+
expect(result.types).toBeDefined()
|
|
271
|
+
// Should have exactly one Label field, not two
|
|
272
|
+
const labelFields = result.types!.match(/\bLabel\b.*`json:"label"`/g) ?? []
|
|
273
|
+
expect(labelFields.length).toBe(1)
|
|
274
|
+
|
|
275
|
+
// NewExampleProps should have exactly one Label assignment
|
|
276
|
+
const labelAssignments = result.types!.match(/Label:/g) ?? []
|
|
277
|
+
expect(labelAssignments.length).toBe(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('generates Go struct types', () => {
|
|
281
|
+
const adapter = new GoTemplateAdapter()
|
|
282
|
+
const ir = compileToIR(`
|
|
283
|
+
"use client"
|
|
284
|
+
import { createSignal } from "@barefootjs/client"
|
|
285
|
+
|
|
286
|
+
export function Counter(props: { initial?: number }) {
|
|
287
|
+
const [count, setCount] = createSignal(props.initial ?? 0)
|
|
288
|
+
return <div>{count()}</div>
|
|
289
|
+
}
|
|
290
|
+
`)
|
|
291
|
+
const result = adapter.generate(ir)
|
|
292
|
+
|
|
293
|
+
expect(result.types).toBeDefined()
|
|
294
|
+
expect(result.types).toContain('package components')
|
|
295
|
+
expect(result.types).toContain('type CounterProps struct')
|
|
296
|
+
expect(result.types).toContain('ScopeID string')
|
|
297
|
+
expect(result.types).toContain('Initial int')
|
|
298
|
+
expect(result.types).toContain('Count int')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('dynamic loop with child component → NewXxxProps carries a populate-this-slice doc comment (#1442 echo TodoApp repro)', () => {
|
|
302
|
+
// Regression: a `todos().map(t => <TodoItem todo={t} />)` loop with a
|
|
303
|
+
// dynamic array (signal getter, not a static prop) declares
|
|
304
|
+
// `TodoItems []TodoItemProps` on the Props struct, but
|
|
305
|
+
// `NewTodoAppProps` returned it empty — and the SSR template
|
|
306
|
+
// iterated over the empty slice into a blank list with no signal
|
|
307
|
+
// anywhere. The "you must populate this in your handler" rule was
|
|
308
|
+
// pure tribal knowledge.
|
|
309
|
+
//
|
|
310
|
+
// Now `NewXxxProps`'s doc comment carries a concrete example for
|
|
311
|
+
// every dynamic loop child, including the field name, the child's
|
|
312
|
+
// Input/Props names, and the slot id needed for bf-h / bf-m.
|
|
313
|
+
// Authors land on the comment as soon as they read the generated
|
|
314
|
+
// file and see exactly what to do.
|
|
315
|
+
const adapter = new GoTemplateAdapter()
|
|
316
|
+
const todoItemIR = compileToIR(`
|
|
317
|
+
"use client"
|
|
318
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
319
|
+
export function TodoItem(props: { todo: Todo }) {
|
|
320
|
+
return <li>{props.todo.text}</li>
|
|
321
|
+
}
|
|
322
|
+
`)
|
|
323
|
+
// Sanity: TodoItem alone produces a Props struct; no dynamic loop
|
|
324
|
+
// inside it, so no extra comment.
|
|
325
|
+
const todoItemResult = adapter.generate(todoItemIR)
|
|
326
|
+
expect(todoItemResult.types).not.toContain('NOTE: `')
|
|
327
|
+
|
|
328
|
+
const todoAppIR = compileToIR(`
|
|
329
|
+
"use client"
|
|
330
|
+
import { createSignal } from "@barefootjs/client"
|
|
331
|
+
import { TodoItem } from './TodoItem'
|
|
332
|
+
|
|
333
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
334
|
+
|
|
335
|
+
export function TodoApp(props: { initial?: Todo[] }) {
|
|
336
|
+
const [todos, setTodos] = createSignal<Todo[]>(props.initial ?? [])
|
|
337
|
+
return (
|
|
338
|
+
<ul>
|
|
339
|
+
{todos().map(todo => (
|
|
340
|
+
<TodoItem key={todo.id} todo={todo} />
|
|
341
|
+
))}
|
|
342
|
+
</ul>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
`)
|
|
346
|
+
const result = adapter.generate(todoAppIR)
|
|
347
|
+
// Doc-comment carries the per-child concrete example, naming the
|
|
348
|
+
// populated field, the child's Input/Props types, and the bf-h /
|
|
349
|
+
// bf-m wiring the SSR template relies on.
|
|
350
|
+
expect(result.types).toContain('NOTE: `TodoItems`')
|
|
351
|
+
expect(result.types).toContain('props.TodoItems = make([]TodoItemProps, len(items))')
|
|
352
|
+
expect(result.types).toContain('NewTodoItemProps(TodoItemInput{')
|
|
353
|
+
expect(result.types).toContain('props.TodoItems[i].BfParent = props.ScopeID')
|
|
354
|
+
expect(result.types).toContain('props.TodoItems[i].BfMount =')
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('signal initialized via `(props.X ?? []).length` lands as int, not []T (#1442 echo TodoApp repro)', () => {
|
|
358
|
+
// Regression: `extractPropNameFromInitialValue` greedily matched
|
|
359
|
+
// any `(props.X ?? Y).<something>` shape and propagated the prop's
|
|
360
|
+
// Go type ([]Todo) to the signal field, even when the trailing
|
|
361
|
+
// accessor (`.length`) transformed the expression to a number.
|
|
362
|
+
// The signal was `Seq []Todo` instead of `Seq int`, which is
|
|
363
|
+
// technically a Go compile error if the field is consumed
|
|
364
|
+
// arithmetically — runtime behaviour was a silent wrong-type
|
|
365
|
+
// initialisation.
|
|
366
|
+
const adapter = new GoTemplateAdapter()
|
|
367
|
+
const ir = compileToIR(`
|
|
368
|
+
"use client"
|
|
369
|
+
import { createSignal, createMemo } from "@barefootjs/client"
|
|
370
|
+
|
|
371
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
372
|
+
|
|
373
|
+
export function TodoApp(props: { initial?: Todo[] }) {
|
|
374
|
+
const [todos] = createSignal<Todo[]>(props.initial ?? [])
|
|
375
|
+
const [seq] = createSignal((props.initial ?? []).length)
|
|
376
|
+
const remaining = createMemo(() => todos().filter(t => !t.done).length)
|
|
377
|
+
const total = createMemo(() => todos().length)
|
|
378
|
+
return <div>{seq()} {remaining()} / {total()}</div>
|
|
379
|
+
}
|
|
380
|
+
`)
|
|
381
|
+
const result = adapter.generate(ir)
|
|
382
|
+
// Signal seeded from `.length` of an array → number → Go int.
|
|
383
|
+
expect(result.types).toContain('Seq int')
|
|
384
|
+
// Memos whose body is a `.length` chain → also int (analyzer
|
|
385
|
+
// now runs inferTypeFromValue on the arrow body for memos).
|
|
386
|
+
expect(result.types).toContain('Remaining int')
|
|
387
|
+
expect(result.types).toContain('Total int')
|
|
388
|
+
// Underlying prop / array signal still uses the array type.
|
|
389
|
+
expect(result.types).toContain('Initial []Todo')
|
|
390
|
+
expect(result.types).toContain('Todos []Todo')
|
|
391
|
+
// The misclassified shapes from the original repro must not
|
|
392
|
+
// resurface — even as `interface{}` (the fallback before the
|
|
393
|
+
// analyzer recognised `.length`).
|
|
394
|
+
expect(result.types).not.toContain('Seq []Todo')
|
|
395
|
+
expect(result.types).not.toContain('Remaining interface{}')
|
|
396
|
+
expect(result.types).not.toContain('Total interface{}')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('hoists signal-time `props.X ?? N` fallback into shared local var (#1423)', () => {
|
|
400
|
+
// Mirrors the Mojo manifest-defaults coverage (#1419): when the
|
|
401
|
+
// signal default lives on a `??` against a bare prop access, the
|
|
402
|
+
// generator hoists the fallback so the signal, the prop field,
|
|
403
|
+
// and any derived memo share one fallback-applied value.
|
|
404
|
+
const adapter = new GoTemplateAdapter()
|
|
405
|
+
const ir = compileToIR(`
|
|
406
|
+
"use client"
|
|
407
|
+
import { createSignal, createMemo } from "@barefootjs/client"
|
|
408
|
+
|
|
409
|
+
export function Counter(props: { initial?: number }) {
|
|
410
|
+
const [count, setCount] = createSignal(props.initial ?? 99)
|
|
411
|
+
const doubled = createMemo(() => count() * 2)
|
|
412
|
+
return <div>{count()} {doubled()}</div>
|
|
413
|
+
}
|
|
414
|
+
`)
|
|
415
|
+
const result = adapter.generate(ir)
|
|
416
|
+
expect(result.types).toBeDefined()
|
|
417
|
+
const types = result.types!
|
|
418
|
+
|
|
419
|
+
// Hoist: `initial := in.Initial` + zero-check + fallback assign.
|
|
420
|
+
expect(types).toContain('initial := in.Initial')
|
|
421
|
+
expect(types).toMatch(/if initial == 0 \{\s*initial = 99\s*\}/)
|
|
422
|
+
|
|
423
|
+
// Prop, signal, and memo all reference the hoisted variable.
|
|
424
|
+
expect(types).toContain('Initial: initial,')
|
|
425
|
+
expect(types).toContain('Count: initial,')
|
|
426
|
+
expect(types).toContain('Doubled: initial * 2,')
|
|
427
|
+
|
|
428
|
+
// Pre-fix output is no longer present.
|
|
429
|
+
expect(types).not.toContain('Count: in.Initial,')
|
|
430
|
+
expect(types).not.toContain('Doubled: in.Initial * 2,')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('zero-fallback (`?? 0`) leaves NewXxxProps unchanged (#1423)', () => {
|
|
434
|
+
// The hoist is a no-op when the fallback is the Go zero value;
|
|
435
|
+
// emitting `if initial == 0 { initial = 0 }` would be noise.
|
|
436
|
+
const adapter = new GoTemplateAdapter()
|
|
437
|
+
const ir = compileToIR(`
|
|
438
|
+
"use client"
|
|
439
|
+
import { createSignal } from "@barefootjs/client"
|
|
440
|
+
|
|
441
|
+
export function Counter(props: { initial?: number }) {
|
|
442
|
+
const [count, setCount] = createSignal(props.initial ?? 0)
|
|
443
|
+
return <div>{count()}</div>
|
|
444
|
+
}
|
|
445
|
+
`)
|
|
446
|
+
const result = adapter.generate(ir)
|
|
447
|
+
const types = result.types!
|
|
448
|
+
expect(types).not.toContain('initial := in.Initial')
|
|
449
|
+
expect(types).toContain('Initial: in.Initial,')
|
|
450
|
+
expect(types).toContain('Count: in.Initial,')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('hoists string fallback for `props.X ?? "default"` (#1423)', () => {
|
|
454
|
+
const adapter = new GoTemplateAdapter()
|
|
455
|
+
const ir = compileToIR(`
|
|
456
|
+
"use client"
|
|
457
|
+
import { createSignal } from "@barefootjs/client"
|
|
458
|
+
|
|
459
|
+
export function Label(props: { label?: string }) {
|
|
460
|
+
const [text, setText] = createSignal(props.label ?? 'Default')
|
|
461
|
+
return <div>{text()}</div>
|
|
462
|
+
}
|
|
463
|
+
`)
|
|
464
|
+
const result = adapter.generate(ir)
|
|
465
|
+
const types = result.types!
|
|
466
|
+
expect(types).toContain('label := in.Label')
|
|
467
|
+
expect(types).toMatch(/if label == ""\s*\{\s*label = "Default"\s*\}/)
|
|
468
|
+
expect(types).toContain('Label: label,')
|
|
469
|
+
// Signal name `text` differs from prop name `label`, so the
|
|
470
|
+
// signal field gets its own entry that resolves through the
|
|
471
|
+
// hoisted var.
|
|
472
|
+
expect(types).toContain('Text: label,')
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('hoists `props.X ?? true` against the bool zero (#1423 review)', () => {
|
|
476
|
+
// Bool-true falls through the same hoist path as int / string —
|
|
477
|
+
// the asymmetry is documented (caller can't thread "explicit
|
|
478
|
+
// false" through because Go's bool zero IS false), but emitting
|
|
479
|
+
// a hoisted local matches the int case's shape so a derived
|
|
480
|
+
// memo can inherit it.
|
|
481
|
+
const adapter = new GoTemplateAdapter()
|
|
482
|
+
const ir = compileToIR(`
|
|
483
|
+
"use client"
|
|
484
|
+
import { createSignal } from "@barefootjs/client"
|
|
485
|
+
|
|
486
|
+
export function Check(props: { checked?: boolean }) {
|
|
487
|
+
const [c, setC] = createSignal(props.checked ?? true)
|
|
488
|
+
return <div>{c() ? 'on' : 'off'}</div>
|
|
489
|
+
}
|
|
490
|
+
`)
|
|
491
|
+
const result = adapter.generate(ir)
|
|
492
|
+
const types = result.types!
|
|
493
|
+
expect(types).toContain('checked := in.Checked')
|
|
494
|
+
expect(types).toMatch(/if checked == false\s*\{\s*checked = true\s*\}/)
|
|
495
|
+
expect(types).toContain('Checked: checked,')
|
|
496
|
+
expect(types).toContain('C: checked,')
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('skips hoist for zero-equivalent string and float fallbacks (#1423 review)', () => {
|
|
500
|
+
// The skip predicate compares the Go fallback against the
|
|
501
|
+
// type's zero literal — covers `?? ''` (string) and `?? 0.0`
|
|
502
|
+
// (numeric spelling that parses to zero), not just the bare
|
|
503
|
+
// `?? 0` / `?? false` literals.
|
|
504
|
+
const adapter = new GoTemplateAdapter()
|
|
505
|
+
const emptyStringIr = compileToIR(`
|
|
506
|
+
"use client"
|
|
507
|
+
import { createSignal } from "@barefootjs/client"
|
|
508
|
+
|
|
509
|
+
export function Label(props: { label?: string }) {
|
|
510
|
+
const [text, setText] = createSignal(props.label ?? '')
|
|
511
|
+
return <div>{text()}</div>
|
|
512
|
+
}
|
|
513
|
+
`)
|
|
514
|
+
const emptyStringTypes = adapter.generate(emptyStringIr).types!
|
|
515
|
+
expect(emptyStringTypes).not.toContain('label := in.Label')
|
|
516
|
+
expect(emptyStringTypes).toContain('Label: in.Label,')
|
|
517
|
+
|
|
518
|
+
const floatIr = compileToIR(`
|
|
519
|
+
"use client"
|
|
520
|
+
import { createSignal } from "@barefootjs/client"
|
|
521
|
+
|
|
522
|
+
export function Score(props: { value?: number }) {
|
|
523
|
+
const [v, setV] = createSignal(props.value ?? 0.0)
|
|
524
|
+
return <div>{v()}</div>
|
|
525
|
+
}
|
|
526
|
+
`)
|
|
527
|
+
const floatTypes = adapter.generate(floatIr).types!
|
|
528
|
+
expect(floatTypes).not.toContain('value := in.Value')
|
|
529
|
+
expect(floatTypes).toContain('Value: in.Value,')
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
describe('JSX children forwarding (#1203)', () => {
|
|
534
|
+
test('forwards element children via Children: template.HTML(...)', () => {
|
|
535
|
+
const adapter = new GoTemplateAdapter()
|
|
536
|
+
const ir = compileToIR(`
|
|
537
|
+
'use client'
|
|
538
|
+
import { createSignal } from "@barefootjs/client"
|
|
539
|
+
|
|
540
|
+
export function Page() {
|
|
541
|
+
const [x] = createSignal(0)
|
|
542
|
+
return (
|
|
543
|
+
<main data-x={x()}>
|
|
544
|
+
<Card>
|
|
545
|
+
<span>hello</span>
|
|
546
|
+
<span>world</span>
|
|
547
|
+
</Card>
|
|
548
|
+
</main>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
`)
|
|
552
|
+
const types = adapter.generateTypes(ir)!
|
|
553
|
+
expect(types).toContain('"html/template"')
|
|
554
|
+
expect(types).toContain(
|
|
555
|
+
'Children: template.HTML("<span>hello</span><span>world</span>")',
|
|
556
|
+
)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test('text-only children stay on the plain string path (#461 carry-over)', () => {
|
|
560
|
+
const adapter = new GoTemplateAdapter()
|
|
561
|
+
const ir = compileToIR(`
|
|
562
|
+
'use client'
|
|
563
|
+
import { createSignal } from "@barefootjs/client"
|
|
564
|
+
|
|
565
|
+
export function Page() {
|
|
566
|
+
const [x] = createSignal(0)
|
|
567
|
+
return <main data-x={x()}><Button>+1</Button></main>
|
|
568
|
+
}
|
|
569
|
+
`)
|
|
570
|
+
const types = adapter.generateTypes(ir)!
|
|
571
|
+
expect(types).toContain('Children: "+1"')
|
|
572
|
+
expect(types).not.toContain('template.HTML')
|
|
573
|
+
expect(types).not.toContain('"html/template"')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('omits Children entry when component has no JSX children', () => {
|
|
577
|
+
const adapter = new GoTemplateAdapter()
|
|
578
|
+
const ir = compileToIR(`
|
|
579
|
+
'use client'
|
|
580
|
+
import { createSignal } from "@barefootjs/client"
|
|
581
|
+
|
|
582
|
+
export function Page() {
|
|
583
|
+
const [x] = createSignal(0)
|
|
584
|
+
return <main data-x={x()}><Card label="x" /></main>
|
|
585
|
+
}
|
|
586
|
+
`)
|
|
587
|
+
const types = adapter.generateTypes(ir)!
|
|
588
|
+
expect(types).not.toContain('Children:')
|
|
589
|
+
expect(types).not.toContain('template.HTML')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('drops dynamic children that would emit Go template actions', () => {
|
|
593
|
+
// V1 limitation: a `template.HTML` value isn't re-parsed by the
|
|
594
|
+
// parent's `{{.Children}}` pipeline, so any `{{...}}` inside the
|
|
595
|
+
// rendered fragment would output literally. Dynamic-expression /
|
|
596
|
+
// nested-component / conditional children stay on the existing
|
|
597
|
+
// drop path (same as before this issue) until a re-evaluation
|
|
598
|
+
// hook lands.
|
|
599
|
+
const cases = [
|
|
600
|
+
// signal expression inside child
|
|
601
|
+
`'use client'
|
|
602
|
+
import { createSignal } from "@barefootjs/client"
|
|
603
|
+
export function Page() {
|
|
604
|
+
const [c] = createSignal(0)
|
|
605
|
+
return <main><Card><span>{c()}</span></Card></main>
|
|
606
|
+
}`,
|
|
607
|
+
// nested component child
|
|
608
|
+
`'use client'
|
|
609
|
+
import { createSignal } from "@barefootjs/client"
|
|
610
|
+
export function Page() {
|
|
611
|
+
const [x] = createSignal(0)
|
|
612
|
+
return <main data-x={x()}><Card><Button/></Card></main>
|
|
613
|
+
}`,
|
|
614
|
+
// conditional child
|
|
615
|
+
`'use client'
|
|
616
|
+
import { createSignal } from "@barefootjs/client"
|
|
617
|
+
export function Page() {
|
|
618
|
+
const [v] = createSignal(true)
|
|
619
|
+
return <main><Card>{v() ? <span>a</span> : <span>b</span>}</Card></main>
|
|
620
|
+
}`,
|
|
621
|
+
]
|
|
622
|
+
for (const src of cases) {
|
|
623
|
+
const adapter = new GoTemplateAdapter()
|
|
624
|
+
const ir = compileToIR(src, adapter)
|
|
625
|
+
const types = adapter.generateTypes(ir)!
|
|
626
|
+
expect(types).not.toContain('Children:')
|
|
627
|
+
expect(types).not.toContain('template.HTML')
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
describe('generateTypes', () => {
|
|
633
|
+
test('generates types with custom package name', () => {
|
|
634
|
+
const customAdapter = new GoTemplateAdapter({ packageName: 'views' })
|
|
635
|
+
const ir = compileToIR(`
|
|
636
|
+
export function Button(props: { label: string }) {
|
|
637
|
+
return <button>{props.label}</button>
|
|
638
|
+
}
|
|
639
|
+
`, customAdapter)
|
|
640
|
+
|
|
641
|
+
const types = customAdapter.generateTypes(ir)
|
|
642
|
+
|
|
643
|
+
expect(types).toContain('package views')
|
|
644
|
+
expect(types).toContain('type ButtonProps struct')
|
|
645
|
+
expect(types).toContain('Label string')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('generates fields for multiple static child components with slotId', () => {
|
|
649
|
+
const adapter = new GoTemplateAdapter()
|
|
650
|
+
// ReactiveChild is not defined here — only referenced as a child component.
|
|
651
|
+
// The compiler creates IRComponent nodes for any PascalCase JSX element.
|
|
652
|
+
const ir = compileToIR(`
|
|
653
|
+
"use client"
|
|
654
|
+
import { createSignal, createMemo } from "@barefootjs/client"
|
|
655
|
+
|
|
656
|
+
export default function ReactiveProps() {
|
|
657
|
+
const [count, setCount] = createSignal(0)
|
|
658
|
+
const doubled = createMemo(() => count() * 2)
|
|
659
|
+
return (
|
|
660
|
+
<div>
|
|
661
|
+
<ReactiveChild value={count()} label="Child A" />
|
|
662
|
+
<ReactiveChild value={doubled()} label="Child B (doubled)" />
|
|
663
|
+
</div>
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
`)
|
|
667
|
+
|
|
668
|
+
const types = adapter.generateTypes(ir)!
|
|
669
|
+
|
|
670
|
+
// Two ReactiveChild instances should produce two distinct Props fields
|
|
671
|
+
const fieldMatches = types.match(/ReactiveChild\w+ ReactiveChildProps/g) ?? []
|
|
672
|
+
expect(fieldMatches.length).toBe(2)
|
|
673
|
+
|
|
674
|
+
// Each instance should have a NewReactiveChildProps initializer
|
|
675
|
+
const initMatches = types.match(/NewReactiveChildProps\(ReactiveChildInput\{/g) ?? []
|
|
676
|
+
expect(initMatches.length).toBe(2)
|
|
677
|
+
|
|
678
|
+
// Each should have its own ScopeID derived from parent
|
|
679
|
+
const scopeMatches = types.match(/ScopeID: scopeID \+ "_/g) ?? []
|
|
680
|
+
expect(scopeMatches.length).toBe(2)
|
|
681
|
+
|
|
682
|
+
// Label values should be present
|
|
683
|
+
expect(types).toContain('Label: "Child A"')
|
|
684
|
+
expect(types).toContain('Label: "Child B (doubled)"')
|
|
685
|
+
})
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
describe('Portal component handling', () => {
|
|
689
|
+
test('renders Portal component with children as portal collection', () => {
|
|
690
|
+
// Portal is referenced as a child component (not defined in file).
|
|
691
|
+
const result = compileAndGenerate(`
|
|
692
|
+
"use client"
|
|
693
|
+
import { createSignal } from "@barefootjs/client"
|
|
694
|
+
|
|
695
|
+
export function DialogDemo() {
|
|
696
|
+
const [open, setOpen] = createSignal(false)
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
<Portal>
|
|
700
|
+
<div data-slot="dialog-overlay"></div>
|
|
701
|
+
</Portal>
|
|
702
|
+
</div>
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
`)
|
|
706
|
+
expect(result.template).toContain('.Portals.Add')
|
|
707
|
+
expect(result.template).toContain('data-slot=\\"dialog-overlay\\"')
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
test('renders Portal with dynamic attribute in children', () => {
|
|
711
|
+
const result = compileAndGenerate(`
|
|
712
|
+
"use client"
|
|
713
|
+
import { createSignal } from "@barefootjs/client"
|
|
714
|
+
|
|
715
|
+
export function DialogDemo() {
|
|
716
|
+
const [open, setOpen] = createSignal(false)
|
|
717
|
+
return (
|
|
718
|
+
<div>
|
|
719
|
+
<Portal>
|
|
720
|
+
<div data-slot="dialog-overlay" data-state={open() ? 'open' : 'closed'}></div>
|
|
721
|
+
</Portal>
|
|
722
|
+
</div>
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
`)
|
|
726
|
+
expect(result.template).toContain('.Portals.Add')
|
|
727
|
+
expect(result.template).toContain('bfPortalHTML')
|
|
728
|
+
expect(result.template).toContain('data-state')
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
test('Portal without children renders empty portal add', () => {
|
|
732
|
+
const result = compileAndGenerate(`
|
|
733
|
+
"use client"
|
|
734
|
+
import { createSignal } from "@barefootjs/client"
|
|
735
|
+
|
|
736
|
+
export function DialogDemo() {
|
|
737
|
+
const [open, setOpen] = createSignal(false)
|
|
738
|
+
return (
|
|
739
|
+
<div>
|
|
740
|
+
<Portal />
|
|
741
|
+
</div>
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
`)
|
|
745
|
+
expect(result.template).toContain('.Portals.Add')
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
test('non-Portal component renders normally', () => {
|
|
749
|
+
// DialogTrigger is referenced but not defined — compiler creates IRComponent.
|
|
750
|
+
const result = compileAndGenerate(`
|
|
751
|
+
"use client"
|
|
752
|
+
import { createSignal } from "@barefootjs/client"
|
|
753
|
+
|
|
754
|
+
export function DialogDemo() {
|
|
755
|
+
const [open, setOpen] = createSignal(false)
|
|
756
|
+
return (
|
|
757
|
+
<div>
|
|
758
|
+
<DialogTrigger />
|
|
759
|
+
</div>
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
`)
|
|
763
|
+
expect(result.template).toContain('{{template "DialogTrigger"')
|
|
764
|
+
expect(result.template).not.toContain('.Portals.Add')
|
|
765
|
+
})
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
describe('block body filter rendering', () => {
|
|
769
|
+
test('renders loop with simple block body filter', () => {
|
|
770
|
+
const result = compileAndGenerate(`
|
|
771
|
+
"use client"
|
|
772
|
+
import { createSignal } from "@barefootjs/client"
|
|
773
|
+
|
|
774
|
+
type Todo = { text: string; done: boolean }
|
|
775
|
+
|
|
776
|
+
export function TodoList() {
|
|
777
|
+
const [todos, setTodos] = createSignal<Todo[]>([])
|
|
778
|
+
return (
|
|
779
|
+
<ul>
|
|
780
|
+
{todos().filter(t => { return !t.done }).map(todo => (
|
|
781
|
+
<li>Item</li>
|
|
782
|
+
))}
|
|
783
|
+
</ul>
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
`)
|
|
787
|
+
expect(result.template).toContain('{{range')
|
|
788
|
+
expect(result.template).toContain('not .Done')
|
|
789
|
+
expect(result.template).toContain('Item')
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test('renders loop with variable declaration and simple if', () => {
|
|
793
|
+
const result = compileAndGenerate(`
|
|
794
|
+
"use client"
|
|
795
|
+
import { createSignal } from "@barefootjs/client"
|
|
796
|
+
|
|
797
|
+
type Todo = { text: string; done: boolean }
|
|
798
|
+
|
|
799
|
+
export function TodoList() {
|
|
800
|
+
const [todos, setTodos] = createSignal<Todo[]>([])
|
|
801
|
+
const [filter, setFilter] = createSignal('all')
|
|
802
|
+
return (
|
|
803
|
+
<ul>
|
|
804
|
+
{todos().filter(t => {
|
|
805
|
+
const f = filter()
|
|
806
|
+
if (f === 'active') return !t.done
|
|
807
|
+
return true
|
|
808
|
+
}).map(todo => (
|
|
809
|
+
<li>TodoItem</li>
|
|
810
|
+
))}
|
|
811
|
+
</ul>
|
|
812
|
+
)
|
|
813
|
+
}
|
|
814
|
+
`)
|
|
815
|
+
expect(result.template).toContain('{{range')
|
|
816
|
+
expect(result.template).toContain('{{if')
|
|
817
|
+
expect(result.template).toContain('$.Filter')
|
|
818
|
+
expect(result.template).toContain('TodoItem')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('renders loop with TodoApp filter pattern', () => {
|
|
822
|
+
const result = compileAndGenerate(`
|
|
823
|
+
"use client"
|
|
824
|
+
import { createSignal } from "@barefootjs/client"
|
|
825
|
+
|
|
826
|
+
type Todo = { text: string; done: boolean }
|
|
827
|
+
|
|
828
|
+
export function TodoApp() {
|
|
829
|
+
const [todos, setTodos] = createSignal<Todo[]>([])
|
|
830
|
+
const [filter, setFilter] = createSignal('all')
|
|
831
|
+
return (
|
|
832
|
+
<ul>
|
|
833
|
+
{todos().filter(t => {
|
|
834
|
+
const f = filter()
|
|
835
|
+
if (f === 'active') return !t.done
|
|
836
|
+
if (f === 'completed') return t.done
|
|
837
|
+
return true
|
|
838
|
+
}).map(todo => (
|
|
839
|
+
<li>TodoItem</li>
|
|
840
|
+
))}
|
|
841
|
+
</ul>
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
`)
|
|
845
|
+
expect(result.template).toContain('{{range')
|
|
846
|
+
expect(result.template).toContain('{{if')
|
|
847
|
+
expect(result.template).toContain('$.Filter')
|
|
848
|
+
expect(result.template).toContain('active')
|
|
849
|
+
expect(result.template).toContain('completed')
|
|
850
|
+
expect(result.template).toContain('TodoItem')
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
test('loop-param member access stays scoped to range dot (#1442 echo TodoApp repro)', () => {
|
|
854
|
+
// Regression: `todo.done` inside `{{range $_, $todo := .Todos}}`
|
|
855
|
+
// used to lower to `.Todo.Done` (a non-existent field) instead of
|
|
856
|
+
// `.Done` (the range dot's field). The bug only surfaced through
|
|
857
|
+
// the condition-expression rendering path — boolean attributes
|
|
858
|
+
// (`checked={todo.done}`) and `style` ternaries (`todo.done ? ...`)
|
|
859
|
+
// both route through `renderConditionExpr`, which had its own
|
|
860
|
+
// member-handling path that skipped the loop-param normalization
|
|
861
|
+
// applied by the main `ParsedExprEmitter`. Result was a silent
|
|
862
|
+
// failure: Go's html/template expanded the bogus field to "" and
|
|
863
|
+
// aborted the surrounding `{{if}}`, Echo returned HTTP 200 with a
|
|
864
|
+
// truncated body, and the user saw a blank list with no console
|
|
865
|
+
// signal.
|
|
866
|
+
const result = compileAndGenerate(`
|
|
867
|
+
"use client"
|
|
868
|
+
import { createSignal } from "@barefootjs/client"
|
|
869
|
+
|
|
870
|
+
type Todo = { id: number; text: string; done: boolean }
|
|
871
|
+
|
|
872
|
+
export function TodoApp() {
|
|
873
|
+
const [todos] = createSignal<Todo[]>([])
|
|
874
|
+
return (
|
|
875
|
+
<ul>
|
|
876
|
+
{todos().map(todo => (
|
|
877
|
+
<li key={todo.id}>
|
|
878
|
+
<input type="checkbox" checked={todo.done} />
|
|
879
|
+
<span style={todo.done ? 'text-decoration: line-through' : ''}>{todo.text}</span>
|
|
880
|
+
</li>
|
|
881
|
+
))}
|
|
882
|
+
</ul>
|
|
883
|
+
)
|
|
884
|
+
}
|
|
885
|
+
`)
|
|
886
|
+
// The range form is unchanged — still `{{range $_, $todo := .Todos}}`.
|
|
887
|
+
expect(result.template).toContain('{{range $_, $todo := .Todos}}')
|
|
888
|
+
// Boolean attribute condition: `.Done`, NOT `.Todo.Done`.
|
|
889
|
+
expect(result.template).toContain('{{if .Done}}checked{{end}}')
|
|
890
|
+
expect(result.template).not.toContain('.Todo.Done')
|
|
891
|
+
// Ternary inside `style="..."` uses the same condition path —
|
|
892
|
+
// also `.Done`.
|
|
893
|
+
expect(result.template).toContain('{{if .Done}}text-decoration: line-through')
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
describe('higher-order methods - regression', () => {
|
|
898
|
+
test('simple every(t => t.done) uses bf_every', () => {
|
|
899
|
+
const result = compileAndGenerate(`
|
|
900
|
+
"use client"
|
|
901
|
+
import { createSignal } from "@barefootjs/client"
|
|
902
|
+
|
|
903
|
+
type Todo = { text: string; done: boolean }
|
|
904
|
+
|
|
905
|
+
export function TodoStatus() {
|
|
906
|
+
const [todos, setTodos] = createSignal<Todo[]>([])
|
|
907
|
+
return <div>{todos().every(t => t.done)}</div>
|
|
908
|
+
}
|
|
909
|
+
`)
|
|
910
|
+
expect(result.template).toContain('bf_every .Todos "Done"')
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
test('nested .filter(...).length in filter predicate lowers via len (bf_filter ...) (#1443 PR4)', () => {
|
|
914
|
+
// Pre-#1443 PR4: `renderFilterExpr` fell through to the
|
|
915
|
+
// `default` arm for the inner `.filter()` and pushed BF101.
|
|
916
|
+
// PR4 reuses the top-level `renderFilterLengthExpr` path
|
|
917
|
+
// (`len (bf_filter <arr> "<field>" <value>)`) inside the filter
|
|
918
|
+
// predicate emitter, wrapped in parens so the outer `gt` /
|
|
919
|
+
// `eq` / etc. parses as a single operand. The canonical
|
|
920
|
+
// "tags has at least one active" shape now renders cleanly.
|
|
921
|
+
const adapter = new GoTemplateAdapter()
|
|
922
|
+
const result = compileAndGenerate(`
|
|
923
|
+
"use client"
|
|
924
|
+
import { createSignal } from "@barefootjs/client"
|
|
925
|
+
|
|
926
|
+
type Todo = { id: number; name: string; tags: { active: boolean }[] }
|
|
927
|
+
|
|
928
|
+
export function TodoList() {
|
|
929
|
+
const [items, setItems] = createSignal<Todo[]>([])
|
|
930
|
+
return (
|
|
931
|
+
<ul>
|
|
932
|
+
{items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => (
|
|
933
|
+
<li key={t.id}>{t.name}</li>
|
|
934
|
+
))}
|
|
935
|
+
</ul>
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
`, adapter)
|
|
939
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
940
|
+
expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
|
|
941
|
+
// No degenerate fallbacks
|
|
942
|
+
expect(result.template).not.toContain('{{if false}}')
|
|
943
|
+
expect(result.template).not.toContain('[UNSUPPORTED-FILTER-EXPR]')
|
|
944
|
+
expect(result.template).not.toContain('false.Length')
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
test('state does not bleed between two nested-filter expressions in the same component', () => {
|
|
948
|
+
// Both filters now lower (one nested + one supported). Pin
|
|
949
|
+
// both to make sure the depth-scoped state reset
|
|
950
|
+
// (`filterExprUnsupported`) doesn't accidentally smother a
|
|
951
|
+
// sibling predicate when a transient default-arm hit DOES
|
|
952
|
+
// occur (e.g. for the still-unsupported `flatMap` / `reduce`
|
|
953
|
+
// shapes elsewhere in the same component).
|
|
954
|
+
const adapter = new GoTemplateAdapter()
|
|
955
|
+
const result = compileAndGenerate(`
|
|
956
|
+
"use client"
|
|
957
|
+
import { createSignal } from "@barefootjs/client"
|
|
958
|
+
|
|
959
|
+
type Todo = { id: number; name: string; done: boolean; tags: { active: boolean }[] }
|
|
960
|
+
|
|
961
|
+
export function TodoList() {
|
|
962
|
+
const [items, setItems] = createSignal<Todo[]>([])
|
|
963
|
+
return (
|
|
964
|
+
<div>
|
|
965
|
+
<ul>{items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
966
|
+
<ul>{items().filter(t => !t.done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
967
|
+
</div>
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
`, adapter)
|
|
971
|
+
expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
|
|
972
|
+
expect(result.template).toContain('{{if not .Done}}')
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test('nested higher-order in filter predicate + /* @client */ suppresses BF101', () => {
|
|
976
|
+
const adapter = new GoTemplateAdapter()
|
|
977
|
+
const ir = compileToIR(`
|
|
978
|
+
"use client"
|
|
979
|
+
import { createSignal } from "@barefootjs/client"
|
|
980
|
+
|
|
981
|
+
type Todo = { id: number; name: string; tags: { active: boolean }[] }
|
|
982
|
+
|
|
983
|
+
export function TodoList() {
|
|
984
|
+
const [items, setItems] = createSignal<Todo[]>([])
|
|
985
|
+
return (
|
|
986
|
+
<ul>
|
|
987
|
+
{/* @client */ items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => (
|
|
988
|
+
<li key={t.id}>{t.name}</li>
|
|
989
|
+
))}
|
|
990
|
+
</ul>
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
`, adapter)
|
|
994
|
+
adapter.generate(ir)
|
|
995
|
+
const bf101 = adapter.errors.filter(e => e.code === 'BF101')
|
|
996
|
+
expect(bf101).toEqual([])
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
describe('find/findIndex - adapter specific', () => {
|
|
1001
|
+
test('renders find() with equality + comparison mixed predicate', () => {
|
|
1002
|
+
const result = compileAndGenerate(`
|
|
1003
|
+
"use client"
|
|
1004
|
+
import { createSignal } from "@barefootjs/client"
|
|
1005
|
+
|
|
1006
|
+
type Item = { price: number; category: string }
|
|
1007
|
+
|
|
1008
|
+
export function ItemFinder() {
|
|
1009
|
+
const [items, setItems] = createSignal<Item[]>([])
|
|
1010
|
+
const [type, setType] = createSignal('')
|
|
1011
|
+
return <div>{items().find(t => t.price > 100 && t.category === type())}</div>
|
|
1012
|
+
}
|
|
1013
|
+
`)
|
|
1014
|
+
expect(result.template).toContain('{{range')
|
|
1015
|
+
expect(result.template).toContain('gt .Price 100')
|
|
1016
|
+
expect(result.template).toContain('eq .Category $.Type')
|
|
1017
|
+
expect(result.template).toContain('{{break}}')
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
test('renders find() in condition', () => {
|
|
1021
|
+
const result = compileAndGenerate(`
|
|
1022
|
+
"use client"
|
|
1023
|
+
import { createSignal } from "@barefootjs/client"
|
|
1024
|
+
|
|
1025
|
+
type Item = { name: string; done: boolean }
|
|
1026
|
+
|
|
1027
|
+
export function ItemChecker() {
|
|
1028
|
+
const [items, setItems] = createSignal<Item[]>([])
|
|
1029
|
+
return <div>{items().find(t => t.done) ? 'Found' : 'Not found'}</div>
|
|
1030
|
+
}
|
|
1031
|
+
`)
|
|
1032
|
+
expect(result.template).toContain('bf_find .Items "Done" true')
|
|
1033
|
+
expect(result.template).toContain('Found')
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
describe('component root scope comment propagation', () => {
|
|
1038
|
+
test('component root in client component outputs bfScopeComment', () => {
|
|
1039
|
+
const result = compileAndGenerate(`
|
|
1040
|
+
"use client"
|
|
1041
|
+
import { createSignal } from "@barefootjs/client"
|
|
1042
|
+
|
|
1043
|
+
export function Wrapper() {
|
|
1044
|
+
const [active, setActive] = createSignal(false)
|
|
1045
|
+
return <Badge active={active()} />
|
|
1046
|
+
}
|
|
1047
|
+
`)
|
|
1048
|
+
// Component root should have scope comment for hydration boundary
|
|
1049
|
+
expect(result.template).toContain('{{bfScopeComment .}}')
|
|
1050
|
+
expect(result.template).toContain('{{template "Badge"')
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
test('element root in client component does NOT output bfScopeComment', () => {
|
|
1054
|
+
// Element roots use bf-s attribute directly, not scope comments
|
|
1055
|
+
const result = compileAndGenerate(`
|
|
1056
|
+
"use client"
|
|
1057
|
+
import { createSignal } from "@barefootjs/client"
|
|
1058
|
+
|
|
1059
|
+
export function Counter() {
|
|
1060
|
+
const [count, setCount] = createSignal(0)
|
|
1061
|
+
return <div>{count()}</div>
|
|
1062
|
+
}
|
|
1063
|
+
`)
|
|
1064
|
+
expect(result.template).not.toContain('{{bfScopeComment .}}')
|
|
1065
|
+
expect(result.template).toContain('<div')
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
test('if-statement root with component branches outputs bfScopeComment', () => {
|
|
1069
|
+
const result = compileAndGenerate(`
|
|
1070
|
+
"use client"
|
|
1071
|
+
import { createSignal } from "@barefootjs/client"
|
|
1072
|
+
|
|
1073
|
+
export function ConditionalComponent(props: { variant: string }) {
|
|
1074
|
+
const [active, setActive] = createSignal(false)
|
|
1075
|
+
if (props.variant === 'primary') {
|
|
1076
|
+
return <PrimaryBadge active={active()} />
|
|
1077
|
+
}
|
|
1078
|
+
return <DefaultBadge active={active()} />
|
|
1079
|
+
}
|
|
1080
|
+
`)
|
|
1081
|
+
// Both branches should have scope comments
|
|
1082
|
+
const template = result.template
|
|
1083
|
+
const scopeCommentCount = (template.match(/\{\{bfScopeComment \.\}\}/g) ?? []).length
|
|
1084
|
+
expect(scopeCommentCount).toBeGreaterThanOrEqual(2)
|
|
1085
|
+
})
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
describe('script registration - asset paths', () => {
|
|
1089
|
+
const source = `
|
|
1090
|
+
"use client"
|
|
1091
|
+
import { createSignal } from "@barefootjs/client"
|
|
1092
|
+
|
|
1093
|
+
export function Counter() {
|
|
1094
|
+
const [count, setCount] = createSignal(0)
|
|
1095
|
+
return <div>{count()}</div>
|
|
1096
|
+
}
|
|
1097
|
+
`
|
|
1098
|
+
|
|
1099
|
+
test('defaults to /static/client/', () => {
|
|
1100
|
+
const result = compileAndGenerate(source)
|
|
1101
|
+
expect(result.template).toContain('{{.Scripts.Register "/static/client/barefoot.js"}}')
|
|
1102
|
+
expect(result.template).toContain('{{.Scripts.Register "/static/client/Counter.client.js"}}')
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
test('honors clientJsBasePath and barefootJsPath options', () => {
|
|
1106
|
+
const adapter = new GoTemplateAdapter({
|
|
1107
|
+
clientJsBasePath: '/examples/echo/client/',
|
|
1108
|
+
barefootJsPath: '/examples/echo/client/barefoot.js',
|
|
1109
|
+
})
|
|
1110
|
+
const result = compileAndGenerate(source, adapter)
|
|
1111
|
+
expect(result.template).toContain('{{.Scripts.Register "/examples/echo/client/barefoot.js"}}')
|
|
1112
|
+
expect(result.template).toContain('{{.Scripts.Register "/examples/echo/client/Counter.client.js"}}')
|
|
1113
|
+
expect(result.template).not.toContain('/static/client/')
|
|
1114
|
+
})
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
describe('cva-style class derivation (#1177)', () => {
|
|
1118
|
+
// The registry <Button> uses
|
|
1119
|
+
// const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
|
|
1120
|
+
// Make sure jsx-to-ir's const resolution + the adapter's
|
|
1121
|
+
// template-literal renderer agree on every piece of that.
|
|
1122
|
+
const variantSource = `
|
|
1123
|
+
"use client"
|
|
1124
|
+
|
|
1125
|
+
const baseClasses = 'inline-flex items-center'
|
|
1126
|
+
const variantClasses: Record<string, string> = {
|
|
1127
|
+
default: 'bg-primary',
|
|
1128
|
+
secondary: 'bg-secondary',
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export function Tag(props: { variant?: 'default' | 'secondary'; className?: string }) {
|
|
1132
|
+
const classes = \`\${baseClasses} \${variantClasses[props.variant ?? 'default']} \${props.className ?? ''}\`
|
|
1133
|
+
return <span className={classes}>tag</span>
|
|
1134
|
+
}
|
|
1135
|
+
`
|
|
1136
|
+
|
|
1137
|
+
test('inlines string-literal const + emits switch for Record lookup', () => {
|
|
1138
|
+
const result = compileAndGenerate(variantSource)
|
|
1139
|
+
const tpl = result.template
|
|
1140
|
+
// baseClasses substituted as static text:
|
|
1141
|
+
expect(tpl).toContain('inline-flex items-center')
|
|
1142
|
+
// variantClasses[...] became a Go switch with both cases:
|
|
1143
|
+
expect(tpl).toMatch(/\{\{if eq [^}]+ "default"\}\}bg-primary/)
|
|
1144
|
+
expect(tpl).toMatch(/\{\{else if eq [^}]+ "secondary"\}\}bg-secondary/)
|
|
1145
|
+
expect(tpl).toContain('{{end}}')
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
test('html-escapes UnoCSS arbitrary-value classes inside attribute values', () => {
|
|
1149
|
+
const escapingSource = `
|
|
1150
|
+
"use client"
|
|
1151
|
+
|
|
1152
|
+
const baseClasses = '[class*="size-"]:size-4'
|
|
1153
|
+
|
|
1154
|
+
export function Tagged(props: { className?: string }) {
|
|
1155
|
+
const classes = \`\${baseClasses} \${props.className ?? ''}\`
|
|
1156
|
+
return <span className={classes}>x</span>
|
|
1157
|
+
}
|
|
1158
|
+
`
|
|
1159
|
+
const tpl = compileAndGenerate(escapingSource).template
|
|
1160
|
+
// The literal `"` in `[class*="size-"]` would otherwise terminate
|
|
1161
|
+
// the wrapping attribute. Must be entity-escaped.
|
|
1162
|
+
expect(tpl).toContain('[class*="size-"]:size-4')
|
|
1163
|
+
// And no raw `"` should appear inside the class= value (any
|
|
1164
|
+
// remaining `"` must be an attribute boundary, not literal).
|
|
1165
|
+
const classAttrMatch = tpl.match(/class="([^"]*)/)
|
|
1166
|
+
expect(classAttrMatch).not.toBeNull()
|
|
1167
|
+
expect(classAttrMatch![1]).not.toContain('"')
|
|
1168
|
+
})
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
describe('templatePrimitives — JS-compat callees (#1188)', () => {
|
|
1172
|
+
// The registry fires when the call appears DIRECTLY in a JSX
|
|
1173
|
+
// expression position (`<div data-x={JSON.stringify(...)}>`).
|
|
1174
|
+
// Chained-const usage (`const j = JSON.stringify(...); <div
|
|
1175
|
+
// data-x={j}>`) routes through the Go adapter's struct-field
|
|
1176
|
+
// lift today and doesn't invoke the registry — that's a
|
|
1177
|
+
// separate limitation not addressed here. The conformance
|
|
1178
|
+
// test for the via-const shape inspects the CLIENT JS, where
|
|
1179
|
+
// the call IS inlined (relocate accepts it via the registry's
|
|
1180
|
+
// boolean-acceptance side).
|
|
1181
|
+
|
|
1182
|
+
test('JSON.stringify(props.x) emits bf_json in SSR template (inline)', () => {
|
|
1183
|
+
const result = compileAndGenerate(`
|
|
1184
|
+
'use client'
|
|
1185
|
+
export function Foo(props: { config: object }) {
|
|
1186
|
+
return <div data-config={JSON.stringify(props.config)}>hi</div>
|
|
1187
|
+
}
|
|
1188
|
+
`)
|
|
1189
|
+
expect(result.template).toContain('bf_json .Config')
|
|
1190
|
+
// No raw JS leaked into the Go template.
|
|
1191
|
+
expect(result.template).not.toContain('JSON.stringify')
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
test('Math.floor(props.score) emits bf_floor in SSR template (inline)', () => {
|
|
1195
|
+
const result = compileAndGenerate(`
|
|
1196
|
+
'use client'
|
|
1197
|
+
export function Foo(props: { score: number }) {
|
|
1198
|
+
return <div data-rounded={Math.floor(props.score)}>hi</div>
|
|
1199
|
+
}
|
|
1200
|
+
`)
|
|
1201
|
+
expect(result.template).toContain('bf_floor .Score')
|
|
1202
|
+
expect(result.template).not.toContain('Math.floor')
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
test('Math.ceil / Math.round both map to their bf_* equivalents', () => {
|
|
1206
|
+
const ceilResult = compileAndGenerate(`
|
|
1207
|
+
'use client'
|
|
1208
|
+
export function Foo(props: { v: number }) {
|
|
1209
|
+
return <div data-x={Math.ceil(props.v)}>hi</div>
|
|
1210
|
+
}
|
|
1211
|
+
`)
|
|
1212
|
+
expect(ceilResult.template).toContain('bf_ceil .V')
|
|
1213
|
+
|
|
1214
|
+
const roundResult = compileAndGenerate(`
|
|
1215
|
+
'use client'
|
|
1216
|
+
export function Foo(props: { v: number }) {
|
|
1217
|
+
return <div data-x={Math.round(props.v)}>hi</div>
|
|
1218
|
+
}
|
|
1219
|
+
`)
|
|
1220
|
+
expect(roundResult.template).toContain('bf_round .V')
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
test('String(props.x) and Number(props.x) emit bf_string / bf_number', () => {
|
|
1224
|
+
const stringResult = compileAndGenerate(`
|
|
1225
|
+
'use client'
|
|
1226
|
+
export function Foo(props: { v: number }) {
|
|
1227
|
+
return <div data-x={String(props.v)}>hi</div>
|
|
1228
|
+
}
|
|
1229
|
+
`)
|
|
1230
|
+
expect(stringResult.template).toContain('bf_string .V')
|
|
1231
|
+
|
|
1232
|
+
const numberResult = compileAndGenerate(`
|
|
1233
|
+
'use client'
|
|
1234
|
+
export function Foo(props: { v: string }) {
|
|
1235
|
+
return <div data-x={Number(props.v)}>hi</div>
|
|
1236
|
+
}
|
|
1237
|
+
`)
|
|
1238
|
+
expect(numberResult.template).toContain('bf_number .V')
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
test('registry exposes the expected V1 callees', () => {
|
|
1242
|
+
// Pin the V1 surface so a future refactor doesn't accidentally
|
|
1243
|
+
// drop a primitive. New entries are additive — extend this
|
|
1244
|
+
// list rather than replace.
|
|
1245
|
+
const a = new GoTemplateAdapter()
|
|
1246
|
+
const keys = Object.keys(a.templatePrimitives ?? {}).sort()
|
|
1247
|
+
expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
test('unregistered identifier-path callee is NOT accepted by the registry', () => {
|
|
1251
|
+
// The registry is identifier-path-only and explicit. A
|
|
1252
|
+
// user-import like `customSerialize` is NOT registered, so
|
|
1253
|
+
// the Go adapter can't render it server-side. Pin so a
|
|
1254
|
+
// future refactor doesn't accidentally start accepting
|
|
1255
|
+
// arbitrary identifier-paths via this map.
|
|
1256
|
+
const a = new GoTemplateAdapter()
|
|
1257
|
+
expect(a.templatePrimitives?.['customSerialize']).toBeUndefined()
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
test('wrong-arity primitive call falls back to BF101 instead of emitting invalid template', () => {
|
|
1261
|
+
// V1 emit fns blindly read `args[0]`. The arity gate must
|
|
1262
|
+
// reject 0-arg / 2-arg shapes so we don't ship invalid Go
|
|
1263
|
+
// template syntax (`bf_json` with no operand) or silently
|
|
1264
|
+
// drop extra args.
|
|
1265
|
+
const result = compileAndGenerate(`
|
|
1266
|
+
'use client'
|
|
1267
|
+
export function Foo(props: { config: object; replacer: any }) {
|
|
1268
|
+
return <div data-x={JSON.stringify(props.config, props.replacer)}>hi</div>
|
|
1269
|
+
}
|
|
1270
|
+
`)
|
|
1271
|
+
// The substituted form must NOT appear with a stray second
|
|
1272
|
+
// arg; the call falls through and surfaces an error
|
|
1273
|
+
// instead.
|
|
1274
|
+
expect(result.template).not.toContain('bf_json')
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
test('computed-member callee does NOT match a string-keyed registry path', () => {
|
|
1278
|
+
// `arr[0](x)` parses as a call whose callee is a
|
|
1279
|
+
// computed-member. `identifierPath` must return null for
|
|
1280
|
+
// computed members so a same-named primitive in the
|
|
1281
|
+
// registry can't be triggered through array indexing.
|
|
1282
|
+
// Pin: the substitution path doesn't fire here.
|
|
1283
|
+
const result = compileAndGenerate(`
|
|
1284
|
+
'use client'
|
|
1285
|
+
export function Foo(props: { fns: ((x: any) => string)[]; v: any }) {
|
|
1286
|
+
return <div data-x={props.fns[0](props.v)}>hi</div>
|
|
1287
|
+
}
|
|
1288
|
+
`)
|
|
1289
|
+
expect(result.template).not.toContain('bf_json')
|
|
1290
|
+
expect(result.template).not.toContain('bf_string')
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
test('two-tier source-of-truth keeps emit + arity in sync', () => {
|
|
1294
|
+
// Regression for the previous parallel-map shape (#1200
|
|
1295
|
+
// review): ensure every `templatePrimitives` key has a
|
|
1296
|
+
// matching arity entry, so a registry-only addition can't
|
|
1297
|
+
// silently bypass the arity gate.
|
|
1298
|
+
const a = new GoTemplateAdapter()
|
|
1299
|
+
const arities = (a as unknown as { templatePrimitiveArities: Record<string, number> }).templatePrimitiveArities
|
|
1300
|
+
for (const key of Object.keys(a.templatePrimitives ?? {})) {
|
|
1301
|
+
expect(arities[key]).toBeGreaterThan(0)
|
|
1302
|
+
}
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
test('Math.floor(Number(...)) end-to-end via go run produces the expected rendered HTML', async () => {
|
|
1306
|
+
// The other tests assert template emission strings; this one
|
|
1307
|
+
// closes the loop by actually running `go run` against the
|
|
1308
|
+
// generated template + Go runtime helpers, so a regression in
|
|
1309
|
+
// the Go-side `Floor`/`Number` funcs surfaces here. Also
|
|
1310
|
+
// exercises chained-primitive composition (`bf_floor
|
|
1311
|
+
// (bf_number .Score)`) which the inline-direct emit path
|
|
1312
|
+
// produces for nested calls.
|
|
1313
|
+
//
|
|
1314
|
+
// The prop is typed `string` rather than `number` because
|
|
1315
|
+
// generateTypes maps TS `number` to Go `int`, and an `int`
|
|
1316
|
+
// field can't hold the fractional value we need to actually
|
|
1317
|
+
// exercise floor's rounding behaviour. Coercing through
|
|
1318
|
+
// `Number(props.score)` keeps the Go field as `string` and
|
|
1319
|
+
// shifts the float arithmetic into the runtime helpers.
|
|
1320
|
+
// Skipped on hosts without Go ≥ 1.25 (existing harness
|
|
1321
|
+
// GoNotAvailableError path).
|
|
1322
|
+
try {
|
|
1323
|
+
const html = await renderGoTemplateComponent({
|
|
1324
|
+
source: `
|
|
1325
|
+
'use client'
|
|
1326
|
+
export function Foo(props: { score: string }) {
|
|
1327
|
+
return <div data-rounded={Math.floor(Number(props.score))}>hi</div>
|
|
1328
|
+
}
|
|
1329
|
+
`,
|
|
1330
|
+
adapter: new GoTemplateAdapter(),
|
|
1331
|
+
props: { score: '3.7' },
|
|
1332
|
+
})
|
|
1333
|
+
// Math.floor(Number("3.7")) === 3. Go float64 with integer
|
|
1334
|
+
// value formats as "3" via %v.
|
|
1335
|
+
expect(html).toContain('data-rounded="3"')
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
if (err instanceof GoNotAvailableError) {
|
|
1338
|
+
console.log('Skipping Math.floor e2e: go command not found')
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
throw err
|
|
1342
|
+
}
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
test('JSON.stringify end-to-end via go run produces the expected rendered HTML', async () => {
|
|
1346
|
+
try {
|
|
1347
|
+
const html = await renderGoTemplateComponent({
|
|
1348
|
+
source: `
|
|
1349
|
+
'use client'
|
|
1350
|
+
export function Foo(props: { name: string }) {
|
|
1351
|
+
return <div data-config={JSON.stringify(props.name)}>hi</div>
|
|
1352
|
+
}
|
|
1353
|
+
`,
|
|
1354
|
+
adapter: new GoTemplateAdapter(),
|
|
1355
|
+
props: { name: 'alice' },
|
|
1356
|
+
})
|
|
1357
|
+
// `JSON.stringify("alice")` → `"alice"` (with quotes).
|
|
1358
|
+
// The template interpolates into an attribute value, so the
|
|
1359
|
+
// outer quotes get HTML-entity escaped.
|
|
1360
|
+
expect(html).toContain('"alice"')
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
if (err instanceof GoNotAvailableError) {
|
|
1363
|
+
console.log('Skipping JSON.stringify e2e: go command not found')
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
throw err
|
|
1367
|
+
}
|
|
1368
|
+
})
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
describe('NewXxxProps template-parts dispatch (#1275)', () => {
|
|
1372
|
+
// Companion to the Mojo / Hono unit tests for the
|
|
1373
|
+
// `record-index-lookup-via-child-prop` conformance fixture. The IR
|
|
1374
|
+
// producer collapses `template` → `expression` for component props
|
|
1375
|
+
// but preserves the parts on `ExpressionAttr.parts`; the Go adapter
|
|
1376
|
+
// must read those parts and emit an IIFE (`switch`-based) in the
|
|
1377
|
+
// generated `NewXxxProps` so the variant class is materialised at
|
|
1378
|
+
// SSR time. The previous behaviour silently dropped the prop —
|
|
1379
|
+
// visible end-to-end as `class=""` on the scaffold's Button.
|
|
1380
|
+
test('record-index-lookup via child prop emits a Go switch IIFE, not a dropped field', () => {
|
|
1381
|
+
const adapter = new GoTemplateAdapter()
|
|
1382
|
+
const ir = compileToIR(`
|
|
1383
|
+
import { Slot } from './slot'
|
|
1384
|
+
export function V({ variant }: { variant: 'a' | 'b' }) {
|
|
1385
|
+
const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
|
|
1386
|
+
return <Slot className={\`base \${classes[variant]}\`}>hi</Slot>
|
|
1387
|
+
}
|
|
1388
|
+
`, adapter)
|
|
1389
|
+
const out = adapter.generate(ir)
|
|
1390
|
+
const goCode = out.types ?? ''
|
|
1391
|
+
// The ClassName field MUST be set on the SlotInput literal.
|
|
1392
|
+
expect(goCode).toContain('ClassName:')
|
|
1393
|
+
// The IIFE shape: a self-invoking func that switches on the
|
|
1394
|
+
// variant key and returns the matching case.
|
|
1395
|
+
expect(goCode).toContain('func() string {')
|
|
1396
|
+
expect(goCode).toContain('in.Variant.(string)')
|
|
1397
|
+
expect(goCode).toContain('case "a": return "class-a"')
|
|
1398
|
+
expect(goCode).toContain('case "b": return "class-b"')
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
test('intermediate-const composition (Button shape) carries through', () => {
|
|
1402
|
+
const adapter = new GoTemplateAdapter()
|
|
1403
|
+
const ir = compileToIR(`
|
|
1404
|
+
import { Slot } from './slot'
|
|
1405
|
+
export function V({ variant }: { variant: 'a' | 'b' }) {
|
|
1406
|
+
const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
|
|
1407
|
+
const composed = \`base \${classes[variant]}\`
|
|
1408
|
+
return <Slot className={composed}>hi</Slot>
|
|
1409
|
+
}
|
|
1410
|
+
`, adapter)
|
|
1411
|
+
const out = adapter.generate(ir)
|
|
1412
|
+
const goCode = out.types ?? ''
|
|
1413
|
+
expect(goCode).toContain('ClassName:')
|
|
1414
|
+
expect(goCode).toContain('case "a": return "class-a"')
|
|
1415
|
+
})
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
describe('destructured / function-keyword filter shapes (#1443)', () => {
|
|
1419
|
+
test('.filter(({done}) => done).map(...) lowers cleanly', () => {
|
|
1420
|
+
// Pre-#1443 the destructured arrow rejected at the parser and the
|
|
1421
|
+
// surrounding `.map()` loop fell back to a BF101 path. With the
|
|
1422
|
+
// parser rewriting `({done}) => done` to `_t => _t.done`, the
|
|
1423
|
+
// adapter's existing IRLoop.filterPredicate path renders the
|
|
1424
|
+
// chain as `bf_filter .Items "Done" true`.
|
|
1425
|
+
const result = compileAndGenerate(`'use client'
|
|
1426
|
+
import { createSignal } from '@barefootjs/client'
|
|
1427
|
+
export function C() {
|
|
1428
|
+
const [items] = createSignal<any[]>([])
|
|
1429
|
+
return <ul>{items().filter(({done}) => done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
1430
|
+
}`)
|
|
1431
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
1432
|
+
expect(result.template).toContain('bf_filter .Items "Done" true')
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
test('.filter(function (x) { return x.done }).map(...) lowers cleanly', () => {
|
|
1436
|
+
// Function expressions with a single `return <expr>` body
|
|
1437
|
+
// normalise to the arrow-fn IR shape at parse time.
|
|
1438
|
+
const result = compileAndGenerate(`'use client'
|
|
1439
|
+
import { createSignal } from '@barefootjs/client'
|
|
1440
|
+
export function C() {
|
|
1441
|
+
const [items] = createSignal<any[]>([])
|
|
1442
|
+
return <ul>{items().filter(function (x) { return x.done }).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
1443
|
+
}`)
|
|
1444
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
1445
|
+
expect(result.template).toContain('bf_filter .Items "Done" true')
|
|
1446
|
+
})
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
describe('registry Slot class-merge chain (#1443)', () => {
|
|
1450
|
+
test('[a, b].filter(Boolean).join(\' \') lowers to bf_join (bf_filter_truthy (bf_arr ...)) " "', () => {
|
|
1451
|
+
// The registry `<Slot>` merges className via
|
|
1452
|
+
// `[className, childClass].filter(Boolean).join(' ')`. Pre-#1443
|
|
1453
|
+
// each link in the chain (array literal, `Boolean` callable
|
|
1454
|
+
// filter, `.join`) hit a Go-side refusal gate and the chain
|
|
1455
|
+
// emitted BF101 — making the scaffold `<Button>` / `<Card>`
|
|
1456
|
+
// unusable on Go templates. The fix lowers all three:
|
|
1457
|
+
//
|
|
1458
|
+
// - `[a, b]` → `bf_arr a b` (variadic helper)
|
|
1459
|
+
// - `.filter(Boolean)` → `bf_filter_truthy <arr>`
|
|
1460
|
+
// - `.join(sep)` → `bf_join <arr> <sep>`
|
|
1461
|
+
//
|
|
1462
|
+
// Composing through paren-wrapped function calls keeps Go
|
|
1463
|
+
// template's prefix-call precedence well-formed.
|
|
1464
|
+
const result = compileAndGenerate(`
|
|
1465
|
+
"use client"
|
|
1466
|
+
function Slot({ children, className }: { children?: unknown; className?: string }) {
|
|
1467
|
+
if (children) {
|
|
1468
|
+
const merged = [className].filter(Boolean).join(' ')
|
|
1469
|
+
return <div className={merged}>x</div>
|
|
1470
|
+
}
|
|
1471
|
+
return <div>fallback</div>
|
|
1472
|
+
}
|
|
1473
|
+
export { Slot }
|
|
1474
|
+
`.trimStart())
|
|
1475
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
1476
|
+
expect(result.template).toContain('bf_join (bf_filter_truthy (bf_arr .ClassName)) " "')
|
|
1477
|
+
})
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
describe('.includes lowering (#1448 Tier A)', () => {
|
|
1481
|
+
test('items.includes(target) emits `bf_includes .Items .Target` in condition position', () => {
|
|
1482
|
+
// Pre-#1448: BF102 ("Condition not supported") because the
|
|
1483
|
+
// `array-method` IR variant didn't include `includes`, so
|
|
1484
|
+
// `isSupported` refused. Now the parser produces an
|
|
1485
|
+
// `array-method` node and the condition-position dispatcher
|
|
1486
|
+
// (`renderConditionExpr`'s `array-method` arm) delegates to
|
|
1487
|
+
// the same `arrayMethod` emit as expression position, so the
|
|
1488
|
+
// `{{if bf_includes ...}}` shape works.
|
|
1489
|
+
const result = compileAndGenerate(`'use client'
|
|
1490
|
+
import { createSignal } from '@barefootjs/client'
|
|
1491
|
+
export function C() {
|
|
1492
|
+
const [items] = createSignal<string[]>([])
|
|
1493
|
+
const [target] = createSignal('x')
|
|
1494
|
+
return <div>{items().includes(target()) ? 'yes' : 'no'}</div>
|
|
1495
|
+
}`)
|
|
1496
|
+
expect(result.template).toContain('{{if bf_includes .Items .Target}}')
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
test('value.includes(needle) emits the same bf_includes form (runtime dispatches on receiver)', () => {
|
|
1500
|
+
// String and array `.includes` share the parser surface; the
|
|
1501
|
+
// adapter emits the same `bf_includes` call and the Go
|
|
1502
|
+
// runtime helper inspects `reflect.Kind()` at evaluation time.
|
|
1503
|
+
const result = compileAndGenerate(`'use client'
|
|
1504
|
+
import { createSignal } from '@barefootjs/client'
|
|
1505
|
+
export function C() {
|
|
1506
|
+
const [value] = createSignal('hello world')
|
|
1507
|
+
const [needle] = createSignal('world')
|
|
1508
|
+
return <div>{value().includes(needle()) ? 'yes' : 'no'}</div>
|
|
1509
|
+
}`)
|
|
1510
|
+
expect(result.template).toContain('{{if bf_includes .Value .Needle}}')
|
|
1511
|
+
})
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
describe('.indexOf / .lastIndexOf lowering (#1448 Tier A)', () => {
|
|
1515
|
+
test('items.indexOf(target) emits `bf_index_of .Items .Target`', () => {
|
|
1516
|
+
// Pre-#1448: parser refused `.indexOf` via `UNSUPPORTED_METHODS`
|
|
1517
|
+
// and surfaced BF101. The new `array-method` arm + the
|
|
1518
|
+
// `bf_index_of` runtime helper give it value-equality semantics
|
|
1519
|
+
// (DeepEqual against scalars / structs), disjoint from the
|
|
1520
|
+
// struct-field `bf_find_index` used by `.find`.
|
|
1521
|
+
const result = compileAndGenerate(`'use client'
|
|
1522
|
+
import { createSignal } from '@barefootjs/client'
|
|
1523
|
+
export function C() {
|
|
1524
|
+
const [items] = createSignal<string[]>([])
|
|
1525
|
+
const [target] = createSignal('x')
|
|
1526
|
+
return <div>idx: {items().indexOf(target())}</div>
|
|
1527
|
+
}`)
|
|
1528
|
+
expect(result.template).toContain('bf_index_of .Items .Target')
|
|
1529
|
+
// Defensive: must not route through the struct-field helper.
|
|
1530
|
+
expect(result.template).not.toContain('bf_find_index')
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
test('items.lastIndexOf(target) emits `bf_last_index_of .Items .Target`', () => {
|
|
1534
|
+
// Backward-walk variant of indexOf — disambiguating the
|
|
1535
|
+
// duplicated-value case is the canonical reason an author
|
|
1536
|
+
// reaches for `.lastIndexOf` over `.indexOf`.
|
|
1537
|
+
const result = compileAndGenerate(`'use client'
|
|
1538
|
+
import { createSignal } from '@barefootjs/client'
|
|
1539
|
+
export function C() {
|
|
1540
|
+
const [items] = createSignal<string[]>([])
|
|
1541
|
+
const [target] = createSignal('x')
|
|
1542
|
+
return <div>last: {items().lastIndexOf(target())}</div>
|
|
1543
|
+
}`)
|
|
1544
|
+
expect(result.template).toContain('bf_last_index_of .Items .Target')
|
|
1545
|
+
})
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
describe('.at lowering (#1448 Tier A)', () => {
|
|
1549
|
+
test('items.at(-1) emits `bf_at .Items (bf_neg 1)` (negative-index pass-through)', () => {
|
|
1550
|
+
// The Go runtime's `At` already handles negative indices —
|
|
1551
|
+
// `bf_at .Items -1` returns the last element. Go template
|
|
1552
|
+
// syntax doesn't accept literal negative numbers in prefix-
|
|
1553
|
+
// call positions, so the adapter routes the unary minus
|
|
1554
|
+
// through `bf_neg`; the runtime is unchanged.
|
|
1555
|
+
const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
|
|
1556
|
+
return <div>last: {items.at(-1)}</div>
|
|
1557
|
+
}
|
|
1558
|
+
export { A }`)
|
|
1559
|
+
expect(result.template).toContain('bf_at .Items (bf_neg 1)')
|
|
1560
|
+
})
|
|
1561
|
+
|
|
1562
|
+
test('items.at(i) with a signal index emits `bf_at .Items .I`', () => {
|
|
1563
|
+
// Non-literal index — the inner expression goes through the
|
|
1564
|
+
// standard `emit(arg)` path, so any supported expression form
|
|
1565
|
+
// composes (signal call, prop access, arithmetic, etc.).
|
|
1566
|
+
const result = compileAndGenerate(`'use client'
|
|
1567
|
+
import { createSignal } from '@barefootjs/client'
|
|
1568
|
+
export function C() {
|
|
1569
|
+
const [items] = createSignal<string[]>([])
|
|
1570
|
+
const [i] = createSignal(0)
|
|
1571
|
+
return <div>el: {items().at(i())}</div>
|
|
1572
|
+
}`)
|
|
1573
|
+
expect(result.template).toContain('bf_at .Items .I')
|
|
1574
|
+
})
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
describe('.slice lowering (#1448 Tier A)', () => {
|
|
1578
|
+
test('items.slice(1, 3).join(\' \') chains through bf_slice → bf_join', () => {
|
|
1579
|
+
// 2-arg form. The canonical Tier A fixture pins the two-arg
|
|
1580
|
+
// start-and-end shape since a lowering that only handled
|
|
1581
|
+
// single-arg `.slice(start)` would still pass `.slice(1)`
|
|
1582
|
+
// but fail here.
|
|
1583
|
+
const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
|
|
1584
|
+
return <div>{items.slice(1, 3).join(' ')}</div>
|
|
1585
|
+
}
|
|
1586
|
+
export { A }`)
|
|
1587
|
+
expect(result.template).toContain('bf_join (bf_slice .Items 1 3) " "')
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
test('items.slice(start) emits the 1-arg form (no `end`)', () => {
|
|
1591
|
+
// 1-arg form. The Go helper's variadic `end ...int` parameter
|
|
1592
|
+
// distinguishes "absent" from "0"; the absent case slices to
|
|
1593
|
+
// length, the explicit `0` case slices to empty.
|
|
1594
|
+
const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
|
|
1595
|
+
return <div>{items.slice(2).join(' ')}</div>
|
|
1596
|
+
}
|
|
1597
|
+
export { A }`)
|
|
1598
|
+
expect(result.template).toContain('bf_join (bf_slice .Items 2) " "')
|
|
1599
|
+
})
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
describe('.toLowerCase / .toUpperCase lowering (#1448 Tier A)', () => {
|
|
1603
|
+
test('value.toLowerCase() emits `bf_lower .Value`', () => {
|
|
1604
|
+
// Pre-#1448: parser refused `.toLowerCase` via
|
|
1605
|
+
// `UNSUPPORTED_METHODS` and surfaced BF101. The runtime's
|
|
1606
|
+
// `bf_lower` helper has been registered from a prior code
|
|
1607
|
+
// path; this PR wires the JS method name to it.
|
|
1608
|
+
const result = compileAndGenerate(`function A({ value }: { value: string }) {
|
|
1609
|
+
return <div>{value.toLowerCase()}</div>
|
|
1610
|
+
}
|
|
1611
|
+
export { A }`)
|
|
1612
|
+
expect(result.template).toContain('bf_lower .Value')
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
test('value.toUpperCase() emits `bf_upper .Value`', () => {
|
|
1616
|
+
// Mirrors toLowerCase — pre-existing `bf_upper` runtime
|
|
1617
|
+
// helper, JS method name wired at the adapter layer.
|
|
1618
|
+
const result = compileAndGenerate(`function A({ value }: { value: string }) {
|
|
1619
|
+
return <div>{value.toUpperCase()}</div>
|
|
1620
|
+
}
|
|
1621
|
+
export { A }`)
|
|
1622
|
+
expect(result.template).toContain('bf_upper .Value')
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
test('value.trim() emits `bf_trim .Value`', () => {
|
|
1626
|
+
// Pre-existing `bf_trim` (wraps `strings.TrimSpace`); only
|
|
1627
|
+
// adapter wiring is new.
|
|
1628
|
+
const result = compileAndGenerate(`function A({ value }: { value: string }) {
|
|
1629
|
+
return <div>[{value.trim()}]</div>
|
|
1630
|
+
}
|
|
1631
|
+
export { A }`)
|
|
1632
|
+
expect(result.template).toContain('bf_trim .Value')
|
|
1633
|
+
})
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
describe('.reverse / .toReversed lowering (#1448 Tier A)', () => {
|
|
1637
|
+
test('items.reverse().join(\' \') chains through bf_reverse → bf_join', () => {
|
|
1638
|
+
// `Array.prototype.reverse()` mutates the receiver in JS, but
|
|
1639
|
+
// in SSR template context the receiver is never observed,
|
|
1640
|
+
// so the helper returns a new slice. Composes with .join(' ')
|
|
1641
|
+
// to make the reversed order visible in the rendered output.
|
|
1642
|
+
const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
|
|
1643
|
+
return <div>{items.reverse().join(' ')}</div>
|
|
1644
|
+
}
|
|
1645
|
+
export { A }`)
|
|
1646
|
+
expect(result.template).toContain('bf_join (bf_reverse .Items) " "')
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
test('items.toReversed().join(\' \') routes through the same helper', () => {
|
|
1650
|
+
// `.toReversed()` is the non-mutating sibling. Sharing a
|
|
1651
|
+
// lowering with `.reverse()` is fine in template context.
|
|
1652
|
+
const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
|
|
1653
|
+
return <div>{items.toReversed().join(' ')}</div>
|
|
1654
|
+
}
|
|
1655
|
+
export { A }`)
|
|
1656
|
+
expect(result.template).toContain('bf_join (bf_reverse .Items) " "')
|
|
1657
|
+
})
|
|
1658
|
+
})
|
|
1659
|
+
|
|
1660
|
+
describe('.concat lowering (#1448 Tier A)', () => {
|
|
1661
|
+
test('left.concat(right).join(\' \') chains through bf_concat → bf_join', () => {
|
|
1662
|
+
// Composition pin: the canonical Tier A fixture
|
|
1663
|
+
// (`packages/adapter-tests/fixtures/methods/array-concat.ts`)
|
|
1664
|
+
// composes `.concat(...).join(' ')` so the concatenation
|
|
1665
|
+
// result must be a real iterable (`[]any` from `bf_concat`),
|
|
1666
|
+
// not a stringified `[object Object]` from a wrong lowering.
|
|
1667
|
+
const result = compileAndGenerate(`function A({ left, right }: { left: string[]; right: string[] }) {
|
|
1668
|
+
return <div>{left.concat(right).join(' ')}</div>
|
|
1669
|
+
}
|
|
1670
|
+
export { A }`)
|
|
1671
|
+
expect(result.template).toContain('bf_join (bf_concat .Left .Right) " "')
|
|
1672
|
+
})
|
|
1673
|
+
})
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
// =============================================================================
|
|
1677
|
+
// #1448 Tier A — fixture-driven lowering pins
|
|
1678
|
+
// =============================================================================
|
|
1679
|
+
//
|
|
1680
|
+
// Companion to the Mojo adapter's fixture-driven block (see
|
|
1681
|
+
// `packages/adapter-mojolicious/src/__tests__/mojo-adapter.test.ts`).
|
|
1682
|
+
// The conformance test suite above renders every fixture end-to-end
|
|
1683
|
+
// through `go run` and compares HTML — strongest possible signal —
|
|
1684
|
+
// but skips with `GoNotAvailableError` on hosts without Go installed.
|
|
1685
|
+
// This block compiles each Tier A fixture's `source` through the
|
|
1686
|
+
// adapter and pins the emitted helper-call substring directly on
|
|
1687
|
+
// the Go template string. No `go run` needed; runs on every host.
|
|
1688
|
+
//
|
|
1689
|
+
// One row per Tier A method fixture from
|
|
1690
|
+
// packages/adapter-tests/fixtures/methods/. Each PR in the Tier A
|
|
1691
|
+
// stack appends its rows as the corresponding lowering lands.
|
|
1692
|
+
|
|
1693
|
+
import { fixture as arrayIncludesFixture } from '../../../adapter-tests/fixtures/methods/array-includes'
|
|
1694
|
+
import { fixture as stringIncludesFixture } from '../../../adapter-tests/fixtures/methods/string-includes'
|
|
1695
|
+
import { fixture as arrayIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-indexOf'
|
|
1696
|
+
import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-lastIndexOf'
|
|
1697
|
+
import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
|
|
1698
|
+
import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
|
|
1699
|
+
import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
|
|
1700
|
+
import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
|
|
1701
|
+
import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
|
|
1702
|
+
import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
|
|
1703
|
+
import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
|
|
1704
|
+
import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
|
|
1705
|
+
// #1448 Tier B — .sort / .toSorted fixtures.
|
|
1706
|
+
import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
|
|
1707
|
+
import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
|
|
1708
|
+
import { fixture as arraySortPrimitiveFixture } from '../../../adapter-tests/fixtures/methods/array-sort-primitive'
|
|
1709
|
+
import { fixture as arraySortLocaleFixture } from '../../../adapter-tests/fixtures/methods/array-sort-locale'
|
|
1710
|
+
import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures/methods/array-toSorted'
|
|
1711
|
+
|
|
1712
|
+
describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
|
|
1713
|
+
const cases = [
|
|
1714
|
+
// The `.includes` fixtures sit at condition position
|
|
1715
|
+
// (`{cond ? 'yes' : 'no'}`), so the emit lands inside `{{if ...}}`.
|
|
1716
|
+
{ fixture: arrayIncludesFixture, expect: '{{if bf_includes .Items .Target}}' },
|
|
1717
|
+
{ fixture: stringIncludesFixture, expect: '{{if bf_includes .Value .Needle}}' },
|
|
1718
|
+
{ fixture: arrayIndexOfFixture, expect: 'bf_index_of .Items .Target' },
|
|
1719
|
+
{ fixture: arrayLastIndexOfFixture, expect: 'bf_last_index_of .Items .Target' },
|
|
1720
|
+
// The literal `-1` lowers through `bf_neg 1` — Go template
|
|
1721
|
+
// doesn't accept literal negative numbers in prefix-call
|
|
1722
|
+
// positions. Pre-existing unary-emit pattern.
|
|
1723
|
+
{ fixture: arrayAtFixture, expect: 'bf_at .Items (bf_neg 1)' },
|
|
1724
|
+
{ fixture: arrayConcatFixture, expect: 'bf_concat .Left .Right' },
|
|
1725
|
+
{ fixture: arraySliceFixture, expect: 'bf_slice .Items 1 3' },
|
|
1726
|
+
{ fixture: arrayReverseFixture, expect: 'bf_reverse .Items' },
|
|
1727
|
+
// .toReversed shares the helper with .reverse — pinning both
|
|
1728
|
+
// routings catches a future divergence between them.
|
|
1729
|
+
{ fixture: arrayToReversedFixture, expect: 'bf_reverse .Items' },
|
|
1730
|
+
{ fixture: stringToLowerCaseFixture,expect: 'bf_lower .Value' },
|
|
1731
|
+
{ fixture: stringToUpperCaseFixture,expect: 'bf_upper .Value' },
|
|
1732
|
+
{ fixture: stringTrimFixture, expect: 'bf_trim .Value' },
|
|
1733
|
+
// #1448 Tier B — sort / toSorted. Loop-chained shapes wrap the
|
|
1734
|
+
// iterable in `bf_sort .Items <kind> <key> <type> <dir>`;
|
|
1735
|
+
// standalone shapes inline the helper at the call site.
|
|
1736
|
+
{ fixture: arraySortFieldAscFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "asc"' },
|
|
1737
|
+
{ fixture: arraySortFieldDescFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "desc"' },
|
|
1738
|
+
{ fixture: arraySortPrimitiveFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
|
|
1739
|
+
{ fixture: arraySortLocaleFixture, expect: 'bf_sort .Names "self" "" "string" "asc"' },
|
|
1740
|
+
{ fixture: arrayToSortedFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
|
|
1741
|
+
]
|
|
1742
|
+
|
|
1743
|
+
for (const { fixture, expect: expectedHelper } of cases) {
|
|
1744
|
+
test(`[${fixture.id}] lowers to \`${expectedHelper}\``, () => {
|
|
1745
|
+
const adapter = new GoTemplateAdapter()
|
|
1746
|
+
const result = compileJSX(fixture.source, `${fixture.id}.tsx`, { adapter })
|
|
1747
|
+
// No BF101 — the parser arm + adapter case took the call.
|
|
1748
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
1749
|
+
// ...and no BF102 — `.includes` lands at condition position so
|
|
1750
|
+
// a regression to the "Condition not supported" path would
|
|
1751
|
+
// surface here.
|
|
1752
|
+
expect(result.errors?.filter(e => e.code === 'BF102') ?? []).toEqual([])
|
|
1753
|
+
const template = result.files.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
|
|
1754
|
+
expect(template).toContain(expectedHelper)
|
|
1755
|
+
})
|
|
1756
|
+
}
|
|
1757
|
+
})
|