@barefootjs/erb 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/analysis/component-tree.d.ts +24 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +66 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +107 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/erb-adapter.d.ts +374 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +87 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +102 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +34 -0
- package/dist/adapter/expr/operand.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 +189154 -0
- package/dist/adapter/lib/constants.d.ts +25 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +27 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/ruby-naming.d.ts +65 -0
- package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +28 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +35 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +50 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +63 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +21 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189174 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189172 -0
- package/lib/barefoot_js/backend/erb.rb +123 -0
- package/lib/barefoot_js/dev_reload.rb +159 -0
- package/lib/barefoot_js/evaluator.rb +714 -0
- package/lib/barefoot_js/search_params.rb +63 -0
- package/lib/barefoot_js.rb +1155 -0
- package/package.json +67 -0
- package/src/__tests__/erb-adapter.test.ts +298 -0
- package/src/adapter/analysis/component-tree.ts +122 -0
- package/src/adapter/boolean-result.ts +157 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/erb-adapter.ts +1768 -0
- package/src/adapter/expr/array-method.ts +424 -0
- package/src/adapter/expr/emitters.ts +629 -0
- package/src/adapter/expr/operand.ts +55 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +51 -0
- package/src/adapter/lib/ruby-naming.ts +127 -0
- package/src/adapter/lib/types.ts +31 -0
- package/src/adapter/memo/seed.ts +75 -0
- package/src/adapter/props/prop-classes.ts +89 -0
- package/src/adapter/spread/spread-codegen.ts +176 -0
- package/src/adapter/value/parsed-literal.ts +29 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +100 -0
- package/src/index.ts +9 -0
- package/src/test-render.ts +668 -0
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS ERB (Embedded Ruby) Template Adapter
|
|
3
|
+
*
|
|
4
|
+
* Generates ERB template files (.erb) from BarefootJS IR. Ported from the
|
|
5
|
+
* Mojolicious EP adapter (`@barefootjs/mojolicious`) — EP (Embedded Perl)
|
|
6
|
+
* maps 1:1 onto ERB (Embedded Ruby) for control flow and hydration-marker
|
|
7
|
+
* shape; the substantive divergences are:
|
|
8
|
+
*
|
|
9
|
+
* - **Variable model.** Templates receive exactly two locals: `bf`
|
|
10
|
+
* (runtime) and `v` (vars Hash, symbol keys). Every prop / signal /
|
|
11
|
+
* memo / module-constant reference lowers to `v[:name]` — never a bare
|
|
12
|
+
* Ruby local. Perl's uniform `$name` sigil doesn't need this split
|
|
13
|
+
* (a lexical `my $name` and a stash `$name` render identically); Ruby's
|
|
14
|
+
* bare-identifier ambiguity (reserved words, leading-uppercase =
|
|
15
|
+
* constant) is why props/signals/consts move to a Hash instead.
|
|
16
|
+
* Loop/block params are the one case that stays a bare Ruby local
|
|
17
|
+
* (`lib/ruby-naming.ts::rubyLocal`).
|
|
18
|
+
* - **Escaping.** Mojo's `<%= %>` auto-escapes; stdlib ERB does not — every
|
|
19
|
+
* text/attribute-value interpolation that was a plain mojo `<%= %>`
|
|
20
|
+
* becomes ERB `<%= bf.h(...) %>`. Every *raw* mojo `<%== %>` (runtime
|
|
21
|
+
* helper output, already HTML) becomes a plain ERB `<%= %>` (no `bf.h`
|
|
22
|
+
* — the plan's blanket EP→ERB mapping rule, applied mechanically
|
|
23
|
+
* everywhere so there's exactly one place to audit for escaping bugs).
|
|
24
|
+
* - **Ruby truthiness.** Ruby's falsy set is only `nil`/`false` — JS's is
|
|
25
|
+
* `false, 0, NaN, "", null/undefined`. Every JS conditional TEST (`if`,
|
|
26
|
+
* `&&`, `||`, `!`, `?:`) wraps in `bf.truthy?(...)`; ERB conditionals
|
|
27
|
+
* wrap the same way. See `expr/emitters.ts`'s file docstring for the
|
|
28
|
+
* `&&`/`||` operand-returning rewrite this forces.
|
|
29
|
+
* - **Content capture.** Mojo's `begin %>…<% end` block-capture (for
|
|
30
|
+
* forwarded JSX children / async fallback) has no ERB syntax analog;
|
|
31
|
+
* ERB captures by slicing the shared output buffer around the nested
|
|
32
|
+
* render (see `renderComponent` / `renderAsync`).
|
|
33
|
+
*
|
|
34
|
+
* See `expr/emitters.ts`, `expr/operand.ts`, and `lib/ruby-naming.ts` for
|
|
35
|
+
* the rest of the Perl→Ruby emission-contract detail.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type {
|
|
39
|
+
ComponentIR,
|
|
40
|
+
IRMetadata,
|
|
41
|
+
IRNode,
|
|
42
|
+
IRElement,
|
|
43
|
+
IRText,
|
|
44
|
+
IRExpression,
|
|
45
|
+
IRConditional,
|
|
46
|
+
IRLoop,
|
|
47
|
+
IRComponent,
|
|
48
|
+
IRFragment,
|
|
49
|
+
IRSlot,
|
|
50
|
+
IRIfStatement,
|
|
51
|
+
IRProvider,
|
|
52
|
+
IRAsync,
|
|
53
|
+
IRProp,
|
|
54
|
+
IRTemplatePart,
|
|
55
|
+
AttrValue,
|
|
56
|
+
CompilerError,
|
|
57
|
+
TypeInfo,
|
|
58
|
+
TemplatePrimitiveRegistry,
|
|
59
|
+
} from '@barefootjs/jsx'
|
|
60
|
+
import {
|
|
61
|
+
BaseAdapter,
|
|
62
|
+
type AdapterOutput,
|
|
63
|
+
type AdapterGenerateOptions,
|
|
64
|
+
type TemplateSections,
|
|
65
|
+
type IRNodeEmitter,
|
|
66
|
+
type EmitIRNode,
|
|
67
|
+
type AttrValueEmitter,
|
|
68
|
+
isBooleanAttr,
|
|
69
|
+
parseExpression,
|
|
70
|
+
stringifyParsedExpr,
|
|
71
|
+
parseStyleObjectEntries,
|
|
72
|
+
isSupported,
|
|
73
|
+
exprToString,
|
|
74
|
+
parseProviderObjectLiteral,
|
|
75
|
+
emitParsedExpr,
|
|
76
|
+
emitIRNode,
|
|
77
|
+
emitAttrValue,
|
|
78
|
+
augmentInheritedPropAccesses,
|
|
79
|
+
parseRecordIndexAccess,
|
|
80
|
+
extractArrowBodyExpression,
|
|
81
|
+
collectContextConsumers,
|
|
82
|
+
isLowerableLoopDestructure,
|
|
83
|
+
type ContextConsumer,
|
|
84
|
+
collectModuleStringConsts,
|
|
85
|
+
lookupStaticRecordLiteral,
|
|
86
|
+
searchParamsLocalNames,
|
|
87
|
+
prepareLoweringMatchers,
|
|
88
|
+
queryHrefArgs,
|
|
89
|
+
isValidHelperId,
|
|
90
|
+
sortComparatorFromArrow,
|
|
91
|
+
} from '@barefootjs/jsx'
|
|
92
|
+
import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
|
|
93
|
+
import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
|
|
94
|
+
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
95
|
+
|
|
96
|
+
import type { ErbRenderCtx } from './lib/types.ts'
|
|
97
|
+
import { ERB_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
|
|
98
|
+
import {
|
|
99
|
+
rubyLocal,
|
|
100
|
+
rubyStringLiteral,
|
|
101
|
+
rubySymbolKey,
|
|
102
|
+
rubySymbolLiteral,
|
|
103
|
+
rubyIdentifierFromMarkerId,
|
|
104
|
+
} from './lib/ruby-naming.ts'
|
|
105
|
+
import {
|
|
106
|
+
resolveJsxChildrenProp,
|
|
107
|
+
collectRootScopeNodes,
|
|
108
|
+
} from './lib/ir-scope.ts'
|
|
109
|
+
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
110
|
+
import { ErbFilterEmitter, ErbTopLevelEmitter } from './expr/emitters.ts'
|
|
111
|
+
import type { ErbEmitContext, ErbSpreadContext, ErbMemoContext } from './emit-context.ts'
|
|
112
|
+
import {
|
|
113
|
+
hasClientInteractivity,
|
|
114
|
+
collectImportedLoopChildComponentErrors,
|
|
115
|
+
} from './analysis/component-tree.ts'
|
|
116
|
+
import {
|
|
117
|
+
conditionalSpreadToRuby,
|
|
118
|
+
objectLiteralExprToRubyHash,
|
|
119
|
+
} from './spread/spread-codegen.ts'
|
|
120
|
+
import {
|
|
121
|
+
generateContextConsumerSeed,
|
|
122
|
+
generateDerivedMemoSeed,
|
|
123
|
+
} from './memo/seed.ts'
|
|
124
|
+
import {
|
|
125
|
+
collectProviderDataNames,
|
|
126
|
+
collectBooleanTypedProps,
|
|
127
|
+
collectNullableOptionalProps,
|
|
128
|
+
collectStringValueNames,
|
|
129
|
+
} from './props/prop-classes.ts'
|
|
130
|
+
|
|
131
|
+
export type { ErbAdapterOptions } from './lib/types.ts'
|
|
132
|
+
import type { ErbAdapterOptions } from './lib/types.ts'
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a chained Ruby Hash/Array accessor from a `.map()` destructure
|
|
136
|
+
* binding's structured `segments` path (#2087 Phase B) — walking `segments`
|
|
137
|
+
* instead of string-parsing `LoopParamBinding.path` (repo rule: never parse
|
|
138
|
+
* JS/TS syntax with regex or string matching). A `field` step reads a
|
|
139
|
+
* symbol key (`[:name]` / `[:"data-priority"]`, matching every item Hash's
|
|
140
|
+
* `JSON.parse(..., symbolize_names: true)` construction); an `index` step
|
|
141
|
+
* reads a numeric Array index (`[0]`). Empty `segments` (a rest binding at
|
|
142
|
+
* the loop root) returns `base` unchanged.
|
|
143
|
+
*/
|
|
144
|
+
function rubyAccessorFromSegments(base: string, segments: readonly LoopBindingPathSegment[]): string {
|
|
145
|
+
let accessor = base
|
|
146
|
+
for (const seg of segments) {
|
|
147
|
+
accessor += seg.kind === 'index' ? `[${seg.index}]` : `[${rubySymbolLiteral(seg.key)}]`
|
|
148
|
+
}
|
|
149
|
+
return accessor
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCtx> {
|
|
153
|
+
name = 'erb'
|
|
154
|
+
extension = '.erb'
|
|
155
|
+
templatesPerComponent = true
|
|
156
|
+
// Template-string target with no component layer: `bf build` emits a
|
|
157
|
+
// static `barefoot-importmap.html` to include in the page <head>, same as
|
|
158
|
+
// the Mojo/Go adapters.
|
|
159
|
+
importMapInjection = 'html-snippet' as const
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Identifier-path callees the ERB runtime can render in template scope.
|
|
163
|
+
* The relocate pass consults this map to mark matching calls as
|
|
164
|
+
* template-safe so the surrounding expression stays inlinable; the SSR
|
|
165
|
+
* template emitter substitutes the JS call with the registered Ruby
|
|
166
|
+
* helper invocation.
|
|
167
|
+
*/
|
|
168
|
+
templatePrimitives: TemplatePrimitiveRegistry = ERB_PRIMITIVE_EMIT_MAP
|
|
169
|
+
|
|
170
|
+
private componentName: string = ''
|
|
171
|
+
/** The component's root scope element(s) — each carries `data-key` for a
|
|
172
|
+
* keyed loop item (set by the child renderer from the JSX `key` prop). A
|
|
173
|
+
* plain element root is a single node; an `if-statement` (early-return)
|
|
174
|
+
* root contributes the top element of every branch, since any one of
|
|
175
|
+
* them can be the rendered root at runtime. */
|
|
176
|
+
private rootScopeNodes: Set<IRNode> = new Set()
|
|
177
|
+
private options: Required<ErbAdapterOptions>
|
|
178
|
+
private errors: CompilerError[] = []
|
|
179
|
+
private inLoop: boolean = false
|
|
180
|
+
/**
|
|
181
|
+
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
182
|
+
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
183
|
+
* per-attribute `emitSpread` callback can build a propsObject spread bag
|
|
184
|
+
* as an inline Ruby Hash literal without re-walking the IR.
|
|
185
|
+
*/
|
|
186
|
+
private propsObjectName: string | null = null
|
|
187
|
+
private propsParams: { name: string }[] = []
|
|
188
|
+
private booleanTypedProps: Set<string> = new Set()
|
|
189
|
+
/**
|
|
190
|
+
* Names that resolve to a real SSR template var (via `v[:name]`) — prop
|
|
191
|
+
* param, signal getter, or memo. A `<Ctx.Provider value>` member
|
|
192
|
+
* referencing a name NOT in this set is a client-only function (a local
|
|
193
|
+
* handler const, or a signal setter) with no SSR value: it would read an
|
|
194
|
+
* un-seeded vars-Hash key, so it's lowered to `nil` instead.
|
|
195
|
+
*/
|
|
196
|
+
private providerDataNames: Set<string> = new Set()
|
|
197
|
+
/**
|
|
198
|
+
* Names (signal getters + props) whose value is a string. Ruby's `==`
|
|
199
|
+
* doesn't drive equality-operator selection the way Perl's `eq`/`ne`
|
|
200
|
+
* split does (see `props/prop-classes.ts`), but this set still gates
|
|
201
|
+
* index-access Hash-vs-Array lowering (`expr/operand.ts`).
|
|
202
|
+
*/
|
|
203
|
+
private stringValueNames: Set<string> = new Set()
|
|
204
|
+
/**
|
|
205
|
+
* Local binding names the request-scoped `searchParams()` env signal is
|
|
206
|
+
* imported under (handles `import { searchParams as sp }`). When
|
|
207
|
+
* non-empty the emitter lowers a `<binding>().get(k)` call to a real
|
|
208
|
+
* method call on the reserved `v[:search_params]` reader instead of the
|
|
209
|
+
* generic Hash lookup. Set at `generate()` entry from `ir.metadata.imports`;
|
|
210
|
+
* read by the top-level ParsedExpr emitter.
|
|
211
|
+
*/
|
|
212
|
+
private _searchParamsLocals: Set<string> = new Set()
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Call-lowering matchers active for this component. Bound at
|
|
216
|
+
* `generate()` entry via `prepareLoweringMatchers` and read by the
|
|
217
|
+
* top-level emitter. Covers both userland plugins and the compiler's
|
|
218
|
+
* built-in plugins (e.g. `queryHref` → `bf.query`) — one uniform path,
|
|
219
|
+
* no per-API branch.
|
|
220
|
+
*/
|
|
221
|
+
private _loweringMatchers: LoweringMatcher[] = []
|
|
222
|
+
/**
|
|
223
|
+
* Module-scope pure string-literal constants (`const X = 'literal'` at
|
|
224
|
+
* file top-level), keyed by name → resolved literal value. Populated at
|
|
225
|
+
* `generate()` entry from `ir.metadata.localConstants`. When an
|
|
226
|
+
* identifier in an expression resolves to one of these, the adapter
|
|
227
|
+
* inlines the literal instead of emitting `v[:X]` against a vars-Hash key
|
|
228
|
+
* that is never seeded (a module const isn't a prop, signal, or local —
|
|
229
|
+
* the value would render empty).
|
|
230
|
+
*/
|
|
231
|
+
private moduleStringConsts: Map<string, string> = new Map()
|
|
232
|
+
/**
|
|
233
|
+
* Full local-constant metadata from the entry IR, kept so spread
|
|
234
|
+
* lowering can resolve a bare-identifier spread (`{...sizeAttrs}`) to
|
|
235
|
+
* its initializer text and a `Record[propKey]` spread value to the
|
|
236
|
+
* module-const object literal it indexes. Populated at `generate()`
|
|
237
|
+
* entry alongside `moduleStringConsts`.
|
|
238
|
+
*/
|
|
239
|
+
private localConstants: IRMetadata['localConstants'] = []
|
|
240
|
+
/**
|
|
241
|
+
* Names currently bound by an enclosing loop body — the block-param
|
|
242
|
+
* locals `renderLoop` introduces (item, index, per-binding destructure
|
|
243
|
+
* fields) — ref-counted so nested loops compose. This is load-bearing
|
|
244
|
+
* for TWO things in the ERB adapter (more than the Mojo original, which
|
|
245
|
+
* only used it to guard const-inlining): it also decides the
|
|
246
|
+
* fundamental `v[:name]` vs bare-Ruby-local rendering choice in
|
|
247
|
+
* `ErbTopLevelEmitter.identifier` — see `emit-context.ts`'s
|
|
248
|
+
* `isLoopBoundName` docstring for why ERB's two-locals model needs this
|
|
249
|
+
* where Perl's uniform `$name` sigil does not.
|
|
250
|
+
*/
|
|
251
|
+
private loopBoundNames: Map<string, number> = new Map()
|
|
252
|
+
/**
|
|
253
|
+
* Prop names whose value is `nil` in the template body when the caller
|
|
254
|
+
* omits them — so a bare-reference attribute should be dropped rather
|
|
255
|
+
* than rendered as `attr=""`. See `props/prop-classes.ts`'s
|
|
256
|
+
* `collectNullableOptionalProps` for the exact population criterion.
|
|
257
|
+
* Used by `elementAttrEmitter.emitExpression` to guard such an attribute
|
|
258
|
+
* with a Ruby nil-check (`<textarea>` omits `rows`), matching Hono's
|
|
259
|
+
* nullish-attribute omission.
|
|
260
|
+
*/
|
|
261
|
+
private nullableOptionalProps: Set<string> = new Set()
|
|
262
|
+
|
|
263
|
+
constructor(options: ErbAdapterOptions = {}) {
|
|
264
|
+
super()
|
|
265
|
+
this.options = {
|
|
266
|
+
clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
|
|
267
|
+
barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
|
|
272
|
+
this.componentName = ir.metadata.componentName
|
|
273
|
+
this.propsObjectName = ir.metadata.propsObjectName ?? null
|
|
274
|
+
// Enumerate inherited-attribute accesses for the props-object pattern
|
|
275
|
+
// (`function Checkbox(props: CheckboxProps)`) before deriving
|
|
276
|
+
// `nullableOptionalProps`, so a bare optional attribute like
|
|
277
|
+
// `id={props.id}` gets the Ruby nil-guard (Hono-style omission).
|
|
278
|
+
// Shared with the Go/Mojo adapters (single source of truth in
|
|
279
|
+
// `@barefootjs/jsx`).
|
|
280
|
+
augmentInheritedPropAccesses(ir)
|
|
281
|
+
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
282
|
+
// Per-compile prop classifications (see `props/prop-classes.ts`).
|
|
283
|
+
this.providerDataNames = collectProviderDataNames(ir)
|
|
284
|
+
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
285
|
+
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
286
|
+
this.stringValueNames = collectStringValueNames(ir)
|
|
287
|
+
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
288
|
+
this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
|
|
289
|
+
this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
|
|
290
|
+
this.localConstants = ir.metadata.localConstants ?? []
|
|
291
|
+
this.loopBoundNames.clear()
|
|
292
|
+
this.errors = []
|
|
293
|
+
this.childrenCaptureCounter = 0
|
|
294
|
+
|
|
295
|
+
// Mirror of the Go/Mojo adapters' BF103 check: when a child component
|
|
296
|
+
// referenced inside a loop body is imported from a sibling .tsx, the
|
|
297
|
+
// ERB adapter emits a `<%= bf.render_child(...) %>`-style cross-
|
|
298
|
+
// template call that resolves only if the user has compiled the
|
|
299
|
+
// sibling file and registered the resulting template alongside the
|
|
300
|
+
// parent. When that doesn't happen the failure is silent at build time
|
|
301
|
+
// and surfaces at request time — surface it loudly here so the user
|
|
302
|
+
// can act on it. Suppressed when the caller (e.g. the barefoot CLI)
|
|
303
|
+
// guarantees that all sibling templates are registered on the same
|
|
304
|
+
// template instance at render time.
|
|
305
|
+
if (!options?.siblingTemplatesRegistered) {
|
|
306
|
+
this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this.rootScopeNodes = collectRootScopeNodes(ir.root)
|
|
310
|
+
const templateBody = ir.root.type === 'if-statement'
|
|
311
|
+
? this.renderIfStatement(ir.root as IRIfStatement)
|
|
312
|
+
: this.renderNode(ir.root)
|
|
313
|
+
|
|
314
|
+
// Generate script registration
|
|
315
|
+
const scriptReg = options?.skipScriptRegistration
|
|
316
|
+
? ''
|
|
317
|
+
: this.generateScriptRegistrations(ir, options?.scriptBaseName)
|
|
318
|
+
|
|
319
|
+
// SSR context consumers (`const x = useContext(Ctx)`): seed each local
|
|
320
|
+
// from the active provider value (or the `createContext` default) so
|
|
321
|
+
// the body's `v[:x]` resolves. The provider side pushes the value via
|
|
322
|
+
// `emitProvider`; here the consumer reads it.
|
|
323
|
+
const ctxSeed = generateContextConsumerSeed(ir)
|
|
324
|
+
|
|
325
|
+
// Prop/signal-derived memos that aren't statically evaluable (e.g.
|
|
326
|
+
// `createMemo(() => props.value * 10)`) have a `null` SSR default, so
|
|
327
|
+
// their `v[:x]` would render empty. Compute them in-template from the
|
|
328
|
+
// already-seeded prop/signal vars — mirroring Go's generated child
|
|
329
|
+
// constructor that evaluates the memo from the passed prop.
|
|
330
|
+
const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
|
|
331
|
+
|
|
332
|
+
const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
|
|
333
|
+
|
|
334
|
+
// Merge collected errors into IR errors
|
|
335
|
+
if (this.errors.length > 0) {
|
|
336
|
+
ir.errors.push(...this.errors)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ERB templates have no JS-style imports / types / default-export
|
|
340
|
+
// sections. The `templatesPerComponent` mode emits one file per
|
|
341
|
+
// component using the raw `template` value; sections are populated for
|
|
342
|
+
// contract uniformity so the compiler never has to fall back to
|
|
343
|
+
// string-parsing the template.
|
|
344
|
+
const sections: TemplateSections = {
|
|
345
|
+
imports: '',
|
|
346
|
+
types: '',
|
|
347
|
+
component: template,
|
|
348
|
+
defaultExport: '',
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
template,
|
|
353
|
+
sections,
|
|
354
|
+
extension: this.extension,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether `expr` is a bare reference to a boolean-TYPED prop
|
|
360
|
+
* (`props.isActive` / destructured `isActive`) — used to route the
|
|
361
|
+
* binding through `bool_str` even though the expression itself is
|
|
362
|
+
* structurally opaque.
|
|
363
|
+
*/
|
|
364
|
+
isBooleanTypedPropRef(expr: string): boolean {
|
|
365
|
+
let bare = expr.trim()
|
|
366
|
+
if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
|
|
367
|
+
bare = bare.slice(this.propsObjectName.length + 1)
|
|
368
|
+
}
|
|
369
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
|
|
370
|
+
return this.booleanTypedProps.has(bare)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Whether an attribute-value expression should route through
|
|
375
|
+
* `bf.bool_str` (single source of truth for all three attribute-emission
|
|
376
|
+
* call sites that make this decision). Three witnesses (any one
|
|
377
|
+
* suffices): the JS source structurally evaluates to a boolean
|
|
378
|
+
* (`isBooleanResultExpr`), the attribute name is one of the ARIA
|
|
379
|
+
* true/false(/mixed) names (`isAriaBooleanAttr` — the expression itself
|
|
380
|
+
* may be opaque, e.g. `accepted()`), or the expression is a bare
|
|
381
|
+
* reference to a boolean-TYPED prop (`isBooleanTypedPropRef`).
|
|
382
|
+
*
|
|
383
|
+
* EXCEPT when the expression is already a top-level `String(...)` call
|
|
384
|
+
* (`isExplicitStringCall`): `convertExpressionToRuby` lowers that to
|
|
385
|
+
* `bf.string(...)`, which for a real boolean already returns the
|
|
386
|
+
* JS-correct `"true"`/`"false"` text — wrapping that STRING in
|
|
387
|
+
* `bf.bool_str` again is a bug (Ruby has no falsy-string, so
|
|
388
|
+
* `bf.bool_str("false")` always returns `"true"`), not a harmless
|
|
389
|
+
* no-op. See `isExplicitStringCall`'s docstring for the full
|
|
390
|
+
* Perl-vs-Ruby truthiness contrast.
|
|
391
|
+
*/
|
|
392
|
+
private shouldWrapBoolStr(expr: string, name: string): boolean {
|
|
393
|
+
if (isExplicitStringCall(expr)) return false
|
|
394
|
+
return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Parse `cond ? value : undefined` (or `: null`), returning the
|
|
399
|
+
* condition/consequent source spans, else `null`. Used for the
|
|
400
|
+
* attribute-omission rule; mirrors the Mojo/Xslate adapters.
|
|
401
|
+
*/
|
|
402
|
+
parseUndefinedAlternateTernary(
|
|
403
|
+
expr: string,
|
|
404
|
+
): { condition: string; consequent: string } | null {
|
|
405
|
+
const parsed = parseExpression(expr.trim())
|
|
406
|
+
if (parsed?.kind !== 'conditional') return null
|
|
407
|
+
const alt = parsed.alternate
|
|
408
|
+
const isUndef =
|
|
409
|
+
(alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
|
|
410
|
+
(alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
|
|
411
|
+
if (!isUndef) return null
|
|
412
|
+
// Serialise the parsed sub-expressions back to JS source rather than
|
|
413
|
+
// slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
|
|
414
|
+
// mis-split when the consequent itself contains `?` / `:` inside a
|
|
415
|
+
// string or nested ternary (`cond ? 'a:b' : undefined`).
|
|
416
|
+
return {
|
|
417
|
+
condition: exprToString(parsed.test),
|
|
418
|
+
consequent: exprToString(parsed.consequent),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Inline a const (any scope) whose initializer is a pure numeric or
|
|
424
|
+
* quoted string literal (`const totalPages = 5`) — function-scope
|
|
425
|
+
* consts never reach the per-render vars Hash, so a bare `v[:totalPages]`
|
|
426
|
+
* would read nil.
|
|
427
|
+
*/
|
|
428
|
+
private resolveLiteralConst(name: string): string | null {
|
|
429
|
+
if (this.loopBoundNames?.has?.(name)) return null
|
|
430
|
+
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
431
|
+
if (c?.value === undefined) return null
|
|
432
|
+
const v = c.value.trim()
|
|
433
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return v
|
|
434
|
+
const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
|
|
435
|
+
if (strLit) return rubyStringLiteral(strLit[1])
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
440
|
+
if (this.loopBoundNames?.has?.(objectName)) return null
|
|
441
|
+
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
442
|
+
if (!hit) return null
|
|
443
|
+
return hit.kind === 'number' ? hit.text : rubyStringLiteral(hit.text)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private resolveModuleStringConst(name: string): string | null {
|
|
447
|
+
// A loop body introduces block-param bindings that shadow a module
|
|
448
|
+
// const of the same name — never inline inside one.
|
|
449
|
+
if (this.loopBoundNames.has(name)) return null
|
|
450
|
+
const value = this.moduleStringConsts.get(name)
|
|
451
|
+
if (value === undefined) return null
|
|
452
|
+
return rubyStringLiteral(value)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Whether `name` currently names a loop-bound Ruby local. See
|
|
456
|
+
* `ErbEmitContext.isLoopBoundName`'s docstring. */
|
|
457
|
+
private isLoopBoundName(name: string): boolean {
|
|
458
|
+
return this.loopBoundNames.has(name)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ===========================================================================
|
|
462
|
+
// Script Registration
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
|
|
465
|
+
private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
|
|
466
|
+
const hasInteractivity = hasClientInteractivity(ir)
|
|
467
|
+
if (!hasInteractivity) return ''
|
|
468
|
+
|
|
469
|
+
const name = scriptBaseName ?? ir.metadata.componentName
|
|
470
|
+
const runtimePath = this.options.barefootJsPath
|
|
471
|
+
const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
|
|
472
|
+
|
|
473
|
+
const lines: string[] = []
|
|
474
|
+
lines.push(`<%- bf.register_script('${runtimePath}') -%>`)
|
|
475
|
+
lines.push(`<%- bf.register_script('${clientJsPath}') -%>`)
|
|
476
|
+
lines.push('')
|
|
477
|
+
return lines.join('\n')
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ===========================================================================
|
|
481
|
+
// Node Rendering
|
|
482
|
+
// ===========================================================================
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Public entry point for node rendering. Delegates to the shared
|
|
486
|
+
* `IRNodeEmitter` dispatcher; per-kind logic lives in the
|
|
487
|
+
* `IRNodeEmitter` methods below.
|
|
488
|
+
*/
|
|
489
|
+
renderNode(node: IRNode): string {
|
|
490
|
+
return emitIRNode<ErbRenderCtx>(node, this, {} as ErbRenderCtx)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
// IRNodeEmitter implementation (ERB / Ruby)
|
|
495
|
+
// ===========================================================================
|
|
496
|
+
|
|
497
|
+
emitElement(node: IRElement, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
498
|
+
return this.renderElement(node)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
emitText(node: IRText): string {
|
|
502
|
+
return node.value
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
emitExpression(node: IRExpression): string {
|
|
506
|
+
return this.renderExpression(node)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
emitConditional(node: IRConditional, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
510
|
+
return this.renderConditional(node)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
emitLoop(node: IRLoop, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
514
|
+
return this.renderLoop(node)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
emitComponent(node: IRComponent, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
518
|
+
return this.renderComponent(node)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
emitFragment(node: IRFragment, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
522
|
+
return this.renderFragment(node)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
emitSlot(node: IRSlot): string {
|
|
526
|
+
return this.renderSlot(node)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
emitIfStatement(node: IRIfStatement, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
530
|
+
return this.renderIfStatement(node)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
emitProvider(node: IRProvider, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
534
|
+
// SSR context propagation: push the provider value onto the shared
|
|
535
|
+
// controller context stack, render the children (descendant
|
|
536
|
+
// `useContext` consumers read it via `bf.use_context`), then pop. The
|
|
537
|
+
// push/pop bracket the children in the same render so the value scopes
|
|
538
|
+
// exactly to the subtree — mirroring the client `provideContext`.
|
|
539
|
+
// Both calls are actions (no output) — trimmed non-outputting tags.
|
|
540
|
+
const value = this.providerValueRuby(node.valueProp)
|
|
541
|
+
const children = this.renderChildren(node.children)
|
|
542
|
+
const name = node.contextName
|
|
543
|
+
return (
|
|
544
|
+
`<%- bf.provide_context('${name}', ${value}) -%>` +
|
|
545
|
+
children +
|
|
546
|
+
`<%- bf.revoke_context('${name}') -%>`
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Lower a `<Ctx.Provider value>` value prop to a Ruby expression. */
|
|
551
|
+
private providerValueRuby(valueProp: IRProvider['valueProp']): string {
|
|
552
|
+
const v = valueProp.value
|
|
553
|
+
if (v.kind === 'literal') {
|
|
554
|
+
return typeof v.value === 'string' ? rubyStringLiteral(v.value) : String(v.value)
|
|
555
|
+
}
|
|
556
|
+
if (v.kind === 'expression') {
|
|
557
|
+
const hashRuby = this.providerObjectLiteralRuby(v.expr)
|
|
558
|
+
if (hashRuby !== null) return hashRuby
|
|
559
|
+
return this.convertExpressionToRuby(v.expr)
|
|
560
|
+
}
|
|
561
|
+
if (v.kind === 'template') return this.convertTemplateLiteralPartsToRuby(v.parts)
|
|
562
|
+
// Out-of-shape value (spread / jsx-children) — render as nil rather
|
|
563
|
+
// than emit invalid Ruby; the consumer falls back to its default.
|
|
564
|
+
return 'nil'
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Lower an object-literal provider value (`value={{ open: () => props.open
|
|
569
|
+
* ?? false, onOpenChange: … }}`) to a Ruby Hash. The SSR lowering is a
|
|
570
|
+
* per-member snapshot of what a consumer would READ during the same
|
|
571
|
+
* render:
|
|
572
|
+
*
|
|
573
|
+
* - zero-param expression-body arrows are getters — lower the body (the
|
|
574
|
+
* value is fixed for the render, so the call-time indirection drops out)
|
|
575
|
+
* - `on[A-Z]`-named members and function-shaped values are client-only
|
|
576
|
+
* behavior SSR never invokes — lower to `nil`
|
|
577
|
+
* - anything else lowers through the normal expression pipeline (so an
|
|
578
|
+
* unsupported getter body still refuses loudly with BF101)
|
|
579
|
+
*
|
|
580
|
+
* Keys keep their JS names verbatim (as symbol keys) so a consumer-side
|
|
581
|
+
* `ctx.open` access maps onto the same key. Returns `null` when the
|
|
582
|
+
* expression is not a plain object literal (spread / computed key) — the
|
|
583
|
+
* caller falls back to the whole-expression path, which refuses those
|
|
584
|
+
* shapes with BF101.
|
|
585
|
+
*/
|
|
586
|
+
private providerObjectLiteralRuby(expr: string): string | null {
|
|
587
|
+
const members = parseProviderObjectLiteral(expr.trim())
|
|
588
|
+
if (members === null) return null
|
|
589
|
+
const entries = members.map(m => {
|
|
590
|
+
const key = rubySymbolKey(m.name)
|
|
591
|
+
if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} nil`
|
|
592
|
+
const src = m.kind === 'getter' ? m.body : m.expr
|
|
593
|
+
// A member whose value is a bare identifier that doesn't resolve to
|
|
594
|
+
// a prop/signal/memo is a client-only function reference (a local
|
|
595
|
+
// handler const like `scrollPrev`, or a signal setter like
|
|
596
|
+
// `setCanScrollPrev`) — no SSR value, and emitting `v[:scrollPrev]`
|
|
597
|
+
// would read an un-seeded vars-Hash key. Lower to nil.
|
|
598
|
+
if (this.isClientOnlyContextIdentifier(src)) return `${key} nil`
|
|
599
|
+
return `${key} ${this.convertExpressionToRuby(src)}`
|
|
600
|
+
})
|
|
601
|
+
return `{ ${entries.join(', ')} }`
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* True when `src` is a bare identifier that doesn't resolve to a
|
|
606
|
+
* prop/signal/memo or an SSR-inlinable module string const — i.e. a
|
|
607
|
+
* client-only function reference in a context value (a local handler
|
|
608
|
+
* const like `scrollPrev`, or a signal setter like `setCanScrollPrev`).
|
|
609
|
+
* See `providerDataNames`. Module-scope string consts (`carouselClasses`)
|
|
610
|
+
* ARE SSR-resolvable via `moduleStringConsts`, so they're excluded here.
|
|
611
|
+
*/
|
|
612
|
+
private isClientOnlyContextIdentifier(src: string): boolean {
|
|
613
|
+
const t = src.trim()
|
|
614
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(t)) return false
|
|
615
|
+
return !this.providerDataNames.has(t) && !this.moduleStringConsts.has(t)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
emitAsync(node: IRAsync, _ctx: ErbRenderCtx, _emit: EmitIRNode<ErbRenderCtx>): string {
|
|
619
|
+
return this.renderAsync(node)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ===========================================================================
|
|
623
|
+
// Element Rendering
|
|
624
|
+
// ===========================================================================
|
|
625
|
+
|
|
626
|
+
renderElement(element: IRElement): string {
|
|
627
|
+
const tag = element.tag
|
|
628
|
+
const attrs = this.renderAttributes(element)
|
|
629
|
+
const children = this.renderChildren(element.children)
|
|
630
|
+
|
|
631
|
+
let hydrationAttrs = ''
|
|
632
|
+
if (element.needsScope) {
|
|
633
|
+
hydrationAttrs += ` ${this.renderScopeMarker('')}`
|
|
634
|
+
}
|
|
635
|
+
// A root scope element carries `data-key` for a keyed loop item
|
|
636
|
+
// (emitted from the bf instance; the child renderer sets it from the
|
|
637
|
+
// JSX `key` prop), so a non-keyed render adds nothing. Mirrors Hono
|
|
638
|
+
// stamping data-key on each loop item's scope root, including
|
|
639
|
+
// early-return (if-statement) roots where every branch's top element
|
|
640
|
+
// qualifies.
|
|
641
|
+
if (this.rootScopeNodes.has(element) && element.needsScope) {
|
|
642
|
+
hydrationAttrs += ` <%= bf.data_key_attr %>`
|
|
643
|
+
}
|
|
644
|
+
if (element.slotId) {
|
|
645
|
+
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
646
|
+
}
|
|
647
|
+
// Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The
|
|
648
|
+
// id is a deterministic static string (`<file scope>:<index>`), so it
|
|
649
|
+
// emits as a plain literal attribute — no ERB tag.
|
|
650
|
+
if (element.regionId) {
|
|
651
|
+
hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const voidElements = [
|
|
655
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
656
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
660
|
+
return `<${tag}${attrs}${hydrationAttrs}>`
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ===========================================================================
|
|
667
|
+
// Expression Rendering
|
|
668
|
+
// ===========================================================================
|
|
669
|
+
|
|
670
|
+
renderExpression(expr: IRExpression): string {
|
|
671
|
+
if (expr.clientOnly) {
|
|
672
|
+
if (expr.slotId) {
|
|
673
|
+
return `<%= bf.comment("client:${expr.slotId}") %>`
|
|
674
|
+
}
|
|
675
|
+
return ''
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const rubyExpr = this.convertExpressionToRuby(expr.expr)
|
|
679
|
+
|
|
680
|
+
// A bare read of the `children` prop (`{children}` / `{props.children}`,
|
|
681
|
+
// optionally `?? fallback`) is pre-rendered HTML — captured via the
|
|
682
|
+
// ERB output-buffer slice in `renderComponent` when THIS component was
|
|
683
|
+
// itself invoked as a child, or materialized by `Context#render_child`
|
|
684
|
+
// before the registered renderer runs. Mojo / Xslate don't need this
|
|
685
|
+
// check: their runtimes bless the captured value itself (Mojo::ByteStream
|
|
686
|
+
// / Kolon's `mark_raw`), so any later `<%= %>`/`<: :>` auto-escape
|
|
687
|
+
// dispatches on the VALUE and skips already-raw content transparently.
|
|
688
|
+
// Stdlib ERB's `<%=` has no such wrapper type (see
|
|
689
|
+
// `BarefootJS::Backend::Erb#mark_raw`'s docstring — "there is nothing to
|
|
690
|
+
// opt out of"), so the ADAPTER must decide raw-vs-escaped at this call
|
|
691
|
+
// site instead: every other raw-HTML position (component forwarding,
|
|
692
|
+
// spread_attrs, hydration markers) already routes around `bf.h`
|
|
693
|
+
// structurally; a component reading its OWN `children` back as a text
|
|
694
|
+
// expression was the one position still falling through the generic
|
|
695
|
+
// escape-everything path. Emitting the same `bf.h(...)` wrap here would
|
|
696
|
+
// double-encode markup the runtime already rendered.
|
|
697
|
+
const wrapped = this.isChildrenValueExpr(expr) ? rubyExpr : `bf.h(${rubyExpr})`
|
|
698
|
+
|
|
699
|
+
if (expr.slotId) {
|
|
700
|
+
return `<%= bf.text_start("${expr.slotId}") %><%= ${wrapped} %><%= bf.text_end %>`
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return `<%= ${wrapped} %>`
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* True when `expr` is a structural read of the `children` prop: a bare
|
|
708
|
+
* `children` identifier, `props.children` / `<propsObjectName>.children`,
|
|
709
|
+
* or either of those on the left of a `?? fallback`. Prefers the IR's
|
|
710
|
+
* already-parsed `expr.parsed` tree (attached once during IR construction)
|
|
711
|
+
* and falls back to parsing `expr.expr` only when that's absent — never a
|
|
712
|
+
* regex/string scan of the source text.
|
|
713
|
+
*/
|
|
714
|
+
private isChildrenValueExpr(expr: IRExpression): boolean {
|
|
715
|
+
const parsed = expr.parsed ?? parseExpression(expr.expr.trim()) ?? undefined
|
|
716
|
+
return this.isChildrenReferenceParsedExpr(parsed)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private isChildrenReferenceParsedExpr(parsed: ParsedExpr | undefined): boolean {
|
|
720
|
+
if (!parsed) return false
|
|
721
|
+
if (parsed.kind === 'logical' && parsed.op === '??') {
|
|
722
|
+
return this.isChildrenReferenceParsedExpr(parsed.left)
|
|
723
|
+
}
|
|
724
|
+
if (parsed.kind === 'identifier') return parsed.name === 'children'
|
|
725
|
+
if (parsed.kind === 'member' && !parsed.computed && parsed.property === 'children') {
|
|
726
|
+
return (
|
|
727
|
+
parsed.object.kind === 'identifier' &&
|
|
728
|
+
(parsed.object.name === 'props' ||
|
|
729
|
+
(this.propsObjectName !== null && parsed.object.name === this.propsObjectName))
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
return false
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ===========================================================================
|
|
736
|
+
// Conditional Rendering
|
|
737
|
+
// ===========================================================================
|
|
738
|
+
|
|
739
|
+
renderConditional(cond: IRConditional): string {
|
|
740
|
+
if (cond.clientOnly && cond.slotId) {
|
|
741
|
+
return `<%= bf.comment("cond-start:${cond.slotId}") %><%= bf.comment("cond-end:${cond.slotId}") %>`
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const condition = this.convertExpressionToRuby(cond.condition)
|
|
745
|
+
const whenTrue = this.renderNode(cond.whenTrue)
|
|
746
|
+
const whenFalse = this.renderNodeOrNull(cond.whenFalse)
|
|
747
|
+
|
|
748
|
+
// When slotId is present, add bf-c marker.
|
|
749
|
+
// Use comment markers for fragments (multiple sibling elements), attribute for single elements.
|
|
750
|
+
const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
|
|
751
|
+
const useCommentMarkers = cond.slotId && isFragmentBranch
|
|
752
|
+
|
|
753
|
+
let markedTrue = whenTrue
|
|
754
|
+
let markedFalse = whenFalse
|
|
755
|
+
if (cond.slotId && !useCommentMarkers) {
|
|
756
|
+
markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
|
|
757
|
+
markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let result: string
|
|
761
|
+
if (useCommentMarkers) {
|
|
762
|
+
// Fragment branches: use comment markers
|
|
763
|
+
const inner = whenFalse
|
|
764
|
+
? `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- else -%>\n${whenFalse}\n<%- end -%>\n`
|
|
765
|
+
: `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n`
|
|
766
|
+
result = `<%= bf.comment("cond-start:${cond.slotId}") %>${inner}<%= bf.comment("cond-end:${cond.slotId}") %>`
|
|
767
|
+
} else if (markedFalse) {
|
|
768
|
+
result = `\n<%- if bf.truthy?(${condition}) -%>\n${markedTrue}\n<%- else -%>\n${markedFalse}\n<%- end -%>\n`
|
|
769
|
+
} else if (cond.slotId) {
|
|
770
|
+
// Conditional with no else: wrap with comment markers for client hydration
|
|
771
|
+
result = `<%= bf.comment("cond-start:${cond.slotId}") %>\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n<%= bf.comment("cond-end:${cond.slotId}") %>`
|
|
772
|
+
} else {
|
|
773
|
+
result = `\n<%- if bf.truthy?(${condition}) -%>\n${whenTrue}\n<%- end -%>\n`
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return result
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private renderNodeOrNull(node: IRNode): string | null {
|
|
780
|
+
if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
|
|
781
|
+
return null
|
|
782
|
+
}
|
|
783
|
+
return this.renderNode(node)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Add bf-c attribute to the first HTML element in a branch. If no element
|
|
788
|
+
* found, wrap with comment markers. Operates on already-emitted ERB
|
|
789
|
+
* template TEXT (not JS/TS source), matching the Mojo/Go adapters'
|
|
790
|
+
* identical marker-injection precedent.
|
|
791
|
+
*/
|
|
792
|
+
private addCondMarkerToFirstElement(content: string, condId: string): string {
|
|
793
|
+
const match = content.match(/^(<\w+)([\s>])/)
|
|
794
|
+
if (match) {
|
|
795
|
+
return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
|
|
796
|
+
}
|
|
797
|
+
// Fall back to comment markers for non-element content
|
|
798
|
+
return `<%= bf.comment("cond-start:${condId}") %>${content}<%= bf.comment("cond-end:${condId}") %>`
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ===========================================================================
|
|
802
|
+
// Loop Rendering
|
|
803
|
+
// ===========================================================================
|
|
804
|
+
|
|
805
|
+
renderLoop(loop: IRLoop): string {
|
|
806
|
+
// clientOnly loops must not render items at SSR time, but must still
|
|
807
|
+
// emit the `loop:`/`/loop:` boundary marker pair (Hono and Go parity)
|
|
808
|
+
// so the client runtime's mapArray() can locate the insertion anchor
|
|
809
|
+
// when hydrating the array. The marker id disambiguates sibling
|
|
810
|
+
// `.map()` calls under the same parent.
|
|
811
|
+
if (loop.clientOnly) {
|
|
812
|
+
return `<%= bf.comment("loop:${loop.markerId}") %><%= bf.comment("/loop:${loop.markerId}") %>`
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// An array/object-destructure loop param (`([emoji, users]) => ...` or
|
|
816
|
+
// `({ name, age }) => ...`) lowers to invalid Ruby block-param syntax in
|
|
817
|
+
// general — Ruby has no destructuring-assignment analog for an arbitrary
|
|
818
|
+
// nested pattern in a single statement. `isLowerableLoopDestructure`
|
|
819
|
+
// (#2087) instead admits any FIXED-binding shape — single field, nested
|
|
820
|
+
// field, array-index, any depth/mix (`{ user: { name } }`, `([k, v])`,
|
|
821
|
+
// `{ cells: [head] }`) — by walking the binding's structured `segments`
|
|
822
|
+
// path into a chained Ruby accessor (`__bf_item[:user][:name]`), plus
|
|
823
|
+
// array-rest (`[first, ...tail]`, native `bf.slice`) and object-rest
|
|
824
|
+
// (`{ id, ...rest }`, native `Hash#except`) whose every use is a member
|
|
825
|
+
// read (`rest.flag`) or a `{...rest}` spread onto an intrinsic element.
|
|
826
|
+
// Bare-value rest uses, a spread onto a component/provider, and
|
|
827
|
+
// `.filter().map(destructure)` still have no Ruby scalar form → BF104.
|
|
828
|
+
const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
|
|
829
|
+
const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
|
|
830
|
+
if (destructure && !supportableDestructure) {
|
|
831
|
+
this.errors.push({
|
|
832
|
+
code: 'BF104',
|
|
833
|
+
severity: 'error',
|
|
834
|
+
message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the ERB adapter cannot lower — the rest binding is used in a way (bare value, or spread onto a component) that has no native Ruby accessor form.`,
|
|
835
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
836
|
+
suggestion: {
|
|
837
|
+
message:
|
|
838
|
+
`Options:\n` +
|
|
839
|
+
` 1. Read the rest binding as a member access (\`rest.field\`) or spread it onto an intrinsic element (\`<li {...rest}>\`) instead of using it as a bare value.\n` +
|
|
840
|
+
` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
841
|
+
` 3. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Loop array is a bare identifier naming a component-scope (non-module)
|
|
847
|
+
// `const` the adapter has no way to compute at SSR render time. Only a
|
|
848
|
+
// pure-literal const (`resolveLiteralConst`) or a module-scope
|
|
849
|
+
// pure-string const (`resolveModuleStringConst`) is ever inlined —
|
|
850
|
+
// anything else (`const entries = Object.entries(props.x).filter(...)`)
|
|
851
|
+
// falls through to the generic `v[:entries]` vars-Hash read, which is
|
|
852
|
+
// never seeded (the ERB test/production vars-seed only covers props,
|
|
853
|
+
// signals, and memos — see `test-render.ts::buildRubyProps`) and raises
|
|
854
|
+
// a Ruby `NoMethodError` on `.length` at render time. This is a
|
|
855
|
+
// pre-existing, orthogonal gap surfaced by #2087 widening the loop
|
|
856
|
+
// destructure gate (`isLowerableLoopDestructure` now admits the
|
|
857
|
+
// array-index destructure `static-array-from-props(-with-component)`
|
|
858
|
+
// use, which used to be hidden behind the old destructure BF104) — it
|
|
859
|
+
// reproduces identically with a non-destructured param, so it is NOT a
|
|
860
|
+
// destructure-lowering limitation. Surface BF101 honestly instead of
|
|
861
|
+
// emitting a loop bound that silently crashes / renders empty.
|
|
862
|
+
if (loop.arrayParsed?.kind === 'identifier') {
|
|
863
|
+
const arrayName = loop.arrayParsed.name
|
|
864
|
+
const isUnresolvableLocalConst =
|
|
865
|
+
!this.loopBoundNames.has(arrayName) &&
|
|
866
|
+
this.resolveModuleStringConst(arrayName) === null &&
|
|
867
|
+
this.resolveLiteralConst(arrayName) === null &&
|
|
868
|
+
this.localConstants.some(c => c.name === arrayName && !c.isModule)
|
|
869
|
+
if (isUnresolvableLocalConst) {
|
|
870
|
+
this._recordExprBF101(
|
|
871
|
+
`Loop array \`${arrayName}\` is a component-scope const computed from a runtime expression the ERB adapter cannot evaluate at SSR render time.`,
|
|
872
|
+
`Options:\n1. Inline the array expression directly in the .map() call instead of a preceding const.\n2. Mark the loop position as @client-only so the array materialises on the client.\n3. Precompute the value server-side and pass it in as a prop.`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const rawArray = this.convertExpressionToRuby(loop.array)
|
|
878
|
+
// Apply sort if present: hoist the (possibly sorted) array into a Ruby
|
|
879
|
+
// local BEFORE the index loop, so both the loop bound and the per-item
|
|
880
|
+
// lookup reference the same materialised array — otherwise a sort
|
|
881
|
+
// helper call spliced into both spots would run twice per render.
|
|
882
|
+
let sortedHoist: string | null = null
|
|
883
|
+
let array = rawArray
|
|
884
|
+
if (loop.sortComparator) {
|
|
885
|
+
sortedHoist = `bf_iter_${rubyIdentifierFromMarkerId(loop.markerId)}`
|
|
886
|
+
array = sortedHoist
|
|
887
|
+
}
|
|
888
|
+
const param = loop.param
|
|
889
|
+
// `.keys().map(k => ...)` — the callback param is the index. Use it as
|
|
890
|
+
// the loop's index local and skip the per-item value assignment.
|
|
891
|
+
const indexVar = loop.iterationShape === 'keys'
|
|
892
|
+
? rubyLocal(param)
|
|
893
|
+
: rubyLocal(loop.index ?? '_i')
|
|
894
|
+
// Names this loop binds in body scope. Guard module-const inlining (and,
|
|
895
|
+
// in ERB, the fundamental v[:name]-vs-local rendering choice) for the
|
|
896
|
+
// whole body (children + key + filter) so a same-named loop variable
|
|
897
|
+
// isn't replaced by the const literal / a vars-Hash read. Ref-counted
|
|
898
|
+
// for nested loops; released after the body lines are assembled below.
|
|
899
|
+
const loopBound = loop.iterationShape === 'keys'
|
|
900
|
+
? [param]
|
|
901
|
+
: supportableDestructure
|
|
902
|
+
? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
|
|
903
|
+
: [param, loop.index ?? '_i']
|
|
904
|
+
for (const n of loopBound) {
|
|
905
|
+
this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
|
|
906
|
+
}
|
|
907
|
+
const prevInLoop = this.inLoop
|
|
908
|
+
this.inLoop = true
|
|
909
|
+
const renderedChildren = this.renderChildren(loop.children)
|
|
910
|
+
this.inLoop = prevInLoop
|
|
911
|
+
|
|
912
|
+
// Whole-item conditional: prepend an always-present
|
|
913
|
+
// `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
|
|
914
|
+
// conditional content so the client's `mapArrayAnchored` can hydrate
|
|
915
|
+
// every SSR-rendered item by its anchor. `bf.comment` prepends `bf-`,
|
|
916
|
+
// so `"loop-i:" + KEY` yields `<!--bf-loop-i:KEY-->`. `bf.string` keeps
|
|
917
|
+
// the concatenation valid when KEY isn't already a Ruby String.
|
|
918
|
+
const children =
|
|
919
|
+
loop.bodyIsItemConditional && loop.key
|
|
920
|
+
? `<%= bf.comment("loop-i:" + bf.string(${this.convertExpressionToRuby(loop.key)})) %>\n${renderedChildren}`
|
|
921
|
+
: renderedChildren
|
|
922
|
+
|
|
923
|
+
const lines: string[] = []
|
|
924
|
+
// Scoped per-call-site marker so sibling `.map()`s under the same
|
|
925
|
+
// parent each get their own reconciliation range.
|
|
926
|
+
lines.push(`<%= bf.comment("loop:${loop.markerId}") %>`)
|
|
927
|
+
if (sortedHoist && loop.sortComparator) {
|
|
928
|
+
// Evaluator-first: serialize the comparator + emit `bf.sort_eval`;
|
|
929
|
+
// fall back to the structured `bf.sort` for a comparator the
|
|
930
|
+
// evaluator can't model (e.g. `localeCompare`).
|
|
931
|
+
//
|
|
932
|
+
// The hoisted sort runs OUTSIDE this loop, so this loop's bound names
|
|
933
|
+
// must not shadow the comparator's captured free vars while emitting
|
|
934
|
+
// the env — otherwise a captured var that happens to share a
|
|
935
|
+
// loop-param name is blocked from inlining its module const / from
|
|
936
|
+
// reading `v[:name]` and instead resolves to the (out-of-scope) loop
|
|
937
|
+
// local. Drop this loop's bound names for the sort emit, then
|
|
938
|
+
// restore (a nested loop's outer bindings, ref-counted, stay in effect).
|
|
939
|
+
for (const n of loopBound) {
|
|
940
|
+
const c = (this.loopBoundNames.get(n) ?? 1) - 1
|
|
941
|
+
if (c <= 0) this.loopBoundNames.delete(n)
|
|
942
|
+
else this.loopBoundNames.set(n, c)
|
|
943
|
+
}
|
|
944
|
+
const sortEmit = (e: ParsedExpr) => this.convertExpressionToRuby('', e)
|
|
945
|
+
const sortArrow = loop.sortComparator.arrow
|
|
946
|
+
let sorted: string | null = null
|
|
947
|
+
if (sortArrow.kind === 'arrow') {
|
|
948
|
+
sorted = renderSortEval(rawArray, sortArrow.body, sortArrow.params, sortEmit)
|
|
949
|
+
}
|
|
950
|
+
if (sorted === null) {
|
|
951
|
+
const structured = sortComparatorFromArrow(sortArrow)
|
|
952
|
+
if (structured !== null) sorted = renderSortMethod(rawArray, structured)
|
|
953
|
+
}
|
|
954
|
+
if (sorted === null) {
|
|
955
|
+
// Neither the evaluator nor the structured fallback can model this
|
|
956
|
+
// comparator — record BF101 and fall through with the unsorted
|
|
957
|
+
// array so the hoist line stays syntactically valid.
|
|
958
|
+
this._recordExprBF101(
|
|
959
|
+
`.sort(...) loop comparator is not lowerable to a template sort`,
|
|
960
|
+
`Pre-sort the array in the route handler, or mark the loop @client-only.`,
|
|
961
|
+
)
|
|
962
|
+
sorted = rawArray
|
|
963
|
+
}
|
|
964
|
+
for (const n of loopBound) {
|
|
965
|
+
this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
|
|
966
|
+
}
|
|
967
|
+
lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
|
|
968
|
+
}
|
|
969
|
+
lines.push(`<%- (0...${array}.length).each do |${indexVar}| -%>`)
|
|
970
|
+
if (loop.iterationShape !== 'keys') {
|
|
971
|
+
if (supportableDestructure) {
|
|
972
|
+
// Per-item local + one local per binding, built off the binding's
|
|
973
|
+
// structured `segments` path (never `b.path` — repo rule: no
|
|
974
|
+
// string-parsing of a JS-shaped accessor). See `rubyAccessorFromSegments`.
|
|
975
|
+
lines.push(`<%- __bf_item = ${array}[${indexVar}] -%>`)
|
|
976
|
+
for (const b of loop.paramBindings ?? []) {
|
|
977
|
+
// Fixed bindings: `segments` is the FULL accessor path from the
|
|
978
|
+
// item to this binding. Rest bindings: `segments` is the PARENT
|
|
979
|
+
// prefix (possibly empty, at the loop root) — the accessor below
|
|
980
|
+
// is the rest's parent object/array, not the rest binding itself.
|
|
981
|
+
const accessor = rubyAccessorFromSegments('__bf_item', b.segments ?? [])
|
|
982
|
+
if (b.rest?.kind === 'array') {
|
|
983
|
+
// Native Ruby range-slice has different past-end nil semantics
|
|
984
|
+
// than JS `Array.prototype.slice` (`arr[4..]` is `nil` where
|
|
985
|
+
// `arr.slice(4)` is `[]`) — route through the runtime's `slice`
|
|
986
|
+
// helper (barefoot_js.rb) so both edge cases (`from` at or past
|
|
987
|
+
// length) return `[]` like JS, matching the Go/Perl ports.
|
|
988
|
+
lines.push(`<%- ${rubyLocal(b.name)} = bf.slice(${accessor}, ${b.rest.from}) -%>`)
|
|
989
|
+
} else if (b.rest?.kind === 'object') {
|
|
990
|
+
// A TRUE residual Hash (not an alias of the parent) via native
|
|
991
|
+
// `Hash#except` (Ruby >= 3.0; CI pins 3.3) — so a member read
|
|
992
|
+
// (`rest.flag` → `rest[:flag]`, `ErbTopLevelEmitter#member`) and
|
|
993
|
+
// the existing `{...rest}` spread emit (`bf.spread_attrs`) both
|
|
994
|
+
// see only the non-destructured keys, same as the Hono/CSR IIFE.
|
|
995
|
+
// Symbol keys match the vars-Hash convention: every item Hash is
|
|
996
|
+
// built by `JSON.parse(..., symbolize_names: true)`.
|
|
997
|
+
const excludeSyms = b.rest.exclude.map(k => rubySymbolLiteral(k.key)).join(', ')
|
|
998
|
+
lines.push(`<%- ${rubyLocal(b.name)} = ${accessor}.except(${excludeSyms}) -%>`)
|
|
999
|
+
} else {
|
|
1000
|
+
lines.push(`<%- ${rubyLocal(b.name)} = ${accessor} -%>`)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
} else {
|
|
1004
|
+
lines.push(`<%- ${rubyLocal(param)} = ${array}[${indexVar}] -%>`)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Handle filter().map() pattern by wrapping children in if-condition
|
|
1009
|
+
if (loop.filterPredicate) {
|
|
1010
|
+
let filterCond: string
|
|
1011
|
+
if (loop.filterPredicate.predicate) {
|
|
1012
|
+
// The loop's own (possibly destructure-adjusted) param name IS the
|
|
1013
|
+
// filter predicate's parameter for rendering purposes — pass it
|
|
1014
|
+
// straight through as the filter emitter's bound param. Ruby's
|
|
1015
|
+
// named-block-param model makes the Mojo original's regex
|
|
1016
|
+
// `$filterParam → $loopParam` rename unnecessary here: the emitter
|
|
1017
|
+
// just renders every reference to the predicate's own arrow
|
|
1018
|
+
// parameter AS `param` from the start.
|
|
1019
|
+
filterCond = this.renderRubyFilterExpr(loop.filterPredicate.predicate, param)
|
|
1020
|
+
} else {
|
|
1021
|
+
filterCond = 'true'
|
|
1022
|
+
}
|
|
1023
|
+
lines.push(`<%- if bf.truthy?(${filterCond}) -%>`)
|
|
1024
|
+
lines.push(children)
|
|
1025
|
+
lines.push(`<%- end -%>`)
|
|
1026
|
+
} else {
|
|
1027
|
+
lines.push(children)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Body fully rendered — release the loop-bound names.
|
|
1031
|
+
for (const n of loopBound) {
|
|
1032
|
+
const c = (this.loopBoundNames.get(n) ?? 1) - 1
|
|
1033
|
+
if (c <= 0) this.loopBoundNames.delete(n)
|
|
1034
|
+
else this.loopBoundNames.set(n, c)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
lines.push(`<%- end -%>`)
|
|
1038
|
+
lines.push(`<%= bf.comment("/loop:${loop.markerId}") %>`)
|
|
1039
|
+
|
|
1040
|
+
return lines.join('\n')
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ===========================================================================
|
|
1044
|
+
// Component Rendering
|
|
1045
|
+
// ===========================================================================
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* AttrValue lowering for component invocation props (ERB / Ruby Hash
|
|
1049
|
+
* literal form). Routed through the shared dispatcher so a new AttrValue
|
|
1050
|
+
* kind becomes a TS compile error here.
|
|
1051
|
+
*
|
|
1052
|
+
* `jsx-children` returns empty — children are captured via the ERB
|
|
1053
|
+
* output-buffer slice below, not threaded through the `render_child`
|
|
1054
|
+
* props Hash.
|
|
1055
|
+
*/
|
|
1056
|
+
private readonly componentPropEmitter: AttrValueEmitter = {
|
|
1057
|
+
emitLiteral: (value, name) => `${rubySymbolKey(name)} ${rubyStringLiteral(String(value.value))}`,
|
|
1058
|
+
emitExpression: (value, name) => {
|
|
1059
|
+
// The IR producer collapses component-prop `template` kinds into
|
|
1060
|
+
// `expression` for client-runtime reasons but preserves the parsed
|
|
1061
|
+
// parts on `v.parts`. Prefer the structured form when available —
|
|
1062
|
+
// the bare-expression path can't handle `${MAP[KEY]}` shapes (the JS
|
|
1063
|
+
// object literal leaks into the Ruby template).
|
|
1064
|
+
if (value.parts) {
|
|
1065
|
+
return `${rubySymbolKey(name)} ${this.convertTemplateLiteralPartsToRuby(value.parts)}`
|
|
1066
|
+
}
|
|
1067
|
+
// Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
|
|
1068
|
+
// lower to a Ruby Hash so the child can serialize it (`data-opts`),
|
|
1069
|
+
// instead of refusing the bare object with BF101. Read the
|
|
1070
|
+
// IR-carried structured `ParsedExpr` tree instead of re-parsing
|
|
1071
|
+
// `value.expr`; the lowering returns null for any non-object-literal
|
|
1072
|
+
// shape, so the common non-object case falls straight through to the
|
|
1073
|
+
// bare-expression path below.
|
|
1074
|
+
if (value.parsed) {
|
|
1075
|
+
const hashRuby = objectLiteralExprToRubyHash(this.spreadCtx, value.parsed)
|
|
1076
|
+
if (hashRuby !== null) return `${rubySymbolKey(name)} ${hashRuby}`
|
|
1077
|
+
}
|
|
1078
|
+
return `${rubySymbolKey(name)} ${this.convertExpressionToRuby(value.expr)}`
|
|
1079
|
+
},
|
|
1080
|
+
emitSpread: (value) => {
|
|
1081
|
+
// Ruby's `**hash` double-splat flattens a Hash's entries into a
|
|
1082
|
+
// surrounding Hash literal — the direct analog of Perl's `%{$props}`
|
|
1083
|
+
// deref, but native syntax rather than a deref workaround.
|
|
1084
|
+
const rubyExpr = this.convertExpressionToRuby(value.expr)
|
|
1085
|
+
return `**${rubyExpr}`
|
|
1086
|
+
},
|
|
1087
|
+
emitTemplate: (value, name) =>
|
|
1088
|
+
`${rubySymbolKey(name)} ${this.convertTemplateLiteralPartsToRuby(value.parts)}`,
|
|
1089
|
+
emitBooleanAttr: (_value, name) => `${rubySymbolKey(name)} true`,
|
|
1090
|
+
emitBooleanShorthand: (_value, name) => `${rubySymbolKey(name)} true`,
|
|
1091
|
+
// JSX children flow through the ERB buffer-slice capture below; they're
|
|
1092
|
+
// not part of the props Hash.
|
|
1093
|
+
emitJsxChildren: () => '',
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
renderComponent(comp: IRComponent): string {
|
|
1097
|
+
const propParts: string[] = []
|
|
1098
|
+
for (const p of comp.props) {
|
|
1099
|
+
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
1100
|
+
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
1101
|
+
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
1102
|
+
const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
|
|
1103
|
+
if (lowered) propParts.push(lowered)
|
|
1104
|
+
}
|
|
1105
|
+
// Pass slot ID so the child renderer can set correct scope ID for
|
|
1106
|
+
// hydration. Skip for loop children — they use ComponentName_random
|
|
1107
|
+
// pattern instead.
|
|
1108
|
+
if (comp.slotId && !this.inLoop) {
|
|
1109
|
+
propParts.push(`${rubySymbolKey('_bf_slot')} '${comp.slotId}'`)
|
|
1110
|
+
}
|
|
1111
|
+
const tplName = this.toTemplateName(comp.name)
|
|
1112
|
+
// Resolve the effective children: a nested `<Box>…</Box>` populates
|
|
1113
|
+
// `comp.children`; an attribute-form `<Box children={<jsx/>} />`
|
|
1114
|
+
// lands in a `jsx-children` AttrValue on the corresponding prop. The
|
|
1115
|
+
// parent's scope marker is already attached to each hoisted root by
|
|
1116
|
+
// the IR collector (`needsScope: true`), so the adapter just needs to
|
|
1117
|
+
// render the IR through the same children pipeline as the nested form.
|
|
1118
|
+
const effectiveChildren: IRNode[] = comp.children.length > 0
|
|
1119
|
+
? comp.children
|
|
1120
|
+
: resolveJsxChildrenProp(comp.props)
|
|
1121
|
+
if (effectiveChildren.length > 0) {
|
|
1122
|
+
// Forward JSX children via an ERB output-buffer slice so dynamic
|
|
1123
|
+
// segments inside the children (signals, conditionals) get evaluated
|
|
1124
|
+
// in the parent's template scope before reaching the child renderer.
|
|
1125
|
+
// Mark the buffer position, render the children inline (they append
|
|
1126
|
+
// straight to `_erbout`, the same buffer the surrounding template
|
|
1127
|
+
// uses — `ERB.new(src, eoutvar: '_erbout')`), then slice everything
|
|
1128
|
+
// appended since the mark back OUT of the buffer into a local. No
|
|
1129
|
+
// literal text (not even a newline) may sit between the mark/slice
|
|
1130
|
+
// tags and the children — the slice captures byte-for-byte whatever
|
|
1131
|
+
// `_erbout` gained in between, so any interposed template text would
|
|
1132
|
+
// leak into (or out of) the capture.
|
|
1133
|
+
const prevInLoop = this.inLoop
|
|
1134
|
+
this.inLoop = false
|
|
1135
|
+
const childrenBody = this.renderChildren(effectiveChildren)
|
|
1136
|
+
this.inLoop = prevInLoop
|
|
1137
|
+
const suffix = comp.slotId ?? `c${this.childrenCaptureCounter++}`
|
|
1138
|
+
const lenVar = `__bf_len_${suffix}`
|
|
1139
|
+
const capVar = `__bf_children_${suffix}`
|
|
1140
|
+
const propsHash = `{ ${[...propParts, `children: ${capVar}`].join(', ')} }`
|
|
1141
|
+
return `<% ${lenVar} = _erbout.length %>${childrenBody}<% ${capVar} = _erbout.slice!(${lenVar}..) %><%= bf.render_child('${tplName}', ${propsHash}) %>`
|
|
1142
|
+
}
|
|
1143
|
+
const propsHash = propParts.length > 0 ? `{ ${propParts.join(', ')} }` : '{}'
|
|
1144
|
+
return `<%= bf.render_child('${tplName}', ${propsHash}) %>`
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private childrenCaptureCounter = 0
|
|
1148
|
+
|
|
1149
|
+
private toTemplateName(componentName: string): string {
|
|
1150
|
+
// Convert PascalCase to snake_case for ERB template naming
|
|
1151
|
+
return componentName
|
|
1152
|
+
.replace(/([A-Z])/g, '_$1')
|
|
1153
|
+
.toLowerCase()
|
|
1154
|
+
.replace(/^_/, '')
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ===========================================================================
|
|
1158
|
+
// If-Statement (Conditional Return) Rendering
|
|
1159
|
+
// ===========================================================================
|
|
1160
|
+
|
|
1161
|
+
private renderIfStatement(ifStmt: IRIfStatement): string {
|
|
1162
|
+
const condition = this.convertExpressionToRuby(ifStmt.condition)
|
|
1163
|
+
const consequent = ifStmt.consequent.type === 'if-statement'
|
|
1164
|
+
? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
|
|
1165
|
+
: this.renderNode(ifStmt.consequent)
|
|
1166
|
+
let result = `<%- if bf.truthy?(${condition}) -%>\n${consequent}\n`
|
|
1167
|
+
|
|
1168
|
+
if (ifStmt.alternate) {
|
|
1169
|
+
if (ifStmt.alternate.type === 'if-statement') {
|
|
1170
|
+
const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
|
|
1171
|
+
// Replace the leading "<%- if" with "<%- elsif" — operating on
|
|
1172
|
+
// already-emitted ERB text we constructed above (the exact literal
|
|
1173
|
+
// prefix every `renderIfStatement` call emits), not on JS/TS source.
|
|
1174
|
+
result += altResult.replace(/^<%- if /, '<%- elsif ')
|
|
1175
|
+
} else {
|
|
1176
|
+
const alternate = this.renderNode(ifStmt.alternate)
|
|
1177
|
+
result += `<%- else -%>\n${alternate}\n`
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
result += `<%- end -%>`
|
|
1182
|
+
return result
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ===========================================================================
|
|
1186
|
+
// Fragment & Slot Rendering
|
|
1187
|
+
// ===========================================================================
|
|
1188
|
+
|
|
1189
|
+
private renderFragment(fragment: IRFragment): string {
|
|
1190
|
+
const children = this.renderChildren(fragment.children)
|
|
1191
|
+
if (fragment.needsScopeComment) {
|
|
1192
|
+
return `<%= bf.scope_comment %>${children}`
|
|
1193
|
+
}
|
|
1194
|
+
return children
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private renderSlot(_slot: IRSlot): string {
|
|
1198
|
+
// ERB's native layout-content mechanism is `yield` (the partial/layout
|
|
1199
|
+
// convention closest to Mojo's implicit `content` helper).
|
|
1200
|
+
return `<%= yield %>`
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
override renderAsync(node: IRAsync): string {
|
|
1204
|
+
const fallback = this.renderNode(node.fallback)
|
|
1205
|
+
const children = this.renderChildren(node.children)
|
|
1206
|
+
// Use the BarefootJS runtime's streaming helpers for OOS streaming.
|
|
1207
|
+
// bf.async_boundary() wraps the fallback in a <div bf-async="aX">
|
|
1208
|
+
// placeholder. The resolved content is rendered below for
|
|
1209
|
+
// non-streaming fallback; in streaming mode the backend's write_chunk
|
|
1210
|
+
// delivers it as a resolve chunk.
|
|
1211
|
+
//
|
|
1212
|
+
// The fallback is captured via the same output-buffer slice
|
|
1213
|
+
// `renderComponent` uses for forwarded children — see that method's
|
|
1214
|
+
// docstring for why no literal text may sit between the mark/slice
|
|
1215
|
+
// tags and the fallback markup.
|
|
1216
|
+
const lenVar = `__bf_alen_${node.id}`
|
|
1217
|
+
const fallbackVar = `bf_async_fallback_${node.id}`
|
|
1218
|
+
return `<% ${lenVar} = _erbout.length %>${fallback}<% ${fallbackVar} = _erbout.slice!(${lenVar}..) %><%= bf.async_boundary('${node.id}', ${fallbackVar}) %>\n${children}`
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ===========================================================================
|
|
1222
|
+
// Attribute Rendering
|
|
1223
|
+
// ===========================================================================
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* AttrValue lowering for intrinsic-element attributes (ERB template).
|
|
1227
|
+
* Routed through the shared dispatcher.
|
|
1228
|
+
*/
|
|
1229
|
+
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
1230
|
+
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
1231
|
+
emitExpression: (value, name) => {
|
|
1232
|
+
// `style={{ … }}` object literal → a CSS string with dynamic values
|
|
1233
|
+
// interpolated, instead of refusing the bare object with BF101.
|
|
1234
|
+
if (name === 'style') {
|
|
1235
|
+
const css = this.tryLowerStyleObject(value.expr)
|
|
1236
|
+
if (css !== null) return `style="${css}"`
|
|
1237
|
+
}
|
|
1238
|
+
// Refuse shapes that have no idiomatic ERB template representation.
|
|
1239
|
+
// Tagged-template-literal call expressions (`cn\`base \${tone()}\``)
|
|
1240
|
+
// have no idiomatic ERB template form; the Go adapter raises BF101
|
|
1241
|
+
// here via `convertExpressionToGo` + `isSupported`. Lift the same
|
|
1242
|
+
// gate so the user gets a clear diagnostic instead of broken output.
|
|
1243
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
|
|
1244
|
+
return ''
|
|
1245
|
+
}
|
|
1246
|
+
// Hono-style nullish-attribute omission (#textarea rows): when the
|
|
1247
|
+
// attribute value is a BARE reference to an optional, no-default
|
|
1248
|
+
// prop (which reads `nil` when the caller omits it), guard the
|
|
1249
|
+
// attribute with a Ruby nil-check so it DROPS rather than rendering
|
|
1250
|
+
// `attr=""`. The guarded body reuses the exact normal emission, so
|
|
1251
|
+
// value escaping is unchanged; only the presence is conditional.
|
|
1252
|
+
const bareId = value.expr.trim()
|
|
1253
|
+
// Normalize a props-object access (`props.id`) to its bare prop name
|
|
1254
|
+
// (`id`) so the nullable-optional set — keyed by bare name — matches
|
|
1255
|
+
// the SolidJS props-object pattern, not just destructured params
|
|
1256
|
+
// (#checkbox `id={props.id}`).
|
|
1257
|
+
const normalizedBareId =
|
|
1258
|
+
this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
|
|
1259
|
+
? bareId.slice(this.propsObjectName.length + 1)
|
|
1260
|
+
: bareId
|
|
1261
|
+
if (
|
|
1262
|
+
!isBooleanAttr(name) &&
|
|
1263
|
+
!value.presenceOrUndefined &&
|
|
1264
|
+
/^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
|
|
1265
|
+
this.nullableOptionalProps.has(normalizedBareId)
|
|
1266
|
+
) {
|
|
1267
|
+
const ruby = this.convertExpressionToRuby(value.expr)
|
|
1268
|
+
const body =
|
|
1269
|
+
this.shouldWrapBoolStr(value.expr, name)
|
|
1270
|
+
? `${name}="<%= bf.h(bf.bool_str(${ruby})) %>"`
|
|
1271
|
+
: `${name}="<%= bf.h(${ruby}) %>"`
|
|
1272
|
+
return `<% if !(${ruby}).nil? %>${body}<% end %>`
|
|
1273
|
+
}
|
|
1274
|
+
if (isBooleanAttr(name)) {
|
|
1275
|
+
// Boolean attributes: render conditionally (present or absent).
|
|
1276
|
+
return `<%= bf.truthy?(${this.convertExpressionToRuby(value.expr)}) ? '${name}' : '' %>`
|
|
1277
|
+
}
|
|
1278
|
+
if (value.presenceOrUndefined) {
|
|
1279
|
+
// `attr={expr || undefined}` on a NON-boolean attribute: Hono
|
|
1280
|
+
// renders the attr with its stringified value when truthy and
|
|
1281
|
+
// omits it otherwise (`aria-disabled={isDisabled() || undefined}`
|
|
1282
|
+
// → `aria-disabled="true"`), so bare presence would diverge. Route
|
|
1283
|
+
// through `bool_str` when the name/shape witnesses a boolean
|
|
1284
|
+
// value, same as the unconditional path below. Bind to a temp
|
|
1285
|
+
// first so the expression evaluates once, not in both the guard
|
|
1286
|
+
// and the value.
|
|
1287
|
+
const ruby = this.convertExpressionToRuby(value.expr)
|
|
1288
|
+
const tmp = `__bf_pu${this.presenceVarCounter++}`
|
|
1289
|
+
const body =
|
|
1290
|
+
this.shouldWrapBoolStr(value.expr, name)
|
|
1291
|
+
? `${name}="<%= bf.h(bf.bool_str(${tmp})) %>"`
|
|
1292
|
+
: `${name}="<%= bf.h(${tmp}) %>"`
|
|
1293
|
+
return `<% ${tmp} = ${ruby}; if bf.truthy?(${tmp}) %>${body}<% end %>`
|
|
1294
|
+
}
|
|
1295
|
+
// Boolean-result handling: see `shouldWrapBoolStr`'s docstring for
|
|
1296
|
+
// the three wrap witnesses and the `String(...)`-call exception.
|
|
1297
|
+
//
|
|
1298
|
+
// `attr={cond ? value : undefined}` OMITS the attribute on the falsy
|
|
1299
|
+
// branch (Hono drops undefined-valued attributes) — wrap the whole
|
|
1300
|
+
// attribute in the condition instead of rendering `attr=""`.
|
|
1301
|
+
{
|
|
1302
|
+
const m = this.parseUndefinedAlternateTernary(value.expr)
|
|
1303
|
+
if (m) {
|
|
1304
|
+
const cond = this.convertExpressionToRuby(m.condition)
|
|
1305
|
+
const val = this.convertExpressionToRuby(m.consequent)
|
|
1306
|
+
return `<% if bf.truthy?(${cond}) %>${name}="<%= bf.h(${val}) %>"<% end %>`
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const ruby = this.convertExpressionToRuby(value.expr)
|
|
1310
|
+
if (this.shouldWrapBoolStr(value.expr, name)) {
|
|
1311
|
+
return `${name}="<%= bf.h(bf.bool_str(${ruby})) %>"`
|
|
1312
|
+
}
|
|
1313
|
+
return `${name}="<%= bf.h(${ruby}) %>"`
|
|
1314
|
+
},
|
|
1315
|
+
emitBooleanAttr: (_value, name) => name,
|
|
1316
|
+
emitTemplate: (value, name) =>
|
|
1317
|
+
`${name}="<%= bf.h(${this.convertTemplateLiteralPartsToRuby(value.parts)}) %>"`,
|
|
1318
|
+
// Spread attributes (`<div {...attrs()} />`) lower through the
|
|
1319
|
+
// `bf.spread_attrs` Ruby runtime helper, mirroring the Go adapter's
|
|
1320
|
+
// `bf_spread_attrs` and the JS `spreadAttrs` from
|
|
1321
|
+
// `@barefootjs/client/runtime`. The bag's source JS expression is
|
|
1322
|
+
// translated to a Ruby expression via `convertExpressionToRuby`
|
|
1323
|
+
// (e.g. `attrs()` → `v[:attrs]`, `props.bag` → `v[:bag]`); the helper
|
|
1324
|
+
// accepts a Hash and emits pre-escaped, sorted `key="value"` pairs.
|
|
1325
|
+
//
|
|
1326
|
+
// `IRAttribute.slotId` is set by the IR pass but the ERB adapter
|
|
1327
|
+
// ignores it — the slot field exists only for the Go adapter's
|
|
1328
|
+
// static-typed Props struct.
|
|
1329
|
+
emitSpread: (value) => {
|
|
1330
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
|
|
1331
|
+
return ''
|
|
1332
|
+
}
|
|
1333
|
+
// SolidJS-style props identifier (`(props: P) { <el {...props}/> }`)
|
|
1334
|
+
// has no matching `v[:props]` entry in the ERB vars Hash — props
|
|
1335
|
+
// arrive as one vars-Hash key per `propsParams` entry, not as a
|
|
1336
|
+
// single nested Hash. Emit an inline Hash literal that enumerates
|
|
1337
|
+
// the analyzer-extracted props params so `bf.spread_attrs(...)` gets
|
|
1338
|
+
// a real Hash (matches the Go adapter's same-shape map-literal
|
|
1339
|
+
// path). For `restPropsName` and other identifier shapes, the
|
|
1340
|
+
// standard `convertExpressionToRuby` translation handles it (rest
|
|
1341
|
+
// binding name → `v[:<name>]` resolves against the Hash the caller /
|
|
1342
|
+
// harness placed under that key).
|
|
1343
|
+
const trimmed = value.expr.trim()
|
|
1344
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
1345
|
+
const entries = this.propsParams.map(p =>
|
|
1346
|
+
`${rubySymbolKey(p.name)} v[${rubySymbolLiteral(p.name)}]`,
|
|
1347
|
+
)
|
|
1348
|
+
return `<%= bf.spread_attrs({${entries.join(', ')}}) %>`
|
|
1349
|
+
}
|
|
1350
|
+
// Conditional inline-object spread:
|
|
1351
|
+
// `{...(COND ? { 'aria-describedby': describedBy } : {})}`
|
|
1352
|
+
// Emit a Ruby inline ternary of Hashes — the falsy `{}` branch OMITS
|
|
1353
|
+
// the key (`bf.spread_attrs` does NOT filter empty strings, so we
|
|
1354
|
+
// cannot always-include it). Mirrors the Go adapter's IIFE-of-maps
|
|
1355
|
+
// lowering (#textarea).
|
|
1356
|
+
const ternaryHash = conditionalSpreadToRuby(this.spreadCtx, value.parsed)
|
|
1357
|
+
if (ternaryHash !== null) {
|
|
1358
|
+
return `<%= bf.spread_attrs(${ternaryHash}) %>`
|
|
1359
|
+
}
|
|
1360
|
+
// Function-scope local const holding a conditional inline-object
|
|
1361
|
+
// `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
|
|
1362
|
+
// (#checkbox / icon). Resolve the bare identifier to its initializer
|
|
1363
|
+
// text and route through the same conditional-spread lowering. Only
|
|
1364
|
+
// function-scope (`!isModule`) consts whose value is NOT itself a
|
|
1365
|
+
// bare identifier (loop guard) are considered.
|
|
1366
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
|
|
1367
|
+
const localConst = this.localConstants.find(
|
|
1368
|
+
c => c.name === trimmed && !c.isModule,
|
|
1369
|
+
)
|
|
1370
|
+
if (localConst?.value !== undefined) {
|
|
1371
|
+
const initTrimmed = localConst.value.trim()
|
|
1372
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
|
|
1373
|
+
const resolved = conditionalSpreadToRuby(
|
|
1374
|
+
this.spreadCtx,
|
|
1375
|
+
parseExpression(initTrimmed),
|
|
1376
|
+
)
|
|
1377
|
+
if (resolved !== null) {
|
|
1378
|
+
return `<%= bf.spread_attrs(${resolved}) %>`
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const rubyExpr = this.convertExpressionToRuby(value.expr)
|
|
1384
|
+
return `<%= bf.spread_attrs(${rubyExpr}) %>`
|
|
1385
|
+
},
|
|
1386
|
+
// Neither variant is legal on intrinsic elements.
|
|
1387
|
+
emitBooleanShorthand: () => '',
|
|
1388
|
+
emitJsxChildren: () => '',
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Uniquifies the `presenceOrUndefined` temp binding (`__bf_puN`) so two
|
|
1393
|
+
* presence-folded attrs in one template don't collide.
|
|
1394
|
+
*/
|
|
1395
|
+
private presenceVarCounter = 0
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Lower a `style={{ … }}` object literal to a CSS string with dynamic
|
|
1399
|
+
* values interpolated as ERB tags, e.g. `{ backgroundColor: color,
|
|
1400
|
+
* padding: '8px' }` → `background-color:<%= bf.h(v[:color]) %>;padding:8px`.
|
|
1401
|
+
* Returns null when the shape is unsupported or any value can't be
|
|
1402
|
+
* lowered (caller then falls through to the BF101 refusal).
|
|
1403
|
+
*/
|
|
1404
|
+
private tryLowerStyleObject(expr: string): string | null {
|
|
1405
|
+
const entries = parseStyleObjectEntries(expr)
|
|
1406
|
+
if (!entries) return null
|
|
1407
|
+
for (const e of entries) {
|
|
1408
|
+
if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
|
|
1409
|
+
}
|
|
1410
|
+
// The static CSS key + literal value are inlined into a double-quoted
|
|
1411
|
+
// `style="..."` attribute as raw template text, so HTML-attr escape
|
|
1412
|
+
// them (a value like `'"'` would otherwise break the attribute /
|
|
1413
|
+
// inject markup). The dynamic arm's `<%= bf.h(...) %>` is explicitly
|
|
1414
|
+
// escaped (stdlib ERB doesn't auto-escape).
|
|
1415
|
+
return entries
|
|
1416
|
+
.map(e =>
|
|
1417
|
+
e.kind === 'literal'
|
|
1418
|
+
? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
|
|
1419
|
+
: `${this.escapeAttrText(e.cssKey)}:<%= bf.h(${this.convertExpressionToRuby(e.expr)}) %>`,
|
|
1420
|
+
)
|
|
1421
|
+
.join(';')
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** HTML-attribute escape for static text inlined into a `"..."` attribute. */
|
|
1425
|
+
private escapeAttrText(s: string): string {
|
|
1426
|
+
return s
|
|
1427
|
+
.replace(/&/g, '&')
|
|
1428
|
+
.replace(/"/g, '"')
|
|
1429
|
+
.replace(/'/g, ''')
|
|
1430
|
+
.replace(/</g, '<')
|
|
1431
|
+
.replace(/>/g, '>')
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private renderAttributes(element: IRElement): string {
|
|
1435
|
+
const parts: string[] = []
|
|
1436
|
+
|
|
1437
|
+
for (const attr of element.attrs) {
|
|
1438
|
+
// `/* @client */` attribute bindings are deferred to hydrate: the
|
|
1439
|
+
// client runtime sets/patches the attribute in a mount effect (the
|
|
1440
|
+
// CSR template omits it; ir-to-client-js emits the setAttribute
|
|
1441
|
+
// effect). Skip SSR emission so the server omits the attribute and
|
|
1442
|
+
// the unsupported-expression lowering is never reached for a
|
|
1443
|
+
// deferred predicate (no BF101 / BF102).
|
|
1444
|
+
if (attr.clientOnly) continue
|
|
1445
|
+
// Rewrite JSX special-prop names to their HTML-attribute
|
|
1446
|
+
// counterparts. `className` → `class`; `key` → `data-key` matches
|
|
1447
|
+
// the canonical Hono attribute name the client runtime reconciles
|
|
1448
|
+
// against. Hono SSR strips raw `key` via its JSX runtime; the ERB
|
|
1449
|
+
// template path has no such layer so the rewrite happens at
|
|
1450
|
+
// attribute-emit time.
|
|
1451
|
+
let attrName: string
|
|
1452
|
+
if (attr.name === 'className') attrName = 'class'
|
|
1453
|
+
else if (attr.name === 'key') attrName = 'data-key'
|
|
1454
|
+
else attrName = attr.name
|
|
1455
|
+
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
1456
|
+
if (lowered) parts.push(lowered)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ===========================================================================
|
|
1463
|
+
// Hydration Markers
|
|
1464
|
+
// ===========================================================================
|
|
1465
|
+
|
|
1466
|
+
renderScopeMarker(_instanceIdExpr: string): string {
|
|
1467
|
+
// bf-s is the addressable scope id. hydration_attrs adds bf-h / bf-m /
|
|
1468
|
+
// bf-r conditionally; props_attr adds bf-p when props are present.
|
|
1469
|
+
// These are all runtime-generated markup (raw helper output, not user
|
|
1470
|
+
// text), so no `bf.h` wrap — mirrors mojo's `<%==` raw-output tags.
|
|
1471
|
+
return `bf-s="<%= bf.scope_attr %>" <%= bf.hydration_attrs %> <%= bf.props_attr %>`
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
renderSlotMarker(slotId: string): string {
|
|
1475
|
+
return `${BF_SLOT}="${slotId}"`
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
renderCondMarker(condId: string): string {
|
|
1479
|
+
return `${BF_COND}="${condId}"`
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// ===========================================================================
|
|
1483
|
+
// Filter Predicate Rendering (ParsedExpr → Ruby)
|
|
1484
|
+
// ===========================================================================
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Convert a ParsedExpr AST to Ruby expression string for filter
|
|
1488
|
+
* predicates. Wraps the shared ParsedExpr dispatcher with an
|
|
1489
|
+
* `ErbFilterEmitter` carrying the predicate's loop param and any
|
|
1490
|
+
* block-body local var aliases.
|
|
1491
|
+
*/
|
|
1492
|
+
private renderRubyFilterExpr(
|
|
1493
|
+
expr: ParsedExpr,
|
|
1494
|
+
param: string,
|
|
1495
|
+
localVarMap: Map<string, string> = new Map(),
|
|
1496
|
+
): string {
|
|
1497
|
+
return emitParsedExpr(
|
|
1498
|
+
expr,
|
|
1499
|
+
new ErbFilterEmitter(
|
|
1500
|
+
param,
|
|
1501
|
+
localVarMap,
|
|
1502
|
+
n => this.isLoopBoundName(n),
|
|
1503
|
+
n => this._isStringValueName(n),
|
|
1504
|
+
(message, reason) => this._recordExprBF101(message, reason),
|
|
1505
|
+
),
|
|
1506
|
+
)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// ===========================================================================
|
|
1510
|
+
// Expression Conversion: JS → Ruby
|
|
1511
|
+
// ===========================================================================
|
|
1512
|
+
|
|
1513
|
+
private convertTemplateLiteralPartsToRuby(literalParts: IRTemplatePart[]): string {
|
|
1514
|
+
const parts: string[] = []
|
|
1515
|
+
for (const part of literalParts) {
|
|
1516
|
+
if (part.type === 'string') {
|
|
1517
|
+
// The IR producer may leave `${ident}` / `${_p.ident}`
|
|
1518
|
+
// interpolations in `string` parts when it can't statically inline
|
|
1519
|
+
// them (typically a destructured prop the caller will supply at
|
|
1520
|
+
// hydrate time, e.g. `${className}` in shadcn-style composition).
|
|
1521
|
+
// Substitute those to their Ruby variable form before quoting,
|
|
1522
|
+
// otherwise the single-quoted literal here passes the JS-shape
|
|
1523
|
+
// interpolation through verbatim into the rendered HTML.
|
|
1524
|
+
parts.push(this.substituteJsInterpolationsToRuby(part.value))
|
|
1525
|
+
} else if (part.type === 'ternary') {
|
|
1526
|
+
const cond = this.convertExpressionToRuby(part.condition)
|
|
1527
|
+
parts.push(`(bf.truthy?(${cond}) ? ${rubyStringLiteral(part.whenTrue)} : ${rubyStringLiteral(part.whenFalse)})`)
|
|
1528
|
+
} else if (part.type === 'lookup') {
|
|
1529
|
+
// `${MAP[KEY]}` against a Record<T, string> literal — emit a Ruby
|
|
1530
|
+
// Hash literal with an immediate symbol-key lookup, `|| ''`
|
|
1531
|
+
// guarded so a miss turns into an empty string, matching the
|
|
1532
|
+
// go-template adapter's "empty when no case matches" semantics.
|
|
1533
|
+
// Pass `key` through `convertExpressionToRuby` so its
|
|
1534
|
+
// top-level-identifier tail (`variant` → `v[:variant]`) and
|
|
1535
|
+
// existing `props.x` rule apply uniformly.
|
|
1536
|
+
const keyExpr = this.convertExpressionToRuby(part.key)
|
|
1537
|
+
const entries = Object.entries(part.cases)
|
|
1538
|
+
.map(([k, v]) => `${rubySymbolKey(k)} ${rubyStringLiteral(v)}`)
|
|
1539
|
+
.join(', ')
|
|
1540
|
+
parts.push(`({ ${entries} }[(${keyExpr}).to_sym] || '')`)
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// Join with Ruby string concatenation
|
|
1544
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' + ')})`
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Translate `${EXPR}` interpolations in a static template-part string
|
|
1549
|
+
* into Ruby variable references and concatenate them with the
|
|
1550
|
+
* surrounding literal text. Used by `convertTemplateLiteralPartsToRuby`
|
|
1551
|
+
* when a `string` part still carries unresolved interpolations (e.g.
|
|
1552
|
+
* `${className}` from a destructured prop the IR analyzer couldn't
|
|
1553
|
+
* inline statically).
|
|
1554
|
+
*/
|
|
1555
|
+
private substituteJsInterpolationsToRuby(s: string): string {
|
|
1556
|
+
const segments: string[] = []
|
|
1557
|
+
const re = /\$\{([^}]+)\}/g
|
|
1558
|
+
let lastIndex = 0
|
|
1559
|
+
let m: RegExpExecArray | null
|
|
1560
|
+
while ((m = re.exec(s)) !== null) {
|
|
1561
|
+
if (m.index > lastIndex) {
|
|
1562
|
+
segments.push(rubyStringLiteral(s.slice(lastIndex, m.index)))
|
|
1563
|
+
}
|
|
1564
|
+
segments.push(`bf.string(${this.convertExpressionToRuby(m[1].trim())})`)
|
|
1565
|
+
lastIndex = re.lastIndex
|
|
1566
|
+
}
|
|
1567
|
+
if (lastIndex < s.length) {
|
|
1568
|
+
segments.push(rubyStringLiteral(s.slice(lastIndex)))
|
|
1569
|
+
}
|
|
1570
|
+
if (segments.length === 0) return `''`
|
|
1571
|
+
return segments.length === 1 ? segments[0] : `(${segments.join(' + ')})`
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Refuse JS expression shapes that have no idiomatic ERB template
|
|
1576
|
+
* representation. Currently catches:
|
|
1577
|
+
*
|
|
1578
|
+
* - Object literals (`style={{ background: bg(), color: fg() }}`):
|
|
1579
|
+
* the regex pipeline strips signal calls but leaves the surrounding
|
|
1580
|
+
* `{ k: v, ... }` syntax intact, producing invalid Ruby inside
|
|
1581
|
+
* `<%= ... %>`.
|
|
1582
|
+
* - Tagged-template-literal call expressions
|
|
1583
|
+
* (`className={cn\`base \${tone()}\`}`): regex translation
|
|
1584
|
+
* produces malformed Ruby with no callable target.
|
|
1585
|
+
*
|
|
1586
|
+
* Records `BF101` with the same shape the Go/Mojo adapters emit, so
|
|
1587
|
+
* cross-adapter diagnostics stay consistent. Returns `true` when the
|
|
1588
|
+
* shape was rejected (caller should drop the attribute / skip the emit).
|
|
1589
|
+
*/
|
|
1590
|
+
private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
|
|
1591
|
+
let probe = expr.trim()
|
|
1592
|
+
while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
|
|
1593
|
+
const startsAsObjectLiteral = probe.startsWith('{')
|
|
1594
|
+
const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
|
|
1595
|
+
if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
|
|
1596
|
+
const parsed = parseExpression(expr.trim())
|
|
1597
|
+
const support = isSupported(parsed)
|
|
1598
|
+
if (parsed.kind !== 'unsupported' && support.supported) return false
|
|
1599
|
+
const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
|
|
1600
|
+
const reasonLine = reason ? `\n${reason}` : ''
|
|
1601
|
+
this.errors.push({
|
|
1602
|
+
code: 'BF101',
|
|
1603
|
+
severity: 'error',
|
|
1604
|
+
message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
|
|
1605
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1606
|
+
suggestion: {
|
|
1607
|
+
message: 'The ERB adapter cannot lower JS object literals or tagged-template-literal expressions into Embedded Ruby. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
|
|
1608
|
+
},
|
|
1609
|
+
})
|
|
1610
|
+
return true
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
|
|
1615
|
+
* Built as a private object (the adapter does NOT `implements ErbEmitContext`)
|
|
1616
|
+
* so the wrapped bookkeeping — `_searchParamsLocals`, the const/record
|
|
1617
|
+
* resolvers, the loop-bound-name predicate, BF101 recording, the
|
|
1618
|
+
* filter-predicate entry — stays private and off the exported adapter's
|
|
1619
|
+
* public type, matching the Mojo/Go adapters' `emitCtx`.
|
|
1620
|
+
*/
|
|
1621
|
+
private get emitCtx(): ErbEmitContext {
|
|
1622
|
+
return {
|
|
1623
|
+
_searchParamsLocals: this._searchParamsLocals,
|
|
1624
|
+
resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
|
|
1625
|
+
resolveLiteralConst: (name) => this.resolveLiteralConst(name),
|
|
1626
|
+
resolveStaticRecordLiteral: (o, k) => this.resolveStaticRecordLiteral(o, k),
|
|
1627
|
+
isLoopBoundName: (name) => this.isLoopBoundName(name),
|
|
1628
|
+
_isStringValueName: (name) => this._isStringValueName(name),
|
|
1629
|
+
_recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
|
|
1630
|
+
_renderRubyFilterExprPublic: (e, p) => this._renderRubyFilterExprPublic(e, p),
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Build the narrow context the extracted spread lowering depends on.
|
|
1636
|
+
* Passing a purpose-built object (rather than `this`) keeps the
|
|
1637
|
+
* adapter's bookkeeping members private.
|
|
1638
|
+
*/
|
|
1639
|
+
private get spreadCtx(): ErbSpreadContext {
|
|
1640
|
+
return {
|
|
1641
|
+
componentName: this.componentName,
|
|
1642
|
+
errors: this.errors,
|
|
1643
|
+
localConstants: this.localConstants,
|
|
1644
|
+
propsParams: this.propsParams,
|
|
1645
|
+
convertExpressionToRuby: (e, preParsed) => this.convertExpressionToRuby(e, preParsed),
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Build the narrow context the extracted memo seeding depends on. */
|
|
1650
|
+
private get memoCtx(): ErbMemoContext {
|
|
1651
|
+
return { convertExpressionToRuby: (e, preParsed) => this.convertExpressionToRuby(e, preParsed) }
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
private convertExpressionToRuby(expr: string, preParsed?: ParsedExpr): string {
|
|
1655
|
+
// Parse-first lowering — parity with the Go adapter's
|
|
1656
|
+
// `convertExpressionToGo`. Parse the JS expression once, gate it on
|
|
1657
|
+
// the shared `isSupported`, and render every supported shape through
|
|
1658
|
+
// the AST emitter (`renderParsedExprToRuby`). The parser's
|
|
1659
|
+
// `UNSUPPORTED_METHODS` is the single source of truth for what's
|
|
1660
|
+
// refused — there are no per-method routing regexes and no regex
|
|
1661
|
+
// string-rewriting pipeline. Unsupported shapes (un-lowered methods,
|
|
1662
|
+
// unparseable hand-written JS, etc.) surface as BF101 with the
|
|
1663
|
+
// `/* @client */` escape hatch instead of being silently mangled.
|
|
1664
|
+
//
|
|
1665
|
+
// `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
|
|
1666
|
+
// `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
|
|
1667
|
+
// used directly instead of re-parsing `expr`, so spread condition/value
|
|
1668
|
+
// lowering threads the carried tree through without a stringify→re-parse
|
|
1669
|
+
// round-trip. The diagnostic text is then derived from the tree
|
|
1670
|
+
// (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
|
|
1671
|
+
let parsed: ParsedExpr
|
|
1672
|
+
if (preParsed) {
|
|
1673
|
+
parsed = preParsed
|
|
1674
|
+
} else {
|
|
1675
|
+
const trimmed = expr.trim()
|
|
1676
|
+
if (trimmed === '') return "''"
|
|
1677
|
+
parsed = parseExpression(trimmed)
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Registered call lowerings, including the built-in `queryHref` plugin,
|
|
1681
|
+
// which lowers `queryHref(base, { … })` to a neutral `guard-list` on
|
|
1682
|
+
// the `query` helper → `bf.query(base, <triples>)`. Recognised before
|
|
1683
|
+
// the support gate because the object-literal arg is otherwise
|
|
1684
|
+
// `unsupported` (BF101). The `bf.query` helper includes a pair iff its
|
|
1685
|
+
// guard is truthy AND its value is a non-empty string (the client's
|
|
1686
|
+
// `if (value)`): a plain `key: v` passes guard `true`, a conditional
|
|
1687
|
+
// `key: cond ? v : undefined` passes the lowered cond. Only the
|
|
1688
|
+
// `query` helper renders to `bf.query`; another guard-list helper must
|
|
1689
|
+
// not be silently mis-rendered as a query.
|
|
1690
|
+
if (parsed.kind === 'call') {
|
|
1691
|
+
for (const matcher of this._loweringMatchers) {
|
|
1692
|
+
const node = matcher(parsed.callee, parsed.args)
|
|
1693
|
+
if (node?.kind === 'guard-list' && node.helper === 'query') {
|
|
1694
|
+
const argsRuby = queryHrefArgs(node, n => this.renderParsedExprToRuby(n))
|
|
1695
|
+
return `bf.query(${argsRuby.join(', ')})`
|
|
1696
|
+
}
|
|
1697
|
+
// Generic `helper-call` (#2069) — the neutral vocabulary's escape
|
|
1698
|
+
// hatch for a userland `LoweringPlugin` that lowers to a single
|
|
1699
|
+
// runtime-helper invocation. `bf.<helper>(args…)` mirrors the
|
|
1700
|
+
// `query` helper's own naming convention exactly: the framework
|
|
1701
|
+
// renders the call, the plugin author registers `<helper>` as a
|
|
1702
|
+
// Ruby-callable method on their own `bf` helper object — same
|
|
1703
|
+
// contract as `bf.query` itself, just not built in.
|
|
1704
|
+
if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
|
|
1705
|
+
const argsX = node.args.map(a => this.renderParsedExprToRuby(a))
|
|
1706
|
+
return `bf.${node.helper}(${argsX.join(', ')})`
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const support = isSupported(parsed)
|
|
1712
|
+
if (!support.supported) {
|
|
1713
|
+
this.errors.push({
|
|
1714
|
+
code: 'BF101',
|
|
1715
|
+
severity: 'error',
|
|
1716
|
+
message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
|
|
1717
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1718
|
+
suggestion: {
|
|
1719
|
+
message: support.reason
|
|
1720
|
+
? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby`
|
|
1721
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby',
|
|
1722
|
+
},
|
|
1723
|
+
})
|
|
1724
|
+
// Safe Ruby empty-string literal — valid in every context the
|
|
1725
|
+
// result might land in (`<%= '' %>`, `if bf.truthy?('')`, attribute
|
|
1726
|
+
// interpolation, template-literal substitution).
|
|
1727
|
+
return "''"
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
return this.renderParsedExprToRuby(parsed)
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Render a full ParsedExpr tree to Ruby for top-level (non-filter)
|
|
1735
|
+
* expressions where identifiers are signals / vars-Hash entries.
|
|
1736
|
+
* Delegates to the shared ParsedExpr dispatcher with `ErbTopLevelEmitter`.
|
|
1737
|
+
*/
|
|
1738
|
+
private renderParsedExprToRuby(expr: ParsedExpr): string {
|
|
1739
|
+
return emitParsedExpr(expr, new ErbTopLevelEmitter(this.emitCtx))
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** Whether `name` (a signal getter or prop) holds a string value — gates
|
|
1743
|
+
* index-access Hash-vs-Array lowering (see `expr/operand.ts`). */
|
|
1744
|
+
private _isStringValueName(name: string): boolean {
|
|
1745
|
+
return this.stringValueNames.has(name)
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private _recordExprBF101(message: string, reason?: string): void {
|
|
1749
|
+
this.errors.push({
|
|
1750
|
+
code: 'BF101',
|
|
1751
|
+
severity: 'error',
|
|
1752
|
+
message,
|
|
1753
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1754
|
+
suggestion: {
|
|
1755
|
+
message: reason
|
|
1756
|
+
? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby`
|
|
1757
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby',
|
|
1758
|
+
},
|
|
1759
|
+
})
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** Internal hook for higher-order: predicate body re-uses the filter emitter. */
|
|
1763
|
+
private _renderRubyFilterExprPublic(expr: ParsedExpr, param: string): string {
|
|
1764
|
+
return this.renderRubyFilterExpr(expr, param)
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
export const erbAdapter = new ErbAdapter()
|