@barefootjs/mojolicious 0.6.0 → 0.7.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.
@@ -1,4 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
+ our $VERSION = "0.01";
2
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
3
4
 
4
5
  use Mojo::File qw(path);
@@ -102,3 +103,58 @@ sub _load_manifest ($app, $config) {
102
103
  }
103
104
 
104
105
  1;
106
+ __END__
107
+
108
+ =encoding utf8
109
+
110
+ =head1 NAME
111
+
112
+ Mojolicious::Plugin::BarefootJS - Mojolicious integration for BarefootJS
113
+
114
+ =head1 SYNOPSIS
115
+
116
+ # Mojolicious application
117
+ $self->plugin('BarefootJS');
118
+
119
+ # In a controller / template, the `bf` helper exposes a per-request
120
+ # BarefootJS runtime backed by BarefootJS::Backend::Mojo.
121
+
122
+ =head1 DESCRIPTION
123
+
124
+ Wires the L<BarefootJS> server runtime into L<Mojolicious>. It registers a
125
+ C<bf> controller helper that lazily instantiates one BarefootJS object per
126
+ request (rendering via L<BarefootJS::Backend::Mojo>), and supports rendering
127
+ compiled marked templates as Mojolicious templates.
128
+
129
+ For non-Mojolicious / PSGI hosts, see L<BarefootJS::Backend::Xslate>, which
130
+ drives the same runtime with Text::Xslate and no web framework.
131
+
132
+ =head1 METHODS
133
+
134
+ L<Mojolicious::Plugin::BarefootJS> inherits all methods from
135
+ L<Mojolicious::Plugin> and implements the following new one.
136
+
137
+ =head2 register
138
+
139
+ $plugin->register(Mojolicious->new, \%conf);
140
+
141
+ Registers the plugin (the C<bf> helper and supporting hooks) in a Mojolicious
142
+ application.
143
+
144
+ =head1 SEE ALSO
145
+
146
+ L<BarefootJS>, L<BarefootJS::Backend::Mojo>, L<BarefootJS::Backend::Xslate>,
147
+ L<Mojolicious>, L<https://github.com/piconic-ai/barefootjs>
148
+
149
+ =head1 AUTHOR
150
+
151
+ kobaken E<lt>kentafly88@gmail.comE<gt>
152
+
153
+ =head1 LICENSE
154
+
155
+ Copyright (c) 2025-present BarefootJS Contributors.
156
+
157
+ This library is free software; you can redistribute it and/or modify it under
158
+ the MIT License. See the F<LICENSE> file in the distribution for the full text.
159
+
160
+ =cut
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "build": "bun run build:js && bun run build:types",
32
- "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
32
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared --external typescript",
33
33
  "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
34
34
  "test": "bun test",
35
35
  "clean": "rm -rf dist",
@@ -52,14 +52,15 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.6.0"
55
+ "@barefootjs/perl": "0.7.0",
56
+ "@barefootjs/shared": "0.7.0"
56
57
  },
57
58
  "peerDependencies": {
58
- "@barefootjs/jsx": ">=0.2.0"
59
+ "@barefootjs/jsx": ">=0.2.0",
60
+ "typescript": "^5.0.0"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@barefootjs/adapter-tests": "0.1.0",
62
- "@barefootjs/jsx": "0.6.0",
63
- "typescript": "^5.0.0"
64
+ "@barefootjs/jsx": "0.7.0"
64
65
  }
65
66
  }
