@barefootjs/mojolicious 0.1.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.
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Mojolicious EP template test renderer
3
+ *
4
+ * Compiles JSX source with MojoAdapter and renders to HTML via `perl`.
5
+ * Used by adapter-tests conformance runner.
6
+ */
7
+
8
+ import { compileJSX, extractSsrDefaults } from '@barefootjs/jsx'
9
+ import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { mkdir, rm } from 'node:fs/promises'
11
+ import { resolve } from 'node:path'
12
+
13
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
14
+ const LIB_DIR = resolve(import.meta.dir, '../lib')
15
+
16
+ export class PerlNotAvailableError extends Error {
17
+ constructor(message: string) {
18
+ super(message)
19
+ this.name = 'PerlNotAvailableError'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Recover the bare component name from a compiler-emitted template
25
+ * file path. `templatesPerComponent` adapters write each component to
26
+ * `<dir>/<ComponentName><adapter.extension>` (Mojo: `.html.ep`), and
27
+ * downstream pairing logic needs the raw component name back so it can
28
+ * look up the matching IR in `irsByName`.
29
+ *
30
+ * Stripping the *full* adapter extension matters because Mojo's
31
+ * extension is multi-segment (`.html.ep`). A naive `\.[^.]+$/` strips
32
+ * only the last segment, leaves `<ComponentName>.html`, misses the
33
+ * IR map, and silently pairs every sibling template to the
34
+ * entry-point IR — exactly the silent-gap class issue #1297 was
35
+ * filed to surface.
36
+ *
37
+ * Exported for testing.
38
+ */
39
+ export function templateBaseName(path: string, extension: string): string {
40
+ const filename = path.substring(path.lastIndexOf('/') + 1)
41
+ return filename.endsWith(extension)
42
+ ? filename.slice(0, -extension.length)
43
+ : filename
44
+ }
45
+
46
+ /**
47
+ * Escape a string for safe embedding inside a Perl single-quoted
48
+ * literal (`'…'`). Single-quoted Perl strings honour only two
49
+ * metacharacters: `\\` and `\'`. Newlines, tabs, and everything else
50
+ * pass through literally.
51
+ *
52
+ * Used when interpolating a user-supplied / harness-derived
53
+ * `__instanceId` into the generated Perl render script — current
54
+ * call sites always pass `<ComponentName>_test`, but defensive
55
+ * escaping avoids a future fixture that injects a quote silently
56
+ * corrupting the generated script.
57
+ */
58
+ function escapePerlSingleQuoted(s: string): string {
59
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
60
+ }
61
+
62
+ let _perlAvailable: boolean | null = null
63
+ async function isPerlAvailable(): Promise<boolean> {
64
+ if (_perlAvailable !== null) return _perlAvailable
65
+ try {
66
+ const proc = Bun.spawn(['perl', '-MMojolicious', '-e', 'print $Mojolicious::VERSION'], {
67
+ stdout: 'pipe',
68
+ stderr: 'pipe',
69
+ })
70
+ await proc.exited
71
+ _perlAvailable = proc.exitCode === 0
72
+ } catch {
73
+ _perlAvailable = false
74
+ }
75
+ return _perlAvailable
76
+ }
77
+
78
+ export interface RenderOptions {
79
+ /** JSX source code */
80
+ source: string
81
+ /** Template adapter to use */
82
+ adapter: import('@barefootjs/jsx').TemplateAdapter
83
+ /** Props to inject (optional) */
84
+ props?: Record<string, unknown>
85
+ /** Additional component files (filename → source) */
86
+ components?: Record<string, string>
87
+ }
88
+
89
+ export async function renderMojoComponent(options: RenderOptions): Promise<string> {
90
+ const { source, adapter, props, components } = options
91
+
92
+ // Compile child components first
93
+ const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
94
+ if (components) {
95
+ for (const [filename, childSource] of Object.entries(components)) {
96
+ const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
97
+ const childErrors = childResult.errors.filter(e => e.severity === 'error')
98
+ if (childErrors.length > 0) {
99
+ throw new Error(`Compilation errors in ${filename}:\n${childErrors.map(e => e.message).join('\n')}`)
100
+ }
101
+ const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
102
+ if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
103
+ const childIrFiles = childResult.files.filter(f => f.type === 'ir')
104
+ if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
105
+ const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
106
+ if (childTemplateFiles.length === 1) {
107
+ // Single-component child source: only one template + one IR.
108
+ childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
109
+ } else {
110
+ // Multi-component child source: pair template ↔ IR by basename.
111
+ // The Mojo adapter's `templatesPerComponent` emits files named
112
+ // `<ComponentName><adapter.extension>` (e.g. `Counter.html.ep`),
113
+ // so we strip the *full* `.html.ep` — not just the last dot
114
+ // segment — to recover the componentName. A naive `\.[^.]+$/`
115
+ // would leave `Counter.html`, miss the IR map, and silently
116
+ // pair every sibling to the entry-point IR.
117
+ const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
118
+ for (const tf of childTemplateFiles) {
119
+ const baseName = templateBaseName(tf.path, adapter.extension)
120
+ const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
121
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // Compile parent source
128
+ const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
129
+
130
+ const errors = result.errors.filter(e => e.severity === 'error')
131
+ if (errors.length > 0) {
132
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
133
+ }
134
+
135
+ // Collect every IR + template emitted from the parent source. Single-
136
+ // component files yield one `markedTemplate` named after the source
137
+ // file (e.g. `component.html.ep`); multi-component files with
138
+ // `templatesPerComponent` yield one named after each component (e.g.
139
+ // `Counter.html.ep`). Multi-component sources also emit one IR per
140
+ // component (#1297) — pick the entry-point IR (default export wins;
141
+ // else first inline-exported; else first) and route sibling
142
+ // components through `childTemplates` so cross-component references
143
+ // resolve at render time.
144
+ const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
145
+ if (templateFiles.length === 0) throw new Error('No marked template in compile output')
146
+
147
+ const irFiles = result.files.filter(f => f.type === 'ir')
148
+ if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
149
+ const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
150
+ const ir =
151
+ irs.find(i => i.metadata.hasDefaultExport) ??
152
+ irs.find(i => i.metadata.isExported) ??
153
+ irs[0]
154
+
155
+ let templateFile: { content: string } | undefined
156
+ if (templateFiles.length === 1) {
157
+ // Single-component source: the one template file is the entry-point
158
+ // template regardless of its basename (which comes from the source
159
+ // filename, not the component name).
160
+ templateFile = templateFiles[0]
161
+ } else {
162
+ // Multi-component source: templates are named by component
163
+ // (templatesPerComponent). Match each template file to its IR by
164
+ // basename so we can split the entry-point from siblings. See
165
+ // `templateBaseName` for why the full `adapter.extension` is
166
+ // stripped rather than the last dot segment alone.
167
+ const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
168
+ for (const tf of templateFiles) {
169
+ const baseName = templateBaseName(tf.path, adapter.extension)
170
+ const matchedIR = irsByName.get(baseName)
171
+ if (matchedIR === ir) {
172
+ templateFile = tf
173
+ } else if (matchedIR) {
174
+ childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
175
+ }
176
+ }
177
+ }
178
+ if (!templateFile) throw new Error('No marked template in compile output')
179
+
180
+ const componentName = ir.metadata.componentName
181
+
182
+ // Build temp directory
183
+ const tempDir = resolve(
184
+ RENDER_TEMP_DIR,
185
+ `mojo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
186
+ )
187
+ await mkdir(tempDir, { recursive: true })
188
+
189
+ try {
190
+ // Write template files (parent + children)
191
+ // In real Mojolicious, bf is a helper (no $ prefix).
192
+ // For Mojo::Template standalone, convert bf-> to $bf-> so it resolves as a variable.
193
+ const patchTemplate = (content: string) => content.replace(/\bbf->/g, '$bf->')
194
+ await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.html.ep`), patchTemplate(templateFile.content))
195
+ for (const [childName, { template }] of childTemplates) {
196
+ await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.html.ep`), patchTemplate(template))
197
+ }
198
+
199
+ // Build props hash for Perl
200
+ const propsPerl = buildPerlProps(componentName, props, ir)
201
+
202
+ // Honour `__instanceId` from props for the root scope id so
203
+ // shared-component fixtures (which pin `<ComponentName>_test`) match
204
+ // cross-adapter; default to 'test' otherwise. Escape for Perl
205
+ // single-quoted embedding — `\` and `'` are the only metacharacters
206
+ // inside `q{}` / `'…'`.
207
+ const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
208
+ const rootScopeId = escapePerlSingleQuoted(rootScopeIdRaw)
209
+
210
+ // Build child template rendering functions for Perl
211
+ const childRenderers = buildChildRenderers(childTemplates, ir, tempDir)
212
+
213
+ // Write render script
214
+ const renderScript = `#!/usr/bin/env perl
215
+ use strict;
216
+ use warnings;
217
+ use utf8;
218
+
219
+ use lib '${LIB_DIR}';
220
+ use Mojolicious;
221
+ use Mojo::Template;
222
+ # Boolean values in spread bags arrive as Mojo::JSON::true /
223
+ # Mojo::JSON::false from the JS-side toPerlLiteral so
224
+ # BarefootJS::spread_attrs can detect them via ref() and apply
225
+ # boolean-attr semantics (#1407 follow-up, #1413 review).
226
+ use Mojo::JSON;
227
+
228
+ use BarefootJS;
229
+
230
+ my $app = Mojolicious->new;
231
+
232
+ # Read template
233
+ open my $fh, '<:utf8', '${resolve(tempDir, `${toSnakeCase(componentName)}.html.ep`)}' or die "Cannot open template: $!";
234
+ my $template_content = do { local $/; <$fh> };
235
+ close $fh;
236
+
237
+ # Set up props
238
+ my $props = ${propsPerl};
239
+
240
+ # Create BarefootJS instance with mock controller
241
+ my $c = $app->build_controller;
242
+ my $bf = BarefootJS->new($c, {});
243
+ # Honour an explicit __instanceId from props so shared-component fixtures
244
+ # (which pin <ComponentName>_test scope ids for cross-adapter normalisation)
245
+ # match what Hono renderHonoComponent emits. Default to 'test' otherwise.
246
+ $bf->_scope_id('${rootScopeId}');
247
+
248
+ ${childRenderers}
249
+
250
+ # Render template inline
251
+ my $mt = Mojo::Template->new(vars => 1, auto_escape => 1);
252
+ my $output = $mt->render($template_content, {
253
+ %$props,
254
+ bf => $bf,
255
+ });
256
+
257
+ if (ref $output) {
258
+ # Mojo::Template returns Mojo::Exception on error
259
+ die $output->to_string;
260
+ }
261
+
262
+ print $output;
263
+ `
264
+ await Bun.write(resolve(tempDir, 'render.pl'), renderScript)
265
+
266
+ // Check if Perl + Mojolicious is available
267
+ if (!await isPerlAvailable()) {
268
+ throw new PerlNotAvailableError('perl with Mojolicious not found — skipping Mojo rendering')
269
+ }
270
+
271
+ // Run render script
272
+ const proc = Bun.spawn(['perl', 'render.pl'], {
273
+ cwd: tempDir,
274
+ stdout: 'pipe',
275
+ stderr: 'pipe',
276
+ })
277
+
278
+ const [stdout, stderr] = await Promise.all([
279
+ new Response(proc.stdout).text(),
280
+ new Response(proc.stderr).text(),
281
+ ])
282
+
283
+ const exitCode = await proc.exited
284
+ if (exitCode !== 0) {
285
+ throw new Error(`perl render failed (exit ${exitCode}):\n${stderr}`)
286
+ }
287
+
288
+ return stdout
289
+ } finally {
290
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Build Perl code that replaces `%= include 'child_name', ...` with inline template rendering.
296
+ * Each child component becomes a Perl sub that renders its template with Mojo::Template.
297
+ */
298
+ function buildChildRenderers(
299
+ childTemplates: Map<string, { template: string; ir: ComponentIR }>,
300
+ parentIR: ComponentIR,
301
+ tempDir: string,
302
+ ): string {
303
+ if (childTemplates.size === 0) return ''
304
+
305
+ const lines: string[] = []
306
+ lines.push(`# Register child component renderers`)
307
+
308
+ for (const [componentName] of childTemplates) {
309
+ const snakeName = toSnakeCase(componentName)
310
+ const childTemplatePath = resolve(tempDir, `${snakeName}.html.ep`)
311
+
312
+ // Compute child scope ID from parent IR
313
+ const childSlotIds = findChildSlotIds(parentIR, componentName)
314
+
315
+ lines.push(`{`)
316
+ lines.push(` open my $child_fh, '<:utf8', '${childTemplatePath}' or die "Cannot open child template: $!";`)
317
+ lines.push(` my $child_tmpl = do { local $/; <$child_fh> };`)
318
+ lines.push(` close $child_fh;`)
319
+ lines.push(` my $child_mt = Mojo::Template->new(vars => 1, auto_escape => 1);`)
320
+
321
+ // Track instance counter for multiple-instances support
322
+ lines.push(` my $instance_idx = 0;`)
323
+ const slotIdsPerl = childSlotIds.length > 0
324
+ ? `my @slot_ids = (${childSlotIds.map(id => `'${id}'`).join(', ')}); my $sid = $slot_ids[$instance_idx] // $slot_ids[-1]; $instance_idx++;`
325
+ : `my $sid = '${snakeName}';`
326
+
327
+ lines.push(` $bf->register_child_renderer('${snakeName}', sub {`)
328
+ lines.push(` my ($child_props) = @_;`)
329
+ lines.push(` ${slotIdsPerl}`)
330
+ lines.push(` my $child_bf = BarefootJS->new($c, {});`)
331
+ lines.push(` $child_bf->_scope_id("test_$sid");`)
332
+ lines.push(` my $rendered = $child_mt->render($child_tmpl, { %$child_props, bf => $child_bf });`)
333
+ lines.push(` die $rendered->to_string if ref $rendered;`)
334
+ lines.push(` chomp $rendered;`)
335
+ lines.push(` return $rendered;`)
336
+ lines.push(` });`)
337
+ lines.push(`}`)
338
+ lines.push(``)
339
+ }
340
+
341
+ return lines.join('\n')
342
+ }
343
+
344
+ /**
345
+ * Find slot IDs assigned to a child component in the parent IR.
346
+ */
347
+ function findChildSlotIds(parentIR: ComponentIR, childName: string): string[] {
348
+ const ids: string[] = []
349
+ function walk(node: import('@barefootjs/jsx').IRNode): void {
350
+ if (node.type === 'component' && node.name === childName && node.slotId) {
351
+ ids.push(node.slotId)
352
+ }
353
+ if ('children' in node && Array.isArray(node.children)) {
354
+ for (const child of node.children) walk(child)
355
+ }
356
+ if (node.type === 'conditional') {
357
+ walk(node.whenTrue)
358
+ walk(node.whenFalse)
359
+ }
360
+ }
361
+ walk(parentIR.root)
362
+ return ids
363
+ }
364
+
365
+ /**
366
+ * Convert PascalCase to snake_case for Mojo template naming.
367
+ */
368
+ function toSnakeCase(name: string): string {
369
+ return name
370
+ .replace(/([A-Z])/g, '_$1')
371
+ .toLowerCase()
372
+ .replace(/^_/, '')
373
+ }
374
+
375
+ /**
376
+ * Build a Perl hash literal from props.
377
+ */
378
+ function buildPerlProps(
379
+ _componentName: string,
380
+ props: Record<string, unknown> | undefined,
381
+ ir: ComponentIR,
382
+ ): string {
383
+ const entries: string[] = []
384
+
385
+ // Add scope_id — honour an explicit `__instanceId` from props so
386
+ // shared-component fixtures (which pin a `<ComponentName>_test` scope
387
+ // id) match cross-adapter; default to 'test' for the rest of the
388
+ // corpus. Escape for Perl single-quoted embedding.
389
+ const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
390
+ entries.push(`scope_id => '${escapePerlSingleQuoted(explicitScope)}'`)
391
+
392
+ // Add props params with defaults (before signals, so signals can reference them)
393
+ for (const param of ir.metadata.propsParams) {
394
+ if (props && param.name in props) continue
395
+ if (param.defaultValue) {
396
+ const perlValue = jsToPerlValue(param.defaultValue)
397
+ if (perlValue !== null) {
398
+ entries.push(`${param.name} => ${perlValue}`)
399
+ continue
400
+ }
401
+ }
402
+ // No default and no caller-supplied value: pass `undef` so the
403
+ // Mojo::Template `vars => 1` auto-declaration fires. Without
404
+ // this, references to an optional prop variable (`$label`,
405
+ // `$on`) trip Perl's strict-mode "Global symbol requires
406
+ // explicit package name" error before the template gets a
407
+ // chance to skip the falsy branch — the same failure mode the
408
+ // restPropsName carve-out below was added for (#1407 follow-up).
409
+ // Surfaces with #1443: lowering `[a, b].filter(Boolean).join(' ')`
410
+ // emits a literal `$label` reference where the BF101 path used
411
+ // to emit `''`, exposing this latent test-harness gap.
412
+ entries.push(`${param.name} => undef`)
413
+ }
414
+
415
+ // (#1407 follow-up) Default the rest-binding identifier to an
416
+ // empty hashref so `bf->spread_attrs($extras)` in the generated
417
+ // Mojo template doesn't trip Perl's strict-mode "Global symbol
418
+ // requires explicit package name" check when the caller doesn't
419
+ // supply a bag value (the destructured-rest fixture exercises
420
+ // the COMPILE path on Go, where the bag plumbing matters; the
421
+ // runtime is a no-op when the caller leaves the bag unset, which
422
+ // mirrors the empty-spread case on every adapter).
423
+ if (ir.metadata.restPropsName && !(props && ir.metadata.restPropsName in props)) {
424
+ entries.push(`${ir.metadata.restPropsName} => {}`)
425
+ }
426
+
427
+ // Add user props
428
+ if (props) {
429
+ for (const [key, value] of Object.entries(props)) {
430
+ if (typeof value === 'string') {
431
+ entries.push(`${key} => '${value.replace(/'/g, "\\'")}'`)
432
+ } else if (typeof value === 'number') {
433
+ entries.push(`${key} => ${value}`)
434
+ } else if (typeof value === 'boolean') {
435
+ // Mojo::JSON sentinels so BarefootJS helpers can detect
436
+ // booleans via ref() (see toPerlLiteral / spread_attrs).
437
+ entries.push(`${key} => ${value ? 'Mojo::JSON::true' : 'Mojo::JSON::false'}`)
438
+ } else if (Array.isArray(value)) {
439
+ // Array → Perl arrayref literal. Fixtures that exercise
440
+ // array-receiver methods (`items.every(...)`, `items.some(...)`,
441
+ // `items.join(' - ')`, etc. — #1448 method catalog) need the
442
+ // prop value to reach the template as a real arrayref so the
443
+ // generated `@{$items}` / `$items->[$i]` references resolve.
444
+ // Without this branch, `Mojo::Template`'s `vars => 1` never
445
+ // declares `my $items` (the key is absent from the vars
446
+ // hash) and the template trips Perl's strict-mode "Global
447
+ // symbol $items requires explicit package name" check.
448
+ entries.push(`${key} => ${toPerlLiteral(value)}`)
449
+ } else if (value && typeof value === 'object') {
450
+ // Plain object → Perl hashref literal (#1407 follow-up).
451
+ // Used by the destructured-rest / propsObject fixtures
452
+ // (`jsx-spread-rest-prop`, `jsx-spread-props-object`) so
453
+ // the test harness can pass through bag-shaped props that
454
+ // weren't enumerated by the analyzer.
455
+ entries.push(`${key} => ${toPerlLiteral(value)}`)
456
+ }
457
+ }
458
+ }
459
+
460
+ // Add signal values evaluated from props (must come after user props)
461
+ for (const signal of ir.metadata.signals) {
462
+ const value = evaluateSignalInit(signal.initialValue.trim(), props)
463
+ if (value !== null) {
464
+ entries.push(`${signal.getter} => ${toPerlLiteral(value)}`)
465
+ }
466
+ }
467
+
468
+ // Add memo values. The production Mojo plugin seeds these from the
469
+ // manifest's `ssrDefaults` (see Plugin/BarefootJS.pm `before_render`
470
+ // hook), which carries the statically-evaluated result of each memo
471
+ // computation. Mirror that here so the test harness doesn't diverge
472
+ // from the plugin: hard-coding `0` masked memos with non-zero
473
+ // initial values until #1423 added a fixture that exposed the gap.
474
+ const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
475
+ for (const memo of ir.metadata.memos) {
476
+ const entry = ssrDefaults[memo.name]
477
+ const value = entry && typeof entry === 'object' && 'value' in entry ? entry.value : 0
478
+ entries.push(`${memo.name} => ${toPerlLiteral(value ?? 0)}`)
479
+ }
480
+
481
+ return `{${entries.join(', ')}}`
482
+ }
483
+
484
+ /**
485
+ * Evaluate a signal initializer expression using provided props.
486
+ * Handles patterns like: props.initial ?? 0, props.value, literal values.
487
+ */
488
+ function evaluateSignalInit(
489
+ expr: string,
490
+ props?: Record<string, unknown>,
491
+ ): unknown {
492
+ // props.xxx ?? default
493
+ const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
494
+ if (nullishMatch) {
495
+ const propName = nullishMatch[1]
496
+ const defaultExpr = nullishMatch[2].trim()
497
+ if (props && propName in props) {
498
+ return props[propName]
499
+ }
500
+ return parseLiteral(defaultExpr)
501
+ }
502
+
503
+ // props.xxx (no default)
504
+ const propsMatch = expr.match(/^props\.(\w+)$/)
505
+ if (propsMatch) {
506
+ if (props && propsMatch[1] in props) {
507
+ return props[propsMatch[1]]
508
+ }
509
+ return null
510
+ }
511
+
512
+ // Literal value
513
+ return parseLiteral(expr)
514
+ }
515
+
516
+ function parseLiteral(expr: string): unknown {
517
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
518
+ if (expr === 'true') return true
519
+ if (expr === 'false') return false
520
+ if (expr === '[]') return []
521
+ // String literal — require matching opener/closer (the previous
522
+ // regex `^['"]…['"]$` accepted mixed quotes like `'foo"`) and
523
+ // unescape JS-style escape sequences so `'a\\'b'` round-trips as
524
+ // `a\'b` instead of leaking the source-level escapes into the
525
+ // Perl literal (#1413 review).
526
+ const stringMatch = expr.match(/^(['"])(.*)\1$/s)
527
+ if (stringMatch) return unescapeJsString(stringMatch[2])
528
+ // JS object literal (#1407 follow-up): `{ id: 'a', class: 'on' }`.
529
+ // Used for spread-bag signal initial values in the `jsx-spread-*`
530
+ // fixture family. Keys may be bare identifiers or string
531
+ // literals; values are scalars (string / number / boolean /
532
+ // null) or nested object literals via recursive `parseLiteral`.
533
+ // Non-empty array values (`[1, 2]`) are NOT supported — only
534
+ // the `[]` empty-array literal recognised by the early-return
535
+ // above lowers. Trailing commas (`{ id: 'a', }`) are accepted
536
+ // by skipping empty segments (#1413 review). Anything the
537
+ // recursive call can't handle (identifiers, function calls,
538
+ // member access, non-empty arrays) surfaces as null and bubbles
539
+ // up so the harness falls back to its existing `undef`
540
+ // behaviour.
541
+ const trimmed = expr.trim()
542
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
543
+ const inner = trimmed.slice(1, -1).trim()
544
+ if (!inner) return {}
545
+ const obj: Record<string, unknown> = {}
546
+ // Split on commas at depth 0; values may contain `:` so use a
547
+ // simple state machine instead of a regex.
548
+ const pairs: string[] = []
549
+ let depth = 0
550
+ let start = 0
551
+ let quote: string | null = null
552
+ for (let i = 0; i < inner.length; i++) {
553
+ const c = inner[i]
554
+ if (quote) {
555
+ // Quote-termination check: count consecutive backslashes
556
+ // immediately before the current position. An EVEN count
557
+ // means each `\\` pair represents a literal backslash and
558
+ // the quote is unescaped, closing the string. An ODD
559
+ // count means the trailing `\` escapes the quote, so the
560
+ // string keeps going. The naive `inner[i-1] !== '\\'`
561
+ // check mis-classifies even runs (`\\"`) as escaped
562
+ // (#1413 review).
563
+ if (c === quote) {
564
+ let backslashes = 0
565
+ for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
566
+ if (backslashes % 2 === 0) quote = null
567
+ }
568
+ continue
569
+ }
570
+ if (c === '"' || c === "'") {
571
+ quote = c
572
+ continue
573
+ }
574
+ if (c === '{' || c === '[') depth++
575
+ else if (c === '}' || c === ']') depth--
576
+ else if (c === ',' && depth === 0) {
577
+ pairs.push(inner.slice(start, i))
578
+ start = i + 1
579
+ }
580
+ }
581
+ pairs.push(inner.slice(start))
582
+ for (const pair of pairs) {
583
+ // Skip empty segments — typically a trailing comma's tail
584
+ // (#1413 review).
585
+ if (!pair.trim()) continue
586
+ const colonIdx = pair.indexOf(':')
587
+ if (colonIdx < 0) return null
588
+ let key = pair.slice(0, colonIdx).trim()
589
+ const val = pair.slice(colonIdx + 1).trim()
590
+ // Strip key quotes if any — require matching open/close
591
+ // quote and unescape, same shape as the value-side string
592
+ // literal handling above (#1413 review).
593
+ const keyMatch = key.match(/^(['"])(.*)\1$/s)
594
+ if (keyMatch) key = unescapeJsString(keyMatch[2])
595
+ const parsedVal = parseLiteral(val)
596
+ if (parsedVal === null && val !== 'null') return null
597
+ obj[key] = parsedVal
598
+ }
599
+ return obj
600
+ }
601
+ return null
602
+ }
603
+
604
+ /**
605
+ * Unescape a JS string-literal body (the content between the
606
+ * matching opening and closing quotes, not the quotes themselves).
607
+ * Handles the common single-character escapes `\\`, `\'`, `\"`,
608
+ * `\n`, `\r`, `\t`, `\0`, and the backslash-anything fallback that
609
+ * mirrors JS's "unknown escape is the character itself" semantics.
610
+ * Hex / unicode / octal escapes are intentionally out of scope —
611
+ * the spread-bag fixture corpus uses ASCII identifiers and short
612
+ * literal values, so the harness doesn't need a full JS string
613
+ * decoder (#1413 review).
614
+ */
615
+ function unescapeJsString(s: string): string {
616
+ return s.replace(/\\(.)/g, (_, c) => {
617
+ switch (c) {
618
+ case 'n': return '\n'
619
+ case 'r': return '\r'
620
+ case 't': return '\t'
621
+ case '0': return '\0'
622
+ // `\\`, `\'`, `\"`, and any other single-character escape
623
+ // collapse to the literal character (matches JS semantics
624
+ // for unrecognised escapes).
625
+ default: return c
626
+ }
627
+ })
628
+ }
629
+
630
+ /**
631
+ * Perl single-quoted string escape: `'` AND `\` need escaping.
632
+ * Perl single quotes treat a trailing backslash as escaping the
633
+ * closing quote (`'foo\'` is invalid), so values ending in `\`
634
+ * must double the backslash (#1413 review).
635
+ */
636
+ function perlSingleQuote(s: string): string {
637
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
638
+ }
639
+
640
+ function toPerlLiteral(value: unknown): string {
641
+ if (typeof value === 'string') return perlSingleQuote(value)
642
+ if (typeof value === 'number') return String(value)
643
+ // JS booleans → Mojo::JSON sentinel objects so `BarefootJS::spread_attrs`
644
+ // can detect them via `ref()` and apply boolean-attr semantics
645
+ // (true → bare attribute, false → omitted). Emitting plain Perl
646
+ // 0/1 would conflate genuine numeric values with booleans and
647
+ // turn `disabled: false` into `disabled="0"` (#1413 review).
648
+ if (typeof value === 'boolean') return value ? 'Mojo::JSON::true' : 'Mojo::JSON::false'
649
+ // Array → Perl arrayref literal, recursing so element types are
650
+ // serialised correctly. Previously this returned the literal `[]`
651
+ // — fine when the only caller was the spread-bag initial-value
652
+ // path (which never carried array shapes), but loses the contents
653
+ // for #1448's method fixtures where `items: ['a', 'b', 'c']`
654
+ // needs to reach the template as `['a', 'b', 'c']`, not an empty
655
+ // arrayref.
656
+ if (Array.isArray(value)) {
657
+ return `[${value.map(toPerlLiteral).join(', ')}]`
658
+ }
659
+ // Plain object → Perl hashref literal. Used by the spread-bag
660
+ // signal initial values (#1407 follow-up). Keys are quoted as
661
+ // Perl strings (escaped via `perlSingleQuote` so values
662
+ // containing `\` or `'` round-trip safely); values recurse so
663
+ // nested-but-simple shapes still work.
664
+ if (value && typeof value === 'object') {
665
+ const entries: string[] = []
666
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
667
+ entries.push(`${perlSingleQuote(key)} => ${toPerlLiteral(v)}`)
668
+ }
669
+ return `{${entries.join(', ')}}`
670
+ }
671
+ return 'undef'
672
+ }
673
+
674
+ /**
675
+ * Convert a JS literal value to a Perl literal.
676
+ * Handles: numbers, strings, booleans, empty arrays, props.xxx ?? default patterns.
677
+ */
678
+ function jsToPerlValue(jsValue: string): string | null {
679
+ const v = jsValue.trim()
680
+
681
+ // Number
682
+ if (/^-?\d+(\.\d+)?$/.test(v)) return v
683
+
684
+ // String literal
685
+ if (/^['"].*['"]$/.test(v)) return v
686
+
687
+ // Boolean
688
+ if (v === 'true') return '1'
689
+ if (v === 'false') return '0'
690
+
691
+ // Empty array
692
+ if (v === '[]') return '[]'
693
+
694
+ // props.xxx ?? default — extract the default value
695
+ const nullishMatch = v.match(/\?\?\s*(.+)$/)
696
+ if (nullishMatch) {
697
+ return jsToPerlValue(nullishMatch[1])
698
+ }
699
+
700
+ // props.xxx (no default) — return undef
701
+ if (v.startsWith('props.')) return 'undef'
702
+
703
+ return null
704
+ }