@barefootjs/go-template 0.6.1 → 0.8.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 +122 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +191 -3
- package/dist/build.js +191 -3
- package/dist/index.js +191 -3
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +124 -10
- package/src/__tests__/slot-dynamic-tag.test.ts +79 -0
- package/src/adapter/go-template-adapter.ts +380 -3
- package/src/test-render.ts +33 -1
|
@@ -76,16 +76,24 @@ runAdapterConformanceTests({
|
|
|
76
76
|
// option fixed on the Hono side. Separate follow-up.
|
|
77
77
|
'toggle-shared',
|
|
78
78
|
'props-reactivity-comparison',
|
|
79
|
-
// #1467 Phase
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
79
|
+
// #1467 Phase 2b: basic interactive `site/ui` primitives. Cross-adapter
|
|
80
|
+
// parity for the `site/ui` corpus is Phase 3, so these participate only
|
|
81
|
+
// in Hono SSR conformance + the fixture-hydrate runtime layer for now.
|
|
82
|
+
// (`label` and `kbd` are static helpers; `toggle` / `switch` /
|
|
83
|
+
// `checkbox` carry uncontrolled state; `input` is a pass-through
|
|
84
|
+
// native control.)
|
|
85
|
+
//
|
|
86
|
+
// `textarea` now participates: its conditional inline-object spread
|
|
87
|
+
// (`{...(describedBy ? {...} : {})}`) lowers via
|
|
88
|
+
// `buildConditionalSpreadInitializer`, and its optional
|
|
89
|
+
// `rows={rows}` attribute is omitted when nil via the nillable-field
|
|
90
|
+
// guard in `elementAttrEmitter.emitExpression`
|
|
91
|
+
// (`{{if ne .Rows nil}}rows="{{.Rows}}"{{end}}`), matching Hono's
|
|
92
|
+
// nullish-attribute omission.
|
|
93
|
+
'toggle',
|
|
94
|
+
'switch',
|
|
95
|
+
'checkbox',
|
|
96
|
+
'kbd',
|
|
89
97
|
],
|
|
90
98
|
// Per-fixture build-time contracts for shapes the Go template
|
|
91
99
|
// adapter intentionally refuses to lower. Lives here (not on the
|
|
@@ -298,6 +306,28 @@ export function Counter(props: { initial?: number }) {
|
|
|
298
306
|
expect(result.types).toContain('Count int')
|
|
299
307
|
})
|
|
300
308
|
|
|
309
|
+
test('module pure-string const referenced in className inlines the literal (#1467 Phase 2b)', () => {
|
|
310
|
+
// A module-scope `const X = 'literal'` used inside a className
|
|
311
|
+
// template literal must inline its value, NOT emit `{{.X}}` against a
|
|
312
|
+
// Props field that never exists (Go fails `can't evaluate field X`).
|
|
313
|
+
// Hono inlines it at runtime; this restores byte-parity.
|
|
314
|
+
const source = `
|
|
315
|
+
"use client"
|
|
316
|
+
const labelClasses = 'flex items-center group-data-[disabled=true]:opacity-50'
|
|
317
|
+
export function Label({ className = '' }: { className?: string }) {
|
|
318
|
+
return <label className={\`\${labelClasses} \${className}\`} />
|
|
319
|
+
}
|
|
320
|
+
`
|
|
321
|
+
const { template, types } = compileAndGenerate(source)
|
|
322
|
+
// The literal is inlined as a Go string literal, escaped tokens intact.
|
|
323
|
+
expect(template).toContain(
|
|
324
|
+
'{{"flex items-center group-data-[disabled=true]:opacity-50"}}',
|
|
325
|
+
)
|
|
326
|
+
// No struct-field reference to the const, and no Props field for it.
|
|
327
|
+
expect(template).not.toContain('.LabelClasses')
|
|
328
|
+
expect(types).not.toContain('LabelClasses')
|
|
329
|
+
})
|
|
330
|
+
|
|
301
331
|
test('dynamic loop with child component → NewXxxProps carries a populate-this-slice doc comment (#1442 echo TodoApp repro)', () => {
|
|
302
332
|
// Regression: a `todos().map(t => <TodoItem todo={t} />)` loop with a
|
|
303
333
|
// dynamic array (signal getter, not a static prop) declares
|
|
@@ -795,6 +825,90 @@ export function List() {
|
|
|
795
825
|
})
|
|
796
826
|
})
|
|
797
827
|
|
|
828
|
+
describe('conditional inline-object spread (textarea aria-describedby)', () => {
|
|
829
|
+
// `{...(cond ? { 'aria-describedby': cond } : {})}` lowers to an
|
|
830
|
+
// IIFE-of-maps in `NewXxxProps` so the falsy branch OMITS the key
|
|
831
|
+
// (SpreadAttrs does not filter empty strings). The fixture only
|
|
832
|
+
// exercises the falsy branch; this pins the TRUTHY branch.
|
|
833
|
+
test('lowers to a conditional map IIFE and keeps the {{bf_spread_attrs}} template', () => {
|
|
834
|
+
const source = `
|
|
835
|
+
function Box({ describedBy }: { describedBy?: string }) {
|
|
836
|
+
return <div {...(describedBy ? { 'aria-describedby': describedBy } : {})} />
|
|
837
|
+
}
|
|
838
|
+
`
|
|
839
|
+
const { template, types } = compileAndGenerate(source)
|
|
840
|
+
// Template emission is unchanged from the proven {...props} path.
|
|
841
|
+
expect(template).toContain('{{bf_spread_attrs .Spread_0}}')
|
|
842
|
+
// The bag value is a conditional map built in NewBoxProps. The
|
|
843
|
+
// prop type is unresolved (interface{}), so the condition routes
|
|
844
|
+
// through `bf.Truthy` for a faithful JS `Boolean(x)` test.
|
|
845
|
+
expect(types).toContain('Spread_0: func() map[string]any {')
|
|
846
|
+
expect(types).toContain('if bf.Truthy(in.DescribedBy) {')
|
|
847
|
+
expect(types).toContain('return map[string]any{"aria-describedby": in.DescribedBy}')
|
|
848
|
+
expect(types).toContain('return map[string]any{}')
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
test('resolves the object value reference and key for a second prop', () => {
|
|
852
|
+
const source = `
|
|
853
|
+
function Box({ label }: { label: string }) {
|
|
854
|
+
return <div {...(label ? { 'data-label': label } : {})} />
|
|
855
|
+
}
|
|
856
|
+
`
|
|
857
|
+
const { types } = compileAndGenerate(source)
|
|
858
|
+
// The condition prop and the value reference resolve to `in.<Field>`,
|
|
859
|
+
// the static key is preserved. (The analyzer surfaces these
|
|
860
|
+
// destructured props as `unknown`/`interface{}`, so the condition
|
|
861
|
+
// routes through `bf.Truthy` for a faithful JS truthiness test.)
|
|
862
|
+
expect(types).toContain('if bf.Truthy(in.Label) {')
|
|
863
|
+
expect(types).toContain('return map[string]any{"data-label": in.Label}')
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
test('refuses a non-identifier condition with BF101 (out-of-shape fallback)', () => {
|
|
867
|
+
const adapter = new GoTemplateAdapter()
|
|
868
|
+
const source = `
|
|
869
|
+
function Box({ a, b }: { a?: string; b?: string }) {
|
|
870
|
+
return <div {...(a === b ? { 'data-x': a } : {})} />
|
|
871
|
+
}
|
|
872
|
+
`
|
|
873
|
+
const ir = compileToIR(source, adapter)
|
|
874
|
+
adapter.generate(ir)
|
|
875
|
+
const errs = (adapter as unknown as { errors: { code: string }[] }).errors
|
|
876
|
+
expect(errs.some(e => e.code === 'BF101')).toBe(true)
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
describe('nullish optional-attribute omission (textarea rows)', () => {
|
|
881
|
+
// An optional, no-default prop whose Go field type resolves to
|
|
882
|
+
// `interface{}` (nillable) is emitted with a `ne .X nil` guard so an
|
|
883
|
+
// unset value DROPS the attribute instead of rendering `attr=""` —
|
|
884
|
+
// matching Hono's nullish-attribute omission. Concrete/defaulted
|
|
885
|
+
// props are never nil and stay unconditional.
|
|
886
|
+
test('guards a nillable optional attr with {{if ne .X nil}}', () => {
|
|
887
|
+
const source = `
|
|
888
|
+
function C({ rows }: { rows?: number }) {
|
|
889
|
+
return <textarea rows={rows} />
|
|
890
|
+
}
|
|
891
|
+
`
|
|
892
|
+
const { template } = compileAndGenerate(source)
|
|
893
|
+
expect(template).toContain('{{if ne .Rows nil}}rows="{{.Rows}}"{{end}}')
|
|
894
|
+
// Must NOT emit the bare unconditional form.
|
|
895
|
+
expect(template).not.toMatch(/(?<!if ne \.Rows nil}})rows="\{\{\.Rows\}\}"/)
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
test('leaves a concrete/defaulted attr unconditional (scope did not widen)', () => {
|
|
899
|
+
const source = `
|
|
900
|
+
function C({ value = '' }: { value?: string }) {
|
|
901
|
+
return <textarea value={value} />
|
|
902
|
+
}
|
|
903
|
+
`
|
|
904
|
+
const { template } = compileAndGenerate(source)
|
|
905
|
+
// `value` has a destructure default → concrete `string` field →
|
|
906
|
+
// never nil → emitted unconditionally, exactly like Hono's value="".
|
|
907
|
+
expect(template).toContain('value="{{.Value}}"')
|
|
908
|
+
expect(template).not.toContain('if ne .Value nil')
|
|
909
|
+
})
|
|
910
|
+
})
|
|
911
|
+
|
|
798
912
|
describe('loop body outer-scope references (#1677)', () => {
|
|
799
913
|
test('references an outer signal inside a loop via $ root scope, not the element', () => {
|
|
800
914
|
// Inside `{{range $_, $t := .Items}}` the dot is rebound to the loop
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the Go template lowering of the `Slot` component's dynamic tag.
|
|
3
|
+
*
|
|
4
|
+
* `Slot` does `const Tag = children.tag; return <Tag …>{…}</Tag>` inside
|
|
5
|
+
* an `if (isValidElement(children)) { … }` block. A naive lowering would:
|
|
6
|
+
* 1. emit `{{template "Tag" .TagSlot0}}` — a template that can never be
|
|
7
|
+
* registered. Go's html/template escape-walks ALL registered
|
|
8
|
+
* templates (even dead branches), so the whole render fails with
|
|
9
|
+
* `no such template "Tag"`.
|
|
10
|
+
* 2. lower `isValidElement(children)` to a bogus `.IsValidElement`
|
|
11
|
+
* struct-field access (a user type-guard predicate Go can't evaluate).
|
|
12
|
+
*
|
|
13
|
+
* The `dynamicTag` IR flag (jsx-to-ir) plus the call-condition fallback
|
|
14
|
+
* (go-template-adapter) defuse both. This test asserts the *emitted Go
|
|
15
|
+
* template string* carries neither pathology, complementing the full
|
|
16
|
+
* conformance render in `go-template-adapter.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import { GoTemplateAdapter } from '../adapter/go-template-adapter'
|
|
21
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
22
|
+
|
|
23
|
+
// The committed SSR-precompiled Slot module the conformance harness feeds
|
|
24
|
+
// to Go (`children.tag` dynamic tag inside an isValidElement guard).
|
|
25
|
+
const SLOT_SSR = `/** @jsxImportSource hono/jsx */
|
|
26
|
+
interface SlotProps { children?: unknown; className?: string; [key: string]: unknown }
|
|
27
|
+
function isValidElement(element: unknown): element is { tag: unknown; props: Record<string, unknown> } {
|
|
28
|
+
return !!(element && typeof element === 'object' && 'tag' in element && 'props' in element)
|
|
29
|
+
}
|
|
30
|
+
export function Slot({ children, className, ...props }: SlotProps) {
|
|
31
|
+
if (children && isValidElement(children)) {
|
|
32
|
+
const Tag = children.tag as any
|
|
33
|
+
const childProps = children.props
|
|
34
|
+
const childClass = (childProps.className as string) || ''
|
|
35
|
+
const childChildren = childProps.children
|
|
36
|
+
const mergedClass = [className, childClass].filter(Boolean).join(' ')
|
|
37
|
+
return <Tag {...childProps} {...props} className={mergedClass || undefined}>{childChildren}</Tag>
|
|
38
|
+
}
|
|
39
|
+
return <>{children}</>
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
describe('Slot dynamic-tag Go lowering', () => {
|
|
44
|
+
test('emitted Go template has no `{{template "Tag"` call and no `.IsValidElement` field', () => {
|
|
45
|
+
const adapter = new GoTemplateAdapter()
|
|
46
|
+
const result = compileJSX(SLOT_SSR, 'slot.tsx', { adapter, componentName: 'Slot' })
|
|
47
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
48
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!
|
|
49
|
+
expect(template).toBeDefined()
|
|
50
|
+
expect(template.content).not.toContain('{{template "Tag"')
|
|
51
|
+
expect(template.content).not.toContain('.IsValidElement')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('`isValidElement(children)` lowers to a real truthiness check — no diagnostic, no forced literal', () => {
|
|
55
|
+
const adapter = new GoTemplateAdapter()
|
|
56
|
+
const result = compileJSX(SLOT_SSR, 'slot.tsx', { adapter, componentName: 'Slot' })
|
|
57
|
+
// The guard evaluates faithfully on Go (element ⟺ has children to render),
|
|
58
|
+
// so there is neither an error nor an ignorable warning, and the condition
|
|
59
|
+
// is a real `.Children` truthiness check rather than a fudged literal.
|
|
60
|
+
expect(result.errors).toEqual([])
|
|
61
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!
|
|
62
|
+
expect(template.content).toContain('.Children')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a non-isValidElement user predicate (e.g. `isAdmin`) is a hard BF102 error, not a silent literal', () => {
|
|
66
|
+
const SRC = `/** @jsxImportSource hono/jsx */
|
|
67
|
+
declare function isAdmin(u: unknown): boolean
|
|
68
|
+
export function Gate({ user }: { user?: unknown }) {
|
|
69
|
+
return <div>{isAdmin(user) ? <span>secret</span> : null}</div>
|
|
70
|
+
}
|
|
71
|
+
`
|
|
72
|
+
const adapter = new GoTemplateAdapter()
|
|
73
|
+
const result = compileJSX(SRC, 'gate.tsx', { adapter, componentName: 'Gate' })
|
|
74
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
75
|
+
expect(
|
|
76
|
+
errors.some(e => e.code === 'BF102' && /cannot be evaluated/i.test(e.message))
|
|
77
|
+
).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
})
|