@@ -69,16 +69,17 @@ runAdapterConformanceTests({
69
69
  'toggle-shared',
70
70
  'reactive-props',
71
71
  'props-reactivity-comparison',
72
- // #1467 Phase 2a: first `site/ui` source-root fixture. Button
73
- // compiles cleanly on the Mojo adapter (no diagnostic), but its
74
- // variant/size class composition (`Record<…>[key]` indexed object
75
- // literals) and `applyRestAttrs` spread haven't been validated for
76
- // byte-parity against the Hono reference under Mojo's template
77
- // semantics. Cross-adapter parity for the `site/ui` corpus is
78
- // explicitly Phase 3 ("cross-adapter parity (Mojo/Go templates)"),
79
- // so Button participates only in Hono SSR conformance + the
80
- // fixture-hydrate runtime layer for now.
81
- 'button',
72
+ // #1467 Phase 2b: basic interactive `site/ui` primitives. Cross-adapter
73
+ // parity for the `site/ui` corpus is Phase 3, so these participate only
74
+ // in Hono SSR conformance + the fixture-hydrate runtime layer for now.
75
+ // (`label` and `kbd` are static helpers; `toggle` / `switch` /
76
+ // `checkbox` carry uncontrolled state; `input` / `textarea` are
77
+ // pass-through native controls.)
78
+ 'toggle',
79
+ 'switch',
80
+ 'checkbox',
81
+ 'textarea',
82
+ 'kbd',
82
83
  ],
83
84
  // Per-fixture build-time contracts for shapes the Mojo adapter
84
85
  // intentionally refuses to lower. Owned by this adapter test file
@@ -256,6 +257,42 @@ export function Hello() {
256
257
  expect(adapter.extension).toBe('.html.ep')
257
258
  })
258
259
 
