@barefootjs/mojolicious 0.1.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/__tests__/boolean-result.test.d.ts +2 -0
- package/dist/adapter/__tests__/boolean-result.test.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +42 -0
- package/dist/adapter/boolean-result.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 +1143 -0
- package/dist/adapter/mojo-adapter.d.ts +219 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +1163 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1143 -0
- package/dist/test-render.d.ts +38 -0
- package/dist/test-render.d.ts.map +1 -0
- package/lib/BarefootJS.pm +745 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +150 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +104 -0
- package/package.json +65 -0
- package/src/__tests__/mojo-adapter.test.ts +940 -0
- package/src/__tests__/mojo-streaming.test.ts +136 -0
- package/src/__tests__/scaffold.test.ts +224 -0
- package/src/__tests__/template-base-name.test.ts +26 -0
- package/src/adapter/__tests__/boolean-result.test.ts +106 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/mojo-adapter.ts +1931 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +704 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MojoAdapter - Tests
|
|
3
|
+
*
|
|
4
|
+
* Conformance tests (shared across adapters) + Mojo-specific tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from 'bun:test'
|
|
8
|
+
import { MojoAdapter } from '../adapter/mojo-adapter'
|
|
9
|
+
import {
|
|
10
|
+
runAdapterConformanceTests,
|
|
11
|
+
TemplatePrimitiveCaseId,
|
|
12
|
+
} from '@barefootjs/adapter-tests'
|
|
13
|
+
import { renderMojoComponent, PerlNotAvailableError } from '../test-render'
|
|
14
|
+
import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
|
|
15
|
+
|
|
16
|
+
runAdapterConformanceTests({
|
|
17
|
+
name: 'mojo',
|
|
18
|
+
factory: () => new MojoAdapter(),
|
|
19
|
+
render: renderMojoComponent,
|
|
20
|
+
// Dynamic style objects (non-static values) require Perl template
|
|
21
|
+
// interpolation support for JS object literals, not yet implemented.
|
|
22
|
+
// Mojo currently emits invalid Perl silently for this shape — the
|
|
23
|
+
// Go adapter records BF101 via `convertExpressionToGo()` for the
|
|
24
|
+
// same fixture (now contracted via `expectedDiagnostics`), but the
|
|
25
|
+
// Mojo adapter's expression gate doesn't yet lift the same
|
|
26
|
+
// failure into a `CompilerError`, so the fixture stays on `skipJsx`
|
|
27
|
+
// until that gate is extended (#1266 follow-up).
|
|
28
|
+
// `logical-or-jsx`, `nullish-coalescing-jsx`, `branch-map` reference
|
|
29
|
+
// a prop directly inside a conditional branch (`$label`, `$banner`,
|
|
30
|
+
// `$active`). The Mojo adapter emits these as bare Perl variables
|
|
31
|
+
// (`% if ($label) { ... }`) without a corresponding
|
|
32
|
+
// `my $label = ...;` declaration, so Perl rejects the template with
|
|
33
|
+
// "Global symbol requires explicit package name". Same class of
|
|
34
|
+
// Perl-scoping divergence that motivates the existing skips —
|
|
35
|
+
// out of scope for the #971 refactor.
|
|
36
|
+
// Return-position variants of the same divergence —
|
|
37
|
+
// `return-logical-or` / `return-nullish-coalescing` reference
|
|
38
|
+
// `$label` / `$banner` directly; `return-map` iterates over `$items`
|
|
39
|
+
// without a `my` declaration.
|
|
40
|
+
//
|
|
41
|
+
// `static-array-children` / `static-array-from-props` /
|
|
42
|
+
// `static-array-from-props-with-component` are no longer here —
|
|
43
|
+
// they're covered by `expectedDiagnostics` below, asserting that
|
|
44
|
+
// the adapter emits `BF103` / `BF104` at build time instead of
|
|
45
|
+
// silently emitting invalid Perl / unresolved cross-template
|
|
46
|
+
// references (#1266).
|
|
47
|
+
skipJsx: [
|
|
48
|
+
'style-object-dynamic',
|
|
49
|
+
'logical-or-jsx',
|
|
50
|
+
'nullish-coalescing-jsx',
|
|
51
|
+
'branch-map',
|
|
52
|
+
'return-logical-or',
|
|
53
|
+
'return-nullish-coalescing',
|
|
54
|
+
'return-map',
|
|
55
|
+
// #1297 fixed the harness-side IR emission gate. The remaining
|
|
56
|
+
// gap is adapter-side: the Mojo adapter has no SSR context-
|
|
57
|
+
// propagation mechanism, so `<Ctx.Provider value="dark">` doesn't
|
|
58
|
+
// make `useContext(Ctx)` resolve to `"dark"` at template-eval
|
|
59
|
+
// time — the template emits `<%= $theme %>` against a hash that
|
|
60
|
+
// never receives a `theme` key. Provider SSR coverage on Mojo
|
|
61
|
+
// waits on that adapter feature; see #1297 follow-up.
|
|
62
|
+
'context-provider',
|
|
63
|
+
// Multi-component fixtures still diverge because Mojo's child
|
|
64
|
+
// template emitter pins the child's `bf-s` to the literal
|
|
65
|
+
// `test_<sN>` (`_scope_id("test_$sid")` in `test-render.ts`)
|
|
66
|
+
// instead of `<ChildName>_<id>_<sN>` like Hono / CSR. Same family
|
|
67
|
+
// of test-harness scope-id plumbing the `componentName` option
|
|
68
|
+
// fixed on the Hono side. Separate follow-up.
|
|
69
|
+
'toggle-shared',
|
|
70
|
+
'reactive-props',
|
|
71
|
+
'props-reactivity-comparison',
|
|
72
|
+
],
|
|
73
|
+
// Per-fixture build-time contracts for shapes the Mojo adapter
|
|
74
|
+
// intentionally refuses to lower. Owned by this adapter test file
|
|
75
|
+
// (not by the shared fixtures) so adding a new adapter doesn't
|
|
76
|
+
// require touching any cross-adapter file.
|
|
77
|
+
expectedDiagnostics: {
|
|
78
|
+
// Sibling-imported child component in a loop body: Mojo emits
|
|
79
|
+
// a cross-template call that needs separate registration. BF103
|
|
80
|
+
// makes the requirement loud. (The barefoot CLI passes
|
|
81
|
+
// `siblingTemplatesRegistered: true` so CLI builds suppress it.)
|
|
82
|
+
'static-array-children': [{ code: 'BF103', severity: 'error' }],
|
|
83
|
+
// TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
|
|
84
|
+
// call it inside a keyed `.map`. Same BF103 surface as the
|
|
85
|
+
// synthetic `static-array-children` above — pinned at adapter
|
|
86
|
+
// level so the shared-component corpus stays adapter-neutral.
|
|
87
|
+
'todo-app': [{ code: 'BF103', severity: 'error' }],
|
|
88
|
+
'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
|
|
89
|
+
// Array-destructure loop param (`([k, v]) => ...`) lowers to
|
|
90
|
+
// invalid Perl (`% my $[k, v] = $entries->[$_i];`).
|
|
91
|
+
'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
|
|
92
|
+
// Both BF103 (imported child) and BF104 (destructure) fire.
|
|
93
|
+
'static-array-from-props-with-component': [
|
|
94
|
+
{ code: 'BF103', severity: 'error' },
|
|
95
|
+
{ code: 'BF104', severity: 'error' },
|
|
96
|
+
],
|
|
97
|
+
// #1310: rest destructure in .map() callback. Hono / CSR lower
|
|
98
|
+
// these via the inline residual-object accessor (#1309); the Mojo
|
|
99
|
+
// adapter's loop emitter raises the generic BF104 destructure
|
|
100
|
+
// refusal regardless of whether the binding is rest or plain.
|
|
101
|
+
// Pinning the contract here makes the limitation declarative.
|
|
102
|
+
'rest-destructure-object-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
103
|
+
// #1244 catalog: rest spread back onto the root element. Same
|
|
104
|
+
// refusal shape as the read-only variant above — `paramBindings`
|
|
105
|
+
// is non-empty so BF104 fires regardless of how `rest` is used.
|
|
106
|
+
'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
107
|
+
'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
108
|
+
'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
109
|
+
// #1244 stress catalog #11 (#1322): JS object literal in an
|
|
110
|
+
// attribute value (`style={{ background: bg(), color: fg() }}`) has
|
|
111
|
+
// no idiomatic Mojo template form. `refuseUnsupportedAttrExpression`
|
|
112
|
+
// surfaces BF101 with a wrap-in-`/* @client */` suggestion, matching
|
|
113
|
+
// the Go adapter's behaviour.
|
|
114
|
+
'style-3-signals': [{ code: 'BF101', severity: 'error' }],
|
|
115
|
+
// #1244 stress catalog #12 (#1323): tagged-template-literal call
|
|
116
|
+
// (`cn\`base \${tone()}\``) — same family as #1322 above and refused
|
|
117
|
+
// via the same gate.
|
|
118
|
+
'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
|
|
119
|
+
// #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
|
|
120
|
+
// shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
|
|
121
|
+
// No BF101 expected — pinned positively via the
|
|
122
|
+
// `branch-local-filter-join` template-output test below.
|
|
123
|
+
//
|
|
124
|
+
// #1448 Tier A — JS Array / String methods that the Mojo adapter
|
|
125
|
+
// hasn't lowered yet. Each row drops once the corresponding
|
|
126
|
+
// method PR lands. Hono / CSR pass these out of the box (they
|
|
127
|
+
// evaluate JS at runtime) so the pin only applies here.
|
|
128
|
+
//
|
|
129
|
+
// `array-includes` / `string-includes` no longer pinned — both
|
|
130
|
+
// shapes lower via the shared `array-method` IR + `bf->includes`
|
|
131
|
+
// runtime dispatch (#1448 Tier A first PR).
|
|
132
|
+
// `array-indexOf` / `array-lastIndexOf` no longer pinned —
|
|
133
|
+
// value-equality `bf->index_of` / `bf->last_index_of` helpers
|
|
134
|
+
// handle the shape (#1448 Tier A second PR).
|
|
135
|
+
// `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
|
|
136
|
+
// handle the negative-index lookup (#1448 Tier A third PR).
|
|
137
|
+
// `array-concat` no longer pinned — `bf->concat` (Mojo) /
|
|
138
|
+
// `bf_concat` (Go) merge two arrays into a new array
|
|
139
|
+
// (#1448 Tier A fourth PR).
|
|
140
|
+
// `array-slice` no longer pinned — `bf->slice` (Mojo) /
|
|
141
|
+
// `bf_slice` (Go) carve out a sub-range with JS-compat
|
|
142
|
+
// negative-index / out-of-bounds clamping (#1448 Tier A
|
|
143
|
+
// fifth PR).
|
|
144
|
+
// `array-reverse` / `array-toReversed` no longer pinned —
|
|
145
|
+
// both share the `bf->reverse` / `bf_reverse` helper since
|
|
146
|
+
// SSR templates render a snapshot and the JS mutate-vs-new
|
|
147
|
+
// distinction has no template-level meaning (#1448 Tier A
|
|
148
|
+
// sixth PR).
|
|
149
|
+
// `string-toLowerCase` / `string-toUpperCase` no longer pinned —
|
|
150
|
+
// Perl's native `lc` / `uc` (Mojo) and pre-existing
|
|
151
|
+
// `bf_lower` / `bf_upper` (Go) handle the JS method names
|
|
152
|
+
// (#1448 Tier A seventh + eighth PRs).
|
|
153
|
+
// `string-trim` no longer pinned — pre-existing `bf_trim`
|
|
154
|
+
// (Go) and new `bf->trim` helper (Mojo) handle the strip
|
|
155
|
+
// (#1448 Tier A ninth PR, closing out Tier A).
|
|
156
|
+
// #1448 catalog — `.find` / `.findIndex` have no Mojo lowering
|
|
157
|
+
// yet (no `array-method` IR variant, no emitter), so the
|
|
158
|
+
// Mojo-specific gate in `convertExpressionToPerl` refuses them
|
|
159
|
+
// up front. `.join` is NOT pinned here — it's lifted to the
|
|
160
|
+
// `array-method` IR by the parser and `renderArrayMethod`'s
|
|
161
|
+
// `case 'join'` emits `join(sep, @{arr})` correctly; the
|
|
162
|
+
// text-expression form is routed through the same AST path.
|
|
163
|
+
'array-find': [{ code: 'BF101', severity: 'error' }],
|
|
164
|
+
'array-findIndex': [{ code: 'BF101', severity: 'error' }],
|
|
165
|
+
},
|
|
166
|
+
// `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
|
|
167
|
+
// via `MojoAdapter.templatePrimitives` (#1189). The two remaining
|
|
168
|
+
// cases stay skipped because the V1 registry is identifier-path-
|
|
169
|
+
// only and explicit:
|
|
170
|
+
// - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
|
|
171
|
+
// the registry and can't be rendered server-side without
|
|
172
|
+
// user-supplied helper mappings.
|
|
173
|
+
// - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
|
|
174
|
+
// too, same reason.
|
|
175
|
+
// Adding new entries to `templatePrimitives` should narrow this
|
|
176
|
+
// skip set; see `MOJO_TEMPLATE_PRIMITIVES` in `mojo-adapter.ts`
|
|
177
|
+
// for the full V1 surface.
|
|
178
|
+
skipTemplatePrimitives: new Set([
|
|
179
|
+
TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
|
|
180
|
+
TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
|
|
181
|
+
]),
|
|
182
|
+
// Mojo `renderLoop` does not yet emit the `bf->comment("loop:<id>")`
|
|
183
|
+
// boundary markers when the loop is `@client` (Hono and Go both do).
|
|
184
|
+
// The client runtime relies on these markers to locate the insertion
|
|
185
|
+
// anchor when hydrating the array; without them, mapArray() resolves
|
|
186
|
+
// anchor = null and appends after sibling markers (#872 parity).
|
|
187
|
+
// Tracked as a follow-up; remove from this set when Mojo emits the
|
|
188
|
+
// boundary pair for clientOnly loops too.
|
|
189
|
+
skipMarkerConformance: new Set([
|
|
190
|
+
'client-only',
|
|
191
|
+
'client-only-loop-with-sibling-cond',
|
|
192
|
+
// Same as Hono: `/* @client */` markers on TodoApp's keyed `.map`
|
|
193
|
+
// intentionally elide a slot id from the SSR template that the IR
|
|
194
|
+
// still declares (s6). See hono-adapter.test for the contract.
|
|
195
|
+
'todo-app',
|
|
196
|
+
]),
|
|
197
|
+
onRenderError: (err, id) => {
|
|
198
|
+
if (err instanceof PerlNotAvailableError) {
|
|
199
|
+
console.log(`Skipping [${id}]: ${err.message}`)
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
return false
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// Helpers
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
function compileToIR(source: string, adapter?: MojoAdapter): ComponentIR {
|
|
211
|
+
const result = compileJSX(source.trimStart(), 'test.tsx', {
|
|
212
|
+
adapter: adapter ?? new MojoAdapter(),
|
|
213
|
+
outputIR: true,
|
|
214
|
+
})
|
|
215
|
+
const irFile = result.files.find(f => f.type === 'ir')
|
|
216
|
+
if (!irFile) throw new Error('No IR output')
|
|
217
|
+
return JSON.parse(irFile.content) as ComponentIR
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function compileAndGenerate(source: string, adapter?: MojoAdapter) {
|
|
221
|
+
const a = adapter ?? new MojoAdapter()
|
|
222
|
+
const ir = compileToIR(source, a)
|
|
223
|
+
return a.generate(ir)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// Mojo-Specific Tests
|
|
228
|
+
// =============================================================================
|
|
229
|
+
|
|
230
|
+
describe('MojoAdapter - Template Generation', () => {
|
|
231
|
+
test('generates basic element with scope marker', () => {
|
|
232
|
+
const result = compileAndGenerate(`
|
|
233
|
+
export function Hello() {
|
|
234
|
+
return <div>Hello</div>
|
|
235
|
+
}
|
|
236
|
+
`)
|
|
237
|
+
expect(result.template).toContain('<div')
|
|
238
|
+
expect(result.template).toContain('Hello')
|
|
239
|
+
expect(result.template).toContain('bf-s=')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('generates .html.ep extension', () => {
|
|
243
|
+
const adapter = new MojoAdapter()
|
|
244
|
+
expect(adapter.extension).toBe('.html.ep')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('generates conditional with Perl if/else', () => {
|
|
248
|
+
const result = compileAndGenerate(`
|
|
249
|
+
"use client"
|
|
250
|
+
import { createSignal } from "@barefootjs/client"
|
|
251
|
+
|
|
252
|
+
export function Toggle() {
|
|
253
|
+
const [active, setActive] = createSignal(false)
|
|
254
|
+
return <div>{active() ? 'On' : 'Off'}</div>
|
|
255
|
+
}
|
|
256
|
+
`)
|
|
257
|
+
expect(result.template).toContain('% if')
|
|
258
|
+
expect(result.template).toContain('% }')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('generates loop with Perl for', () => {
|
|
262
|
+
const result = compileAndGenerate(`
|
|
263
|
+
"use client"
|
|
264
|
+
import { createSignal } from "@barefootjs/client"
|
|
265
|
+
|
|
266
|
+
export function List() {
|
|
267
|
+
const [items, setItems] = createSignal<string[]>([])
|
|
268
|
+
return <ul>{items().map(item => <li>{item}</li>)}</ul>
|
|
269
|
+
}
|
|
270
|
+
`)
|
|
271
|
+
expect(result.template).toContain('% for my')
|
|
272
|
+
// Markers are scoped per-call-site (#1087): `bf->comment("loop:<id>")`.
|
|
273
|
+
expect(result.template).toMatch(/bf->comment\("loop:[^"]+"\)/)
|
|
274
|
+
expect(result.template).toMatch(/bf->comment\("\/loop:[^"]+"\)/)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('generates script registration for client components', () => {
|
|
278
|
+
const result = compileAndGenerate(`
|
|
279
|
+
"use client"
|
|
280
|
+
import { createSignal } from "@barefootjs/client"
|
|
281
|
+
|
|
282
|
+
export function Counter() {
|
|
283
|
+
const [count, setCount] = createSignal(0)
|
|
284
|
+
return <div>{count()}</div>
|
|
285
|
+
}
|
|
286
|
+
`)
|
|
287
|
+
expect(result.template).toContain("bf->register_script")
|
|
288
|
+
expect(result.template).toContain('barefoot.js')
|
|
289
|
+
expect(result.template).toContain('Counter.client.js')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('does not generate script registration for static components', () => {
|
|
293
|
+
const result = compileAndGenerate(`
|
|
294
|
+
export function Static() {
|
|
295
|
+
return <div>Static content</div>
|
|
296
|
+
}
|
|
297
|
+
`)
|
|
298
|
+
expect(result.template).not.toContain("bf->register_script")
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('forwards JSX children via begin/end capture (#1202)', () => {
|
|
302
|
+
const result = compileAndGenerate(`
|
|
303
|
+
'use client'
|
|
304
|
+
export function Page() {
|
|
305
|
+
return <main><Card><span>hello</span><span>world</span></Card></main>
|
|
306
|
+
}
|
|
307
|
+
`)
|
|
308
|
+
// Capture lives in its own action so the inner `%>` can't close
|
|
309
|
+
// the outer render_child tag.
|
|
310
|
+
expect(result.template).toMatch(/<% my \$bf_children_\w+ = begin %>/)
|
|
311
|
+
expect(result.template).toContain('<span>hello</span><span>world</span>')
|
|
312
|
+
expect(result.template).toContain('<% end %>')
|
|
313
|
+
expect(result.template).toMatch(
|
|
314
|
+
/bf->render_child\('card'.*children => \$bf_children_\w+\)/,
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('omits children entry when component has no JSX children', () => {
|
|
319
|
+
const result = compileAndGenerate(`
|
|
320
|
+
'use client'
|
|
321
|
+
export function Page() {
|
|
322
|
+
return <main><Card label="x" /></main>
|
|
323
|
+
}
|
|
324
|
+
`)
|
|
325
|
+
expect(result.template).not.toContain('begin %>')
|
|
326
|
+
expect(result.template).not.toContain('children =>')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('emits BF101 for JS-only filter / array patterns the Mojo adapter cannot lower to EP', () => {
|
|
330
|
+
// These patterns previously fell through Mojo's regex pipeline and
|
|
331
|
+
// emitted broken Embedded Perl silently (e.g. `$items->{filter}->[...]`
|
|
332
|
+
// for a destructured filter, `[grep {...}]->{length}` for nested
|
|
333
|
+
// higher-order). The Go adapter rejects them via its
|
|
334
|
+
// `convertExpressionToGo` / `renderFilterExpr` gates with BF101;
|
|
335
|
+
// these tests pin Mojo to the same contract so users on
|
|
336
|
+
// non-JS-runtime adapters see a compile error and can either
|
|
337
|
+
// rewrite or add `/* @client */`.
|
|
338
|
+
const wrap = (body: string) => `'use client'
|
|
339
|
+
import { createSignal } from '@barefootjs/client'
|
|
340
|
+
export function C() {
|
|
341
|
+
const [items] = createSignal<any[]>([])
|
|
342
|
+
return ${body}
|
|
343
|
+
}`
|
|
344
|
+
|
|
345
|
+
// #1443 follow-up: destructured filter param and function-keyword
|
|
346
|
+
// filter no longer fall in this BF101 group — they lower cleanly
|
|
347
|
+
// via parser-side normalisation (see `lowers .filter(({done}) =>
|
|
348
|
+
// done) ...` parser tests + the Mojo positive-output test below).
|
|
349
|
+
// The nested-higher-order-in-filter-predicate shape also lowers
|
|
350
|
+
// now (#1443 PR4) — moved to a positive-output test below.
|
|
351
|
+
const cases: { name: string; body: string; needle: string }[] = [
|
|
352
|
+
{ name: 'reduce', body: `<div>{items().reduce((s, x) => s + x, 0)}</div>`, needle: '.reduce(' },
|
|
353
|
+
{ name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
|
|
354
|
+
{ name: 'flatMap', body: `<ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>`, needle: '.flatMap(' },
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
for (const { name, body, needle } of cases) {
|
|
358
|
+
test(`${name} → BF101`, () => {
|
|
359
|
+
const adapter = new MojoAdapter()
|
|
360
|
+
const result = compileJSX(wrap(body), 'C.tsx', { adapter })
|
|
361
|
+
const bf101 = result.errors?.filter(e => e.code === 'BF101') ?? []
|
|
362
|
+
expect(bf101.length).toBeGreaterThan(0)
|
|
363
|
+
expect(bf101.some(e => e.message.includes(needle))).toBe(true)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test(`${name} + /* @client */ suppresses BF101`, () => {
|
|
367
|
+
const adapter = new MojoAdapter()
|
|
368
|
+
const wrappedBody = body.replace(/\{(?!\/\* @client \*\/)/, '{/* @client */ ')
|
|
369
|
+
const result = compileJSX(wrap(wrappedBody), 'C.tsx', { adapter })
|
|
370
|
+
const bf101 = result.errors?.filter(e => e.code === 'BF101') ?? []
|
|
371
|
+
expect(bf101).toEqual([])
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('lowers .filter(({done}) => done).map(...) — destructured filter param (#1443)', () => {
|
|
377
|
+
// Pre-#1443 the destructured arrow rejected at the parser and the
|
|
378
|
+
// surrounding `.map()` loop fell back to a BF101 path. With the
|
|
379
|
+
// parser rewriting `({done}) => done` to `_t => _t.done`, the
|
|
380
|
+
// adapter's existing `IRLoop.filterPredicate` path renders the
|
|
381
|
+
// chain as a Perl `for` over `grep { $_->{done} } @{$items}`.
|
|
382
|
+
const adapter = new MojoAdapter()
|
|
383
|
+
const result = compileJSX(`'use client'
|
|
384
|
+
import { createSignal } from '@barefootjs/client'
|
|
385
|
+
export function C() {
|
|
386
|
+
const [items] = createSignal<any[]>([])
|
|
387
|
+
return <ul>{items().filter(({done}) => done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
388
|
+
}`, 'C.tsx', { adapter })
|
|
389
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
390
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
391
|
+
expect(template).toContain('grep { $_->{done} } @{$items}')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('lowers nested .filter(...).length > 0 in outer filter predicate (#1443 PR4)', () => {
|
|
395
|
+
// Pre-#1443 PR4: the predicate `x => x.tags.filter(t => t.active).length > 0`
|
|
396
|
+
// emitted `[grep { ... } ...]->{length}` — a hash-key lookup on
|
|
397
|
+
// an anonymous array ref, undef at runtime. The
|
|
398
|
+
// `containsHigherOrder` gate refused this outright with BF101.
|
|
399
|
+
// PR4 fixes the `member` emit for `.length` on higher-order
|
|
400
|
+
// objects to produce `scalar(@{...})` and removes the gate, so
|
|
401
|
+
// the canonical "tags have at least one active" shape lowers
|
|
402
|
+
// to valid EP.
|
|
403
|
+
const adapter = new MojoAdapter()
|
|
404
|
+
const result = compileJSX(`'use client'
|
|
405
|
+
import { createSignal } from '@barefootjs/client'
|
|
406
|
+
export function C() {
|
|
407
|
+
const [items] = createSignal<any[]>([])
|
|
408
|
+
return <ul>{items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
409
|
+
}`, 'C.tsx', { adapter })
|
|
410
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
411
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
412
|
+
expect(template).toContain('scalar(@{[grep { $_->{active} } @{$t->{tags}}]})')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('lowers .filter(function (x) { return x.done }).map(...) — function-keyword filter (#1443)', () => {
|
|
416
|
+
// Function expressions with a single `return <expr>` body normalise
|
|
417
|
+
// to the arrow-fn IR shape at parse time, so the higher-order
|
|
418
|
+
// detector + adapter lowering paths fire alongside their arrow
|
|
419
|
+
// counterparts.
|
|
420
|
+
const adapter = new MojoAdapter()
|
|
421
|
+
const result = compileJSX(`'use client'
|
|
422
|
+
import { createSignal } from '@barefootjs/client'
|
|
423
|
+
export function C() {
|
|
424
|
+
const [items] = createSignal<any[]>([])
|
|
425
|
+
return <ul>{items().filter(function (x) { return x.done }).map(t => <li key={t.id}>{t.name}</li>)}</ul>
|
|
426
|
+
}`, 'C.tsx', { adapter })
|
|
427
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
428
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
429
|
+
expect(template).toContain('grep { $_->{done} } @{$items}')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('lowers the registry Slot\'s [a, b].filter(Boolean).join(\' \') chain (#1443)', () => {
|
|
433
|
+
// The registry `<Slot>` builds its merged className via
|
|
434
|
+
// `[className, childClass].filter(Boolean).join(' ')`. Pre-#1443
|
|
435
|
+
// each link in the chain (array literal, `Boolean` callable
|
|
436
|
+
// filter, `.join`) hit a separate refusal gate and the chain
|
|
437
|
+
// emitted BF101 — making the scaffold `<Button>` / `<Card>`
|
|
438
|
+
// unusable on Mojo. The fix lowers all three to Embedded Perl
|
|
439
|
+
// (`join(' ', @{[grep { $_ } @{[...]}]})`), unblocking the
|
|
440
|
+
// registry surface. The #1421 recursion guard stays in place
|
|
441
|
+
// as defence in depth for other unsupported shapes, but this
|
|
442
|
+
// specific chain no longer reaches the loop because the parser
|
|
443
|
+
// succeeds.
|
|
444
|
+
const adapter = new MojoAdapter()
|
|
445
|
+
const result = compileJSX(
|
|
446
|
+
`
|
|
447
|
+
"use client"
|
|
448
|
+
function Slot({ children, className }: { children?: unknown; className?: string }) {
|
|
449
|
+
if (children) {
|
|
450
|
+
const merged = [className].filter(Boolean).join(' ')
|
|
451
|
+
return <div className={merged}>x</div>
|
|
452
|
+
}
|
|
453
|
+
return <div>fallback</div>
|
|
454
|
+
}
|
|
455
|
+
export { Slot }
|
|
456
|
+
`.trimStart(),
|
|
457
|
+
'slot.tsx',
|
|
458
|
+
{ adapter },
|
|
459
|
+
)
|
|
460
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
461
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
462
|
+
expect(template).toContain(`join(' ', @{[grep { $_ } @{[$className]}]})`)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('lowers .includes(x) on an array prop via bf->includes(...) (#1448 Tier A)', () => {
|
|
466
|
+
// Pre-#1448: `items.includes(target)` rejected at the parser
|
|
467
|
+
// (`UNSUPPORTED_METHODS`) and surfaced as BF101. The lowering
|
|
468
|
+
// now routes through the shared `array-method` IR + the
|
|
469
|
+
// `bf->includes` helper, which inspects `ref()` to dispatch
|
|
470
|
+
// between ARRAY-ref element search and scalar substring search.
|
|
471
|
+
//
|
|
472
|
+
// The bare `bf->` form (no `$` prefix) matches every other
|
|
473
|
+
// helper emit in this adapter; the standalone Mojo::Template
|
|
474
|
+
// test render in `test-render.ts` rewrites it to `$bf->` so
|
|
475
|
+
// both render paths stay consistent.
|
|
476
|
+
const adapter = new MojoAdapter()
|
|
477
|
+
const result = compileJSX(`'use client'
|
|
478
|
+
import { createSignal } from '@barefootjs/client'
|
|
479
|
+
export function C() {
|
|
480
|
+
const [items] = createSignal<string[]>([])
|
|
481
|
+
const [target] = createSignal('x')
|
|
482
|
+
return <div>{items().includes(target()) ? 'yes' : 'no'}</div>
|
|
483
|
+
}`, 'C.tsx', { adapter })
|
|
484
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
485
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
486
|
+
expect(template).toContain('bf->includes($items, $target)')
|
|
487
|
+
// Defensive pin: no leaked `$bf->` (would survive the test-render
|
|
488
|
+
// patch as `$$bf->` and crash perl with "Not a SCALAR reference").
|
|
489
|
+
expect(template).not.toContain('$bf->includes')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('lowers .includes(sub) on a string prop via bf->includes(...) (#1448 Tier A)', () => {
|
|
493
|
+
// String receiver shares the IR node with the array form; the
|
|
494
|
+
// helper's `ref() ne 'ARRAY'` branch falls through to
|
|
495
|
+
// `index(...) != -1`. Pinning the emit shape — same emitter
|
|
496
|
+
// surface, different runtime behaviour.
|
|
497
|
+
const adapter = new MojoAdapter()
|
|
498
|
+
const result = compileJSX(`'use client'
|
|
499
|
+
import { createSignal } from '@barefootjs/client'
|
|
500
|
+
export function C() {
|
|
501
|
+
const [value] = createSignal('hello world')
|
|
502
|
+
const [needle] = createSignal('world')
|
|
503
|
+
return <div>{value().includes(needle()) ? 'yes' : 'no'}</div>
|
|
504
|
+
}`, 'C.tsx', { adapter })
|
|
505
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
506
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
507
|
+
expect(template).toContain('bf->includes($value, $needle)')
|
|
508
|
+
expect(template).not.toContain('$bf->includes')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test('lowers .indexOf(x) on an array prop via bf->index_of(...) (#1448 Tier A)', () => {
|
|
512
|
+
// Value-equality search. Mojo's `bf->index_of` walks the array
|
|
513
|
+
// forward and returns the first matching index (or -1). The
|
|
514
|
+
// existing `.find` lowering uses Perl `grep` for struct-field
|
|
515
|
+
// find — disjoint surface, disjoint helpers.
|
|
516
|
+
const adapter = new MojoAdapter()
|
|
517
|
+
const result = compileJSX(`'use client'
|
|
518
|
+
import { createSignal } from '@barefootjs/client'
|
|
519
|
+
export function C() {
|
|
520
|
+
const [items] = createSignal<string[]>([])
|
|
521
|
+
const [target] = createSignal('x')
|
|
522
|
+
return <div>idx: {items().indexOf(target())}</div>
|
|
523
|
+
}`, 'C.tsx', { adapter })
|
|
524
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
525
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
526
|
+
expect(template).toContain('bf->index_of($items, $target)')
|
|
527
|
+
expect(template).not.toContain('$bf->index_of')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('lowers .lastIndexOf(x) on an array prop via bf->last_index_of(...) (#1448 Tier A)', () => {
|
|
531
|
+
// Backward-walk variant. Sharing a helper module with index_of
|
|
532
|
+
// keeps the dispatch trivial (`_array_index_of(..., $reverse)`)
|
|
533
|
+
// and the per-direction emit a one-liner.
|
|
534
|
+
const adapter = new MojoAdapter()
|
|
535
|
+
const result = compileJSX(`'use client'
|
|
536
|
+
import { createSignal } from '@barefootjs/client'
|
|
537
|
+
export function C() {
|
|
538
|
+
const [items] = createSignal<string[]>([])
|
|
539
|
+
const [target] = createSignal('x')
|
|
540
|
+
return <div>last: {items().lastIndexOf(target())}</div>
|
|
541
|
+
}`, 'C.tsx', { adapter })
|
|
542
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
543
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
544
|
+
expect(template).toContain('bf->last_index_of($items, $target)')
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('lowers .at(-1) on an array prop via bf->at(...) (#1448 Tier A)', () => {
|
|
548
|
+
// Negative indices are the canonical reason an author reaches
|
|
549
|
+
// for `.at` over `[i]`; pinning `.at(-1)` (last element) — a
|
|
550
|
+
// positive-only lowering would still pass `.at(0)` but fail
|
|
551
|
+
// here.
|
|
552
|
+
const adapter = new MojoAdapter()
|
|
553
|
+
const result = compileJSX(`function A({ items }: { items: string[] }) {
|
|
554
|
+
return <div>last: {items.at(-1)}</div>
|
|
555
|
+
}
|
|
556
|
+
export { A }`, 'A.tsx', { adapter })
|
|
557
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
558
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
559
|
+
expect(template).toContain('bf->at($items, -1)')
|
|
560
|
+
expect(template).not.toContain('$bf->at(')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('lowers .toLowerCase() via Perl native lc (#1448 Tier A)', () => {
|
|
564
|
+
// Perl's `lc` is the native lowering — no helper needed.
|
|
565
|
+
// Defensive: must not emit a `$lc(...)` form (which the
|
|
566
|
+
// test-render patch would mangle); emit must be the bare
|
|
567
|
+
// `lc(...)` call so it stays well-formed in both the
|
|
568
|
+
// standalone test renderer and real Mojolicious.
|
|
569
|
+
const adapter = new MojoAdapter()
|
|
570
|
+
const result = compileJSX(`function A({ value }: { value: string }) {
|
|
571
|
+
return <div>{value.toLowerCase()}</div>
|
|
572
|
+
}
|
|
573
|
+
export { A }`, 'A.tsx', { adapter })
|
|
574
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
575
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
576
|
+
expect(template).toContain('lc($value)')
|
|
577
|
+
expect(template).not.toContain('$lc(')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
test('lowers .toUpperCase() via Perl native uc (#1448 Tier A)', () => {
|
|
581
|
+
// Mirrors toLowerCase — Perl's `uc` builtin, no helper.
|
|
582
|
+
const adapter = new MojoAdapter()
|
|
583
|
+
const result = compileJSX(`function A({ value }: { value: string }) {
|
|
584
|
+
return <div>{value.toUpperCase()}</div>
|
|
585
|
+
}
|
|
586
|
+
export { A }`, 'A.tsx', { adapter })
|
|
587
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
588
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
589
|
+
expect(template).toContain('uc($value)')
|
|
590
|
+
expect(template).not.toContain('$uc(')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test('lowers .trim() via bf->trim helper (#1448 Tier A)', () => {
|
|
594
|
+
// No native Perl `trim`; the helper wraps a single regex so an
|
|
595
|
+
// undef receiver (common for missing-prop case) doesn't trigger
|
|
596
|
+
// a substitution-on-undef warning.
|
|
597
|
+
const adapter = new MojoAdapter()
|
|
598
|
+
const result = compileJSX(`function A({ value }: { value: string }) {
|
|
599
|
+
return <div>[{value.trim()}]</div>
|
|
600
|
+
}
|
|
601
|
+
export { A }`, 'A.tsx', { adapter })
|
|
602
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
603
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
604
|
+
expect(template).toContain('bf->trim($value)')
|
|
605
|
+
expect(template).not.toContain('$bf->trim')
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('lowers .reverse().join(\' \') via bf->reverse + join (#1448 Tier A)', () => {
|
|
609
|
+
// SSR templates render a snapshot, so `.reverse` and
|
|
610
|
+
// `.toReversed` share a Mojo lowering — both return a new
|
|
611
|
+
// ARRAY ref so downstream `.join(...)` composes naturally.
|
|
612
|
+
const adapter = new MojoAdapter()
|
|
613
|
+
const result = compileJSX(`function A({ items }: { items: string[] }) {
|
|
614
|
+
return <div>{items.reverse().join(' ')}</div>
|
|
615
|
+
}
|
|
616
|
+
export { A }`, 'A.tsx', { adapter })
|
|
617
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
618
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
619
|
+
expect(template).toContain("join(' ', @{bf->reverse($items)})")
|
|
620
|
+
expect(template).not.toContain('$bf->reverse')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
test('lowers .toReversed().join(\' \') via the same bf->reverse helper', () => {
|
|
624
|
+
const adapter = new MojoAdapter()
|
|
625
|
+
const result = compileJSX(`function A({ items }: { items: string[] }) {
|
|
626
|
+
return <div>{items.toReversed().join(' ')}</div>
|
|
627
|
+
}
|
|
628
|
+
export { A }`, 'A.tsx', { adapter })
|
|
629
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
630
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
631
|
+
expect(template).toContain("join(' ', @{bf->reverse($items)})")
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
test('lowers .slice(start, end).join(\' \') via bf->slice + join (#1448 Tier A)', () => {
|
|
635
|
+
// 2-arg form. Canonical Tier A fixture pins the start+end shape.
|
|
636
|
+
const adapter = new MojoAdapter()
|
|
637
|
+
const result = compileJSX(`function A({ items }: { items: string[] }) {
|
|
638
|
+
return <div>{items.slice(1, 3).join(' ')}</div>
|
|
639
|
+
}
|
|
640
|
+
export { A }`, 'A.tsx', { adapter })
|
|
641
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
642
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
643
|
+
expect(template).toContain("join(' ', @{bf->slice($items, 1, 3)})")
|
|
644
|
+
expect(template).not.toContain('$bf->slice')
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('lowers .slice(start) (1-arg) via bf->slice with end=undef', () => {
|
|
648
|
+
// 1-arg form. The Perl helper treats undef `end` as
|
|
649
|
+
// "to length", matching the Go variadic-arg-absent case.
|
|
650
|
+
const adapter = new MojoAdapter()
|
|
651
|
+
const result = compileJSX(`function A({ items }: { items: string[] }) {
|
|
652
|
+
return <div>{items.slice(2).join(' ')}</div>
|
|
653
|
+
}
|
|
654
|
+
export { A }`, 'A.tsx', { adapter })
|
|
655
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
656
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
657
|
+
expect(template).toContain("join(' ', @{bf->slice($items, 2, undef)})")
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
test('lowers .concat(other).join(\' \') via bf->concat + join (#1448 Tier A)', () => {
|
|
661
|
+
// Composition pin: the canonical Tier A fixture
|
|
662
|
+
// (`packages/adapter-tests/fixtures/methods/array-concat.ts`)
|
|
663
|
+
// chains `.concat(...).join(' ')`. The Mojo helper returns an
|
|
664
|
+
// ARRAY ref so the downstream `@{...}` dereference in `join(...)`
|
|
665
|
+
// works without an extra coercion.
|
|
666
|
+
const adapter = new MojoAdapter()
|
|
667
|
+
const result = compileJSX(`function A({ left, right }: { left: string[]; right: string[] }) {
|
|
668
|
+
return <div>{left.concat(right).join(' ')}</div>
|
|
669
|
+
}
|
|
670
|
+
export { A }`, 'A.tsx', { adapter })
|
|
671
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
672
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
673
|
+
expect(template).toContain("join(' ', @{bf->concat($left, $right)})")
|
|
674
|
+
expect(template).not.toContain('$bf->concat')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('does not leak module-level export statements into the .html.ep template', () => {
|
|
678
|
+
// Regression: trailing `export { Name }` / `export type { ... }` lines
|
|
679
|
+
// were concatenated into the single-component template content, so
|
|
680
|
+
// Mojolicious rendered them as visible HTML text (the create-barefootjs
|
|
681
|
+
// scaffold's registry Button has this shape).
|
|
682
|
+
const result = compileJSX(
|
|
683
|
+
`
|
|
684
|
+
type ButtonVariant = 'default' | 'secondary'
|
|
685
|
+
|
|
686
|
+
function Button(props: { variant?: ButtonVariant, children?: unknown }) {
|
|
687
|
+
return <button className={props.variant ?? 'default'}>{props.children}</button>
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export { Button }
|
|
691
|
+
export type { ButtonVariant }
|
|
692
|
+
`.trimStart(),
|
|
693
|
+
'button.tsx',
|
|
694
|
+
{ adapter: new MojoAdapter() },
|
|
695
|
+
)
|
|
696
|
+
const template = result.files.find(f => f.type === 'markedTemplate')
|
|
697
|
+
expect(template).toBeDefined()
|
|
698
|
+
expect(template!.content).not.toContain('export {')
|
|
699
|
+
expect(template!.content).not.toContain('export type')
|
|
700
|
+
})
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
describe('MojoAdapter - templatePrimitives (#1189)', () => {
|
|
704
|
+
// The registry fires when the call appears DIRECTLY in a JSX
|
|
705
|
+
// expression position. Chained-const usage (`const j =
|
|
706
|
+
// JSON.stringify(...); <div data-x={j}>`) routes through the
|
|
707
|
+
// adapter's own const-resolution path; the conformance test for
|
|
708
|
+
// that shape inspects the CLIENT JS, where the call IS inlined
|
|
709
|
+
// (relocate accepts via the registry's boolean-acceptance side).
|
|
710
|
+
|
|
711
|
+
test('JSON.stringify(props.x) emits bf->json($x) in SSR template', () => {
|
|
712
|
+
const result = compileAndGenerate(`
|
|
713
|
+
'use client'
|
|
714
|
+
export function Foo(props: { config: object }) {
|
|
715
|
+
return <div data-config={JSON.stringify(props.config)}>hi</div>
|
|
716
|
+
}
|
|
717
|
+
`)
|
|
718
|
+
expect(result.template).toContain('bf->json($config)')
|
|
719
|
+
expect(result.template).not.toContain('JSON.stringify')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('Math.floor(props.score) emits bf->floor($score) in SSR template', () => {
|
|
723
|
+
const result = compileAndGenerate(`
|
|
724
|
+
'use client'
|
|
725
|
+
export function Foo(props: { score: number }) {
|
|
726
|
+
return <div data-rounded={Math.floor(props.score)}>hi</div>
|
|
727
|
+
}
|
|
728
|
+
`)
|
|
729
|
+
expect(result.template).toContain('bf->floor($score)')
|
|
730
|
+
expect(result.template).not.toContain('Math.floor')
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
test('Math.ceil / Math.round map to bf->ceil / bf->round', () => {
|
|
734
|
+
const ceilResult = compileAndGenerate(`
|
|
735
|
+
'use client'
|
|
736
|
+
export function Foo(props: { v: number }) {
|
|
737
|
+
return <div data-x={Math.ceil(props.v)}>hi</div>
|
|
738
|
+
}
|
|
739
|
+
`)
|
|
740
|
+
expect(ceilResult.template).toContain('bf->ceil($v)')
|
|
741
|
+
|
|
742
|
+
const roundResult = compileAndGenerate(`
|
|
743
|
+
'use client'
|
|
744
|
+
export function Foo(props: { v: number }) {
|
|
745
|
+
return <div data-x={Math.round(props.v)}>hi</div>
|
|
746
|
+
}
|
|
747
|
+
`)
|
|
748
|
+
expect(roundResult.template).toContain('bf->round($v)')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
test('String(props.x) and Number(props.x) emit bf->string / bf->number', () => {
|
|
752
|
+
const stringResult = compileAndGenerate(`
|
|
753
|
+
'use client'
|
|
754
|
+
export function Foo(props: { v: number }) {
|
|
755
|
+
return <div data-x={String(props.v)}>hi</div>
|
|
756
|
+
}
|
|
757
|
+
`)
|
|
758
|
+
expect(stringResult.template).toContain('bf->string($v)')
|
|
759
|
+
|
|
760
|
+
const numberResult = compileAndGenerate(`
|
|
761
|
+
'use client'
|
|
762
|
+
export function Foo(props: { v: string }) {
|
|
763
|
+
return <div data-x={Number(props.v)}>hi</div>
|
|
764
|
+
}
|
|
765
|
+
`)
|
|
766
|
+
expect(numberResult.template).toContain('bf->number($v)')
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
test('nested primitive call (Math.floor(Number(props.x))) chains correctly', () => {
|
|
770
|
+
const result = compileAndGenerate(`
|
|
771
|
+
'use client'
|
|
772
|
+
export function Foo(props: { v: string }) {
|
|
773
|
+
return <div data-x={Math.floor(Number(props.v))}>hi</div>
|
|
774
|
+
}
|
|
775
|
+
`)
|
|
776
|
+
expect(result.template).toContain('bf->floor(bf->number($v))')
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
test('registry exposes the V1 callee surface', () => {
|
|
780
|
+
// Pin the V1 surface so a future refactor doesn't accidentally
|
|
781
|
+
// drop a primitive. New entries are additive — extend this
|
|
782
|
+
// list rather than replace.
|
|
783
|
+
const a = new MojoAdapter()
|
|
784
|
+
const keys = Object.keys(a.templatePrimitives ?? {}).sort()
|
|
785
|
+
expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
test('unregistered identifier-path callee is NOT accepted', () => {
|
|
789
|
+
const a = new MojoAdapter()
|
|
790
|
+
expect(a.templatePrimitives?.['customSerialize']).toBeUndefined()
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
test('wrong-arity primitive call falls back instead of emitting invalid Perl', () => {
|
|
794
|
+
// V1 emit fns expect 1 arg. A 2-arg `JSON.stringify(x, replacer)`
|
|
795
|
+
// must not produce `bf->json($x, $replacer)` (which Perl would
|
|
796
|
+
// accept silently) — the arity gate records BF101 and leaves
|
|
797
|
+
// the call un-substituted.
|
|
798
|
+
const result = compileAndGenerate(`
|
|
799
|
+
'use client'
|
|
800
|
+
export function Foo(props: { config: object; replacer: any }) {
|
|
801
|
+
return <div data-x={JSON.stringify(props.config, props.replacer)}>hi</div>
|
|
802
|
+
}
|
|
803
|
+
`)
|
|
804
|
+
expect(result.template).not.toContain('bf->json')
|
|
805
|
+
})
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
describe('MojoAdapter - render_child template-parts dispatch (#1275)', () => {
|
|
809
|
+
// The IR producer collapses a structured `template` AttrValue into
|
|
810
|
+
// `expression` for component props (so the value can flow through
|
|
811
|
+
// runtime hydration), but it keeps the parsed parts on
|
|
812
|
+
// `ExpressionAttr.parts`. The Mojo adapter must dispatch to
|
|
813
|
+
// `convertTemplateLiteralPartsToPerl` when those parts are present
|
|
814
|
+
// — otherwise the bare JS source leaks into the Perl template (the
|
|
815
|
+
// original #1275 failure: a `({...})[key]` Perl parse error and the
|
|
816
|
+
// scaffold's Button rendering with no `class` attribute end-to-end).
|
|
817
|
+
test('record-index-lookup via child prop emits Perl hash lookup, not raw JS', () => {
|
|
818
|
+
const result = compileAndGenerate(`
|
|
819
|
+
import { Slot } from './slot'
|
|
820
|
+
export function V({ variant }: { variant: 'a' | 'b' }) {
|
|
821
|
+
const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
|
|
822
|
+
return <Slot className={\`base \${classes[variant]}\`}>hi</Slot>
|
|
823
|
+
}
|
|
824
|
+
`)
|
|
825
|
+
// The Perl hash form means the parts dispatch fired.
|
|
826
|
+
expect(result.template).toContain("'a' => 'class-a'")
|
|
827
|
+
expect(result.template).toContain("'b' => 'class-b'")
|
|
828
|
+
expect(result.template).toContain("->{$variant}")
|
|
829
|
+
// Negative pin: the raw JS object-literal shape must NOT survive
|
|
830
|
+
// into the Mojo template. The original bug emitted
|
|
831
|
+
// `({"a": "class-a", "b": "class-b"})[variant]` directly into the
|
|
832
|
+
// `render_child` argument string.
|
|
833
|
+
expect(result.template).not.toContain('{"a":')
|
|
834
|
+
expect(result.template).not.toContain('"a": "class-a"')
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('intermediate-const composition (Button shape) carries through', () => {
|
|
838
|
+
const result = compileAndGenerate(`
|
|
839
|
+
import { Slot } from './slot'
|
|
840
|
+
export function V({ variant }: { variant: 'a' | 'b' }) {
|
|
841
|
+
const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
|
|
842
|
+
const composed = \`base \${classes[variant]}\`
|
|
843
|
+
return <Slot className={composed}>hi</Slot>
|
|
844
|
+
}
|
|
845
|
+
`)
|
|
846
|
+
expect(result.template).toContain("'a' => 'class-a'")
|
|
847
|
+
expect(result.template).toContain("->{$variant}")
|
|
848
|
+
})
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
// =============================================================================
|
|
852
|
+
// #1448 Tier A — fixture-driven lowering pins
|
|
853
|
+
// =============================================================================
|
|
854
|
+
//
|
|
855
|
+
// The conformance test suite (runAdapterConformanceTests above) renders
|
|
856
|
+
// every fixture end-to-end through perl + Mojolicious and compares HTML —
|
|
857
|
+
// the strongest possible signal — but it short-circuits with
|
|
858
|
+
// `PerlNotAvailableError` on hosts without Mojolicious installed (CI ARM
|
|
859
|
+
// runners, contributor laptops without `cpanm Mojolicious`, the sandbox
|
|
860
|
+
// each Tier A PR was developed in). Those skips mean a lowering can
|
|
861
|
+
// silently regress to BF101 / wrong helper-call shape and the conformance
|
|
862
|
+
// run still passes "green" on those hosts.
|
|
863
|
+
//
|
|
864
|
+
// This block compiles each Tier A fixture's `source` through the
|
|
865
|
+
// adapter and pins the emitted helper-call substring directly on the
|
|
866
|
+
// template string. No perl needed; runs on every host. The expected
|
|
867
|
+
// substring uses the same `$prop` form the fixture's prop bindings
|
|
868
|
+
// produce — same lowering path the conformance runner exercises when
|
|
869
|
+
// Mojolicious IS present, just with the assertion staged one step
|
|
870
|
+
// earlier (template-string rather than rendered HTML).
|
|
871
|
+
//
|
|
872
|
+
// One row per Tier A method fixture from
|
|
873
|
+
// packages/adapter-tests/fixtures/methods/. Each PR in the Tier A
|
|
874
|
+
// stack appends its rows as the corresponding lowering lands —
|
|
875
|
+
// keeping the block in sync with the `expectedDiagnostics` drops
|
|
876
|
+
// above.
|
|
877
|
+
|
|
878
|
+
import { fixture as arrayIncludesFixture } from '../../../adapter-tests/fixtures/methods/array-includes'
|
|
879
|
+
import { fixture as stringIncludesFixture } from '../../../adapter-tests/fixtures/methods/string-includes'
|
|
880
|
+
import { fixture as arrayIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-indexOf'
|
|
881
|
+
import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-lastIndexOf'
|
|
882
|
+
import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
|
|
883
|
+
import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
|
|
884
|
+
import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
|
|
885
|
+
import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
|
|
886
|
+
import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
|
|
887
|
+
import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
|
|
888
|
+
import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
|
|
889
|
+
import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
|
|
890
|
+
// #1448 Tier B — .sort / .toSorted fixtures (loop-chained + standalone).
|
|
891
|
+
import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
|
|
892
|
+
import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
|
|
893
|
+
import { fixture as arraySortPrimitiveFixture } from '../../../adapter-tests/fixtures/methods/array-sort-primitive'
|
|
894
|
+
import { fixture as arraySortLocaleFixture } from '../../../adapter-tests/fixtures/methods/array-sort-locale'
|
|
895
|
+
import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures/methods/array-toSorted'
|
|
896
|
+
|
|
897
|
+
describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
|
|
898
|
+
const cases = [
|
|
899
|
+
{ fixture: arrayIncludesFixture, expect: 'bf->includes($items, $target)' },
|
|
900
|
+
{ fixture: stringIncludesFixture, expect: 'bf->includes($value, $needle)' },
|
|
901
|
+
{ fixture: arrayIndexOfFixture, expect: 'bf->index_of($items, $target)' },
|
|
902
|
+
{ fixture: arrayLastIndexOfFixture, expect: 'bf->last_index_of($items, $target)' },
|
|
903
|
+
{ fixture: arrayAtFixture, expect: 'bf->at($items, -1)' },
|
|
904
|
+
{ fixture: arrayConcatFixture, expect: 'bf->concat($left, $right)' },
|
|
905
|
+
{ fixture: arraySliceFixture, expect: 'bf->slice($items, 1, 3)' },
|
|
906
|
+
{ fixture: arrayReverseFixture, expect: 'bf->reverse($items)' },
|
|
907
|
+
// .toReversed shares the helper with .reverse — pinning both
|
|
908
|
+
// routings catches a future divergence between them.
|
|
909
|
+
{ fixture: arrayToReversedFixture, expect: 'bf->reverse($items)' },
|
|
910
|
+
{ fixture: stringToLowerCaseFixture,expect: 'lc($value)' },
|
|
911
|
+
{ fixture: stringToUpperCaseFixture,expect: 'uc($value)' },
|
|
912
|
+
{ fixture: stringTrimFixture, expect: 'bf->trim($value)' },
|
|
913
|
+
// #1448 Tier B — sort / toSorted. The loop-chained field cases
|
|
914
|
+
// hoist into a `my $bf_iter_lN = bf->sort(...)` local; the
|
|
915
|
+
// standalone primitive cases inline the call.
|
|
916
|
+
{ fixture: arraySortFieldAscFixture, expect: `bf->sort($items, { key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' })` },
|
|
917
|
+
{ fixture: arraySortFieldDescFixture, expect: `bf->sort($items, { key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'desc' })` },
|
|
918
|
+
{ fixture: arraySortPrimitiveFixture, expect: `bf->sort($nums, { key_kind => 'self', compare_type => 'numeric', direction => 'asc' })` },
|
|
919
|
+
{ fixture: arraySortLocaleFixture, expect: `bf->sort($names, { key_kind => 'self', compare_type => 'string', direction => 'asc' })` },
|
|
920
|
+
{ fixture: arrayToSortedFixture, expect: `bf->sort($nums, { key_kind => 'self', compare_type => 'numeric', direction => 'asc' })` },
|
|
921
|
+
]
|
|
922
|
+
|
|
923
|
+
for (const { fixture, expect: expectedHelper } of cases) {
|
|
924
|
+
test(`[${fixture.id}] lowers to \`${expectedHelper}\``, () => {
|
|
925
|
+
const adapter = new MojoAdapter()
|
|
926
|
+
const result = compileJSX(fixture.source, `${fixture.id}.tsx`, { adapter })
|
|
927
|
+
// No BF101 — the parser arm + adapter case took the call.
|
|
928
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
929
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
930
|
+
expect(template).toContain(expectedHelper)
|
|
931
|
+
// Defensive pin against the `$bf->...` form that the
|
|
932
|
+
// test-render `bf->` → `$bf->` patch would mangle to
|
|
933
|
+
// `$$bf->...` (crashes perl with "Not a SCALAR reference"
|
|
934
|
+
// — see the first-PR fix commit in this stack).
|
|
935
|
+
if (expectedHelper.startsWith('bf->')) {
|
|
936
|
+
expect(template).not.toContain(`$${expectedHelper}`)
|
|
937
|
+
}
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
})
|