@barefootjs/erb 0.31.3 → 0.31.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/erb",
3
- "version": "0.31.3",
3
+ "version": "0.31.5",
4
4
  "description": "ERB (Embedded Ruby) adapter for BarefootJS — compiles IR to .erb templates and ships the Ruby rendering backend; runs under any Rack app (Sinatra, Rails)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,7 +54,7 @@
54
54
  "directory": "packages/adapter-erb"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.31.3"
57
+ "@barefootjs/shared": "0.31.5"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@barefootjs/jsx": ">=0.2.0",
@@ -71,9 +71,9 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@barefootjs/adapter-tests": "0.1.0",
74
- "@barefootjs/jsx": "0.31.3",
75
- "@barefootjs/vite": "0.31.3",
76
- "@barefootjs/client": "0.31.3",
74
+ "@barefootjs/jsx": "0.31.5",
75
+ "@barefootjs/vite": "0.31.5",
76
+ "@barefootjs/client": "0.31.5",
77
77
  "typescript": "^5.0.0",
78
78
  "vite": "^6.0.0"
79
79
  }
@@ -94,6 +94,7 @@ import {
94
94
  dangerousInnerHtmlDiagnostic,
95
95
  resolveStaticLoopSource,
96
96
  derivesScopeFromSlot,
97
+ BindingScope,
97
98
  } from '@barefootjs/jsx'
98
99
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
99
100
  import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
@@ -248,17 +249,23 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
248
249
  */
249
250
  private localConstants: IRMetadata['localConstants'] = []
250
251
  /**
251
- * Names currently bound by an enclosing loop body — the block-param
252
- * locals `renderLoop` introduces (item, index, per-binding destructure
253
- * fields) ref-counted so nested loops compose. This is load-bearing
254
- * for TWO things in the ERB adapter (more than the Mojo original, which
255
- * only used it to guard const-inlining): it also decides the
256
- * fundamental `v[:name]` vs bare-Ruby-local rendering choice in
257
- * `ErbTopLevelEmitter.identifier` — see `emit-context.ts`'s
258
- * `isLoopBoundName` docstring for why ERB's two-locals model needs this
259
- * where Perl's uniform `$name` sigil does not.
252
+ * The one canonical, position-accurate "names bound by an enclosing loop
253
+ * callback" service (#2482 Stage 2) — replaces the ref-counted
254
+ * `Map<string, number>` this adapter used to push/pop around
255
+ * `renderLoop`'s body. Load-bearing for TWO things in the ERB adapter
256
+ * (more than the Mojo original, which only used it to guard
257
+ * const-inlining): it also decides the fundamental `v[:name]` vs
258
+ * bare-Ruby-local rendering choice in `ErbTopLevelEmitter.identifier` —
259
+ * see `emit-context.ts`'s `isLoopBoundName` docstring for why ERB's
260
+ * two-locals model needs this where Perl's uniform `$name` sigil does
261
+ * not. Threaded via save/restore-by-reference around
262
+ * `renderChildren(loop.children)` in `renderLoop` (immutable — no
263
+ * ref-count bookkeeping), mirroring the Stage 1a/1b `ctx.scope`
264
+ * precedent in `jsx-to-ir.ts`. `IRLoop` already structurally satisfies
265
+ * `LoopBindingSource` (`param`/`index`/`paramBindings`/`preamble`), so
266
+ * `renderLoop` passes the loop node straight to `enterLoopRow`.
260
267
  */
261
- private loopBoundNames: Map<string, number> = new Map()
268
+ private scope: BindingScope = BindingScope.EMPTY
262
269
  /**
263
270
  * Prop names whose value is `nil` in the template body when the caller
264
271
  * omits them — so a bare-reference attribute should be dropped rather
@@ -298,7 +305,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
298
305
  this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
299
306
  this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
300
307
  this.localConstants = ir.metadata.localConstants ?? []
301
- this.loopBoundNames.clear()
308
+ this.scope = BindingScope.EMPTY
302
309
  this.errors = []
303
310
  this.childrenCaptureCounter = 0
304
311
 
@@ -440,7 +447,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
440
447
  * would read nil.
441
448
  */
442
449
  private resolveLiteralConst(name: string): string | null {
443
- if (this.loopBoundNames?.has?.(name)) return null
450
+ if (this.scope.isBound(name)) return null
444
451
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
445
452
  if (c?.value === undefined) return null
446
453
  const v = c.value.trim()
@@ -451,8 +458,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
451
458
  }
452
459
 
453
460
  private resolveStaticRecordLiteral(objectName: string, key: string): string | null {
454
- if (this.loopBoundNames?.has?.(objectName)) return null
455
- const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
461
+ const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants, name => this.scope.isBound(name))
456
462
  if (!hit) return null
457
463
  return hit.kind === 'number' ? hit.text : rubyStringLiteral(hit.text)
458
464
  }
