@barefootjs/go-template 0.15.2 → 0.17.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.
Files changed (73) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +23 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +53 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/helper-inline.d.ts +31 -0
  6. package/dist/adapter/expr/helper-inline.d.ts.map +1 -0
  7. package/dist/adapter/expr/url-builder.d.ts +23 -0
  8. package/dist/adapter/expr/url-builder.d.ts.map +1 -0
  9. package/dist/adapter/go-template-adapter.d.ts +291 -1070
  10. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  11. package/dist/adapter/index.js +2859 -2618
  12. package/dist/adapter/lib/compile-state.d.ts +116 -0
  13. package/dist/adapter/lib/compile-state.d.ts.map +1 -0
  14. package/dist/adapter/lib/constants.d.ts +11 -0
  15. package/dist/adapter/lib/constants.d.ts.map +1 -0
  16. package/dist/adapter/lib/go-emit.d.ts +117 -0
  17. package/dist/adapter/lib/go-emit.d.ts.map +1 -0
  18. package/dist/adapter/lib/go-naming.d.ts +39 -0
  19. package/dist/adapter/lib/go-naming.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +13 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/types.d.ts +165 -0
  23. package/dist/adapter/lib/types.d.ts.map +1 -0
  24. package/dist/adapter/memo/ctor-lowering.d.ts +39 -0
  25. package/dist/adapter/memo/ctor-lowering.d.ts.map +1 -0
  26. package/dist/adapter/memo/memo-compute.d.ts +124 -0
  27. package/dist/adapter/memo/memo-compute.d.ts.map +1 -0
  28. package/dist/adapter/memo/memo-type.d.ts +38 -0
  29. package/dist/adapter/memo/memo-type.d.ts.map +1 -0
  30. package/dist/adapter/memo/memo-value.d.ts +64 -0
  31. package/dist/adapter/memo/memo-value.d.ts.map +1 -0
  32. package/dist/adapter/memo/template-interp.d.ts +43 -0
  33. package/dist/adapter/memo/template-interp.d.ts.map +1 -0
  34. package/dist/adapter/props/prop-types.d.ts +31 -0
  35. package/dist/adapter/props/prop-types.d.ts.map +1 -0
  36. package/dist/adapter/spread/spread-codegen.d.ts +40 -0
  37. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  38. package/dist/adapter/type/type-codegen.d.ts +25 -0
  39. package/dist/adapter/type/type-codegen.d.ts.map +1 -0
  40. package/dist/adapter/value/parsed-literal-to-go.d.ts +17 -0
  41. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -0
  42. package/dist/adapter/value/value-lowering.d.ts +46 -0
  43. package/dist/adapter/value/value-lowering.d.ts.map +1 -0
  44. package/dist/build.js +2857 -2616
  45. package/dist/index.js +2859 -2618
  46. package/dist/test-render.d.ts.map +1 -1
  47. package/package.json +3 -3
  48. package/src/__tests__/derived-state-memo.test.ts +155 -84
  49. package/src/__tests__/go-template-adapter.test.ts +211 -60
  50. package/src/__tests__/lowering-plugin.test.ts +109 -0
  51. package/src/__tests__/query-href.test.ts +179 -0
  52. package/src/adapter/analysis/component-tree.ts +174 -0
  53. package/src/adapter/emit-context.ts +59 -0
  54. package/src/adapter/expr/helper-inline.ts +274 -0
  55. package/src/adapter/expr/url-builder.ts +123 -0
  56. package/src/adapter/go-template-adapter.ts +2100 -5316
  57. package/src/adapter/lib/compile-state.ts +150 -0
  58. package/src/adapter/lib/constants.ts +24 -0
  59. package/src/adapter/lib/go-emit.ts +289 -0
  60. package/src/adapter/lib/go-naming.ts +84 -0
  61. package/src/adapter/lib/ir-scope.ts +31 -0
  62. package/src/adapter/lib/types.ts +182 -0
  63. package/src/adapter/memo/ctor-lowering.ts +267 -0
  64. package/src/adapter/memo/memo-compute.ts +451 -0
  65. package/src/adapter/memo/memo-type.ts +74 -0
  66. package/src/adapter/memo/memo-value.ts +197 -0
  67. package/src/adapter/memo/template-interp.ts +246 -0
  68. package/src/adapter/props/prop-types.ts +84 -0
  69. package/src/adapter/spread/spread-codegen.ts +458 -0
  70. package/src/adapter/type/type-codegen.ts +95 -0
  71. package/src/adapter/value/parsed-literal-to-go.ts +94 -0
  72. package/src/adapter/value/value-lowering.ts +162 -0
  73. package/src/test-render.ts +2 -14
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `queryHref(base, { … })` → `bf_query` lowering for the Go adapter (#2042).
3
+ *
4
+ * The pure functional URL builder is a structured `call` + `object-literal` in
5
+ * the IR, so the adapter lowers it directly — no block-body recognizer, no
6
+ * re-parse. The lowering emits `(include) "key" value` triples and lets the
7
+ * `bf_query` runtime helper own the non-empty check (so it can also append array
8
+ * values member-by-member, #2048): a plain `key: v` → `(true) "key" v`; a
9
+ * conditional `key: cond ? a : undefined` → `(cond) "key" a` (the helper drops an
10
+ * empty `a`). The full value semantics are conformance-tested against
11
+ * URLSearchParams in the shared golden vectors (TestHelperVectors, fn "query").
12
+ */
13
+ import { describe, test, expect } from 'bun:test'
14
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
15
+ import { GoTemplateAdapter } from '../adapter/go-template-adapter'
16
+
17
+ function generate(src: string) {
18
+ const adapter = new GoTemplateAdapter()
19
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter, outputIR: true })
20
+ const irFile = result.files.find(f => f.type === 'ir')
21
+ if (!irFile) throw new Error('no IR')
22
+ const ir = JSON.parse(irFile.content) as ComponentIR
23
+ return adapter.generate(ir)
24
+ }
25
+
26
+ describe('queryHref → bf_query (#2042)', () => {
27
+ test('a plain value passes a `true` include — bf_query drops it if empty', () => {
28
+ const src = `
29
+ 'use client'
30
+ import { queryHref } from '@barefootjs/client'
31
+ export function P(props: { base: string; tag: string }) {
32
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
33
+ }
34
+ `
35
+ const { template } = generate(src)
36
+ expect(template).toContain('bf_query .Base (true) "tag" .Tag')
37
+ expect(template).not.toContain('.QueryHref')
38
+ })
39
+
40
+ test('a conditional include lowers to `(cond)` — the helper applies the non-empty check', () => {
41
+ const src = `
42
+ 'use client'
43
+ import { queryHref } from '@barefootjs/client'
44
+ export function P(props: { base: string; sort: string; tag: string }) {
45
+ return (
46
+ <a href={queryHref(props.base, {
47
+ sort: props.sort !== 'date' ? props.sort : undefined,
48
+ tag: props.tag,
49
+ })}>x</a>
50
+ )
51
+ }
52
+ `
53
+ const { template } = generate(src)
54
+ expect(template).toContain(
55
+ 'bf_query .Base (ne (bf_string .Sort) "date") "sort" .Sort (true) "tag" .Tag',
56
+ )
57
+ // The `ne consequent ""` non-empty check is no longer folded into the
58
+ // include — bf_query owns it (so it can also append array values).
59
+ expect(template).not.toContain('(ne .Sort "")')
60
+ })
61
+
62
+ // A `&&` / `||` guard is NOT a comparison, so `lowerUrlGuard` can't emit it as
63
+ // a bare Go bool — `and`/`or` return one of their operands (a string), which
64
+ // `bf_query` type-asserts against. It must take the truthiness-wrap path,
65
+ // `ne (and …) ""`, yielding a real bool.
66
+ test('a `&&` guard is wrapped to a bool — `ne (and …) ""`, not a bare `and`', () => {
67
+ const src = `
68
+ 'use client'
69
+ import { queryHref } from '@barefootjs/client'
70
+ export function P(props: { base: string; a: string; b: string }) {
71
+ return <a href={queryHref(props.base, { both: props.a && props.b ? props.a : undefined })}>x</a>
72
+ }
73
+ `
74
+ const { template } = generate(src)
75
+ expect(template).toContain('bf_query .Base (ne (and .A .B) "") "both" .A')
76
+ })
77
+
78
+ test('null / empty-string alternates are both treated as the omit branch', () => {
79
+ const src = `
80
+ 'use client'
81
+ import { queryHref } from '@barefootjs/client'
82
+ export function P(props: { base: string; mode: string; a: string; b: string }) {
83
+ return <a href={queryHref(props.base, {
84
+ a: props.mode !== 'off' ? props.a : '',
85
+ b: props.mode !== 'off' ? props.b : null,
86
+ })}>x</a>
87
+ }
88
+ `
89
+ const { template } = generate(src)
90
+ // Both '' and null alternates fold to the same conditional-include form.
91
+ expect(template).toContain('(ne (bf_string .Mode) "off") "a" .A')
92
+ expect(template).toContain('(ne (bf_string .Mode) "off") "b" .B')
93
+ })
94
+
95
+ test('an array value lowers the slice expression; bf_query appends its members', () => {
96
+ const src = `
97
+ 'use client'
98
+ import { queryHref } from '@barefootjs/client'
99
+ export function P(props: { base: string; tags: string[] }) {
100
+ return <a href={queryHref(props.base, { tag: props.tags })}>x</a>
101
+ }
102
+ `
103
+ const { template } = generate(src)
104
+ // The value is the raw slice field; member-append + non-empty omit happen in
105
+ // the helper at render time (verified against URLSearchParams in the golden
106
+ // vectors). The old `ne value ""` fold would have been invalid Go here.
107
+ expect(template).toContain('bf_query .Base (true) "tag" .Tags')
108
+ })
109
+
110
+ test('a conditional array value keeps the guard and passes the slice', () => {
111
+ const src = `
112
+ 'use client'
113
+ import { queryHref } from '@barefootjs/client'
114
+ export function P(props: { base: string; on: string; tags: string[] }) {
115
+ return <a href={queryHref(props.base, { tag: props.on !== '' ? props.tags : undefined })}>x</a>
116
+ }
117
+ `
118
+ const { template } = generate(src)
119
+ expect(template).toContain('bf_query .Base (ne (bf_string .On) "") "tag" .Tags')
120
+ })
121
+
122
+ test('an aliased import is still recognised', () => {
123
+ const src = `
124
+ 'use client'
125
+ import { queryHref as qh } from '@barefootjs/client'
126
+ export function P(props: { base: string; tag: string }) {
127
+ return <a href={qh(props.base, { tag: props.tag })}>x</a>
128
+ }
129
+ `
130
+ const { template } = generate(src)
131
+ expect(template).toContain('bf_query .Base')
132
+ expect(template).toContain('"tag" .Tag')
133
+ })
134
+
135
+ test('a param-free expression-bodied helper wrapping queryHref inlines + lowers', () => {
136
+ const src = `
137
+ 'use client'
138
+ import { queryHref } from '@barefootjs/client'
139
+ export function P(props: { base: string }) {
140
+ const homeHref = () => queryHref(props.base, { view: 'home' })
141
+ return <a href={homeHref()}>x</a>
142
+ }
143
+ `
144
+ const { template } = generate(src)
145
+ expect(template).toContain('bf_query .Base (true) "view" "home"')
146
+ expect(template).not.toContain('.HomeHref')
147
+ })
148
+
149
+ // Known limitation (pre-existing, not queryHref-specific): the generic helper
150
+ // inliner declines a body whose object literal references the helper's params,
151
+ // because an object literal lowers opaquely from its `raw` source — so the
152
+ // param can't be substituted. queryHref's idiom is the direct call, so this is
153
+ // a minor gap; helper-delegation ergonomics are a follow-up (#2042).
154
+ test('a helper whose params-object references a param is not yet inlined (falls back)', () => {
155
+ const src = `
156
+ 'use client'
157
+ import { queryHref } from '@barefootjs/client'
158
+ export function P(props: { base: string }) {
159
+ const hrefFor = (s: string) => queryHref(props.base, { sort: s })
160
+ return <a href={hrefFor('title')}>x</a>
161
+ }
162
+ `
163
+ const { template } = generate(src)
164
+ expect(template).not.toContain('bf_query')
165
+ expect(template).toContain('.HrefFor "title"')
166
+ })
167
+
168
+ test('a dynamic (non-literal) params object falls back to the generic lowering', () => {
169
+ const src = `
170
+ 'use client'
171
+ import { queryHref } from '@barefootjs/client'
172
+ export function P(props: { base: string; q: Record<string, string> }) {
173
+ return <a href={queryHref(props.base, props.q)}>x</a>
174
+ }
175
+ `
176
+ const { template } = generate(src)
177
+ expect(template).not.toContain('bf_query')
178
+ })
179
+ })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * IR component-tree analysis for the Go html/template adapter.
3
+ *
4
+ * Pure structural walks over the IR — no adapter instance state, no rendering.
5
+ * Only the two entry points (`hasClientInteractivity`,
6
+ * `findNestedComponents`) are exported; the recursive collectors stay
7
+ * module-internal.
8
+ */
9
+
10
+ import type {
11
+ ComponentIR,
12
+ IRNode,
13
+ IRElement,
14
+ IRFragment,
15
+ IRConditional,
16
+ IRLoop,
17
+ IRIfStatement,
18
+ IRComponent,
19
+ } from '@barefootjs/jsx'
20
+
21
+ import type { NestedComponentInfo } from '../lib/types.ts'
22
+
23
+ /**
24
+ * Does the component need client JS? True when it has reactive state
25
+ * (signals), effects, onMounts, any event handler in the tree, or any
26
+ * child component (which needs the parent's hydration).
27
+ */
28
+ export function hasClientInteractivity(ir: ComponentIR): boolean {
29
+ if (ir.metadata.signals.length > 0) return true
30
+ if (ir.metadata.effects.length > 0) return true
31
+ if (ir.metadata.onMounts.length > 0) return true
32
+ if (hasEventsInTree(ir.root)) return true
33
+ // Child components need the parent's hydration.
34
+ if (findChildComponentNames(ir.root).size > 0) return true
35
+ return false
36
+ }
37
+
38
+ /** Recursively check if any element in the tree has events. */
39
+ function hasEventsInTree(node: IRNode): boolean {
40
+ if (node.type === 'element') {
41
+ const element = node as IRElement
42
+ if (element.events.length > 0) return true
43
+ for (const child of element.children) {
44
+ if (hasEventsInTree(child)) return true
45
+ }
46
+ } else if (node.type === 'fragment') {
47
+ const fragment = node as IRFragment
48
+ for (const child of fragment.children) {
49
+ if (hasEventsInTree(child)) return true
50
+ }
51
+ } else if (node.type === 'conditional') {
52
+ const cond = node as IRConditional
53
+ if (hasEventsInTree(cond.whenTrue)) return true
54
+ if (cond.whenFalse && hasEventsInTree(cond.whenFalse)) return true
55
+ } else if (node.type === 'loop') {
56
+ const loop = node as IRLoop
57
+ for (const child of loop.children) {
58
+ if (hasEventsInTree(child)) return true
59
+ }
60
+ } else if (node.type === 'if-statement') {
61
+ const ifStmt = node as IRIfStatement
62
+ if (hasEventsInTree(ifStmt.consequent)) return true
63
+ if (ifStmt.alternate && hasEventsInTree(ifStmt.alternate)) return true
64
+ }
65
+ return false
66
+ }
67
+
68
+ /** Find all child component names used in the IR tree. */
69
+ function findChildComponentNames(node: IRNode): Set<string> {
70
+ const names = new Set<string>()
71
+ collectChildComponentNames(node, names)
72
+ return names
73
+ }
74
+
75
+ function collectChildComponentNames(node: IRNode, names: Set<string>): void {
76
+ if (node.type === 'component') {
77
+ const comp = node as IRComponent
78
+ names.add(comp.name)
79
+ } else if (node.type === 'element') {
80
+ const element = node as IRElement
81
+ for (const child of element.children) {
82
+ collectChildComponentNames(child, names)
83
+ }
84
+ } else if (node.type === 'fragment') {
85
+ const fragment = node as IRFragment
86
+ for (const child of fragment.children) {
87
+ collectChildComponentNames(child, names)
88
+ }
89
+ } else if (node.type === 'conditional') {
90
+ const cond = node as IRConditional
91
+ collectChildComponentNames(cond.whenTrue, names)
92
+ if (cond.whenFalse) {
93
+ collectChildComponentNames(cond.whenFalse, names)
94
+ }
95
+ } else if (node.type === 'loop') {
96
+ const loop = node as IRLoop
97
+ for (const child of loop.children) {
98
+ collectChildComponentNames(child, names)
99
+ }
100
+ } else if (node.type === 'if-statement') {
101
+ const ifStmt = node as IRIfStatement
102
+ collectChildComponentNames(ifStmt.consequent, names)
103
+ if (ifStmt.alternate) {
104
+ collectChildComponentNames(ifStmt.alternate, names)
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Find all nested components (loops with `childComponent`). Returns extended
111
+ * info that includes whether the component comes from a dynamic (signal) array
112
+ * loop vs a static one.
113
+ */
114
+ export function findNestedComponents(node: IRNode): NestedComponentInfo[] {
115
+ const result: NestedComponentInfo[] = []
116
+ collectNestedComponents(node, result)
117
+ return result
118
+ }
119
+
120
+ function collectNestedComponents(node: IRNode, result: NestedComponentInfo[]): void {
121
+ if (node.type === 'loop') {
122
+ const loop = node as IRLoop
123
+ if (loop.childComponent) {
124
+ if (!result.some(c => c.name === loop.childComponent!.name)) {
125
+ const hasBodyChildren = loop.childComponent.children.length > 0
126
+ result.push({
127
+ ...loop.childComponent,
128
+ isDynamic: !loop.isStaticArray,
129
+ isPropDerived: !!loop.isPropDerivedArray,
130
+ loopKey: loop.key ?? undefined,
131
+ loopParam: loop.param ?? undefined,
132
+ bodyChildren: hasBodyChildren ? loop.childComponent.children : undefined,
133
+ loopArray: loop.array,
134
+ loopArrayParsed: loop.arrayParsed,
135
+ loopMarkerId: loop.markerId,
136
+ loopItemType: loop.itemType,
137
+ })
138
+ }
139
+ }
140
+ for (const child of loop.children) {
141
+ collectNestedComponents(child, result)
142
+ }
143
+ } else if (node.type === 'element') {
144
+ const element = node as IRElement
145
+ for (const child of element.children) {
146
+ collectNestedComponents(child, result)
147
+ }
148
+ } else if (node.type === 'fragment') {
149
+ const fragment = node as IRFragment
150
+ for (const child of fragment.children) {
151
+ collectNestedComponents(child, result)
152
+ }
153
+ } else if (node.type === 'conditional') {
154
+ const cond = node as IRConditional
155
+ collectNestedComponents(cond.whenTrue, result)
156
+ if (cond.whenFalse) {
157
+ collectNestedComponents(cond.whenFalse, result)
158
+ }
159
+ } else if (node.type === 'if-statement') {
160
+ const stmt = node as IRIfStatement
161
+ collectNestedComponents(stmt.consequent, result)
162
+ if (stmt.alternate) {
163
+ collectNestedComponents(stmt.alternate, result)
164
+ }
165
+ } else if (node.type === 'component') {
166
+ // JSX children passed to an imported component render via a companion
167
+ // define with the PARENT's data, so a keyed loop nested inside them needs
168
+ // its `<Name>s` slice on THIS component's props like any other nested loop.
169
+ const comp = node as IRComponent
170
+ for (const child of comp.children) {
171
+ collectNestedComponents(child, result)
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The contract extracted emit modules depend on instead of the concrete
3
+ * `GoTemplateAdapter`.
4
+ *
5
+ * The Go adapter's lowering is deeply mutually recursive (expression ↔
6
+ * condition ↔ rendering), so a module pulled out still needs to call back into
7
+ * the shared per-compile state and the recursive entry points. `GoEmitContext`
8
+ * is that seam: extracted free functions take it as their first argument, and
9
+ * the adapter — which owns the state and implements the entry points — passes
10
+ * `this`. Modules depend on this narrow interface, so they stay unit-testable
11
+ * against a stub.
12
+ *
13
+ * Keep this surface minimal: add a member only when an extracted module
14
+ * genuinely needs it, so the seam documents the real cross-module coupling.
15
+ */
16
+
17
+ import type { ParsedExpr } from '@barefootjs/jsx'
18
+
19
+ import type { CompileState } from './lib/compile-state.ts'
20
+
21
+ export interface GoEmitContext {
22
+ /** Per-compile mutable state (signals, consts, type tables, errors, …). */
23
+ readonly state: CompileState
24
+
25
+ /**
26
+ * Lower a JS expression to its Go-template form (the core recursive entry).
27
+ * `preParsed` reuses an already-built tree instead of re-parsing `jsExpr`.
28
+ */
29
+ convertExpressionToGo(
30
+ jsExpr: string,
31
+ out?: { parsed?: ParsedExpr },
32
+ preParsed?: ParsedExpr,
33
+ ): string
34
+
35
+ /**
36
+ * Lower a JS condition to a Go-template bool + any hoisted preamble.
37
+ * `preParsed` reuses an already-built tree instead of re-parsing `jsCondition`.
38
+ */
39
+ convertConditionToGo(
40
+ jsCondition: string,
41
+ preParsed?: ParsedExpr,
42
+ ): { condition: string; preamble: string }
43
+
44
+ /** Extract the prop name from a `props.X ?? …` initial value, or null. */
45
+ extractPropNameFromInitialValue(initialValue: string): string | null
46
+
47
+ /**
48
+ * Parse a signal-time initial value `props.X ?? <literal>` into the source
49
+ * prop name and the Go-formatted fallback, or null when it isn't that shape.
50
+ */
51
+ extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null
52
+
53
+ /**
54
+ * Inline a module string const by name as a Go double-quoted literal
55
+ * (`"<escaped>"`), or null when the name is not such a const (loop vars and
56
+ * outer-loop params are excluded).
57
+ */
58
+ resolveModuleStringConst(name: string): string | null
59
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Inlining of component-scope arrow-const helpers at a call site.
3
+ *
4
+ * When a JSX expression calls a local `const f = (a) => …` helper, the Go
5
+ * adapter has no function to emit — it inlines the helper body with the call
6
+ * args substituted for the params, so the result lowers like any inline
7
+ * expression. The substitution is scope-blind, so the guards here reject bodies
8
+ * where a param replacement could be wrong.
9
+ */
10
+
11
+ import { type ParsedExpr } from '@barefootjs/jsx'
12
+
13
+ import type { GoEmitContext } from '../emit-context.ts'
14
+
15
+ /**
16
+ * Inline a call to a local arrow-const helper.
17
+ *
18
+ * @param callParsed the call's already-built `ParsedExpr`. Must be a `call`
19
+ * node; inlining is declined without it.
20
+ * @returns the substituted body tree, or null when the expression isn't such a
21
+ * call or the body isn't substitution-safe (nested functions, helper-calling
22
+ * helpers, spread args, …)
23
+ */
24
+ export function inlineLocalHelperCall(
25
+ ctx: GoEmitContext,
26
+ jsExpr: string,
27
+ callParsed?: ParsedExpr,
28
+ ): ParsedExpr | null {
29
+ if (ctx.state.localHelperNames.size === 0) return null
30
+ // Fast path: skip unless the expression begins with a known helper name
31
+ // applied as a call (`<helper>(`); the real shape check is the tree
32
+ // inspection below.
33
+ const head = /^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(jsExpr)
34
+ if (!head || !ctx.state.localHelperNames.has(head[1])) return null
35
+
36
+ if (!callParsed || callParsed.kind !== 'call' || callParsed.callee.kind !== 'identifier') {
37
+ return null
38
+ }
39
+ const calleeName = callParsed.callee.name
40
+ if (!ctx.state.localHelperNames.has(calleeName)) return null
41
+
42
+ const fnConst = ctx.state.localConstants.find(
43
+ c => c.name === calleeName && !c.isModule && c.parsed,
44
+ )
45
+ const arrow = fnConst?.parsed
46
+ if (!arrow || arrow.kind !== 'arrow') return null
47
+ if (arrow.params.length !== callParsed.args.length) return null
48
+
49
+ // The body must be a lowerable `ParsedExpr` — an `unsupported` body (e.g. a
50
+ // nested function / regex outside the narrow surface) refuses here.
51
+ const body = arrow.body
52
+ if (!body || body.kind === 'unsupported') return null
53
+
54
+ // Don't half-inline a helper that calls another local helper.
55
+ if (bodyCallsLocalHelper(ctx, body)) return null
56
+
57
+ // Refuse a body containing a method call (`x.foo(…)`). The structural body
58
+ // tree models it as a generic `call`, but `parseExpression` (which the rest
59
+ // of the lowering is keyed to) folds the string / array method family
60
+ // (`.replace`, `.includes`, `.filter`, …) into specialised `array-method` /
61
+ // `higher-order` nodes — so lowering this tree directly would diverge.
62
+ // (`x()` with an identifier callee — `params()` — is fine.)
63
+ if (bodyHasMethodCall(body)) return null
64
+
65
+ const subs = new Map<string, ParsedExpr>()
66
+ for (let i = 0; i < arrow.params.length; i++) {
67
+ subs.set(arrow.params[i], callParsed.args[i])
68
+ }
69
+ // Reject bodies where a param name appears as an object shorthand key
70
+ // (`{ k }`, which can't be rewritten to `{ (arg) }`).
71
+ if (!isSubstituteSafeBody(body, new Set(subs.keys()))) return null
72
+ return substituteHelperParams(body, subs)
73
+ }
74
+
75
+ /**
76
+ * Guard for `substituteHelperParams`: an `object-literal` lowers opaquely from
77
+ * its `raw` source string, so a param referenced ANYWHERE inside it survives
78
+ * un-substituted — a shorthand key (`{ p }`) or a value position (`{ x: p }`,
79
+ * `{ x: f(p) }`) — and would emit the param's original name instead of the call
80
+ * arg. A body is substitute-safe only when no object literal it contains
81
+ * references any param.
82
+ */
83
+ function isSubstituteSafeBody(body: ParsedExpr, paramNames: ReadonlySet<string>): boolean {
84
+ // True when `n`'s subtree references any param (handling object-literal
85
+ // shorthand keys, which carry the param name on the key rather than a value).
86
+ const referencesParam = (n: ParsedExpr): boolean => {
87
+ if (n.kind === 'identifier') return paramNames.has(n.name)
88
+ if (n.kind === 'object-literal') {
89
+ return n.properties.some(
90
+ p => (p.shorthand && paramNames.has(p.key)) || referencesParam(p.value),
91
+ )
92
+ }
93
+ let hit = false
94
+ forEachValueChild(n, c => {
95
+ if (referencesParam(c)) hit = true
96
+ })
97
+ return hit
98
+ }
99
+
100
+ let safe = true
101
+ const visit = (n: ParsedExpr): void => {
102
+ if (!safe) return
103
+ if (n.kind === 'object-literal') {
104
+ // The whole object literal lowers from `raw`; a param mentioned anywhere
105
+ // inside can't be substituted — decline the body.
106
+ if (referencesParam(n)) safe = false
107
+ return
108
+ }
109
+ forEachValueChild(n, visit)
110
+ }
111
+ visit(body)
112
+ return safe
113
+ }
114
+
115
+ /**
116
+ * True when `body` contains a call to a *local* (component-scope) arrow helper
117
+ * const — the signal that inlining `body` here would only push the problem to
118
+ * another un-lowered helper (e.g. `sortHref`'s body calls `hrefFor`).
119
+ */
120
+ function bodyCallsLocalHelper(ctx: GoEmitContext, body: ParsedExpr): boolean {
121
+ let found = false
122
+ const visit = (n: ParsedExpr): void => {
123
+ if (found) return
124
+ if (n.kind === 'call' && n.callee.kind === 'identifier') {
125
+ const name = n.callee.name
126
+ if (ctx.state.localConstants.some(c => c.name === name && !c.isModule && c.containsArrow)) {
127
+ found = true
128
+ return
129
+ }
130
+ }
131
+ forEachValueChild(n, visit)
132
+ }
133
+ visit(body)
134
+ return found
135
+ }
136
+
137
+ /**
138
+ * True when `body` contains a method call (`x.foo(…)` — a `call` whose callee
139
+ * is a `member`); such a body must not be lowered from this tree. See the guard
140
+ * in `inlineLocalHelperCall` for why.
141
+ */
142
+ function bodyHasMethodCall(body: ParsedExpr): boolean {
143
+ let found = false
144
+ const visit = (n: ParsedExpr): void => {
145
+ if (found) return
146
+ if (n.kind === 'call' && n.callee.kind === 'member') {
147
+ found = true
148
+ return
149
+ }
150
+ forEachValueChild(n, visit)
151
+ }
152
+ visit(body)
153
+ return found
154
+ }
155
+
156
+ /**
157
+ * Re-build `body` with each identifier named in `subs` replaced by the
158
+ * substitution's `ParsedExpr` subtree. The substitution is the arg subtree
159
+ * itself, so operator precedence survives without parenthesisation (the tree
160
+ * *is* the precedence). Non-value identifier positions — the property NAME in
161
+ * `a.b`, a plain `{ k: … }` key — are not visited, so a param sharing a name
162
+ * with a member or key is left untouched.
163
+ */
164
+ export function substituteHelperParams(
165
+ body: ParsedExpr,
166
+ subs: ReadonlyMap<string, ParsedExpr>,
167
+ ): ParsedExpr {
168
+ const go = (n: ParsedExpr): ParsedExpr => {
169
+ switch (n.kind) {
170
+ case 'identifier': {
171
+ const sub = subs.get(n.name)
172
+ return sub !== undefined ? sub : n
173
+ }
174
+ // `object-literal` / `unsupported` lower from their `raw` string, and a
175
+ // `regex` carries its exact source text — re-lowering structured children
176
+ // has no effect, so leave as-is (a param inside an object literal is
177
+ // rejected by the shorthand guard upstream).
178
+ case 'literal':
179
+ case 'object-literal':
180
+ case 'unsupported':
181
+ case 'regex':
182
+ return n
183
+ case 'member':
184
+ return { ...n, object: go(n.object) }
185
+ case 'index-access':
186
+ return { ...n, object: go(n.object), index: go(n.index) }
187
+ case 'call':
188
+ return { ...n, callee: go(n.callee), args: n.args.map(go) }
189
+ case 'binary':
190
+ return { ...n, left: go(n.left), right: go(n.right) }
191
+ case 'logical':
192
+ return { ...n, left: go(n.left), right: go(n.right) }
193
+ case 'unary':
194
+ return { ...n, argument: go(n.argument) }
195
+ case 'conditional':
196
+ return { ...n, test: go(n.test), consequent: go(n.consequent), alternate: go(n.alternate) }
197
+ case 'template-literal':
198
+ return {
199
+ ...n,
200
+ parts: n.parts.map(p =>
201
+ p.type === 'expression' ? { type: 'expression', expr: go(p.expr) } : p,
202
+ ),
203
+ }
204
+ case 'array-literal':
205
+ return { ...n, elements: n.elements.map(go) }
206
+ case 'arrow':
207
+ // A nested arrow's params shadow any same-named outer helper param, but
208
+ // the inline body-method guard (`bodyHasMethodCall`) already declined
209
+ // bodies whose substitution could be wrong; re-lower the body verbatim.
210
+ return { ...n, body: go(n.body) }
211
+ case 'array-method':
212
+ // `.flat` carries no `args`; the generic variant does. Re-lower both
213
+ // `object` and any `args`.
214
+ return n.method === 'flat'
215
+ ? { ...n, object: go(n.object) }
216
+ : { ...n, object: go(n.object), args: n.args.map(go) }
217
+ }
218
+ }
219
+ return go(body)
220
+ }
221
+
222
+ /**
223
+ * Visit the value-position `ParsedExpr` children of `n` (the property name in
224
+ * `a.b` and a plain `{ k: v }` key are skipped — they aren't value positions;
225
+ * `object-literal` is handled by the caller because its shorthand keys carry a
226
+ * guard).
227
+ */
228
+ function forEachValueChild(n: ParsedExpr, visit: (c: ParsedExpr) => void): void {
229
+ switch (n.kind) {
230
+ case 'identifier':
231
+ case 'literal':
232
+ case 'unsupported':
233
+ case 'object-literal':
234
+ case 'regex':
235
+ return
236
+ case 'member':
237
+ visit(n.object)
238
+ return
239
+ case 'index-access':
240
+ visit(n.object)
241
+ visit(n.index)
242
+ return
243
+ case 'call':
244
+ visit(n.callee)
245
+ n.args.forEach(visit)
246
+ return
247
+ case 'binary':
248
+ case 'logical':
249
+ visit(n.left)
250
+ visit(n.right)
251
+ return
252
+ case 'unary':
253
+ visit(n.argument)
254
+ return
255
+ case 'conditional':
256
+ visit(n.test)
257
+ visit(n.consequent)
258
+ visit(n.alternate)
259
+ return
260
+ case 'template-literal':
261
+ for (const p of n.parts) if (p.type === 'expression') visit(p.expr)
262
+ return
263
+ case 'array-literal':
264
+ n.elements.forEach(visit)
265
+ return
266
+ case 'arrow':
267
+ visit(n.body)
268
+ return
269
+ case 'array-method':
270
+ visit(n.object)
271
+ if (n.method !== 'flat') n.args.forEach(visit)
272
+ return
273
+ }
274
+ }