@barefootjs/erb 0.30.6 → 0.31.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.
@@ -68,6 +68,8 @@ module BarefootJS
68
68
  bf_accessor :backend
69
69
  bf_accessor :_scripts, default: -> { [] }
70
70
  bf_accessor :_script_seen, default: -> { {} }
71
+ bf_accessor :_preloads, default: -> { [] }
72
+ bf_accessor :_preload_seen, default: -> { {} }
71
73
  bf_accessor :_scope_id
72
74
  bf_accessor :_is_child, default: -> { false }
73
75
  bf_accessor :_bf_parent
@@ -217,8 +219,28 @@ module BarefootJS
217
219
  _scripts.push(path)
218
220
  end
219
221
 
222
+ # Register a `<link rel="modulepreload">` hint (mirrors `register_script`
223
+ # exactly: same dedup-by-path set, same insertion-order array, same
224
+ # lifetime/reset semantics, same no-output-string return so the compiled
225
+ # ERB template's `<% bf.register_preload(...) %>` produces no bytes
226
+ # where it sits). The `<link>` itself is only ever emitted by `scripts`
227
+ # below, never here -- a preload registration must never inject a node
228
+ # into a component's own template output (see the previous attempt's
229
+ # regression this guards against).
230
+ def register_preload(path)
231
+ return if _preload_seen.key?(path)
232
+
233
+ _preload_seen[path] = true
234
+ _preloads.push(path)
235
+ end
236
+
237
+ # Emits preload hints BEFORE script tags -- a hint that arrives after
238
+ # the script it describes is useless. Same escaping (none; paths are
239
+ # caller-trusted resolved URLs) as the script tags below.
220
240
  def scripts
221
- _scripts.map { |path| %(<script type="module" src="#{path}"></script>) }.join("\n")
241
+ preload_tags = _preloads.map { |path| %(<link rel="modulepreload" crossorigin href="#{path}">) }
242
+ script_tags = _scripts.map { |path| %(<script type="module" src="#{path}"></script>) }
243
+ (preload_tags + script_tags).join("\n")
222
244
  end
223
245
 
224
246
  # -----------------------------------------------------------------
@@ -255,8 +277,9 @@ module BarefootJS
255
277
  # Bulk registration from build manifest
256
278
  # -----------------------------------------------------------------
257
279
  #
258
- # `bf build` emits dist/templates/manifest.json describing every
259
- # component the page might invoke. This walks that manifest and
280
+ # `vite build` (via `@barefootjs/erb/vite`'s `barefoot()` plugin) emits
281
+ # dist/templates/manifest.json describing every component the page
282
+ # might invoke. This walks that manifest and
260
283
  # registers one child renderer per UI registry entry (`ui/<name>/index`
261
284
  # -> slot key `<name>`), seeding each child's template vars from the
262
285
  # manifest's statically-derived `ssrDefaults` (prop destructure
@@ -300,6 +323,8 @@ module BarefootJS
300
323
  child_bf._child_renderers(parent._child_renderers)
301
324
  child_bf._scripts(parent._scripts)
302
325
  child_bf._script_seen(parent._script_seen)
326
+ child_bf._preloads(parent._preloads)
327
+ child_bf._preload_seen(parent._preload_seen)
303
328
 
304
329
  extra =
305
330
  if sig_init
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/erb",
3
- "version": "0.30.6",
3
+ "version": "0.31.0",
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",
@@ -17,9 +17,9 @@
17
17
  "./test-render": {
18
18
  "bun": "./src/test-render.ts"
19
19
  },
20
- "./build": {
21
- "types": "./dist/build.d.ts",
22
- "import": "./dist/build.js"
20
+ "./vite": {
21
+ "types": "./dist/vite.d.ts",
22
+ "import": "./dist/vite.js"
23
23
  }
24
24
  },
