@barefootjs/erb 0.31.4 → 0.31.6

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.4",
3
+ "version": "0.31.6",
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.4"
57
+ "@barefootjs/shared": "0.31.6"
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.4",
75
- "@barefootjs/vite": "0.31.4",
76
- "@barefootjs/client": "0.31.4",
74
+ "@barefootjs/jsx": "0.31.6",
75
+ "@barefootjs/vite": "0.31.6",
76
+ "@barefootjs/client": "0.31.6",
77
77
  "typescript": "^5.0.0",
78
78
  "vite": "^6.0.0"
79
79
  }
@@ -94,9 +94,10 @@ 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
- import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
100
+ import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment, EscapeKind } from '@barefootjs/jsx'
100
101
  import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
101
102
 
102
103
  import type { ErbRenderCtx } from './lib/types.ts'
@@ -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)
@@ -973,6 +981,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
973
981
  this._recordExprBF101(
974
982
  `Loop array \`${arrayName}\` is a component-scope const computed from a runtime expression the ERB adapter cannot evaluate at SSR render time.`,
975
983
  `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.`,
984
+ [{ kind: 'prop-precompute' }, { kind: 'client-directive' }],
976
985
  )
977
986
  }
978
987
  }
@@ -994,29 +1003,22 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
994
1003
  const indexVar = loop.iterationShape === 'keys'
995
1004
  ? rubyLocal(param)
996
1005
  : 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.
1006
+ // This loop's row scope. Guards module-const inlining (and, in ERB, the
1007
+ // fundamental v[:name]-vs-local rendering choice) for the whole body
1008
+ // (children + key + filter) so a same-named loop variable isn't
1009
+ // replaced by the const literal / a vars-Hash read. `IRLoop` already
1010
+ // structurally satisfies `LoopBindingSource`
1011
+ // (`param`/`index`/`paramBindings`/`preamble`) `enterLoopRow(loop)`
1012
+ // binds exactly what this renderLoop's header + preamble locals
1013
+ // introduce (#2482 Stage 2 replaces the ref-counted `Map` this
1014
+ // adapter used to carry). Restored by reference (immutable — no
1015
+ // ref-count bookkeeping) at the matching pop below, once the WHOLE
1016
+ // body (including the filter predicate) has been rendered except for
1017
+ // one temporary drop-to-outer window around the hoisted sort-comparator
1018
+ // emission below, since that runs OUTSIDE this loop.
1015
1019
  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
- }
1020
+ const prevScope = this.scope
1021
+ this.scope = prevScope.enterLoopRow(loop)
1020
1022
  const prevLoopKeyDepth = this.currentLoopKeyDepth
1021
1023
  this.currentLoopKeyDepth = loop.depth
1022
1024
  const renderedChildren = this.renderChildren(loop.children)
@@ -1042,18 +1044,16 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1042
1044
  // fall back to the structured `bf.sort` for a comparator the
1043
1045
  // evaluator can't model (e.g. `localeCompare`).
1044
1046
  //
1045
- // The hoisted sort runs OUTSIDE this loop, so this loop's bound names
1047
+ // The hoisted sort runs OUTSIDE this loop, so this loop's row scope
1046
1048
  // must not shadow the comparator's captured free vars while emitting
1047
1049
  // the env — otherwise a captured var that happens to share a
1048
1050
  // loop-param name is blocked from inlining its module const / from
1049
1051
  // 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
- }
1052
+ // local. Drop back to the outer (pre-row) scope for the sort emit,
1053
+ // then restore the row scope below (a nested loop's own outer
1054
+ // bindings stay in effect throughout, since `prevScope` already
1055
+ // carries them).
1056
+ this.scope = prevScope
1057
1057
  const sortEmit = (e: ParsedExpr) => this.convertExpressionToRuby('', e)
1058
1058
  const sortArrow = loop.sortComparator.arrow
1059
1059
  let sorted: string | null = null
@@ -1074,9 +1074,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1074
1074
  )
1075
1075
  sorted = rawArray
1076
1076
  }
1077
- for (const n of loopBound) {
1078
- this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
1079
- }
1077
+ this.scope = prevScope.enterLoopRow(loop)
1080
1078
  lines.push(`<%- ${sortedHoist} = ${sorted} -%>`)
1081
1079
  }
1082
1080
  if (loop.objectIteration) {
@@ -1184,12 +1182,8 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1184
1182
  lines.push(children)
1185
1183
  }
1186
1184
 
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
- }
1185
+ // Body fully rendered — restore the outer (pre-row) scope.
1186
+ this.scope = prevScope
1193
1187
 
1194
1188
  lines.push(`<%- end -%>`)
