@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
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@barefootjs/erb",
3
+ "version": "0.17.0",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./adapter": {
14
+ "types": "./dist/adapter/index.d.ts",
15
+ "import": "./dist/adapter/index.js"
16
+ },
17
+ "./test-render": {
18
+ "bun": "./src/test-render.ts"
19
+ },
20
+ "./build": {
21
+ "types": "./dist/build.d.ts",
22
+ "import": "./dist/build.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "lib",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
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",
34
+ "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
35
+ "test": "bun test",
36
+ "clean": "rm -rf dist",
37
+ "prepack": "node ../../scripts/swap-publish-config.mjs pack",
38
+ "postpack": "node ../../scripts/swap-publish-config.mjs unpack"
39
+ },
40
+ "keywords": [
41
+ "erb",
42
+ "ruby",
43
+ "sinatra",
44
+ "rack",
45
+ "rails",
46
+ "barefoot",
47
+ "ssr"
48
+ ],
49
+ "author": "kobaken <kentafly88@gmail.com>",
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/piconic-ai/barefootjs",
54
+ "directory": "packages/adapter-erb"
55
+ },
56
+ "dependencies": {
57
+ "@barefootjs/shared": "0.17.1"
58
+ },
59
+ "peerDependencies": {
60
+ "@barefootjs/jsx": ">=0.2.0"
61
+ },
62
+ "devDependencies": {
63
+ "@barefootjs/adapter-tests": "0.1.0",
64
+ "@barefootjs/jsx": "0.17.1",
65
+ "typescript": "^5.0.0"
66
+ }
67
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * ErbAdapter — Conformance Tests
3
+ *
4
+ * Runs the shared adapter conformance corpus (JSX fixtures, template
5
+ * primitives, marker conformance) against the ERB (Embedded Ruby)
6
+ * adapter, rendering each fixture end-to-end through real Ruby stdlib
7
+ * `erb` + `BarefootJS::Backend::Erb` via `renderErbComponent`.
8
+ *
9
+ * The ERB adapter was ported from the Mojolicious adapter (EP → ERB is a
10
+ * 1:1 embedded-language mapping — see `packages/adapter-erb/src/adapter/
11
+ * erb-adapter.ts`'s file docstring), so the skip / diagnostic sets below
12
+ * start from mojo's and diverge only where the engine genuinely differs.
13
+ * Every divergence carries a one-line rationale.
14
+ *
15
+ * Unlike Kolon (Text::Xslate), Ruby is a full general-purpose language:
16
+ * component-invocation Hash literals support a real double-splat
17
+ * (`**hash`), the direct analog of Perl's `%{$props}` flatten. So the
18
+ * `button` / `kbd` shapes that Xslate refuses (BF101 — Kolon has no
19
+ * "splat a runtime hash into named call args" form) LOWER cleanly on
20
+ * ERB, matching mojo rather than xslate.
21
+ */
22
+
23
+ import { describe, test, expect } from 'bun:test'
24
+ import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
25
+ import { ErbAdapter } from '../adapter'
26
+ import { renderErbComponent, ErbNotAvailableError } from '../test-render'
27
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
28
+ import { conformancePins } from '../conformance-pins'
29
+
30
+ runAdapterConformanceTests({
31
+ name: 'erb',
32
+ factory: () => new ErbAdapter(),
33
+ render: renderErbComponent,
34
+ // No JSX-render skips: every shared conformance fixture — including
35
+ // the composed `site/ui` demo corpus (#1467 / #1897) — renders to
36
+ // Hono parity on real Ruby `erb`. `data-table` came off via the
37
+ // body-children `inLoop` reset (#1896): the loop-item component
38
+ // (TableRow) still gets `ComponentName_<random>` scope IDs, but its
39
+ // body children (TableCell) now receive `_bf_slot` for deterministic
40
+ // parent-scope-derived IDs matching Hono.
41
+ // Per-fixture build-time contracts for shapes the ERB adapter
42
+ // intentionally refuses to lower. Lives in `../conformance-pins` —
43
+ // mirrors mojo's set (the lowering gates — `isLowerableLoopDestructure`,
44
+ // `collectImportedLoopChildComponentErrors`, `refuseUnsupportedAttr
45
+ // Expression`, the #2038 nested-higher-order-callback gate — are shared
46
+ // code in `@barefootjs/jsx` that every EP/ERB-family adapter reuses
47
+ // verbatim).
48
+ expectedDiagnostics: conformancePins,
49
+ // Template-primitive registry: `USER_IMPORT_VIA_CONST` and
50
+ // `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now pass (#2069) — a bespoke user
51
+ // import can never be added to the string-keyed registry, but the
52
+ // shared `RelocateEnv.loweringMatchers` acceptance path recognises it
53
+ // via a `LoweringPlugin` the case setup registers around the compile
54
+ // (see `packages/adapter-tests/src/cases/template-primitives.ts`). No
55
+ // skips left, so `skipTemplatePrimitives` is omitted entirely.
56
+ skipMarkerConformance: new Set([
57
+ // Same as Hono / Mojo / Xslate: `/* @client */` markers on TodoApp's
58
+ // keyed `.map` intentionally elide a slot id from the SSR template
59
+ // that the IR still declares (s6). See hono-adapter.test for the
60
+ // contract.
61
+ 'todo-app',
62
+ // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
63
+ 'data-table',
64
+ ]),
65
+ onRenderError: (err, id) => {
66
+ if (err instanceof ErbNotAvailableError) {
67
+ console.log(`Skipping [${id}]: ${err.message}`)
68
+ return true
69
+ }
70
+ return false
71
+ },
72
+ })
73
+
74
+ // =============================================================================
75
+ // Helpers
76
+ // =============================================================================
77
+
78
+ function compileToIR(source: string): ComponentIR {
79
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
80
+ adapter: new ErbAdapter(),
81
+ outputIR: true,
82
+ })
83
+ const irFile = result.files.find(f => f.type === 'ir')
84
+ if (!irFile) throw new Error('No IR output')
85
+ return JSON.parse(irFile.content) as ComponentIR
86
+ }
87
+
88
+ function compileAndGenerate(source: string) {
89
+ return new ErbAdapter().generate(compileToIR(source))
90
+ }
91
+
92
+ // =============================================================================
93
+ // ERB-Specific Tests
94
+ // =============================================================================
95
+
96
+ describe('ErbAdapter - SSR context propagation (#1297)', () => {
97
+ test('provider brackets children with provide_context / revoke_context', () => {
98
+ const { template } = compileAndGenerate(`
99
+ 'use client'
100
+ import { createContext, useContext } from '@barefootjs/client'
101
+ const ThemeContext = createContext('light')
102
+ export function ThemeRoot() {
103
+ return <div><ThemeContext.Provider value="dark"><ThemeLabel /></ThemeContext.Provider></div>
104
+ }
105
+ function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span> }
106
+ `)
107
+ expect(template).toContain("bf.provide_context('ThemeContext', 'dark')")
108
+ expect(template).toContain("bf.revoke_context('ThemeContext')")
109
+ expect(template.indexOf('provide_context')).toBeLessThan(template.indexOf('render_child'))
110
+ expect(template.indexOf('render_child')).toBeLessThan(template.indexOf('revoke_context'))
111
+ })
112
+
113
+ test('consumer seeds its local from use_context with the createContext default', () => {
114
+ const { template } = compileAndGenerate(`
115
+ 'use client'
116
+ import { createContext, useContext } from '@barefootjs/client'
117
+ const ThemeContext = createContext('light')
118
+ export function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span> }
119
+ `)
120
+ expect(template).toContain("v[:theme] = bf.use_context('ThemeContext', 'light')")
121
+ })
122
+ })
123
+
124
+ describe('ErbAdapter - prop-derived memo SSR seeding (#1297)', () => {
125
+ test('seeds a prop-derived memo from the prop var', () => {
126
+ const { template } = compileAndGenerate(`
127
+ 'use client'
128
+ import { createMemo } from '@barefootjs/client'
129
+ export function Child(props: { value: number }) {
130
+ const displayValue = createMemo(() => props.value * 10)
131
+ return <span>{displayValue()}</span>
132
+ }
133
+ `)
134
+ expect(template).toMatch(/v\[:displayValue\]\s*=\s*\(v\[:value\]\s*\*\s*10\)/)
135
+ })
136
+
137
+ test('seeds a memo over a destructured prop', () => {
138
+ const { template } = compileAndGenerate(`
139
+ 'use client'
140
+ import { createMemo } from '@barefootjs/client'
141
+ export function Child({ value }: { value: number }) {
142
+ const displayValue = createMemo(() => value * 10)
143
+ return <span>{displayValue()}</span>
144
+ }
145
+ `)
146
+ expect(template).toMatch(/v\[:displayValue\]\s*=\s*\(v\[:value\]\s*\*\s*10\)/)
147
+ })
148
+ })
149
+
150
+ describe('ErbAdapter - #2075 searchParams()-derived memo seeding', () => {
151
+ // A memo derived from the createSearchParams() env signal must seed
152
+ // in-template from the canonical per-request `v[:search_params]` reader —
153
+ // including under a local alias (`const [sp] = …`), which the expression
154
+ // lowering canonicalises.
155
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
156
+ const { template } = compileAndGenerate(`
157
+ 'use client'
158
+ import { createMemo, createSearchParams } from '@barefootjs/client'
159
+ export function SortStatus() {
160
+ const [sp] = createSearchParams()
161
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
162
+ return <p>sort: {sort()}</p>
163
+ }
164
+ `)
165
+ expect(template).toContain(
166
+ "v[:sort] = ((v[:search_params].get('sort')).nil? ? 'date' : v[:search_params].get('sort'))",
167
+ )
168
+ })
169
+
170
+ // A list-filter memo chained off the derived memo seeds too: the block
171
+ // param (`p`) is a lowering-internal binding, not an out-of-scope
172
+ // template var (the pre-#2075 availability check rejected them and the
173
+ // list rendered empty at SSR).
174
+ test('seeds a filter memo chained off the derived memo', () => {
175
+ const { template } = compileAndGenerate(`
176
+ 'use client'
177
+ import { createMemo, createSearchParams } from '@barefootjs/client'
178
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
179
+ const [searchParams] = createSearchParams()
180
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
181
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
182
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
183
+ }
184
+ `)
185
+ expect(template).toContain(
186
+ "v[:tag] = ((v[:search_params].get('tag')).nil? ? '' : v[:search_params].get('tag'))",
187
+ )
188
+ expect(template).toMatch(/v\[:visible\] = v\[:items\]\.select \{ \|p\|/)
189
+ })
190
+
191
+ // The seed-scope guard used to scan the LOWERED Ruby string, allowing
192
+ // every arrow-callback param tree-wide. That let an outer, unbound `p`
193
+ // (shadowed only inside the callback) slip past the guard as if it were
194
+ // the callback's own bound `p` — emitting a bogus seed line that would
195
+ // read an un-seeded key. The guard now walks the parsed SOURCE tree with
196
+ // proper lexical scoping (`freeIdentifiers`), so this shape seeds nothing
197
+ // and falls back to the nil/ssr-defaults path.
198
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
199
+ const { template } = compileAndGenerate(`
200
+ 'use client'
201
+ import { createMemo } from '@barefootjs/client'
202
+ export function C(props: { items: { ok: boolean }[] }) {
203
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
204
+ return <div>{String(visible())}</div>
205
+ }
206
+ `)
207
+ expect(template).not.toMatch(/v\[:visible\] =/)
208
+ })
209
+
210
+ // An out-of-scope bare `_` reference (not a lowering-internal loop-index
211
+ // binding) must not seed either — the old unconditional allow-list masked
212
+ // this.
213
+ test('an out-of-scope bare `_` reference does not seed', () => {
214
+ const { template } = compileAndGenerate(`
215
+ 'use client'
216
+ import { createMemo } from '@barefootjs/client'
217
+ export function C(props: { count: number }) {
218
+ const doubled = createMemo(() => props.count * 2 + _)
219
+ return <div>{doubled()}</div>
220
+ }
221
+ `)
222
+ expect(template).not.toMatch(/v\[:doubled\] =/)
223
+ })
224
+ })
225
+
226
+ describe('ErbAdapter - prop-derived signal SSR seeding + data-key (#1297, toggle-shared)', () => {
227
+ test('seeds a prop-derived (different-name) signal from the prop var', () => {
228
+ const { template } = compileAndGenerate(`
229
+ 'use client'
230
+ import { createSignal } from '@barefootjs/client'
231
+ export function Item(props: { defaultOn?: boolean }) {
232
+ const [on, setOn] = createSignal(props.defaultOn ?? false)
233
+ return <button>{on() ? 'ON' : 'OFF'}</button>
234
+ }
235
+ `)
236
+ expect(template).toMatch(/v\[:on\]\s*=\s*\(\(v\[:defaultOn\]\)\.nil\?/)
237
+ })
238
+
239
+ test('emits data_key_attr on the component root', () => {
240
+ const { template } = compileAndGenerate(`
241
+ export function Item() { return <div class="x">hi</div> }
242
+ `)
243
+ expect(template).toContain('bf.data_key_attr')
244
+ })
245
+
246
+ test('emits data_key_attr on each branch root of an if-statement root', () => {
247
+ const { template } = compileAndGenerate(`
248
+ export function Item({ on }: { on?: boolean }) {
249
+ if (on) return <div class="a">A</div>
250
+ return <div class="b">B</div>
251
+ }
252
+ `)
253
+ const count = (template.match(/bf\.data_key_attr/g) ?? []).length
254
+ expect(count).toBe(2)
255
+ })
256
+ })
257
+
258
+ describe('ErbAdapter - #1966 @client defers attribute bindings', () => {
259
+ function compileAttr(attrExpr: string) {
260
+ const adapter = new ErbAdapter()
261
+ const ir = compileToIR(`
262
+ "use client"
263
+ import { createSignal } from "@barefootjs/client"
264
+ export function C() {
265
+ const [sel] = createSignal(0)
266
+ const pred = (n: number) => sel() === n
267
+ return <div data-x={${attrExpr}}>hi</div>
268
+ }
269
+ `)
270
+ const template = adapter.generate(ir).template ?? ''
271
+ const errors = (adapter as unknown as { errors: { code: string }[] }).errors ?? []
272
+ return { errors, template }
273
+ }
274
+
275
+ test('bare emits data-x; @client omits it from SSR', () => {
276
+ expect(compileAttr('pred(1)').template).toContain('data-x')
277
+ const deferred = compileAttr('/* @client */ pred(1)')
278
+ expect(deferred.errors).toEqual([])
279
+ expect(deferred.template).not.toContain('data-x')
280
+ })
281
+ })
282
+
283
+ describe('ErbAdapter - component-prop spread via Ruby **hash (button/kbd shape)', () => {
284
+ // The direct engine-divergence case from xslate: a `{...props}` spread
285
+ // into a CHILD component's invocation Hash lowers to Ruby's native
286
+ // double-splat, unlike Kolon (no such call-arg splat form).
287
+ test('spreads a rest-props bag into a child component call via **hash', () => {
288
+ const { template } = compileAndGenerate(`
289
+ 'use client'
290
+ import { Leaf } from './leaf'
291
+ function Slot({ ...rest }: { [key: string]: unknown }) {
292
+ return <Leaf {...rest} />
293
+ }
294
+ export { Slot }
295
+ `)
296
+ expect(template).toMatch(/\*\*v\[:rest\]/)
297
+ })
298
+ })
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Component-tree analysis for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `analysis/component-tree.ts` (issue
5
+ * #2018 track D lineage). Pure functions over the IR — they read no adapter
6
+ * instance state. `collectImportedLoopChildComponentErrors` returns its
7
+ * diagnostics instead of pushing onto the adapter's error list, so the
8
+ * adapter stays the sole owner of `errors`.
9
+ */
10
+
11
+ import type {
12
+ ComponentIR,
13
+ IRNode,
14
+ IRComponent,
15
+ IRElement,
16
+ IRFragment,
17
+ IRConditional,
18
+ IRLoop,
19
+ IRIfStatement,
20
+ IRProvider,
21
+ IRAsync,
22
+ CompilerError,
23
+ } from '@barefootjs/jsx'
24
+
25
+ /**
26
+ * Whether the component needs the client runtime — it owns reactive state
27
+ * (signals / effects / onMount) or the analyzer flagged it as needing init.
28
+ */
29
+ export function hasClientInteractivity(ir: ComponentIR): boolean {
30
+ return (
31
+ ir.metadata.signals.length > 0 ||
32
+ ir.metadata.effects.length > 0 ||
33
+ ir.metadata.onMounts.length > 0 ||
34
+ (ir.metadata.clientAnalysis?.needsInit ?? false)
35
+ )
36
+ }
37
+
38
+ /**
39
+ * Build a `BF103` diagnostic for every component reference inside a loop body
40
+ * whose name is imported from a relative-path module. Mirror of the Go /
41
+ * Mojo adapters' check — the ERB adapter has the same cross-template-
42
+ * registration constraint at request time. Returns the diagnostics so the
43
+ * caller pushes them onto its own error list.
44
+ */
45
+ export function collectImportedLoopChildComponentErrors(
46
+ ir: ComponentIR,
47
+ componentName: string,
48
+ ): CompilerError[] {
49
+ const errors: CompilerError[] = []
50
+ // Collect every name imported from a relative-path module (no
51
+ // case filter — `IRComponent` nodes only exist for PascalCase JSX
52
+ // usages, so a lowercase utility import in the set can't match
53
+ // anyway, and any heuristic on the import name itself would be
54
+ // strictly less robust than the structural IR check below).
55
+ const relativeImports = new Set<string>()
56
+ for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
57
+ if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
58
+ if (imp.isTypeOnly) continue
59
+ for (const spec of imp.specifiers) {
60
+ relativeImports.add(spec.alias ?? spec.name)
61
+ }
62
+ }
63
+ if (relativeImports.size === 0) return errors
64
+
65
+ const loc = { file: componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
66
+ const visit = (node: IRNode, inLoop: boolean): void => {
67
+ switch (node.type) {
68
+ case 'component': {
69
+ const comp = node as IRComponent
70
+ if (inLoop && relativeImports.has(comp.name)) {
71
+ errors.push({
72
+ code: 'BF103',
73
+ severity: 'error',
74
+ message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The ERB adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
75
+ loc: comp.loc ?? loc,
76
+ suggestion: {
77
+ message:
78
+ `Options:\n` +
79
+ ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting ERB template alongside the parent at render time.\n` +
80
+ ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
81
+ ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
82
+ },
83
+ })
84
+ }
85
+ for (const child of comp.children) visit(child, inLoop)
86
+ break
87
+ }
88
+ case 'element':
89
+ for (const child of (node as IRElement).children) visit(child, inLoop)
90
+ break
91
+ case 'fragment':
92
+ for (const child of (node as IRFragment).children) visit(child, inLoop)
93
+ break
94
+ case 'conditional': {
95
+ const cond = node as IRConditional
96
+ visit(cond.whenTrue, inLoop)
97
+ if (cond.whenFalse) visit(cond.whenFalse, inLoop)
98
+ break
99
+ }
100
+ case 'loop':
101
+ for (const child of (node as IRLoop).children) visit(child, true)
102
+ break
103
+ case 'if-statement': {
104
+ const stmt = node as IRIfStatement
105
+ visit(stmt.consequent, inLoop)
106
+ if (stmt.alternate) visit(stmt.alternate, inLoop)
107
+ break
108
+ }
109
+ case 'provider':
110
+ for (const child of (node as IRProvider).children) visit(child, inLoop)
111
+ break
112
+ case 'async': {
113
+ const a = node as IRAsync
114
+ visit(a.fallback, inLoop)
115
+ for (const child of a.children) visit(child, inLoop)
116
+ break
117
+ }
118
+ }
119
+ }
120
+ visit(ir.root, false)
121
+ return errors
122
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Structural classifier for JS expressions whose result is a boolean
3
+ * value (or unambiguously stringifies to "true"/"false" in JS).
4
+ *
5
+ * Used by the ERB adapter's `emitExpression` to decide whether to
6
+ * route a reactive attribute binding through the `bf.bool_str` Ruby
7
+ * runtime helper (mirrors the Mojo/Xslate adapters' `bool_str` use).
8
+ * Ruby has no bare-comparison-to-string coercion either: `(count > 0)`
9
+ * evaluates to `true`/`false` objects, which `to_s` would render as
10
+ * the literal strings `"true"`/`"false"` — that part already matches
11
+ * JS `String(boolean)`. The wrapper still exists for parity with the
12
+ * Perl/Kolon family and for the cases below where the source
13
+ * expression is *opaque* (a bare call) but the attribute name
14
+ * witnesses a boolean value.
15
+ *
16
+ * The classifier walks a `ParsedExpr` produced by
17
+ * `@barefootjs/jsx::parseExpression` — same AST the filter / loop
18
+ * lowerings already use — so detection is structural rather than
19
+ * regex-text-matching. Wrapped expression text is left to the
20
+ * caller's existing `convertExpressionToRuby` pipeline; this module
21
+ * only decides whether to wrap.
22
+ *
23
+ * Detected shapes:
24
+ * - `binary` with a comparison operator (`<`, `>`, `<=`, `>=`,
25
+ * `==`, `===`, `!=`, `!==`)
26
+ * - `unary` with logical `!`
27
+ * - `literal` with `literalType: 'boolean'`
28
+ * - `logical` (`&&` / `||` / `??`) when both sides are themselves
29
+ * boolean-result (catches `x > 0 && y < 10`; intentionally does
30
+ * NOT catch `x() || 'fallback'` whose right side stringifies as
31
+ * a regular value)
32
+ * - `conditional` (`?:`) when both branches are themselves
33
+ * boolean-result
34
+ *
35
+ * Anything else returns `false` — including bare identifiers
36
+ * (`accepted`) and call expressions (`accepted()`) whose return type
37
+ * the adapter has no way to infer from source text alone. Those
38
+ * carry their own (Ruby-coerced) value through unchanged, which
39
+ * stays correct for non-boolean shapes.
40
+ */
41
+
42
+ import { parseExpression, type ParsedExpr } from '@barefootjs/jsx'
43
+
44
+ const COMPARISON_OPS = new Set([
45
+ '<',
46
+ '>',
47
+ '<=',
48
+ '>=',
49
+ '==',
50
+ '===',
51
+ '!=',
52
+ '!==',
53
+ ])
54
+
55
+ function isBooleanResultParsed(node: ParsedExpr): boolean {
56
+ switch (node.kind) {
57
+ case 'literal':
58
+ return node.literalType === 'boolean'
59
+ case 'binary':
60
+ return COMPARISON_OPS.has(node.op)
61
+ case 'unary':
62
+ return node.op === '!'
63
+ case 'logical':
64
+ // `x > 0 && y < 10` is boolean; `x() || 'fallback'` is not.
65
+ // Only both-sides-boolean qualifies.
66
+ return (
67
+ isBooleanResultParsed(node.left) && isBooleanResultParsed(node.right)
68
+ )
69
+ case 'conditional':
70
+ // `cond ? bool : bool` is boolean; `cond ? 'a' : 'b'` is not.
71
+ return (
72
+ isBooleanResultParsed(node.consequent) &&
73
+ isBooleanResultParsed(node.alternate)
74
+ )
75
+ default:
76
+ return false
77
+ }
78
+ }
79
+
80
+ export function isBooleanResultExpr(expr: string): boolean {
81
+ const parsed = parseExpression(expr.trim())
82
+ if (!parsed) return false
83
+ return isBooleanResultParsed(parsed)
84
+ }
85
+
86
+ /**
87
+ * ARIA attributes whose spec values are `"true"`, `"false"`, and (for
88
+ * tri-state members) `"mixed"`. When a fixture binds one of these to
89
+ * an arbitrary JS expression (`aria-checked={accepted()}`), the
90
+ * expression's actual type isn't recoverable from source text — but
91
+ * the attribute name itself witnesses that the binding is
92
+ * boolean-shaped. Routing these through `bf.bool_str` produces the
93
+ * spec-canonical `"true"` / `"false"` even when the expression is
94
+ * opaque.
95
+ *
96
+ * Deliberately conservative — only includes ARIA attributes whose
97
+ * spec value set is exactly `true | false` or `true | false | mixed`.
98
+ * Tokenised ARIA attributes (`aria-current` is `page | step | …`,
99
+ * `aria-sort` is `ascending | descending | …`) are intentionally
100
+ * excluded so a string-valued binding doesn't get coerced to
101
+ * `"true"` / `"false"`.
102
+ */
103
+ const ARIA_BOOLEAN_ATTRS = new Set([
104
+ // Strict boolean state (true | false; some allow `undefined` =
105
+ // attribute absent, which the runtime emits as no-attr regardless).
106
+ 'aria-atomic',
107
+ 'aria-busy',
108
+ 'aria-disabled',
109
+ 'aria-hidden',
110
+ 'aria-modal',
111
+ 'aria-multiline',
112
+ 'aria-multiselectable',
113
+ 'aria-readonly',
114
+ 'aria-required',
115
+ // true | false | undefined (absent) — selection / disclosure state.
116
+ 'aria-selected',
117
+ 'aria-expanded',
118
+ // Tri-state (true | false | mixed). The `bool_str` helper only maps
119
+ // Ruby truthy / falsy to true / false — a fixture that wants the
120
+ // literal `"mixed"` binds a string-valued JSX attr
121
+ // (`aria-checked="mixed"`), which lowers through the `literal` emit
122
+ // path and never touches this code.
123
+ 'aria-checked',
124
+ 'aria-pressed',
125
+ ])
126
+
127
+ export function isAriaBooleanAttr(name: string): boolean {
128
+ return ARIA_BOOLEAN_ATTRS.has(name)
129
+ }
130
+
131
+ /**
132
+ * True when `expr` is (structurally) a top-level `String(...)` call — the
133
+ * one JS shape that has ALREADY fully stringified its argument per JS
134
+ * `String(boolean)` semantics before the attribute emitter's `bool_str`
135
+ * wrap decision runs. `convertExpressionToRuby` lowers `String(x)` through
136
+ * the `ERB_TEMPLATE_PRIMITIVES` registry to `bf.string(x)`, which for a
137
+ * real Ruby `true`/`false` already returns the JS-correct `"true"` /
138
+ * `"false"` text (`Context#string`'s `TrueClass`/`FalseClass` branch).
139
+ * Piping that STRING through `bf.bool_str` again is a bug, not a harmless
140
+ * no-op: Ruby has no falsy-string (only `nil`/`false` are falsy), so
141
+ * `bf.bool_str("false")` unconditionally returns `"true"` — every
142
+ * `aria-checked={String(props.checked ?? false)}`-shaped binding would
143
+ * render `"true"` regardless of the underlying value. Perl doesn't share
144
+ * this bug (`"0"` — what `JSON::PP::false` stringifies to — IS Perl-falsy),
145
+ * which is why the Mojo/Xslate emitters don't need this guard; ERB's
146
+ * truthiness model requires it. Detected structurally off the parsed
147
+ * expression (not a text scan), so a user-defined helper merely NAMED
148
+ * `String` elsewhere can't false-positive: the aria-attr / boolean-result
149
+ * detectors already need real parse trees, and a bespoke `String` lookalike
150
+ * would need to be a genuinely zero-ambiguity call-to-`String` shape to
151
+ * match here in the first place.
152
+ */
153
+ export function isExplicitStringCall(expr: string): boolean {
154
+ const parsed = parseExpression(expr.trim())
155
+ if (!parsed) return false
156
+ return parsed.kind === 'call' && parsed.callee.kind === 'identifier' && parsed.callee.name === 'String'
157
+ }