260
+ test('module pure-string const referenced in className inlines the literal (#1467 Phase 2b)', () => {
261
+ // A module-scope `const X = 'literal'` used inside a className template
262
+ // literal must inline its value, NOT emit `$X` against a stash variable
263
+ // that is never bound (the value would render empty). Hono inlines it at
264
+ // runtime; this restores byte-parity.
265
+ const result = compileAndGenerate(`
266
+ "use client"
267
+ const labelClasses = 'flex items-center group-data-[disabled=true]:opacity-50'
268
+ export function Label({ className = '' }: { className?: string }) {
269
+ return <label className={\`\${labelClasses} \${className}\`} />
270
+ }
271
+ `)
272
+ // Inlined as a Perl single-quoted literal, escaped tokens intact.
273
+ expect(result.template).toContain(
274
+ "'flex items-center group-data-[disabled=true]:opacity-50'",
275
+ )
276
+ // No stash-variable reference to the const.
277
+ expect(result.template).not.toContain('$labelClasses')
278
+ })
279
+
280
+ test('module pure-string const is NOT inlined when shadowed by a loop variable (#1749 review)', () => {
281
+ // A loop param whose name matches a module const must keep its loop
282
+ // binding (`$label`) inside the body — the const literal must not leak
283
+ // in. `renderLoop` guards module-const inlining for the loop body.
284
+ const result = compileAndGenerate(`
285
+ "use client"
286
+ const label = 'MODULE_CONST'
287
+ export function List({ items }: { items: string[] }) {
288
+ return <ul>{items.map(label => <li>{label}</li>)}</ul>
289
+ }
290
+ `)
291
+ // Inside the loop the param wins — emit the loop variable, not the const.
292
+ expect(result.template).toContain('$label')
293
+ expect(result.template).not.toContain('MODULE_CONST')
294
+ })
295
+
259
296
  test('generates conditional with Perl if/else', () => {
260
297
  const result = compileAndGenerate(`
261
298
  "use client"
@@ -395,9 +432,16 @@ export function C() {
395
432
  // The nested-higher-order-in-filter-predicate shape also lowers
396
433
  // now (#1443 PR4) — moved to a positive-output test below.
397
434
  const cases: { name: string; body: string; needle: string }[] = [
398
- { name: 'reduce', body: `<div>{items().reduce((s, x) => s + x, 0)}</div>`, needle: '.reduce(' },
435
+ // The arithmetic-fold `.reduce(fn, init)` catalogue now lowers
436
+ // (positive-output tests below + the reduce-* conformance
437
+ // fixtures); the no-init form stays refused — JS throws on an
438
+ // empty array there, which a template can't mirror.
439
+ { name: 'reduce (no init)', body: `<div>{items().reduce((s, x) => s + x)}</div>`, needle: '.reduce(' },
399
440
  { name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
400
- { name: 'flatMap', body: `<ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>`, needle: '.flatMap(' },
441
+ // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C)
442
+ // even as a loop base — so those moved to positive tests below. A
443
+ // tuple with a non-leaf element (a string literal) stays refused.
444
+ { name: 'flatMap (literal element)', body: `<div>{items().flatMap(x => [x.tag, "x"])}</div>`, needle: '.flatMap(' },
401
445
  ]
402
446
 
403
447
  for (const { name, body, needle } of cases) {
@@ -960,6 +1004,12 @@ import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures
960
1004
  import { fixture as arrayEntriesFixture } from '../../../adapter-tests/fixtures/methods/array-entries'
961
1005
  import { fixture as arrayKeysFixture } from '../../../adapter-tests/fixtures/methods/array-keys'
962
1006
  import { fixture as arrayValuesFixture } from '../../../adapter-tests/fixtures/methods/array-values'
1007
+ // #1448 Tier C — .reduce(fn, init) arithmetic-fold catalogue.
1008
+ import { fixture as reduceSumFieldFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-field'
1009
+ import { fixture as reduceSumSelfFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-self'
1010
+ import { fixture as reduceConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-concat'
1011
+ import { fixture as reduceProductFixture } from '../../../adapter-tests/fixtures/methods/reduce-product'
1012
+ import { fixture as reduceRightConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-right-concat'
963
1013
 
964
1014
  describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
965
1015
  const cases = [
@@ -1020,6 +1070,17 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1020
1070
  { fixture: arrayKeysFixture, expect: '% for my $k (0..$#{$items})' },
1021
1071
  // .values() → standard for loop (same as plain .map())
1022
1072
  { fixture: arrayValuesFixture, expect: '% my $v = $items->[$_i];' },
1073
+ // #1448 Tier C — .reduce(fn, init) arithmetic fold. The structured
1074
+ // ReduceOp lowers to a single `bf->reduce(...)` call with op /
1075
+ // key / type / init / direction in the options hash. Each shape
1076
+ // exercises one arm: field-numeric sum, self-numeric sum,
1077
+ // string-concat fold, the product (`*`) operator, and the
1078
+ // right-to-left `direction` of reduceRight.
1079
+ { fixture: reduceSumFieldFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'duration', type => 'numeric', init => 0, direction => 'left' })` },
1080
+ { fixture: reduceSumSelfFixture, expect: `bf->reduce($nums, { op => '+', key_kind => 'self', type => 'numeric', init => 0, direction => 'left' })` },
1081
+ { fixture: reduceConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'left' })` },
1082
+ { fixture: reduceProductFixture, expect: `bf->reduce($items, { op => '*', key_kind => 'field', key => 'qty', type => 'numeric', init => 1, direction => 'left' })` },
1083
+ { fixture: reduceRightConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'right' })` },
1023
1084
  ]
1024
1085
 
1025
1086
  for (const { fixture, expect: expectedHelper } of cases) {
@@ -1041,6 +1102,73 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1041
1102
  }
1042
1103
  })
1043
1104
 
