@barefootjs/go-template 0.33.6 → 0.35.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.
@@ -176,4 +176,130 @@ export function P(props: { base: string; q: Record<string, string> }) {
176
176
  const { template } = generate(src)
177
177
  expect(template).not.toContain('bf_query')
178
178
  })
179
+
180
+ // #2743: a `queryHref` value in an ATTRIBUTE position emits the whole
181
+ // attribute via `bf_attr` (template.HTMLAttr) so html/template's
182
+ // contextual URL-context autoescape (keyed off the attribute NAME) never
183
+ // percent-encodes the base — see `lowerRegisteredAttrCall`.
184
+ test('an href-attribute queryHref value routes through bf_attr, not `href="{{...}}"`', () => {
185
+ const src = `
186
+ 'use client'
187
+ import { queryHref } from '@barefootjs/client'
188
+ export function P(props: { base: string; tag: string }) {
189
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
190
+ }
191
+ `
192
+ const { template } = generate(src)
193
+ expect(template).toContain('{{bf_attr "href" (bf_query .Base (true) "tag" .Tag)}}')
194
+ expect(template).not.toContain('href="{{')
195
+ })
196
+
197
+ // The route is keyed on the neutral `helper === 'query'` fact, not on the
198
+ // attribute name `href` — any attribute (e.g. `title`) gets the same
199
+ // treatment, since `queryHref` returns a plain string with nothing
200
+ // href-specific about it.
201
+ test('a non-href attribute (title) with a queryHref value also routes through bf_attr', () => {
202
+ const src = `
203
+ 'use client'
204
+ import { queryHref } from '@barefootjs/client'
205
+ export function P(props: { base: string; tag: string }) {
206
+ return <a title={queryHref(props.base, { tag: props.tag })}>x</a>
207
+ }
208
+ `
209
+ const { template } = generate(src)
210
+ expect(template).toContain('{{bf_attr "title" (bf_query .Base (true) "tag" .Tag)}}')
211
+ expect(template).not.toContain('title="{{')
212
+ })
213
+
214
+ // Text-position (non-attribute) use is unaffected — `bf_attr` only wraps
215
+ // the whole-attribute case; a queryHref value read as text still lowers
216
+ // to a bare `bf_query` pipeline.
217
+ test('a queryHref value in text position is not wrapped in bf_attr', () => {
218
+ const src = `
219
+ 'use client'
220
+ import { queryHref } from '@barefootjs/client'
221
+ export function P(props: { base: string; tag: string }) {
222
+ return <span>{queryHref(props.base, { tag: props.tag })}</span>
223
+ }
224
+ `
225
+ const { template } = generate(src)
226
+ expect(template).toContain('bf_query .Base (true) "tag" .Tag')
227
+ expect(template).not.toContain('bf_attr')
228
+ })
229
+
230
+ // #2743 follow-up (pullfrog review on #2841): a ternary attribute value
231
+ // with a real (non-`undefined`) alternate is syntactically valid and
232
+ // already lowers both branches correctly via `bf_ternary` — but was still
233
+ // wrapped in the ordinary `name="{{...}}"` form, leaving it exposed to
234
+ // html/template's URL-context percent-encoding. This must route through
235
+ // `bf_attr` too, wrapping the whole `bf_ternary` pipeline.
236
+ test('a queryHref value in a ternary branch (non-undefined alternate) routes through bf_attr', () => {
237
+ const src = `
238
+ 'use client'
239
+ import { queryHref } from '@barefootjs/client'
240
+ export function P(props: { ok: boolean; base: string; tag: string }) {
241
+ return <a href={props.ok ? queryHref(props.base, { tag: props.tag }) : '/fallback'}>x</a>
242
+ }
243
+ `
244
+ const { template } = generate(src)
245
+ expect(template).toContain(
246
+ '{{bf_attr "href" (bf_ternary (bf_truthy .Ok) (bf_query .Base (true) "tag" .Tag) "/fallback")}}',
247
+ )
248
+ expect(template).not.toContain('href="{{')
249
+ })
250
+
251
+ // #2842: the `undefined`-alternate omission shape used to render only the
252
+ // consequent via a registry-blind path, emitting invalid Go syntax
253
+ // (`.QueryHref .Base bf_map "tag" .Tag`) with no diagnostic. The consequent
254
+ // now routes through `lowerRegisteredAttrCall` (the same whole-attribute
255
+ // `bf_attr` bypass the direct-call and non-undefined-ternary shapes use),
256
+ // inside the `{{if}}` that implements the omission.
257
+ test('the undefined-alternate omission shape routes the consequent through bf_attr inside the {{if}} (#2842)', () => {
258
+ const src = `
259
+ 'use client'
260
+ import { queryHref } from '@barefootjs/client'
261
+ export function P(props: { ok: boolean; base: string; tag: string }) {
262
+ return <a href={props.ok ? queryHref(props.base, { tag: props.tag }) : undefined}>x</a>
263
+ }
264
+ `
265
+ const { template } = generate(src)
266
+ expect(template).toContain('{{if .Ok}}{{bf_attr "href" (bf_query .Base (true) "tag" .Tag)}}{{end}}')
267
+ expect(template).not.toContain('.QueryHref')
268
+ expect(template).not.toContain('bf_map')
269
+ expect(template).not.toContain('href="{{')
270
+ })
271
+
272
+ // #2842: a registered call nested in a template-literal interpolation
273
+ // (not just a direct attribute value) is registry-lowered too — the fix
274
+ // lives in the shared ParsedExpr `call()` dispatcher, so any nested
275
+ // position benefits, not only the ternary/attribute cases above.
276
+ test('a queryHref call nested in a template-literal interpolation is registry-lowered (#2842)', () => {
277
+ const src = `
278
+ 'use client'
279
+ import { queryHref } from '@barefootjs/client'
280
+ export function P(props: { base: string; tag: string }) {
281
+ return <a title={\`pre \${queryHref(props.base, { tag: props.tag })}\`}>x</a>
282
+ }
283
+ `
284
+ const { template } = generate(src)
285
+ expect(template).toContain('title="pre {{bf_query .Base (true) "tag" .Tag}}"')
286
+ expect(template).not.toContain('.QueryHref')
287
+ })
288
+
289
+ // #2842: a nested ternary inside the undef-alternate consequent still
290
+ // recurses correctly — `lowerRegisteredAttrCall`'s `conditional` arm
291
+ // right-folds, matching `lowerTernary`'s own recursion.
292
+ test('a nested ternary inside the undef-alternate consequent still routes through bf_attr (#2842)', () => {
293
+ const src = `
294
+ 'use client'
295
+ import { queryHref } from '@barefootjs/client'
296
+ export function P(props: { a: boolean; b: boolean; base: string; tag: string }) {
297
+ return <a href={props.a ? (props.b ? queryHref(props.base, { tag: props.tag }) : '/x') : undefined}>x</a>
298
+ }
299
+ `
300
+ const { template } = generate(src)
301
+ expect(template).toContain(
302
+ '{{if .A}}{{bf_attr "href" (bf_ternary (bf_truthy .B) (bf_query .Base (true) "tag" .Tag) "/x")}}{{end}}',
303
+ )
304
+ })
179
305
  })
