@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.
@@ -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'
@@ -695,44 +734,89 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
695
734
  // ===========================================================================
696
735
 
697
736
  renderLoop(loop: IRLoop): string {
698
- // Client-only loops: skip SSR rendering entirely
699
- if (loop.clientOnly) return ''
700
-
701
- // An array/object-destructure loop param (`([emoji, users]) => ...`
702
- // or `({ name, age }) => ...`) lowers to invalid Perl — the adapter
703
- // would otherwise emit `% my $[emoji, users] = $entries->[$_i];`,
704
- // which is a parse error. Surface this at build time (#1266)
705
- // instead of shipping the broken template line for the user to
706
- // discover at request time.
737
+ // clientOnly loops must not render items at SSR time, but must still emit
738
+ // the `loop:`/`/loop:` boundary marker pair (Hono and Go parity) so the
739
+ // client runtime's mapArray() can locate the insertion anchor when
740
+ // hydrating the array. Without the markers, mapArray() resolves
741
+ // anchor = null and appends after sibling markers (#872). The marker id
742
+ // disambiguates sibling `.map()` calls under the same parent (#1087).
743
+ if (loop.clientOnly) {
744
+ return `<%== bf->comment("loop:${loop.markerId}") %><%== bf->comment("/loop:${loop.markerId}") %>`
745
+ }
746
+
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.
707
753
  //
708
754
  // Check the IR's structured `paramBindings` field rather than
709
755
  // string-matching `loop.param`: Phase 1 populates `paramBindings`
710
756
  // iff the param is a destructure pattern (array or object); a
711
757
  // simple identifier leaves it `undefined`. The structured check is
712
758
  // robust to whitespace / formatting variants in the source.
713
- // A destructure loop param is lowerable for the object-rest / simple-field
714
- // shape (`.map(({ id, title, ...rest }) => …)`, `rest` read via member
715
- // access): each binding becomes a Perl `my` local off the per-item var, so
716
- // the body's `$id` / `$rest->{flag}` resolve natively. Array-index / nested
717
- // / 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).
718
767
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
719
- const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop)
768
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
720
769
  if (destructure && !supportableDestructure) {
721
770
  this.errors.push({
722
771
  code: 'BF104',
723
772
  severity: 'error',
724
- 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.`,
725
774
  loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
726
775
  suggestion: {
727
776
  message:
728
777
  `Options:\n` +
729
- ` 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` +
730
- ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
731
- ` 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.`,
732
782
  },
733
783
  })
734
784
  }
735
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
+
736
820
  const rawArray = this.convertExpressionToPerl(loop.array)
737
821
  // Apply sort if present (#1448 Tier B): wrap the loop array in the
738
822
  // shared sort helper. The same `renderSortEval` / `renderSortMethod`
@@ -837,15 +921,31 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
837
921
  lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
838
922
  if (loop.iterationShape !== 'keys') {
839
923
  if (supportableDestructure) {
840
- // Per-item var + one `my` local per binding; `rest` aliases the item
841
- // 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.
842
938
  lines.push(`% my $__bf_item = ${array}->[${indexVar}];`)
843
939
  for (const b of loop.paramBindings ?? []) {
844
- lines.push(
845
- b.rest
846
- ? `% my $${b.name} = $__bf_item;`
847
- : `% my $${b.name} = $__bf_item->{${b.path.slice(1)}};`,
848
- )
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
+ }
849
949
  }
850
950
  } else {
851
951
  lines.push(`% my $${param} = ${array}->[${indexVar}];`)
@@ -1389,13 +1489,22 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1389
1489
  // now lowering `.length` on a higher-order object to
1390
1490
  // `scalar(@{...})`, the canonical
1391
1491
  // `x.tags.filter(t => t.active).length > 0` shape lowers
1392
- // cleanly. Predicates that combine a nested higher-order with
1393
- // something OTHER than `.length` (e.g. `.includes`, `.join`)
1394
- // still fall back to whatever the emitter produces most of
1395
- // those would yield runtime errors in Perl, which is the user's
1396
- // signal to refactor. Wholesale refusal would also block the
1397
- // canonical case the issue exists to enable.
1398
- return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1492
+ // cleanly, and nested `filter` / `every` / `some` lower to real
1493
+ // inline `grep` forms. The shapes the emitter can only DEGRADE
1494
+ // (nested `find*`, sort / reduce / flatMap inside a predicate)
1495
+ // surface BF101 through the hook below instead of silently
1496
+ // rewriting the predicate (#2038). Wholesale refusal would block
1497
+ // the canonical case #1443 exists to enable, so the gate lives at
1498
+ // the exact degrade points inside `MojoFilterEmitter.callbackMethod`.
1499
+ return emitParsedExpr(
1500
+ expr,
1501
+ new MojoFilterEmitter(
1502
+ param,
1503
+ localVarMap,
1504
+ n => this._isStringValueName(n),
1505
+ (message, reason) => this._recordExprBF101(message, reason),
1506
+ ),
1507
+ )
1399
1508
  }
1400
1509
 
1401
1510
  // ===========================================================================
@@ -1595,6 +1704,17 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1595
1704
  const argsGo = queryHrefArgs(node, n => this.renderParsedExprToPerl(n))
1596
1705
  return `bf->query(${argsGo.join(', ')})`
1597
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
+ }
1598
1718
  }
1599
1719
  }
1600
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
+ }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@
6
6
 
7
7
  export { MojoAdapter, mojoAdapter } from './adapter/index.ts'
8
8
  export type { MojoAdapterOptions } from './adapter/index.ts'
9
+ export { conformancePins } from './conformance-pins.ts'
@@ -213,6 +213,14 @@ export async function renderMojoComponent(options: RenderOptions): Promise<strin
213
213
  await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.html.ep`), patchTemplate(template))
214
214
  }
215
215
 
216
+ // Surface every `props.<x>` access as a propsParam before building
217
+ // the top-level stash (idempotent; the same shared pass the adapter's
218
+ // `generate` applies to its own IR instance — this round-tripped IR
219
+ // hasn't been augmented). Without it, a read reachable only through a
220
+ // dynamic text child / condition / loop array (#2126 follow-up) has
221
+ // no param entry, `buildPerlProps` seeds nothing, and the template's
222
+ // bare `$var` aborts under Mojo::Template's strict vars.
223
+ augmentInheritedPropAccesses(ir)
216
224
  // Build props hash for Perl
217
225
  const propsPerl = buildPerlProps(componentName, props, ir)
218
226