@barefootjs/go-template 0.14.0 → 0.15.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 +295 -9
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +1282 -137
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1283 -136
- package/dist/index.js +1282 -137
- package/dist/test-render.d.ts +8 -0
- package/dist/test-render.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/__tests__/build.test.ts +40 -1
- package/src/__tests__/derived-state-memo.test.ts +371 -0
- package/src/__tests__/go-template-adapter.test.ts +267 -7
- package/src/adapter/go-template-adapter.ts +2205 -188
- package/src/build.ts +4 -0
- package/src/test-render.ts +109 -27
|
@@ -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
|
-
//
|
|
38
|
-
// parity on real Go
|
|
39
|
-
//
|
|
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-["y"] {{.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
|
|
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(`
|