@@ -128,6 +128,44 @@ function lowerTernaryTest(ctx: GoEmitContext, test: ParsedExpr): string {
128
128
  return isBoolShape ? go : `(bf_truthy ${go})`
129
129
  }
130
130
 
131
+ /**
132
+ * First registered matcher that recognises `callee(args)`, or null (#2842).
133
+ * The single registry consultation every Go lowering path shares: the
134
+ * top-level `lowerRegisteredCall` early return, the ParsedExpr `call()`
135
+ * dispatcher (`go-template-adapter.ts`, so a registered call is recognised no
136
+ * matter how deep it sits in an expression tree — a ternary branch, a
137
+ * template-literal interpolation, a binary operand — not only when the call
138
+ * IS the whole expression), and the attribute-position `bf_attr` route all
139
+ * ask this one question. First-match-wins: a malformed helper id from one
140
+ * plugin must not be silently masked by falling through to another.
141
+ */
142
+ export function matchRegisteredCall(
143
+ ctx: GoEmitContext,
144
+ callee: ParsedExpr,
145
+ args: readonly ParsedExpr[],
146
+ ): LoweringNode | null {
147
+ for (const matcher of ctx.state.loweringMatchers) {
148
+ const node = matcher(callee, args)
149
+ if (node) return node
150
+ }
151
+ return null
152
+ }
153
+
154
+ /**
155
+ * Render a recognised call's neutral node to its Go pipeline (unparenthesised
156
+ * — same contract as `lowerRegisteredCall`), or null when no plugin matches
157
+ * or the matched helper has no Go mapping.
158
+ */
159
+ export function lowerRegisteredCallNode(
160
+ ctx: GoEmitContext,
161
+ callee: ParsedExpr,
162
+ args: readonly ParsedExpr[],
163
+ ): string | null {
164
+ if (ctx.state.loweringMatchers.length === 0) return null
165
+ const node = matchRegisteredCall(ctx, callee, args)
166
+ return node ? renderLoweringNode(ctx, node) : null
167
+ }
168
+
131
169
  /**
132
170
  * Lower a helper call to a Go template expression, or null when no registered
133
171
  * plugin recognises it (→ the generic lowering). Matchers — including the
@@ -144,8 +182,7 @@ export function lowerRegisteredCall(
144
182
  jsExpr: string,
145
183
  preParsed?: ParsedExpr,
146
184
  ): string | null {
147
- const matchers = ctx.state.loweringMatchers
148
- if (matchers.length === 0) return null
185
+ if (ctx.state.loweringMatchers.length === 0) return null
149
186
 
150
187
  let call: ParsedExpr | undefined = preParsed?.kind === 'call' ? preParsed : undefined
151
188
  if (!call) {
@@ -155,13 +192,82 @@ export function lowerRegisteredCall(
155
192
  call = parsed
156
193
  }
157
194
 
158
- for (const matcher of matchers) {
159
- const node = matcher(call.callee, call.args)
160
- if (!node) continue
161
- const rendered = renderLoweringNode(ctx, node)
162
- if (rendered !== null) return rendered
195
+ return lowerRegisteredCallNode(ctx, call.callee, call.args)
196
+ }
197
+
198
+ /**
199
+ * Attribute-position twin of `lowerRegisteredCall` (#2743). When an intrinsic
200
+ * element attribute's value is a `query` guard-list (the neutral node the
201
+ * `queryHref` plugin — or any plugin reusing the `query` helper — produces),
202
+ * emit the WHOLE attribute as one `bf_attr` action returning
203
+ * `template.HTMLAttr`, instead of `name="{{bf_query …}}"`. html/template
204
+ * infers a URL context from the attribute NAME (`href`, `src`, `action`,
205
+ * `data-*`, anything containing `src`/`uri`/`url`, …) and percent-encodes the
206
+ * entire value there; the JS reference (Hono) only HTML-escapes. Keyed on the
207
+ * neutral `helper` id, not the attribute name (Go's URL-context table is a
208
+ * heuristic we must not re-implement) and not the JS API name. Returns null
209
+ * for a non-call, an unmatched call, or any other node kind/helper — the
210
+ * caller then takes its ordinary path.
211
+ *
212
+ * Also accepts a `conditional` node directly (#2743 follow-up, pullfrog
213
+ * review on #2841): a ternary with a real, non-`undefined` alternate
214
+ * (`cond ? queryHref(...) : '/fallback'`) is syntactically valid and already
215
+ * lowers its branches correctly via `lowerTernary` — but the CALLER used to
216
+ * still wrap the resulting `(bf_ternary …)` pipeline in the ordinary
217
+ * `name="{{...}}"` form, which leaves it exposed to the same URL-context
218
+ * percent-encoding this function exists to route around. Whenever EITHER
219
+ * branch (recursing through a right-folded nested ternary chain, the same
220
+ * shape `lowerTernary` itself right-folds) is a `query` guard-list call, the
221
+ * whole ternary is wrapped in `bf_attr` too — matching the reference, which
222
+ * HTML-escapes the picked branch's value the same way regardless of which
223
+ * branch won.
224
+ *
225
+ * The caller passes the CONSEQUENT ALONE (not the full ternary) for the
226
+ * `undefined`-alternate omission shape (`cond ? queryHref(...) : undefined`,
227
+ * #2842) — omission needs the `{{if}}` wrapper that shape's caller already
228
+ * builds; a `bf_ternary … ""` inside `bf_attr` can't express it (`Attr`
229
+ * renders `name=""` for an empty value, not attribute absence).
230
+ */
231
+ export function lowerRegisteredAttrCall(
232
+ ctx: GoEmitContext,
233
+ attrName: string,
234
+ parsed: ParsedExpr,
235
+ ): string | null {
236
+ if (parsed.kind === 'conditional') {
237
+ if (!ternaryHasQueryBranch(ctx, parsed.consequent, parsed.alternate)) return null
238
+ // `lowerTernary` already returns a complete parenthesised `(bf_ternary …)`
239
+ // pipeline — pass it straight through as `bf_attr`'s value argument
240
+ // rather than re-wrapping it in a redundant second set of parens.
241
+ const rendered = lowerTernary(ctx, parsed.test, parsed.consequent, parsed.alternate)
242
+ return `{{bf_attr ${JSON.stringify(attrName)} ${rendered}}}`
163
243
  }
164
- return null
244
+ if (parsed.kind !== 'call') return null
245
+ const node = matchRegisteredCall(ctx, parsed.callee, parsed.args)
246
+ if (!node || node.kind !== 'guard-list' || node.helper !== 'query') return null
247
+ const rendered = renderLoweringNode(ctx, node)
248
+ return rendered === null ? null : `{{bf_attr ${JSON.stringify(attrName)} (${rendered})}}`
249
+ }
250
+
251
+ /** Whether a `call` node is a recognised `query` guard-list lowering. */
252
+ function isQueryGuardListCall(ctx: GoEmitContext, node: ParsedExpr): boolean {
253
+ if (node.kind !== 'call') return false
254
+ const lowered = matchRegisteredCall(ctx, node.callee, node.args)
255
+ return lowered?.kind === 'guard-list' && lowered.helper === 'query'
256
+ }
257
+
258
+ /**
259
+ * Whether either branch of a ternary is (or, for a right-folded chain,
260
+ * eventually resolves to) a `query` guard-list call — the trigger for
261
+ * routing the whole ternary through `bf_attr` in `lowerRegisteredAttrCall`.
262
+ */
263
+ function ternaryHasQueryBranch(
264
+ ctx: GoEmitContext,
265
+ consequent: ParsedExpr,
266
+ alternate: ParsedExpr,
267
+ ): boolean {
268
+ const branchHasQuery = (n: ParsedExpr): boolean =>
269
+ n.kind === 'conditional' ? ternaryHasQueryBranch(ctx, n.consequent, n.alternate) : isQueryGuardListCall(ctx, n)
270
+ return branchHasQuery(consequent) || branchHasQuery(alternate)
165
271
  }
