@barefootjs/mojolicious 0.17.1 → 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 +3 -1
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +7 -3
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +56 -13
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +56 -13
- 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 +72 -13
- 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 +28 -136
- 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 +22 -1
- package/src/adapter/expr/emitters.ts +23 -11
- package/src/adapter/mojo-adapter.ts +128 -24
- package/src/conformance-pins.ts +138 -0
- package/src/index.ts +1 -0
- package/src/test-render.ts +8 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Stock-route smoke test for the Mojo scaffold contract (#2126).
|
|
2
|
+
//
|
|
3
|
+
// The scaffold's happy path is: `npm run dev` (which starts `bf build
|
|
4
|
+
// --watch` and morbo *concurrently*), then open `/`. That page renders a
|
|
5
|
+
// component whose EP template reads props/signals as bare scalars
|
|
6
|
+
// (`% my $count = ($initial // 0);`), and Mojo templates compile under
|
|
7
|
+
// `use strict` — so every one of those scalars must be declared by the
|
|
8
|
+
// time the template runs. Two production bugs hid in that path:
|
|
9
|
+
//
|
|
10
|
+
// 1. The route passes no props, so the stash seeding has to come from
|
|
11
|
+
// the manifest's `ssrDefaults` via the plugin's `before_render`
|
|
12
|
+
// hook — including props only referenced through `props.X` reads.
|
|
13
|
+
// 2. The plugin used to load the manifest once at register time, so
|
|
14
|
+
// booting before the first build wrote `manifest.json` disabled
|
|
15
|
+
// auto-init for the server's lifetime: every render of `/` died
|
|
16
|
+
// with `Global symbol "$initial" requires explicit package name`
|
|
17
|
+
// (HTTP 500) until a manual restart.
|
|
18
|
+
//
|
|
19
|
+
// This test boots a real Mojolicious app whose `app.pl` mirrors the
|
|
20
|
+
// scaffold's (plugin + dist/templates renderer path + a `/` route that
|
|
21
|
+
// passes NO props) against a template + manifest produced by the real
|
|
22
|
+
// compiler, and asserts the page renders — in both boot orders. It is
|
|
23
|
+
// the in-repo equivalent of "scaffold → build → curl / expects 200".
|
|
24
|
+
//
|
|
25
|
+
// Runs only when `perl` with Mojolicious is installed (same skip policy
|
|
26
|
+
// as the conformance render tests). The DevReload plugin is omitted:
|
|
27
|
+
// it's dev-only plumbing with its own coverage in t/dev_reload.t.
|
|
28
|
+
|
|
29
|
+
import { describe, test, expect, beforeAll } from 'bun:test'
|
|
30
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
31
|
+
import { tmpdir } from 'node:os'
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
34
|
+
import { MojoAdapter } from '../adapter/mojo-adapter'
|
|
35
|
+
|
|
36
|
+
const MOJO_LIB_DIR = path.resolve(import.meta.dir, '../../lib')
|
|
37
|
+
const PERL_CORE_LIB_DIR = path.resolve(import.meta.dir, '../../../adapter-perl/lib')
|
|
38
|
+
|
|
39
|
+
function perlWithMojoAvailable(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const proc = Bun.spawnSync(['perl', '-MMojolicious', '-MTest::Mojo', '-e1'], {
|
|
42
|
+
env: process.env,
|
|
43
|
+
})
|
|
44
|
+
return proc.exitCode === 0
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PERL_AVAILABLE = perlWithMojoAvailable()
|
|
51
|
+
|
|
52
|
+
// The starter-Counter shape: a prop read only through `props.X ?? …`
|
|
53
|
+
// (never passed by the stock route) feeding a signal, plus a memo.
|
|
54
|
+
// Exactly the pattern that dies under strict when seeding breaks.
|
|
55
|
+
// `props.label` is read directly in the body (no signal/memo in
|
|
56
|
+
// between) — that flattens to a bare `<%= $label %>` too, so it pins
|
|
57
|
+
// that ssrDefaults seeds every declared prop, not just the ones a
|
|
58
|
+
// signal initializer references.
|
|
59
|
+
const COUNTER_TSX = `'use client'
|
|
60
|
+
|
|
61
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
62
|
+
|
|
63
|
+
interface CounterProps {
|
|
64
|
+
initial?: number
|
|
65
|
+
label?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Counter(props: CounterProps) {
|
|
69
|
+
const [count, setCount] = createSignal(props.initial ?? 0)
|
|
70
|
+
const doubled = createMemo(() => count() * 2)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="counter">
|
|
74
|
+
<p className="counter-label">{props.label}</p>
|
|
75
|
+
<p className="counter-value">count: {count()}</p>
|
|
76
|
+
<p className="counter-doubled">doubled: {doubled()}</p>
|
|
77
|
+
<button onClick={() => setCount(n => n + 1)}>+1</button>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
`
|
|
82
|
+
|
|
83
|
+
// Mirrors the scaffold's app.pl essentials. The `/` route deliberately
|
|
84
|
+
// passes NO props — the manifest seeding alone must carry it. The
|
|
85
|
+
// second route pins that a caller-supplied prop still wins.
|
|
86
|
+
const APP_PL = `#!/usr/bin/env perl
|
|
87
|
+
use Mojolicious::Lite -signatures;
|
|
88
|
+
|
|
89
|
+
plugin 'BarefootJS';
|
|
90
|
+
|
|
91
|
+
app->renderer->paths->[0] = app->home->child('dist/templates');
|
|
92
|
+
|
|
93
|
+
get '/' => sub ($c) {
|
|
94
|
+
$c->render(template => 'Counter', layout => 'default');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
get '/with-prop' => sub ($c) {
|
|
98
|
+
$c->render(template => 'Counter', layout => 'default', initial => 5);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
app->start;
|
|
102
|
+
|
|
103
|
+
__DATA__
|
|
104
|
+
|
|
105
|
+
@@ layouts/default.html.ep
|
|
106
|
+
<!DOCTYPE html>
|
|
107
|
+
<html><body>
|
|
108
|
+
<main><%== content %></main>
|
|
109
|
+
%== $c->bf->scripts
|
|
110
|
+
</body></html>
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
// Test::Mojo harness. SMOKE_SCENARIO picks the boot order:
|
|
114
|
+
// manifest-at-boot — the manifest exists before the app loads
|
|
115
|
+
// (server restarted after a completed build).
|
|
116
|
+
// manifest-after-boot — the app loads first, the manifest appears
|
|
117
|
+
// afterwards (the concurrent `bf build --watch`
|
|
118
|
+
// + morbo dev race on a fresh scaffold).
|
|
119
|
+
const SMOKE_PL = `use Mojo::Base -strict;
|
|
120
|
+
use Test::More;
|
|
121
|
+
use Test::Mojo;
|
|
122
|
+
use Mojo::File qw(curfile path);
|
|
123
|
+
|
|
124
|
+
my $home = curfile->dirname;
|
|
125
|
+
my $scenario = $ENV{SMOKE_SCENARIO} // 'manifest-at-boot';
|
|
126
|
+
my $staged = $home->child('manifest.staged.json');
|
|
127
|
+
my $manifest = $home->child('dist/templates/manifest.json');
|
|
128
|
+
|
|
129
|
+
unlink "$manifest";
|
|
130
|
+
$staged->copy_to("$manifest") if $scenario eq 'manifest-at-boot';
|
|
131
|
+
|
|
132
|
+
my $t = Test::Mojo->new($home->child('app.pl'));
|
|
133
|
+
|
|
134
|
+
$staged->copy_to("$manifest") if $scenario eq 'manifest-after-boot';
|
|
135
|
+
|
|
136
|
+
# Stock route: no props passed — manifest ssrDefaults must seed every
|
|
137
|
+
# bare template scalar ($initial included) or strict mode 500s.
|
|
138
|
+
$t->get_ok('/')->status_is(200)
|
|
139
|
+
->text_like('p.counter-value', qr/count:\\s*0/)
|
|
140
|
+
->text_like('p.counter-doubled', qr/doubled:\\s*0/)
|
|
141
|
+
->content_like(qr/Counter\\.client\\.js/, 'auto-init registered the client bundle');
|
|
142
|
+
|
|
143
|
+
# Caller-supplied prop wins over the manifest default.
|
|
144
|
+
$t->get_ok('/with-prop')->status_is(200)
|
|
145
|
+
->text_like('p.counter-value', qr/count:\\s*5/)
|
|
146
|
+
->text_like('p.counter-doubled', qr/doubled:\\s*10/);
|
|
147
|
+
|
|
148
|
+
done_testing;
|
|
149
|
+
`
|
|
150
|
+
|
|
151
|
+
describe.skipIf(!PERL_AVAILABLE)('Mojo scaffold stock route (#2126)', () => {
|
|
152
|
+
let appDir: string
|
|
153
|
+
|
|
154
|
+
beforeAll(() => {
|
|
155
|
+
appDir = mkdtempSync(path.join(tmpdir(), 'bf-mojo-stock-route-'))
|
|
156
|
+
mkdirSync(path.join(appDir, 'dist/templates'), { recursive: true })
|
|
157
|
+
|
|
158
|
+
const result = compileJSX(COUNTER_TSX, 'Counter.tsx', { adapter: new MojoAdapter() })
|
|
159
|
+
const template = result.files.find(f => f.type === 'markedTemplate')
|
|
160
|
+
const ssrDefaults = result.files.find(f => f.type === 'ssrDefaults')
|
|
161
|
+
if (!template || !ssrDefaults) throw new Error('compileJSX produced no template/ssrDefaults')
|
|
162
|
+
|
|
163
|
+
writeFileSync(path.join(appDir, 'dist/templates/Counter.html.ep'), template.content)
|
|
164
|
+
// Same entry shape `bf build` writes to dist/templates/manifest.json.
|
|
165
|
+
writeFileSync(
|
|
166
|
+
path.join(appDir, 'manifest.staged.json'),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
Counter: {
|
|
169
|
+
markedTemplate: 'templates/Counter.html.ep',
|
|
170
|
+
ssrDefaults: JSON.parse(ssrDefaults.content),
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
writeFileSync(path.join(appDir, 'app.pl'), APP_PL)
|
|
175
|
+
writeFileSync(path.join(appDir, 'smoke.pl'), SMOKE_PL)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
async function runScenario(scenario: string): Promise<void> {
|
|
179
|
+
const perl5lib = [MOJO_LIB_DIR, PERL_CORE_LIB_DIR, process.env.PERL5LIB]
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join(':')
|
|
182
|
+
const proc = Bun.spawn(['perl', 'smoke.pl'], {
|
|
183
|
+
cwd: appDir,
|
|
184
|
+
env: { ...process.env, PERL5LIB: perl5lib, SMOKE_SCENARIO: scenario },
|
|
185
|
+
stdout: 'pipe',
|
|
186
|
+
stderr: 'pipe',
|
|
187
|
+
})
|
|
188
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
189
|
+
new Response(proc.stdout).text(),
|
|
190
|
+
new Response(proc.stderr).text(),
|
|
191
|
+
proc.exited,
|
|
192
|
+
])
|
|
193
|
+
expect(exitCode, `TAP output:\n${stdout}\n${stderr}`).toBe(0)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('renders / with the manifest present at boot', async () => {
|
|
197
|
+
await runScenario('manifest-at-boot')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('renders / when the manifest appears after boot (concurrent dev-startup race)', async () => {
|
|
201
|
+
await runScenario('manifest-after-boot')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -423,7 +423,28 @@ export function renderSortMethod(recv: string, c: SortComparator): string {
|
|
|
423
423
|
// `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
|
|
424
424
|
// to the `-1` sentinel (flatten fully); a finite depth flattens that many
|
|
425
425
|
// levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
|
|
426
|
-
export function renderFlatMethod(
|
|
426
|
+
export function renderFlatMethod(
|
|
427
|
+
recv: string,
|
|
428
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
429
|
+
emit: (e: ParsedExpr) => string,
|
|
430
|
+
): string {
|
|
431
|
+
if (typeof depth === 'object') {
|
|
432
|
+
// Dynamic depth (#2094): routed to a SEPARATE runtime helper
|
|
433
|
+
// (`bf->flat_dynamic`), not `bf->flat`. The literal-depth path below
|
|
434
|
+
// already uses `-1` as a compile-time SENTINEL baked into the template
|
|
435
|
+
// source meaning "the source literally said `Infinity`". A genuinely
|
|
436
|
+
// dynamic depth that happens to evaluate to `-1` at render time means
|
|
437
|
+
// the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
|
|
438
|
+
// `.flat(0)`). Since both paths would otherwise hand the same
|
|
439
|
+
// literal-looking argument to one shared helper, that helper couldn't
|
|
440
|
+
// tell which case it's in — so `flat_dynamic` performs its own
|
|
441
|
+
// `ToIntegerOrInfinity`-style coercion (truncate toward zero; negative
|
|
442
|
+
// -> 0; NaN/non-numeric -> 0; +Infinity or a huge finite value ->
|
|
443
|
+
// flatten fully) and then delegates to `flat`. See `sub flat_dynamic`
|
|
444
|
+
// in BarefootJS.pm and the Go reference (`FlatDynamicDepth`/
|
|
445
|
+
// `coerceFlatDepth` in bf.go).
|
|
446
|
+
return `bf->flat_dynamic(${recv}, ${emit(depth.expr)})`
|
|
447
|
+
}
|
|
427
448
|
const d = depth === 'infinity' ? -1 : depth
|
|
428
449
|
return `bf->flat(${recv}, ${d})`
|
|
429
450
|
}
|
|
@@ -232,8 +232,12 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
|
|
|
232
232
|
return renderArrayMethod(method, object, args, emit)
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
flatMethod(
|
|
236
|
-
|
|
235
|
+
flatMethod(
|
|
236
|
+
object: ParsedExpr,
|
|
237
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
238
|
+
emit: (e: ParsedExpr) => string,
|
|
239
|
+
): string {
|
|
240
|
+
return renderFlatMethod(emit(object), depth, emit)
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
|
|
@@ -559,8 +563,12 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
559
563
|
return renderArrayMethod(method, object, args, emit)
|
|
560
564
|
}
|
|
561
565
|
|
|
562
|
-
flatMethod(
|
|
563
|
-
|
|
566
|
+
flatMethod(
|
|
567
|
+
object: ParsedExpr,
|
|
568
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
569
|
+
emit: (e: ParsedExpr) => string,
|
|
570
|
+
): string {
|
|
571
|
+
return renderFlatMethod(emit(object), depth, emit)
|
|
564
572
|
}
|
|
565
573
|
|
|
566
574
|
conditional(
|
|
@@ -628,12 +636,16 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
628
636
|
return "''"
|
|
629
637
|
}
|
|
630
638
|
|
|
631
|
-
objectLiteral(
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
// `
|
|
637
|
-
|
|
639
|
+
objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
|
|
640
|
+
// The shared `isSupported` gate only ever lets this dispatcher see an
|
|
641
|
+
// object literal as the EMPTY (`?? {}`) fallback operand of `??`
|
|
642
|
+
// (expression-parser.ts, `logical` case) — any other object literal is
|
|
643
|
+
// refused before reaching here. Emit Perl's real empty hashref literal,
|
|
644
|
+
// matching the `'{}'` convention `objectLiteralToPerlHashref` already
|
|
645
|
+
// uses for the zero-property case in the spread path. A populated
|
|
646
|
+
// literal is structurally unreachable given the gate, but still
|
|
647
|
+
// degrades safely to the pre-existing empty-string sentinel rather
|
|
648
|
+
// than silently dropping keys.
|
|
649
|
+
return properties.length === 0 ? '{}' : "''"
|
|
638
650
|
}
|
|
639
651
|
}
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
CompilerError,
|
|
27
27
|
TypeInfo,
|
|
28
28
|
TemplatePrimitiveRegistry,
|
|
29
|
+
LoopBindingPathSegment,
|
|
29
30
|
} from '@barefootjs/jsx'
|
|
30
31
|
import {
|
|
31
32
|
BaseAdapter,
|
|
@@ -49,14 +50,15 @@ import {
|
|
|
49
50
|
parseRecordIndexAccess,
|
|
50
51
|
extractArrowBodyExpression,
|
|
51
52
|
collectContextConsumers,
|
|
52
|
-
isLowerableObjectRestDestructure,
|
|
53
53
|
type ContextConsumer,
|
|
54
54
|
collectModuleStringConsts,
|
|
55
55
|
lookupStaticRecordLiteral,
|
|
56
56
|
searchParamsLocalNames,
|
|
57
57
|
prepareLoweringMatchers,
|
|
58
58
|
queryHrefArgs,
|
|
59
|
+
isValidHelperId,
|
|
59
60
|
sortComparatorFromArrow,
|
|
61
|
+
isLowerableLoopDestructure,
|
|
60
62
|
} from '@barefootjs/jsx'
|
|
61
63
|
import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
|
|
62
64
|
import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
|
|
@@ -94,6 +96,43 @@ import {
|
|
|
94
96
|
export type { MojoAdapterOptions } from './lib/types.ts'
|
|
95
97
|
import type { MojoAdapterOptions } from './lib/types.ts'
|
|
96
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Build a Perl accessor expression for a `.map()` destructure binding's
|
|
101
|
+
* structured `segments` path (#2087 Phase B), walking `field` (`->{key}`,
|
|
102
|
+
* quoted with `perlHashKey`'s convention when `!isIdent`) / `index`
|
|
103
|
+
* (`->[N]`) steps onto `base`. Never string-parses `LoopParamBinding.path` —
|
|
104
|
+
* see the repo-wide rule against regex-parsing JS/TS-derived syntax.
|
|
105
|
+
*
|
|
106
|
+
* Used both for a fixed binding's FULL accessor (`base` = `$__bf_item`,
|
|
107
|
+
* `segments` = the whole path) and a rest binding's PARENT-prefix accessor
|
|
108
|
+
* (`segments` may be empty, at the loop root, in which case this returns
|
|
109
|
+
* `base` unchanged) — see `LoopParamBinding.segments` jsdoc for which case
|
|
110
|
+
* a binding is in.
|
|
111
|
+
*/
|
|
112
|
+
function perlSegmentAccessor(base: string, segments: readonly LoopBindingPathSegment[]): string {
|
|
113
|
+
let expr = base
|
|
114
|
+
for (const seg of segments) {
|
|
115
|
+
expr +=
|
|
116
|
+
seg.kind === 'field'
|
|
117
|
+
? `->{${seg.isIdent ? seg.key : perlHashKey(seg.key)}}`
|
|
118
|
+
: `->[${seg.index}]`
|
|
119
|
+
}
|
|
120
|
+
return expr
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Quote a string as a Perl single-quoted literal (backslash first, then the
|
|
125
|
+
* quote, so the escape doesn't double up). Unlike `perlHashKey` — which
|
|
126
|
+
* intentionally leaves an identifier-safe name as a bareword for `->{key}` /
|
|
127
|
+
* `key => val` hash-key position — a plain list element (`bf->omit($x, [id,
|
|
128
|
+
* title])`) is NOT hash-key position: an unquoted bareword there is a sub
|
|
129
|
+
* call under `use strict subs`, so every object-rest exclude key needs an
|
|
130
|
+
* unconditional string literal.
|
|
131
|
+
*/
|
|
132
|
+
function perlStringLiteral(s: string): string {
|
|
133
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
|
|
98
137
|
name = 'mojolicious'
|
|
99
138
|
extension = '.html.ep'
|
|
@@ -705,41 +744,79 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
705
744
|
return `<%== bf->comment("loop:${loop.markerId}") %><%== bf->comment("/loop:${loop.markerId}") %>`
|
|
706
745
|
}
|
|
707
746
|
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
747
|
+
// A `.map()` destructure loop param (`([k, v]) => ...` / `({ id, user: {
|
|
748
|
+
// name } }) => ...` / `({ id, ...rest }) => ...`) lowers to a Perl `my`
|
|
749
|
+
// local per binding, walking each binding's structured `segments` path
|
|
750
|
+
// (#2087 Phase B) into a native `->{key}` / `->[N]` accessor off the
|
|
751
|
+
// per-item var — so the body's `$id` / `$name` / `$rest->{flag}` /
|
|
752
|
+
// `{...rest}` all resolve natively, at any nesting depth.
|
|
714
753
|
//
|
|
715
754
|
// Check the IR's structured `paramBindings` field rather than
|
|
716
755
|
// string-matching `loop.param`: Phase 1 populates `paramBindings`
|
|
717
756
|
// iff the param is a destructure pattern (array or object); a
|
|
718
757
|
// simple identifier leaves it `undefined`. The structured check is
|
|
719
758
|
// robust to whitespace / formatting variants in the source.
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
759
|
+
//
|
|
760
|
+
// `isLowerableLoopDestructure` (#2087) still refuses: an object-rest
|
|
761
|
+
// binding used any way other than member access (`rest.flag`) or a
|
|
762
|
+
// `{...rest}` spread onto an intrinsic element (that needs the actual
|
|
763
|
+
// residual *object*, which isn't always safe to materialize — see the
|
|
764
|
+
// gate's own jsdoc for the full list); a `.filter().map(destructure)`
|
|
765
|
+
// chain (the filter-param rewrite is out of scope here); and a
|
|
766
|
+
// computed property key (`{ [k]: v }`, refused earlier as BF025).
|
|
725
767
|
const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
|
|
726
|
-
const supportableDestructure = destructure &&
|
|
768
|
+
const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
|
|
727
769
|
if (destructure && !supportableDestructure) {
|
|
728
770
|
this.errors.push({
|
|
729
771
|
code: 'BF104',
|
|
730
772
|
severity: 'error',
|
|
731
|
-
message: `Loop callback uses
|
|
773
|
+
message: `Loop callback uses a destructure pattern (\`${loop.param}\`) that the Mojo adapter cannot lower — see the diagnostic detail for the specific shape.`,
|
|
732
774
|
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
733
775
|
suggestion: {
|
|
734
776
|
message:
|
|
735
777
|
`Options:\n` +
|
|
736
|
-
` 1.
|
|
737
|
-
` 2.
|
|
738
|
-
` 3.
|
|
778
|
+
` 1. If this is an object-rest binding (\`{ ...rest }\`), only reading \`rest.field\` or spreading \`{...rest}\` onto an intrinsic element lowers — other uses (passing \`rest\` to a function, rendering it as text) need the client runtime.\n` +
|
|
779
|
+
` 2. If this is chained \`.filter().map(({ ... }) => ...)\`, hoist the destructure into a variable inside the callback body instead.\n` +
|
|
780
|
+
` 3. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
781
|
+
` 4. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
739
782
|
},
|
|
740
783
|
})
|
|
741
784
|
}
|
|
742
785
|
|
|
786
|
+
// A `.map()` loop whose array is a bare identifier bound to a
|
|
787
|
+
// FUNCTION-scope local const with a non-statically-evaluable initializer
|
|
788
|
+
// that reads props/signals (e.g. `const entries =
|
|
789
|
+
// Object.entries(props.x ?? {}).filter(...)`) can't render correctly.
|
|
790
|
+
// Module-scope consts (`isModule`, e.g. `const payments = [...]` at the
|
|
791
|
+
// top of the file) are a DIFFERENT, already-working case handled
|
|
792
|
+
// elsewhere. Function-scope locals get no per-render stash slot — this
|
|
793
|
+
// adapter's only "elsewhere" for a local const is inlining its value at
|
|
794
|
+
// the use site (`resolveLiteralConst`'s numeric/single-quoted-string fast
|
|
795
|
+
// path, or a static-record-literal lookup), never binding one as a `my`
|
|
796
|
+
// template local. Left unchecked, `% for my $_i (0..$#{$entries}) {`
|
|
797
|
+
// over an undeclared `$entries` faults under `use strict` at request
|
|
798
|
+
// time instead of failing loudly at build time. Pre-existing, general
|
|
799
|
+
// limitation, orthogonal to #2087's destructure-binding work — newly
|
|
800
|
+
// reachable in this adapter's test corpus only because the widened
|
|
801
|
+
// destructure gate (#2087 Phase A/B) no longer refuses this fixture's
|
|
802
|
+
// `([emoji, users]) => ...` param first.
|
|
803
|
+
const arrayName = loop.array.trim()
|
|
804
|
+
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
805
|
+
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
806
|
+
if (arrayConst && !arrayConst.isModule && this.resolveLiteralConst(arrayName) === null) {
|
|
807
|
+
this.errors.push({
|
|
808
|
+
code: 'BF101',
|
|
809
|
+
severity: 'error',
|
|
810
|
+
message: `Loop array \`${arrayName}\` is a local computed value (\`${arrayConst.value}\`) that the Mojo adapter cannot bind as a template variable — only numeric/string-literal locals inline at their use site.`,
|
|
811
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
812
|
+
suggestion: {
|
|
813
|
+
message:
|
|
814
|
+
'Pre-compute the array server-side and pass it as a prop, or mark the loop position as @client-only so it runs in JS on the client.',
|
|
815
|
+
},
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
743
820
|
const rawArray = this.convertExpressionToPerl(loop.array)
|
|
744
821
|
// Apply sort if present (#1448 Tier B): wrap the loop array in the
|
|
745
822
|
// shared sort helper. The same `renderSortEval` / `renderSortMethod`
|
|
@@ -844,15 +921,31 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
844
921
|
lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
|
|
845
922
|
if (loop.iterationShape !== 'keys') {
|
|
846
923
|
if (supportableDestructure) {
|
|
847
|
-
// Per-item var + one `my` local per binding
|
|
848
|
-
//
|
|
924
|
+
// Per-item var + one `my` local per binding, walking each binding's
|
|
925
|
+
// `segments` path (#2087 Phase B):
|
|
926
|
+
// - fixed (`b.rest` unset): the FULL accessor from `$__bf_item`.
|
|
927
|
+
// - array-rest: `bf->slice(parent, from, undef)` — the same
|
|
928
|
+
// runtime helper `.slice()` JS-method calls lower to (see
|
|
929
|
+
// `array-method.ts`), so the "no end → to length" arithmetic
|
|
930
|
+
// stays in one place.
|
|
931
|
+
// - object-rest: `bf->omit(parent, [...excluded keys...])` — a
|
|
932
|
+
// TRUE residual hashref (not the whole item aliased), so both
|
|
933
|
+
// `$rest->{flag}` (member-access use) and `bf->spread_attrs($rest)`
|
|
934
|
+
// (spread-onto-element use) see only the non-destructured keys.
|
|
935
|
+
// `parent` is `$__bf_item` walked through the binding's PARENT-prefix
|
|
936
|
+
// `segments` (empty at the loop root, per the `LoopParamBinding`
|
|
937
|
+
// jsdoc) — NOT the same as a fixed binding's full-accessor segments.
|
|
849
938
|
lines.push(`% my $__bf_item = ${array}->[${indexVar}];`)
|
|
850
939
|
for (const b of loop.paramBindings ?? []) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
)
|
|
940
|
+
const parent = perlSegmentAccessor('$__bf_item', b.segments ?? [])
|
|
941
|
+
if (b.rest?.kind === 'object') {
|
|
942
|
+
const exclude = b.rest.exclude.map(k => perlStringLiteral(k.key)).join(', ')
|
|
943
|
+
lines.push(`% my $${b.name} = bf->omit(${parent}, [${exclude}]);`)
|
|
944
|
+
} else if (b.rest?.kind === 'array') {
|
|
945
|
+
lines.push(`% my $${b.name} = bf->slice(${parent}, ${b.rest.from}, undef);`)
|
|
946
|
+
} else {
|
|
947
|
+
lines.push(`% my $${b.name} = ${perlSegmentAccessor('$__bf_item', b.segments ?? [])};`)
|
|
948
|
+
}
|
|
856
949
|
}
|
|
857
950
|
} else {
|
|
858
951
|
lines.push(`% my $${param} = ${array}->[${indexVar}];`)
|
|
@@ -1611,6 +1704,17 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
1611
1704
|
const argsGo = queryHrefArgs(node, n => this.renderParsedExprToPerl(n))
|
|
1612
1705
|
return `bf->query(${argsGo.join(', ')})`
|
|
1613
1706
|
}
|
|
1707
|
+
// Generic `helper-call` (#2069) — the neutral vocabulary's escape
|
|
1708
|
+
// hatch for a userland `LoweringPlugin` that lowers to a single
|
|
1709
|
+
// runtime-helper invocation. `bf-><helper>(args…)` mirrors the
|
|
1710
|
+
// `query` helper's own naming convention exactly: the framework
|
|
1711
|
+
// renders the call, the plugin author registers `<helper>` as a
|
|
1712
|
+
// method on their own Mojolicious `bf` helper object — same
|
|
1713
|
+
// contract as `bf->query` itself, just not built in.
|
|
1714
|
+
if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
|
|
1715
|
+
const argsX = node.args.map(a => this.renderParsedExprToPerl(a))
|
|
1716
|
+
return `bf->${node.helper}(${argsX.join(', ')})`
|
|
1717
|
+
}
|
|
1614
1718
|
}
|
|
1615
1719
|
}
|
|
1616
1720
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-fixture build-time contracts for shapes the Mojo adapter
|
|
3
|
+
* intentionally refuses to lower. Owned by this module (not by the
|
|
4
|
+
* shared fixtures) so adding a new adapter doesn't require touching any
|
|
5
|
+
* cross-adapter file. Consumed by this package's own conformance test
|
|
6
|
+
* (as `expectedDiagnostics`) and by `bf compat` (issue-URL attribution).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ConformancePins } from '@barefootjs/jsx'
|
|
10
|
+
|
|
11
|
+
export const conformancePins: ConformancePins = {
|
|
12
|
+
// Sibling-imported child component in a loop body: Mojo emits
|
|
13
|
+
// a cross-template call that needs separate registration. BF103
|
|
14
|
+
// makes the requirement loud. (The barefoot CLI passes
|
|
15
|
+
// `siblingTemplatesRegistered: true` so CLI builds suppress it.)
|
|
16
|
+
'static-array-children': [{ code: 'BF103', severity: 'error' }],
|
|
17
|
+
// TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
|
|
18
|
+
// call it inside a keyed `.map`. Same BF103 surface as the
|
|
19
|
+
// synthetic `static-array-children` above — pinned at adapter
|
|
20
|
+
// level so the shared-component corpus stays adapter-neutral.
|
|
21
|
+
'todo-app': [{ code: 'BF103', severity: 'error' }],
|
|
22
|
+
'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
|
|
23
|
+
// `([emoji, users]) => ...` / `([id, t]) => ...` are plain array-index
|
|
24
|
+
// (tuple) destructures, no rest — #2087 Phase B's `segments`-walking
|
|
25
|
+
// accessor lowers both to `$__bf_item->[0]` / `$__bf_item->[1]` `my`
|
|
26
|
+
// locals like any other fixed binding, so BF104 no longer fires for
|
|
27
|
+
// either fixture. Each now hits a DIFFERENT, pre-existing, orthogonal
|
|
28
|
+
// gap instead: the loop array (`entries`) is a function-scope local
|
|
29
|
+
// const with a computed initializer
|
|
30
|
+
// (`Object.entries(props.x ?? {}).filter(...)`) that the adapter can't
|
|
31
|
+
// bind as a template variable — see the dedicated `arrayConst` BF101
|
|
32
|
+
// check in `renderLoop`. This was always true; it was simply
|
|
33
|
+
// unreachable before because BF104 refused the destructure shape first.
|
|
34
|
+
'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
|
|
35
|
+
// Both BF103 (sibling-imported `<Tag>` child component) and the BF101
|
|
36
|
+
// above fire; BF104 no longer does (see above).
|
|
37
|
+
'static-array-from-props-with-component': [
|
|
38
|
+
{ code: 'BF103', severity: 'error' },
|
|
39
|
+
{ code: 'BF101', severity: 'error' },
|
|
40
|
+
],
|
|
41
|
+
// #1310 / #2087: rest destructure in .map() callback. All four shapes
|
|
42
|
+
// now lower via #2087 Phase B's `segments`-walking accessor:
|
|
43
|
+
// - object-rest read via member access (`rest-destructure-object-in-map`):
|
|
44
|
+
// `bf->omit($__bf_item, [...exclude keys...])`, `$rest->{flag}` reads
|
|
45
|
+
// the residual hashref.
|
|
46
|
+
// - object-rest spread onto the root element
|
|
47
|
+
// (`rest-destructure-object-spread-in-map`): same `bf->omit(...)`
|
|
48
|
+
// residual, forwarded via the existing `bf->spread_attrs($rest)` path.
|
|
49
|
+
// - array-rest (`rest-destructure-array-in-map`): `bf->slice($__bf_item,
|
|
50
|
+
// N, undef)` — the same runtime helper `.slice()` JS-method calls use.
|
|
51
|
+
// - nested rest inside an object pattern (`rest-destructure-nested-in-map`):
|
|
52
|
+
// the parent-prefix accessor (`$__bf_item->{cells}`) feeds the same
|
|
53
|
+
// `bf->slice(...)` call.
|
|
54
|
+
// None of these are pinned here anymore.
|
|
55
|
+
// `style-3-signals` / `style-object-dynamic` no longer pinned — a
|
|
56
|
+
// `style={{ … }}` object literal now lowers to a CSS string with dynamic
|
|
57
|
+
// values interpolated (`background-color:<%= $color %>;padding:8px`) via
|
|
58
|
+
// `tryLowerStyleObject` (#1322).
|
|
59
|
+
// (`tagged-template-classname` graduated by #2092 — the tag resolves
|
|
60
|
+
// through the interleave-tag catalogue and desugars to an untagged
|
|
61
|
+
// template literal, so it lowers like any other className template.)
|
|
62
|
+
// #2038: a filter predicate containing a nested `.find(...)` callback.
|
|
63
|
+
// `find*` returns an element, not a boolean — there is no inline grep
|
|
64
|
+
// form, and the emitter used to degrade the call to its receiver.
|
|
65
|
+
// The nested `.some` sibling (`filter-nested-callback-predicate`) is
|
|
66
|
+
// NOT pinned: Mojo lowers it to a real inline Perl `grep` and must
|
|
67
|
+
// render to Hono parity instead.
|
|
68
|
+
// https://github.com/piconic-ai/barefootjs/issues/2038
|
|
69
|
+
'filter-nested-find-predicate': [
|
|
70
|
+
{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2038' },
|
|
71
|
+
],
|
|
72
|
+
// #1467 demo-corpus context providers (`radio-group`, `accordion`,
|
|
73
|
+
// `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
|
|
74
|
+
// `command`) are no longer pinned — an object-literal provider value
|
|
75
|
+
// (`{ open: () => props.open ?? false, onOpenChange: (v) => {…} }`)
|
|
76
|
+
// lowers to a Perl hashref via `parseProviderObjectLiteral` (#1897):
|
|
77
|
+
// getter members snapshot their body's SSR value, handler /
|
|
78
|
+
// function-shaped members lower to `undef`. The command demo's
|
|
79
|
+
// `ref={(el) => {…}}` function prop on an imported component is
|
|
80
|
+
// skipped at SSR like `on*` handlers.
|
|
81
|
+
//
|
|
82
|
+
// #1467 Phase 2e: `data-table` is no longer pinned here either — it
|
|
83
|
+
// compiles clean (`selected()[index]` → `index-access`,
|
|
84
|
+
// `.toFixed(2)` → `bf->to_fixed`, `/* @client */` memo SSR-folded)
|
|
85
|
+
// and renders to Hono parity on real Mojolicious. The keyed-loop
|
|
86
|
+
// scope-ID divergence (#1896) was fixed by the body-children
|
|
87
|
+
// `inLoop` reset (loop-item children get `_bf_slot`); data-table is
|
|
88
|
+
// off `skipJsx` entirely and only kept in `skipMarkerConformance`
|
|
89
|
+
// below for the shared `/* @client */` keyed-map slot-id elision
|
|
90
|
+
// contract (same as `todo-app`), not a render or BF101 gap.
|
|
91
|
+
// #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
|
|
92
|
+
// shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
|
|
93
|
+
// No BF101 expected — pinned positively via the
|
|
94
|
+
// `branch-local-filter-join` template-output test below.
|
|
95
|
+
//
|
|
96
|
+
// #1448 Tier A — JS Array / String methods that the Mojo adapter
|
|
97
|
+
// hasn't lowered yet. Each row drops once the corresponding
|
|
98
|
+
// method PR lands. Hono / CSR pass these out of the box (they
|
|
99
|
+
// evaluate JS at runtime) so the pin only applies here.
|
|
100
|
+
//
|
|
101
|
+
// `array-includes` / `string-includes` no longer pinned — both
|
|
102
|
+
// shapes lower via the shared `array-method` IR + `bf->includes`
|
|
103
|
+
// runtime dispatch (#1448 Tier A first PR).
|
|
104
|
+
// `array-indexOf` / `array-lastIndexOf` no longer pinned —
|
|
105
|
+
// value-equality `bf->index_of` / `bf->last_index_of` helpers
|
|
106
|
+
// handle the shape (#1448 Tier A second PR).
|
|
107
|
+
// `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
|
|
108
|
+
// handle the negative-index lookup (#1448 Tier A third PR).
|
|
109
|
+
// `array-concat` no longer pinned — `bf->concat` (Mojo) /
|
|
110
|
+
// `bf_concat` (Go) merge two arrays into a new array
|
|
111
|
+
// (#1448 Tier A fourth PR).
|
|
112
|
+
// `array-slice` no longer pinned — `bf->slice` (Mojo) /
|
|
113
|
+
// `bf_slice` (Go) carve out a sub-range with JS-compat
|
|
114
|
+
// negative-index / out-of-bounds clamping (#1448 Tier A
|
|
115
|
+
// fifth PR).
|
|
116
|
+
// `array-reverse` / `array-toReversed` no longer pinned —
|
|
117
|
+
// both share the `bf->reverse` / `bf_reverse` helper since
|
|
118
|
+
// SSR templates render a snapshot and the JS mutate-vs-new
|
|
119
|
+
// distinction has no template-level meaning (#1448 Tier A
|
|
120
|
+
// sixth PR).
|
|
121
|
+
// `string-toLowerCase` / `string-toUpperCase` no longer pinned —
|
|
122
|
+
// Perl's native `lc` / `uc` (Mojo) and pre-existing
|
|
123
|
+
// `bf_lower` / `bf_upper` (Go) handle the JS method names
|
|
124
|
+
// (#1448 Tier A seventh + eighth PRs).
|
|
125
|
+
// `string-trim` no longer pinned — pre-existing `bf_trim`
|
|
126
|
+
// (Go) and new `bf->trim` helper (Mojo) handle the strip
|
|
127
|
+
// (#1448 Tier A ninth PR, closing out Tier A).
|
|
128
|
+
// `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are no longer
|
|
129
|
+
// pinned — the Mojo `callbackMethod` predicate arm now lowers them to the
|
|
130
|
+
// runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
|
|
131
|
+
// (per-element coderef predicate), matching Xslate. `.join` was never
|
|
132
|
+
// pinned (handled by `renderArrayMethod`'s `case 'join'`).
|
|
133
|
+
// #2073 follow-up: a function-reference `.map(format)` callback has no
|
|
134
|
+
// arrow body to serialize — not a CALLBACK_METHODS shape — so the
|
|
135
|
+
// UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
|
|
136
|
+
// a broken template.
|
|
137
|
+
'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
|
|
138
|
+
}
|