@barefootjs/mojolicious 0.17.1 → 0.18.1

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.
@@ -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(recv: string, depth: FlatDepth): string {
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(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
236
- return renderFlatMethod(emit(object), depth)
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(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
563
- return renderFlatMethod(emit(object), depth)
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(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
632
- // Mirror `unsupported`: a bare object literal reaching the dispatcher
633
- // lowers to the safe Perl empty-string literal, exactly as before the
634
- // `object-literal` kind existed (byte-identical; Roadmap A-1). Object
635
- // values that round-trip to a Perl hashref go through the dedicated
636
- // `objectLiteralToPerlHashref` lowering in the conditional/attr paths.
637
- return "''"
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
- // An array/object-destructure loop param (`([emoji, users]) => ...`
709
- // or `({ name, age }) => ...`) lowers to invalid Perl — the adapter
710
- // would otherwise emit `% my $[emoji, users] = $entries->[$_i];`,
711
- // which is a parse error. Surface this at build time (#1266)
712
- // instead of shipping the broken template line for the user to
713
- // discover at request time.
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
- // A destructure loop param is lowerable for the object-rest / simple-field
721
- // shape (`.map(({ id, title, ...rest }) => …)`, `rest` read via member
722
- // access): each binding becomes a Perl `my` local off the per-item var, so
723
- // the body's `$id` / `$rest->{flag}` resolve natively. Array-index / nested
724
- // / rest-spread shapes still can't unpack into scalar `my`s BF104. (#1310)
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 && isLowerableObjectRestDestructure(loop)
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 an array/object destructure pattern (\`${loop.param}\`) that the Mojo adapter cannot lower — Perl scalar bindings can't unpack a tuple in a single \`my\` declaration.`,
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. Rename the parameter to a single name and access tuple elements with index syntax in the body (e.g. \`entry => entry->[0]\` instead of \`([k, v]) => ...\`).\n` +
737
- ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
738
- ` 3. Move the loop into a primitive that the adapter registers explicitly.`,
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; `rest` aliases the item
848
- // so `$rest->{flag}` resolves (object-rest read via member access).
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
- lines.push(
852
- b.rest
853
- ? `% my $${b.name} = $__bf_item;`
854
- : `% my $${b.name} = $__bf_item->{${b.path.slice(1)}};`,
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
+ }