@barefootjs/go-template 0.18.5 → 0.18.7

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.
@@ -66,6 +66,15 @@ export class CompileState {
66
66
  */
67
67
  localConstants: IRMetadata['localConstants'] = []
68
68
 
69
+ /**
70
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
71
+ * parameter anywhere in the component (#2208 fable review). Consulted by
72
+ * static loop-source resolution (`getBakedStaticChildLoop`) so a const
73
+ * whose name a DIFFERENT, enclosing loop's own callback param shadows is
74
+ * never resolved as that const's static value.
75
+ */
76
+ staticLoopSourceBoundNames: Set<string> = new Set()
77
+
69
78
  /**
70
79
  * Names of component-scope arrow-const helpers (`const sortClass = …`),
71
80
  * eligible for call-site inlining.
@@ -7,7 +7,7 @@
7
7
  * function over `ir.metadata`; no adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR, TypeInfo } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR, type TypeInfo } from '@barefootjs/jsx'
11
11
 
12
12
  /** True when `type` is the `string` primitive. */
13
13
  function isStringTypeInfo(type: TypeInfo): boolean {
@@ -22,14 +22,33 @@ function isBareStringLiteral(initialValue: string | undefined): boolean {
22
22
  }
23
23
 
24
24
  /**
25
- * String-typed signals and props. A signal is string-typed when its inferred
26
- * type is `string` (or, defensively, when its initial value is a bare string
27
- * literal); a prop when its annotated type is `string`. Drives `isStringName`
28
- * for `isStringConcatBinary` the shared helper (`@barefootjs/jsx`) that
29
- * decides whether a JS `+` is string concatenation rather than numeric
30
- * addition (Go's `html/template` has no native `+` at all; `binary()` always
31
- * emits a runtime call, `bf_add` for addition or `bf_concat_str` for
32
- * concatenation see `go-template-adapter.ts`'s `binary()`).
25
+ * String-typed signals, props, and same-file local consts (#2212, ported
26
+ * here for #2236). A signal is string-typed when its inferred type is
27
+ * `string` (or, defensively, when its initial value is a bare string
28
+ * literal); a prop when its annotated type is `string`; a local const the
29
+ * same way. Drives `isStringName` for `isStringConcatBinary` the shared
30
+ * helper (`@barefootjs/jsx`) that decides whether a JS `+` is string
31
+ * concatenation rather than numeric addition (Go's `html/template` has no
32
+ * native `+` at all; `binary()` always emits a runtime call, `bf_add` for
33
+ * addition or `bf_concat_str` for concatenation — see
34
+ * `go-template-adapter.ts`'s `binary()`). Local consts matter for exactly
35
+ * the shadowing shape this exclusion exists for: with a loop-bound `label`
36
+ * subtracted, an outer `{label + suffix}` (where `suffix = '!'`) must
37
+ * still classify as string concat via its OTHER operand, or it would fall
38
+ * back to `bf_add` and render `0`.
39
+ *
40
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item or
41
+ * index parameter ANYWHERE in the component (#2212, ported here for #2236):
42
+ * this lookup is a flat, scope-blind `Set<string>` with no notion of a loop
43
+ * param shadowing an outer string-typed binding of the same name
44
+ * (`values.map((label) => 1 + label)` inside a component that also has a
45
+ * string `label` prop) — left unguarded, that shadowed `label` would be
46
+ * misdetected as string-typed and `1 + label` would silently lower to
47
+ * `bf_concat_str` instead of staying numeric `bf_add`. Subtracting loop-bound
48
+ * names is coarse (it also suppresses a genuinely non-shadowed same-named
49
+ * string elsewhere in the component) but safe: the suppressed case just
50
+ * falls back to today's numeric `bf_add` — the same, already-accepted
51
+ * residual as an unresolvable operand — never silently-wrong output.
33
52
  */
34
53
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
35
54
  const names = new Set<string>()
@@ -41,5 +60,11 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
41
60
  for (const p of ir.metadata.propsParams) {
42
61
  if (isStringTypeInfo(p.type)) names.add(p.name)
43
62
  }
63
+ for (const c of ir.metadata.localConstants) {
64
+ if ((c.type !== null && isStringTypeInfo(c.type)) || isBareStringLiteral(c.value)) {
65
+ names.add(c.name)
66
+ }
67
+ }
68
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
44
69
  return names
45
70
  }
@@ -15,19 +15,21 @@ export const conformancePins: ConformancePins = {
15
15
  // `style={{ … }}` object literal now lowers to a CSS string with dynamic
16
16
  // values interpolated (`background-color:{{.Color}};padding:8px`) via
17
17
  // `tryLowerStyleObject` (#1322).
18
- // Sibling-imported child component inside a loop body: the adapter
19
- // emits `{{template "X" .}}` which only resolves if the user has
20
- // compiled the sibling file and registered the template on the
21
- // same instance. BF103 makes that requirement loud. (The barefoot
22
- // CLI passes `siblingTemplatesRegistered: true` so CLI builds
23
- // suppress the diagnosticsee compileJSX `siblingTemplatesRegistered`.)
24
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
25
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
26
- // call it inside a keyed `.map`. Same BF103 surface as
27
- // `static-array-children` above pinned at adapter level so the
28
- // shared-component corpus stays adapter-neutral.
29
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
30
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
18
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
19
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
20
+ // sibling `components`, matching `bf build`'s real semantics, so the
21
+ // BF103 loop-body cross-template check no longer fires spuriously.
22
+ // (`todo-app-ssr` is still skipped on this adapter via
23
+ // `render-divergences.ts` #2209for an unrelated signal-seeding gap;
24
+ // `todo-app`'s pre-hydration empty render is unaffected.)
25
+ // `static-array-children` no longer pinned (#2208) — `items`'s
26
+ // array-literal initializer is now recognized as fully-static and its
27
+ // per-item ListItem props/data-key are baked directly into
28
+ // `NewStaticListProps`'s constructor (`analyzeBakeableStaticChildLoop`),
29
+ // since the loop body is a single child component with a plain-value
30
+ // prop set. See #2224 for the narrower remaining gap (a plain-ELEMENT
31
+ // loop body over a static array, or an inline/unnamed array literal —
32
+ // still refused).
31
33
  // `([emoji, users]) => ...` is an array-index tuple destructure — #2087
32
34
  // Phase B's widened gate now admits this shape (`destructure-array-index-in-map`
33
35
  // exercises the same `segments`-based lowering). The remaining refusal here
@@ -40,13 +42,11 @@ export const conformancePins: ConformancePins = {
40
42
  // array bound to such a const. See the `renderLoop` comment at the check
41
43
  // site; Jinja / ERB apply the same narrow check for the same reason.
42
44
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
43
- // Same computed-const array as above, plus the pre-existing BF103 (a
44
- // sibling-imported child component used inside the loop body) — the
45
- // destructure param itself no longer contributes a diagnostic.
46
- 'static-array-from-props-with-component': [
47
- { code: 'BF103', severity: 'error' },
48
- { code: 'BF101', severity: 'error' },
49
- ],
45
+ // Same computed-const array as above the destructure param itself no
46
+ // longer contributes a diagnostic, and BF103 (sibling-imported child
47
+ // component in the loop body) no longer fires either now that the
48
+ // conformance harness passes `siblingTemplatesRegistered: true` (#2205).
49
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
50
50
  // (`style-3-signals` graduated alongside `style-object-dynamic` — see note
51
51
  // above; the `style={{ … }}` object now lowers to a CSS string.)
52
52
  // (`tagged-template-classname` graduated by #2092 — the tag resolves
@@ -127,15 +127,14 @@ export const conformancePins: ConformancePins = {
127
127
  // `string-trim` no longer pinned — pre-existing `bf_trim`
128
128
  // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
129
129
  // ninth PR, closing out Tier A).
130
- // #2073 follow-up: a function-reference `.map(format)` callback has no
131
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
132
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
133
- // a broken template.
134
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
135
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
136
- // deliberate raw-HTML (unescaped) output affordance in the target
137
- // template language. No lowering exists yet, so the compiler refuses
138
- // the shape loudly instead of emitting entity-escaped markup that
139
- // silently renders tags as text.
140
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
130
+ // `array-map-function-reference` no longer pinned — a bare-identifier
131
+ // `.map(format)` callback now resolves one hop to its declaration
132
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
133
+ // #2090 established for `.sort(fnref)`.
134
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
135
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
136
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
137
+ // A dynamic/signal-derived value still refuses with BF101 see the
138
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
139
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
141
140
  }
@@ -17,4 +17,16 @@
17
17
  import type { RenderDivergences } from '@barefootjs/jsx'
18
18
 
19
19
  export const renderDivergences: RenderDivergences = {
20
+ // `todo-app-ssr` no longer diverges (#2209). Two parts: (1) `.Todos`
21
+ // (the loop's DATUM slice) is already seeded straight from the caller's
22
+ // Input — the constructor derives it from `initialTodos`, and `[]Todo`
23
+ // zero-fills `Editing: false`, so the `.map(t => ({ ...t, editing:
24
+ // false }))` transform in the signal initializer was never actually the
25
+ // gap on Go, unlike the 7 template-string adapters. (2) The real gap was
26
+ // `.TodoItems []TodoItemProps` — the loop-body CHILD COMPONENT slice the
27
+ // template actually ranges over — which has no server-side population
28
+ // path in this harness (documented as route-handler-populated in
29
+ // production). `buildDynamicChildLoopSeeding` (this package's
30
+ // `test-render.ts`) now replicates that documented contract for a
31
+ // signal-backed dynamic child-component loop.
20
32
  }
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { compileJSX } from '@barefootjs/jsx'
9
- import type { TemplateAdapter, ComponentIR } from '@barefootjs/jsx'
9
+ import type { TemplateAdapter, ComponentIR, ParsedExpr } from '@barefootjs/jsx'
10
10
  import { GoTemplateAdapter } from './adapter/go-template-adapter.ts'
11
11
  import { deduplicateGoTypes } from './build.ts'
12
- import { capitalizeFieldName, goFieldNameForKey } from './adapter/lib/go-naming.ts'
12
+ import { capitalizeFieldName, goFieldNameForKey, loopKeyToGoFieldPath } from './adapter/lib/go-naming.ts'
13
+ import { findNestedComponents } from './adapter/analysis/component-tree.ts'
14
+ import type { NestedComponentInfo } from './adapter/lib/types.ts'
13
15
  import { mkdir, rm } from 'node:fs/promises'
14
16
  import { resolve } from 'node:path'
15
17
 
@@ -157,8 +159,15 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
157
159
  }
158
160
  }
159
161
 
160
- // Compile parent source
161
- const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
162
+ // Compile parent source. `siblingTemplatesRegistered: Boolean(components)`
163
+ // matches this harness's real behavior every sibling child template is concatenated
164
+ // into `tmplContent` and parsed onto one `*template.Template` instance
165
+ // below, so a loop-body cross-template call resolves at render time (#2205).
166
+ const result = compileJSX(source, 'component.tsx', {
167
+ adapter,
168
+ outputIR: true,
169
+ siblingTemplatesRegistered: Boolean(components),
170
+ })
162
171
 
163
172
  const errors = result.errors.filter(e => e.severity === 'error')
164
173
  if (errors.length > 0) {
@@ -319,11 +328,16 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
319
328
  // cross-adapter; default to 'test' otherwise.
320
329
  const rootScopeId = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
321
330
 
331
+ // (#2209 part 2) Route-handler-equivalent seeding for a signal-backed
332
+ // dynamic child-component loop — see buildDynamicChildLoopSeeding's
333
+ // docstring.
334
+ const { lines: dynamicSeedingLines, needsFmt } = buildDynamicChildLoopSeeding(ir, template)
335
+
322
336
  // main.go — render program
323
337
  const mainGo = `package main
324
338
 
325
339
  import (
326
- "html/template"
340
+ ${needsFmt ? '\t"fmt"\n' : ''} "html/template"
327
341
  "math/rand"
328
342
  "os"
329
343
 
@@ -368,7 +382,7 @@ func main() {
368
382
  ScopeID: ${JSON.stringify(rootScopeId)},
369
383
  ${propsInit}
370
384
  })
371
- if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
385
+ ${dynamicSeedingLines.length > 0 ? dynamicSeedingLines.join('\n') + '\n' : ''} if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
372
386
  os.Stderr.WriteString("template error: " + err.Error() + "\\n")
373
387
  os.Exit(1)
374
388
  }
@@ -551,6 +565,95 @@ function ensureMergedStdlibImports(goTypes: string): string {
551
565
  return goTypes.replace(/import\s*\([^)]*\)/, newBlock)
552
566
  }
553
567
 
568
+ /**
569
+ * Recursively resolve `expr` (a loop's `arrayParsed`) down through
570
+ * `call`/`member` chains to the base signal getter it reads
571
+ * (`todos().filter(...)` → `todos`), or `null` when the base isn't a
572
+ * signal getter call. Structural `ParsedExpr` walk, not string/regex
573
+ * parsing (see CLAUDE.md's "never parse JS with regex" rule).
574
+ */
575
+ function findBaseSignalGetter(expr: ParsedExpr | undefined, signalGetters: ReadonlySet<string>): string | null {
576
+ if (!expr) return null
577
+ switch (expr.kind) {
578
+ case 'identifier':
579
+ return signalGetters.has(expr.name) ? expr.name : null
580
+ case 'call':
581
+ return findBaseSignalGetter(expr.callee, signalGetters)
582
+ case 'member':
583
+ return findBaseSignalGetter(expr.object, signalGetters)
584
+ default:
585
+ return null
586
+ }
587
+ }
588
+
589
+ /**
590
+ * (#2209 part 2) Replicate, in the generated `main.go`, the documented
591
+ * "the route handler populates the loop-body child-component slice at
592
+ * request time" contract for a signal-backed dynamic loop —
593
+ * `generateNewPropsFunction`'s doc comment on `<Name>s []<Name>Props`
594
+ * in `adapter/go-template-adapter.ts`. The constructor only ever seeds
595
+ * the loop's DATUM slice (e.g. `.Todos`, straight from the caller's
596
+ * Input); the child-component Props slice the template actually
597
+ * ranges over (`.TodoItems`) has no server-side population path in
598
+ * this harness — the Hono reference materializes it by literally
599
+ * executing the component, so this closes the gap the same way:
600
+ * derive each item's child Props from the datum slice, exactly as a
601
+ * real route handler is documented to.
602
+ *
603
+ * Deliberately narrow: only fires for a loop whose (a) array source
604
+ * resolves, through `call`/`member` chains, to a component signal
605
+ * getter, (b) generated Go TEMPLATE text actually ranges over
606
+ * `.<Name>s` (a plain substring check on GENERATED GO OUTPUT — not JS
607
+ * parsing — so a `/* @client *\/`-marked loop, whose SSR template has
608
+ * no such range, is untouched by construction), and (c) at least one
609
+ * child prop is a bare pass-through of the loop item (`todo={todo}`).
610
+ * Returns the Go statements to splice into `main()` plus whether `fmt`
611
+ * needs importing.
612
+ */
613
+ function buildDynamicChildLoopSeeding(
614
+ ir: ComponentIR,
615
+ template: string,
616
+ ): { lines: string[]; needsFmt: boolean } {
617
+ const signalGetters = new Set(ir.metadata.signals.map(s => s.getter))
618
+ const lines: string[] = []
619
+ let needsFmt = false
620
+ for (const nested of findNestedComponents(ir.root) as NestedComponentInfo[]) {
621
+ if (!nested.isDynamic || nested.isPropDerived) continue
622
+ if (nested.bodyChildren && nested.bodyChildren.length > 0) continue
623
+ if (!nested.loopParam) continue
624
+ if (!template.includes(`:= .${nested.name}s}}`)) continue
625
+ const datumField = findBaseSignalGetter(nested.loopArrayParsed, signalGetters)
626
+ if (!datumField) continue
627
+
628
+ const inputFields: string[] = []
629
+ for (const prop of nested.props) {
630
+ if (prop.isEventHandler) continue
631
+ if (prop.name === 'key' || prop.name.includes('-')) continue
632
+ if (
633
+ prop.value.kind === 'expression' &&
634
+ prop.value.parsed?.kind === 'identifier' &&
635
+ prop.value.parsed.name === nested.loopParam
636
+ ) {
637
+ inputFields.push(`${capitalizeFieldName(prop.name)}: item`)
638
+ }
639
+ }
640
+ if (inputFields.length === 0) continue
641
+
642
+ lines.push(`\tprops.${nested.name}s = make([]${nested.name}Props, len(props.${capitalizeFieldName(datumField)}))`)
643
+ lines.push(`\tfor i, item := range props.${capitalizeFieldName(datumField)} {`)
644
+ lines.push(`\t\tprops.${nested.name}s[i] = New${nested.name}Props(${nested.name}Input{${inputFields.join(', ')}})`)
645
+ lines.push(`\t\tprops.${nested.name}s[i].BfParent = props.ScopeID`)
646
+ lines.push(`\t\tprops.${nested.name}s[i].BfMount = ${JSON.stringify(nested.slotId ?? '')}`)
647
+ const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
648
+ if (keyField) {
649
+ lines.push(`\t\tprops.${nested.name}s[i].BfDataKey = fmt.Sprint(${keyField})`)
650
+ needsFmt = true
651
+ }
652
+ lines.push(`\t}`)
653
+ }
654
+ return { lines, needsFmt }
655
+ }
656
+
554
657
  /**
555
658
  * Build Go struct field initializers from props.
556
659
  */