1105
+ describe('MojoAdapter - #1448 Tier C .flat(depth?)', () => {
1106
+ function emitFlat(expr: string): string {
1107
+ const a = new MojoAdapter()
1108
+ const ir = compileToIR(`
1109
+ function C({ rows }: { rows: number[][] }) {
1110
+ return <div>{${expr}}</div>
1111
+ }
1112
+ export { C }
1113
+ `, a)
1114
+ return a.generate(ir).template ?? ''
1115
+ }
1116
+
1117
+ test('.flat() emits bf->flat with default depth 1', () => {
1118
+ expect(emitFlat('rows.flat()')).toContain('bf->flat($rows, 1)')
1119
+ })
1120
+
1121
+ test('.flat(2) emits the explicit depth', () => {
1122
+ expect(emitFlat('rows.flat(2)')).toContain('bf->flat($rows, 2)')
1123
+ })
1124
+
1125
+ test('.flat(Infinity) emits the -1 full-depth sentinel', () => {
1126
+ expect(emitFlat('rows.flat(Infinity)')).toContain('bf->flat($rows, -1)')
1127
+ })
1128
+ })
1129
+
1130
+ describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
1131
+ function emitFlatMap(expr: string): string {
1132
+ const a = new MojoAdapter()
1133
+ const ir = compileToIR(`
1134
+ function C({ rows }: { rows: { a: string; b: string; tags: string[] }[] }) {
1135
+ return <div>{${expr}}</div>
1136
+ }
1137
+ export { C }
1138
+ `, a)
1139
+ return a.generate(ir).template ?? ''
1140
+ }
1141
+
1142
+ test('.flatMap(i => i.field) emits bf->flat_map with the raw field key', () => {
1143
+ expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain(`bf->flat_map($rows, 'field', 'tags')`)
1144
+ })
1145
+
1146
+ test('.flatMap(i => i) emits the self projection', () => {
1147
+ expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain(`bf->flat_map($rows, 'self', '')`)
1148
+ })
1149
+
1150
+ test('.flatMap(i => [i.a, i.b]) emits bf->flat_map_tuple with leaf specs', () => {
1151
+ expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['field', 'a'], ['field', 'b'])`)
1152
+ })
1153
+
1154
+ test('tuple self + field leaves', () => {
1155
+ expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['self', ''], ['field', 'a'])`)
1156
+ })
1157
+
1158
+ test('field-projection flatMap as a loop base lowers (no BF101)', () => {
1159
+ const a = new MojoAdapter()
1160
+ const ir = compileToIR(`'use client'
1161
+ import { createSignal } from '@barefootjs/client'
1162
+ export function C() {
1163
+ const [items] = createSignal<{ tags: string[] }[]>([])
1164
+ return <ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>
1165
+ }`, a)
1166
+ const template = a.generate(ir).template ?? ''
1167
+ expect((a.errors ?? []).filter(e => e.code === 'BF101')).toEqual([])
1168
+ expect(template).toContain(`bf->flat_map($items, 'field', 'tags')`)
1169
+ })
1170
+ })
1171
+
1044
1172
  // =============================================================================
1045
1173
  // #1448 — `/* @client */` escape hatch for STILL-UNSUPPORTED methods
1046
1174
  // =============================================================================
