@barefootjs/xslate 0.8.0 → 0.9.1
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/LICENSE +21 -0
- package/dist/adapter/index.js +187506 -57
- package/dist/adapter/xslate-adapter.d.ts +60 -0
- package/dist/adapter/xslate-adapter.d.ts.map +1 -1
- package/dist/build.js +187506 -57
- package/dist/index.js +187506 -57
- package/lib/BarefootJS/Backend/Xslate.pm +10 -2
- package/package.json +10 -24
- package/src/__tests__/xslate-adapter.test.ts +131 -0
- package/src/__tests__/xslate-spread-attrs.test.ts +218 -0
- package/src/adapter/xslate-adapter.ts +356 -50
- package/src/test-render.ts +661 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text::Xslate (Kolon) template test renderer
|
|
3
|
+
*
|
|
4
|
+
* Compiles JSX source with `XslateAdapter` and renders the resulting
|
|
5
|
+
* `.tx` templates to HTML via `perl` + `Text::Xslate` driven through
|
|
6
|
+
* `BarefootJS` + `BarefootJS::Backend::Xslate`. Used by the
|
|
7
|
+
* adapter-tests conformance runner (`runAdapterConformanceTests`).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the sibling Mojolicious `test-render.ts` (same `RenderOptions`
|
|
10
|
+
* contract, same prop / signal / memo seeding, same multi-component
|
|
11
|
+
* child-renderer registration), but the render path is engine-agnostic:
|
|
12
|
+
* the backend builds a plain Kolon Text::Xslate from a template dir, so
|
|
13
|
+
* the harness needs no web framework. Child components are wired through
|
|
14
|
+
* the production `BarefootJS::register_child_renderer` so the child's
|
|
15
|
+
* `bf-s` scope id derives from `<parentScope>_<slotId>` exactly as a real
|
|
16
|
+
* `bf build` page would — closer to the canonical cross-adapter shape
|
|
17
|
+
* than the Mojo harness's literal `test_<sN>`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { compileJSX, extractSsrDefaults } from '@barefootjs/jsx'
|
|
21
|
+
import type { ComponentIR } from '@barefootjs/jsx'
|
|
22
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
23
|
+
import { resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
|
|
26
|
+
// Xslate-specific lib (BarefootJS::Backend::Xslate) lives in this package; the
|
|
27
|
+
// engine-agnostic core (BarefootJS.pm) is in @barefootjs/perl. Both dirs must
|
|
28
|
+
// be on the render script's @INC so `use BarefootJS` and
|
|
29
|
+
// `use BarefootJS::Backend::Xslate` resolve.
|
|
30
|
+
const LIB_DIR = resolve(import.meta.dir, '../lib')
|
|
31
|
+
const PERL_CORE_LIB_DIR = resolve(import.meta.dir, '../../adapter-perl/lib')
|
|
32
|
+
|
|
33
|
+
export class XslateNotAvailableError extends Error {
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'XslateNotAvailableError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recover the bare component name from a compiler-emitted template file
|
|
42
|
+
* path. `templatesPerComponent` adapters write each component to
|
|
43
|
+
* `<dir>/<ComponentName><adapter.extension>` (Xslate: `.tx`), and
|
|
44
|
+
* downstream pairing logic needs the raw component name back so it can
|
|
45
|
+
* look up the matching IR in `irsByName`.
|
|
46
|
+
*
|
|
47
|
+
* Exported for testing.
|
|
48
|
+
*/
|
|
49
|
+
export function templateBaseName(path: string, extension: string): string {
|
|
50
|
+
const filename = path.substring(path.lastIndexOf('/') + 1)
|
|
51
|
+
return filename.endsWith(extension)
|
|
52
|
+
? filename.slice(0, -extension.length)
|
|
53
|
+
: filename
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Escape a string for safe embedding inside a Perl single-quoted
|
|
58
|
+
* literal (`'…'`). Single-quoted Perl strings honour only two
|
|
59
|
+
* metacharacters: `\\` and `\'`.
|
|
60
|
+
*/
|
|
61
|
+
function escapePerlSingleQuoted(s: string): string {
|
|
62
|
+
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let _perlAvailable: boolean | null = null
|
|
66
|
+
async function isXslateAvailable(): Promise<boolean> {
|
|
67
|
+
if (_perlAvailable !== null) return _perlAvailable
|
|
68
|
+
try {
|
|
69
|
+
const proc = Bun.spawn(['perl', '-MText::Xslate', '-e', 'print $Text::Xslate::VERSION'], {
|
|
70
|
+
stdout: 'pipe',
|
|
71
|
+
stderr: 'pipe',
|
|
72
|
+
})
|
|
73
|
+
await proc.exited
|
|
74
|
+
_perlAvailable = proc.exitCode === 0
|
|
75
|
+
} catch {
|
|
76
|
+
_perlAvailable = false
|
|
77
|
+
}
|
|
78
|
+
return _perlAvailable
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RenderOptions {
|
|
82
|
+
/** JSX source code */
|
|
83
|
+
source: string
|
|
84
|
+
/** Template adapter to use */
|
|
85
|
+
adapter: import('@barefootjs/jsx').TemplateAdapter
|
|
86
|
+
/** Props to inject (optional) */
|
|
87
|
+
props?: Record<string, unknown>
|
|
88
|
+
/** Additional component files (filename → source) */
|
|
89
|
+
components?: Record<string, string>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function renderXslateComponent(options: RenderOptions): Promise<string> {
|
|
93
|
+
const { source, adapter, props, components } = options
|
|
94
|
+
|
|
95
|
+
// Compile child components first.
|
|
96
|
+
//
|
|
97
|
+
// A child SOURCE FILE may export more components than the parent actually
|
|
98
|
+
// references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but
|
|
99
|
+
// `Checkbox` only imports `CheckIcon`). Some of those unreferenced
|
|
100
|
+
// components legitimately can't lower to Kolon — the generic `Icon` spreads
|
|
101
|
+
// `{...props}` onto CHILD components (`<GitHubIcon {...props}/>`), which has
|
|
102
|
+
// no Kolon form (`%{$props}` flatten is Perl-only; same engine divergence as
|
|
103
|
+
// the `button` fixture). Throwing on those would block a fixture that never
|
|
104
|
+
// renders them. So defer the per-file error gate: collect every component's
|
|
105
|
+
// template + IR up front, then (after the parent compile pins the reachable
|
|
106
|
+
// set) re-generate ONLY the reachable children and throw if any of THOSE
|
|
107
|
+
// error. Mirrors the Go harness's reachable-children emission (#checkbox).
|
|
108
|
+
const childTemplates: Map<string, { template: string; ir: ComponentIR }> = new Map()
|
|
109
|
+
if (components) {
|
|
110
|
+
for (const [filename, childSource] of Object.entries(components)) {
|
|
111
|
+
const childResult = compileJSX(childSource, filename, { adapter, outputIR: true })
|
|
112
|
+
const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate')
|
|
113
|
+
if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`)
|
|
114
|
+
const childIrFiles = childResult.files.filter(f => f.type === 'ir')
|
|
115
|
+
if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`)
|
|
116
|
+
const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR)
|
|
117
|
+
if (childTemplateFiles.length === 1) {
|
|
118
|
+
childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] })
|
|
119
|
+
} else {
|
|
120
|
+
// Multi-component child source: pair template ↔ IR by basename.
|
|
121
|
+
const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i]))
|
|
122
|
+
for (const tf of childTemplateFiles) {
|
|
123
|
+
const baseName = templateBaseName(tf.path, adapter.extension)
|
|
124
|
+
const matchedIR = childIrsByName.get(baseName) ?? childIrs[0]
|
|
125
|
+
childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Compile parent source.
|
|
132
|
+
const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
|
|
133
|
+
|
|
134
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
135
|
+
if (errors.length > 0) {
|
|
136
|
+
throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const templateFiles = result.files.filter(f => f.type === 'markedTemplate')
|
|
140
|
+
if (templateFiles.length === 0) throw new Error('No marked template in compile output')
|
|
141
|
+
|
|
142
|
+
const irFiles = result.files.filter(f => f.type === 'ir')
|
|
143
|
+
if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)')
|
|
144
|
+
const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR)
|
|
145
|
+
const ir =
|
|
146
|
+
irs.find(i => i.metadata.hasDefaultExport) ??
|
|
147
|
+
irs.find(i => i.metadata.isExported) ??
|
|
148
|
+
irs[0]
|
|
149
|
+
|
|
150
|
+
let templateFile: { content: string } | undefined
|
|
151
|
+
if (templateFiles.length === 1) {
|
|
152
|
+
templateFile = templateFiles[0]
|
|
153
|
+
} else {
|
|
154
|
+
// Multi-component source: split the entry-point template from
|
|
155
|
+
// siblings by pairing each template file to its IR by basename.
|
|
156
|
+
const irsByName = new Map(irs.map(i => [i.metadata.componentName, i]))
|
|
157
|
+
for (const tf of templateFiles) {
|
|
158
|
+
const baseName = templateBaseName(tf.path, adapter.extension)
|
|
159
|
+
const matchedIR = irsByName.get(baseName)
|
|
160
|
+
if (matchedIR === ir) {
|
|
161
|
+
templateFile = tf
|
|
162
|
+
} else if (matchedIR) {
|
|
163
|
+
childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!templateFile) throw new Error('No marked template in compile output')
|
|
168
|
+
|
|
169
|
+
// Reachable-children error gate (#checkbox). Now that the entry-point `ir` is
|
|
170
|
+
// pinned, close transitively over its cross-file component imports and verify
|
|
171
|
+
// each reachable child lowers without error — re-generating the child IR
|
|
172
|
+
// through a fresh adapter to attribute errors per component (the aggregate
|
|
173
|
+
// compile errors aren't component-tagged). A child file may export
|
|
174
|
+
// unreferenced components that legitimately can't lower (e.g. `../icon`'s
|
|
175
|
+
// generic `Icon`); those are dropped silently rather than failing a fixture
|
|
176
|
+
// that never renders them.
|
|
177
|
+
{
|
|
178
|
+
const reachable = new Set<string>()
|
|
179
|
+
const queue = [...collectImportedComponentNames(ir)]
|
|
180
|
+
while (queue.length > 0) {
|
|
181
|
+
const name = queue.shift()!
|
|
182
|
+
if (reachable.has(name)) continue
|
|
183
|
+
const entry = childTemplates.get(name)
|
|
184
|
+
if (!entry) continue // in-source sibling or non-compiled import
|
|
185
|
+
reachable.add(name)
|
|
186
|
+
queue.push(...collectImportedComponentNames(entry.ir))
|
|
187
|
+
}
|
|
188
|
+
for (const name of reachable) {
|
|
189
|
+
const entry = childTemplates.get(name)
|
|
190
|
+
if (!entry) continue
|
|
191
|
+
// The child was first compiled WITHOUT `siblingTemplatesRegistered`, so
|
|
192
|
+
// `entry.ir.errors` may already carry suppressible BF103s (cross-template
|
|
193
|
+
// loop references the harness DOES register). Re-generate with siblings
|
|
194
|
+
// registered and inspect ONLY the errors that pass appends — `generate`
|
|
195
|
+
// resets its own error list and appends to `ir.errors`, so anything after
|
|
196
|
+
// the pre-existing count is the authoritative siblings-registered result.
|
|
197
|
+
const before = entry.ir.errors?.length ?? 0
|
|
198
|
+
adapter.generate(entry.ir, { siblingTemplatesRegistered: true })
|
|
199
|
+
const childErrors = (entry.ir.errors ?? [])
|
|
200
|
+
.slice(before)
|
|
201
|
+
.filter(e => e.severity === 'error')
|
|
202
|
+
if (childErrors.length > 0) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const componentName = ir.metadata.componentName
|
|
211
|
+
|
|
212
|
+
// Build temp directory.
|
|
213
|
+
const tempDir = resolve(
|
|
214
|
+
RENDER_TEMP_DIR,
|
|
215
|
+
`xslate-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
216
|
+
)
|
|
217
|
+
await mkdir(tempDir, { recursive: true })
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Write `.tx` files (parent + children), named by snake_case so
|
|
221
|
+
// the adapter's `$bf.render_child('<snake>', …)` calls + the
|
|
222
|
+
// backend's `render_named('<snake>', …)` resolve from the dir.
|
|
223
|
+
await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.tx`), templateFile.content)
|
|
224
|
+
for (const [childName, { template }] of childTemplates) {
|
|
225
|
+
await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.tx`), template)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build props hash for Perl.
|
|
229
|
+
const propsPerl = buildPerlProps(componentName, props, ir)
|
|
230
|
+
|
|
231
|
+
// Honour `__instanceId` from props for the root scope id so
|
|
232
|
+
// shared-component fixtures (which pin `<ComponentName>_test`) match
|
|
233
|
+
// cross-adapter; default to 'test' otherwise.
|
|
234
|
+
const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
|
|
235
|
+
const rootScopeId = escapePerlSingleQuoted(rootScopeIdRaw)
|
|
236
|
+
|
|
237
|
+
// Build child-renderer registration for Perl.
|
|
238
|
+
const childRenderers = buildChildRenderers(childTemplates, ir)
|
|
239
|
+
|
|
240
|
+
const renderScript = `#!/usr/bin/env perl
|
|
241
|
+
use strict;
|
|
242
|
+
use warnings;
|
|
243
|
+
use utf8;
|
|
244
|
+
|
|
245
|
+
use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
|
|
246
|
+
use JSON::PP ();
|
|
247
|
+
use BarefootJS;
|
|
248
|
+
use BarefootJS::Backend::Xslate;
|
|
249
|
+
|
|
250
|
+
binmode(STDOUT, ':utf8');
|
|
251
|
+
|
|
252
|
+
# Single Text::Xslate (Kolon) backend over the temp template dir. The
|
|
253
|
+
# default-built instance registers the grep_filter / grep_every /
|
|
254
|
+
# grep_some functions the adapter emits for standalone
|
|
255
|
+
# .filter / .every / .some lowering.
|
|
256
|
+
my $backend = BarefootJS::Backend::Xslate->new(path => ['${tempDir}']);
|
|
257
|
+
my $bf = BarefootJS->new(undef, { backend => $backend });
|
|
258
|
+
# Honour an explicit __instanceId so shared-component fixtures match the
|
|
259
|
+
# scope ids Hono / Go emit; default to 'test'.
|
|
260
|
+
$bf->_scope_id('${rootScopeId}');
|
|
261
|
+
|
|
262
|
+
my $props = ${propsPerl};
|
|
263
|
+
|
|
264
|
+
${childRenderers}
|
|
265
|
+
|
|
266
|
+
my \$html = \$backend->render_named('${toSnakeCase(componentName)}', \$bf, \$props);
|
|
267
|
+
print \$html;
|
|
268
|
+
`
|
|
269
|
+
await Bun.write(resolve(tempDir, 'render.pl'), renderScript)
|
|
270
|
+
|
|
271
|
+
if (!await isXslateAvailable()) {
|
|
272
|
+
throw new XslateNotAvailableError('perl with Text::Xslate not found — skipping Xslate rendering')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const proc = Bun.spawn(['perl', 'render.pl'], {
|
|
276
|
+
cwd: tempDir,
|
|
277
|
+
stdout: 'pipe',
|
|
278
|
+
stderr: 'pipe',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const [stdout, stderr] = await Promise.all([
|
|
282
|
+
new Response(proc.stdout).text(),
|
|
283
|
+
new Response(proc.stderr).text(),
|
|
284
|
+
])
|
|
285
|
+
|
|
286
|
+
const exitCode = await proc.exited
|
|
287
|
+
if (exitCode !== 0) {
|
|
288
|
+
throw new Error(`perl render failed (exit ${exitCode}):\n${stderr}`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return stdout
|
|
292
|
+
} finally {
|
|
293
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Component names a component IR imports from sibling source files — i.e.
|
|
299
|
+
* non-type imports from relative (`./` / `../`) specifiers. Used to compute the
|
|
300
|
+
* transitive set of child components a fixture actually references (#checkbox).
|
|
301
|
+
* Mirrors the Go harness helper of the same name.
|
|
302
|
+
*/
|
|
303
|
+
function collectImportedComponentNames(ir: ComponentIR): string[] {
|
|
304
|
+
const names: string[] = []
|
|
305
|
+
for (const imp of ir.metadata.imports ?? []) {
|
|
306
|
+
if (imp.isTypeOnly) continue
|
|
307
|
+
if (!imp.source.startsWith('.')) continue
|
|
308
|
+
for (const spec of imp.specifiers ?? []) {
|
|
309
|
+
if (spec.isNamespace) continue
|
|
310
|
+
names.push(spec.alias ?? spec.name)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return names
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build Perl code that registers one child-component renderer per child
|
|
318
|
+
* template via the production `BarefootJS::register_child_renderer`.
|
|
319
|
+
*
|
|
320
|
+
* The closure mirrors the manifest-driven path in `BarefootJS.pm`: it
|
|
321
|
+
* derives the child scope id from `<parentScope>_<slotId>` (the parent's
|
|
322
|
+
* `$bf.render_child('<name>', { …, _bf_slot => '<slotId>' })` passes
|
|
323
|
+
* `_bf_slot`), seeds signal / memo / prop defaults from the child IR's
|
|
324
|
+
* `ssrDefaults`, shares the parent's script list, and renders the child
|
|
325
|
+
* `.tx` through the same backend. Loop children (no `_bf_slot`) fall back
|
|
326
|
+
* to `<snake_name>` like the Mojo harness.
|
|
327
|
+
*/
|
|
328
|
+
function buildChildRenderers(
|
|
329
|
+
childTemplates: Map<string, { template: string; ir: ComponentIR }>,
|
|
330
|
+
_parentIR: ComponentIR,
|
|
331
|
+
): string {
|
|
332
|
+
if (childTemplates.size === 0) return ''
|
|
333
|
+
|
|
334
|
+
const lines: string[] = []
|
|
335
|
+
lines.push(`# Register child component renderers`)
|
|
336
|
+
|
|
337
|
+
for (const [componentName, { ir: childIR }] of childTemplates) {
|
|
338
|
+
const snakeName = toSnakeCase(componentName)
|
|
339
|
+
// Statically-derived ssrDefaults the child template's vars seed from
|
|
340
|
+
// (prop defaults + signal / memo initial values), serialised to a
|
|
341
|
+
// Perl hashref literal.
|
|
342
|
+
const ssrDefaults = extractSsrDefaults(childIR.metadata) ?? {}
|
|
343
|
+
const defaultsPerl = ssrDefaultsToPerl(ssrDefaults)
|
|
344
|
+
const restPropsName = childIR.metadata.restPropsName
|
|
345
|
+
|
|
346
|
+
lines.push(`{`)
|
|
347
|
+
lines.push(` my $defaults = ${defaultsPerl};`)
|
|
348
|
+
lines.push(` $bf->register_child_renderer('${snakeName}', sub {`)
|
|
349
|
+
lines.push(` my ($child_props) = @_;`)
|
|
350
|
+
// A child that destructures a rest bag references `$<rest>` in its
|
|
351
|
+
// template; seed it with an empty hashref when the caller didn't pass
|
|
352
|
+
// one so Kolon's var lookup doesn't fault.
|
|
353
|
+
if (restPropsName) {
|
|
354
|
+
lines.push(` $child_props->{${restPropsName}} = {} unless defined $child_props->{${restPropsName}};`)
|
|
355
|
+
}
|
|
356
|
+
lines.push(` my $slot_id = delete $child_props->{_bf_slot};`)
|
|
357
|
+
lines.push(` my $child_bf = BarefootJS->new(undef, { backend => $backend });`)
|
|
358
|
+
lines.push(` $child_bf->_scope_id($slot_id ? '${rootChildScopePrefix(snakeName)}' . '_' . $slot_id : '${snakeName}_' . substr(rand() =~ s/^0\\.//r, 0, 6));`)
|
|
359
|
+
lines.push(` $child_bf->_is_child(1);`)
|
|
360
|
+
lines.push(` if ($slot_id) { $child_bf->_bf_parent('${rootChildScopePrefix(snakeName)}'); $child_bf->_bf_mount($slot_id); }`)
|
|
361
|
+
lines.push(` $child_bf->_scripts($bf->_scripts);`)
|
|
362
|
+
lines.push(` $child_bf->_script_seen($bf->_script_seen);`)
|
|
363
|
+
// Seed template vars: static ssrDefaults first, caller's props win.
|
|
364
|
+
lines.push(` my %vars = (%$defaults, %$child_props);`)
|
|
365
|
+
lines.push(` my $rendered = $backend->render_named('${snakeName}', $child_bf, \\%vars);`)
|
|
366
|
+
lines.push(` chomp $rendered;`)
|
|
367
|
+
lines.push(` return $rendered;`)
|
|
368
|
+
lines.push(` });`)
|
|
369
|
+
lines.push(`}`)
|
|
370
|
+
lines.push(``)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return lines.join('\n')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The parent scope prefix child scope ids derive from. The harness pins
|
|
378
|
+
* the root scope id to `test` (or `__instanceId`); children read the live
|
|
379
|
+
* parent scope at render time, but the test renderer doesn't have the
|
|
380
|
+
* parent `$bf` in lexical scope inside `buildChildRenderers`, so we use
|
|
381
|
+
* the same `$bf->_scope_id` value the script set. Emitted as the literal
|
|
382
|
+
* `$bf->_scope_id` lookup so an explicit `__instanceId` still flows
|
|
383
|
+
* through.
|
|
384
|
+
*/
|
|
385
|
+
function rootChildScopePrefix(_snakeName: string): string {
|
|
386
|
+
// Resolve the parent scope dynamically in Perl rather than baking the
|
|
387
|
+
// literal here — keeps the child id correct under an explicit
|
|
388
|
+
// __instanceId. The single quotes in the caller string-concat are
|
|
389
|
+
// closed around this Perl fragment.
|
|
390
|
+
return `' . $bf->_scope_id . '`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Serialise an ssrDefaults map to a Perl hashref literal. */
|
|
394
|
+
function ssrDefaultsToPerl(defaults: Record<string, unknown>): string {
|
|
395
|
+
const entries: string[] = []
|
|
396
|
+
for (const [name, d] of Object.entries(defaults)) {
|
|
397
|
+
// ssrDefaults entries are `{ value, propName?, isRestProps? }` or a
|
|
398
|
+
// bare value. The child renderer's caller props win, so we only need
|
|
399
|
+
// the static fallback `value` here.
|
|
400
|
+
let value: unknown = d
|
|
401
|
+
if (d && typeof d === 'object' && 'value' in (d as Record<string, unknown>)) {
|
|
402
|
+
value = (d as Record<string, unknown>).value
|
|
403
|
+
}
|
|
404
|
+
entries.push(`${perlSingleQuote(name)} => ${toPerlLiteral(value)}`)
|
|
405
|
+
}
|
|
406
|
+
return `{${entries.join(', ')}}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Convert PascalCase to snake_case for template naming (matches the
|
|
411
|
+
* adapter's `toTemplateName`).
|
|
412
|
+
*/
|
|
413
|
+
function toSnakeCase(name: string): string {
|
|
414
|
+
return name
|
|
415
|
+
.replace(/([A-Z])/g, '_$1')
|
|
416
|
+
.toLowerCase()
|
|
417
|
+
.replace(/^_/, '')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Build a Perl hash literal from props (+ signal / memo seeds).
|
|
422
|
+
*/
|
|
423
|
+
function buildPerlProps(
|
|
424
|
+
_componentName: string,
|
|
425
|
+
props: Record<string, unknown> | undefined,
|
|
426
|
+
ir: ComponentIR,
|
|
427
|
+
): string {
|
|
428
|
+
const entries: string[] = []
|
|
429
|
+
|
|
430
|
+
const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test'
|
|
431
|
+
entries.push(`scope_id => '${escapePerlSingleQuoted(explicitScope)}'`)
|
|
432
|
+
|
|
433
|
+
// Prop params with defaults (before signals, so signals can reference them).
|
|
434
|
+
for (const param of ir.metadata.propsParams) {
|
|
435
|
+
if (props && param.name in props) continue
|
|
436
|
+
if (param.defaultValue) {
|
|
437
|
+
const perlValue = jsToPerlValue(param.defaultValue)
|
|
438
|
+
if (perlValue !== null) {
|
|
439
|
+
entries.push(`${param.name} => ${perlValue}`)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// No default + no caller value: pass `undef` so Kolon's var lookup
|
|
444
|
+
// for an optional prop doesn't fault before its falsy branch elides.
|
|
445
|
+
entries.push(`${param.name} => undef`)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Route undeclared props into the rest bag (`spread_attrs($<rest>)`).
|
|
449
|
+
const restPropsName = ir.metadata.restPropsName
|
|
450
|
+
const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
|
|
451
|
+
const restBagEntries: Array<[string, unknown]> = []
|
|
452
|
+
if (restPropsName && props) {
|
|
453
|
+
for (const [key, value] of Object.entries(props)) {
|
|
454
|
+
if (key.startsWith('__')) continue
|
|
455
|
+
if (key === restPropsName || declaredParams.has(key)) continue
|
|
456
|
+
restBagEntries.push([key, value])
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const routedKeys = new Set(restBagEntries.map(([k]) => k))
|
|
460
|
+
|
|
461
|
+
if (restPropsName && !(props && restPropsName in props)) {
|
|
462
|
+
entries.push(`${restPropsName} => ${toPerlLiteral(Object.fromEntries(restBagEntries))}`)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// User props.
|
|
466
|
+
if (props) {
|
|
467
|
+
for (const [key, value] of Object.entries(props)) {
|
|
468
|
+
if (key.startsWith('__')) continue
|
|
469
|
+
if (routedKeys.has(key)) continue
|
|
470
|
+
if (typeof value === 'string') {
|
|
471
|
+
entries.push(`${key} => '${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
|
|
472
|
+
} else if (typeof value === 'number') {
|
|
473
|
+
entries.push(`${key} => ${value}`)
|
|
474
|
+
} else if (typeof value === 'boolean') {
|
|
475
|
+
// JSON::PP boolean sentinels so BarefootJS::spread_attrs can
|
|
476
|
+
// detect them via `ref() eq 'JSON::PP::Boolean'`.
|
|
477
|
+
entries.push(`${key} => ${value ? 'JSON::PP::true' : 'JSON::PP::false'}`)
|
|
478
|
+
} else if (Array.isArray(value) || (value && typeof value === 'object')) {
|
|
479
|
+
entries.push(`${key} => ${toPerlLiteral(value)}`)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Signal values evaluated from props (after user props).
|
|
485
|
+
for (const signal of ir.metadata.signals) {
|
|
486
|
+
const value = evaluateSignalInit(signal.initialValue.trim(), props)
|
|
487
|
+
if (value !== null) {
|
|
488
|
+
entries.push(`${signal.getter} => ${toPerlLiteral(value)}`)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Memo values seeded from the statically-evaluated ssrDefaults, same
|
|
493
|
+
// as the production plugin's before_render hook.
|
|
494
|
+
const ssrDefaults = extractSsrDefaults(ir.metadata) ?? {}
|
|
495
|
+
for (const memo of ir.metadata.memos) {
|
|
496
|
+
const entry = ssrDefaults[memo.name]
|
|
497
|
+
const value = entry && typeof entry === 'object' && 'value' in entry ? entry.value : 0
|
|
498
|
+
entries.push(`${memo.name} => ${toPerlLiteral(value ?? 0)}`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return `{${entries.join(', ')}}`
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Evaluate a signal initializer expression using provided props.
|
|
506
|
+
* Handles: props.initial ?? 0, props.value, literal values.
|
|
507
|
+
*/
|
|
508
|
+
export function evaluateSignalInit(
|
|
509
|
+
expr: string,
|
|
510
|
+
props?: Record<string, unknown>,
|
|
511
|
+
): unknown {
|
|
512
|
+
const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
|
|
513
|
+
if (nullishMatch) {
|
|
514
|
+
const propName = nullishMatch[1]
|
|
515
|
+
const defaultExpr = nullishMatch[2].trim()
|
|
516
|
+
if (props && propName in props) return props[propName]
|
|
517
|
+
return parseLiteral(defaultExpr)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const propsMatch = expr.match(/^props\.(\w+)$/)
|
|
521
|
+
if (propsMatch) {
|
|
522
|
+
if (props && propsMatch[1] in props) return props[propsMatch[1]]
|
|
523
|
+
return null
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return parseLiteral(expr)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function parseLiteral(expr: string): unknown {
|
|
530
|
+
if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
|
|
531
|
+
if (expr === 'true') return true
|
|
532
|
+
if (expr === 'false') return false
|
|
533
|
+
if (expr === '[]') return []
|
|
534
|
+
|
|
535
|
+
{
|
|
536
|
+
const t = expr.trim()
|
|
537
|
+
if (t.startsWith('[') && t.endsWith(']')) {
|
|
538
|
+
const inner = t.slice(1, -1).trim()
|
|
539
|
+
if (!inner) return []
|
|
540
|
+
const out: unknown[] = []
|
|
541
|
+
for (const seg of splitTopLevelCommas(inner)) {
|
|
542
|
+
if (!seg.trim()) continue
|
|
543
|
+
const parsed = parseLiteral(seg.trim())
|
|
544
|
+
if (parsed === null && seg.trim() !== 'null') return null
|
|
545
|
+
out.push(parsed)
|
|
546
|
+
}
|
|
547
|
+
return out
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const stringMatch = expr.match(/^(['"])(.*)\1$/s)
|
|
552
|
+
if (stringMatch) return unescapeJsString(stringMatch[2])
|
|
553
|
+
|
|
554
|
+
const trimmed = expr.trim()
|
|
555
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
556
|
+
const inner = trimmed.slice(1, -1).trim()
|
|
557
|
+
if (!inner) return {}
|
|
558
|
+
const obj: Record<string, unknown> = {}
|
|
559
|
+
for (const pair of splitTopLevelCommas(inner)) {
|
|
560
|
+
if (!pair.trim()) continue
|
|
561
|
+
const colonIdx = pair.indexOf(':')
|
|
562
|
+
if (colonIdx < 0) return null
|
|
563
|
+
let key = pair.slice(0, colonIdx).trim()
|
|
564
|
+
const val = pair.slice(colonIdx + 1).trim()
|
|
565
|
+
const keyMatch = key.match(/^(['"])(.*)\1$/s)
|
|
566
|
+
if (keyMatch) key = unescapeJsString(keyMatch[2])
|
|
567
|
+
const parsedVal = parseLiteral(val)
|
|
568
|
+
if (parsedVal === null && val !== 'null') return null
|
|
569
|
+
obj[key] = parsedVal
|
|
570
|
+
}
|
|
571
|
+
return obj
|
|
572
|
+
}
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function splitTopLevelCommas(inner: string): string[] {
|
|
577
|
+
const segments: string[] = []
|
|
578
|
+
let depth = 0
|
|
579
|
+
let start = 0
|
|
580
|
+
let quote: string | null = null
|
|
581
|
+
for (let i = 0; i < inner.length; i++) {
|
|
582
|
+
const c = inner[i]
|
|
583
|
+
if (quote) {
|
|
584
|
+
if (c === quote) {
|
|
585
|
+
let backslashes = 0
|
|
586
|
+
for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
|
|
587
|
+
if (backslashes % 2 === 0) quote = null
|
|
588
|
+
}
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if (c === '"' || c === "'") {
|
|
592
|
+
quote = c
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
if (c === '{' || c === '[') depth++
|
|
596
|
+
else if (c === '}' || c === ']') depth--
|
|
597
|
+
else if (c === ',' && depth === 0) {
|
|
598
|
+
segments.push(inner.slice(start, i))
|
|
599
|
+
start = i + 1
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
segments.push(inner.slice(start))
|
|
603
|
+
return segments
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function unescapeJsString(s: string): string {
|
|
607
|
+
return s.replace(/\\(.)/g, (_, c) => {
|
|
608
|
+
switch (c) {
|
|
609
|
+
case 'n': return '\n'
|
|
610
|
+
case 'r': return '\r'
|
|
611
|
+
case 't': return '\t'
|
|
612
|
+
case '0': return '\0'
|
|
613
|
+
default: return c
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Perl single-quoted string escape: `'` AND `\` need escaping. */
|
|
619
|
+
function perlSingleQuote(s: string): string {
|
|
620
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function toPerlLiteral(value: unknown): string {
|
|
624
|
+
if (typeof value === 'string') return perlSingleQuote(value)
|
|
625
|
+
if (typeof value === 'number') return String(value)
|
|
626
|
+
// JS booleans → JSON::PP sentinels so `spread_attrs` can detect them
|
|
627
|
+
// via `ref()` and apply boolean-attr semantics.
|
|
628
|
+
if (typeof value === 'boolean') return value ? 'JSON::PP::true' : 'JSON::PP::false'
|
|
629
|
+
if (Array.isArray(value)) {
|
|
630
|
+
return `[${value.map(toPerlLiteral).join(', ')}]`
|
|
631
|
+
}
|
|
632
|
+
if (value && typeof value === 'object') {
|
|
633
|
+
const entries: string[] = []
|
|
634
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
635
|
+
entries.push(`${perlSingleQuote(key)} => ${toPerlLiteral(v)}`)
|
|
636
|
+
}
|
|
637
|
+
return `{${entries.join(', ')}}`
|
|
638
|
+
}
|
|
639
|
+
return 'undef'
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Convert a JS literal value to a Perl literal.
|
|
644
|
+
* Handles: numbers, strings, booleans, empty arrays, props.xxx ?? default.
|
|
645
|
+
*/
|
|
646
|
+
function jsToPerlValue(jsValue: string): string | null {
|
|
647
|
+
const v = jsValue.trim()
|
|
648
|
+
|
|
649
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return v
|
|
650
|
+
if (/^['"].*['"]$/.test(v)) return v
|
|
651
|
+
if (v === 'true') return '1'
|
|
652
|
+
if (v === 'false') return '0'
|
|
653
|
+
if (v === '[]') return '[]'
|
|
654
|
+
|
|
655
|
+
const nullishMatch = v.match(/\?\?\s*(.+)$/)
|
|
656
|
+
if (nullishMatch) return jsToPerlValue(nullishMatch[1])
|
|
657
|
+
|
|
658
|
+
if (v.startsWith('props.')) return 'undef'
|
|
659
|
+
|
|
660
|
+
return null
|
|
661
|
+
}
|