@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.
- package/dist/adapter/__tests__/boolean-result.test.d.ts +2 -0
- package/dist/adapter/__tests__/boolean-result.test.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +42 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +1143 -0
- package/dist/adapter/mojo-adapter.d.ts +219 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +1163 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1143 -0
- package/dist/test-render.d.ts +38 -0
- package/dist/test-render.d.ts.map +1 -0
- package/lib/BarefootJS.pm +745 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +150 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +104 -0
- package/package.json +65 -0
- package/src/__tests__/mojo-adapter.test.ts +940 -0
- package/src/__tests__/mojo-streaming.test.ts +136 -0
- package/src/__tests__/scaffold.test.ts +224 -0
- package/src/__tests__/template-base-name.test.ts +26 -0
- package/src/adapter/__tests__/boolean-result.test.ts +106 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/mojo-adapter.ts +1931 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +704 -0
|
@@ -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
|
+
}
|