25
25
  "files": [
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "build": "bun run build:js && bun run build:types",
33
- "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
33
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared --external @barefootjs/vite --external vite && bun build ./src/vite.ts --outfile ./dist/vite.js --format esm --target node --external @barefootjs/vite --external vite --external typescript",
34
34
  "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
35
35
  "test": "bun test",
36
36
  "clean": "rm -rf dist",
@@ -54,14 +54,27 @@
54
54
  "directory": "packages/adapter-erb"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.30.6"
57
+ "@barefootjs/shared": "0.31.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "@barefootjs/jsx": ">=0.2.0"
60
+ "@barefootjs/jsx": ">=0.2.0",
61
+ "@barefootjs/vite": ">=0.2.0",
62
+ "vite": "^6.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@barefootjs/vite": {
66
+ "optional": true
67
+ },
68
+ "vite": {
69
+ "optional": true
70
+ }
61
71
  },
62
72
  "devDependencies": {
63
73
  "@barefootjs/adapter-tests": "0.1.0",
64
- "@barefootjs/jsx": "0.30.6",
65
- "typescript": "^5.0.0"
74
+ "@barefootjs/jsx": "0.31.0",
75
+ "@barefootjs/vite": "0.31.0",
76
+ "@barefootjs/client": "0.31.0",
77
+ "typescript": "^5.0.0",
78
+ "vite": "^6.0.0"
66
79
  }
67
80
  }
@@ -507,3 +507,147 @@ export function Parent() {
507
507
  expect(template).not.toContain('bf_prop_data')
508
508
  })
509
509
  })
