@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.
Files changed (68) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +24 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/boolean-result.d.ts +66 -0
  4. package/dist/adapter/boolean-result.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +107 -0
  6. package/dist/adapter/emit-context.d.ts.map +1 -0
  7. package/dist/adapter/erb-adapter.d.ts +374 -0
  8. package/dist/adapter/erb-adapter.d.ts.map +1 -0
  9. package/dist/adapter/expr/array-method.d.ts +87 -0
  10. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  11. package/dist/adapter/expr/emitters.d.ts +102 -0
  12. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  13. package/dist/adapter/expr/operand.d.ts +34 -0
  14. package/dist/adapter/expr/operand.d.ts.map +1 -0
  15. package/dist/adapter/index.d.ts +6 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +189154 -0
  18. package/dist/adapter/lib/constants.d.ts +25 -0
  19. package/dist/adapter/lib/constants.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +27 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/ruby-naming.d.ts +65 -0
  23. package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
  24. package/dist/adapter/lib/types.d.ts +28 -0
  25. package/dist/adapter/lib/types.d.ts.map +1 -0
  26. package/dist/adapter/memo/seed.d.ts +35 -0
  27. package/dist/adapter/memo/seed.d.ts.map +1 -0
  28. package/dist/adapter/props/prop-classes.d.ts +50 -0
  29. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  32. package/dist/adapter/value/parsed-literal.d.ts +21 -0
  33. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  34. package/dist/build.d.ts +28 -0
  35. package/dist/build.d.ts.map +1 -0
  36. package/dist/build.js +189174 -0
  37. package/dist/conformance-pins.d.ts +13 -0
  38. package/dist/conformance-pins.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +189172 -0
  42. package/lib/barefoot_js/backend/erb.rb +123 -0
  43. package/lib/barefoot_js/dev_reload.rb +159 -0
  44. package/lib/barefoot_js/evaluator.rb +714 -0
  45. package/lib/barefoot_js/search_params.rb +63 -0
  46. package/lib/barefoot_js.rb +1155 -0
  47. package/package.json +67 -0
  48. package/src/__tests__/erb-adapter.test.ts +298 -0
  49. package/src/adapter/analysis/component-tree.ts +122 -0
  50. package/src/adapter/boolean-result.ts +157 -0
  51. package/src/adapter/emit-context.ts +119 -0
  52. package/src/adapter/erb-adapter.ts +1768 -0
  53. package/src/adapter/expr/array-method.ts +424 -0
  54. package/src/adapter/expr/emitters.ts +629 -0
  55. package/src/adapter/expr/operand.ts +55 -0
  56. package/src/adapter/index.ts +6 -0
  57. package/src/adapter/lib/constants.ts +37 -0
  58. package/src/adapter/lib/ir-scope.ts +51 -0
  59. package/src/adapter/lib/ruby-naming.ts +127 -0
  60. package/src/adapter/lib/types.ts +31 -0
  61. package/src/adapter/memo/seed.ts +75 -0
  62. package/src/adapter/props/prop-classes.ts +89 -0
  63. package/src/adapter/spread/spread-codegen.ts +176 -0
  64. package/src/adapter/value/parsed-literal.ts +29 -0
  65. package/src/build.ts +37 -0
  66. package/src/conformance-pins.ts +100 -0
  67. package/src/index.ts +9 -0
  68. package/src/test-render.ts +668 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Array / string method lowering for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `expr/array-method.ts` (issue #2018
5
+ * track D lineage). Pure free functions shared by both the filter-context
6
+ * emitter and the top-level emitter — they take an `emit` callback for
7
+ * receiver / argument recursion and read no adapter instance state.
8
+ *
9
+ * Every `bf.*` helper name below is kept 1:1 with the Perl (`BarefootJS.pm`)
10
+ * / Go runtime surface — the coordination contract between this TS emitter
11
+ * and the Ruby runtime (`lib/barefoot_js.rb`).
12
+ */
13
+
14
+ import {
15
+ serializeParsedExpr,
16
+ freeVarsInBody,
17
+ } from '@barefootjs/jsx'
18
+ import type {
19
+ ParsedExpr,
20
+ ArrayMethod,
21
+ SortComparator,
22
+ FlatDepth,
23
+ } from '@barefootjs/jsx'
24
+ import { rubySymbolKey, escapeRubySingleQuoted } from '../lib/ruby-naming.ts'
25
+
26
+ /**
27
+ * Lower an `arr.<method>(...)` / `str.<method>(...)` value-builtin call to
28
+ * its Ruby form. The IR lifts these into the dedicated `array-method` kind at
29
+ * parse time (see the `arrayMethod` emitter arms), so this is the single
30
+ * place every adapter-supported array/string method is mapped. An unhandled
31
+ * `ArrayMethod` variant throws rather than emitting a silent no-op — the
32
+ * drift defence we already apply to `ParsedExpr.kind` extended to its
33
+ * sub-discriminator.
34
+ */
35
+ export function renderArrayMethod(
36
+ method: ArrayMethod,
37
+ object: ParsedExpr,
38
+ args: ParsedExpr[],
39
+ emit: (e: ParsedExpr) => string,
40
+ ): string {
41
+ switch (method) {
42
+ case 'join': {
43
+ // arr.join(sep) → bf.join(arr, sep). `.join()` defaults the separator
44
+ // to `,` (JS) and ignores any extra argument.
45
+ const obj = emit(object)
46
+ const sep = args.length >= 1 ? emit(args[0]) : `','`
47
+ return `bf.join(${obj}, ${sep})`
48
+ }
49
+ case 'includes': {
50
+ // Both `arr.includes(x)` and `str.includes(sub)` route here — the
51
+ // parser can't disambiguate the receiver type. The Ruby runtime's
52
+ // `bf.includes(recv, elem)` inspects the receiver's class and
53
+ // dispatches: Array scans the list with `==`, String falls back to
54
+ // substring search. Helper lives in lib/barefoot_js.rb.
55
+ const obj = emit(object)
56
+ const needle = emit(args[0])
57
+ return `bf.includes(${obj}, ${needle})`
58
+ }
59
+ case 'indexOf':
60
+ case 'lastIndexOf': {
61
+ // Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality search.
62
+ const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
63
+ const obj = emit(object)
64
+ const needle = emit(args[0])
65
+ return `bf.${fn}(${obj}, ${needle})`
66
+ }
67
+ case 'at': {
68
+ // `.at(i)` with negative-index support — `.at(-1)` is the last
69
+ // element. `.at()` with no argument is `.at(0)` (the first element);
70
+ // extra arguments are ignored.
71
+ const obj = emit(object)
72
+ const idx = args.length >= 1 ? emit(args[0]) : '0'
73
+ return `bf.at(${obj}, ${idx})`
74
+ }
75
+ case 'concat': {
76
+ // `.concat(other)` merges two arrays. Returns a new Array so the
77
+ // result composes with `.join(...)` / other array-shape methods
78
+ // downstream. `.concat()` with no argument is a shallow copy —
79
+ // indistinguishable from the receiver in an SSR snapshot, so it
80
+ // lowers to the receiver.
81
+ if (args.length === 0) {
82
+ return emit(object)
83
+ }
84
+ const a = emit(object)
85
+ const b = emit(args[0])
86
+ return `bf.concat(${a}, ${b})`
87
+ }
88
+ case 'slice': {
89
+ // `.slice()` / `.slice(start)` / `.slice(start, end)`. The Ruby
90
+ // helper mirrors the Go arithmetic (negative-index normalisation,
91
+ // out-of-bounds clamping, empty result on start >= end). A missing
92
+ // `start` defaults to 0 (full copy); an absent `end` lowers as `nil`,
93
+ // which the helper treats as "to length". JS ignores a third+
94
+ // argument. Returns a new Array so the result composes with
95
+ // `.join(...)` downstream.
96
+ const recv = emit(object)
97
+ const start = args.length >= 1 ? emit(args[0]) : '0'
98
+ const end = args.length >= 2 ? emit(args[1]) : 'nil'
99
+ return `bf.slice(${recv}, ${start}, ${end})`
100
+ }
101
+ case 'reverse':
102
+ case 'toReversed': {
103
+ // Both shapes share a lowering — see the parser arm + Go emit for the
104
+ // SSR-mutation rationale. Returns a new Array so the result composes
105
+ // with `.join(...)` downstream.
106
+ const recv = emit(object)
107
+ return `bf.reverse(${recv})`
108
+ }
109
+ case 'toLowerCase': {
110
+ // Ruby's native `.downcase` is the obvious lowering — no helper
111
+ // method needed. The receiver flows through `emit` so any upstream
112
+ // coercion composes naturally.
113
+ const recv = emit(object)
114
+ return `(${recv}).downcase`
115
+ }
116
+ case 'toUpperCase': {
117
+ // Ruby's native `.upcase` — mirrors `toLowerCase` exactly.
118
+ const recv = emit(object)
119
+ return `(${recv}).upcase`
120
+ }
121
+ case 'trim': {
122
+ // No JS-parity-guaranteed native for leading/trailing whitespace
123
+ // (Ruby `.strip` strips a slightly different whitespace class);
124
+ // route through `bf.trim` so the definition stays in one place.
125
+ const recv = emit(object)
126
+ return `bf.trim(${recv})`
127
+ }
128
+ case 'toFixed': {
129
+ // `.toFixed(digits?)` — Number → fixed-decimal string. `bf.to_fixed`
130
+ // mirrors JS rounding + zero-padding (default 0 digits).
131
+ const recv = emit(object)
132
+ const digits = args.length >= 1 ? emit(args[0]) : '0'
133
+ return `bf.to_fixed(${recv}, ${digits})`
134
+ }
135
+ case 'split': {
136
+ // `.split()` / `.split(sep)` / `.split(sep, limit)` — string → Array
137
+ // via `bf.split`. With no separator the helper returns the whole
138
+ // string as a single element; otherwise it matches the separator
139
+ // literally (not as a regex) and keeps trailing empties, staying
140
+ // byte-equal with Go's `bf_split`. The optional `limit` caps the
141
+ // pieces; JS ignores a third+ argument.
142
+ const recv = emit(object)
143
+ if (args.length === 0) {
144
+ return `bf.split(${recv})`
145
+ }
146
+ const sep = emit(args[0])
147
+ if (args.length === 1) {
148
+ return `bf.split(${recv}, ${sep})`
149
+ }
150
+ const limit = emit(args[1])
151
+ return `bf.split(${recv}, ${sep}, ${limit})`
152
+ }
153
+ case 'startsWith':
154
+ case 'endsWith': {
155
+ // `.startsWith(prefix, position?)` / `.endsWith(suffix, endPosition?)`
156
+ // — string → boolean. The Ruby helpers (`bf.starts_with` /
157
+ // `bf.ends_with`) do an index-anchored comparison so the search
158
+ // string is matched literally (no regex metachar surprises) and a
159
+ // nil receiver stays quiet. The optional second argument re-anchors
160
+ // the test; JS ignores a third+ argument.
161
+ const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
162
+ const recv = emit(object)
163
+ const arg = emit(args[0])
164
+ if (args.length >= 2) {
165
+ return `bf.${fn}(${recv}, ${arg}, ${emit(args[1])})`
166
+ }
167
+ return `bf.${fn}(${recv}, ${arg})`
168
+ }
169
+ case 'replace': {
170
+ // `.replace(old, new)` — string-pattern form, first occurrence. The
171
+ // `bf.replace` helper splices via index/substring (not a Regexp) so
172
+ // both the pattern and the replacement are literal — no Ruby regex
173
+ // metacharacters and no `\1` / `\&` interpolation in the
174
+ // replacement, keeping it byte-equal with Go's `bf_replace`. The
175
+ // regex-pattern form is refused upstream at the parser.
176
+ const recv = emit(object)
177
+ const oldS = emit(args[0])
178
+ const newS = emit(args[1])
179
+ return `bf.replace(${recv}, ${oldS}, ${newS})`
180
+ }
181
+ case 'repeat': {
182
+ // `.repeat(n)` — string repeated `n` times. The `bf.repeat` helper
183
+ // wraps Ruby's `*` string-repeat operator with the same
184
+ // negative-count → "" clamp and integer truncation Go's `bf_repeat`
185
+ // applies, so the two adapters stay byte-equal. Full JS arity: the
186
+ // no-argument form is `repeat(0)` → ""; a second+ argument is
187
+ // ignored.
188
+ const recv = emit(object)
189
+ const count = args.length === 0 ? '0' : emit(args[0])
190
+ return `bf.repeat(${recv}, ${count})`
191
+ }
192
+ case 'padStart':
193
+ case 'padEnd': {
194
+ // `.padStart(target, pad?)` / `.padEnd(target, pad?)`. The
195
+ // `bf.pad_*` helpers default the pad to a single space when the arg
196
+ // is omitted and measure length in characters, matching Go's
197
+ // rune-based `bf_pad_*`. Full JS arity: the no-argument form is
198
+ // `padStart(0)` → the receiver unchanged; a third+ argument is
199
+ // ignored.
200
+ const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
201
+ const recv = emit(object)
202
+ if (args.length === 0) {
203
+ return `bf.${fn}(${recv}, 0)`
204
+ }
205
+ const target = emit(args[0])
206
+ if (args.length === 1) {
207
+ return `bf.${fn}(${recv}, ${target})`
208
+ }
209
+ const pad = emit(args[1])
210
+ return `bf.${fn}(${recv}, ${target}, ${pad})`
211
+ }
212
+ default: {
213
+ // TS-level exhaustiveness guard. If this throws at runtime, the
214
+ // IR was constructed against a newer `ArrayMethod` variant that
215
+ // this adapter hasn't been updated for — loud failure is better
216
+ // than emitting a silent empty string downstream.
217
+ const _exhaustive: never = method
218
+ throw new Error(
219
+ `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
220
+ )
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Build the `base_env` Hash-literal argument for an evaluator call: the
227
+ * comparator / reducer body's free variables (body idents minus the
228
+ * callback params), each materialised to its SSR value via `emit`, under a
229
+ * SYMBOL key (the Ruby evaluator's env is symbol-keyed — see
230
+ * `Evaluator.evaluate`'s `identifier` arm). An empty capture set yields
231
+ * `{}` — the runtime's seed-once env.
232
+ */
233
+ function emitEvalEnvArg(
234
+ body: ParsedExpr,
235
+ params: string[],
236
+ emit: (e: ParsedExpr) => string,
237
+ ): string {
238
+ const free = freeVarsInBody(body, new Set(params))
239
+ if (free.length === 0) return '{}'
240
+ const pairs = free.map(
241
+ n => `${rubySymbolKey(n)} ${emit({ kind: 'identifier', name: n })}`,
242
+ )
243
+ return `{ ${pairs.join(', ')} }`
244
+ }
245
+
246
+ /**
247
+ * Emit a `.sort(cmp)` / `.toSorted(cmp)` via the runtime evaluator: the
248
+ * comparator body travels as serialized-ParsedExpr JSON, evaluated per
249
+ * comparison against `{paramA, paramB, …captured}`. Returns null when the
250
+ * body can't be evaluated (e.g. a `localeCompare` comparator —
251
+ * `serializeParsedExpr` refuses it), so the caller falls back to the
252
+ * structured `bf.sort`. A `||`-chained multi-key comparator needs no
253
+ * special handling — JS `0 || next` is exactly the tie-break semantics.
254
+ */
255
+ export function renderSortEval(
256
+ recv: string,
257
+ body: ParsedExpr,
258
+ params: string[],
259
+ emit: (e: ParsedExpr) => string,
260
+ ): string | null {
261
+ if (params.length < 2) return null
262
+ const [paramA, paramB] = params
263
+ const json = serializeParsedExpr(body)
264
+ if (json === null) return null
265
+ const env = emitEvalEnvArg(body, [paramA, paramB], emit)
266
+ return `bf.sort_eval(${recv}, '${escapeRubySingleQuoted(json)}', '${paramA}', '${paramB}', ${env})`
267
+ }
268
+
269
+ /**
270
+ * Emit a `.reduce(fn, init)` / `.reduceRight(fn, init)` via the runtime
271
+ * evaluator: the reducer body travels as serialized-ParsedExpr JSON, folded
272
+ * over the receiver from `init` in `direction` order. Returns null when the
273
+ * body can't be evaluated (→ caller falls back to BF101). A numeric seed
274
+ * passes through as a bare Ruby number; a concat seed as a single-quoted
275
+ * string.
276
+ */
277
+ export function renderReduceEval(
278
+ recv: string,
279
+ body: ParsedExpr,
280
+ params: string[],
281
+ init: ParsedExpr,
282
+ direction: 'left' | 'right',
283
+ emit: (e: ParsedExpr) => string,
284
+ ): string | null {
285
+ if (params.length < 2) return null
286
+ const [paramAcc, paramItem] = params
287
+ const json = serializeParsedExpr(body)
288
+ if (json === null) return null
289
+ // Only literal seeds round-trip to a Ruby scalar here: a string literal
290
+ // as a single-quoted Ruby string, a number literal as a bare numeric.
291
+ // Anything else (an expression seed, an omitted seed) can't be
292
+ // materialised → null, and the caller records BF101.
293
+ let initRuby: string
294
+ if (init.kind === 'literal' && init.literalType === 'string') {
295
+ initRuby = `'${escapeRubySingleQuoted(String(init.value))}'`
296
+ } else if (init.kind === 'literal' && init.literalType === 'number') {
297
+ initRuby = String(init.value)
298
+ } else {
299
+ return null
300
+ }
301
+ const env = emitEvalEnvArg(body, [paramAcc, paramItem], emit)
302
+ return `bf.reduce_eval(${recv}, '${escapeRubySingleQuoted(json)}', '${paramAcc}', '${paramItem}', ${initRuby}, '${direction}', ${env})`
303
+ }
304
+
305
+ /**
306
+ * Emit a higher-order predicate call via the runtime evaluator:
307
+ * `bf.filter_eval` / `bf.every_eval` / `bf.some_eval` / `bf.find_eval` /
308
+ * `bf.find_index_eval`, carrying the serialized predicate body + captured
309
+ * env Hash. Generalizes the inline predicate lowering to the same
310
+ * JS-faithful evaluator the Go adapter uses (cross-adapter isomorphism).
311
+ * Returns null when the predicate is outside the evaluator surface (e.g. a
312
+ * method-call predicate — `serializeParsedExpr` refuses it), so the caller
313
+ * falls back to the inline form. `forward` (find / findIndex family only)
314
+ * selects the search direction — `false` = findLast / findLastIndex.
315
+ */
316
+ export function renderPredicateEval(
317
+ funcName: string,
318
+ recv: string,
319
+ predicate: ParsedExpr,
320
+ param: string,
321
+ emit: (e: ParsedExpr) => string,
322
+ forward?: boolean,
323
+ ): string | null {
324
+ const json = serializeParsedExpr(predicate)
325
+ if (json === null) return null
326
+ const env = emitEvalEnvArg(predicate, [param], emit)
327
+ const fwd = forward === undefined ? '' : `, ${forward ? 'true' : 'false'}`
328
+ return `bf.${funcName}(${recv}, '${escapeRubySingleQuoted(json)}', '${param}'${fwd}, ${env})`
329
+ }
330
+
331
+ /**
332
+ * Emit a `.flatMap(proj)` via the runtime evaluator: the projection `body`
333
+ * serializes to JSON and `bf.flat_map_eval` projects + flattens one level.
334
+ * Returns null when the projection is outside the evaluator surface, and
335
+ * the caller records BF101.
336
+ */
337
+ export function renderFlatMapEval(
338
+ recv: string,
339
+ body: ParsedExpr,
340
+ param: string,
341
+ emit: (e: ParsedExpr) => string,
342
+ ): string | null {
343
+ const json = serializeParsedExpr(body)
344
+ if (json === null) return null
345
+ const env = emitEvalEnvArg(body, [param], emit)
346
+ return `bf.flat_map_eval(${recv}, '${escapeRubySingleQuoted(json)}', '${param}', ${env})`
347
+ }
348
+
349
+ /**
350
+ * Emit a value-producing `.map(cb)` via the runtime evaluator: the
351
+ * projection `body` serializes to JSON and `bf.map_eval` projects each
352
+ * element, one result per element (no flatten — the JS `.map` contract).
353
+ * Composes through the array-method chain (`.map(cb).join(' ')`). Returns
354
+ * null when the projection is outside the evaluator surface, and the caller
355
+ * records BF101. The JSX-returning `.map` is an IRLoop upstream and never
356
+ * reaches this emit.
357
+ */
358
+ export function renderMapEval(
359
+ recv: string,
360
+ body: ParsedExpr,
361
+ param: string,
362
+ emit: (e: ParsedExpr) => string,
363
+ ): string | null {
364
+ const json = serializeParsedExpr(body)
365
+ if (json === null) return null
366
+ const env = emitEvalEnvArg(body, [param], emit)
367
+ return `bf.map_eval(${recv}, '${escapeRubySingleQuoted(json)}', '${param}', ${env})`
368
+ }
369
+
370
+ /**
371
+ * Shared ERB emit for `.sort(cmp)` / `.toSorted(cmp)`. Used by both the
372
+ * filter-context emitter and the top-level emitter, plus the loop-hoist path
373
+ * in `renderLoop` — same emit shape across all three so a regression in any
374
+ * one path surfaces consistently.
375
+ *
376
+ * The Ruby helper accepts an opts Hash whose `keys:` entry is an ordered
377
+ * list of per-key Hashes (room for a future `nulls` knob without arity
378
+ * churn), and returns a fresh Array so downstream composition
379
+ * (`bf.sort(...).join(...)`, etc.) stays straightforward.
380
+ */
381
+ export function renderSortMethod(recv: string, c: SortComparator): string {
382
+ // One Hash per comparison key, in priority order, under `keys:`. A
383
+ // simple comparator yields a one-element list; a `||`-chained multi-key
384
+ // comparator yields one per operand. `bf.sort` walks them in order,
385
+ // falling through to the next on a tie.
386
+ const keyHashes = c.keys.map((k) => {
387
+ const keyEntry =
388
+ k.key.kind === 'self'
389
+ ? `key_kind: 'self'`
390
+ : `key_kind: 'field', key: '${k.key.field}'`
391
+ return `{ ${keyEntry}, compare_type: '${k.type}', direction: '${k.direction}' }`
392
+ })
393
+ return `bf.sort(${recv}, { keys: [${keyHashes.join(', ')}] })`
394
+ }
395
+
396
+ // `.flat(depth?)` → `bf.flat(recv, depth)`. The `Infinity` form lowers
397
+ // to the `-1` sentinel (flatten fully); a finite depth flattens that many
398
+ // levels (`0` = shallow copy).
399
+ export function renderFlatMethod(
400
+ recv: string,
401
+ depth: FlatDepth | { expr: ParsedExpr },
402
+ emit: (e: ParsedExpr) => string,
403
+ ): string {
404
+ if (typeof depth === 'object') {
405
+ // Dynamic depth (#2094): routed to a SEPARATE runtime helper
406
+ // (`bf.flat_dynamic`), not `bf.flat` — `bf.flat`'s `depth` parameter
407
+ // treats `-1` as a compile-time SENTINEL meaning "the source literally
408
+ // wrote `Infinity`" (the parser's own normalisation of a literal
409
+ // depth). A genuinely dynamic depth value that happens to evaluate to
410
+ // `-1` at render time means the JS-correct OPPOSITE: `.flat(-1)` never
411
+ // recurses (same as `.flat(0)`, a shallow copy). Since both paths would
412
+ // otherwise hand the same literal-looking argument to one shared
413
+ // function, that function couldn't tell which case it's in — so
414
+ // `bf.flat_dynamic` coerces the raw value via JS `ToIntegerOrInfinity`
415
+ // FIRST (truncate toward zero; negative → 0; NaN/non-numeric → 0;
416
+ // +Infinity or a huge finite value → flatten fully) and only then
417
+ // delegates to the same recursion `bf.flat` uses. Mirrors the Go
418
+ // adapter's `bf_flat_dynamic` (go-template-adapter.ts) and runtime
419
+ // `FlatDynamicDepth`/`coerceFlatDepth` (adapter-go-template/runtime/bf.go).
420
+ return `bf.flat_dynamic(${recv}, ${emit(depth.expr)})`
421
+ }
422
+ const d = depth === 'infinity' ? -1 : depth
423
+ return `bf.flat(${recv}, ${d})`
424
+ }