166
272
 
167
273
  /**
@@ -80,6 +80,9 @@ import {
80
80
  evaluateStaticLiteral,
81
81
  BindingScope,
82
82
  buildImportAliasMap,
83
+ resolveGetterAliases,
84
+ collectAliasableGetterNames,
85
+ resolveBodyDestructuredPropAliases,
83
86
  } from '@barefootjs/jsx'
84
87
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
85
88
  import { BF_REGION, escapeHtml, resolveJsxChildrenProp } from '@barefootjs/shared'
@@ -127,7 +130,7 @@ import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChil
127
130
  import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
128
131
  import type { GoEmitContext } from "./emit-context.ts"
129
132
  import { inlineLocalHelperCall } from "./expr/helper-inline.ts"
130
- import { lowerRegisteredCall, lowerTernary } from "./expr/url-builder.ts"
133
+ import { lowerRegisteredAttrCall, lowerRegisteredCall, lowerRegisteredCallNode, lowerTernary } from "./expr/url-builder.ts"
131
134
  import {
132
135
  convertInitialValue,
133
136
  jsLiteralToGo,
@@ -465,6 +468,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
465
468
  )
466
469
  this.state.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
467
470
  this.state.localConstants = ir.metadata.localConstants ?? []
471
+ // #2813: precompute which local consts are bare alias-hop chains onto a
472
+ // signal/memo getter, so `rootFieldRef` can route a call through the
473
+ // getter's OWN field instead of registering a separate, never-seeded
474
+ // field under the alias's name. Env-signal getters are excluded — they
475
+ // resolve through `searchParamsFieldRef`, not a seeded root field.
476
+ {
477
+ const getterNames = collectAliasableGetterNames(ir.metadata.signals ?? [], ir.metadata.memos ?? [])
478
+ this.state.getterAliases = resolveGetterAliases(ir.metadata.localConstants ?? [], (n) => getterNames.has(n))
479
+ }
480
+ // #2788: bare-props-form body destructure aliases (`const { children:
481
+ // kids } = props`) — same alias-resolution need as `getterAliases`
482
+ // above, different terminal set (a prop key here, a signal/memo
483
+ // getter there).
484
+ this.state.propDestructureAliases = resolveBodyDestructuredPropAliases(
485
+ ir.metadata.localConstants ?? [],
486
+ ir.metadata.propsObjectName,
487
+ )
468
488
  // #2208 fable review: every name a `.map()`/`.filter()` loop callback
469
489
  // binds as its item/index parameter anywhere in the component. Static
470
490
  // loop-source resolution (`getBakedStaticChildLoop` /
@@ -4846,9 +4866,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4846
4866
  * rebinds. Outside any loop the root *is* the dot, so we emit `.Field`.
4847
4867
  */
