@barefootjs/mojolicious 0.16.0 → 0.17.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.
Files changed (53) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +30 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +96 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/array-method.d.ts +75 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +77 -0
  8. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  9. package/dist/adapter/expr/operand.d.ts +34 -0
  10. package/dist/adapter/expr/operand.d.ts.map +1 -0
  11. package/dist/adapter/index.js +1490 -1336
  12. package/dist/adapter/lib/constants.d.ts +27 -0
  13. package/dist/adapter/lib/constants.d.ts.map +1 -0
  14. package/dist/adapter/lib/ir-scope.d.ts +40 -0
  15. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  16. package/dist/adapter/lib/perl-naming.d.ts +29 -0
  17. package/dist/adapter/lib/perl-naming.d.ts.map +1 -0
  18. package/dist/adapter/lib/types.d.ts +28 -0
  19. package/dist/adapter/lib/types.d.ts.map +1 -0
  20. package/dist/adapter/memo/seed.d.ts +43 -0
  21. package/dist/adapter/memo/seed.d.ts.map +1 -0
  22. package/dist/adapter/mojo-adapter.d.ts +46 -104
  23. package/dist/adapter/mojo-adapter.d.ts.map +1 -1
  24. package/dist/adapter/props/prop-classes.d.ts +48 -0
  25. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  26. package/dist/adapter/spread/spread-codegen.d.ts +64 -0
  27. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  28. package/dist/adapter/value/parsed-literal.d.ts +26 -0
  29. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  30. package/dist/build.js +1490 -1336
  31. package/dist/index.js +1490 -1336
  32. package/dist/test-render.d.ts.map +1 -1
  33. package/lib/BarefootJS/Backend/Mojo.pm +1 -1
  34. package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +1 -1
  35. package/lib/Mojolicious/Plugin/BarefootJS.pm +1 -1
  36. package/package.json +3 -3
  37. package/src/__tests__/mojo-adapter.test.ts +124 -60
  38. package/src/__tests__/query-href.test.ts +94 -0
  39. package/src/adapter/analysis/component-tree.ts +128 -0
  40. package/src/adapter/emit-context.ts +107 -0
  41. package/src/adapter/expr/array-method.ts +408 -0
  42. package/src/adapter/expr/emitters.ts +607 -0
  43. package/src/adapter/expr/operand.ts +55 -0
  44. package/src/adapter/lib/constants.ts +39 -0
  45. package/src/adapter/lib/ir-scope.ts +70 -0
  46. package/src/adapter/lib/perl-naming.ts +36 -0
  47. package/src/adapter/lib/types.ts +31 -0
  48. package/src/adapter/memo/seed.ts +126 -0
  49. package/src/adapter/mojo-adapter.ts +223 -1476
  50. package/src/adapter/props/prop-classes.ts +87 -0
  51. package/src/adapter/spread/spread-codegen.ts +181 -0
  52. package/src/adapter/value/parsed-literal.ts +34 -0
  53. package/src/test-render.ts +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAgNjF;AAmWD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
1
+ {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAgNjF;AAqWD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Mojo;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.16.0";
3
3
  use Mojo::Base -base, -signatures;
4
4
 
5
5
  use Mojo::ByteStream qw(b);
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.16.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  =head1 NAME
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.16.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  use Mojo::File qw(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -52,7 +52,7 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.16.0"
55
+ "@barefootjs/shared": "0.17.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@barefootjs/jsx": ">=0.2.0",
@@ -60,6 +60,6 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.16.0"
63
+ "@barefootjs/jsx": "0.17.0"
64
64
  }
65
65
  }
