@barefootjs/mojolicious 0.5.1 → 0.5.2

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.
@@ -69,19 +69,16 @@ runAdapterConformanceTests({
69
69
  'toggle-shared',
70
70
  'reactive-props',
71
71
  'props-reactivity-comparison',
72
- // #1665 whole-item loop conditional. The Mojo adapter correctly emits the
73
- // per-item `<!--bf-loop-i:KEY-->` anchor, `data-key`, and the conditional
74
- // markers (verified by template-structure tests), but the fixture's
75
- // `sel() === t.id` string comparison lowers to Perl numeric `==`
76
- // (`"b" == "a"` `0 == 0` → true), so the perl-executed render renders
77
- // every item's true branch instead of only the matching one. Selecting
78
- // `eq` vs `==` from operand types is a separate pre-existing Mojo
79
- // limitation (same family as the skipped `logical-or-jsx` /
80
- // `nullish-coalescing-jsx` map shapes); the anchored SSR shape itself is
81
- // covered cross-adapter by Hono + CSR conformance and the runtime
82
- // hydration tests. Remove this skip once the Mojo limitation is fixed:
83
- // https://github.com/piconic-ai/barefootjs/issues/1672
84
- 'loop-item-conditional',
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',
85
82
  ],
86
83
  // Per-fixture build-time contracts for shapes the Mojo adapter
87
84
  // intentionally refuses to lower. Owned by this adapter test file
@@ -289,6 +286,40 @@ export function List() {
289
286
  expect(result.template).toMatch(/bf->comment\("\/loop:[^"]+"\)/)
290
287
  })
291
288
 
289
+ test('compares string signals with Perl `eq`, not numeric `==` (#1672)', () => {
290
+ // `sel() === t.id` where `sel` is a string signal must lower to `eq`.
291
+ // Perl numeric `==` coerces non-numeric strings to 0, so `"b" == "a"` is
292
+ // true and every loop item would render its true branch.
293
+ const result = compileAndGenerate(`
294
+ "use client"
295
+ import { createSignal } from "@barefootjs/client"
296
+
297
+ export function LoopItemConditional() {
298
+ const [items] = createSignal([{ id: "a" }, { id: "b" }, { id: "c" }])
299
+ const [sel] = createSignal("b")
300
+ return <ul>{items().map(t => sel() === t.id && <li key={t.id}>{t.id}</li>)}</ul>
301
+ }
302
+ `)
303
+ expect(result.template).toContain('$sel eq $t->{id}')
304
+ expect(result.template).not.toContain('$sel == $t->{id}')
305
+ })
306
+
307
+ test('compares number signals with Perl `==` (#1672)', () => {
308
+ // A numeric signal comparison must stay `==`, not flip to `eq`.
309
+ const result = compileAndGenerate(`
310
+ "use client"
311
+ import { createSignal } from "@barefootjs/client"
312
+
313
+ export function L() {
314
+ const [items] = createSignal([{ n: 1 }, { n: 2 }])
315
+ const [sel] = createSignal(2)
316
+ return <ul>{items().map(t => sel() === t.n && <li key={t.n}>{t.n}</li>)}</ul>
317
+ }
318
+ `)
319
+ expect(result.template).toContain('$sel == $t->{n}')
320
+ expect(result.template).not.toContain('$sel eq $t->{n}')
321
+ })
322
+
292
323
  test('generates script registration for client components', () => {
293
324
  const result = compileAndGenerate(`
294
325
  "use client"
@@ -979,17 +1010,19 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
979
1010
  // Mojo sibling of the Go block: #1448 documents `/* @client */` as the
980
1011
  // universal workaround for any Array/String method the template
981
1012
  // adapters can't lower. This pins that contract for the Mojo adapter —
982
- // wrapping the unsupported expression in the directive must clear the
983
- // BF021/BF101 build error the bare form raises and emit a client-only
984
- // placeholder so the Mojo SSR pass renders valid `.html.ep` that the
985
- // client runtime fills at hydration.
1013
+ // the BARE form must surface a BF021/BF101 build error, and wrapping
1014
+ // the expression in the directive must clear it and emit a client-only
1015
+ // placeholder so the Mojo SSR pass renders valid `.html.ep` the client
1016
+ // runtime fills at hydration.
986
1017
  //
987
- // Same silent-footgun caveat as Go: the unsupported *string* methods
988
- // raise NO build diagnostic — bare `.startsWith` / `.repeat` / … lower
989
- // to a Perl hash-deref-and-call (`$name->{startsWith}('a')`) that
990
- // passes the adapter gate, then dies at render with
991
- // `Can't use string (...) as a HASH ref while "strict refs"`.
992
- // `/* @client */` is the only escape hatch, so these tests pin it.
1018
+ // History (#1448 follow-up): the unsupported *string* methods used to
1019
+ // raise NO build diagnostic — bare `.startsWith` / `.repeat` / … fell
1020
+ // into the regex pipeline and lowered to a Perl hash-deref-and-call
1021
+ // (`$name->{startsWith}('a')`) that passed the gate, then died at
1022
+ // render with `Can't use string (...) as a HASH ref while "strict
1023
+ // refs"`. They are now routed through the AST path in
1024
+ // `convertExpressionToPerl` so `isSupported`'s `UNSUPPORTED_METHODS`
1025
+ // gate fires BF101 — parity with the Go adapter. These tests pin it.
993
1026
  describe('MojoAdapter - #1448 @client escape hatch (unsupported methods)', () => {
994
1027
  function emit(expr: string, client: boolean) {
995
1028
  const marker = client ? '/* @client */ ' : ''
@@ -1023,49 +1056,85 @@ export function C() {
1023
1056
  return { errors: adapter.errors ?? [], template }
1024
1057
  }
1025
1058
 
1026
- // Tier C array methods bare form raises BF101 at build time.
1027
- const unsupportedArray: Array<[string, string]> = [
1028
- ['reduce', `items().reduce((a, b) => a + b.n, 0)`],
1029
- ['flatMap', `items().flatMap(i => i.tags)`],
1030
- ['flat', `items().flat()`],
1059
+ // Unsupported methods that surface as BF101 at build time: Tier C
1060
+ // array methods + Tier B/C string methods. `badEmit` is the invalid
1061
+ // Perl fragment that must NOT survive into the template (the pre-fix
1062
+ // silent-footgun output for the string rows).
1063
+ const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
1064
+ // Tier C array methods.
1065
+ { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '->{reduce}' },
1066
+ { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '->{flatMap}' },
1067
+ { name: 'flat', expr: `items().flat()`, badEmit: '->{flat}' },
1068
+ // Tier B/C string methods — previously slipped through with no
1069
+ // diagnostic; now routed through the AST / `isSupported` gate.
1070
+ { name: 'split', expr: `name().split(",")`, badEmit: '->{split}' },
1071
+ { name: 'startsWith', expr: `name().startsWith("a")`, badEmit: '->{startsWith}' },
1072
+ { name: 'endsWith', expr: `name().endsWith("z")`, badEmit: '->{endsWith}' },
1073
+ { name: 'replace', expr: `name().replace("a", "b")`, badEmit: '->{replace}' },
1074
+ { name: 'repeat', expr: `name().repeat(3)`, badEmit: '->{repeat}' },
1075
+ { name: 'padStart', expr: `name().padStart(5, "0")`, badEmit: '->{padStart}' },
1076
+ { name: 'padEnd', expr: `name().padEnd(5, "0")`, badEmit: '->{padEnd}' },
1077
+ { name: 'charAt', expr: `name().charAt(0)`, badEmit: '->{charAt}' },
1031
1078
  ]
1032
- for (const [name, expr] of unsupportedArray) {
1033
- test(`array .${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
1079
+ for (const { name, expr, badEmit } of unsupported) {
1080
+ test(`.${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
1034
1081
  const bare = emit(expr, false)
1035
1082
  expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
1083
+ // The invalid deref-and-call must NOT leak into the template;
1084
+ // the adapter degrades to a safe empty slot alongside the error.
1085
+ expect(bare.template).not.toContain(badEmit)
1036
1086
 
1037
1087
  const guarded = emit(expr, true)
1038
1088
  expect(guarded.errors).toEqual([])
1039
1089
  // Client-only text slot → `<%== bf->comment("client:sN") %>`.
1040
1090
  expect(guarded.template).toMatch(/bf->comment\("client:s\d+"\)/)
1091
+ expect(guarded.template).not.toContain(badEmit)
1041
1092
  })
1042
1093
  }
1043
1094
 
1044
- // Tier B/C string methods bare form emits an INVALID Perl
1045
- // hash-deref-and-call with NO build error (the silent footgun).
1046
- const unsupportedString: Array<[string, string, string]> = [
1047
- ['split', `name().split(",")`, '->{split}'],
1048
- ['startsWith', `name().startsWith("a")`, '->{startsWith}'],
1049
- ['endsWith', `name().endsWith("z")`, '->{endsWith}'],
1050
- ['replace', `name().replace("a", "b")`, '->{replace}'],
1051
- ['repeat', `name().repeat(3)`, '->{repeat}'],
1052
- ['padStart', `name().padStart(5, "0")`, '->{padStart}'],
1053
- ['padEnd', `name().padEnd(5, "0")`, '->{padEnd}'],
1054
- ['charAt', `name().charAt(0)`, '->{charAt}'],
1055
- ]
1056
- for (const [name, expr, badEmit] of unsupportedString) {
1057
- test(`string .${name}: bare emits invalid Perl deref, @client emits client placeholder`, () => {
1058
- const bare = emit(expr, false)
1059
- // Documents the footgun: no BF101 guard, invalid template emitted.
1060
- expect(bare.errors.filter(e => e.code === 'BF101')).toEqual([])
1061
- expect(bare.template).toContain(badEmit)
1095
+ // Routing guard regression: the unsupported-string-method regex is an
1096
+ // unanchored substring test, and these names (`replace`, `split`, )
1097
+ // are ordinary words that also appear inside string literals. A
1098
+ // SUPPORTED expression whose literal merely contains `.replace(` must
1099
+ // NOT be diverted onto the AST path — doing so would bypass
1100
+ // `rewriteTemplatePrimitives` and silently emit broken Perl
1101
+ // (`$JSON->{stringify} + '.replace('`). The `isSupported` gate on the
1102
+ // regex keeps such expressions on the normal pipeline.
1103
+ test('string-method regex does not misroute a supported expr with a method name inside a literal', () => {
1104
+ const adapter = new MojoAdapter()
1105
+ const ir = compileToIR(`
1106
+ "use client"
1107
+ import { createSignal } from "@barefootjs/client"
1108
+ export function C(props: { config: string }) {
1109
+ return <div>{JSON.stringify(props.config) + ".replace("}</div>
1110
+ }
1111
+ `, adapter)
1112
+ const template = adapter.generate(ir).template ?? ''
1113
+ expect(adapter.errors ?? []).toEqual([])
1114
+ // templatePrimitive lowering preserved...
1115
+ expect(template).toContain('bf->json($config)')
1116
+ // ...and the literal is NOT mangled into a hash-deref.
1117
+ expect(template).not.toContain('$JSON->{stringify}')
1118
+ })
1062
1119
 
1063
- const guarded = emit(expr, true)
1064
- expect(guarded.errors).toEqual([])
1065
- expect(guarded.template).toMatch(/bf->comment\("client:s\d+"\)/)
1066
- expect(guarded.template).not.toContain(badEmit)
1067
- })
1068
- }
1120
+ // Predicate-level use of an unsupported string method also fails the
1121
+ // build loudly (intended): a `.filter(t => t.name.startsWith("a"))`
1122
+ // whose predicate calls one of the gated methods now refuses the whole
1123
+ // loop with BF101 (via the shared `isSupported` predicate gate in
1124
+ // jsx-to-ir) rather than lowering to a broken `->{startsWith}` inside
1125
+ // the grep. Pinning this so the loud-failure contract can't silently
1126
+ // regress back to the old emit-broken-template behaviour.
1127
+ test('unsupported string method inside a .filter() predicate raises BF101', () => {
1128
+ const result = compileJSX(`
1129
+ "use client"
1130
+ import { createSignal } from "@barefootjs/client"
1131
+ export function C() {
1132
+ const [items, setItems] = createSignal<{ name: string }[]>([])
1133
+ return <ul>{items().filter(t => t.name.startsWith("a")).map(t => <li key={t.name}>{t.name}</li>)}</ul>
1134
+ }
1135
+ `.trimStart(), 'test.tsx', { adapter: new MojoAdapter() })
1136
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
1137
+ })
1069
1138
 
1070
1139
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
1071
1140
  // The Mojo client-only loop placeholder is an empty element (the
@@ -1097,23 +1166,25 @@ export function C() {
1097
1166
  })
1098
1167
  }
1099
1168
 
1100
- // End-to-end proof via perl + Mojolicious: the bare unsupported form
1101
- // crashes Mojo template execution, while the `@client` form renders a
1102
- // `<!--bf-client:sN-->` placeholder. Skipped on hosts without
1103
- // Mojolicious installed.
1104
- test('e2e: bare string method crashes perl render, @client renders placeholder', async () => {
1105
- const guarded = `
1169
+ // End-to-end proof via perl + Mojolicious: the `@client` form renders
1170
+ // a `<!--bf-client:sN-->` placeholder. The bare form is now caught at
1171
+ // build with BF101 and degrades to an empty, render-safe slot (no
1172
+ // more `HASH ref` crash), so we assert the build error rather than a
1173
+ // render crash. Skipped on hosts without Mojolicious installed.
1174
+ test('e2e: @client renders placeholder; bare is caught at build with BF101', async () => {
1175
+ const bare = emit(`name().repeat(3)`, false)
1176
+ expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
1177
+
1178
+ try {
1179
+ const html = await renderMojoComponent({
1180
+ source: `
1106
1181
  "use client"
1107
1182
  import { createSignal } from "@barefootjs/client"
1108
1183
  export function C() {
1109
1184
  const [name, setName] = createSignal("hello")
1110
1185
  return <div>{/* @client */ name().repeat(3)}</div>
1111
1186
  }
1112
- `
1113
- const bareSrc = guarded.replace('/* @client */ ', '')
1114
- try {
1115
- const html = await renderMojoComponent({
1116
- source: guarded.trimStart(),
1187
+ `.trimStart(),
1117
1188
  adapter: new MojoAdapter(),
1118
1189
  })
1119
1190
  expect(html).toContain('<!--bf-client:s0-->')
@@ -1124,12 +1195,58 @@ export function C() {
1124
1195
  }
1125
1196
  throw err
1126
1197
  }
1127
- // Bare form must fail Mojo template execution (no @client guard).
1128
- await expect(
1129
- renderMojoComponent({
1130
- source: bareSrc.trimStart(),
1131
- adapter: new MojoAdapter(),
1132
- }),
1133
- ).rejects.toThrow(/HASH ref/)
1198
+ })
1199
+ })
1200
+
1201
+ // =============================================================================
1202
+ // #1682: parse-first expression lowering regressions
1203
+ // =============================================================================
1204
+ // The parse-first refactor routes every supported expression through the
1205
+ // AST emitter. These pin the four behaviours the Copilot review surfaced
1206
+ // so they can't silently regress.
1207
+ describe('MojoAdapter - #1682 parse-first lowering', () => {
1208
+ function gen(inner: string) {
1209
+ const adapter = new MojoAdapter()
1210
+ const out = compileAndGenerate(`
1211
+ "use client"
1212
+ import { createSignal } from "@barefootjs/client"
1213
+ export function C() {
1214
+ const [role, setRole] = createSignal("admin")
1215
+ const [count, setCount] = createSignal(1)
1216
+ const [obj, setObj] = createSignal({ a: 1 })
1217
+ const [it, setIt] = createSignal("x")
1218
+ return <div>{${inner}}</div>
1219
+ }
1220
+ `, adapter)
1221
+ return { template: out.template ?? '', errors: adapter.errors ?? [] }
1222
+ }
1223
+
1224
+ test('string === lowers to Perl eq with the literal on EITHER operand', () => {
1225
+ // Reversed literal (`"admin" === role()`) must still use string `eq`,
1226
+ // not numeric `==` (which coerces both sides to 0 in Perl).
1227
+ const { template } = gen('"admin" === role() ? "A" : "B"')
1228
+ expect(template).toContain("'admin' eq $role")
1229
+ expect(template).not.toContain("'admin' ==")
1230
+ expect(template).not.toContain("== 'admin'")
1231
+ })
1232
+
1233
+ test('template literal with a complex expr lowers to Perl concatenation', () => {
1234
+ // Double-quote interpolation would leave `+ 1` unevaluated; concat
1235
+ // (with parens for precedence) evaluates the arithmetic.
1236
+ const { template } = gen('`n=${count() + 1}`')
1237
+ expect(template).toContain('"n=" . ($count + 1)')
1238
+ })
1239
+
1240
+ test('static template-literal text escapes Perl $ and @ sigils', () => {
1241
+ const { template } = gen('`Price: $${it()} @user`')
1242
+ expect(template).toContain('\\$')
1243
+ expect(template).toContain('\\@user')
1244
+ })
1245
+
1246
+ test('wrong-arity templatePrimitive records BF101 and emits no hash-deref', () => {
1247
+ const { template, errors } = gen('JSON.stringify(obj(), null)')
1248
+ expect(errors.some(e => e.code === 'BF101')).toBe(true)
1249
+ // The invalid `$JSON->{stringify}` hash-deref must NOT leak out.
1250
+ expect(template).not.toContain('$JSON->{stringify}')
1134
1251
  })
1135
1252
  })