1195
1189
  lines.push(`<%= bf.comment("/loop:${loop.markerId}") %>`)
@@ -1558,11 +1552,11 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1558
1552
  // function-scope (`!isModule`) consts whose value is NOT itself a
1559
1553
  // bare identifier (loop guard) are considered.
1560
1554
  //
1561
- // `loopBoundNames` guard (#2489): an enclosing `.map()` callback's own
1562
- // param can shadow this outer const's name (`.map((attrs) => <p
1555
+ // `this.scope` shadow guard (#2489): an enclosing `.map()` callback's
1556
+ // own param can shadow this outer const's name (`.map((attrs) => <p
1563
1557
  // {...attrs} />)`) — without the guard this forwarded the OUTER
1564
1558
  // 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)) {
1559
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && !this.scope.isBound(trimmed)) {
1566
1560
  const localConst = this.localConstants.find(
1567
1561
  c => c.name === trimmed && !c.isModule,
1568
1562
  )
@@ -1968,7 +1962,16 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1968
1962
  return this.stringValueNames.has(name)
1969
1963
  }
1970
1964
 
1971
- private _recordExprBF101(message: string, reason?: string): void {
1965
+ /**
1966
+ * `escape` is the structured claim (#2613) — populate it only with kinds
1967
+ * a conformance twin actually demonstrates for this shape, since
1968
+ * `escape-coverage.test.ts` checks claims against the twins. The prose
1969
+ * in `reason` may legitimately offer more than the structured field
1970
+ * (ERB's numbered list opens with an inline-rewrite option that no twin
1971
+ * proves); prose stays authoritative for humans, this field for
1972
+ * machines.
1973
+ */
1974
+ private _recordExprBF101(message: string, reason?: string, escape?: ReadonlyArray<{ kind: EscapeKind }>): void {
1972
1975
  this.errors.push({
1973
1976
  code: 'BF101',
1974
1977
  severity: 'error',
@@ -1978,6 +1981,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
1978
1981
  message: reason
1979
1982
  ? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby`
1980
1983
  : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Ruby',
1984
+ ...(escape ? { escape } : {}),
1981
1985
  },
1982
1986
  })
1983
1987
  }
@@ -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
@@ -16,6 +16,9 @@ export const conformancePins: ConformancePins = {
16
16
  // JS-runtime target runs it, a DSL adapter surfaces BF021 + `/* @client */`.
17
17
  // See spec/callback-fidelity.md.
18
18
  'filter-typeof-predicate': [{ code: 'BF021', severity: 'error' }],
19
+ // Array-builder `.map()` body (imperative `push`-into-array preamble):
20
+ // BF021, with a verified `/* @client */` escape — `map-array-builder-
21
+ // body-client` (#2613). See that fixture's docstring.
19
22
  'map-array-builder-body': [{ code: 'BF021', severity: 'error' }],
20
23
  'map-array-builder-escaping': [{ code: 'BF021', severity: 'error' }],
21
24
  // `.fill(value)` mutates the receiver in place — no template lowering
@@ -82,12 +85,20 @@ export const conformancePins: ConformancePins = {
82
85
  // "Loop array is a bare identifier..." comment) rather than faked as
83
86
  // BF104 or silently producing broken Ruby.
84
87
  'static-array-from-props': [
85
- { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2321' },
88
+ {
89
+ code: 'BF101',
90
+ severity: 'error',
91
+ issue: 'https://github.com/piconic-ai/barefootjs/issues/2321',
92
+ },
86
93
  ],
87
94
  // BF103 (imported child in the loop body) no longer fires now that the
88
95
  // conformance harness passes `siblingTemplatesRegistered: true` (#2205).
89
96
  'static-array-from-props-with-component': [
90
- { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2321' },
97
+ {
98
+ code: 'BF101',
99
+ severity: 'error',
100
+ issue: 'https://github.com/piconic-ai/barefootjs/issues/2321',
101
+ },
91
102
  ],
92
103
  // #2087 Phase B: `isLowerableLoopDestructure` now admits every fixed-
93
104
  // binding shape (any field/index depth — `destructure-array-index-in-map`,
@@ -108,9 +119,7 @@ export const conformancePins: ConformancePins = {
108
119
  // lowers it to a real inline Ruby block predicate and must render to
109
120
  // Hono parity instead.
110
121
  // Faithful lowering tracked: https://github.com/piconic-ai/barefootjs/issues/2320 (successor to #2038)
111
- 'filter-nested-find-predicate': [
112
- { code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2320' },
113
- ],
122
+ 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2320' }],
114
123
  // #1467 demo-corpus context providers (`radio-group`, `accordion`,
115
124
  // `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
116
125
  // `command`) are NOT pinned — an object-literal provider value lowers