@barefootjs/go-template 0.6.0 → 0.7.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 +47 -2
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +123 -20
- package/dist/build.js +123 -20
- package/dist/index.js +123 -20
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +150 -14
- package/src/__tests__/slot-dynamic-tag.test.ts +79 -0
- package/src/adapter/go-template-adapter.ts +280 -11
- package/src/test-render.ts +41 -2
|
@@ -27,10 +27,14 @@ import type {
|
|
|
27
27
|
ParsedExpr,
|
|
28
28
|
ParsedStatement,
|
|
29
29
|
SortComparator,
|
|
30
|
+
ReduceOp,
|
|
31
|
+
FlatDepth,
|
|
32
|
+
FlatMapOp,
|
|
30
33
|
TemplatePart,
|
|
31
34
|
IRIfStatement,
|
|
32
35
|
IRProvider,
|
|
33
36
|
IRAsync,
|
|
37
|
+
IRMetadata,
|
|
34
38
|
TemplatePrimitiveRegistry,
|
|
35
39
|
} from '@barefootjs/jsx'
|
|
36
40
|
import {
|
|
@@ -45,6 +49,7 @@ import {
|
|
|
45
49
|
type IRNodeEmitter,
|
|
46
50
|
type EmitIRNode,
|
|
47
51
|
type AttrValueEmitter,
|
|
52
|
+
type SupportResult,
|
|
48
53
|
isBooleanAttr,
|
|
49
54
|
parseExpression,
|
|
50
55
|
isSupported,
|
|
@@ -208,8 +213,56 @@ function emitBfSort(recv: string, c: SortComparator): string {
|
|
|
208
213
|
return `bf_sort ${wrapIfMultiToken(recv)} ${groups.join(' ')}`
|
|
209
214
|
}
|
|
210
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Emit the `bf_reduce` call for a `.reduce(fn, init)` arithmetic fold
|
|
218
|
+
* (#1448 Tier C):
|
|
219
|
+
*
|
|
220
|
+
* bf_reduce <recv> "<op>" "<keyKind>" "<keyName>" "<type>" "<init>" "<direction>"
|
|
221
|
+
*
|
|
222
|
+
* op: "+" | "*"
|
|
223
|
+
* keyKind: "self" | "field"
|
|
224
|
+
* keyName: "" when keyKind=self; capitalised field name otherwise
|
|
225
|
+
* (matches the Go struct-field convention, mirroring
|
|
226
|
+
* `emitBfSort`)
|
|
227
|
+
* type: "numeric" | "string"
|
|
228
|
+
* init: the fold's start value — the numeric literal's text for a
|
|
229
|
+
* numeric fold, or the string literal's contents for a
|
|
230
|
+
* concat fold
|
|
231
|
+
* direction: "left" (reduce) | "right" (reduceRight)
|
|
232
|
+
*
|
|
233
|
+
* The runtime folds `init <op> key(item)` in `direction` order and
|
|
234
|
+
* returns the accumulated value (float64 for numeric, string for
|
|
235
|
+
* concat). The order is only observable for string concat — numeric
|
|
236
|
+
* sum / product commute.
|
|
237
|
+
*/
|
|
238
|
+
function emitBfReduce(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
|
|
239
|
+
const keyName = op.key.kind === 'field' ? capitalize(op.key.field) : ''
|
|
240
|
+
// `op.init` is already the decoded seed value (canonical decimal for
|
|
241
|
+
// numeric folds — `strconv.ParseFloat`-safe; escape-free contents for
|
|
242
|
+
// concat folds). Pass it as a quoted operand the runtime interprets
|
|
243
|
+
// by `type`. `direction` is "left" (reduce) or "right" (reduceRight)
|
|
244
|
+
// — only observable for string concatenation; numeric folds are
|
|
245
|
+
// commutative.
|
|
246
|
+
return `bf_reduce ${wrapIfMultiToken(recv)} "${op.op}" "${op.key.kind}" "${keyName}" "${op.type}" "${escapeGoString(op.init)}" "${direction}"`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Escape a value for embedding in a Go-template double-quoted string. */
|
|
250
|
+
function escapeGoString(s: string): string {
|
|
251
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
252
|
+
}
|
|
253
|
+
|
|
211
254
|
function capitalize(s: string): string {
|
|
212
|
-
|
|
255
|
+
if (s.length === 0) return s
|
|
256
|
+
// Match the adapter's struct-field naming (`capitalizeFieldName`):
|
|
257
|
+
// a whole-word Go initialism uppercases entirely (`id` → `ID`,
|
|
258
|
+
// `url` → `URL`) so the `bf_sort` / `bf_reduce` reflect lookup
|
|
259
|
+
// resolves the generated exported field instead of silently folding
|
|
260
|
+
// a zero value. The class is fully initialised by the time any emit
|
|
261
|
+
// helper runs, so referencing the static set here is safe.
|
|
262
|
+
if (GoTemplateAdapter.GO_INITIALISMS.has(s.toLowerCase())) {
|
|
263
|
+
return s.toUpperCase()
|
|
264
|
+
}
|
|
265
|
+
return s[0].toUpperCase() + s.slice(1)
|
|
213
266
|
}
|
|
214
267
|
|
|
215
268
|
/**
|
|
@@ -239,6 +292,22 @@ interface PrimitiveSpec {
|
|
|
239
292
|
emit: (args: string[]) => string
|
|
240
293
|
}
|
|
241
294
|
|
|
295
|
+
// Generic remediation appended to BF101 / BF102 diagnostics whose reason
|
|
296
|
+
// doesn't already carry actionable next steps.
|
|
297
|
+
const GO_REMEDIATION_OPTIONS =
|
|
298
|
+
'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code'
|
|
299
|
+
|
|
300
|
+
// Build the `suggestion.message` for an unsupported expression/condition.
|
|
301
|
+
// A self-contained reason (it already spells out the fix — e.g. the
|
|
302
|
+
// pre-compute / @client hint or the tailored forEach message) is shown
|
|
303
|
+
// as-is; a low-level reason gets the generic options appended; with no
|
|
304
|
+
// reason at all we fall back to the options alone.
|
|
305
|
+
function buildUnsupportedSuggestion(support: SupportResult): string {
|
|
306
|
+
if (!support.reason) return GO_REMEDIATION_OPTIONS
|
|
307
|
+
if (support.selfContained) return support.reason
|
|
308
|
+
return `${support.reason}\n\n${GO_REMEDIATION_OPTIONS}`
|
|
309
|
+
}
|
|
310
|
+
|
|
242
311
|
const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
243
312
|
'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${args[0]}` },
|
|
244
313
|
'String': { arity: 1, emit: (args) => `bf_string ${args[0]}` },
|
|
@@ -350,6 +419,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
350
419
|
* `template.HTML(...)`; toggles the `"html/template"` import. */
|
|
351
420
|
private usesHtmlTemplate: boolean = false
|
|
352
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Module-scope pure string-literal constants (`const X = 'literal'` at
|
|
424
|
+
* file top-level), keyed by name → resolved literal value. Populated at
|
|
425
|
+
* `generate()` entry from `ir.metadata.localConstants`. When an identifier
|
|
426
|
+
* in an expression resolves to one of these, the adapter inlines the
|
|
427
|
+
* literal value instead of emitting a struct-field reference
|
|
428
|
+
* (`{{.X}}`) — the field never exists on the Props struct, so without
|
|
429
|
+
* inlining Go's template engine fails with `can't evaluate field X`.
|
|
430
|
+
* Hono inlines it for free (it evaluates real JS); this restores parity.
|
|
431
|
+
* Only module-scope, pure string literals qualify — function-scope
|
|
432
|
+
* locals legitimately become template vars/props, and `Record<T,string>`
|
|
433
|
+
* indexed lookups / memos / signals are deliberately excluded.
|
|
434
|
+
*/
|
|
435
|
+
private moduleStringConsts: Map<string, string> = new Map()
|
|
436
|
+
|
|
353
437
|
constructor(options: GoTemplateAdapterOptions = {}) {
|
|
354
438
|
super()
|
|
355
439
|
this.options = {
|
|
@@ -370,6 +454,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
370
454
|
this.templateVarCounter = 0
|
|
371
455
|
this.propsObjectName = ir.metadata.propsObjectName
|
|
372
456
|
this.restPropsName = ir.metadata.restPropsName ?? null
|
|
457
|
+
this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
|
|
373
458
|
|
|
374
459
|
// Surface loop-body usages of components imported from sibling
|
|
375
460
|
// .tsx files. The adapter emits `{{template "X" .}}` for these,
|
|
@@ -1556,6 +1641,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1556
1641
|
): void {
|
|
1557
1642
|
if (node.type === 'component') {
|
|
1558
1643
|
const comp = node as IRComponent
|
|
1644
|
+
// Dynamic-tag locals (`const Tag = children.tag`) have no registrable
|
|
1645
|
+
// template, so they get no `.<Name>SlotN` struct field. Recurse into
|
|
1646
|
+
// their children (which lower as a passthrough) so any real static
|
|
1647
|
+
// child components nested inside still get their slot fields.
|
|
1648
|
+
if (comp.dynamicTag) {
|
|
1649
|
+
for (const child of comp.children) {
|
|
1650
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1651
|
+
}
|
|
1652
|
+
return
|
|
1653
|
+
}
|
|
1559
1654
|
// Skip Portal components (handled separately via PortalCollector)
|
|
1560
1655
|
// Skip components inside loops (handled by nestedComponents)
|
|
1561
1656
|
if (comp.name !== 'Portal' && !inLoop && comp.slotId) {
|
|
@@ -2579,7 +2674,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2579
2674
|
private static GO_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
2580
2675
|
|
|
2581
2676
|
/** Go common initialisms that should be fully uppercased (https://go.dev/wiki/CodeReviewComments#initialisms) */
|
|
2582
|
-
private
|
|
2677
|
+
// Not `private`: the module-level `capitalize` helper reads this so
|
|
2678
|
+
// `bf_sort` / `bf_reduce` field projection matches `capitalizeFieldName`.
|
|
2679
|
+
static GO_INITIALISMS = new Set([
|
|
2583
2680
|
'id', 'url', 'http', 'https', 'api', 'json', 'xml', 'html', 'css', 'sql',
|
|
2584
2681
|
'ip', 'tcp', 'udp', 'dns', 'ssh', 'tls', 'ssl', 'uri', 'uid', 'uuid',
|
|
2585
2682
|
'ascii', 'utf8', 'eof', 'grpc', 'rpc', 'cpu', 'gpu', 'ram', 'os',
|
|
@@ -2821,6 +2918,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2821
2918
|
// ===========================================================================
|
|
2822
2919
|
|
|
2823
2920
|
identifier(name: string): string {
|
|
2921
|
+
// Module pure-string const (e.g. `const baseClasses = '...'` used in a
|
|
2922
|
+
// className template literal): inline the literal value rather than
|
|
2923
|
+
// emit `{{.BaseClasses}}` against a Props field that never exists.
|
|
2924
|
+
const inlined = this.resolveModuleStringConst(name)
|
|
2925
|
+
if (inlined !== null) return inlined
|
|
2824
2926
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
2825
2927
|
if (currentLoopParam && name === currentLoopParam) return '.'
|
|
2826
2928
|
// An *outer* loop's value variable (we're in a nested loop) is in scope as
|
|
@@ -2857,6 +2959,72 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2857
2959
|
return `${prefix}${this.capitalizeFieldName(name)}`
|
|
2858
2960
|
}
|
|
2859
2961
|
|
|
2962
|
+
/**
|
|
2963
|
+
* Build the module pure-string-const map from the IR's localConstants.
|
|
2964
|
+
* A const qualifies only when it is module-scope (`isModule`) and its
|
|
2965
|
+
* initializer parses to a single string literal (`ts.StringLiteral` or
|
|
2966
|
+
* `ts.NoSubstitutionTemplateLiteral` — a backtick string with no `${}`).
|
|
2967
|
+
* Template literals *with* interpolations, numeric/object initializers,
|
|
2968
|
+
* `Record<T,string>` maps, memos, and signals are all excluded: only a
|
|
2969
|
+
* pure compile-time string can be safely inlined byte-for-byte.
|
|
2970
|
+
*/
|
|
2971
|
+
private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
|
|
2972
|
+
const map = new Map<string, string>()
|
|
2973
|
+
for (const c of constants ?? []) {
|
|
2974
|
+
if (!c.isModule) continue
|
|
2975
|
+
if (c.value === undefined) continue
|
|
2976
|
+
const literal = this.parsePureStringLiteral(c.value)
|
|
2977
|
+
if (literal !== null) map.set(c.name, literal)
|
|
2978
|
+
}
|
|
2979
|
+
return map
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
/**
|
|
2983
|
+
* Parse a const initializer's source text. Returns the unescaped string
|
|
2984
|
+
* value when the whole initializer is a single string literal (or a
|
|
2985
|
+
* no-substitution template literal), else `null`. Uses the TS parser so
|
|
2986
|
+
* escapes/quotes are resolved exactly as JS would, matching the value
|
|
2987
|
+
* the Hono reference inlines at runtime.
|
|
2988
|
+
*/
|
|
2989
|
+
private parsePureStringLiteral(source: string): string | null {
|
|
2990
|
+
const sf = ts.createSourceFile(
|
|
2991
|
+
'__const.ts',
|
|
2992
|
+
`const __x = (${source});`,
|
|
2993
|
+
ts.ScriptTarget.Latest,
|
|
2994
|
+
/*setParentNodes*/ false,
|
|
2995
|
+
)
|
|
2996
|
+
const stmt = sf.statements[0]
|
|
2997
|
+
if (!stmt || !ts.isVariableStatement(stmt)) return null
|
|
2998
|
+
const decl = stmt.declarationList.declarations[0]
|
|
2999
|
+
let init = decl?.initializer
|
|
3000
|
+
while (init && ts.isParenthesizedExpression(init)) init = init.expression
|
|
3001
|
+
if (!init) return null
|
|
3002
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
3003
|
+
return init.text
|
|
3004
|
+
}
|
|
3005
|
+
return null
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
/**
|
|
3009
|
+
* Resolve an identifier to its inlined Go string literal when it names a
|
|
3010
|
+
* module pure-string const. Returns the Go template literal form
|
|
3011
|
+
* (`"<escaped>"`) so callers can drop it straight into a `{{...}}` action,
|
|
3012
|
+
* or `null` when the name is not such a const (the caller then falls back
|
|
3013
|
+
* to its normal field-ref lowering). The value is escaped for a Go
|
|
3014
|
+
* double-quoted string literal — Go's `html/template` then applies the
|
|
3015
|
+
* same contextual auto-escaping it applies to any literal, matching Hono.
|
|
3016
|
+
*/
|
|
3017
|
+
private resolveModuleStringConst(name: string): string | null {
|
|
3018
|
+
if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
|
|
3019
|
+
return null
|
|
3020
|
+
}
|
|
3021
|
+
if (this.loopVarRefCount.has(name)) return null
|
|
3022
|
+
if (this.isOuterLoopParam(name)) return null
|
|
3023
|
+
const value = this.moduleStringConsts.get(name)
|
|
3024
|
+
if (value === undefined) return null
|
|
3025
|
+
return `"${this.escapeGoString(value)}"`
|
|
3026
|
+
}
|
|
3027
|
+
|
|
2860
3028
|
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
2861
3029
|
if (literalType === 'string') return `"${value}"`
|
|
2862
3030
|
if (literalType === 'null') return 'nil'
|
|
@@ -3089,7 +3257,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
3089
3257
|
message: `Higher-order method '.${method}' shape cannot be lowered to a Go template action`,
|
|
3090
3258
|
loc: this.makeLoc(),
|
|
3091
3259
|
suggestion: {
|
|
3092
|
-
message:
|
|
3260
|
+
message: GO_REMEDIATION_OPTIONS,
|
|
3093
3261
|
},
|
|
3094
3262
|
})
|
|
3095
3263
|
return `""`
|
|
@@ -3311,6 +3479,51 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
3311
3479
|
return emitBfSort(emit(object), comparator)
|
|
3312
3480
|
}
|
|
3313
3481
|
|
|
3482
|
+
reduceMethod(
|
|
3483
|
+
method: 'reduce' | 'reduceRight',
|
|
3484
|
+
object: ParsedExpr,
|
|
3485
|
+
reduceOp: ReduceOp,
|
|
3486
|
+
emit: (e: ParsedExpr) => string,
|
|
3487
|
+
): string {
|
|
3488
|
+
// `.reduce(fn, init)` / `.reduceRight(fn, init)` arithmetic-fold
|
|
3489
|
+
// lowering (#1448 Tier C). The structured `ReduceOp` (op / key /
|
|
3490
|
+
// type / init) plus the fold direction feed the `bf_reduce` runtime
|
|
3491
|
+
// helper, which folds the receiver into a scalar.
|
|
3492
|
+
const direction = method === 'reduceRight' ? 'right' : 'left'
|
|
3493
|
+
return emitBfReduce(emit(object), reduceOp, direction)
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
|
|
3497
|
+
// `.flat(depth?)` → `bf_flat <recv> <depth>`. The `Infinity` form
|
|
3498
|
+
// lowers to the `-1` sentinel (flatten fully); a finite depth flattens
|
|
3499
|
+
// that many levels (`0` = shallow copy). See packages/adapter-go-
|
|
3500
|
+
// template/runtime/bf.go.
|
|
3501
|
+
const d = depth === 'infinity' ? -1 : depth
|
|
3502
|
+
return `bf_flat ${wrapIfMultiToken(emit(object))} ${d}`
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
|
|
3506
|
+
const recv = wrapIfMultiToken(emit(object))
|
|
3507
|
+
const proj = op.projection
|
|
3508
|
+
// Tuple projection `i => [i.a, i.b]` → `bf_flat_map_tuple <recv>
|
|
3509
|
+
// "<kind>" "<name>" ...` (one quoted pair per leaf). flat(1) removes
|
|
3510
|
+
// only the literal's wrapper, so the runtime appends each leaf verbatim.
|
|
3511
|
+
if (proj.kind === 'tuple') {
|
|
3512
|
+
const pairs = proj.elements
|
|
3513
|
+
.map(l => (l.kind === 'self' ? `"self" ""` : `"field" "${capitalize(l.field)}"`))
|
|
3514
|
+
.join(' ')
|
|
3515
|
+
return `bf_flat_map_tuple ${recv} ${pairs}`
|
|
3516
|
+
}
|
|
3517
|
+
// Scalar `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf_flat_map
|
|
3518
|
+
// <recv> "<kind>" "<name>"`. The runtime projects each item then
|
|
3519
|
+
// flattens one level. The field name uses the Go struct-field
|
|
3520
|
+
// capitalisation, matching `bf_reduce` / `bf_sort`.
|
|
3521
|
+
if (proj.kind === 'self') {
|
|
3522
|
+
return `bf_flat_map ${recv} "self" ""`
|
|
3523
|
+
}
|
|
3524
|
+
return `bf_flat_map ${recv} "field" "${capitalize(proj.field)}"`
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3314
3527
|
unsupported(raw: string, _reason: string): string {
|
|
3315
3528
|
// Should not happen if `isSupported` was checked at parse time.
|
|
3316
3529
|
return `[UNSUPPORTED: ${raw}]`
|
|
@@ -4046,9 +4259,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4046
4259
|
message: `Expression not supported: ${trimmed}`,
|
|
4047
4260
|
loc: this.makeLoc(),
|
|
4048
4261
|
suggestion: {
|
|
4049
|
-
message: support
|
|
4050
|
-
? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
|
|
4051
|
-
: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
|
|
4262
|
+
message: buildUnsupportedSuggestion(support),
|
|
4052
4263
|
},
|
|
4053
4264
|
})
|
|
4054
4265
|
// Return empty string - Go template comments must be separate actions
|
|
@@ -4086,7 +4297,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4086
4297
|
message: `Complex predicate in else-if is not supported: ${altIfStmt.condition}`,
|
|
4087
4298
|
loc: this.makeLoc(),
|
|
4088
4299
|
suggestion: {
|
|
4089
|
-
message:
|
|
4300
|
+
message: GO_REMEDIATION_OPTIONS,
|
|
4090
4301
|
},
|
|
4091
4302
|
})
|
|
4092
4303
|
}
|
|
@@ -4183,9 +4394,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4183
4394
|
message: `Condition not supported: ${trimmed}`,
|
|
4184
4395
|
loc: this.makeLoc(),
|
|
4185
4396
|
suggestion: {
|
|
4186
|
-
message: support
|
|
4187
|
-
? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
|
|
4188
|
-
: 'Expression contains unsupported syntax',
|
|
4397
|
+
message: buildUnsupportedSuggestion(support),
|
|
4189
4398
|
},
|
|
4190
4399
|
})
|
|
4191
4400
|
// Return false - Go template comments must be separate actions
|
|
@@ -4202,6 +4411,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4202
4411
|
switch (expr.kind) {
|
|
4203
4412
|
case 'identifier':
|
|
4204
4413
|
{
|
|
4414
|
+
const inlined = this.resolveModuleStringConst(expr.name)
|
|
4415
|
+
if (inlined !== null) return plain(inlined)
|
|
4205
4416
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
4206
4417
|
if (currentLoopParam && expr.name === currentLoopParam) {
|
|
4207
4418
|
return plain('.')
|
|
@@ -4225,6 +4436,46 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4225
4436
|
if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
4226
4437
|
return plain(this.rootFieldRef(expr.callee.name))
|
|
4227
4438
|
}
|
|
4439
|
+
// `isValidElement(x)` — the framework "is this a renderable element?"
|
|
4440
|
+
// predicate. In the Go SSR children model an element is represented by
|
|
4441
|
+
// its already-rendered markup, so this evaluates faithfully as a
|
|
4442
|
+
// truthiness check on the argument (an element is "valid" when there is
|
|
4443
|
+
// something to render). Lowering it as a real, evaluatable expression —
|
|
4444
|
+
// rather than a fabricated `.IsValidElement` field access — is what lets
|
|
4445
|
+
// the `Slot` dynamic-tag guard register and run cleanly on Go.
|
|
4446
|
+
if (
|
|
4447
|
+
expr.callee.kind === 'identifier' &&
|
|
4448
|
+
(identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
|
|
4449
|
+
expr.args.length === 1
|
|
4450
|
+
) {
|
|
4451
|
+
return this.renderConditionExpr(expr.args[0])
|
|
4452
|
+
}
|
|
4453
|
+
// Any other user-defined predicate call with arguments (e.g.
|
|
4454
|
+
// `isAdmin(user)`) has no server-side evaluator and is not a registered
|
|
4455
|
+
// template primitive. There is no honest way to evaluate it at SSR time,
|
|
4456
|
+
// and silently forcing it (to true OR false) is a correctness hazard —
|
|
4457
|
+
// a forced-true could expose auth-gated content, a forced-false could
|
|
4458
|
+
// hide required content, and a warning is too easily ignored. Refuse
|
|
4459
|
+
// with a hard BF102 error so the author must move the predicate to a
|
|
4460
|
+
// supported primitive or defer it with `/* @client */`.
|
|
4461
|
+
if (
|
|
4462
|
+
expr.callee.kind === 'identifier' &&
|
|
4463
|
+
!this.templatePrimitives[identifierPath(expr.callee) ?? '']
|
|
4464
|
+
) {
|
|
4465
|
+
const path = identifierPath(expr.callee) ?? expr.callee.name
|
|
4466
|
+
this.errors.push({
|
|
4467
|
+
code: 'BF102',
|
|
4468
|
+
severity: 'error',
|
|
4469
|
+
message:
|
|
4470
|
+
`Predicate '${path}(...)' cannot be evaluated in a Go template. ` +
|
|
4471
|
+
`A server-side template cannot call user-defined JavaScript predicates.`,
|
|
4472
|
+
loc: this.makeLoc(),
|
|
4473
|
+
suggestion: { message: GO_REMEDIATION_OPTIONS },
|
|
4474
|
+
})
|
|
4475
|
+
// Go template actions must be self-contained; emit a literal so the
|
|
4476
|
+
// partial still parses while the build fails on the BF102 error.
|
|
4477
|
+
return plain('false')
|
|
4478
|
+
}
|
|
4228
4479
|
return plain(this.renderParsedExpr(expr))
|
|
4229
4480
|
}
|
|
4230
4481
|
|
|
@@ -4523,6 +4774,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4523
4774
|
return this.renderPortalComponent(comp)
|
|
4524
4775
|
}
|
|
4525
4776
|
|
|
4777
|
+
// Dynamic-tag local (`const Tag = children.tag`): there is no template
|
|
4778
|
+
// named `<Tag>` to call — emitting `{{template "Tag" .TagSlot0}}` would
|
|
4779
|
+
// reference a template that can never be registered, and Go's
|
|
4780
|
+
// html/template escape-walks ALL registered templates (even dead
|
|
4781
|
+
// branches), so the whole render fails with `no such template "Tag"`.
|
|
4782
|
+
// Lower it to its children passthrough instead, so the dead branch
|
|
4783
|
+
// renders harmlessly and the Slot template registers cleanly. (Real
|
|
4784
|
+
// server-side asChild prop-merge on Go is a separate, deferred concern.)
|
|
4785
|
+
if (comp.dynamicTag) {
|
|
4786
|
+
return this.renderChildren(comp.children)
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4526
4789
|
// In Go templates, components are rendered using {{template "name" data}}
|
|
4527
4790
|
let templateCall: string
|
|
4528
4791
|
if (this.inLoop) {
|
|
@@ -4796,7 +5059,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4796
5059
|
if (caseEntries.length === 0) continue
|
|
4797
5060
|
const branches = caseEntries.map(([k, v], i) => {
|
|
4798
5061
|
const head = i === 0 ? '{{if' : '{{else if'
|
|
4799
|
-
|
|
5062
|
+
// The case value is a static Record<T,string> literal emitted
|
|
5063
|
+
// straight into attribute-value text, so HTML-escape it the same
|
|
5064
|
+
// way `string` parts are (via substituteJsInterpolations →
|
|
5065
|
+
// escapeAttrText). Without this, UnoCSS tokens like
|
|
5066
|
+
// `has-[>svg]:px-2.5` would leak a raw `>` and diverge from the
|
|
5067
|
+
// Hono reference, which escapes it to `>`.
|
|
5068
|
+
return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${this.escapeAttrText(v)}`
|
|
4800
5069
|
})
|
|
4801
5070
|
output += branches.join('') + '{{end}}'
|
|
4802
5071
|
}
|
package/src/test-render.ts
CHANGED
|
@@ -180,7 +180,7 @@ export async function renderGoTemplateComponent(options: RenderOptions): Promise
|
|
|
180
180
|
const escapedTemplate = template.replace(/`/g, '` + "`" + `')
|
|
181
181
|
|
|
182
182
|
// Build props initialization
|
|
183
|
-
const propsInit = buildGoPropsInit(componentName, props)
|
|
183
|
+
const propsInit = buildGoPropsInit(componentName, props, ir)
|
|
184
184
|
|
|
185
185
|
// Honour `__instanceId` from props for the root scope id so
|
|
186
186
|
// shared-component fixtures (which pin `<ComponentName>_test`) match
|
|
@@ -280,10 +280,26 @@ ${propsInit}
|
|
|
280
280
|
function buildGoPropsInit(
|
|
281
281
|
_componentName: string,
|
|
282
282
|
props?: Record<string, unknown>,
|
|
283
|
+
ir?: ComponentIR,
|
|
283
284
|
): string {
|
|
284
285
|
if (!props) return ''
|
|
285
286
|
|
|
287
|
+
// A `{...props}` rest spread on a component means props that are NOT
|
|
288
|
+
// declared as named params don't get their own top-level Input struct
|
|
289
|
+
// field — they flow through the open-ended rest bag (`Props map[string]any`,
|
|
290
|
+
// the `Capitalize(restPropsName)` field). Without routing them there, a
|
|
291
|
+
// fixture passing e.g. `placeholder` to the `Input` component (whose only
|
|
292
|
+
// declared params are `className` / `type`) emits a top-level
|
|
293
|
+
// `Placeholder:` initializer and Go fails with `unknown field Placeholder
|
|
294
|
+
// in struct literal of type InputInput`. (#1467 Phase 2b)
|
|
295
|
+
const declaredParams = new Set((ir?.metadata.propsParams ?? []).map(p => p.name))
|
|
296
|
+
const restPropsName = ir?.metadata.restPropsName ?? null
|
|
297
|
+
const restBagField = restPropsName
|
|
298
|
+
? restPropsName.charAt(0).toUpperCase() + restPropsName.slice(1)
|
|
299
|
+
: null
|
|
300
|
+
|
|
286
301
|
const lines: string[] = []
|
|
302
|
+
const restBagEntries: Array<[string, unknown]> = []
|
|
287
303
|
for (const [key, value] of Object.entries(props)) {
|
|
288
304
|
// Skip internal hydration markers — `__instanceId` / `__bfScope`
|
|
289
305
|
// / `__bfChild` are routed by the framework (consumed via the
|
|
@@ -291,6 +307,15 @@ function buildGoPropsInit(
|
|
|
291
307
|
// appear on the user-facing input struct). Including them produces
|
|
292
308
|
// `unknown field __instanceId in struct literal of type XxxInput`.
|
|
293
309
|
if (key.startsWith('__')) continue
|
|
310
|
+
// A prop that isn't a declared named param on a rest-spread component
|
|
311
|
+
// belongs in the rest bag, not a top-level field. A key that literally
|
|
312
|
+
// matches `restPropsName` already carries a pre-formed bag object and
|
|
313
|
+
// maps straight onto the `Capitalize(restPropsName)` field, so it falls
|
|
314
|
+
// through to the normal (object → map literal) emit below.
|
|
315
|
+
if (restBagField && key !== restPropsName && !declaredParams.has(key)) {
|
|
316
|
+
restBagEntries.push([key, value])
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
294
319
|
// Capitalize first letter for Go field name
|
|
295
320
|
const goField = key.charAt(0).toUpperCase() + key.slice(1)
|
|
296
321
|
if (typeof value === 'string') {
|
|
@@ -319,6 +344,13 @@ function buildGoPropsInit(
|
|
|
319
344
|
lines.push(`\t\t${goField}: ${goMapLiteralFromObject(value as Record<string, unknown>)},`)
|
|
320
345
|
}
|
|
321
346
|
}
|
|
347
|
+
// Emit the collected rest-bag entries as the open-ended bag field. Skip
|
|
348
|
+
// when a direct `restPropsName`-keyed prop already populated it above
|
|
349
|
+
// (merging two sources isn't a shape any fixture needs).
|
|
350
|
+
if (restBagField && restBagEntries.length > 0 && !(restPropsName! in props)) {
|
|
351
|
+
const bag = Object.fromEntries(restBagEntries)
|
|
352
|
+
lines.push(`\t\t${restBagField}: ${goMapLiteralFromObject(bag)},`)
|
|
353
|
+
}
|
|
322
354
|
return lines.join('\n')
|
|
323
355
|
}
|
|
324
356
|
|
|
@@ -355,7 +387,14 @@ function goMapLiteralFromObject(
|
|
|
355
387
|
else if (typeof v === 'number') entries.push(`${key}: ${v}`)
|
|
356
388
|
else if (typeof v === 'boolean') entries.push(`${key}: ${v}`)
|
|
357
389
|
else if (v === null) entries.push(`${key}: nil`)
|
|
358
|
-
else if (
|
|
390
|
+
else if (Array.isArray(v)) {
|
|
391
|
+
// Array-valued field inside an object-in-array (e.g. the `tags`
|
|
392
|
+
// of `items.flatMap(i => i.tags)`). Without this branch the field
|
|
393
|
+
// was silently dropped, leaving an empty map so the template
|
|
394
|
+
// rendered nothing for the projection (#1448 Tier C flatMap).
|
|
395
|
+
entries.push(`${key}: ${goArrayLiteralFromArray(v)}`)
|
|
396
|
+
}
|
|
397
|
+
else if (v && typeof v === 'object') {
|
|
359
398
|
entries.push(`${key}: ${goMapLiteralFromObject(v as Record<string, unknown>, capitalizeKeys)}`)
|
|
360
399
|
}
|
|
361
400
|
}
|