4848
4868
  private rootFieldRef(name: string): string {
4849
- this.state.templateReadRootFields.add(name)
4869
+ // #2813 / #2788: `name` may be a bare local-const alias of a
4870
+ // signal/memo getter (`const items__alias = items`) or a bare-props-
4871
+ // form body destructure's renamed prop (`const { children: kids } =
4872
+ // props`) — resolve it to the ALIASED entity's own name FIRST, so the
4873
+ // emitted field matches what that entity itself seeds (`.Items`,
4874
+ // `.Children`) instead of registering a second, never-populated field
4875
+ // under the local alias (`.Items__alias`, `.Kids`).
4876
+ const resolved = this.state.getterAliases.get(name) ?? this.state.propDestructureAliases.get(name) ?? name
4877
+ this.state.templateReadRootFields.add(resolved)
4850
4878
  const prefix = this.inLoop ? '$.' : '.'
4851
- return `${prefix}${capitalizeFieldName(name)}`
4879
+ return `${prefix}${capitalizeFieldName(resolved)}`
4852
4880
  }
4853
4881
 
4854
4882
  /**
@@ -4956,6 +4984,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4956
4984
  }
4957
4985
 
4958
4986
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
4987
+ // #2842: a registered lowering (the built-in `queryHref`, or any userland
4988
+ // plugin) must win in EVERY position this shared dispatcher reaches — a
4989
+ // ternary branch, a template-literal interpolation, a binary operand —
4990
+ // not only when the call is the whole expression (`convertExpressionToGo`'s
4991
+ // own early return). Same precedence as that top-level path, where the
4992
+ // registry is consulted before signal-read resolution and template
4993
+ // primitives.
4994
+ const lowered = lowerRegisteredCallNode(this.emitCtx, callee, args)
4995
+ if (lowered !== null) return lowered
4959
4996
  // Signal call: count() -> .Count (or $.Count inside a loop). An env-signal
4960
4997
  // binding (`searchParams()`, or an aliased `sp()`) resolves to the canonical
4961
4998
  // `.SearchParams` field regardless of the JS name.
@@ -5232,7 +5269,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5232
5269
 
5233
5270
  unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
5234
5271
  const arg = emit(argument)
5235
- if (op === '!') return `not ${arg}`
5272
+ // `not` is a Go template prefix builtin like `and`/`or` (see `logical()`
5273
+ // below) — a multi-token argument (e.g. `or a b`) must be parenthesised
5274
+ // or it degrades into extra sibling args of `not` itself (#2758: `not
5275
+ // or a b` parses as `not` applied to 3 args, not 1).
5276
+ if (op === '!') return `not ${wrapIfMultiToken(arg)}`
5236
5277
  if (op === '-') return `bf_neg ${arg}`
5237
5278
  return arg
5238
5279
  }
@@ -7074,7 +7115,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7074
7115
 
7075
7116
  case 'unary': {
7076
7117
  const arg = this.renderConditionExpr(expr.argument)
7077
- if (expr.op === '!') return { preamble: arg.preamble, expr: `not ${arg.expr}` }
7118
+ // Same prefix-builtin wrapping rule as the `logical` case just below
7119
+ // (and the main `unary()` emitter above): `not` needs its multi-token
7120
+ // argument parenthesised, or e.g. `not or a b` parses as `not`
7121
+ // applied to 3 sibling args instead of 1 (#2758).
7122
+ if (expr.op === '!') return { preamble: arg.preamble, expr: `not ${wrapIfMultiToken(arg.expr)}` }
7078
7123
  if (expr.op === '-') return { preamble: arg.preamble, expr: `bf_neg ${arg.expr}` }
7079
7124
  return arg
7080
7125
  }
@@ -7687,7 +7732,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7687
7732
  // `bfComment` prepends `bf-`, so `printf "loop-i:%v"` yields
7688
7733
  // `<!--bf-loop-i:KEY-->`. The key expression resolves against the current
7689
7734
  // range item (`.` context), matching `data-key`'s emission.
7690
- return `{{bfComment (printf "loop-i:%v" ${this.convertExpressionToGo(loop.key)})}}`
7735
+ // `bfEscapeCommentKey` (#2795 follow-up) neutralizes `-` so a key can't
7736
+ // spell `-->` and close the comment early — see its doc in `bf.go`.
7737
+ return `{{bfComment (printf "loop-i:%v" (bfEscapeCommentKey ${this.convertExpressionToGo(loop.key)}))}}`
7691
7738
  }
7692
7739
  return ''
7693
7740
  }
@@ -8021,10 +8068,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
8021
8068
  ? value.expr
8022
8069
  : value.expr.slice(0, value.expr.indexOf('?')).trim(),
8023
8070
  )
8024
- const valueExpr = this.renderParsedExpr(parsed.consequent)
8025
- const body = `${name}="{{${valueExpr}}}"`
8071
+ // #2842 / #2743: a `query` guard-list consequent (queryHref) takes
8072
+ // the whole-attribute `bf_attr` route INSIDE the `{{if}}`, so
8073
+ // html/template's URL-context inference never percent-encodes it —
8074
+ // the same route the direct-call and non-undefined-ternary shapes
8075
+ // take. The consequent is passed alone (not the ternary): omission
8076
+ // needs this `{{if}}` wrapper, which `bf_attr` can't express on its
8077
+ // own. Any other consequent (a `helper-call` plugin, an unmatched
8078
+ // call, a member/literal) keeps the ordinary `name="{{…}}"` form —
8079
+ // `call()` now consults the registry too, so a plugin call renders
8080
+ // its `bf_<helper>` pipeline there instead of invalid Go syntax.
8081
+ const attrConsequent = lowerRegisteredAttrCall(this.emitCtx, name, parsed.consequent)
8082
+ const body = attrConsequent !== null
8083
+ ? attrConsequent
8084
+ : `${name}="{{${this.renderParsedExpr(parsed.consequent)}}}"`
8026
8085
  return `${preamble}{{if ${goCond}}}${body}{{end}}`
8027
8086
  }
8087
+ // #2743 follow-up (pullfrog review on #2841): a `query` guard-list
8088
+ // value (queryHref) reachable through EITHER branch of this ternary
8089
+ // still needs the whole-attribute `bf_attr` route — otherwise the
8090
+ // pipeline below lands inside the ordinary `name="{{...}}"` wrapper
8091
+ // and html/template's URL-context inference still percent-encodes
8092
+ // whichever branch wins at render time. See `lowerRegisteredAttrCall`.
8093
+ const attrTernary = lowerRegisteredAttrCall(this.emitCtx, name, parsed)
8094
+ if (attrTernary !== null) return attrTernary
8028
8095
  // #2335: the ternary lowers to the pipeline-position `(bf_ternary …)`
8029
8096
  // value (no longer a `{{if}}…{{end}}` fragment), so wrap it in a single
8030
8097
  // `{{…}}` action inside the attribute string — `name="{{bf_ternary …}}"`.
@@ -8034,6 +8101,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
8034
8101
  // Inline Go template syntax with embedded `{{...}}` actions.
8035
8102
  return `${name}="${this.renderParsedExpr(parsed)}"`
8036
8103
  }
8104
+ // #2743: a `query` guard-list value (queryHref) emits the WHOLE
8105
+ // attribute via `bf_attr` (template.HTMLAttr) so html/template's
8106
+ // URL-context inference on the attribute name never percent-encodes
8107
+ // the base. See `lowerRegisteredAttrCall`.
8108
+ const attrAction = lowerRegisteredAttrCall(this.emitCtx, name, parsed)
8109
+ if (attrAction !== null) return attrAction
8037
8110
  // Nullish-attribute omission: when the attribute value is a BARE reference
8038
8111
  // to a nillable (`interface{}`) prop field, guard emission on `ne .X nil`
8039
8112
  // so an unset optional prop drops the attribute entirely instead of
@@ -88,6 +88,28 @@ export class CompileState {
88
88
  */