@@ -1099,10 +1227,14 @@ export function C() {
1099
1227
  // Perl fragment that must NOT survive into the template (the pre-fix
1100
1228
  // silent-footgun output for the string rows).
1101
1229
  const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
1102
- // Tier C array methods.
1103
- { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '->{reduce}' },
1104
- { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '->{flatMap}' },
1105
- { name: 'flat', expr: `items().flat()`, badEmit: '->{flat}' },
1230
+ // Tier C array methods. The arithmetic-fold `.reduce(fn, init)`
1231
+ // catalogue now lowers (pinned in the positive reduce-* fixtures);
1232
+ // the no-initial-value form stays refused JS throws on an empty
1233
+ // array there, which a template can't mirror.
1234
+ { name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '->{reduce}' },
1235
+ // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
1236
+ // tuple with a non-leaf element (here a string literal) stays refused.
1237
+ { name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '->{flatMap}' },
1106
1238
  // Lowered methods whose MEANINGFUL extra argument isn't lowered yet
1107
1239
  // (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
1108
1240
  // and the variadic `.concat`. The parser refuses these (silently
@@ -4,8 +4,10 @@
4
4
  * Generates Mojolicious EP template files (.html.ep) from BarefootJS IR.
5
5
  */
6
6
 
7
+ import ts from 'typescript'
7
8
  import type {
8
9
  ComponentIR,
10
+ IRMetadata,
9
11
  IRNode,
10
12
  IRElement,
11
13
  IRText,
@@ -56,7 +58,7 @@ import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
56
58
  * the `IRNodeEmitter` interface.
57
59
  */
58
60
  type MojoRenderCtx = Record<string, never>
59
- import type { ParsedExpr, ParsedStatement, SortComparator, TemplatePart } from '@barefootjs/jsx'
61
+ import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
60
62
  import { BF_SLOT, BF_COND } from '@barefootjs/shared'
61
63
 
62
64
  interface PrimitiveSpec {
@@ -117,6 +119,32 @@ export interface MojoAdapterOptions {
117
119
  barefootJsPath?: string
118
120
  }
119
121
 
122
+ /**
123
+ * Parse a const initializer's source text. Returns the unescaped string
124
+ * value when the whole initializer is a single string literal (or a
125
+ * no-substitution template literal), else `null`. Uses the TS parser so
126
+ * escapes/quotes resolve exactly as JS would, matching the value the Hono
127
+ * reference inlines at runtime.
128
+ */
129
+ function parsePureStringLiteral(source: string): string | null {
130
+ const sf = ts.createSourceFile(
131
+ '__const.ts',
132
+ `const __x = (${source});`,
133
+ ts.ScriptTarget.Latest,
134
+ /*setParentNodes*/ false,
135
+ )
136
+ const stmt = sf.statements[0]
137
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
138
+ const decl = stmt.declarationList.declarations[0]
139
+ let init = decl?.initializer
140
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
141
+ if (!init) return null
142
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
143
+ return init.text
144
+ }
145
+ return null
146
+ }
147
+
120
148
  export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
121
149
  name = 'mojolicious'
122
150
  extension = '.html.ep'
@@ -159,6 +187,26 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
159
187
  * true — selecting the string operator from the operand's type avoids that.
160
188
  */
161
189
  private stringValueNames: Set<string> = new Set()
190
+ /**
191
+ * Module-scope pure string-literal constants (`const X = 'literal'` at
192
+ * file top-level), keyed by name → resolved literal value. Populated at
193
+ * `generate()` entry from `ir.metadata.localConstants`. When an identifier
194
+ * in an expression resolves to one of these, the adapter inlines the
195
+ * literal instead of emitting `$X` against a stash variable that is never
196
+ * bound (a module const isn't a prop, signal, or local — the value would
197
+ * render empty). Hono inlines it for free; this restores parity. Only
198
+ * module-scope pure string literals qualify (see `collectModuleStringConsts`).
199
+ */
200
+ private moduleStringConsts: Map<string, string> = new Map()
201
+ /**
202
+ * Names currently bound by an enclosing loop body — the `my $<param>` and
203
+ * `my $<index>` bindings `renderLoop` introduces — ref-counted so nested
204
+ * loops compose. `resolveModuleStringConst` consults this so a loop
205
+ * variable whose name happens to match a module string const is NOT
206
+ * inlined as the const literal (mirrors the Go adapter's loop-param /
207
+ * loop-var shadowing guards). (#1749 review)
208
+ */
209
+ private loopBoundNames: Map<string, number> = new Map()
162
210
 
163
211
  constructor(options: MojoAdapterOptions = {}) {
164
212
  super()
@@ -186,6 +234,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
186
234
  for (const p of ir.metadata.propsParams) {
187
235
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
188
236
  }
237
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
238
+ this.loopBoundNames.clear()
189
239
  this.errors = []
190
240
  this.childrenCaptureCounter = 0
191
241
 
@@ -238,6 +288,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
238
288
  }
239
289
  }
240
290
 
291
+ /**
292
+ * Build the module pure-string-const map from the IR's localConstants.
293
+ * A const qualifies only when it is module-scope (`isModule`) and its
294
+ * initializer parses to a single string literal (`ts.StringLiteral` or
295
+ * `ts.NoSubstitutionTemplateLiteral`). Template literals with `${}`,
296
+ * numeric/object initializers, `Record<T,string>` maps, memos, and
297
+ * signals are all excluded — only a pure compile-time string can be
298
+ * inlined byte-for-byte.
299
+ */
300
+ private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
301
+ const map = new Map<string, string>()
302
+ for (const c of constants ?? []) {
303
+ if (!c.isModule) continue
304
+ if (c.value === undefined) continue
305
+ const literal = parsePureStringLiteral(c.value)
306
+ if (literal !== null) map.set(c.name, literal)
307
+ }
308
+ return map
309
+ }
310
+
311
+ /**
312
+ * Resolve an identifier to its inlined Perl single-quoted string literal
313
+ * when it names a module pure-string const, else `null` (the caller then
314
+ * falls back to its normal `$name` stash lowering). Returns the Perl
315
+ * literal form `'<escaped>'` ready to drop into an expression.
316
+ */
317
+ resolveModuleStringConst(name: string): string | null {
318
+ // A loop body introduces `my $<param>` / `my $<index>` bindings that
319
+ // shadow a module const of the same name — never inline inside one.
320
+ if (this.loopBoundNames.has(name)) return null
321
+ const value = this.moduleStringConsts.get(name)
322
+ if (value === undefined) return null
323
+ return `'${value.replace(/[\\']/g, m => `\\${m}`)}'`
324
+ }
325
+
241
326
  // ===========================================================================