@@ -460,7 +466,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
460
466
  private resolveModuleStringConst(name: string): string | null {
461
467
  // A loop body introduces block-param bindings that shadow a module
462
468
  // const of the same name — never inline inside one.
463
- if (this.loopBoundNames.has(name)) return null
469
+ if (this.scope.isBound(name)) return null
464
470
  const value = this.moduleStringConsts.get(name)
465
471
  if (value === undefined) return null
466
472
  return rubyStringLiteral(value)
@@ -469,7 +475,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
469
475
  /** Whether `name` currently names a loop-bound Ruby local. See
470
476
  * `ErbEmitContext.isLoopBoundName`'s docstring. */
471
477
  private isLoopBoundName(name: string): boolean {
472
- return this.loopBoundNames.has(name)
478
+ return this.scope.isBound(name)
473
479
  }
474
480
 
475
481
  // ===========================================================================
@@ -957,15 +963,17 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
957
963
  // `unsupported` object-literal path (BF101, "Expression not
958
964
  // supported"), which is what this loop-specific check now also does
959
965
  // deliberately, up front, for both shapes.
966
+ // Canonical, position-accurate predicate (#2482 Stage 2) — the
967
+ // enclosing loop scope's own membership.
960
968
  const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
961
- isNameShadowed: name => this.loopBoundNames.has(name),
969
+ isNameShadowed: this.scope.asShadowPredicate(),
962
970
  })
963
971
  const staticArray = staticItems !== null ? staticValueToRuby(staticItems) : null
964
972
 
965
973
  if (staticArray === null && loop.arrayParsed?.kind === 'identifier') {
966
974
  const arrayName = loop.arrayParsed.name
967
975
  const isUnresolvableLocalConst =
968
- !this.loopBoundNames.has(arrayName) &&
976
+ !this.scope.isBound(arrayName) &&
969
977
  this.resolveModuleStringConst(arrayName) === null &&
970
978
  this.resolveLiteralConst(arrayName) === null &&
971
979
  this.localConstants.some(c => c.name === arrayName && !c.isModule)
@@ -994,29 +1002,22 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
994
1002
  const indexVar = loop.iterationShape === 'keys'
995
1003
  ? rubyLocal(param)
996
1004
  : rubyLocal(loop.index ?? '_i')
997
- // Names this loop binds in body scope. Guard module-const inlining (and,
998
- // in ERB, the fundamental v[:name]-vs-local rendering choice) for the
999
- // whole body (children + key + filter) so a same-named loop variable
1000
- // isn't replaced by the const literal / a vars-Hash read. Ref-counted
1001
- // for nested loops; released after the body lines are assembled below.
1002
- const loopBound = loop.objectIteration === 'entries'
1003
- ? [param, loop.index ?? '_k']
1004
- : loop.objectIteration === 'keys' || loop.objectIteration === 'values' || loop.iterationShape === 'keys'
1005
- ? [param]
1006
- : supportableDestructure
1007
- ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
1008
- : [param, loop.index ?? '_i']
1009
- // A `.map()` callback preamble lowers to one per-row Ruby local per
1010
- // declaration (#2447). The names must be loop-bound for the whole body,
1011
- // or `ErbTopLevelEmitter.identifier` renders each read as `v[:cls]` —
1012
- // a vars-Hash key nothing ever seeds, i.e. the empty attribute this
1013
- // fixes. Phase 1 guarantees `declarations` is present or the loop was
1014
- // already refused, so there is no partial-lowering case here.
1005
+ // This loop's row scope. Guards module-const inlining (and, in ERB, the
1006
+ // fundamental v[:name]-vs-local rendering choice) for the whole body
1007
+ // (children + key + filter) so a same-named loop variable isn't
1008
+ // replaced by the const literal / a vars-Hash read. `IRLoop` already
1009
+ // structurally satisfies `LoopBindingSource`
1010
+ // (`param`/`index`/`paramBindings`/`preamble`) `enterLoopRow(loop)`
1011
+ // binds exactly what this renderLoop's header + preamble locals
1012
+ // introduce (#2482 Stage 2 replaces the ref-counted `Map` this
1013
+ // adapter used to carry). Restored by reference (immutable — no
1014
+ // ref-count bookkeeping) at the matching pop below, once the WHOLE
1015
+ // body (including the filter predicate) has been rendered except for
1016
+ // one temporary drop-to-outer window around the hoisted sort-comparator
1017
+ // emission below, since that runs OUTSIDE this loop.
1015
1018
  const preambleDecls = loop.preamble?.declarations ?? []
1016
- for (const d of preambleDecls) loopBound.push(d.name)
1017
- for (const n of loopBound) {
1018
- this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
1019
- }
1019
+ const prevScope = this.scope
1020
+ this.scope = prevScope.enterLoopRow(loop)
1020
1021
  const prevLoopKeyDepth = this.currentLoopKeyDepth
1021
1022
  this.currentLoopKeyDepth = loop.depth
1022
1023
  const renderedChildren = this.renderChildren(loop.children)
@@ -1042,18 +1043,16 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1042
1043
  // fall back to the structured `bf.sort` for a comparator the
1043
1044
  // evaluator can't model (e.g. `localeCompare`).
1044
1045
  //
1045
- // The hoisted sort runs OUTSIDE this loop, so this loop's bound names
1046
+ // The hoisted sort runs OUTSIDE this loop, so this loop's row scope
1046
1047
  // must not shadow the comparator's captured free vars while emitting
1047
1048
  // the env — otherwise a captured var that happens to share a
1048
1049
  // loop-param name is blocked from inlining its module const / from
1049
1050
  // reading `v[:name]` and instead resolves to the (out-of-scope) loop
1050
- // local. Drop this loop's bound names for the sort emit, then
1051
- // restore (a nested loop's outer bindings, ref-counted, stay in effect).
1052
- for (const n of loopBound) {
1053
- const c = (this.loopBoundNames.get(n) ?? 1) - 1
1054
- if (c <= 0) this.loopBoundNames.delete(n)
1055
- else this.loopBoundNames.set(n, c)
1056
- }
1051
+ // local. Drop back to the outer (pre-row) scope for the sort emit,
1052
+ // then restore the row scope below (a nested loop's own outer
1053
+ // bindings stay in effect throughout, since `prevScope` already
1054
+ // carries them).
1055
+ this.scope = prevScope
1057
1056
  const sortEmit = (e: ParsedExpr) => this.convertExpressionToRuby('', e)
1058
1057
  const sortArrow = loop.sortComparator.arrow
1059
1058
  let sorted: string | null = null
@@ -1074,9 +1073,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1074
1073
  )
