@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.
@@ -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 2a: first `site/ui` source-root fixture. Button
80
- // compiles cleanly on the Go adapter (no diagnostic), but its
81
- // variant/size class composition (`Record<…>[key]` indexed object
82
- // literals) and `applyRestAttrs` spread haven't been validated for
83
- // byte-parity against the Hono reference under Go's template
84
- // semantics. Cross-adapter parity for the `site/ui` corpus is
85
- // explicitly Phase 3 ("cross-adapter parity (Mojo/Go templates)"),
86
- // so Button participates only in Hono SSR conformance + the
87
- // fixture-hydrate runtime layer for now.
88
- 'button',
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
+ })