242
327
  // Script Registration
243
328
  // ===========================================================================
@@ -587,6 +672,16 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
587
672
  const indexVar = loop.iterationShape === 'keys'
588
673
  ? `$${param}`
589
674
  : loop.index ? `$${loop.index}` : '$_i'
675
+ // Names this loop binds in body scope. Guard module-const inlining for
676
+ // the whole body (children + key + filter) so a same-named loop variable
677
+ // isn't replaced by the const literal (#1749 review). Ref-counted for
678
+ // nested loops; released after the body lines are assembled below.
679
+ const loopBound = loop.iterationShape === 'keys'
680
+ ? [param]
681
+ : [param, loop.index ?? '_i']
682
+ for (const n of loopBound) {
683
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
684
+ }
590
685
  const prevInLoop = this.inLoop
591
686
  this.inLoop = true
592
687
  const renderedChildren = this.renderChildren(loop.children)
@@ -644,6 +739,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
644
739
  lines.push(children)
645
740
  }
646
741
 
742
+ // Body fully rendered — release the loop-bound names.
743
+ for (const n of loopBound) {
744
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
745
+ if (c <= 0) this.loopBoundNames.delete(n)
746
+ else this.loopBoundNames.set(n, c)
747
+ }
748
+
647
749
  lines.push(`% }`)
648
750
  lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
649
751
 