@@ -122,8 +122,8 @@ runAdapterConformanceTests({
122
122
  // (Go) and new `bf->trim` helper (Mojo) handle the strip
123
123
  // (#1448 Tier A ninth PR, closing out Tier A).
124
124
  // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are no longer
125
- // pinned — the Mojo `higherOrder` emitter now lowers them to the runtime
126
- // `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
125
+ // pinned — the Mojo `callbackMethod` predicate arm now lowers them to the
126
+ // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
127
127
  // (per-element coderef predicate), matching Xslate. `.join` was never
128
128
  // pinned (handled by `renderArrayMethod`'s `case 'join'`).
129
129
  },
@@ -239,8 +239,9 @@ describe('MojoAdapter - searchParams() env-signal lowering (#1922)', () => {
239
239
  // generic hash deref `$searchParams->{get}` (which drops the arg).
240
240
  test('lowers searchParams().get(k) to a method call on $searchParams', () => {
241
241
  const { template } = compileAndGenerate(`
242
- import { searchParams } from '@barefootjs/client'
242
+ import { createSearchParams } from '@barefootjs/client'
243
243
  function SortLabel() {
244
+ const [searchParams] = createSearchParams()
244
245
  return <p>{searchParams().get('sort') ?? 'none'}</p>
245
246
  }
246
247
  `)
@@ -248,13 +249,14 @@ function SortLabel() {
248
249
  expect(template).not.toContain('$searchParams->{get}')
249
250
  })
250
251
 
251
- // An aliased import binds the env signal to a different local name; the
252
- // expression reads `sp()`, but it still lowers to the canonical
252
+ // An aliased destructured getter binds the env signal to a different local
253
+ // name; the expression reads `sp()`, but it still lowers to the canonical
253
254
  // `$searchParams` reader (the harness/plugin seed that fixed var).
254
- test('matches an aliased import (`searchParams as sp`) and emits canonical $searchParams', () => {
255
+ test('matches an aliased env-signal getter (`const [sp] = createSearchParams()`) and emits canonical $searchParams', () => {
255
256
  const { template } = compileAndGenerate(`
256
- import { searchParams as sp } from '@barefootjs/client'
257
+ import { createSearchParams } from '@barefootjs/client'
257
258
  function SortLabel() {
259
+ const [sp] = createSearchParams()
258
260
  return <p>{sp().get('sort') ?? 'none'}</p>
259
261
  }
260
262
  `)
@@ -720,10 +722,12 @@ export function C() {
720
722
  // empty array there, which a template can't mirror.
721
723
  { name: 'reduce (no init)', body: `<div>{items().reduce((s, x) => s + x)}</div>`, needle: '.reduce(' },
722
724
  { name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
723
- // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C) —
724
- // even as a loop base so those moved to positive tests below. A
725
- // tuple with a non-leaf element (a string literal) stays refused.
726
- { name: 'flatMap (literal element)', body: `<div>{items().flatMap(x => [x.tag, "x"])}</div>`, needle: '.flatMap(' },
725
+ // #2018 P5: an array-literal projection with a literal element
726
+ // (`x => [x.tag, "x"]`) now lowers through the runtime evaluator
727
+ // (`bf->flat_map_eval`) the structured-tuple restriction that
728
+ // refused a non-`self`/`field` leaf is gone. See the positive
729
+ // flat_map_eval pins below. The remaining BF101 flatMap surface
730
+ // (a projection the evaluator can't serialize) has no example here.
727
731
  ]
728
732
 
729
733
  for (const { name, body, needle } of cases) {
@@ -760,7 +764,11 @@ export function C() {
760
764
  }`, 'C.tsx', { adapter })
761
765
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
762
766
  const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
763
- expect(template).toContain('grep { $_->{done} } @{$items}')
767
+ // #2018 P2: the `.filter().map()` loop hoists over the filtered array,
768
+ // now produced by the evaluator (`bf->filter_eval`). (Hoisting the call
769
+ // into a single `my` var is a P3 optimization.)
770
+ expect(template).toContain('bf->filter_eval($items,')
771
+ expect(template).toContain('"property":"done"')
764
772
  })
765
773
 
766
774
  test('lowers nested .filter(...).length > 0 in outer filter predicate (#1443 PR4)', () => {
@@ -798,7 +806,11 @@ export function C() {
798
806
  }`, 'C.tsx', { adapter })
799
807
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
800
808
  const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
801
- expect(template).toContain('grep { $_->{done} } @{$items}')
809
+ // #2018 P2: the `.filter().map()` loop hoists over the filtered array,
810
+ // now produced by the evaluator (`bf->filter_eval`). (Hoisting the call
811
+ // into a single `my` var is a P3 optimization.)
812
+ expect(template).toContain('bf->filter_eval($items,')
813
+ expect(template).toContain('"property":"done"')
802
814
  })
803
815
 
804
816
  test('lowers the registry Slot\'s [a, b].filter(Boolean).join(\' \') chain (#1443)', () => {
@@ -977,17 +989,18 @@ export { A }`, 'A.tsx', { adapter })
977
989
  expect(template).not.toContain('$bf->trim')
978
990
  })
979
991
 
980
- test('lowers .find / .findIndex / .findLast / .findLastIndex via runtime helpers', () => {
981
- // The runtime `bf->find` / `find_index` / `find_last` / `find_last_index`
982
- // call the predicate as a per-element coderef; the camelCase JS names map
983
- // to the snake_case helpers (matching Xslate's Kolon-lambda lowering).
984
- const cases: Array<[string, string]> = [
985
- ['find', 'find'],
986
- ['findIndex', 'find_index'],
987
- ['findLast', 'find_last'],
988
- ['findLastIndex', 'find_last_index'],
992
+ test('lowers .find / .findIndex / .findLast / .findLastIndex via the evaluator', () => {
993
+ // #2018 P2: the pure predicate `x => x === 'b'` serializes and lowers
994
+ // through the evaluator. find / findIndex share `bf->find_eval` /
995
+ // `bf->find_index_eval` with findLast / findLastIndex, distinguished by
996
+ // the `forward` flag (1 = forward, 0 = backward).
997
+ const cases: Array<[string, string, number]> = [
998
+ ['find', 'find_eval', 1],
999
+ ['findIndex', 'find_index_eval', 1],
1000
+ ['findLast', 'find_eval', 0],
1001
+ ['findLastIndex', 'find_index_eval', 0],
989
1002
  ]
990
- for (const [js, helper] of cases) {
1003
+ for (const [js, helper, fwd] of cases) {
991
1004
  const adapter = new MojoAdapter()
992
1005
  const result = compileJSX(`function A({ items }: { items: string[] }) {
993
1006
  return <div>{items.${js}(x => x === 'b')}</div>
@@ -995,11 +1008,45 @@ export { A }`, 'A.tsx', { adapter })
995
1008
  export { A }`, 'A.tsx', { adapter })
996
1009
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
997
1010
  const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
998
- expect(template).toContain(`bf->${helper}($items, sub { my $x = $_[0]; $x eq 'b' })`)
1011
+ expect(template).toContain(`bf->${helper}($items,`)
1012
+ expect(template).toContain(`'x', ${fwd}, {})`)
999
1013
  expect(template).not.toContain(`$bf->${helper}`)
1000
1014
  }
1001
1015
  })
1002
1016
 
1017
+ test('lowers .every / .some via the evaluator, with grep fallback for a method-call predicate', () => {
1018
+ // #2018 P2: a pure predicate routes `.every` / `.some` through
1019
+ // `bf->every_eval` / `bf->some_eval`; a method-call predicate the
1020
+ // evaluator can't model (`serializeParsedExpr` → null) falls back to the
1021
+ // inline `grep` form.
1022
+ const evalCases: Array<[string, string]> = [
1023
+ ['every', 'every_eval'],
1024
+ ['some', 'some_eval'],
1025
+ ]
1026
+ for (const [js, helper] of evalCases) {
1027
+ const adapter = new MojoAdapter()
1028
+ const result = compileJSX(`function A({ items }: { items: { done: boolean }[] }) {
1029
+ return <div>{items.${js}(x => x.done) ? 'y' : 'n'}</div>
1030
+ }
1031
+ export { A }`, 'A.tsx', { adapter })
1032
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1033
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
1034
+ expect(template).toContain(`bf->${helper}($items,`)
1035
+ expect(template).toContain('"property":"done"')
1036
+ }
1037
+
1038
+ // Fallback: a method-call predicate (`x => x.name.includes('a')`) is
1039
+ // outside the evaluator surface, so `.every` keeps the inline grep form.
1040
+ const adapter = new MojoAdapter()
1041
+ const fb = compileJSX(`function A({ items }: { items: { name: string }[] }) {
1042
+ return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
1043
+ }
1044
+ export { A }`, 'A.tsx', { adapter })
1045
+ const fbTemplate = fb.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
1046
+ expect(fbTemplate).toContain('grep {')
1047
+ expect(fbTemplate).not.toContain('every_eval')
1048
+ })
1049
+
1003
1050
  test('lowers .reverse().join(\' \') via bf->reverse + join (#1448 Tier A)', () => {
1004
1051
  // SSR templates render a snapshot, so `.reverse` and
1005
1052
  // `.toReversed` share a Mojo lowering — both return a new
@@ -1355,19 +1402,26 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1355
1402
  // #1448 Tier B — string → string, padded to a target width.
1356
1403
  { fixture: stringPadStartFixture, expect: `bf->pad_start($value, 5, '0')` },
1357
1404
  { fixture: stringPadEndFixture, expect: `bf->pad_end($value, 5, '.')` },
1358
- // #1448 Tier B — sort / toSorted. The loop-chained field cases
1359
- // hoist into a `my $bf_iter_lN = bf->sort(...)` local; the
1360
- // standalone primitive cases inline the call. Each comparison key
1361
- // is one hash under `keys` (a single-key comparator one element).
1362
- { fixture: arraySortFieldAscFixture, expect: `bf->sort($items, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }] })` },
1363
- { fixture: arraySortFieldDescFixture, expect: `bf->sort($items, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'desc' }] })` },
1364
- { fixture: arraySortPrimitiveFixture, expect: `bf->sort($nums, { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] })` },
1405
+ // #1448 Tier B — sort / toSorted. EXPR2 migration (#2018): both a
1406
+ // STANDALONE `.sort(cmp)` value call AND the `.sort().map()` loop-hoist
1407
+ // (#2018 P3) serialize the comparator body and emit `bf->sort_eval(...)`
1408
+ // (JSON body + param names + captured env). `localeCompare` comparators
1409
+ // fall back to the structured `bf->sort` `serializeParsedExpr` refuses
1410
+ // them. (Loop-hoist field cases pin only the helper + receiver; their
1411
+ // comparator JSON is verified by the render conformance.)
1412
+ { fixture: arraySortFieldAscFixture, expect: `bf->sort_eval($items,` },
1413
+ { fixture: arraySortFieldDescFixture, expect: `bf->sort_eval($items,` },
1414
+ { fixture: arraySortPrimitiveFixture, expect: `bf->sort_eval($nums, '{"kind":"binary","op":"-","left":{"kind":"identifier","name":"a"},"right":{"kind":"identifier","name":"b"}}', 'a', 'b', {})` },
1415
+ // localeCompare → outside the evaluator surface → legacy `bf->sort`.
1365
1416
  { fixture: arraySortLocaleFixture, expect: `bf->sort($names, { keys => [{ key_kind => 'self', compare_type => 'string', direction => 'asc' }] })` },
1366
- // Multi-key (`||`-chain): one hash per comparison key, in order.
1417
+ // Multi-key (`||`-chain): the second key is a `localeCompare`, so the
1418
+ // whole comparator falls back to the structured `bf->sort` (one hash
1419
+ // per comparison key, in order).
1367
1420
  { fixture: arraySortMultiKeyFixture, expect: `bf->sort($items, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }, { key_kind => 'field', key => 'name', compare_type => 'string', direction => 'asc' }] })` },
1368
- // Relational-ternary comparator lowers to a single `auto` key.
1369
- { fixture: arraySortTernaryFixture, expect: `bf->sort($items, { keys => [{ key_kind => 'field', key => 'rank', compare_type => 'auto', direction => 'asc' }] })` },
1370
- { fixture: arrayToSortedFixture, expect: `bf->sort($nums, { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] })` },
1421
+ // Relational-ternary comparator a pure body, so the loop-hoist now
1422
+ // serializes it through `bf->sort_eval` like the other field sorts.
1423
+ { fixture: arraySortTernaryFixture, expect: `bf->sort_eval($items,` },
1424
+ { fixture: arrayToSortedFixture, expect: `bf->sort_eval($nums, '{"kind":"binary","op":"-","left":{"kind":"identifier","name":"a"},"right":{"kind":"identifier","name":"b"}}', 'a', 'b', {})` },
1371
1425
  // #1448 Tier B — iteration shapes. These are loop-level patterns.
1372
1426
  // .entries() → for loop with both $i index var and $v value var
1373
1427
  { fixture: arrayEntriesFixture, expect: '% my $v = $items->[$i];' },
@@ -1375,17 +1429,19 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1375
1429
  { fixture: arrayKeysFixture, expect: '% for my $k (0..$#{$items})' },
1376
1430
  // .values() → standard for loop (same as plain .map())
1377
1431
  { fixture: arrayValuesFixture, expect: '% my $v = $items->[$_i];' },
1378
- // #1448 Tier C — .reduce(fn, init) arithmetic fold. The structured
1379
- // ReduceOp lowers to a single `bf->reduce(...)` call with op /
1380
- // key / type / init / direction in the options hash. Each shape
1381
- // exercises one arm: field-numeric sum, self-numeric sum,
1382
- // string-concat fold, the product (`*`) operator, and the
1383
- // right-to-left `direction` of reduceRight.
1384
- { fixture: reduceSumFieldFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'duration', type => 'numeric', init => 0, direction => 'left' })` },
1385
- { fixture: reduceSumSelfFixture, expect: `bf->reduce($nums, { op => '+', key_kind => 'self', type => 'numeric', init => 0, direction => 'left' })` },
1386
- { fixture: reduceConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'left' })` },
1387
- { fixture: reduceProductFixture, expect: `bf->reduce($items, { op => '*', key_kind => 'field', key => 'qty', type => 'numeric', init => 1, direction => 'left' })` },
1388
- { fixture: reduceRightConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'right' })` },
1432
+ // #1448 Tier C — .reduce(fn, init) arithmetic fold. EXPR2 migration
1433
+ // (#2018): the reducer body is serialized to ParsedExpr JSON and
1434
+ // folded by `bf->reduce_eval(json, accName, itemName, init, direction,
1435
+ // env)` the runtime evaluator subsumes the old `+`/`*` catalogue.
1436
+ // A numeric seed passes through bare (`0` / `1`); a concat seed as a
1437
+ // single-quoted string (`''`). Each shape exercises one arm: field-
1438
+ // numeric sum, self-numeric sum, string-concat fold, the product
1439
+ // (`*`) operator, and the right-to-left `direction` of reduceRight.
1440
+ { fixture: reduceSumFieldFixture, expect: `bf->reduce_eval($items, '{"kind":"binary","op":"+","left":{"kind":"identifier","name":"sum"},"right":{"kind":"member","object":{"kind":"identifier","name":"t"},"property":"duration"}}', 'sum', 't', 0, 'left', {})` },
1441
+ { fixture: reduceSumSelfFixture, expect: `bf->reduce_eval($nums, '{"kind":"binary","op":"+","left":{"kind":"identifier","name":"a"},"right":{"kind":"identifier","name":"b"}}', 'a', 'b', 0, 'left', {})` },
1442
+ { fixture: reduceConcatFixture, expect: `bf->reduce_eval($items, '{"kind":"binary","op":"+","left":{"kind":"identifier","name":"acc"},"right":{"kind":"member","object":{"kind":"identifier","name":"x"},"property":"label"}}', 'acc', 'x', '', 'left', {})` },
1443
+ { fixture: reduceProductFixture, expect: `bf->reduce_eval($items, '{"kind":"binary","op":"*","left":{"kind":"identifier","name":"acc"},"right":{"kind":"member","object":{"kind":"identifier","name":"x"},"property":"qty"}}', 'acc', 'x', 1, 'left', {})` },
1444
+ { fixture: reduceRightConcatFixture, expect: `bf->reduce_eval($items, '{"kind":"binary","op":"+","left":{"kind":"identifier","name":"acc"},"right":{"kind":"member","object":{"kind":"identifier","name":"x"},"property":"label"}}', 'acc', 'x', '', 'right', {})` },
1389
1445
  ]
1390
1446
 
1391
1447
  for (const { fixture, expect: expectedHelper } of cases) {
@@ -1444,20 +1500,26 @@ export { C }
1444
1500
  return a.generate(ir).template ?? ''
1445
1501
  }
1446
1502
 
1447
- test('.flatMap(i => i.field) emits bf->flat_map with the raw field key', () => {
1448
- expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain(`bf->flat_map($rows, 'field', 'tags')`)
1449
- })
1450
-
1451
- test('.flatMap(i => i) emits the self projection', () => {
1452
- expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain(`bf->flat_map($rows, 'self', '')`)
1503
+ // #2018 P3: `.flatMap(proj)` lowers through the evaluator the projection
1504
+ // body serializes to ParsedExpr JSON and `bf->flat_map_eval` flattens the
1505
+ // results one level. (In Mojo the JSON rides in a single-quoted Perl string,
1506
+ // so its double quotes are literal and assertable.)
1507
+ test('.flatMap(i => i.field) emits bf->flat_map_eval with the field projection', () => {
1508
+ const t = emitFlatMap('rows.flatMap(i => i.tags).join(" ")')
1509
+ expect(t).toContain(`bf->flat_map_eval($rows,`)
1510
+ expect(t).toContain(`"property":"tags"`)
1453
1511
  })
1454
1512
 
1455
- test('.flatMap(i => [i.a, i.b]) emits bf->flat_map_tuple with leaf specs', () => {
1456
- expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['field', 'a'], ['field', 'b'])`)
1513
+ test('.flatMap(i => i) emits bf->flat_map_eval (self/identifier projection)', () => {
1514
+ const t = emitFlatMap('rows.flatMap(i => i).join(" ")')
1515
+ expect(t).toContain(`bf->flat_map_eval($rows,`)
1516
+ expect(t).toContain(`"kind":"identifier","name":"i"`)
1457
1517
  })
1458
1518
 
1459
- test('tuple self + field leaves', () => {
1460
- expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['self', ''], ['field', 'a'])`)
1519
+ test('.flatMap(i => [i.a, i.b]) emits bf->flat_map_eval over an array-literal projection', () => {
1520
+ const t = emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')
1521
+ expect(t).toContain(`bf->flat_map_eval($rows,`)
1522
+ expect(t).toContain(`"kind":"array-literal"`)
1461
1523
  })
1462
1524
 
1463
1525
  test('field-projection flatMap as a loop base lowers (no BF101)', () => {
@@ -1470,7 +1532,7 @@ export function C() {
1470
1532
  }`, a)
1471
1533
  const template = a.generate(ir).template ?? ''
1472
1534
  expect((a.errors ?? []).filter(e => e.code === 'BF101')).toEqual([])
1473
- expect(template).toContain(`bf->flat_map($items, 'field', 'tags')`)
1535
+ expect(template).toContain(`bf->flat_map_eval($items,`)
1474
1536
  })
1475
1537
  })
1476
1538
 
@@ -1537,9 +1599,11 @@ export function C() {
1537
1599
  // the no-initial-value form stays refused — JS throws on an empty
1538
1600
  // array there, which a template can't mirror.
1539
1601
  { name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '->{reduce}' },
1540
- // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
1541
- // tuple with a non-leaf element (here a string literal) stays refused.
1542
- { name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '->{flatMap}' },
1602
+ // #2018 P5: an array-literal projection with a literal element
1603
+ // (`i => [i.name, "x"]`) now lowers through the runtime evaluator
1604
+ // (`bf->flat_map_eval`) rather than refusing the structured-tuple
1605
+ // leaf restriction is gone. (Cross-adapter isomorphic with the Go
1606
+ // adapter's `bf_flat_map_eval`.) Pinned positively below.
1543
1607
  // Lowered methods whose MEANINGFUL extra argument isn't lowered yet
1544
1608
  // (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
1545
1609
  // and the variadic `.concat`. The parser refuses these (silently
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `queryHref(base, { … })` → `bf->query(...)` lowering for the Mojolicious
3
+ * adapter (#2042). Parity with the go-template `bf_query` lowering: the call +
4
+ * object literal are structured IR, so it lowers directly. The `bf->query`
5
+ * runtime helper (BarefootJS.pm) includes a pair iff its guard is truthy AND its
6
+ * value is a non-empty string, so a plain `key: v` passes guard `1` and a
7
+ * conditional `key: cond ? v : undefined` passes the lowered condition.
8
+ */
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
11
+ import { MojoAdapter } from '../adapter/mojo-adapter'
12
+
13
+ function template(src: string): string {
14
+ const a = new MojoAdapter()
15
+ const r = compileJSX(src.trimStart(), 'T.tsx', { adapter: a, outputIR: true })
16
+ const ir = JSON.parse(r.files.find(f => f.type === 'ir')!.content) as ComponentIR
17
+ return a.generate(ir).template
18
+ }
19
+
20
+ describe('queryHref → bf->query (Mojo, #2042)', () => {
21
+ test('a plain value passes guard 1', () => {
22
+ const t = template(`
23
+ 'use client'
24
+ import { queryHref } from '@barefootjs/client'
25
+ export function P(props: { base: string; tag: string }) {
26
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
27
+ }
28
+ `)
29
+ expect(t).toContain("bf->query($base, 1, 'tag', $tag)")
30
+ })
31
+
32
+ test('a conditional include passes the lowered condition as the guard', () => {
33
+ const t = template(`
34
+ 'use client'
35
+ import { queryHref } from '@barefootjs/client'
36
+ export function P(props: { base: string; sort: string; tag: string }) {
37
+ return <a href={queryHref(props.base, { sort: props.sort !== 'date' ? props.sort : undefined, tag: props.tag })}>x</a>
38
+ }
39
+ `)
40
+ expect(t).toContain("bf->query($base, ($sort ne 'date'), 'sort', $sort, 1, 'tag', $tag)")
41
+ })
42
+
43
+ // A bare-value guard (`flag ? v : undefined`) is JS *string* truthiness — `'0'`
44
+ // is a truthy string in JS but false under Perl's `unless`. The lowering must
45
+ // normalise it to a non-empty-string test so SSR matches the client / go (where
46
+ // `lowerUrlGuard` emits `ne <value> ""`), not pass the bare value as the guard.
47
+ test('a bare-value guard is normalised to a non-empty-string test', () => {
48
+ const t = template(`
49
+ 'use client'
50
+ import { queryHref } from '@barefootjs/client'
51
+ export function P(props: { base: string; flag: string; val: string }) {
52
+ return <a href={queryHref(props.base, { q: props.flag ? props.val : undefined })}>x</a>
53
+ }
54
+ `)
55
+ expect(t).toContain("bf->query($base, ($flag ne ''), 'q', $val)")
56
+ })
57
+
58
+ // An array value (`{ tag: props.tags }`) lowers to the bare slice expression;
59
+ // the shared Perl `query` helper detects the arrayref at runtime and appends
60
+ // one pair per non-empty member (#2048). No adapter-side change beyond passing
61
+ // the value through.
62
+ test('an array value passes the slice expression for the helper to append', () => {
63
+ const t = template(`
64
+ 'use client'
65
+ import { queryHref } from '@barefootjs/client'
66
+ export function P(props: { base: string; tags: string[] }) {
67
+ return <a href={queryHref(props.base, { tag: props.tags })}>x</a>
68
+ }
69
+ `)
70
+ expect(t).toContain("bf->query($base, 1, 'tag', $tags)")
71
+ })
72
+
73
+ test('an aliased import is recognised', () => {
74
+ const t = template(`
75
+ 'use client'
76
+ import { queryHref as qh } from '@barefootjs/client'
77
+ export function P(props: { base: string; tag: string }) {
78
+ return <a href={qh(props.base, { tag: props.tag })}>x</a>
79
+ }
80
+ `)
81
+ expect(t).toContain("bf->query($base, 1, 'tag', $tag)")
82
+ })
83
+
84
+ test('a dynamic (non-literal) params object falls back (no bf->query)', () => {
85
+ const t = template(`
86
+ 'use client'
87
+ import { queryHref } from '@barefootjs/client'
88
+ export function P(props: { base: string; q: Record<string, string> }) {
89
+ return <a href={queryHref(props.base, props.q)}>x</a>
90
+ }
91
+ `)
92
+ expect(t).not.toContain('bf->query')
93
+ })
94
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Component-tree analysis for the Mojolicious EP template adapter.
3
+ *
4
+ * Extracted from `mojo-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure functions over the IR — they read no adapter instance
6
+ * state. `collectImportedLoopChildComponentErrors` returns its diagnostics
7
+ * instead of pushing onto the adapter's error list, so the adapter stays the
8
+ * sole owner of `errors`.
9
+ *
10
+ * SHARED CANDIDATE: `hasClientInteractivity` is byte-identical to the Xslate
11
+ * adapter's copy and adapter-agnostic; the BF103 loop-child check is the same
12
+ * structural walk in both, differing only in the Perl/Kolon diagnostic text —
13
+ * both are groundwork for a shared Perl-family codegen module (issue #2018
14
+ * track D).
15
+ */
16
+
17
+ import type {
18
+ ComponentIR,
19
+ IRNode,
20
+ IRComponent,
21
+ IRElement,
22
+ IRFragment,
23
+ IRConditional,
24
+ IRLoop,
25
+ IRIfStatement,
26
+ IRProvider,
27
+ IRAsync,
28
+ CompilerError,
29
+ } from '@barefootjs/jsx'
30
+
31
+ /**
32
+ * Whether the component needs the client runtime — it owns reactive state
33
+ * (signals / effects / onMount) or the analyzer flagged it as needing init.
34
+ */
35
+ export function hasClientInteractivity(ir: ComponentIR): boolean {
36
+ return (
37
+ ir.metadata.signals.length > 0 ||
38
+ ir.metadata.effects.length > 0 ||
39
+ ir.metadata.onMounts.length > 0 ||
40
+ (ir.metadata.clientAnalysis?.needsInit ?? false)
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Build a `BF103` diagnostic for every component reference inside a loop body
46
+ * whose name is imported from a relative-path module. Mirror of the Go
47
+ * adapter's check — the Mojo adapter has the same cross-template-registration
48
+ * constraint at request time. Returns the diagnostics so the caller pushes
49
+ * them onto its own error list.
50
+ */
51
+ export function collectImportedLoopChildComponentErrors(
52
+ ir: ComponentIR,
53
+ componentName: string,
54
+ ): CompilerError[] {
55
+ const errors: CompilerError[] = []
56
+ // Collect every name imported from a relative-path module (no
57
+ // case filter — `IRComponent` nodes only exist for PascalCase JSX
58
+ // usages, so a lowercase utility import in the set can't match
59
+ // anyway, and any heuristic on the import name itself would be
60
+ // strictly less robust than the structural IR check below).
61
+ const relativeImports = new Set<string>()
62
+ for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
63
+ if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
64
+ if (imp.isTypeOnly) continue
65
+ for (const spec of imp.specifiers) {
66
+ relativeImports.add(spec.alias ?? spec.name)
67
+ }
68
+ }
69
+ if (relativeImports.size === 0) return errors
70
+
71
+ const loc = { file: componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
72
+ const visit = (node: IRNode, inLoop: boolean): void => {
73
+ switch (node.type) {
74
+ case 'component': {
75
+ const comp = node as IRComponent
76
+ if (inLoop && relativeImports.has(comp.name)) {
77
+ errors.push({
78
+ code: 'BF103',
79
+ severity: 'error',
80
+ message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Mojo adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
81
+ loc: comp.loc ?? loc,
82
+ suggestion: {
83
+ message:
84
+ `Options:\n` +
85
+ ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Mojo template alongside the parent at render time.\n` +
86
+ ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
87
+ ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
88
+ },
89
+ })
90
+ }
91
+ for (const child of comp.children) visit(child, inLoop)
92
+ break
93
+ }
94
+ case 'element':
95
+ for (const child of (node as IRElement).children) visit(child, inLoop)
96
+ break
97
+ case 'fragment':
98
+ for (const child of (node as IRFragment).children) visit(child, inLoop)
99
+ break
100
+ case 'conditional': {
101
+ const cond = node as IRConditional
102
+ visit(cond.whenTrue, inLoop)
103
+ if (cond.whenFalse) visit(cond.whenFalse, inLoop)
104
+ break
105
+ }
106
+ case 'loop':
107
+ for (const child of (node as IRLoop).children) visit(child, true)
108
+ break
109
+ case 'if-statement': {
110
+ const stmt = node as IRIfStatement
111
+ visit(stmt.consequent, inLoop)
112
+ if (stmt.alternate) visit(stmt.alternate, inLoop)
113
+ break
114
+ }
115
+ case 'provider':
116
+ for (const child of (node as IRProvider).children) visit(child, inLoop)
117
+ break
118
+ case 'async': {
119
+ const a = node as IRAsync
120
+ visit(a.fallback, inLoop)
121
+ for (const child of a.children) visit(child, inLoop)
122
+ break
123
+ }
124
+ }
125
+ }
126
+ visit(ir.root, false)
127
+ return errors
128
+ }