@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.
- package/dist/adapter/index.js +109 -145
- package/dist/adapter/mojo-adapter.d.ts +16 -46
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +109 -145
- package/dist/index.js +109 -145
- package/dist/test-render.d.ts +5 -0
- package/dist/test-render.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/evaluate-signal-init.test.ts +35 -0
- package/src/__tests__/mojo-adapter.test.ts +292 -0
- package/src/adapter/mojo-adapter.ts +213 -294
- package/src/test-render.ts +62 -37
|
@@ -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
|
+
})
|