@@ -1307,7 +1409,7 @@ function renderArrayMethod(
1307
1409
  // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
1308
1410
  // and dispatches: ARRAY ref scans the list with `eq`, scalar
1309
1411
  // falls back to `index(..., ...) != -1`. Helper lives in
1310
- // packages/adapter-mojolicious/lib/BarefootJS.pm.
1412
+ // packages/adapter-perl/lib/BarefootJS.pm.
1311
1413
  //
1312
1414
  // The `bf->` (no `$`) form matches every other helper emit —
1313
1415
  // in real Mojolicious `bf` is a controller helper; the
@@ -1533,6 +1635,64 @@ function renderSortMethod(recv: string, c: SortComparator): string {
1533
1635
  return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
1534
1636
  }
1535
1637
 
1638
+ /**
1639
+ * Render a `.reduce(fn, init)` arithmetic fold (#1448 Tier C) as a
1640
+ * `bf->reduce(...)` call. The structured `ReduceOp` maps to the Perl
1641
+ * helper's options hash:
1642
+ *
1643
+ * bf->reduce($recv, { op => '+', key_kind => 'field', key => 'duration',
1644
+ * type => 'numeric', init => 0 })
1645
+ *
1646
+ * A numeric init passes through as a bare Perl number (`0`, `-1`); a
1647
+ * string init (concat fold) is re-quoted from its literal contents.
1648
+ */
1649
+ function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
1650
+ const keyEntry =
1651
+ op.key.kind === 'self'
1652
+ ? `key_kind => 'self'`
1653
+ : `key_kind => 'field', key => '${op.key.field}'`
1654
+ // `op.init` is the decoded seed value. A numeric seed is already a
1655
+ // canonical decimal literal Perl reads directly; a concat seed is the
1656
+ // string contents, embedded in a single-quoted Perl literal. The `'`
1657
+ // escape is REQUIRED: a seed decoded from a double-quoted JS literal
1658
+ // (e.g. `"a'b"`) is escape-free yet contains an apostrophe. A literal
1659
+ // backslash can't occur (it would need a `\\` escape, which the parser
1660
+ // refuses), but escaping it too keeps this self-contained.
1661
+ const init =
1662
+ op.type === 'string'
1663
+ ? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
1664
+ : op.init
1665
+ // `direction` is "left" (reduce) or "right" (reduceRight); the Perl
1666
+ // helper reverses the list for "right". Only observable for concat.
1667
+ return `bf->reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
1668
+ }
1669
+
1670
+ // `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
1671
+ // to the `-1` sentinel (flatten fully); a finite depth flattens that many
1672
+ // levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
1673
+ function renderFlatMethod(recv: string, depth: FlatDepth): string {
1674
+ const d = depth === 'infinity' ? -1 : depth
1675
+ return `bf->flat(${recv}, ${d})`
1676
+ }
1677
+
1678
+ // `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf->flat_map($recv,
1679
+ // 'self'|'field', 'field')`, and the array-literal tuple form
1680
+ // `i => [i.a, i.b]` → `bf->flat_map_tuple($recv, ['field','a'], ...)`
1681
+ // (one arrayref per leaf). The field key is the raw JS prop name (Perl
1682
+ // hashes are keyed by it), mirroring `bf->reduce`. See `sub flat_map` /
1683
+ // `sub flat_map_tuple` in BarefootJS.pm.
1684
+ function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
1685
+ const proj = op.projection
1686
+ if (proj.kind === 'tuple') {
1687
+ const specs = proj.elements
1688
+ .map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
1689
+ .join(', ')
1690
+ return `bf->flat_map_tuple(${recv}, ${specs})`
1691
+ }
1692
+ if (proj.kind === 'self') return `bf->flat_map(${recv}, 'self', '')`
1693
+ return `bf->flat_map(${recv}, 'field', '${proj.field}')`
1694
+ }
1695
+
1536
1696
  /** True when `type` is the `string` primitive. */
1537
1697
  function isStringTypeInfo(type: TypeInfo | undefined): boolean {
1538
1698
  return type?.kind === 'primitive' && type.primitive === 'string'
@@ -1715,6 +1875,18 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1715
1875
  return renderSortMethod(emit(object), comparator)
1716
1876
  }
1717
1877
 
1878
+ reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
1879
+ return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
1880
+ }
1881
+
1882
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
1883
+ return renderFlatMethod(emit(object), depth)
1884
+ }
1885
+
1886
+ flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
1887
+ return renderFlatMapMethod(emit(object), op)
1888
+ }
1889
+
1718
1890
  conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
1719
1891
  return '1'
1720
1892
  }
@@ -1747,6 +1919,11 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1747
1919
  constructor(private readonly adapter: MojoAdapter) {}
1748
1920
 
1749
1921
  identifier(name: string): string {
1922
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
1923
+ // className template literal): inline the literal value rather than emit
1924
+ // `$baseClasses` against a stash variable that is never bound.
1925
+ const inlined = this.adapter.resolveModuleStringConst(name)
1926
+ if (inlined !== null) return inlined
1750
1927
  return `$${name}`
1751
1928
  }
1752
1929
 
@@ -1896,6 +2073,18 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1896
2073
  return renderSortMethod(emit(object), comparator)
1897
2074
  }
1898
2075
 