1075
1074
  sorted = rawArray
1076
1075
  }
1077
- for (const n of loopBound) {
1078
- this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
1079
- }
1076
+ this.scope = prevScope.enterLoopRow(loop)
1080
1077
  lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
1081
1078
  }
1082
1079
  if (loop.objectIteration) {
@@ -1184,12 +1181,8 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1184
1181
  lines.push(children)
1185
1182
  }
1186
1183
 
1187
- // Body fully rendered — release the loop-bound names.
1188
- for (const n of loopBound) {
1189
- const c = (this.loopBoundNames.get(n) ?? 1) - 1
1190
- if (c <= 0) this.loopBoundNames.delete(n)
1191
- else this.loopBoundNames.set(n, c)
1192
- }
1184
+ // Body fully rendered — restore the outer (pre-row) scope.
1185
+ this.scope = prevScope
1193
1186
 
1194
1187
  lines.push(`<%- end -%>`)
1195
1188
  lines.push(`<%= bf.comment("/loop:${loop.markerId}") %>`)
@@ -1558,11 +1551,11 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1558
1551
  // function-scope (`!isModule`) consts whose value is NOT itself a
1559
1552
  // bare identifier (loop guard) are considered.
1560
1553
  //
1561
- // `loopBoundNames` guard (#2489): an enclosing `.map()` callback's own
1562
- // param can shadow this outer const's name (`.map((attrs) => <p
1554
+ // `this.scope` shadow guard (#2489): an enclosing `.map()` callback's
1555
+ // own param can shadow this outer const's name (`.map((attrs) => <p
1563
1556
  // {...attrs} />)`) — without the guard this forwarded the OUTER
1564
1557
  // const's value at every iteration instead of the per-item value.
1565
- if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && !this.loopBoundNames.has(trimmed)) {
1558
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && !this.scope.isBound(trimmed)) {
1566
1559
  const localConst = this.localConstants.find(
1567
1560
  c => c.name === trimmed && !c.isModule,
1568
1561
  )
@@ -352,11 +352,11 @@ export class ErbTopLevelEmitter implements ParsedExprEmitter {
352
352
  if (name === 'undefined' || name === 'null') return 'nil'
353
353
  // A loop-bound name (this identifier resolves to a bare Ruby local
354
354
  // introduced by an enclosing loop, not a vars-Hash entry) takes
355
- // priority over const inlining — mirrors the Mojo adapter's
356
- // `loopBoundNames` shadow guard, but here it ALSO decides the
357
- // fundamental v[:name]-vs-bare-local rendering, not just the const
358
- // fast path (ERB's two-locals variable model needs this distinction;
359
- // Perl's uniform `$name` sigil does not — see `ErbEmitContext`).
355
+ // priority over const inlining — mirrors the Mojo adapter's threaded
356
+ // scope shadow guard, but here it ALSO decides the fundamental
357
+ // v[:name]-vs-bare-local rendering, not just the const fast path
358
+ // (ERB's two-locals variable model needs this distinction; Perl's
359
+ // uniform `$name` sigil does not — see `ErbEmitContext`).
360
360
  if (this.ctx.isLoopBoundName(name)) return rubyLocal(name)
361
361
  // Module pure-string const (e.g. `const baseClasses = '...'` used in a
362
362
  // className template literal): inline the literal value rather than