@barefootjs/mojolicious 0.5.0 → 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,6 +69,16 @@ 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
82
  ],
73
83
  // Per-fixture build-time contracts for shapes the Mojo adapter
74
84
  // intentionally refuses to lower. Owned by this adapter test file
@@ -276,6 +286,40 @@ export function List() {
276
286
  expect(result.template).toMatch(/bf->comment\("\/loop:[^"]+"\)/)
277
287
  })
278
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
+
279
323
  test('generates script registration for client components', () => {
280
324
  const result = compileAndGenerate(`
281
325
  "use client"
@@ -958,3 +1002,251 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
958
1002
  })
959
1003
  }
960
1004
  })
1005
+
1006
+ // =============================================================================
1007
+ // #1448 — `/* @client */` escape hatch for STILL-UNSUPPORTED methods
1008
+ // =============================================================================
1009
+ //
1010
+ // Mojo sibling of the Go block: #1448 documents `/* @client */` as the
1011
+ // universal workaround for any Array/String method the template
1012
+ // adapters can't lower. This pins that contract for the Mojo adapter —
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.
1017
+ //
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.
1026
+ describe('MojoAdapter - #1448 @client escape hatch (unsupported methods)', () => {
1027
+ function emit(expr: string, client: boolean) {
1028
+ const marker = client ? '/* @client */ ' : ''
1029
+ const adapter = new MojoAdapter()
1030
+ const ir = compileToIR(`
1031
+ "use client"
1032
+ import { createSignal } from "@barefootjs/client"
1033
+ export function C() {
1034
+ const [items, setItems] = createSignal<{ name: string; n: number; tags: string[] }[]>([])
1035
+ const [name, setName] = createSignal("x")
1036
+ return <div>{${marker}${expr}}</div>
1037
+ }
1038
+ `, adapter)
1039
+ const template = adapter.generate(ir).template ?? ''
1040
+ return { errors: adapter.errors ?? [], template }
1041
+ }
1042
+
1043
+ function emitLoop(chain: string, client: boolean) {
1044
+ const marker = client ? '/* @client */ ' : ''
1045
+ const adapter = new MojoAdapter()
1046
+ const ir = compileToIR(`
1047
+ "use client"
1048
+ import { createSignal } from "@barefootjs/client"
1049
+ export function C() {
1050
+ const [items, setItems] = createSignal<{ name: string; n: number }[]>([])
1051
+ const myCmp = (a: { n: number }, b: { n: number }) => a.n - b.n
1052
+ return <ul>{${marker}${chain}}</ul>
1053
+ }
1054
+ `, adapter)
1055
+ const template = adapter.generate(ir).template ?? ''
1056
+ return { errors: adapter.errors ?? [], template }
1057
+ }
1058
+
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}' },
1078
+ ]
1079
+ for (const { name, expr, badEmit } of unsupported) {
1080
+ test(`.${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
1081
+ const bare = emit(expr, false)
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)
1086
+
1087
+ const guarded = emit(expr, true)
1088
+ expect(guarded.errors).toEqual([])
1089
+ // Client-only text slot → `<%== bf->comment("client:sN") %>`.
1090
+ expect(guarded.template).toMatch(/bf->comment\("client:s\d+"\)/)
1091
+ expect(guarded.template).not.toContain(badEmit)
1092
+ })
1093
+ }
1094
+
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
+ })
1119
+
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
+ })
1138
+
1139
+ // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
1140
+ // The Mojo client-only loop placeholder is an empty element (the
1141
+ // client runtime repopulates it via the `bf-s` scope marker), so the
1142
+ // contract here is: no errors + the comparator never lowers + no
1143
+ // rendered `<li>` survives.
1144
+ const unsupportedSort: Array<[string, string]> = [
1145
+ ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
1146
+ ['localeCompare locale/options arg', `items().toSorted((a, b) => a.name.localeCompare(b.name, "ja", { numeric: true })).map(x => <li key={x.name}>{x.name}</li>)`],
1147
+ ]
1148
+ for (const [label, chain] of unsupportedSort) {
1149
+ test(`sort follow-up (${label}): bare raises BF021, @client clears it`, () => {
1150
+ const bare = compileJSX(`
1151
+ "use client"
1152
+ import { createSignal } from "@barefootjs/client"
1153
+ export function C() {
1154
+ const [items, setItems] = createSignal<{ name: string; n: number }[]>([])
1155
+ const myCmp = (a: { n: number }, b: { n: number }) => a.n - b.n
1156
+ return <ul>{${chain}}</ul>
1157
+ }
1158
+ `.trimStart(), 'test.tsx', { adapter: new MojoAdapter() })
1159
+ expect(bare.errors?.some(e => e.code === 'BF021')).toBe(true)
1160
+
1161
+ const guarded = emitLoop(chain, true)
1162
+ expect(guarded.errors).toEqual([])
1163
+ // Empty client-only loop placeholder — no item rows emitted SSR.
1164
+ expect(guarded.template).not.toContain('<li')
1165
+ expect(guarded.template).not.toContain('localeCompare')
1166
+ })
1167
+ }
1168
+
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: `
1181
+ "use client"
1182
+ import { createSignal } from "@barefootjs/client"
1183
+ export function C() {
1184
+ const [name, setName] = createSignal("hello")
1185
+ return <div>{/* @client */ name().repeat(3)}</div>
1186
+ }
1187
+ `.trimStart(),
1188
+ adapter: new MojoAdapter(),
1189
+ })
1190
+ expect(html).toContain('<!--bf-client:s0-->')
1191
+ } catch (err) {
1192
+ if (err instanceof PerlNotAvailableError) {
1193
+ console.log('Skipping #1448 @client e2e: perl/Mojolicious not found')
1194
+ return
1195
+ }
1196
+ throw err
1197
+ }
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}')
1251
+ })
1252
+ })