510
+
511
+ describe('ErbAdapter - scriptAssets (Vite late-binding, PR1)', () => {
512
+ const CLIENT_COMPONENT = `
513
+ 'use client'
514
+ import { createSignal } from '@barefootjs/client'
515
+ export function Counter() {
516
+ const [count, setCount] = createSignal(0)
517
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
518
+ }
519
+ `
520
+
521
+ test('emits one register_script per URL, in order, when scriptAssets is set', () => {
522
+ const ir = compileToIR(CLIENT_COMPONENT)
523
+ const { template } = new ErbAdapter().generate(ir, {
524
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
525
+ })
526
+ const runtimeIdx = template.indexOf("bf.register_script('/assets/runtime-abc123.js')")
527
+ const compIdx = template.indexOf("bf.register_script('/assets/counter-def456.js')")
528
+ expect(runtimeIdx).toBeGreaterThanOrEqual(0)
529
+ expect(compIdx).toBeGreaterThanOrEqual(0)
530
+ expect(runtimeIdx).toBeLessThan(compIdx)
531
+ // Legacy computed paths must not leak in.
532
+ expect(template).not.toContain('/static/components/barefoot.js')
533
+ expect(template).not.toContain('Counter.client.js')
534
+ })
535
+
536
+ test('emits a single registration for a single-element scriptAssets array', () => {
537
+ const ir = compileToIR(CLIENT_COMPONENT)
538
+ const { template } = new ErbAdapter().generate(ir, {
539
+ scriptAssets: ['/assets/only-one.js'],
540
+ })
541
+ expect(template).toContain("bf.register_script('/assets/only-one.js')")
542
+ expect(template.match(/register_script/g)?.length).toBe(1)
543
+ })
544
+
545
+ test('an empty scriptAssets array emits no script registrations', () => {
546
+ const ir = compileToIR(CLIENT_COMPONENT)
547
+ const { template } = new ErbAdapter().generate(ir, { scriptAssets: [] })
548
+ expect(template).not.toContain('register_script')
549
+ })
550
+
551
+ test('skipScriptRegistration still wins when scriptAssets is also set', () => {
552
+ const ir = compileToIR(CLIENT_COMPONENT)
553
+ const { template } = new ErbAdapter().generate(ir, {
554
+ skipScriptRegistration: true,
555
+ scriptAssets: ['/assets/should-not-appear.js'],
556
+ })
557
+ expect(template).not.toContain('register_script')
558
+ })
559
+
560
+ test('absent scriptAssets falls back to adapter-computed script paths', () => {
561
+ const ir = compileToIR(CLIENT_COMPONENT)
562
+ const computed = new ErbAdapter().generate(ir).template
563
+ const explicitUndefined = new ErbAdapter().generate(ir, { scriptAssets: undefined }).template
564
+ expect(computed).toContain("bf.register_script('/static/components/barefoot.js')")
565
+ expect(computed).toContain("bf.register_script('/static/components/Counter.client.js')")
566
+ expect(explicitUndefined).toBe(computed)
567
+ })
568
+ })
569
+
570
+ describe('ErbAdapter - preloadAssets', () => {
571
+ const CLIENT_COMPONENT = `
572
+ 'use client'
573
+ import { createSignal } from '@barefootjs/client'
574
+ export function Counter() {
575
+ const [count, setCount] = createSignal(0)
576
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
577
+ }
578
+ `
579
+
580
+ test('non-empty preloadAssets + non-empty scriptAssets: preload registrations emitted, in order, before script registrations', () => {
581
+ const ir = compileToIR(CLIENT_COMPONENT)
582
+ const { template } = new ErbAdapter().generate(ir, {
583
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
584
+ preloadAssets: ['/assets/index-pre1.js', '/assets/shared-pre2.js'],
585
+ })
586
+ const pre1Idx = template.indexOf("<%- bf.register_preload('/assets/index-pre1.js') -%>")
587
+ const pre2Idx = template.indexOf("<%- bf.register_preload('/assets/shared-pre2.js') -%>")
588
+ const script1Idx = template.indexOf("<%- bf.register_script('/assets/runtime-abc123.js') -%>")
589
+ const script2Idx = template.indexOf("<%- bf.register_script('/assets/counter-def456.js') -%>")
590
+ expect(pre1Idx).toBeGreaterThanOrEqual(0)
591
+ expect(pre2Idx).toBeGreaterThan(pre1Idx)
592
+ expect(script1Idx).toBeGreaterThan(pre2Idx)
593
+ expect(script2Idx).toBeGreaterThan(script1Idx)
594
+ })
595
+
596
+ test('preloadAssets: [] emits no preload registration', () => {
597
+ const ir = compileToIR(CLIENT_COMPONENT)
598
+ const { template } = new ErbAdapter().generate(ir, {
599
+ scriptAssets: ['/assets/runtime-abc123.js'],
600
+ preloadAssets: [],
601
+ })
602
+ expect(template).not.toContain('register_preload')
603
+ expect(template).toContain("<%- bf.register_script('/assets/runtime-abc123.js') -%>")
604
+ })
605
+
606
+ test('preloadAssets: undefined emits no preload registration', () => {
607
+ const ir = compileToIR(CLIENT_COMPONENT)
608
+ const { template } = new ErbAdapter().generate(ir, {
609
+ scriptAssets: ['/assets/runtime-abc123.js'],
610
+ preloadAssets: undefined,
611
+ })
612
+ expect(template).not.toContain('register_preload')
613
+ expect(template).toContain("<%- bf.register_script('/assets/runtime-abc123.js') -%>")
614
+ })
615
+
616
+ test('preloadAssets non-empty but scriptAssets: [] emits no preload registration (preloads are only meaningful alongside a real script)', () => {
617
+ const ir = compileToIR(CLIENT_COMPONENT)
618
+ const { template } = new ErbAdapter().generate(ir, {
619
+ scriptAssets: [],
620
+ preloadAssets: ['/assets/index-pre1.js'],
621
+ })
622
+ expect(template).not.toContain('register_preload')
623
+ expect(template).not.toContain('register_script')
624
+ })
625
+
626
+ test('skipScriptRegistration: true suppresses both preloads and scripts', () => {
627
+ const ir = compileToIR(CLIENT_COMPONENT)
628
+ const { template } = new ErbAdapter().generate(ir, {
629
+ skipScriptRegistration: true,
630
+ scriptAssets: ['/assets/runtime-abc123.js'],
631
+ preloadAssets: ['/assets/index-pre1.js'],
632
+ })
633
+ expect(template).not.toContain('register_preload')
634
+ expect(template).not.toContain('register_script')
635
+ })
636
+
637
+ // Regression guard: a previous attempt emitted a literal
638
+ // `<link rel="modulepreload">` tag directly into the component template,
639
+ // which injected a rendered DOM node before the component's root and
640
+ // broke hydration across all eight integrations (blade, erb,
641
+ // go-template, jinja, mojolicious, rust, twig, xslate). Preload hints
642
+ // must ONLY ever be emitted as no-output register statements (here,
643
+ // `<%- bf.register_preload(...) -%>`) that the adapter's runtime later
644
+ // renders itself — never as literal markup baked into the template.
645
+ test('never emits a literal <link tag into the template', () => {
646
+ const ir = compileToIR(CLIENT_COMPONENT)
647
+ const { template } = new ErbAdapter().generate(ir, {
648
+ scriptAssets: ['/assets/runtime-abc123.js'],
649
+ preloadAssets: ['/assets/index-pre1.js'],
650
+ })
651
+ expect(template).not.toContain('<link')
652
+ })
653
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Coverage of `@barefootjs/erb/vite`'s `barefoot()`:
3
+ *
4
+ * - one real `vite build()` end to end (mirrors `@barefootjs/go-template/
5
+ * vite`, `@barefootjs/hono/vite`, `@barefootjs/blade/vite`, and
6
+ * `@barefootjs/jinja/vite`'s own `vite.test.ts` rigor — a plugin that
7
+ * only passes mocked unit tests hasn't been shown to work) against a
8
+ * checked-in fixture (not a system tmpdir) so `@barefootjs/client`
9
+ * resolves through the monorepo's real node_modules symlinks;
10
+ * - the `assets` → generated `bf-assets.json` behavior, exercised the same
11
+ * way (a real build, since it needs the real manifest Vite writes).
12
+ *
13
+ * Unlike Go, there is no `afterEmit`-driven type-combination step to test
14
+ * here (see `vite.ts`'s module docstring) — `ErbAdapter.generate()` never
15
+ * produces a `types` section at all, so `barefoot()` always returns a
16
+ * single-element array unless `assets` is set.
17
+ */
18
+ import { describe, test, expect } from 'bun:test'
19
+ import { build } from 'vite'
20
+ import { mkdtemp, rm, readFile } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import { join, resolve } from 'node:path'
23
+ import { barefoot, barefoot as defaultBarefoot } from '../vite.ts'
24
+
25
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture')
26
+
27
+ describe('@barefootjs/erb/vite: real vite build', () => {
28
+ test('exports the same function as both named `barefoot` and default', () => {
29
+ expect(defaultBarefoot).toBe(barefoot)
30
+ })
31
+
32
+ test('returns a single-element plugin array when `assets` is omitted', () => {
33
+ const plugins = barefoot({ components: ['src/components'], templates: 'views' })
34
+ expect(plugins).toHaveLength(1)
35
+ })
36
+
37
+ test('writes a self-contained .erb template with scriptAssets baked in, same as core alone would do', async () => {
38
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-dist-'))
39
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-views-'))
40
+
41
+ try {
42
+ await build({
43
+ configFile: false,
44
+ root: FIXTURE_ROOT,
45
+ base: '/static/build/',
46
+ logLevel: 'warn',
47
+ build: { outDir, emptyOutDir: true },
48
+ plugins: barefoot({
49
+ components: ['src/components'],
50
+ templates: templatesDir,
51
+ }),
52
+ })
53
+
54
+ const template = await readFile(join(templatesDir, 'Counter.erb'), 'utf8')
55
+ expect(template).toContain("bf.register_script(")
56
+ } finally {
57
+ await rm(outDir, { recursive: true, force: true })
58
+ await rm(templatesDir, { recursive: true, force: true })
59
+ }
60
+ }, 60_000)
61
+
62
+ test('`assets` resolves a non-component entry\'s manifest-hashed URL into a generated JSON asset map', async () => {
63
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-dist-assets-'))
64
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-views-assets-'))
65
+ const assetsPath = join(FIXTURE_ROOT, 'dist/bf-assets.json')
66
+
67
+ try {
68
+ await build({
69
+ configFile: false,
70
+ root: FIXTURE_ROOT,
71
+ base: '/static/build/',
72
+ logLevel: 'warn',
73
+ build: {
74
+ outDir,
75
+ emptyOutDir: true,
76
+ // Registering the non-component entry is the CALLER's job (stock
77
+ // Vite config) — `assets` below only resolves the URL Vite
78
+ // already bundled it to, it doesn't request the bundling.
79
+ rollupOptions: { input: { bootstrap: resolve(FIXTURE_ROOT, 'client/bootstrap.ts') } },
80
+ },
81
+ plugins: barefoot({
82
+ components: ['src/components'],
83
+ templates: templatesDir,
84
+ assets: { Bootstrap: 'client/bootstrap.ts' },
85
+ }),
86
+ })
87
+
88
+ const manifest = JSON.parse(await readFile(join(outDir, '.vite/manifest.json'), 'utf8'))
89
+ const expectedUrl = `/static/build/${manifest['client/bootstrap.ts'].file}`
90
+
91
+ const content = JSON.parse(await readFile(assetsPath, 'utf8'))
92
+ expect(content).toEqual({ Bootstrap: expectedUrl })
93
+ } finally {
94
+ await rm(outDir, { recursive: true, force: true })
95
+ await rm(templatesDir, { recursive: true, force: true })
96
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
97
+ }
98
+ }, 60_000)
99
+
100
+ test('`assets` throws an actionable error when the entry was never registered as a Rollup input', async () => {
101
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-dist-assets-missing-'))
102
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-erb-vite-views-assets-missing-'))
103
+
104
+ try {
105
+ await expect(
106
+ build({
107
+ configFile: false,
108
+ root: FIXTURE_ROOT,
109
+ base: '/static/build/',
110
+ logLevel: 'silent',
111
+ build: { outDir, emptyOutDir: true },
112
+ plugins: barefoot({
113
+ components: ['src/components'],
114
+ templates: templatesDir,
115
+ assets: { Bootstrap: 'client/bootstrap.ts' },
116
+ }),
117
+ }),
118
+ ).rejects.toThrow(/was not found in the build manifest/)
119
+ } finally {
120
+ await rm(outDir, { recursive: true, force: true })
121
+ await rm(templatesDir, { recursive: true, force: true })
122
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
123
+ }
124
+ }, 60_000)
125
+ })
@@ -160,10 +160,6 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
160
160
  name = 'erb'
161
161
  extension = '.erb'
162
162
  templatesPerComponent = true
163
- // Template-string target with no component layer: `bf build` emits a
164
- // static `barefoot-importmap.html` to include in the page <head>, same as
165
- // the Mojo/Go adapters.
166
- importMapInjection = 'html-snippet' as const
167
163
 
168
164
  /**
169
165
  * Identifier-path callees the ERB runtime can render in template scope.
@@ -328,7 +324,7 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
328
324
  // Generate script registration
329
325
  const scriptReg = options?.skipScriptRegistration
330
326
  ? ''
331
- : this.generateScriptRegistrations(ir, options?.scriptBaseName)
327
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName, options?.scriptAssets, options?.preloadAssets)
332
328
 
333
329
  // SSR context consumers (`const x = useContext(Ctx)`): seed each local
334
330
  // from the active provider value (or the `createContext` default) so
@@ -480,7 +476,32 @@ export class ErbAdapter extends BaseAdapter implements IRNodeEmitter<ErbRenderCt
480
476
  // Script Registration
481
477
  // ===========================================================================
482
478
 
483
- private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
479
+ private generateScriptRegistrations(
480
+ ir: ComponentIR,
481
+ scriptBaseName?: string,
482
+ scriptAssets?: string[],
483
+ preloadAssets?: string[],
484
+ ): string {
485
+ // `scriptAssets`, when present (including `[]`), fully supersedes the
486
+ // adapter-computed `barefootJsPath` / `clientJsBasePath` pair — see
487
+ // `AdapterGenerateOptions.scriptAssets`. The caller (e.g. the Vite
488
+ // plugin) has already decided the exact ordered URL list, including
489
+ // whether any script is needed at all.
490
+ if (scriptAssets) {
491
+ if (scriptAssets.length === 0) return ''
492
+ // `preloadAssets` is only meaningful alongside a non-empty
493
+ // `scriptAssets` (see `AdapterGenerateOptions.preloadAssets`), and
494
+ // every preload registration is emitted BEFORE every script
495
+ // registration — a hint that arrives after the script it describes
496
+ // is useless. Same `<%- ... -%>` no-output ERB tag as
497
+ // `register_script`: the `<link rel="modulepreload">` itself is only
498
+ // ever rendered by `Context#scripts` (barefoot_js.rb), never here.
499
+ const lines = (preloadAssets ?? []).map((url) => `<%- bf.register_preload('${url}') -%>`)
500
+ lines.push(...scriptAssets.map((url) => `<%- bf.register_script('${url}') -%>`))
501
+ lines.push('')
502
+ return lines.join('\n')
503
+ }
504
+
484
505
  const hasInteractivity = hasClientInteractivity(ir)
485
506
  if (!hasInteractivity) return ''
486
507
 
@@ -88,38 +88,60 @@ function unusedIsStringName(_n: string): boolean {
88
88
  }
89
89
 
90
90
  export class ErbFilterEmitter implements ParsedExprEmitter {
91
+ // Plain field declarations + assignment, NOT TS constructor-parameter-
92
+ // property shorthand: Vite's `bundleConfigFile` externalizes any bare
93
+ // (non-relative) import when loading `vite.config.ts` (see
94
+ // `@barefootjs/erb/vite`'s docstring), so this file can be loaded
95
+ // directly by Node's OWN native TypeScript type-stripping (enabled by
96
+ // default since Node 22.18/23.6) rather than esbuild — and Node's
97
+ // strip-only mode does not support parameter properties (`SyntaxError
98
+ // [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]`), only plain type annotations.
99
+ private readonly param: string
100
+ private readonly localVarMap: Map<string, string>
101
+ // Whether `name` currently names a bare Ruby local bound by an
102
+ // ENCLOSING loop/block (distinct from `this.param`, which is this
103
+ // predicate's own — possibly nested — loop param). See
104
+ // `ErbEmitContext.isLoopBoundName`'s docstring for why ERB needs this
105
+ // and EP does not.
106
+ private readonly isLoopBoundOuter: (n: string) => boolean
107
+ // Reports whether a getter/prop name is string-typed, for the Hash-vs-
108
+ // Array index-access split (#operand.ts). Defaults to "never" for
109
+ // callers that don't thread it through.
110
+ private readonly isStringName: (n: string) => boolean
111
+ // Records a BF101 for nested callback shapes this emitter can only
112
+ // degrade — `find*` and the non-predicate methods. Optional so emitter
113
+ // construction stays possible without an adapter; a missing hook keeps
114
+ // the old silent-degrade emit.
115
+ private readonly onUnsupported?: (message: string, reason?: string) => void
116
+ // The Ruby local to EMIT for a reference matching `this.param` — as
117
+ // opposed to `this.param` itself, which is only the name to MATCH.
118
+ // These two are the SAME value everywhere in this file except the
119
+ // `filter().map()` loop-gating `<if>` (erb-adapter.ts's `renderLoop`,
120
+ // #2245): `todos.filter(t => t.done).map(todo => ...)` parses the
121
+ // predicate against the filter callback's OWN param (`t`), but the
122
+ // Ruby local actually bound by the loop is the MAP callback's param
123
+ // (`todo`) — Ruby has no per-callback block scope there (unlike the
124
+ // real nested `.select { |t| ... }` block `callbackMethod` below
125
+ // builds, where match and render are naturally the same param).
126
+ // Defaults to `rubyLocal(param)`, i.e. every other construction site is
127
+ // unaffected.
128
+ private readonly renderParamAs: string
129
+
91
130
  constructor(
92
- private readonly param: string,
93
- private readonly localVarMap: Map<string, string>,
94
- // Whether `name` currently names a bare Ruby local bound by an
95
- // ENCLOSING loop/block (distinct from `this.param`, which is this
96
- // predicate's own possibly nested — loop param). See
97
- // `ErbEmitContext.isLoopBoundName`'s docstring for why ERB needs this
98
- // and EP does not.
99
- private readonly isLoopBoundOuter: (n: string) => boolean = () => false,
100
- // Reports whether a getter/prop name is string-typed, for the Hash-vs-
101
- // Array index-access split (#operand.ts). Defaults to "never" for
102
- // callers that don't thread it through.
103
- private readonly isStringName: (n: string) => boolean = unusedIsStringName,
104
- // Records a BF101 for nested callback shapes this emitter can only
105
- // degrade — `find*` and the non-predicate methods. Optional so emitter
106
- // construction stays possible without an adapter; a missing hook keeps
107
- // the old silent-degrade emit.
108
- private readonly onUnsupported?: (message: string, reason?: string) => void,
109
- // The Ruby local to EMIT for a reference matching `this.param` — as
110
- // opposed to `this.param` itself, which is only the name to MATCH.
111
- // These two are the SAME value everywhere in this file except the
112
- // `filter().map()` loop-gating `<if>` (erb-adapter.ts's `renderLoop`,
113
- // #2245): `todos.filter(t => t.done).map(todo => ...)` parses the
114
- // predicate against the filter callback's OWN param (`t`), but the
115
- // Ruby local actually bound by the loop is the MAP callback's param
116
- // (`todo`) — Ruby has no per-callback block scope there (unlike the
117
- // real nested `.select { |t| ... }` block `callbackMethod` below
118
- // builds, where match and render are naturally the same param).
119
- // Defaults to `rubyLocal(this.param)`, i.e. every other construction
120
- // site is unaffected.
121
- private readonly renderParamAs: string = rubyLocal(param),
122
- ) {}
131
+ param: string,
132
+ localVarMap: Map<string, string>,
133
+ isLoopBoundOuter: (n: string) => boolean = () => false,
134
+ isStringName: (n: string) => boolean = unusedIsStringName,
135
+ onUnsupported?: (message: string, reason?: string) => void,
136
+ renderParamAs: string = rubyLocal(param),
137
+ ) {
138
+ this.param = param
139
+ this.localVarMap = localVarMap
140
+ this.isLoopBoundOuter = isLoopBoundOuter
141
+ this.isStringName = isStringName
142
+ this.onUnsupported = onUnsupported
143
+ this.renderParamAs = renderParamAs
144
+ }
123
145
 
124
146
  identifier(name: string): string {
125
147
  if (name === this.param) return this.renderParamAs
@@ -315,7 +337,13 @@ export class ErbFilterEmitter implements ParsedExprEmitter {
315
337
  * - the `unsupported` fallback returns the safe empty-string Ruby literal.
316
338
  */
317
339
  export class ErbTopLevelEmitter implements ParsedExprEmitter {
318
- constructor(private readonly ctx: ErbEmitContext) {}
340
+ // Plain field + assignment, not a parameter property — see
341
+ // `ErbFilterEmitter`'s constructor comment above for why.
342
+ private readonly ctx: ErbEmitContext
343
+
344
+ constructor(ctx: ErbEmitContext) {
345
+ this.ctx = ctx
346
+ }
319
347
 
320
348
  identifier(name: string): string {
321
349
  // `undefined` / `null` nested inside a larger expression tree (e.g.