@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.
- package/dist/adapter/go-template-adapter.d.ts +683 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +2672 -0
- package/dist/build.d.ts +53 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +3198 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2672 -0
- package/dist/test-render.d.ts +22 -0
- package/dist/test-render.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/build.test.ts +65 -0
- package/src/__tests__/go-template-adapter.test.ts +1757 -0
- package/src/adapter/go-template-adapter.ts +4316 -0
- package/src/adapter/index.ts +6 -0
- package/src/build.ts +258 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +363 -0
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,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
|
+
}
|