2076
+ reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2077
+ return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2078
+ }
2079
+
2080
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2081
+ return renderFlatMethod(emit(object), depth)
2082
+ }
2083
+
2084
+ flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2085
+ return renderFlatMapMethod(emit(object), op)
2086
+ }
2087
+
1899
2088
  conditional(
1900
2089
  test: ParsedExpr,
1901
2090
  consequent: ParsedExpr,
@@ -11,7 +11,12 @@ import { mkdir, rm } from 'node:fs/promises'
11
11
  import { resolve } from 'node:path'
12
12
 
13
13
  const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
14
+ // Mojo-specific lib (BarefootJS::Backend::Mojo + the plugin) lives in this
15
+ // package; the engine-agnostic core (BarefootJS.pm) moved to @barefootjs/perl.
16
+ // Both dirs must be on the render script's @INC so `use BarefootJS` and
17
+ // `use BarefootJS::Backend::Mojo` resolve.
14
18
  const LIB_DIR = resolve(import.meta.dir, '../lib')
19
+ const PERL_CORE_LIB_DIR = resolve(import.meta.dir, '../../adapter-perl/lib')
15
20
 
16
21
  export class PerlNotAvailableError extends Error {
17
22
  constructor(message: string) {
@@ -216,7 +221,7 @@ use strict;
216
221
  use warnings;
217
222
  use utf8;
218
223
 
219
- use lib '${LIB_DIR}';
224
+ use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
220
225
  use Mojolicious;
221
226
  use Mojo::Template;
222
227
  # Boolean values in spread bags arrive as Mojo::JSON::true /
@@ -428,6 +433,25 @@ function buildPerlProps(
428
433
  entries.push(`${param.name} => undef`)
429
434
  }
430
435
 
436
+ // A `{...props}` rest spread means props that aren't declared named
437
+ // params flow through the rest bag (`bf->spread_attrs($<restPropsName>)`),
438
+ // not their own top-level template var. Route them into the bag hashref so
439
+ // a fixture passing e.g. `placeholder` to `Input` (whose declared params
440
+ // are `className` / `type`) renders `placeholder="..."` via the spread
441
+ // rather than silently dropping it into an unused `my $placeholder`. (#1467
442
+ // Phase 2b — mirrors the Go harness fix in the sibling `test-render.ts`.)
443
+ const restPropsName = ir.metadata.restPropsName
444
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
445
+ const restBagEntries: Array<[string, unknown]> = []
446
+ if (restPropsName && props) {
447
+ for (const [key, value] of Object.entries(props)) {
448
+ if (key.startsWith('__')) continue
449
+ if (key === restPropsName || declaredParams.has(key)) continue
450
+ restBagEntries.push([key, value])
451
+ }
452
+ }
453
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
454
+
431
455
  // (#1407 follow-up) Default the rest-binding identifier to an
432
456
  // empty hashref so `bf->spread_attrs($extras)` in the generated
433
457
  // Mojo template doesn't trip Perl's strict-mode "Global symbol
@@ -436,13 +460,16 @@ function buildPerlProps(
436
460
  // the COMPILE path on Go, where the bag plumbing matters; the
437
461
  // runtime is a no-op when the caller leaves the bag unset, which
438
462
  // mirrors the empty-spread case on every adapter).
439
- if (ir.metadata.restPropsName && !(props && ir.metadata.restPropsName in props)) {
440
- entries.push(`${ir.metadata.restPropsName} => {}`)
463
+ // When the fixture supplied undeclared props, seed the bag with those
464
+ // routed entries instead of an empty hashref.
465
+ if (restPropsName && !(props && restPropsName in props)) {
466
+ entries.push(`${restPropsName} => ${toPerlLiteral(Object.fromEntries(restBagEntries))}`)
441
467
  }
442
468
 
443
469
  // Add user props
444
470
  if (props) {
445
471
  for (const [key, value] of Object.entries(props)) {
472
+ if (routedKeys.has(key)) continue
446
473
  if (typeof value === 'string') {
447
474
  entries.push(`${key} => '${value.replace(/'/g, "\\'")}'`)
448
475
  } else if (typeof value === 'number') {