@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.
- package/dist/adapter/analysis/component-tree.d.ts +24 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +66 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +107 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/erb-adapter.d.ts +374 -0
- package/dist/adapter/erb-adapter.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +87 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +102 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +34 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189154 -0
- package/dist/adapter/lib/constants.d.ts +25 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +27 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/ruby-naming.d.ts +65 -0
- package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +28 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +35 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +50 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +63 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +21 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189174 -0
- package/dist/conformance-pins.d.ts +13 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189172 -0
- package/lib/barefoot_js/backend/erb.rb +123 -0
- package/lib/barefoot_js/dev_reload.rb +159 -0
- package/lib/barefoot_js/evaluator.rb +714 -0
- package/lib/barefoot_js/search_params.rb +63 -0
- package/lib/barefoot_js.rb +1155 -0
- package/package.json +67 -0
- package/src/__tests__/erb-adapter.test.ts +298 -0
- package/src/adapter/analysis/component-tree.ts +122 -0
- package/src/adapter/boolean-result.ts +157 -0
- package/src/adapter/emit-context.ts +119 -0
- package/src/adapter/erb-adapter.ts +1768 -0
- package/src/adapter/expr/array-method.ts +424 -0
- package/src/adapter/expr/emitters.ts +629 -0
- package/src/adapter/expr/operand.ts +55 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/lib/constants.ts +37 -0
- package/src/adapter/lib/ir-scope.ts +51 -0
- package/src/adapter/lib/ruby-naming.ts +127 -0
- package/src/adapter/lib/types.ts +31 -0
- package/src/adapter/memo/seed.ts +75 -0
- package/src/adapter/props/prop-classes.ts +89 -0
- package/src/adapter/spread/spread-codegen.ts +176 -0
- package/src/adapter/value/parsed-literal.ts +29 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +100 -0
- package/src/index.ts +9 -0
- 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
|
+
}
|