@barefootjs/go-template 0.6.1 → 0.8.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 +122 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +191 -3
- package/dist/build.js +191 -3
- package/dist/index.js +191 -3
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +124 -10
- package/src/__tests__/slot-dynamic-tag.test.ts +79 -0
- package/src/adapter/go-template-adapter.ts +380 -3
- package/src/test-render.ts +33 -1
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
IRIfStatement,
|
|
35
35
|
IRProvider,
|
|
36
36
|
IRAsync,
|
|
37
|
+
IRMetadata,
|
|
37
38
|
TemplatePrimitiveRegistry,
|
|
38
39
|
} from '@barefootjs/jsx'
|
|
39
40
|
import {
|
|
@@ -418,6 +419,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
418
419
|
* `template.HTML(...)`; toggles the `"html/template"` import. */
|
|
419
420
|
private usesHtmlTemplate: boolean = false
|
|
420
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
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Set of prop NAMES whose resolved Go struct-field type is exactly
|
|
439
|
+
* `interface{}` — i.e. nillable. Populated at `generate()` entry from
|
|
440
|
+
* the SAME per-prop Go-type computation `generatePropsStruct` /
|
|
441
|
+
* `generateInputStruct` use (`propTypeOverrides` + `typeInfoToGo`), so
|
|
442
|
+
* it can't drift from the actual field types. Used by
|
|
443
|
+
* `elementAttrEmitter.emitExpression` to omit a dynamic attribute whose
|
|
444
|
+
* value is a bare reference to a nillable prop when that prop is nil
|
|
445
|
+
* (Hono-style nullish-attribute omission: an `undefined`-valued
|
|
446
|
+
* attribute is dropped rather than rendered as `attr=""`). Concrete
|
|
447
|
+
* (`string`/`int`/`bool`) fields are never in this set and always emit
|
|
448
|
+
* unconditionally, matching Hono's `value=""` / `data-count="0"`.
|
|
449
|
+
*/
|
|
450
|
+
private nillablePropNames: Set<string> = new Set()
|
|
451
|
+
|
|
421
452
|
constructor(options: GoTemplateAdapterOptions = {}) {
|
|
422
453
|
super()
|
|
423
454
|
this.options = {
|
|
@@ -438,6 +469,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
438
469
|
this.templateVarCounter = 0
|
|
439
470
|
this.propsObjectName = ir.metadata.propsObjectName
|
|
440
471
|
this.restPropsName = ir.metadata.restPropsName ?? null
|
|
472
|
+
this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
|
|
473
|
+
this.nillablePropNames = this.collectNillablePropNames(ir)
|
|
441
474
|
|
|
442
475
|
// Surface loop-body usages of components imported from sibling
|
|
443
476
|
// .tsx files. The adapter emits `{{template "X" .}}` for these,
|
|
@@ -1048,6 +1081,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1048
1081
|
return overrides
|
|
1049
1082
|
}
|
|
1050
1083
|
|
|
1084
|
+
/**
|
|
1085
|
+
* Resolve a prop param's Go struct-field type using the SAME logic
|
|
1086
|
+
* `generatePropsStruct` / `generateInputStruct` use for the field
|
|
1087
|
+
* declaration: a `propTypeOverrides` entry (signal-inferred override)
|
|
1088
|
+
* wins, otherwise `typeInfoToGo(param.type, param.defaultValue)`.
|
|
1089
|
+
* Factored out so the nillable-field set (`collectNillablePropNames`)
|
|
1090
|
+
* can't drift from the actual emitted field types.
|
|
1091
|
+
*/
|
|
1092
|
+
private resolvePropGoType(
|
|
1093
|
+
param: IRMetadata['propsParams'][number],
|
|
1094
|
+
propTypeOverrides: Map<string, string>,
|
|
1095
|
+
): string {
|
|
1096
|
+
return propTypeOverrides.get(param.name) ?? this.typeInfoToGo(param.type, param.defaultValue)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Build the set of prop NAMES whose resolved Go field type is exactly
|
|
1101
|
+
* `interface{}` (nillable). Uses the same `propTypeOverrides` +
|
|
1102
|
+
* `resolvePropGoType` pipeline as the struct generators, so a prop
|
|
1103
|
+
* that ends up `interface{}` on the Props struct — and only such a
|
|
1104
|
+
* prop — is treated as nillable for Hono-style attribute omission.
|
|
1105
|
+
* Concrete (`string`/`int`/`bool`/`[]T`/struct) types are excluded.
|
|
1106
|
+
*/
|
|
1107
|
+
private collectNillablePropNames(ir: ComponentIR): Set<string> {
|
|
1108
|
+
const propTypeOverrides = this.buildPropTypeOverrides(ir)
|
|
1109
|
+
const nillable = new Set<string>()
|
|
1110
|
+
for (const param of ir.metadata.propsParams) {
|
|
1111
|
+
if (this.resolvePropGoType(param, propTypeOverrides) === 'interface{}') {
|
|
1112
|
+
nillable.add(param.name)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return nillable
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1051
1118
|
/**
|
|
1052
1119
|
* Generate Input struct for a component
|
|
1053
1120
|
*/
|
|
@@ -1079,7 +1146,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1079
1146
|
for (const param of ir.metadata.propsParams) {
|
|
1080
1147
|
const fieldName = this.capitalizeFieldName(param.name)
|
|
1081
1148
|
if (nestedArrayFields.has(fieldName)) continue
|
|
1082
|
-
const goType =
|
|
1149
|
+
const goType = this.resolvePropGoType(param, propTypeOverrides)
|
|
1083
1150
|
lines.push(`\t${fieldName} ${goType}`)
|
|
1084
1151
|
}
|
|
1085
1152
|
|
|
@@ -1152,7 +1219,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1152
1219
|
const fieldName = this.capitalizeFieldName(param.name)
|
|
1153
1220
|
// Skip if this field will be replaced by a typed array for nested components
|
|
1154
1221
|
if (nestedArrayFields.has(fieldName)) continue
|
|
1155
|
-
const goType =
|
|
1222
|
+
const goType = this.resolvePropGoType(param, propTypeOverrides)
|
|
1156
1223
|
const jsonTag = this.toJsonTag(param.name)
|
|
1157
1224
|
lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
1158
1225
|
propFieldNames.add(fieldName)
|
|
@@ -1624,6 +1691,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1624
1691
|
): void {
|
|
1625
1692
|
if (node.type === 'component') {
|
|
1626
1693
|
const comp = node as IRComponent
|
|
1694
|
+
// Dynamic-tag locals (`const Tag = children.tag`) have no registrable
|
|
1695
|
+
// template, so they get no `.<Name>SlotN` struct field. Recurse into
|
|
1696
|
+
// their children (which lower as a passthrough) so any real static
|
|
1697
|
+
// child components nested inside still get their slot fields.
|
|
1698
|
+
if (comp.dynamicTag) {
|
|
1699
|
+
for (const child of comp.children) {
|
|
1700
|
+
this.collectStaticChildInstancesRecursive(child, result, inLoop)
|
|
1701
|
+
}
|
|
1702
|
+
return
|
|
1703
|
+
}
|
|
1627
1704
|
// Skip Portal components (handled separately via PortalCollector)
|
|
1628
1705
|
// Skip components inside loops (handled by nestedComponents)
|
|
1629
1706
|
if (comp.name !== 'Portal' && !inLoop && comp.slotId) {
|
|
@@ -1895,6 +1972,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1895
1972
|
ir: ComponentIR,
|
|
1896
1973
|
): string | null {
|
|
1897
1974
|
const trimmed = spreadExpr.trim()
|
|
1975
|
+
// Conditional inline-object spread:
|
|
1976
|
+
// `{...(COND ? { 'k': v } : {})}` (either branch possibly `{}`).
|
|
1977
|
+
// Lower to an immediately-invoked func literal that conditionally
|
|
1978
|
+
// builds the bag, so the falsy branch yields an empty map (the key
|
|
1979
|
+
// is OMITTED rather than rendered as `k=""` — `SpreadAttrs` does
|
|
1980
|
+
// NOT filter empty strings). Returns null for any shape it can't
|
|
1981
|
+
// faithfully convert so the caller falls back to BF101 (#textarea).
|
|
1982
|
+
const conditional = this.buildConditionalSpreadInitializer(trimmed, ir)
|
|
1983
|
+
if (conditional !== undefined) return conditional
|
|
1898
1984
|
// Signal-getter call: `attrs()` — pluck the signal's initialValue
|
|
1899
1985
|
// and translate the JS object literal to a Go map literal.
|
|
1900
1986
|
const callMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*\)$/.exec(trimmed)
|
|
@@ -1947,6 +2033,152 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1947
2033
|
return null
|
|
1948
2034
|
}
|
|
1949
2035
|
|
|
2036
|
+
/**
|
|
2037
|
+
* Lower a conditional inline-object spread bag value:
|
|
2038
|
+
* `(COND ? { 'aria-describedby': describedBy } : {})`
|
|
2039
|
+
* into an immediately-invoked Go func literal that conditionally
|
|
2040
|
+
* builds the map (so the falsy branch OMITS the key rather than
|
|
2041
|
+
* rendering it as an empty string, which `SpreadAttrs` does not
|
|
2042
|
+
* filter):
|
|
2043
|
+
*
|
|
2044
|
+
* func() map[string]any {
|
|
2045
|
+
* if in.DescribedBy != nil && in.DescribedBy != "" {
|
|
2046
|
+
* return map[string]any{"aria-describedby": in.DescribedBy}
|
|
2047
|
+
* }
|
|
2048
|
+
* return map[string]any{}
|
|
2049
|
+
* }()
|
|
2050
|
+
*
|
|
2051
|
+
* Returns:
|
|
2052
|
+
* - `undefined` when the expression is NOT a parenthesized ternary
|
|
2053
|
+
* of object literals — the caller falls through to other shapes.
|
|
2054
|
+
* - `null` when it IS that shape but a part can't be faithfully
|
|
2055
|
+
* converted (non-static key, unsupported condition, …) — the
|
|
2056
|
+
* caller raises BF101.
|
|
2057
|
+
* - the Go IIFE string when fully convertible.
|
|
2058
|
+
*/
|
|
2059
|
+
private buildConditionalSpreadInitializer(
|
|
2060
|
+
spreadExpr: string,
|
|
2061
|
+
ir: ComponentIR,
|
|
2062
|
+
): string | null | undefined {
|
|
2063
|
+
const expr = this.parseLiteralExpression(spreadExpr)
|
|
2064
|
+
if (!expr || !ts.isConditionalExpression(expr)) return undefined
|
|
2065
|
+
const whenTrue = this.unwrapParens(expr.whenTrue)
|
|
2066
|
+
const whenFalse = this.unwrapParens(expr.whenFalse)
|
|
2067
|
+
if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
|
|
2068
|
+
return undefined
|
|
2069
|
+
}
|
|
2070
|
+
// Condition → Go bool against `in.`, type-aware on the prop.
|
|
2071
|
+
const goCond = this.conditionToGoBool(expr.condition, ir)
|
|
2072
|
+
if (goCond === null) return null
|
|
2073
|
+
const trueMap = this.objectLiteralToGoSpreadMap(whenTrue, ir)
|
|
2074
|
+
const falseMap = this.objectLiteralToGoSpreadMap(whenFalse, ir)
|
|
2075
|
+
if (trueMap === null || falseMap === null) return null
|
|
2076
|
+
return (
|
|
2077
|
+
`func() map[string]any {\n` +
|
|
2078
|
+
`\t\tif ${goCond} {\n` +
|
|
2079
|
+
`\t\t\treturn ${trueMap}\n` +
|
|
2080
|
+
`\t\t}\n` +
|
|
2081
|
+
`\t\treturn ${falseMap}\n` +
|
|
2082
|
+
`\t}()`
|
|
2083
|
+
)
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/** Strip redundant parenthesised wrappers off a TS expression. */
|
|
2087
|
+
private unwrapParens(node: ts.Expression): ts.Expression {
|
|
2088
|
+
let e = node
|
|
2089
|
+
while (ts.isParenthesizedExpression(e)) e = e.expression
|
|
2090
|
+
return e
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/**
|
|
2094
|
+
* Convert a conditional-spread condition expression to a Go bool in
|
|
2095
|
+
* the `in.` context. Supports a bare prop identifier (`describedBy`)
|
|
2096
|
+
* and its negation (`!describedBy`), type-aware on the prop:
|
|
2097
|
+
* string → `in.X != ""`
|
|
2098
|
+
* boolean → `in.X`
|
|
2099
|
+
* number → `in.X != 0`
|
|
2100
|
+
* unknown / interface{} → `in.X != nil && in.X != ""`
|
|
2101
|
+
* (faithful JS string-truthiness for an interface holding a
|
|
2102
|
+
* string — textarea's `describedBy` resolves to interface{}).
|
|
2103
|
+
* Returns null for any other shape (caller → BF101).
|
|
2104
|
+
*/
|
|
2105
|
+
private conditionToGoBool(
|
|
2106
|
+
condition: ts.Expression,
|
|
2107
|
+
ir: ComponentIR,
|
|
2108
|
+
): string | null {
|
|
2109
|
+
let node = this.unwrapParens(condition)
|
|
2110
|
+
let negate = false
|
|
2111
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
2112
|
+
negate = true
|
|
2113
|
+
node = this.unwrapParens(node.operand)
|
|
2114
|
+
}
|
|
2115
|
+
if (!ts.isIdentifier(node)) return null
|
|
2116
|
+
const param = ir.metadata.propsParams.find(p => p.name === node.text)
|
|
2117
|
+
if (!param) return null
|
|
2118
|
+
const field = `in.${this.capitalizeFieldName(param.name)}`
|
|
2119
|
+
const prim = param.type.kind === 'primitive' ? param.type.primitive : undefined
|
|
2120
|
+
let truthy: string
|
|
2121
|
+
if (prim === 'boolean') {
|
|
2122
|
+
truthy = field
|
|
2123
|
+
} else if (prim === 'number') {
|
|
2124
|
+
truthy = `${field} != 0`
|
|
2125
|
+
} else if (prim === 'string') {
|
|
2126
|
+
truthy = `${field} != ""`
|
|
2127
|
+
} else {
|
|
2128
|
+
// unknown / interface{}: the runtime value may be a string, number,
|
|
2129
|
+
// bool, etc., so a string-biased `!= ""` test would diverge from JS
|
|
2130
|
+
// truthiness (e.g. an `interface{}` holding `0` or `false` is falsy in
|
|
2131
|
+
// JS but `!= ""` reads true). Route through `bf.Truthy`, the exported
|
|
2132
|
+
// `Boolean(x)` equivalent, for a faithful check (Copilot review #1752).
|
|
2133
|
+
truthy = `bf.Truthy(${field})`
|
|
2134
|
+
}
|
|
2135
|
+
if (!negate) return truthy
|
|
2136
|
+
// Negation: wrap so `!` applies to the whole truthiness test.
|
|
2137
|
+
if (prim === 'boolean') return `!${field}`
|
|
2138
|
+
if (prim === 'number') return `${field} == 0`
|
|
2139
|
+
if (prim === 'string') return `${field} == ""`
|
|
2140
|
+
return `!bf.Truthy(${field})`
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Convert a static object literal (`{ 'aria-describedby': describedBy }`)
|
|
2145
|
+
* into a Go `map[string]any{...}` literal for a conditional spread.
|
|
2146
|
+
* Only static string/identifier keys are allowed; values resolve
|
|
2147
|
+
* prop-identifier references to `in.FieldName` and string literals to
|
|
2148
|
+
* Go string literals. Returns null for any computed/spread/dynamic
|
|
2149
|
+
* key or unsupported value (caller → BF101). Empty object → `map[string]any{}`.
|
|
2150
|
+
*/
|
|
2151
|
+
private objectLiteralToGoSpreadMap(
|
|
2152
|
+
obj: ts.ObjectLiteralExpression,
|
|
2153
|
+
ir: ComponentIR,
|
|
2154
|
+
): string | null {
|
|
2155
|
+
const entries: string[] = []
|
|
2156
|
+
for (const prop of obj.properties) {
|
|
2157
|
+
if (!ts.isPropertyAssignment(prop)) return null
|
|
2158
|
+
let key: string
|
|
2159
|
+
if (ts.isIdentifier(prop.name)) {
|
|
2160
|
+
key = prop.name.text
|
|
2161
|
+
} else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
|
|
2162
|
+
key = prop.name.text
|
|
2163
|
+
} else {
|
|
2164
|
+
return null
|
|
2165
|
+
}
|
|
2166
|
+
const val = this.unwrapParens(prop.initializer)
|
|
2167
|
+
let goVal: string
|
|
2168
|
+
if (ts.isStringLiteral(val) || ts.isNoSubstitutionTemplateLiteral(val)) {
|
|
2169
|
+
goVal = JSON.stringify(val.text)
|
|
2170
|
+
} else if (ts.isIdentifier(val)) {
|
|
2171
|
+
const param = ir.metadata.propsParams.find(p => p.name === val.text)
|
|
2172
|
+
if (!param) return null
|
|
2173
|
+
goVal = `in.${this.capitalizeFieldName(param.name)}`
|
|
2174
|
+
} else {
|
|
2175
|
+
return null
|
|
2176
|
+
}
|
|
2177
|
+
entries.push(`${JSON.stringify(key)}: ${goVal}`)
|
|
2178
|
+
}
|
|
2179
|
+
return `map[string]any{${entries.join(', ')}}`
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1950
2182
|
/**
|
|
1951
2183
|
* Convert JavaScript initial value to Go value for NewXxxProps function.
|
|
1952
2184
|
* References to props params are converted to in.FieldName format.
|
|
@@ -2891,6 +3123,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2891
3123
|
// ===========================================================================
|
|
2892
3124
|
|
|
2893
3125
|
identifier(name: string): string {
|
|
3126
|
+
// Module pure-string const (e.g. `const baseClasses = '...'` used in a
|
|
3127
|
+
// className template literal): inline the literal value rather than
|
|
3128
|
+
// emit `{{.BaseClasses}}` against a Props field that never exists.
|
|
3129
|
+
const inlined = this.resolveModuleStringConst(name)
|
|
3130
|
+
if (inlined !== null) return inlined
|
|
2894
3131
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
2895
3132
|
if (currentLoopParam && name === currentLoopParam) return '.'
|
|
2896
3133
|
// An *outer* loop's value variable (we're in a nested loop) is in scope as
|
|
@@ -2927,6 +3164,72 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2927
3164
|
return `${prefix}${this.capitalizeFieldName(name)}`
|
|
2928
3165
|
}
|
|
2929
3166
|
|
|
3167
|
+
/**
|
|
3168
|
+
* Build the module pure-string-const map from the IR's localConstants.
|
|
3169
|
+
* A const qualifies only when it is module-scope (`isModule`) and its
|
|
3170
|
+
* initializer parses to a single string literal (`ts.StringLiteral` or
|
|
3171
|
+
* `ts.NoSubstitutionTemplateLiteral` — a backtick string with no `${}`).
|
|
3172
|
+
* Template literals *with* interpolations, numeric/object initializers,
|
|
3173
|
+
* `Record<T,string>` maps, memos, and signals are all excluded: only a
|
|
3174
|
+
* pure compile-time string can be safely inlined byte-for-byte.
|
|
3175
|
+
*/
|
|
3176
|
+
private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
|
|
3177
|
+
const map = new Map<string, string>()
|
|
3178
|
+
for (const c of constants ?? []) {
|
|
3179
|
+
if (!c.isModule) continue
|
|
3180
|
+
if (c.value === undefined) continue
|
|
3181
|
+
const literal = this.parsePureStringLiteral(c.value)
|
|
3182
|
+
if (literal !== null) map.set(c.name, literal)
|
|
3183
|
+
}
|
|
3184
|
+
return map
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
/**
|
|
3188
|
+
* Parse a const initializer's source text. Returns the unescaped string
|
|
3189
|
+
* value when the whole initializer is a single string literal (or a
|
|
3190
|
+
* no-substitution template literal), else `null`. Uses the TS parser so
|
|
3191
|
+
* escapes/quotes are resolved exactly as JS would, matching the value
|
|
3192
|
+
* the Hono reference inlines at runtime.
|
|
3193
|
+
*/
|
|
3194
|
+
private parsePureStringLiteral(source: string): string | null {
|
|
3195
|
+
const sf = ts.createSourceFile(
|
|
3196
|
+
'__const.ts',
|
|
3197
|
+
`const __x = (${source});`,
|
|
3198
|
+
ts.ScriptTarget.Latest,
|
|
3199
|
+
/*setParentNodes*/ false,
|
|
3200
|
+
)
|
|
3201
|
+
const stmt = sf.statements[0]
|
|
3202
|
+
if (!stmt || !ts.isVariableStatement(stmt)) return null
|
|
3203
|
+
const decl = stmt.declarationList.declarations[0]
|
|
3204
|
+
let init = decl?.initializer
|
|
3205
|
+
while (init && ts.isParenthesizedExpression(init)) init = init.expression
|
|
3206
|
+
if (!init) return null
|
|
3207
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
3208
|
+
return init.text
|
|
3209
|
+
}
|
|
3210
|
+
return null
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
/**
|
|
3214
|
+
* Resolve an identifier to its inlined Go string literal when it names a
|
|
3215
|
+
* module pure-string const. Returns the Go template literal form
|
|
3216
|
+
* (`"<escaped>"`) so callers can drop it straight into a `{{...}}` action,
|
|
3217
|
+
* or `null` when the name is not such a const (the caller then falls back
|
|
3218
|
+
* to its normal field-ref lowering). The value is escaped for a Go
|
|
3219
|
+
* double-quoted string literal — Go's `html/template` then applies the
|
|
3220
|
+
* same contextual auto-escaping it applies to any literal, matching Hono.
|
|
3221
|
+
*/
|
|
3222
|
+
private resolveModuleStringConst(name: string): string | null {
|
|
3223
|
+
if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
|
|
3224
|
+
return null
|
|
3225
|
+
}
|
|
3226
|
+
if (this.loopVarRefCount.has(name)) return null
|
|
3227
|
+
if (this.isOuterLoopParam(name)) return null
|
|
3228
|
+
const value = this.moduleStringConsts.get(name)
|
|
3229
|
+
if (value === undefined) return null
|
|
3230
|
+
return `"${this.escapeGoString(value)}"`
|
|
3231
|
+
}
|
|
3232
|
+
|
|
2930
3233
|
literal(value: string | number | boolean | null, literalType: LiteralType): string {
|
|
2931
3234
|
if (literalType === 'string') return `"${value}"`
|
|
2932
3235
|
if (literalType === 'null') return 'nil'
|
|
@@ -4313,6 +4616,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4313
4616
|
switch (expr.kind) {
|
|
4314
4617
|
case 'identifier':
|
|
4315
4618
|
{
|
|
4619
|
+
const inlined = this.resolveModuleStringConst(expr.name)
|
|
4620
|
+
if (inlined !== null) return plain(inlined)
|
|
4316
4621
|
const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
|
|
4317
4622
|
if (currentLoopParam && expr.name === currentLoopParam) {
|
|
4318
4623
|
return plain('.')
|
|
@@ -4336,6 +4641,46 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4336
4641
|
if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
|
|
4337
4642
|
return plain(this.rootFieldRef(expr.callee.name))
|
|
4338
4643
|
}
|
|
4644
|
+
// `isValidElement(x)` — the framework "is this a renderable element?"
|
|
4645
|
+
// predicate. In the Go SSR children model an element is represented by
|
|
4646
|
+
// its already-rendered markup, so this evaluates faithfully as a
|
|
4647
|
+
// truthiness check on the argument (an element is "valid" when there is
|
|
4648
|
+
// something to render). Lowering it as a real, evaluatable expression —
|
|
4649
|
+
// rather than a fabricated `.IsValidElement` field access — is what lets
|
|
4650
|
+
// the `Slot` dynamic-tag guard register and run cleanly on Go.
|
|
4651
|
+
if (
|
|
4652
|
+
expr.callee.kind === 'identifier' &&
|
|
4653
|
+
(identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
|
|
4654
|
+
expr.args.length === 1
|
|
4655
|
+
) {
|
|
4656
|
+
return this.renderConditionExpr(expr.args[0])
|
|
4657
|
+
}
|
|
4658
|
+
// Any other user-defined predicate call with arguments (e.g.
|
|
4659
|
+
// `isAdmin(user)`) has no server-side evaluator and is not a registered
|
|
4660
|
+
// template primitive. There is no honest way to evaluate it at SSR time,
|
|
4661
|
+
// and silently forcing it (to true OR false) is a correctness hazard —
|
|
4662
|
+
// a forced-true could expose auth-gated content, a forced-false could
|
|
4663
|
+
// hide required content, and a warning is too easily ignored. Refuse
|
|
4664
|
+
// with a hard BF102 error so the author must move the predicate to a
|
|
4665
|
+
// supported primitive or defer it with `/* @client */`.
|
|
4666
|
+
if (
|
|
4667
|
+
expr.callee.kind === 'identifier' &&
|
|
4668
|
+
!this.templatePrimitives[identifierPath(expr.callee) ?? '']
|
|
4669
|
+
) {
|
|
4670
|
+
const path = identifierPath(expr.callee) ?? expr.callee.name
|
|
4671
|
+
this.errors.push({
|
|
4672
|
+
code: 'BF102',
|
|
4673
|
+
severity: 'error',
|
|
4674
|
+
message:
|
|
4675
|
+
`Predicate '${path}(...)' cannot be evaluated in a Go template. ` +
|
|
4676
|
+
`A server-side template cannot call user-defined JavaScript predicates.`,
|
|
4677
|
+
loc: this.makeLoc(),
|
|
4678
|
+
suggestion: { message: GO_REMEDIATION_OPTIONS },
|
|
4679
|
+
})
|
|
4680
|
+
// Go template actions must be self-contained; emit a literal so the
|
|
4681
|
+
// partial still parses while the build fails on the BF102 error.
|
|
4682
|
+
return plain('false')
|
|
4683
|
+
}
|
|
4339
4684
|
return plain(this.renderParsedExpr(expr))
|
|
4340
4685
|
}
|
|
4341
4686
|
|
|
@@ -4634,6 +4979,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4634
4979
|
return this.renderPortalComponent(comp)
|
|
4635
4980
|
}
|
|
4636
4981
|
|
|
4982
|
+
// Dynamic-tag local (`const Tag = children.tag`): there is no template
|
|
4983
|
+
// named `<Tag>` to call — emitting `{{template "Tag" .TagSlot0}}` would
|
|
4984
|
+
// reference a template that can never be registered, and Go's
|
|
4985
|
+
// html/template escape-walks ALL registered templates (even dead
|
|
4986
|
+
// branches), so the whole render fails with `no such template "Tag"`.
|
|
4987
|
+
// Lower it to its children passthrough instead, so the dead branch
|
|
4988
|
+
// renders harmlessly and the Slot template registers cleanly. (Real
|
|
4989
|
+
// server-side asChild prop-merge on Go is a separate, deferred concern.)
|
|
4990
|
+
if (comp.dynamicTag) {
|
|
4991
|
+
return this.renderChildren(comp.children)
|
|
4992
|
+
}
|
|
4993
|
+
|
|
4637
4994
|
// In Go templates, components are rendered using {{template "name" data}}
|
|
4638
4995
|
let templateCall: string
|
|
4639
4996
|
if (this.inLoop) {
|
|
@@ -4734,6 +5091,20 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4734
5091
|
// Inline Go template syntax with embedded `{{...}}` actions.
|
|
4735
5092
|
return `${name}="${this.renderParsedExpr(parsed)}"`
|
|
4736
5093
|
}
|
|
5094
|
+
// Hono-style nullish-attribute omission (#textarea rows): when the
|
|
5095
|
+
// attribute value is a BARE reference to a nillable (`interface{}`)
|
|
5096
|
+
// prop field, guard emission on `ne .X nil` so an unset optional
|
|
5097
|
+
// prop drops the attribute entirely instead of rendering `attr=""`.
|
|
5098
|
+
// Hono omits `undefined`/`null`-valued attributes; this restores
|
|
5099
|
+
// parity. Scope is deliberately narrow — bare identifiers only — so
|
|
5100
|
+
// member exprs, calls, ternaries, template literals, and
|
|
5101
|
+
// concrete-typed props (which are never nil) are unaffected and
|
|
5102
|
+
// still emit `attr=""` / `attr="0"` exactly as Hono does.
|
|
5103
|
+
const bareId = value.expr.trim()
|
|
5104
|
+
if (this.nillablePropNames.has(bareId)) {
|
|
5105
|
+
const field = `.${this.capitalizeFieldName(bareId)}`
|
|
5106
|
+
return `{{if ne ${field} nil}}${name}="{{${this.convertExpressionToGo(value.expr)}}}"{{end}}`
|
|
5107
|
+
}
|
|
4737
5108
|
return `${name}="{{${this.convertExpressionToGo(value.expr)}}}"`
|
|
4738
5109
|
},
|
|
4739
5110
|
emitBooleanAttr: (_value, name) => name,
|
|
@@ -4907,7 +5278,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
4907
5278
|
if (caseEntries.length === 0) continue
|
|
4908
5279
|
const branches = caseEntries.map(([k, v], i) => {
|
|
4909
5280
|
const head = i === 0 ? '{{if' : '{{else if'
|
|
4910
|
-
|
|
5281
|
+
// The case value is a static Record<T,string> literal emitted
|
|
5282
|
+
// straight into attribute-value text, so HTML-escape it the same
|
|
5283
|
+
// way `string` parts are (via substituteJsInterpolations →
|
|
5284
|
+
// escapeAttrText). Without this, UnoCSS tokens like
|
|
5285
|
+
// `has-[>svg]:px-2.5` would leak a raw `>` and diverge from the
|
|
5286
|
+
// Hono reference, which escapes it to `>`.
|
|
5287
|
+
return `${head} eq ${keyExpr} ${JSON.stringify(k)}}}${this.escapeAttrText(v)}`
|
|
4911
5288
|
})
|
|
4912
5289
|
output += branches.join('') + '{{end}}'
|
|
4913
5290
|
}
|
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
|
|