@barefootjs/mojolicious 0.17.0 → 0.18.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/expr/array-method.d.ts +13 -1
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +9 -4
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +100 -68
- package/dist/adapter/lib/ir-scope.d.ts +0 -7
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -1
- package/dist/adapter/memo/seed.d.ts +10 -19
- package/dist/adapter/memo/seed.d.ts.map +1 -1
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +100 -68
- package/dist/conformance-pins.d.ts +10 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +116 -68
- package/dist/test-render.d.ts.map +1 -1
- package/lib/BarefootJS/Backend/Mojo.pm +14 -5
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +1 -1
- package/lib/Mojolicious/Plugin/BarefootJS.pm +55 -33
- package/package.json +3 -3
- package/src/__tests__/mojo-adapter.test.ts +180 -135
- package/src/__tests__/multi-component-registry.test.ts +215 -0
- package/src/__tests__/stock-route.test.ts +203 -0
- package/src/adapter/expr/array-method.ts +43 -1
- package/src/adapter/expr/emitters.ts +59 -15
- package/src/adapter/lib/ir-scope.ts +0 -13
- package/src/adapter/memo/seed.ts +20 -73
- package/src/adapter/mojo-adapter.ts +154 -34
- package/src/conformance-pins.ts +138 -0
- package/src/index.ts +1 -0
- package/src/test-render.ts +8 -0
|
@@ -6,12 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, test, expect } from 'bun:test'
|
|
8
8
|
import { MojoAdapter } from '../adapter/mojo-adapter'
|
|
9
|
-
import {
|
|
10
|
-
runAdapterConformanceTests,
|
|
11
|
-
TemplatePrimitiveCaseId,
|
|
12
|
-
} from '@barefootjs/adapter-tests'
|
|
9
|
+
import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
|
|
13
10
|
import { renderMojoComponent, PerlNotAvailableError } from '../test-render'
|
|
14
11
|
import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
|
|
12
|
+
import { conformancePins } from '../conformance-pins'
|
|
15
13
|
|
|
16
14
|
runAdapterConformanceTests({
|
|
17
15
|
name: 'mojo',
|
|
@@ -25,134 +23,27 @@ runAdapterConformanceTests({
|
|
|
25
23
|
// body children (TableCell) now receive `_bf_slot` for deterministic
|
|
26
24
|
// parent-scope-derived IDs matching Hono.
|
|
27
25
|
// Per-fixture build-time contracts for shapes the Mojo adapter
|
|
28
|
-
// intentionally refuses to lower.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
expectedDiagnostics:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{ code: 'BF103', severity: 'error' },
|
|
49
|
-
{ code: 'BF104', severity: 'error' },
|
|
50
|
-
],
|
|
51
|
-
// #1310: rest destructure in .map() callback. The object-rest shape read
|
|
52
|
-
// via member access (`rest-destructure-object-in-map`) now lowers — each
|
|
53
|
-
// binding becomes a Perl `my` local off the per-item var (`$rest` aliases
|
|
54
|
-
// the item so `$rest->{flag}` resolves). The other three stay refused:
|
|
55
|
-
// rest SPREAD (`{...rest}`) needs a residual hash, and array-index /
|
|
56
|
-
// nested paths can't unpack into scalar `my`s.
|
|
57
|
-
'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
58
|
-
'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
59
|
-
'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
|
|
60
|
-
// `style-3-signals` / `style-object-dynamic` no longer pinned — a
|
|
61
|
-
// `style={{ … }}` object literal now lowers to a CSS string with dynamic
|
|
62
|
-
// values interpolated (`background-color:<%= $color %>;padding:8px`) via
|
|
63
|
-
// `tryLowerStyleObject` (#1322).
|
|
64
|
-
// #1244 stress catalog #12 (#1323): tagged-template-literal call
|
|
65
|
-
// (`cn\`base \${tone()}\``) — same family as #1322 above and refused
|
|
66
|
-
// via the same gate.
|
|
67
|
-
'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
|
|
68
|
-
// #1467 demo-corpus context providers (`radio-group`, `accordion`,
|
|
69
|
-
// `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
|
|
70
|
-
// `command`) are no longer pinned — an object-literal provider value
|
|
71
|
-
// (`{ open: () => props.open ?? false, onOpenChange: (v) => {…} }`)
|
|
72
|
-
// lowers to a Perl hashref via `parseProviderObjectLiteral` (#1897):
|
|
73
|
-
// getter members snapshot their body's SSR value, handler /
|
|
74
|
-
// function-shaped members lower to `undef`. The command demo's
|
|
75
|
-
// `ref={(el) => {…}}` function prop on an imported component is
|
|
76
|
-
// skipped at SSR like `on*` handlers.
|
|
77
|
-
//
|
|
78
|
-
// #1467 Phase 2e: `data-table` is no longer pinned here either — it
|
|
79
|
-
// compiles clean (`selected()[index]` → `index-access`,
|
|
80
|
-
// `.toFixed(2)` → `bf->to_fixed`, `/* @client */` memo SSR-folded)
|
|
81
|
-
// and renders to Hono parity on real Mojolicious. The keyed-loop
|
|
82
|
-
// scope-ID divergence (#1896) was fixed by the body-children
|
|
83
|
-
// `inLoop` reset (loop-item children get `_bf_slot`); data-table is
|
|
84
|
-
// off `skipJsx` entirely and only kept in `skipMarkerConformance`
|
|
85
|
-
// below for the shared `/* @client */` keyed-map slot-id elision
|
|
86
|
-
// contract (same as `todo-app`), not a render or BF101 gap.
|
|
87
|
-
// #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
|
|
88
|
-
// shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
|
|
89
|
-
// No BF101 expected — pinned positively via the
|
|
90
|
-
// `branch-local-filter-join` template-output test below.
|
|
91
|
-
//
|
|
92
|
-
// #1448 Tier A — JS Array / String methods that the Mojo adapter
|
|
93
|
-
// hasn't lowered yet. Each row drops once the corresponding
|
|
94
|
-
// method PR lands. Hono / CSR pass these out of the box (they
|
|
95
|
-
// evaluate JS at runtime) so the pin only applies here.
|
|
96
|
-
//
|
|
97
|
-
// `array-includes` / `string-includes` no longer pinned — both
|
|
98
|
-
// shapes lower via the shared `array-method` IR + `bf->includes`
|
|
99
|
-
// runtime dispatch (#1448 Tier A first PR).
|
|
100
|
-
// `array-indexOf` / `array-lastIndexOf` no longer pinned —
|
|
101
|
-
// value-equality `bf->index_of` / `bf->last_index_of` helpers
|
|
102
|
-
// handle the shape (#1448 Tier A second PR).
|
|
103
|
-
// `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
|
|
104
|
-
// handle the negative-index lookup (#1448 Tier A third PR).
|
|
105
|
-
// `array-concat` no longer pinned — `bf->concat` (Mojo) /
|
|
106
|
-
// `bf_concat` (Go) merge two arrays into a new array
|
|
107
|
-
// (#1448 Tier A fourth PR).
|
|
108
|
-
// `array-slice` no longer pinned — `bf->slice` (Mojo) /
|
|
109
|
-
// `bf_slice` (Go) carve out a sub-range with JS-compat
|
|
110
|
-
// negative-index / out-of-bounds clamping (#1448 Tier A
|
|
111
|
-
// fifth PR).
|
|
112
|
-
// `array-reverse` / `array-toReversed` no longer pinned —
|
|
113
|
-
// both share the `bf->reverse` / `bf_reverse` helper since
|
|
114
|
-
// SSR templates render a snapshot and the JS mutate-vs-new
|
|
115
|
-
// distinction has no template-level meaning (#1448 Tier A
|
|
116
|
-
// sixth PR).
|
|
117
|
-
// `string-toLowerCase` / `string-toUpperCase` no longer pinned —
|
|
118
|
-
// Perl's native `lc` / `uc` (Mojo) and pre-existing
|
|
119
|
-
// `bf_lower` / `bf_upper` (Go) handle the JS method names
|
|
120
|
-
// (#1448 Tier A seventh + eighth PRs).
|
|
121
|
-
// `string-trim` no longer pinned — pre-existing `bf_trim`
|
|
122
|
-
// (Go) and new `bf->trim` helper (Mojo) handle the strip
|
|
123
|
-
// (#1448 Tier A ninth PR, closing out Tier A).
|
|
124
|
-
// `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are no longer
|
|
125
|
-
// pinned — the Mojo `callbackMethod` predicate arm now lowers them to the
|
|
126
|
-
// runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
|
|
127
|
-
// (per-element coderef predicate), matching Xslate. `.join` was never
|
|
128
|
-
// pinned (handled by `renderArrayMethod`'s `case 'join'`).
|
|
129
|
-
},
|
|
130
|
-
// `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
|
|
131
|
-
// via `MojoAdapter.templatePrimitives` (#1189). The two remaining
|
|
132
|
-
// cases stay skipped because the V1 registry is identifier-path-
|
|
133
|
-
// only and explicit:
|
|
134
|
-
// - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
|
|
135
|
-
// the registry and can't be rendered server-side without
|
|
136
|
-
// user-supplied helper mappings.
|
|
137
|
-
// - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
|
|
138
|
-
// too, same reason.
|
|
139
|
-
// Adding new entries to `templatePrimitives` should narrow this
|
|
140
|
-
// skip set; see `MOJO_TEMPLATE_PRIMITIVES` in `mojo-adapter.ts`
|
|
141
|
-
// for the full V1 surface.
|
|
142
|
-
skipTemplatePrimitives: new Set([
|
|
143
|
-
TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
|
|
144
|
-
TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
|
|
145
|
-
]),
|
|
146
|
-
// Mojo `renderLoop` does not yet emit the `bf->comment("loop:<id>")`
|
|
147
|
-
// boundary markers when the loop is `@client` (Hono and Go both do).
|
|
148
|
-
// The client runtime relies on these markers to locate the insertion
|
|
149
|
-
// anchor when hydrating the array; without them, mapArray() resolves
|
|
150
|
-
// anchor = null and appends after sibling markers (#872 parity).
|
|
151
|
-
// Tracked as a follow-up; remove from this set when Mojo emits the
|
|
152
|
-
// boundary pair for clientOnly loops too.
|
|
26
|
+
// intentionally refuses to lower. Lives in `../conformance-pins` (not
|
|
27
|
+
// by the shared fixtures) so adding a new adapter doesn't require
|
|
28
|
+
// touching any cross-adapter file.
|
|
29
|
+
expectedDiagnostics: conformancePins,
|
|
30
|
+
// `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` pass via
|
|
31
|
+
// `MojoAdapter.templatePrimitives` (#1189) — the identifier-path
|
|
32
|
+
// registry for well-known JS builtins. `USER_IMPORT_VIA_CONST` and
|
|
33
|
+
// `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now ALSO pass (#2069): a bespoke
|
|
34
|
+
// user import can never be added to the string-keyed registry, but the
|
|
35
|
+
// shared `RelocateEnv.loweringMatchers` acceptance path recognises it
|
|
36
|
+
// via a `LoweringPlugin` the case setup registers around the compile
|
|
37
|
+
// (see `packages/adapter-tests/src/cases/template-primitives.ts`) — the
|
|
38
|
+
// same seam a real userland plugin author would use. No skips left, so
|
|
39
|
+
// `skipTemplatePrimitives` is omitted entirely (defaults to "skip
|
|
40
|
+
// nothing").
|
|
41
|
+
// `client-only` / `client-only-loop-with-sibling-cond` /
|
|
42
|
+
// `filter-nested-callback-predicate-client` are no longer skipped —
|
|
43
|
+
// `renderLoop` now emits the `bf->comment("loop:<id>")` boundary pair
|
|
44
|
+
// for clientOnly loops (Hono / Go parity), so mapArray() can locate
|
|
45
|
+
// its insertion anchor at hydration time (#872 / #1087).
|
|
153
46
|
skipMarkerConformance: new Set([
|
|
154
|
-
'client-only',
|
|
155
|
-
'client-only-loop-with-sibling-cond',
|
|
156
47
|
// Same as Hono: `/* @client */` markers on TodoApp's keyed `.map`
|
|
157
48
|
// intentionally elide a slot id from the SSR template that the IR
|
|
158
49
|
// still declares (s6). See hono-adapter.test for the contract.
|
|
@@ -792,6 +683,31 @@ export function C() {
|
|
|
792
683
|
expect(template).toContain('scalar(@{[grep { $_->{active} } @{$t->{tags}}]})')
|
|
793
684
|
})
|
|
794
685
|
|
|
686
|
+
test('lowers nested .some(...) in filter predicate to an inline grep — no BF101 (#2038)', () => {
|
|
687
|
+
// The evaluator refuses the nested arrow (`serializeParsedExpr` → null),
|
|
688
|
+
// but the Perl filter emitter has a FAITHFUL form for nested
|
|
689
|
+
// filter / every / some: a real inline `grep` closing over the outer
|
|
690
|
+
// loop var. Pin the emitted EP shape positively so the #2038 loudness
|
|
691
|
+
// fix (which targets the degrade-only arms: nested `find*`,
|
|
692
|
+
// sort / reduce / flatMap — see the `filter-nested-find-predicate`
|
|
693
|
+
// expectedDiagnostics entry) never over-reaches into this supported
|
|
694
|
+
// shape. The rendered-HTML side of this contract lives in the shared
|
|
695
|
+
// `filter-nested-callback-predicate` fixture (Hono-parity render).
|
|
696
|
+
const adapter = new MojoAdapter()
|
|
697
|
+
const result = compileJSX(`'use client'
|
|
698
|
+
import { createSignal } from '@barefootjs/client'
|
|
699
|
+
type Item = { id: number }
|
|
700
|
+
export function Picker() {
|
|
701
|
+
const [items] = createSignal<Item[]>([])
|
|
702
|
+
const [picked] = createSignal<Item[]>([])
|
|
703
|
+
return <ul>{items().filter(t => !picked().some(p => p.id === t.id)).map(t => <li key={t.id}>{t.id}</li>)}</ul>
|
|
704
|
+
}`, 'C.tsx', { adapter })
|
|
705
|
+
expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
|
|
706
|
+
const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
707
|
+
expect(template).toContain('grep')
|
|
708
|
+
expect(template).toContain('@{$picked}')
|
|
709
|
+
})
|
|
710
|
+
|
|
795
711
|
test('lowers .filter(function (x) { return x.done }).map(...) — function-keyword filter (#1443)', () => {
|
|
796
712
|
// Function expressions with a single `return <expr>` body normalise
|
|
797
713
|
// to the arrow-fn IR shape at parse time, so the higher-order
|
|
@@ -1035,11 +951,26 @@ export { A }`, 'A.tsx', { adapter })
|
|
|
1035
951
|
expect(template).toContain('"property":"done"')
|
|
1036
952
|
}
|
|
1037
953
|
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
954
|
+
// #2075: `.includes(x)` is now in the evaluator surface (`array-method`
|
|
955
|
+
// gate, shared with the Perl `Evaluator.pm` runtime), so a method-call
|
|
956
|
+
// predicate built from it ALSO routes through `every_eval` rather than
|
|
957
|
+
// falling back — it's no longer the "unsupported method call" example.
|
|
958
|
+
const includesAdapter = new MojoAdapter()
|
|
959
|
+
const includesResult = compileJSX(`function A({ items }: { items: { name: string }[] }) {
|
|
960
|
+
return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
|
|
961
|
+
}
|
|
962
|
+
export { A }`, 'A.tsx', { adapter: includesAdapter })
|
|
963
|
+
const includesTemplate = includesResult.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
964
|
+
expect(includesTemplate).toContain('bf->every_eval($items,')
|
|
965
|
+
expect(includesTemplate).toContain('"method":"includes"')
|
|
966
|
+
expect(includesTemplate).not.toContain('grep {')
|
|
967
|
+
|
|
968
|
+
// Fallback: a method-call predicate the evaluator still can't model
|
|
969
|
+
// (`.toUpperCase()` is outside the `array-method` gate — only `includes`
|
|
970
|
+
// is recognized there) keeps the inline grep form.
|
|
1040
971
|
const adapter = new MojoAdapter()
|
|
1041
972
|
const fb = compileJSX(`function A({ items }: { items: { name: string }[] }) {
|
|
1042
|
-
return <div>{items.every(x => x.name.
|
|
973
|
+
return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
|
|
1043
974
|
}
|
|
1044
975
|
export { A }`, 'A.tsx', { adapter })
|
|
1045
976
|
const fbTemplate = fb.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
|
|
@@ -1488,6 +1419,110 @@ export { C }
|
|
|
1488
1419
|
})
|
|
1489
1420
|
})
|
|
1490
1421
|
|
|
1422
|
+
describe('MojoAdapter - #2075 searchParams()-derived memo seeding', () => {
|
|
1423
|
+
// A memo derived from the createSearchParams() env signal must seed
|
|
1424
|
+
// in-template from the canonical per-request `$searchParams` reader —
|
|
1425
|
+
// including under a local alias (`const [sp] = …`), which the expression
|
|
1426
|
+
// lowering canonicalises.
|
|
1427
|
+
test('seeds an aliased scalar derived memo from the canonical reader', () => {
|
|
1428
|
+
const { template } = compileAndGenerate(`
|
|
1429
|
+
'use client'
|
|
1430
|
+
import { createMemo, createSearchParams } from '@barefootjs/client'
|
|
1431
|
+
export function SortStatus() {
|
|
1432
|
+
const [sp] = createSearchParams()
|
|
1433
|
+
const sort = createMemo(() => sp().get('sort') ?? 'date')
|
|
1434
|
+
return <p>sort: {sort()}</p>
|
|
1435
|
+
}
|
|
1436
|
+
`)
|
|
1437
|
+
expect(template).toContain("% my $sort = ($searchParams->get('sort') // 'date');")
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
// A list-filter memo chained off the derived memo seeds too: the inline
|
|
1441
|
+
// grep's `$_` topic and the callback param are lowering-internal bindings,
|
|
1442
|
+
// not out-of-scope template vars (the pre-#2075 availability check
|
|
1443
|
+
// rejected them and the list rendered empty at SSR).
|
|
1444
|
+
test('seeds a filter memo chained off the derived memo', () => {
|
|
1445
|
+
const { template } = compileAndGenerate(`
|
|
1446
|
+
'use client'
|
|
1447
|
+
import { createMemo, createSearchParams } from '@barefootjs/client'
|
|
1448
|
+
export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
|
|
1449
|
+
const [searchParams] = createSearchParams()
|
|
1450
|
+
const tag = createMemo(() => searchParams().get('tag') ?? '')
|
|
1451
|
+
const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
|
|
1452
|
+
return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
|
|
1453
|
+
}
|
|
1454
|
+
`)
|
|
1455
|
+
expect(template).toContain("% my $tag = ($searchParams->get('tag') // '');")
|
|
1456
|
+
expect(template).toMatch(/% my \$visible = \[grep/)
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
// The seed-scope guard used to scan the LOWERED
|
|
1460
|
+
// Perl string, allowing every arrow-callback param tree-wide. That let an
|
|
1461
|
+
// outer, unbound `p` (shadowed only inside the callback) slip past the
|
|
1462
|
+
// guard as if it were the callback's own bound `$p` — emitting a bogus
|
|
1463
|
+
// seed line that would crash Perl strict mode. The guard now walks the
|
|
1464
|
+
// parsed SOURCE tree with proper lexical scoping (`freeIdentifiers`), so
|
|
1465
|
+
// this shape seeds nothing and falls back to the null/ssr-defaults path.
|
|
1466
|
+
test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
|
|
1467
|
+
const { template } = compileAndGenerate(`
|
|
1468
|
+
'use client'
|
|
1469
|
+
import { createMemo } from '@barefootjs/client'
|
|
1470
|
+
export function C(props: { items: { ok: boolean }[] }) {
|
|
1471
|
+
const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
|
|
1472
|
+
return <div>{String(visible())}</div>
|
|
1473
|
+
}
|
|
1474
|
+
`)
|
|
1475
|
+
expect(template).not.toContain('my $visible')
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
// An out-of-scope bare `_` reference (not the `grep` topic var of an
|
|
1479
|
+
// in-scope higher-order lowering) must not seed either — the old
|
|
1480
|
+
// unconditional `allowed.add('_')` masked this.
|
|
1481
|
+
test('an out-of-scope bare `_` reference does not seed', () => {
|
|
1482
|
+
const { template } = compileAndGenerate(`
|
|
1483
|
+
'use client'
|
|
1484
|
+
import { createMemo } from '@barefootjs/client'
|
|
1485
|
+
export function C(props: { count: number }) {
|
|
1486
|
+
const doubled = createMemo(() => props.count * 2 + _)
|
|
1487
|
+
return <div>{doubled()}</div>
|
|
1488
|
+
}
|
|
1489
|
+
`)
|
|
1490
|
+
expect(template).not.toContain('my $doubled')
|
|
1491
|
+
})
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
describe('MojoAdapter - #2073 value-producing .map(cb)', () => {
|
|
1495
|
+
function emitMap(expr: string): string {
|
|
1496
|
+
const a = new MojoAdapter()
|
|
1497
|
+
const ir = compileToIR(`
|
|
1498
|
+
function C({ tags, users }: { tags: string[]; users: { name: string }[] }) {
|
|
1499
|
+
return <div>{${expr}}</div>
|
|
1500
|
+
}
|
|
1501
|
+
export { C }
|
|
1502
|
+
`, a)
|
|
1503
|
+
return a.generate(ir).template ?? ''
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
|
|
1507
|
+
// projection, not JSX) lowers through the evaluator — `bf->map_eval`
|
|
1508
|
+
// projects each element (no flatten) and composes through `.join`.
|
|
1509
|
+
test('.map(t => `#${t}`).join(" ") emits bf->map_eval composed into join', () => {
|
|
1510
|
+
const t = emitMap("tags.map(t => `#${t}`).join(' ')")
|
|
1511
|
+
expect(t).toContain(`join(' ', @{bf->map_eval($tags,`)
|
|
1512
|
+
expect(t).toContain(`"kind":"template-literal"`)
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
test('.map(u => u.name) emits bf->map_eval with the field projection', () => {
|
|
1516
|
+
const t = emitMap("users.map(u => u.name).join(', ')")
|
|
1517
|
+
expect(t).toContain(`bf->map_eval($users,`)
|
|
1518
|
+
expect(t).toContain(`"property":"name"`)
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
// The function-reference `.map(format)` BF101 refusal is now covered
|
|
1522
|
+
// cross-adapter by the `array-map-function-reference` shared fixture's
|
|
1523
|
+
// `expectedDiagnostics` entry above.
|
|
1524
|
+
})
|
|
1525
|
+
|
|
1491
1526
|
describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
|
|
1492
1527
|
function emitFlatMap(expr: string): string {
|
|
1493
1528
|
const a = new MojoAdapter()
|
|
@@ -1706,7 +1741,6 @@ export { C }
|
|
|
1706
1741
|
// contract here is: no errors + the comparator never lowers + no
|
|
1707
1742
|
// rendered `<li>` survives.
|
|
1708
1743
|
const unsupportedSort: Array<[string, string]> = [
|
|
1709
|
-
['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
|
|
1710
1744
|
['localeCompare locale/options arg', `items().toSorted((a, b) => a.name.localeCompare(b.name, "ja", { numeric: true })).map(x => <li key={x.name}>{x.name}</li>)`],
|
|
1711
1745
|
]
|
|
1712
1746
|
for (const [label, chain] of unsupportedSort) {
|
|
@@ -1730,6 +1764,17 @@ export function C() {
|
|
|
1730
1764
|
})
|
|
1731
1765
|
}
|
|
1732
1766
|
|
|
1767
|
+
// #2090: a function-reference comparator (`.toSorted(myCmp)`, `myCmp` a
|
|
1768
|
+
// same-file const arrow) now resolves through the analyzer's scope
|
|
1769
|
+
// machinery and compiles — no BF021, and the sort lowers exactly like an
|
|
1770
|
+
// inline comparator (a `bf->sort` / `bf->sort_eval` call in the template).
|
|
1771
|
+
test('sort follow-up (function-reference comparator): resolves and compiles without BF021', () => {
|
|
1772
|
+
const chain = `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`
|
|
1773
|
+
const result = emitLoop(chain, false)
|
|
1774
|
+
expect(result.errors).toEqual([])
|
|
1775
|
+
expect(result.template).toMatch(/bf->sort/)
|
|
1776
|
+
})
|
|
1777
|
+
|
|
1733
1778
|
// End-to-end proof via perl + Mojolicious: the `@client` form renders
|
|
1734
1779
|
// a `<!--bf-client:sN-->` placeholder. The bare form is now caught at
|
|
1735
1780
|
// build with BF101 and degrades to an empty, render-safe slot (no
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Multi-component registry modules on the Mojo plugin path (#2132).
|
|
2
|
+
//
|
|
3
|
+
// A registry module that exports several components from one file —
|
|
4
|
+
// `ui/toast/index.tsx` exporting ToastProvider / Toast / ToastTitle — compiles
|
|
5
|
+
// to one EP template PER component, and every compiled parent invokes each one
|
|
6
|
+
// under its snake_cased name (`bf->render_child('toast_provider')`). The
|
|
7
|
+
// manifest entry used to carry only the module's FIRST template, so
|
|
8
|
+
// `register_components_from_manifest` registered nothing for the
|
|
9
|
+
// sub-components and every page using Toast / Dialog / Tabs 500'd with
|
|
10
|
+
// "No renderer registered for child component 'toast_provider'".
|
|
11
|
+
//
|
|
12
|
+
// This is the in-repo equivalent of the issue's repro: `bf add toast` →
|
|
13
|
+
// render a <Toast>-using component on mojo → expect 200 with toast markup.
|
|
14
|
+
// It boots a real Mojolicious app with the production plugin against
|
|
15
|
+
// compiler-produced templates plus a manifest in the shape `bf build` now
|
|
16
|
+
// emits (per-component rows under `components` — pinned on the emitter side
|
|
17
|
+
// by packages/cli/src/__tests__/build-manifest-components.test.ts).
|
|
18
|
+
//
|
|
19
|
+
// Runs only when `perl` with Mojolicious is installed (same skip policy as
|
|
20
|
+
// stock-route.test.ts, which this file mirrors).
|
|
21
|
+
|
|
22
|
+
import { describe, test, expect, beforeAll } from 'bun:test'
|
|
23
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
27
|
+
import { MojoAdapter } from '../adapter/mojo-adapter'
|
|
28
|
+
|
|
29
|
+
const MOJO_LIB_DIR = path.resolve(import.meta.dir, '../../lib')
|
|
30
|
+
const PERL_CORE_LIB_DIR = path.resolve(import.meta.dir, '../../../adapter-perl/lib')
|
|
31
|
+
|
|
32
|
+
function perlWithMojoAvailable(): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const proc = Bun.spawnSync(['perl', '-MMojolicious', '-MTest::Mojo', '-e1'], {
|
|
35
|
+
env: process.env,
|
|
36
|
+
})
|
|
37
|
+
return proc.exitCode === 0
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PERL_AVAILABLE = perlWithMojoAvailable()
|
|
44
|
+
|
|
45
|
+
// The registry-toast shape: one module, several exported components, one of
|
|
46
|
+
// them ("Toast") snake_casing to the module's directory name — the collision
|
|
47
|
+
// that used to render the WRONG template even when the name resolved.
|
|
48
|
+
const TOAST_TSX = `'use client'
|
|
49
|
+
|
|
50
|
+
interface ToastProviderProps {
|
|
51
|
+
children?: any
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ToastProvider({ children }: ToastProviderProps) {
|
|
55
|
+
return <div data-slot="toast-provider">{children}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ToastProps {
|
|
59
|
+
open?: boolean
|
|
60
|
+
children?: any
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Toast({ open = false, children }: ToastProps) {
|
|
64
|
+
return (
|
|
65
|
+
<div data-slot="toast" data-state={open ? 'open' : 'closed'}>
|
|
66
|
+
{children}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ToastTitle({ children }: { children?: any }) {
|
|
72
|
+
return <div data-slot="toast-title">{children}</div>
|
|
73
|
+
}
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
// The issue's ToastProbe: a page component driving the module's
|
|
77
|
+
// sub-components, with a signal feeding Toast's `open` prop.
|
|
78
|
+
const PROBE_TSX = `'use client'
|
|
79
|
+
import { createSignal } from '@barefootjs/client'
|
|
80
|
+
import { ToastProvider, Toast, ToastTitle } from '@/components/ui/toast'
|
|
81
|
+
|
|
82
|
+
export function ToastProbe() {
|
|
83
|
+
const [open, setOpen] = createSignal(true)
|
|
84
|
+
return (
|
|
85
|
+
<ToastProvider>
|
|
86
|
+
<Toast open={open()}>
|
|
87
|
+
<ToastTitle>hi</ToastTitle>
|
|
88
|
+
</Toast>
|
|
89
|
+
</ToastProvider>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
`
|
|
93
|
+
|
|
94
|
+
const APP_PL = `#!/usr/bin/env perl
|
|
95
|
+
use Mojolicious::Lite -signatures;
|
|
96
|
+
|
|
97
|
+
plugin 'BarefootJS';
|
|
98
|
+
|
|
99
|
+
app->renderer->paths->[0] = app->home->child('dist/templates');
|
|
100
|
+
|
|
101
|
+
get '/toast-probe' => sub ($c) {
|
|
102
|
+
$c->render(template => 'ToastProbe', layout => 'default');
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
app->start;
|
|
106
|
+
|
|
107
|
+
__DATA__
|
|
108
|
+
|
|
109
|
+
@@ layouts/default.html.ep
|
|
110
|
+
<!DOCTYPE html>
|
|
111
|
+
<html><body>
|
|
112
|
+
<main><%== content %></main>
|
|
113
|
+
%== $c->bf->scripts
|
|
114
|
+
</body></html>
|
|
115
|
+
`
|
|
116
|
+
|
|
117
|
+
const SMOKE_PL = `use Mojo::Base -strict;
|
|
118
|
+
use Test::More;
|
|
119
|
+
use Test::Mojo;
|
|
120
|
+
use Mojo::File qw(curfile);
|
|
121
|
+
|
|
122
|
+
my $t = Test::Mojo->new(curfile->dirname->child('app.pl'));
|
|
123
|
+
|
|
124
|
+
# The issue's regression bar: 200 with toast markup (was a 500 with
|
|
125
|
+
# "No renderer registered for child component 'toast_provider'").
|
|
126
|
+
$t->get_ok('/toast-probe')->status_is(200)
|
|
127
|
+
->element_exists('[data-slot="toast-provider"]', 'provider rendered')
|
|
128
|
+
->element_exists('[data-slot="toast"]', 'toast rendered')
|
|
129
|
+
->element_exists('[data-slot="toast-title"]', 'title rendered')
|
|
130
|
+
->text_like('[data-slot="toast-title"]', qr/hi/, 'children reached the title')
|
|
131
|
+
->element_exists('[data-slot="toast"][data-state="open"]',
|
|
132
|
+
'parent signal reached the sub-component prop (not Toast\\'s own open=false default)');
|
|
133
|
+
|
|
134
|
+
done_testing;
|
|
135
|
+
`
|
|
136
|
+
|
|
137
|
+
describe.skipIf(!PERL_AVAILABLE)('Mojo multi-component registry modules (#2132)', () => {
|
|
138
|
+
let appDir: string
|
|
139
|
+
|
|
140
|
+
beforeAll(() => {
|
|
141
|
+
appDir = mkdtempSync(path.join(tmpdir(), 'bf-mojo-multi-component-'))
|
|
142
|
+
mkdirSync(path.join(appDir, 'dist/templates/ui/toast'), { recursive: true })
|
|
143
|
+
|
|
144
|
+
const adapter = new MojoAdapter()
|
|
145
|
+
|
|
146
|
+
// Compile the multi-component module: one template + ssr-defaults pair
|
|
147
|
+
// per exported component, each stamped with its componentName.
|
|
148
|
+
const toastResult = compileJSX(TOAST_TSX, 'components/ui/toast/index.tsx', { adapter })
|
|
149
|
+
const toastErrors = toastResult.errors.filter(e => e.severity === 'error')
|
|
150
|
+
if (toastErrors.length > 0) {
|
|
151
|
+
throw new Error(`toast module compile failed:\n${toastErrors.map(e => e.message).join('\n')}`)
|
|
152
|
+
}
|
|
153
|
+
const componentRows: Record<string, { markedTemplate: string; ssrDefaults?: unknown }> = {}
|
|
154
|
+
const toastTemplates = toastResult.files.filter(f => f.type === 'markedTemplate')
|
|
155
|
+
for (const tpl of toastTemplates) {
|
|
156
|
+
const fileName = path.basename(tpl.path)
|
|
157
|
+
writeFileSync(path.join(appDir, 'dist/templates/ui/toast', fileName), tpl.content)
|
|
158
|
+
const defaults = toastResult.files.find(
|
|
159
|
+
f => f.type === 'ssrDefaults' && f.componentName === tpl.componentName,
|
|
160
|
+
)
|
|
161
|
+
componentRows[tpl.componentName!] = {
|
|
162
|
+
markedTemplate: `templates/ui/toast/${fileName}`,
|
|
163
|
+
...(defaults ? { ssrDefaults: JSON.parse(defaults.content) } : {}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
expect(Object.keys(componentRows).sort()).toEqual(['Toast', 'ToastProvider', 'ToastTitle'])
|
|
167
|
+
|
|
168
|
+
// Compile the probe page.
|
|
169
|
+
const probeResult = compileJSX(PROBE_TSX, 'components/ToastProbe.tsx', { adapter })
|
|
170
|
+
const probeErrors = probeResult.errors.filter(e => e.severity === 'error')
|
|
171
|
+
if (probeErrors.length > 0) {
|
|
172
|
+
throw new Error(`probe compile failed:\n${probeErrors.map(e => e.message).join('\n')}`)
|
|
173
|
+
}
|
|
174
|
+
const probeTemplate = probeResult.files.find(f => f.type === 'markedTemplate')
|
|
175
|
+
const probeDefaults = probeResult.files.find(f => f.type === 'ssrDefaults')
|
|
176
|
+
if (!probeTemplate) throw new Error('probe compile produced no template')
|
|
177
|
+
writeFileSync(path.join(appDir, 'dist/templates/ToastProbe.html.ep'), probeTemplate.content)
|
|
178
|
+
|
|
179
|
+
// Same entry shape `bf build` writes to dist/templates/manifest.json
|
|
180
|
+
// (see build-manifest-components.test.ts for the emitter pin).
|
|
181
|
+
writeFileSync(
|
|
182
|
+
path.join(appDir, 'dist/templates/manifest.json'),
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
ToastProbe: {
|
|
185
|
+
markedTemplate: 'templates/ToastProbe.html.ep',
|
|
186
|
+
...(probeDefaults ? { ssrDefaults: JSON.parse(probeDefaults.content) } : {}),
|
|
187
|
+
},
|
|
188
|
+
'ui/toast/index': {
|
|
189
|
+
markedTemplate: componentRows[toastTemplates[0].componentName!].markedTemplate,
|
|
190
|
+
components: componentRows,
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
writeFileSync(path.join(appDir, 'app.pl'), APP_PL)
|
|
195
|
+
writeFileSync(path.join(appDir, 'smoke.pl'), SMOKE_PL)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('a <Toast>-using page renders 200 with toast markup', async () => {
|
|
199
|
+
const perl5lib = [MOJO_LIB_DIR, PERL_CORE_LIB_DIR, process.env.PERL5LIB]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(':')
|
|
202
|
+
const proc = Bun.spawn(['perl', 'smoke.pl'], {
|
|
203
|
+
cwd: appDir,
|
|
204
|
+
env: { ...process.env, PERL5LIB: perl5lib },
|
|
205
|
+
stdout: 'pipe',
|
|
206
|
+
stderr: 'pipe',
|
|
207
|
+
})
|
|
208
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
209
|
+
new Response(proc.stdout).text(),
|
|
210
|
+
new Response(proc.stderr).text(),
|
|
211
|
+
proc.exited,
|
|
212
|
+
])
|
|
213
|
+
expect(exitCode, `TAP output:\n${stdout}\n${stderr}`).toBe(0)
|
|
214
|
+
})
|
|
215
|
+
})
|