89
89
  localConstants: IRMetadata['localConstants'] = []
90
90
 
91
+ /**
92
+ * Bare local-const alias-hop chains that ultimately name a signal/memo
93
+ * getter (`const items__alias = items` → `items__alias` -> `items`,
94
+ * #2813) — the SSR-side twin of #2778's CSR-template fix. `rootFieldRef`
95
+ * resolves a name through this map before capitalizing it into a struct
96
+ * field, so `items__alias()` reads `.Items` (the field the getter
97
+ * actually seeds) instead of a phantom, never-seeded `.Items__alias`.
98
+ */
99
+ getterAliases: Map<string, string> = new Map()
100
+
101
+ /**
102
+ * Local names that are BODY-level destructured aliases of a prop key
103
+ * for a bare-props-form component (`const { children: kids } =
104
+ * props`), #2788 — the SSR-side twin of `aliased-destructured-prop`'s
105
+ * PARAMETER-destructuring case (`{ n: count }`, already handled via
106
+ * `ParamInfo.sourceName`/`capitalizeFieldName(p.name)`). `rootFieldRef`
107
+ * resolves a name through this map too, so `kids` reads the same
108
+ * `.Children` field the prop itself seeds instead of a phantom,
109
+ * never-populated `.Kids`.
110
+ */
111
+ propDestructureAliases: Map<string, string> = new Map()
112
+
91
113
  /**
92
114
  * Every name a `.map()`/`.filter()` loop callback binds as its item/index
93
115
  * parameter anywhere in the component (#2208 fable review). Consulted by
@@ -37,9 +37,4 @@
37
37
 
38
38
  import type { RenderDivergences } from '@barefootjs/jsx'
39
39
 
40
- export const renderDivergences: RenderDivergences = {
41
- 'children-passthrough-renamed':
42
- 'A `children` prop destructured under a different name (`const { children: kids } = props`) does not reach the SSR template on Go, tracked as https://github.com/piconic-ai/barefootjs/issues/2788. The same fixture also fails on Mojolicious, where the mechanism IS isolated: the `.html.ep` interpolates the LOCAL alias (`$kids`) while the stash defines only the caller-facing `children`, so the Perl render dies inside `Mojo::Template::process`. Go\'s own failure mode has NOT been read — `go` is not reachable from the local test process (the conformance case prints "go command not found" and skips), so this entry is declared from the CI failure on #2787 alone, not from a local reproduction. Whoever graduates this should read Go\'s actual output first rather than assume it shares Mojo\'s mechanism. Same alias family as `aliased-destructured-prop` (`{ n: count }`), whose Go half graduated in #2525 — worth checking whether the reserved `children` slot bypasses that fix or never had it. `children-passthrough-renamed` asserts the CORRECT (Hono-generated) output, so deleting this entry is the graduation.',
43
- 'aliased-loop-source':
44
- 'A `.map()` loop whose source is a local const alias of a signal getter (`const items__alias = items`) fails template execution on real Go (`can\'t evaluate field Items__alias in type main.AliasedLoopSourceProps`) — the zero-arg-call-to-field lowering routes `items__alias()` to a `.Items__alias` struct field that was never seeded, since seeding only knows about `items`, the real signal name; the alias hop is never resolved. This is the SSR-side twin of #2778 (fixed for the CSR client-JS template in the same PR that added this fixture) — that fix only touches client-JS emission, not Go\'s field-routing/seeding. Tracked at https://github.com/piconic-ai/barefootjs/issues/2813; graduate by resolving the alias hop at field-routing time using the same `resolveAliasOrigin`/`resolveGetterAliases` mechanism #2778 introduced, rather than a third alias-hop walker.',
45
- }
40
+ export const renderDivergences: RenderDivergences = {}