@barefootjs/go-template 0.1.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.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Go html/template Adapter Exports
3
+ */
4
+
5
+ export { GoTemplateAdapter, goTemplateAdapter } from './go-template-adapter'
6
+ export type { GoTemplateAdapterOptions } from './go-template-adapter'
package/src/build.ts ADDED
@@ -0,0 +1,258 @@
1
+ // Go template build config factory for barefoot.config.ts
2
+
3
+ import type { BuildOptions, PostBuildContext } from '@barefootjs/jsx'
4
+ import { GoTemplateAdapter } from './adapter'
5
+ import type { GoTemplateAdapterOptions } from './adapter'
6
+
7
+ export interface GoTemplateBuildOptions extends BuildOptions {
8
+ /** Adapter-specific options passed to GoTemplateAdapter */
9
+ adapterOptions?: GoTemplateAdapterOptions
10
+ /** Output path for combined Go types file (relative to projectDir, default: 'components.go') */
11
+ typesOutputFile?: string
12
+ /** Transform the combined types string before writing (for app-specific type fixes) */
13
+ transformTypes?: (types: string) => string
14
+ /** Manual type definitions to append (app-specific types not generated from components) */
15
+ manualTypes?: string
16
+ }
17
+
18
+ // ── Go type helpers ──────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Strip Go package header and import block, returning only type definitions.
22
+ */
23
+ export function stripGoPackageHeader(types: string): string {
24
+ const lines = types.split('\n')
25
+ const packageEnd = lines.findIndex(l => l.startsWith('package '))
26
+ if (packageEnd < 0) return types
27
+
28
+ let startLine = packageEnd + 1
29
+ let inImportBlock = false
30
+
31
+ while (startLine < lines.length) {
32
+ const line = lines[startLine]
33
+ const trimmedLine = line?.trim() ?? ''
34
+
35
+ if (trimmedLine === '') {
36
+ startLine++
37
+ continue
38
+ }
39
+
40
+ // Single-line import: import "foo"
41
+ if (trimmedLine.startsWith('import ') && !trimmedLine.includes('(')) {
42
+ startLine++
43
+ continue
44
+ }
45
+
46
+ // Multi-line import block: import (
47
+ if (trimmedLine.startsWith('import (')) {
48
+ inImportBlock = true
49
+ startLine++
50
+ continue
51
+ }
52
+
53
+ if (inImportBlock) {
54
+ if (trimmedLine === ')') {
55
+ inImportBlock = false
56
+ }
57
+ startLine++
58
+ continue
59
+ }
60
+
61
+ break
62
+ }
63
+
64
+ return lines.slice(startLine).join('\n').trim()
65
+ }
66
+
67
+ /**
68
+ * Deduplicate Go type definitions and NewXxxProps constructor functions.
69
+ * When duplicates exist, prefer the version that contains ScopeID (the complete Props struct
70
+ * from generatePropsStruct) over the simplified version from typeDefinitions.
71
+ */
72
+ export function deduplicateGoTypes(combined: string): string {
73
+ // --- Pass 1: Collect all type definitions, preferring ScopeID-containing versions ---
74
+ const typeRegex = /\/\/ \w+ (?:is|represents) .*\ntype (\w+) (?:struct\s*\{[\s\S]*?^\}|= \w+)/gm
75
+ const bestTypes = new Map<string, string>()
76
+ let match: RegExpExecArray | null
77
+ while ((match = typeRegex.exec(combined)) !== null) {
78
+ const typeName = match[1]
79
+ const fullMatch = match[0]
80
+ const existing = bestTypes.get(typeName)
81
+ if (!existing) {
82
+ bestTypes.set(typeName, fullMatch)
83
+ } else {
84
+ // Prefer the version with ScopeID (complete Props struct)
85
+ if (!existing.includes('ScopeID') && fullMatch.includes('ScopeID')) {
86
+ bestTypes.set(typeName, fullMatch)
87
+ }
88
+ }
89
+ }
90
+
91
+ // Remove all type definitions from the combined string
92
+ let result = combined.replace(typeRegex, '')
93
+
94
+ // Re-insert the best version of each type
95
+ const typeInsertions = Array.from(bestTypes.values()).join('\n\n')
96
+ // Insert types at the beginning (after any leading whitespace)
97
+ result = typeInsertions + '\n\n' + result
98
+
99
+ // --- Pass 2: Deduplicate NewXxxProps functions (prefer version with ScopeID) ---
100
+ const funcRegex = /\/\/ (New\w+Props) creates .*(?:\n\/\/.*)*\nfunc \1\([^)]*\) \w+ \{[\s\S]*?\n\}/g
101
+ const bestFuncs = new Map<string, string>()
102
+ while ((match = funcRegex.exec(result)) !== null) {
103
+ const funcName = match[1]
104
+ const fullMatch = match[0]
105
+ const existing = bestFuncs.get(funcName)
106
+ if (!existing) {
107
+ bestFuncs.set(funcName, fullMatch)
108
+ } else {
109
+ if (!existing.includes('ScopeID') && fullMatch.includes('ScopeID')) {
110
+ bestFuncs.set(funcName, fullMatch)
111
+ }
112
+ }
113
+ }
114
+
115
+ result = result.replace(funcRegex, '')
116
+ const funcInsertions = Array.from(bestFuncs.values()).join('\n\n')
117
+ if (funcInsertions) {
118
+ result = result + '\n\n' + funcInsertions
119
+ }
120
+
121
+ // Clean up multiple empty lines
122
+ return result.replace(/\n{3,}/g, '\n\n').trim()
123
+ }
124
+
125
+ /**
126
+ * Combine Go types from multiple components into a single .go file.
127
+ */
128
+ export function combineGoTypes(options: {
129
+ types: Map<string, string>
130
+ packageName: string
131
+ manualTypes?: string
132
+ transformTypes?: (types: string) => string
133
+ }): string {
134
+ const { types, packageName, manualTypes, transformTypes } = options
135
+
136
+ // Strip package headers and collect raw type bodies.
137
+ // A single types entry may contain multiple package headers (from multi-component files),
138
+ // so split on 'package ' boundaries and strip each section individually.
139
+ const typeBodies: string[] = []
140
+ for (const [, content] of types) {
141
+ // Split on package boundaries to handle multi-component files
142
+ const sections = content.split(/(?=^package \w+)/m)
143
+ for (const section of sections) {
144
+ const stripped = stripGoPackageHeader(section.trim())
145
+ if (stripped) typeBodies.push(stripped)
146
+ }
147
+ }
148
+
149
+ if (typeBodies.length === 0 && !manualTypes) return ''
150
+
151
+ // Combine and deduplicate
152
+ let combinedContent = deduplicateGoTypes(typeBodies.join('\n\n'))
153
+
154
+ // Apply app-specific transforms
155
+ if (transformTypes) {
156
+ combinedContent = transformTypes(combinedContent)
157
+ }
158
+
159
+ // Build final file
160
+ const parts = [
161
+ `// Code generated by BarefootJS. DO NOT EDIT.`,
162
+ `package ${packageName}`,
163
+ '',
164
+ `import (`,
165
+ `\t"math/rand"`,
166
+ '',
167
+ `\tbf "github.com/barefootjs/runtime/bf"`,
168
+ `)`,
169
+ '',
170
+ `// randomID generates a random string of length n for ScopeID.`,
171
+ `func randomID(n int) string {`,
172
+ `\tconst chars = "abcdefghijklmnopqrstuvwxyz0123456789"`,
173
+ `\tb := make([]byte, n)`,
174
+ `\tfor i := range b {`,
175
+ `\t\tb[i] = chars[rand.Intn(len(chars))]`,
176
+ `\t}`,
177
+ `\treturn string(b)`,
178
+ `}`,
179
+ ]
180
+
181
+ if (manualTypes) {
182
+ parts.push('', manualTypes)
183
+ }
184
+
185
+ if (combinedContent) {
186
+ parts.push('', combinedContent)
187
+ }
188
+
189
+ return parts.join('\n') + '\n'
190
+ }
191
+
192
+ // ── Config factory ───────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Create a BarefootBuildConfig for Go html/template projects.
196
+ *
197
+ * Uses structural typing — does not import BarefootBuildConfig to avoid
198
+ * circular dependency between @barefootjs/go-template and @barefootjs/cli.
199
+ */
200
+ export function createConfig(options: GoTemplateBuildOptions = {}) {
201
+ const packageName = options.adapterOptions?.packageName ?? 'main'
202
+ const typesOutputFile = options.typesOutputFile ?? 'components.go'
203
+
204
+ const postBuild = async (ctx: PostBuildContext) => {
205
+ if (ctx.types.size === 0) return
206
+
207
+ const content = combineGoTypes({
208
+ types: ctx.types,
209
+ packageName,
210
+ manualTypes: options.manualTypes,
211
+ transformTypes: options.transformTypes,
212
+ })
213
+
214
+ if (content) {
215
+ const { resolve } = await import('node:path')
216
+ const { readFile, writeFile } = await import('node:fs/promises')
217
+ const outPath = resolve(ctx.projectDir, typesOutputFile)
218
+ // Write only when content changed so cache-hit builds don't trip the
219
+ // dev-reload sentinel (ctx.markChanged) and trigger a spurious reload.
220
+ // Use node:fs/promises (not Bun.*) so this hook runs under either
221
+ // runtime — the published `barefoot` CLI bin starts via Node.
222
+ const prev = await readFile(outPath, 'utf-8').catch(() => null)
223
+ if (prev !== content) {
224
+ await writeFile(outPath, content)
225
+ ctx.markChanged?.()
226
+ console.log(`Generated: ${typesOutputFile}`)
227
+ }
228
+ }
229
+ }
230
+
231
+ // Chain user's postBuild with Go types generation
232
+ const userPostBuild = options.postBuild
233
+ const combinedPostBuild = userPostBuild
234
+ ? async (ctx: PostBuildContext) => {
235
+ await postBuild(ctx)
236
+ await userPostBuild(ctx)
237
+ }
238
+ : postBuild
239
+
240
+ return {
241
+ adapter: new GoTemplateAdapter(options.adapterOptions),
242
+ paths: options.paths,
243
+ components: options.components,
244
+ outDir: options.outDir,
245
+ minify: options.minify,
246
+ contentHash: options.contentHash,
247
+ externals: options.externals,
248
+ externalsBasePath: options.externalsBasePath,
249
+ bundleEntries: options.bundleEntries,
250
+ localImportPrefixes: options.localImportPrefixes,
251
+ outputLayout: options.outputLayout ?? {
252
+ templates: 'templates',
253
+ clientJs: 'client',
254
+ runtime: 'client',
255
+ },
256
+ postBuild: combinedPostBuild,
257
+ }
258
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * BarefootJS Go html/template Adapter
3
+ *
4
+ * Generates Go html/template files from BarefootJS IR.
5
+ */
6
+
7
+ export { GoTemplateAdapter, goTemplateAdapter } from './adapter'
8
+ export type { GoTemplateAdapterOptions } from './adapter'
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Go Template test renderer
3
+ *
4
+ * Compiles JSX source with GoTemplateAdapter and renders to HTML via `go run`.
5
+ * Used by adapter-tests conformance runner.
6
+ */
7
+
8
+ import { compileJSX } from '@barefootjs/jsx'
9
+ import type { TemplateAdapter, ComponentIR } from '@barefootjs/jsx'
10
+ import { mkdir, rm } from 'node:fs/promises'
11
+ import { resolve } from 'node:path'
12
+
13
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
14
+ const GO_RUNTIME_DIR = resolve(import.meta.dir, '../runtime')
15
+
16
+ export class GoNotAvailableError extends Error {
17
+ constructor(message: string) {
18
+ super(message)
19
+ this.name = 'GoNotAvailableError'
20
+ }
21
+ }
22
+
23
+ let _goAvailable: boolean | null = null
24
+ async function isGoAvailable(): Promise<boolean> {
25
+ if (_goAvailable !== null) return _goAvailable
26
+ try {
27
+ // If the caller pinned GOTOOLCHAIN to a specific version, run
28
+ // `go version` under it — `go` itself respects GOTOOLCHAIN and
29
+ // will auto-fetch the requested toolchain. This means the
30
+ // availability probe reports the *effective* version, not the
31
+ // system Go.
32
+ const env = { ...process.env, GOTOOLCHAIN: process.env.GOTOOLCHAIN ?? 'local' }
33
+ const proc = Bun.spawn(['go', 'version'], { stdout: 'pipe', stderr: 'pipe', env })
34
+ const stdout = await new Response(proc.stdout).text()
35
+ await proc.exited
36
+ if (proc.exitCode !== 0) { _goAvailable = false; return false }
37
+
38
+ // Check Go version is sufficient (go.mod requires 1.25+)
39
+ const match = stdout.match(/go(\d+)\.(\d+)/)
40
+ if (match) {
41
+ const major = parseInt(match[1], 10)
42
+ const minor = parseInt(match[2], 10)
43
+ _goAvailable = major > 1 || (major === 1 && minor >= 25)
44
+ } else {
45
+ _goAvailable = false
46
+ }
47
+ } catch {
48
+ _goAvailable = false
49
+ }
50
+ return _goAvailable
51
+ }
52
+
53
+ export interface RenderOptions {
54
+ /** JSX source code */
55
+ source: string
56
+ /** Template adapter to use */
57
+ adapter: TemplateAdapter
58
+ /** Props to inject (optional) */
59
+ props?: Record<string, unknown>
60
+ /** Additional component files (filename → source) */
61
+ components?: Record<string, string>
62
+ }
63
+
64
+ export async function renderGoTemplateComponent(options: RenderOptions): Promise<string> {
65
+ const { source, adapter, props, components } = options
66
+
67
+ if (!adapter.generateTypes) {
68
+ throw new Error('Go Template adapter must implement generateTypes()')
69
+ }
70
+
71
+ // Compile child components first
72
+ const childTemplates: string[] = []
73
+ const childTypeBlocks: string[] = []
74
+ if (components) {
75
+ for (const [filename, childSource] of Object.entries(components)) {
76
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
77
+ const childErrors = childResult.errors.filter(e => e.severity === 'error')
78
+ if (childErrors.length > 0) {
79
+ throw new Error(`Compilation errors in ${filename}:\n${childErrors.map(e => e.message).join('\n')}`)
80
+ }
81
+ const childTemplate = childResult.files.find(f => f.type === 'markedTemplate')
82
+ if (!childTemplate) throw new Error(`No marked template for ${filename}`)
83
+ childTemplates.push(childTemplate.content)
84
+
85
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
86
+ for (const childIrFile of childIrFiles) {
87
+ const childIR = JSON.parse(childIrFile.content) as ComponentIR
88
+ let childTypes = adapter.generateTypes!(childIR)
89
+ if (childTypes) {
90
+ // Strip package declaration and imports — will be merged into main types
91
+ childTypes = childTypes.replace(/^package \w+\n*/, '')
92
+ childTypes = childTypes.replace(/import\s*\([^)]*\)\n*/g, '')
93
+ childTypes = childTypes.replace(/\t"math\/rand"\n/g, '')
94
+ childTypeBlocks.push(childTypes.trim())
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ // Compile parent source
101
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
102
+
103
+ const errors = result.errors.filter(e => e.severity === 'error')
104
+ if (errors.length > 0) {
105
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
106
+ }
107
+
108
+ const templateFile = result.files.find(f => f.type === 'markedTemplate')
109
+ if (!templateFile) throw new Error('No marked template in compile output')
110
+
111
+ // Collect every IR emitted from the parent source. Single-component
112
+ // files yield one file; multi-component files yield one per component
113
+ // (#1297). Pick the entry-point IR — default export wins, else the
114
+ // first inline-exported component, else the first IR.
115
+ const irFiles = result.files.filter(f => f.type === 'ir')
116
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
117
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
118
+ const ir =
119
+ irs.find(i => i.metadata.hasDefaultExport) ??
120
+ irs.find(i => i.metadata.isExported) ??
121
+ irs[0]
122
+
123
+ // Generate types for the entry-point component first, then append
124
+ // types for every sibling component in the same source file so the
125
+ // generated `types.go` is self-contained (multi-component test
126
+ // fixtures otherwise lose helper-component struct definitions).
127
+ let goTypes = adapter.generateTypes(ir)
128
+ if (!goTypes) throw new Error('generateTypes() returned null')
129
+
130
+ // Replace package declaration to match main.go
131
+ goTypes = goTypes.replace(/^package \w+/, 'package main')
132
+
133
+ // Remove "math/rand" import from types (randomID is defined in main.go)
134
+ goTypes = goTypes.replace(/\t"math\/rand"\n/, '')
135
+
136
+ // Append sibling-component type definitions (multi-component source).
137
+ for (const siblingIR of irs) {
138
+ if (siblingIR === ir) continue
139
+ let siblingTypes = adapter.generateTypes(siblingIR)
140
+ if (!siblingTypes) continue
141
+ siblingTypes = siblingTypes.replace(/^package \w+\n*/, '')
142
+ siblingTypes = siblingTypes.replace(/import\s*\([^)]*\)\n*/g, '')
143
+ siblingTypes = siblingTypes.replace(/\t"math\/rand"\n/g, '')
144
+ goTypes += '\n\n' + siblingTypes.trim()
145
+ }
146
+
147
+ // Append child type definitions
148
+ if (childTypeBlocks.length > 0) {
149
+ goTypes += '\n\n' + childTypeBlocks.join('\n\n')
150
+ }
151
+
152
+ const componentName = ir.metadata.componentName
153
+ // Concatenate all templates (child define blocks + parent)
154
+ const template = [...childTemplates, templateFile.content].join('\n')
155
+
156
+ // Build temp directory with Go files
157
+ const tempDir = resolve(
158
+ RENDER_TEMP_DIR,
159
+ `go-${Date.now()}-${Math.random().toString(36).slice(2)}`,
160
+ )
161
+ await mkdir(tempDir, { recursive: true })
162
+
163
+ try {
164
+ // go.mod with replace directive pointing to local runtime
165
+ const goMod = [
166
+ 'module render-temp',
167
+ '',
168
+ 'go 1.25.6',
169
+ '',
170
+ 'require github.com/barefootjs/runtime/bf v0.0.0',
171
+ '',
172
+ `replace github.com/barefootjs/runtime/bf => ${GO_RUNTIME_DIR}`,
173
+ ].join('\n')
174
+ await Bun.write(resolve(tempDir, 'go.mod'), goMod)
175
+
176
+ // types.go — generated struct definitions
177
+ await Bun.write(resolve(tempDir, 'types.go'), goTypes)
178
+
179
+ // template content as Go raw string
180
+ const escapedTemplate = template.replace(/`/g, '` + "`" + `')
181
+
182
+ // Build props initialization
183
+ const propsInit = buildGoPropsInit(componentName, props)
184
+
185
+ // Honour `__instanceId` from props for the root scope id so
186
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
187
+ // cross-adapter; default to 'test' otherwise.
188
+ const rootScopeId = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
189
+
190
+ // main.go — render program
191
+ const mainGo = `package main
192
+
193
+ import (
194
+ "html/template"
195
+ "math/rand"
196
+ "os"
197
+
198
+ bf "github.com/barefootjs/runtime/bf"
199
+ )
200
+
201
+ // Silence unused import for bf if only FuncMap is used
202
+ var _ = bf.FuncMap
203
+
204
+ // Merge StreamingFuncMap into the base FuncMap so fixtures using
205
+ // <Async> (which compiles to a bfAsyncBoundary call) can be parsed
206
+ // by the test harness. See packages/adapter-go-template/runtime/streaming.go
207
+ // for the recommended merge recipe.
208
+ func bfTestFuncMap() template.FuncMap {
209
+ funcMap := bf.FuncMap()
210
+ for k, v := range bf.StreamingFuncMap() {
211
+ funcMap[k] = v
212
+ }
213
+ return funcMap
214
+ }
215
+
216
+ const tmplContent = \`${escapedTemplate}\`
217
+
218
+ // randomID generates a random alphanumeric string of given length.
219
+ // Required by generated NewXxxProps constructors.
220
+ func randomID(n int) string {
221
+ const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
222
+ b := make([]byte, n)
223
+ for i := range b {
224
+ b[i] = letters[rand.Intn(len(letters))]
225
+ }
226
+ return string(b)
227
+ }
228
+
229
+ func main() {
230
+ tmpl := template.Must(template.New("").Funcs(bfTestFuncMap()).Parse(tmplContent))
231
+ props := New${componentName}Props(${componentName}Input{
232
+ ScopeID: ${JSON.stringify(rootScopeId)},
233
+ ${propsInit}
234
+ })
235
+ if err := tmpl.ExecuteTemplate(os.Stdout, "${componentName}", props); err != nil {
236
+ os.Stderr.WriteString("template error: " + err.Error() + "\\n")
237
+ os.Exit(1)
238
+ }
239
+ }
240
+ `
241
+ await Bun.write(resolve(tempDir, 'main.go'), mainGo)
242
+
243
+ // Check if Go is available
244
+ if (!await isGoAvailable()) {
245
+ throw new GoNotAvailableError('go command not found — skipping Go Template rendering')
246
+ }
247
+
248
+ // Run `go run .`
249
+ // GOTOOLCHAIN=local prevents Go from downloading a newer toolchain
250
+ // when go.mod specifies a patch version newer than the installed one.
251
+ // Honour a caller-supplied GOTOOLCHAIN env var so CI / dev environments
252
+ // with an older system Go can opt into Go's auto-download behaviour
253
+ // (e.g. `GOTOOLCHAIN=go1.25.6 bun test`).
254
+ const proc = Bun.spawn(['go', 'run', '.'], {
255
+ cwd: tempDir,
256
+ stdout: 'pipe',
257
+ stderr: 'pipe',
258
+ env: { ...process.env, GOTOOLCHAIN: process.env.GOTOOLCHAIN ?? 'local' },
259
+ })
260
+
261
+ const [stdout, stderr] = await Promise.all([
262
+ new Response(proc.stdout).text(),
263
+ new Response(proc.stderr).text(),
264
+ ])
265
+
266
+ const exitCode = await proc.exited
267
+ if (exitCode !== 0) {
268
+ throw new Error(`go run failed (exit ${exitCode}):\n${stderr}`)
269
+ }
270
+
271
+ return stdout
272
+ } finally {
273
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Build Go struct field initializers from props.
279
+ */
280
+ function buildGoPropsInit(
281
+ _componentName: string,
282
+ props?: Record<string, unknown>,
283
+ ): string {
284
+ if (!props) return ''
285
+
286
+ const lines: string[] = []
287
+ for (const [key, value] of Object.entries(props)) {
288
+ // Skip internal hydration markers — `__instanceId` / `__bfScope`
289
+ // / `__bfChild` are routed by the framework (consumed via the
290
+ // separate `ScopeID` struct field for `__instanceId` and never
291
+ // appear on the user-facing input struct). Including them produces
292
+ // `unknown field __instanceId in struct literal of type XxxInput`.
293
+ if (key.startsWith('__')) continue
294
+ // Capitalize first letter for Go field name
295
+ const goField = key.charAt(0).toUpperCase() + key.slice(1)
296
+ if (typeof value === 'string') {
297
+ lines.push(`\t\t${goField}: "${value}",`)
298
+ } else if (typeof value === 'number') {
299
+ lines.push(`\t\t${goField}: ${value},`)
300
+ } else if (typeof value === 'boolean') {
301
+ lines.push(`\t\t${goField}: ${value},`)
302
+ } else if (Array.isArray(value)) {
303
+ // Array → Go `[]any` literal. Fixtures that exercise
304
+ // array-receiver methods (`items.every(...)`, `items.join(' - ')`,
305
+ // etc. — #1448 method catalog) need the prop value to reach
306
+ // the rendered template as a real slice so `range .Items` /
307
+ // `bf_join (.Items) ...` see actual elements; without this
308
+ // branch the prop was silently dropped, the input-struct
309
+ // field stayed at its zero value, and the template rendered
310
+ // empty content alongside the expected wrappers (the bug
311
+ // surfaced as "expected 'idx: 1' / got 'idx:'" on CI).
312
+ lines.push(`\t\t${goField}: ${goArrayLiteralFromArray(value)},`)
313
+ } else if (value && typeof value === 'object') {
314
+ // Plain object → Go `map[string]any` literal (#1407 follow-up).
315
+ // Used by `jsx-spread-rest-prop` to populate the input-bag
316
+ // Spread_<N> field that carries the destructured-rest payload.
317
+ // The same harness change is needed when any future fixture
318
+ // passes a `Record<string, unknown>`-shaped prop through.
319
+ lines.push(`\t\t${goField}: ${goMapLiteralFromObject(value as Record<string, unknown>)},`)
320
+ }
321
+ }
322
+ return lines.join('\n')
323
+ }
324
+
325
+ function goArrayLiteralFromArray(arr: unknown[]): string {
326
+ const entries: string[] = []
327
+ for (const v of arr) {
328
+ if (typeof v === 'string') entries.push(`"${v.replace(/"/g, '\\"')}"`)
329
+ else if (typeof v === 'number') entries.push(String(v))
330
+ else if (typeof v === 'boolean') entries.push(String(v))
331
+ else if (v === null) entries.push('nil')
332
+ else if (Array.isArray(v)) entries.push(goArrayLiteralFromArray(v))
333
+ else if (v && typeof v === 'object') {
334
+ // Objects inside arrays are accessed via Go-struct-style
335
+ // template field paths (`{{.Name}}`) and sort projections
336
+ // (`bf_sort ... "Price" ...`), both of which expect PascalCase
337
+ // identifiers. html/template does case-sensitive map lookup,
338
+ // so emit capitalized keys so `{{.Name}}` resolves directly
339
+ // without relying on the runtime's case-fallback. (#1487)
340
+ entries.push(goMapLiteralFromObject(v as Record<string, unknown>, true))
341
+ }
342
+ }
343
+ return `[]any{${entries.join(', ')}}`
344
+ }
345
+
346
+ function goMapLiteralFromObject(
347
+ obj: Record<string, unknown>,
348
+ capitalizeKeys = false,
349
+ ): string {
350
+ const entries: string[] = []
351
+ for (const [k, v] of Object.entries(obj)) {
352
+ const emittedKey = capitalizeKeys ? k.charAt(0).toUpperCase() + k.slice(1) : k
353
+ const key = JSON.stringify(emittedKey)
354
+ if (typeof v === 'string') entries.push(`${key}: "${v.replace(/"/g, '\\"')}"`)
355
+ else if (typeof v === 'number') entries.push(`${key}: ${v}`)
356
+ else if (typeof v === 'boolean') entries.push(`${key}: ${v}`)
357
+ else if (v === null) entries.push(`${key}: nil`)
358
+ else if (v && typeof v === 'object' && !Array.isArray(v)) {
359
+ entries.push(`${key}: ${goMapLiteralFromObject(v as Record<string, unknown>, capitalizeKeys)}`)
360
+ }
361
+ }
362
+ return `map[string]any{${entries.join(', ')}}`
363
+ }