@barefootjs/mojolicious 0.6.0 → 0.7.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/index.js +93 -0
- package/dist/adapter/mojo-adapter.d.ts +37 -0
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +93 -0
- package/dist/index.js +93 -0
- package/dist/test-render.d.ts.map +1 -1
- package/lib/BarefootJS/Backend/Mojo.pm +91 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +1 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +56 -0
- package/package.json +7 -6
- package/src/__tests__/mojo-adapter.test.ts +148 -16
- package/src/adapter/mojo-adapter.ts +191 -2
- package/src/test-render.ts +30 -3
- package/lib/BarefootJS.pm +0 -938
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
package Mojolicious::Plugin::BarefootJS;
|
|
2
|
+
our $VERSION = "0.01";
|
|
2
3
|
use Mojo::Base 'Mojolicious::Plugin', -signatures;
|
|
3
4
|
|
|
4
5
|
use Mojo::File qw(path);
|
|
@@ -102,3 +103,58 @@ sub _load_manifest ($app, $config) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
1;
|
|
106
|
+
__END__
|
|
107
|
+
|
|
108
|
+
=encoding utf8
|
|
109
|
+
|
|
110
|
+
=head1 NAME
|
|
111
|
+
|
|
112
|
+
Mojolicious::Plugin::BarefootJS - Mojolicious integration for BarefootJS
|
|
113
|
+
|
|
114
|
+
=head1 SYNOPSIS
|
|
115
|
+
|
|
116
|
+
# Mojolicious application
|
|
117
|
+
$self->plugin('BarefootJS');
|
|
118
|
+
|
|
119
|
+
# In a controller / template, the `bf` helper exposes a per-request
|
|
120
|
+
# BarefootJS runtime backed by BarefootJS::Backend::Mojo.
|
|
121
|
+
|
|
122
|
+
=head1 DESCRIPTION
|
|
123
|
+
|
|
124
|
+
Wires the L<BarefootJS> server runtime into L<Mojolicious>. It registers a
|
|
125
|
+
C<bf> controller helper that lazily instantiates one BarefootJS object per
|
|
126
|
+
request (rendering via L<BarefootJS::Backend::Mojo>), and supports rendering
|
|
127
|
+
compiled marked templates as Mojolicious templates.
|
|
128
|
+
|
|
129
|
+
For non-Mojolicious / PSGI hosts, see L<BarefootJS::Backend::Xslate>, which
|
|
130
|
+
drives the same runtime with Text::Xslate and no web framework.
|
|
131
|
+
|
|
132
|
+
=head1 METHODS
|
|
133
|
+
|
|
134
|
+
L<Mojolicious::Plugin::BarefootJS> inherits all methods from
|
|
135
|
+
L<Mojolicious::Plugin> and implements the following new one.
|
|
136
|
+
|
|
137
|
+
=head2 register
|
|
138
|
+
|
|
139
|
+
$plugin->register(Mojolicious->new, \%conf);
|
|
140
|
+
|
|
141
|
+
Registers the plugin (the C<bf> helper and supporting hooks) in a Mojolicious
|
|
142
|
+
application.
|
|
143
|
+
|
|
144
|
+
=head1 SEE ALSO
|
|
145
|
+
|
|
146
|
+
L<BarefootJS>, L<BarefootJS::Backend::Mojo>, L<BarefootJS::Backend::Xslate>,
|
|
147
|
+
L<Mojolicious>, L<https://github.com/piconic-ai/barefootjs>
|
|
148
|
+
|
|
149
|
+
=head1 AUTHOR
|
|
150
|
+
|
|
151
|
+
kobaken E<lt>kentafly88@gmail.comE<gt>
|
|
152
|
+
|
|
153
|
+
=head1 LICENSE
|
|
154
|
+
|
|
155
|
+
Copyright (c) 2025-present BarefootJS Contributors.
|
|
156
|
+
|
|
157
|
+
This library is free software; you can redistribute it and/or modify it under
|
|
158
|
+
the MIT License. See the F<LICENSE> file in the distribution for the full text.
|
|
159
|
+
|
|
160
|
+
=cut
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/mojolicious",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "bun run build:js && bun run build:types",
|
|
32
|
-
"build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
|
|
32
|
+
"build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared --external typescript",
|
|
33
33
|
"build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
|
|
34
34
|
"test": "bun test",
|
|
35
35
|
"clean": "rm -rf dist",
|
|
@@ -52,14 +52,15 @@
|
|
|
52
52
|
"directory": "packages/adapter-mojolicious"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@barefootjs/
|
|
55
|
+
"@barefootjs/perl": "0.7.0",
|
|
56
|
+
"@barefootjs/shared": "0.7.0"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@barefootjs/jsx": ">=0.2.0"
|
|
59
|
+
"@barefootjs/jsx": ">=0.2.0",
|
|
60
|
+
"typescript": "^5.0.0"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
61
63
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
62
|
-
"@barefootjs/jsx": "0.
|
|
63
|
-
"typescript": "^5.0.0"
|
|
64
|
+
"@barefootjs/jsx": "0.7.0"
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -69,16 +69,17 @@ runAdapterConformanceTests({
|
|
|
69
69
|
'toggle-shared',
|
|
70
70
|
'reactive-props',
|
|
71
71
|
'props-reactivity-comparison',
|
|
72
|
-
// #1467 Phase
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'
|
|
72
|
+
// #1467 Phase 2b: basic interactive `site/ui` primitives. Cross-adapter
|
|
73
|
+
// parity for the `site/ui` corpus is Phase 3, so these participate only
|
|
74
|
+
// in Hono SSR conformance + the fixture-hydrate runtime layer for now.
|
|
75
|
+
// (`label` and `kbd` are static helpers; `toggle` / `switch` /
|
|
76
|
+
// `checkbox` carry uncontrolled state; `input` / `textarea` are
|
|
77
|
+
// pass-through native controls.)
|
|
78
|
+
'toggle',
|
|
79
|
+
'switch',
|
|
80
|
+
'checkbox',
|
|
81
|
+
'textarea',
|
|
82
|
+
'kbd',
|
|
82
83
|
],
|
|
83
84
|
// Per-fixture build-time contracts for shapes the Mojo adapter
|
|
84
85
|
// intentionally refuses to lower. Owned by this adapter test file
|
|
@@ -256,6 +257,42 @@ export function Hello() {
|
|
|
256
257
|
expect(adapter.extension).toBe('.html.ep')
|
|
257
258
|
})
|
|
258
259
|
|
|
260
|
+
test('module pure-string const referenced in className inlines the literal (#1467 Phase 2b)', () => {
|
|
261
|
+
// A module-scope `const X = 'literal'` used inside a className template
|
|
262
|
+
// literal must inline its value, NOT emit `$X` against a stash variable
|
|
263
|
+
// that is never bound (the value would render empty). Hono inlines it at
|
|
264
|
+
// runtime; this restores byte-parity.
|
|
265
|
+
const result = compileAndGenerate(`
|
|
266
|
+
"use client"
|
|
267
|
+
const labelClasses = 'flex items-center group-data-[disabled=true]:opacity-50'
|
|
268
|
+
export function Label({ className = '' }: { className?: string }) {
|
|
269
|
+
return <label className={\`\${labelClasses} \${className}\`} />
|
|
270
|
+
}
|
|
271
|
+
`)
|
|
272
|
+
// Inlined as a Perl single-quoted literal, escaped tokens intact.
|
|
273
|
+
expect(result.template).toContain(
|
|
274
|
+
"'flex items-center group-data-[disabled=true]:opacity-50'",
|
|
275
|
+
)
|
|
276
|
+
// No stash-variable reference to the const.
|
|
277
|
+
expect(result.template).not.toContain('$labelClasses')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('module pure-string const is NOT inlined when shadowed by a loop variable (#1749 review)', () => {
|
|
281
|
+
// A loop param whose name matches a module const must keep its loop
|
|
282
|
+
// binding (`$label`) inside the body — the const literal must not leak
|
|
283
|
+
// in. `renderLoop` guards module-const inlining for the loop body.
|
|
284
|
+
const result = compileAndGenerate(`
|
|
285
|
+
"use client"
|
|
286
|
+
const label = 'MODULE_CONST'
|
|
287
|
+
export function List({ items }: { items: string[] }) {
|
|
288
|
+
return <ul>{items.map(label => <li>{label}</li>)}</ul>
|
|
289
|
+
}
|
|
290
|
+
`)
|
|
291
|
+
// Inside the loop the param wins — emit the loop variable, not the const.
|
|
292
|
+
expect(result.template).toContain('$label')
|
|
293
|
+
expect(result.template).not.toContain('MODULE_CONST')
|
|
294
|
+
})
|
|
295
|
+
|
|
259
296
|
test('generates conditional with Perl if/else', () => {
|
|
260
297
|
const result = compileAndGenerate(`
|
|
261
298
|
"use client"
|
|
@@ -395,9 +432,16 @@ export function C() {
|
|
|
395
432
|
// The nested-higher-order-in-filter-predicate shape also lowers
|
|
396
433
|
// now (#1443 PR4) — moved to a positive-output test below.
|
|
397
434
|
const cases: { name: string; body: string; needle: string }[] = [
|
|
398
|
-
|
|
435
|
+
// The arithmetic-fold `.reduce(fn, init)` catalogue now lowers
|
|
436
|
+
// (positive-output tests below + the reduce-* conformance
|
|
437
|
+
// fixtures); the no-init form stays refused — JS throws on an
|
|
438
|
+
// empty array there, which a template can't mirror.
|
|
439
|
+
{ name: 'reduce (no init)', body: `<div>{items().reduce((s, x) => s + x)}</div>`, needle: '.reduce(' },
|
|
399
440
|
{ name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
|
|
400
|
-
|
|
441
|
+
// Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C) —
|
|
442
|
+
// even as a loop base — so those moved to positive tests below. A
|
|
443
|
+
// tuple with a non-leaf element (a string literal) stays refused.
|
|
444
|
+
{ name: 'flatMap (literal element)', body: `<div>{items().flatMap(x => [x.tag, "x"])}</div>`, needle: '.flatMap(' },
|
|
401
445
|
]
|
|
402
446
|
|
|
403
447
|
for (const { name, body, needle } of cases) {
|
|
@@ -960,6 +1004,12 @@ import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures
|
|
|
960
1004
|
import { fixture as arrayEntriesFixture } from '../../../adapter-tests/fixtures/methods/array-entries'
|
|
961
1005
|
import { fixture as arrayKeysFixture } from '../../../adapter-tests/fixtures/methods/array-keys'
|
|
962
1006
|
import { fixture as arrayValuesFixture } from '../../../adapter-tests/fixtures/methods/array-values'
|
|
1007
|
+
// #1448 Tier C — .reduce(fn, init) arithmetic-fold catalogue.
|
|
1008
|
+
import { fixture as reduceSumFieldFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-field'
|
|
1009
|
+
import { fixture as reduceSumSelfFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-self'
|
|
1010
|
+
import { fixture as reduceConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-concat'
|
|
1011
|
+
import { fixture as reduceProductFixture } from '../../../adapter-tests/fixtures/methods/reduce-product'
|
|
1012
|
+
import { fixture as reduceRightConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-right-concat'
|
|
963
1013
|
|
|
964
1014
|
describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
|
|
965
1015
|
const cases = [
|
|
@@ -1020,6 +1070,17 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
|
|
|
1020
1070
|
{ fixture: arrayKeysFixture, expect: '% for my $k (0..$#{$items})' },
|
|
1021
1071
|
// .values() → standard for loop (same as plain .map())
|
|
1022
1072
|
{ fixture: arrayValuesFixture, expect: '% my $v = $items->[$_i];' },
|
|
1073
|
+
// #1448 Tier C — .reduce(fn, init) arithmetic fold. The structured
|
|
1074
|
+
// ReduceOp lowers to a single `bf->reduce(...)` call with op /
|
|
1075
|
+
// key / type / init / direction in the options hash. Each shape
|
|
1076
|
+
// exercises one arm: field-numeric sum, self-numeric sum,
|
|
1077
|
+
// string-concat fold, the product (`*`) operator, and the
|
|
1078
|
+
// right-to-left `direction` of reduceRight.
|
|
1079
|
+
{ fixture: reduceSumFieldFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'duration', type => 'numeric', init => 0, direction => 'left' })` },
|
|
1080
|
+
{ fixture: reduceSumSelfFixture, expect: `bf->reduce($nums, { op => '+', key_kind => 'self', type => 'numeric', init => 0, direction => 'left' })` },
|
|
1081
|
+
{ fixture: reduceConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'left' })` },
|
|
1082
|
+
{ fixture: reduceProductFixture, expect: `bf->reduce($items, { op => '*', key_kind => 'field', key => 'qty', type => 'numeric', init => 1, direction => 'left' })` },
|
|
1083
|
+
{ fixture: reduceRightConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'right' })` },
|
|
1023
1084
|
]
|
|
1024
1085
|
|
|
1025
1086
|
for (const { fixture, expect: expectedHelper } of cases) {
|
|
@@ -1041,6 +1102,73 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
|
|
|
1041
1102
|
}
|
|
1042
1103
|
})
|
|
1043
1104
|
|
|
1105
|
+
describe('MojoAdapter - #1448 Tier C .flat(depth?)', () => {
|
|
1106
|
+
function emitFlat(expr: string): string {
|
|
1107
|
+
const a = new MojoAdapter()
|
|
1108
|
+
const ir = compileToIR(`
|
|
1109
|
+
function C({ rows }: { rows: number[][] }) {
|
|
1110
|
+
return <div>{${expr}}</div>
|
|
1111
|
+
}
|
|
1112
|
+
export { C }
|
|
1113
|
+
`, a)
|
|
1114
|
+
return a.generate(ir).template ?? ''
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
test('.flat() emits bf->flat with default depth 1', () => {
|
|
1118
|
+
expect(emitFlat('rows.flat()')).toContain('bf->flat($rows, 1)')
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
test('.flat(2) emits the explicit depth', () => {
|
|
1122
|
+
expect(emitFlat('rows.flat(2)')).toContain('bf->flat($rows, 2)')
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('.flat(Infinity) emits the -1 full-depth sentinel', () => {
|
|
1126
|
+
expect(emitFlat('rows.flat(Infinity)')).toContain('bf->flat($rows, -1)')
|
|
1127
|
+
})
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
|
|
1131
|
+
function emitFlatMap(expr: string): string {
|
|
1132
|
+
const a = new MojoAdapter()
|
|
1133
|
+
const ir = compileToIR(`
|
|
1134
|
+
function C({ rows }: { rows: { a: string; b: string; tags: string[] }[] }) {
|
|
1135
|
+
return <div>{${expr}}</div>
|
|
1136
|
+
}
|
|
1137
|
+
export { C }
|
|
1138
|
+
`, a)
|
|
1139
|
+
return a.generate(ir).template ?? ''
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
test('.flatMap(i => i.field) emits bf->flat_map with the raw field key', () => {
|
|
1143
|
+
expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain(`bf->flat_map($rows, 'field', 'tags')`)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
test('.flatMap(i => i) emits the self projection', () => {
|
|
1147
|
+
expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain(`bf->flat_map($rows, 'self', '')`)
|
|
1148
|
+
})
|
|
1149
|
+
|
|
1150
|
+
test('.flatMap(i => [i.a, i.b]) emits bf->flat_map_tuple with leaf specs', () => {
|
|
1151
|
+
expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['field', 'a'], ['field', 'b'])`)
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
test('tuple self + field leaves', () => {
|
|
1155
|
+
expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['self', ''], ['field', 'a'])`)
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
test('field-projection flatMap as a loop base lowers (no BF101)', () => {
|
|
1159
|
+
const a = new MojoAdapter()
|
|
1160
|
+
const ir = compileToIR(`'use client'
|
|
1161
|
+
import { createSignal } from '@barefootjs/client'
|
|
1162
|
+
export function C() {
|
|
1163
|
+
const [items] = createSignal<{ tags: string[] }[]>([])
|
|
1164
|
+
return <ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>
|
|
1165
|
+
}`, a)
|
|
1166
|
+
const template = a.generate(ir).template ?? ''
|
|
1167
|
+
expect((a.errors ?? []).filter(e => e.code === 'BF101')).toEqual([])
|
|
1168
|
+
expect(template).toContain(`bf->flat_map($items, 'field', 'tags')`)
|
|
1169
|
+
})
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1044
1172
|
// =============================================================================
|
|
1045
1173
|
// #1448 — `/* @client */` escape hatch for STILL-UNSUPPORTED methods
|
|
1046
1174
|
// =============================================================================
|
|
@@ -1099,10 +1227,14 @@ export function C() {
|
|
|
1099
1227
|
// Perl fragment that must NOT survive into the template (the pre-fix
|
|
1100
1228
|
// silent-footgun output for the string rows).
|
|
1101
1229
|
const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
|
|
1102
|
-
// Tier C array methods.
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1230
|
+
// Tier C array methods. The arithmetic-fold `.reduce(fn, init)`
|
|
1231
|
+
// catalogue now lowers (pinned in the positive reduce-* fixtures);
|
|
1232
|
+
// the no-initial-value form stays refused — JS throws on an empty
|
|
1233
|
+
// array there, which a template can't mirror.
|
|
1234
|
+
{ name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '->{reduce}' },
|
|
1235
|
+
// Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
|
|
1236
|
+
// tuple with a non-leaf element (here a string literal) stays refused.
|
|
1237
|
+
{ name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '->{flatMap}' },
|
|
1106
1238
|
// Lowered methods whose MEANINGFUL extra argument isn't lowered yet
|
|
1107
1239
|
// (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
|
|
1108
1240
|
// and the variadic `.concat`. The parser refuses these (silently
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Generates Mojolicious EP template files (.html.ep) from BarefootJS IR.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import ts from 'typescript'
|
|
7
8
|
import type {
|
|
8
9
|
ComponentIR,
|
|
10
|
+
IRMetadata,
|
|
9
11
|
IRNode,
|
|
10
12
|
IRElement,
|
|
11
13
|
IRText,
|
|
@@ -56,7 +58,7 @@ import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
|
|
|
56
58
|
* the `IRNodeEmitter` interface.
|
|
57
59
|
*/
|
|
58
60
|
type MojoRenderCtx = Record<string, never>
|
|
59
|
-
import type { ParsedExpr, ParsedStatement, SortComparator, TemplatePart } from '@barefootjs/jsx'
|
|
61
|
+
import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
|
|
60
62
|
import { BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
61
63
|
|
|
62
64
|
interface PrimitiveSpec {
|
|
@@ -117,6 +119,32 @@ export interface MojoAdapterOptions {
|
|
|
117
119
|
barefootJsPath?: string
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Parse a const initializer's source text. Returns the unescaped string
|
|
124
|
+
* value when the whole initializer is a single string literal (or a
|
|
125
|
+
* no-substitution template literal), else `null`. Uses the TS parser so
|
|
126
|
+
* escapes/quotes resolve exactly as JS would, matching the value the Hono
|
|
127
|
+
* reference inlines at runtime.
|
|
128
|
+
*/
|
|
129
|
+
function parsePureStringLiteral(source: string): string | null {
|
|
130
|
+
const sf = ts.createSourceFile(
|
|
131
|
+
'__const.ts',
|
|
132
|
+
`const __x = (${source});`,
|
|
133
|
+
ts.ScriptTarget.Latest,
|
|
134
|
+
/*setParentNodes*/ false,
|
|
135
|
+
)
|
|
136
|
+
const stmt = sf.statements[0]
|
|
137
|
+
if (!stmt || !ts.isVariableStatement(stmt)) return null
|
|
138
|
+
const decl = stmt.declarationList.declarations[0]
|
|
139
|
+
let init = decl?.initializer
|
|
140
|
+
while (init && ts.isParenthesizedExpression(init)) init = init.expression
|
|
141
|
+
if (!init) return null
|
|
142
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
143
|
+
return init.text
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
|
|
121
149
|
name = 'mojolicious'
|
|
122
150
|
extension = '.html.ep'
|
|
@@ -159,6 +187,26 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
159
187
|
* true — selecting the string operator from the operand's type avoids that.
|
|
160
188
|
*/
|
|
161
189
|
private stringValueNames: Set<string> = new Set()
|
|
190
|
+
/**
|
|
191
|
+
* Module-scope pure string-literal constants (`const X = 'literal'` at
|
|
192
|
+
* file top-level), keyed by name → resolved literal value. Populated at
|
|
193
|
+
* `generate()` entry from `ir.metadata.localConstants`. When an identifier
|
|
194
|
+
* in an expression resolves to one of these, the adapter inlines the
|
|
195
|
+
* literal instead of emitting `$X` against a stash variable that is never
|
|
196
|
+
* bound (a module const isn't a prop, signal, or local — the value would
|
|
197
|
+
* render empty). Hono inlines it for free; this restores parity. Only
|
|
198
|
+
* module-scope pure string literals qualify (see `collectModuleStringConsts`).
|
|
199
|
+
*/
|
|
200
|
+
private moduleStringConsts: Map<string, string> = new Map()
|
|
201
|
+
/**
|
|
202
|
+
* Names currently bound by an enclosing loop body — the `my $<param>` and
|
|
203
|
+
* `my $<index>` bindings `renderLoop` introduces — ref-counted so nested
|
|
204
|
+
* loops compose. `resolveModuleStringConst` consults this so a loop
|
|
205
|
+
* variable whose name happens to match a module string const is NOT
|
|
206
|
+
* inlined as the const literal (mirrors the Go adapter's loop-param /
|
|
207
|
+
* loop-var shadowing guards). (#1749 review)
|
|
208
|
+
*/
|
|
209
|
+
private loopBoundNames: Map<string, number> = new Map()
|
|
162
210
|
|
|
163
211
|
constructor(options: MojoAdapterOptions = {}) {
|
|
164
212
|
super()
|
|
@@ -186,6 +234,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
186
234
|
for (const p of ir.metadata.propsParams) {
|
|
187
235
|
if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
|
|
188
236
|
}
|
|
237
|
+
this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
|
|
238
|
+
this.loopBoundNames.clear()
|
|
189
239
|
this.errors = []
|
|
190
240
|
this.childrenCaptureCounter = 0
|
|
191
241
|
|
|
@@ -238,6 +288,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
238
288
|
}
|
|
239
289
|
}
|
|
240
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Build the module pure-string-const map from the IR's localConstants.
|
|
293
|
+
* A const qualifies only when it is module-scope (`isModule`) and its
|
|
294
|
+
* initializer parses to a single string literal (`ts.StringLiteral` or
|
|
295
|
+
* `ts.NoSubstitutionTemplateLiteral`). Template literals with `${}`,
|
|
296
|
+
* numeric/object initializers, `Record<T,string>` maps, memos, and
|
|
297
|
+
* signals are all excluded — only a pure compile-time string can be
|
|
298
|
+
* inlined byte-for-byte.
|
|
299
|
+
*/
|
|
300
|
+
private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
|
|
301
|
+
const map = new Map<string, string>()
|
|
302
|
+
for (const c of constants ?? []) {
|
|
303
|
+
if (!c.isModule) continue
|
|
304
|
+
if (c.value === undefined) continue
|
|
305
|
+
const literal = parsePureStringLiteral(c.value)
|
|
306
|
+
if (literal !== null) map.set(c.name, literal)
|
|
307
|
+
}
|
|
308
|
+
return map
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve an identifier to its inlined Perl single-quoted string literal
|
|
313
|
+
* when it names a module pure-string const, else `null` (the caller then
|
|
314
|
+
* falls back to its normal `$name` stash lowering). Returns the Perl
|
|
315
|
+
* literal form `'<escaped>'` ready to drop into an expression.
|
|
316
|
+
*/
|
|
317
|
+
resolveModuleStringConst(name: string): string | null {
|
|
318
|
+
// A loop body introduces `my $<param>` / `my $<index>` bindings that
|
|
319
|
+
// shadow a module const of the same name — never inline inside one.
|
|
320
|
+
if (this.loopBoundNames.has(name)) return null
|
|
321
|
+
const value = this.moduleStringConsts.get(name)
|
|
322
|
+
if (value === undefined) return null
|
|
323
|
+
return `'${value.replace(/[\\']/g, m => `\\${m}`)}'`
|
|
324
|
+
}
|
|
325
|
+
|
|
241
326
|
// ===========================================================================
|
|
242
327
|
// Script Registration
|
|
243
328
|
// ===========================================================================
|
|
@@ -587,6 +672,16 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
587
672
|
const indexVar = loop.iterationShape === 'keys'
|
|
588
673
|
? `$${param}`
|
|
589
674
|
: loop.index ? `$${loop.index}` : '$_i'
|
|
675
|
+
// Names this loop binds in body scope. Guard module-const inlining for
|
|
676
|
+
// the whole body (children + key + filter) so a same-named loop variable
|
|
677
|
+
// isn't replaced by the const literal (#1749 review). Ref-counted for
|
|
678
|
+
// nested loops; released after the body lines are assembled below.
|
|
679
|
+
const loopBound = loop.iterationShape === 'keys'
|
|
680
|
+
? [param]
|
|
681
|
+
: [param, loop.index ?? '_i']
|
|
682
|
+
for (const n of loopBound) {
|
|
683
|
+
this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
|
|
684
|
+
}
|
|
590
685
|
const prevInLoop = this.inLoop
|
|
591
686
|
this.inLoop = true
|
|
592
687
|
const renderedChildren = this.renderChildren(loop.children)
|
|
@@ -644,6 +739,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
|
|
|
644
739
|
lines.push(children)
|
|
645
740
|
}
|
|
646
741
|
|
|
742
|
+
// Body fully rendered — release the loop-bound names.
|
|
743
|
+
for (const n of loopBound) {
|
|
744
|
+
const c = (this.loopBoundNames.get(n) ?? 1) - 1
|
|
745
|
+
if (c <= 0) this.loopBoundNames.delete(n)
|
|
746
|
+
else this.loopBoundNames.set(n, c)
|
|
747
|
+
}
|
|
748
|
+
|
|
647
749
|
lines.push(`% }`)
|
|
648
750
|
lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
|
|
649
751
|
|
|
@@ -1307,7 +1409,7 @@ function renderArrayMethod(
|
|
|
1307
1409
|
// runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
|
|
1308
1410
|
// and dispatches: ARRAY ref scans the list with `eq`, scalar
|
|
1309
1411
|
// falls back to `index(..., ...) != -1`. Helper lives in
|
|
1310
|
-
// packages/adapter-
|
|
1412
|
+
// packages/adapter-perl/lib/BarefootJS.pm.
|
|
1311
1413
|
//
|
|
1312
1414
|
// The `bf->` (no `$`) form matches every other helper emit —
|
|
1313
1415
|
// in real Mojolicious `bf` is a controller helper; the
|
|
@@ -1533,6 +1635,64 @@ function renderSortMethod(recv: string, c: SortComparator): string {
|
|
|
1533
1635
|
return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
|
|
1534
1636
|
}
|
|
1535
1637
|
|
|
1638
|
+
/**
|
|
1639
|
+
* Render a `.reduce(fn, init)` arithmetic fold (#1448 Tier C) as a
|
|
1640
|
+
* `bf->reduce(...)` call. The structured `ReduceOp` maps to the Perl
|
|
1641
|
+
* helper's options hash:
|
|
1642
|
+
*
|
|
1643
|
+
* bf->reduce($recv, { op => '+', key_kind => 'field', key => 'duration',
|
|
1644
|
+
* type => 'numeric', init => 0 })
|
|
1645
|
+
*
|
|
1646
|
+
* A numeric init passes through as a bare Perl number (`0`, `-1`); a
|
|
1647
|
+
* string init (concat fold) is re-quoted from its literal contents.
|
|
1648
|
+
*/
|
|
1649
|
+
function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
|
|
1650
|
+
const keyEntry =
|
|
1651
|
+
op.key.kind === 'self'
|
|
1652
|
+
? `key_kind => 'self'`
|
|
1653
|
+
: `key_kind => 'field', key => '${op.key.field}'`
|
|
1654
|
+
// `op.init` is the decoded seed value. A numeric seed is already a
|
|
1655
|
+
// canonical decimal literal Perl reads directly; a concat seed is the
|
|
1656
|
+
// string contents, embedded in a single-quoted Perl literal. The `'`
|
|
1657
|
+
// escape is REQUIRED: a seed decoded from a double-quoted JS literal
|
|
1658
|
+
// (e.g. `"a'b"`) is escape-free yet contains an apostrophe. A literal
|
|
1659
|
+
// backslash can't occur (it would need a `\\` escape, which the parser
|
|
1660
|
+
// refuses), but escaping it too keeps this self-contained.
|
|
1661
|
+
const init =
|
|
1662
|
+
op.type === 'string'
|
|
1663
|
+
? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
1664
|
+
: op.init
|
|
1665
|
+
// `direction` is "left" (reduce) or "right" (reduceRight); the Perl
|
|
1666
|
+
// helper reverses the list for "right". Only observable for concat.
|
|
1667
|
+
return `bf->reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
|
|
1671
|
+
// to the `-1` sentinel (flatten fully); a finite depth flattens that many
|
|
1672
|
+
// levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
|
|
1673
|
+
function renderFlatMethod(recv: string, depth: FlatDepth): string {
|
|
1674
|
+
const d = depth === 'infinity' ? -1 : depth
|
|
1675
|
+
return `bf->flat(${recv}, ${d})`
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf->flat_map($recv,
|
|
1679
|
+
// 'self'|'field', 'field')`, and the array-literal tuple form
|
|
1680
|
+
// `i => [i.a, i.b]` → `bf->flat_map_tuple($recv, ['field','a'], ...)`
|
|
1681
|
+
// (one arrayref per leaf). The field key is the raw JS prop name (Perl
|
|
1682
|
+
// hashes are keyed by it), mirroring `bf->reduce`. See `sub flat_map` /
|
|
1683
|
+
// `sub flat_map_tuple` in BarefootJS.pm.
|
|
1684
|
+
function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
|
|
1685
|
+
const proj = op.projection
|
|
1686
|
+
if (proj.kind === 'tuple') {
|
|
1687
|
+
const specs = proj.elements
|
|
1688
|
+
.map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
|
|
1689
|
+
.join(', ')
|
|
1690
|
+
return `bf->flat_map_tuple(${recv}, ${specs})`
|
|
1691
|
+
}
|
|
1692
|
+
if (proj.kind === 'self') return `bf->flat_map(${recv}, 'self', '')`
|
|
1693
|
+
return `bf->flat_map(${recv}, 'field', '${proj.field}')`
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1536
1696
|
/** True when `type` is the `string` primitive. */
|
|
1537
1697
|
function isStringTypeInfo(type: TypeInfo | undefined): boolean {
|
|
1538
1698
|
return type?.kind === 'primitive' && type.primitive === 'string'
|
|
@@ -1715,6 +1875,18 @@ class MojoFilterEmitter implements ParsedExprEmitter {
|
|
|
1715
1875
|
return renderSortMethod(emit(object), comparator)
|
|
1716
1876
|
}
|
|
1717
1877
|
|
|
1878
|
+
reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
|
|
1879
|
+
return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
|
|
1883
|
+
return renderFlatMethod(emit(object), depth)
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
|
|
1887
|
+
return renderFlatMapMethod(emit(object), op)
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1718
1890
|
conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
|
|
1719
1891
|
return '1'
|
|
1720
1892
|
}
|
|
@@ -1747,6 +1919,11 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
1747
1919
|
constructor(private readonly adapter: MojoAdapter) {}
|
|
1748
1920
|
|
|
1749
1921
|
identifier(name: string): string {
|
|
1922
|
+
// Module pure-string const (e.g. `const baseClasses = '...'` used in a
|
|
1923
|
+
// className template literal): inline the literal value rather than emit
|
|
1924
|
+
// `$baseClasses` against a stash variable that is never bound.
|
|
1925
|
+
const inlined = this.adapter.resolveModuleStringConst(name)
|
|
1926
|
+
if (inlined !== null) return inlined
|
|
1750
1927
|
return `$${name}`
|
|
1751
1928
|
}
|
|
1752
1929
|
|
|
@@ -1896,6 +2073,18 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
|
|
|
1896
2073
|
return renderSortMethod(emit(object), comparator)
|
|
1897
2074
|
}
|
|
1898
2075
|
|
|
2076
|
+
reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
|
|
2077
|
+
return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
|
|
2081
|
+
return renderFlatMethod(emit(object), depth)
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
|
|
2085
|
+
return renderFlatMapMethod(emit(object), op)
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1899
2088
|
conditional(
|
|
1900
2089
|
test: ParsedExpr,
|
|
1901
2090
|
consequent: ParsedExpr,
|
package/src/test-render.ts
CHANGED
|
@@ -11,7 +11,12 @@ import { mkdir, rm } from 'node:fs/promises'
|
|
|
11
11
|
import { resolve } from 'node:path'
|
|
12
12
|
|
|
13
13
|
const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
|
|
14
|
+
// Mojo-specific lib (BarefootJS::Backend::Mojo + the plugin) lives in this
|
|
15
|
+
// package; the engine-agnostic core (BarefootJS.pm) moved to @barefootjs/perl.
|
|
16
|
+
// Both dirs must be on the render script's @INC so `use BarefootJS` and
|
|
17
|
+
// `use BarefootJS::Backend::Mojo` resolve.
|
|
14
18
|
const LIB_DIR = resolve(import.meta.dir, '../lib')
|
|
19
|
+
const PERL_CORE_LIB_DIR = resolve(import.meta.dir, '../../adapter-perl/lib')
|
|
15
20
|
|
|
16
21
|
export class PerlNotAvailableError extends Error {
|
|
17
22
|
constructor(message: string) {
|
|
@@ -216,7 +221,7 @@ use strict;
|
|
|
216
221
|
use warnings;
|
|
217
222
|
use utf8;
|
|
218
223
|
|
|
219
|
-
use lib '${LIB_DIR}';
|
|
224
|
+
use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
|
|
220
225
|
use Mojolicious;
|
|
221
226
|
use Mojo::Template;
|
|
222
227
|
# Boolean values in spread bags arrive as Mojo::JSON::true /
|
|
@@ -428,6 +433,25 @@ function buildPerlProps(
|
|
|
428
433
|
entries.push(`${param.name} => undef`)
|
|
429
434
|
}
|
|
430
435
|
|
|
436
|
+
// A `{...props}` rest spread means props that aren't declared named
|
|
437
|
+
// params flow through the rest bag (`bf->spread_attrs($<restPropsName>)`),
|
|
438
|
+
// not their own top-level template var. Route them into the bag hashref so
|
|
439
|
+
// a fixture passing e.g. `placeholder` to `Input` (whose declared params
|
|
440
|
+
// are `className` / `type`) renders `placeholder="..."` via the spread
|
|
441
|
+
// rather than silently dropping it into an unused `my $placeholder`. (#1467
|
|
442
|
+
// Phase 2b — mirrors the Go harness fix in the sibling `test-render.ts`.)
|
|
443
|
+
const restPropsName = ir.metadata.restPropsName
|
|
444
|
+
const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
|
|
445
|
+
const restBagEntries: Array<[string, unknown]> = []
|
|
446
|
+
if (restPropsName && props) {
|
|
447
|
+
for (const [key, value] of Object.entries(props)) {
|
|
448
|
+
if (key.startsWith('__')) continue
|
|
449
|
+
if (key === restPropsName || declaredParams.has(key)) continue
|
|
450
|
+
restBagEntries.push([key, value])
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const routedKeys = new Set(restBagEntries.map(([k]) => k))
|
|
454
|
+
|
|
431
455
|
// (#1407 follow-up) Default the rest-binding identifier to an
|
|
432
456
|
// empty hashref so `bf->spread_attrs($extras)` in the generated
|
|
433
457
|
// Mojo template doesn't trip Perl's strict-mode "Global symbol
|
|
@@ -436,13 +460,16 @@ function buildPerlProps(
|
|
|
436
460
|
// the COMPILE path on Go, where the bag plumbing matters; the
|
|
437
461
|
// runtime is a no-op when the caller leaves the bag unset, which
|
|
438
462
|
// mirrors the empty-spread case on every adapter).
|
|
439
|
-
|
|
440
|
-
|
|
463
|
+
// When the fixture supplied undeclared props, seed the bag with those
|
|
464
|
+
// routed entries instead of an empty hashref.
|
|
465
|
+
if (restPropsName && !(props && restPropsName in props)) {
|
|
466
|
+
entries.push(`${restPropsName} => ${toPerlLiteral(Object.fromEntries(restBagEntries))}`)
|
|
441
467
|
}
|
|
442
468
|
|
|
443
469
|
// Add user props
|
|
444
470
|
if (props) {
|
|
445
471
|
for (const [key, value] of Object.entries(props)) {
|
|
472
|
+
if (routedKeys.has(key)) continue
|
|
446
473
|
if (typeof value === 'string') {
|
|
447
474
|
entries.push(`${key} => '${value.replace(/'/g, "\\'")}'`)
|
|
448
475
|
} else if (typeof value === 'number') {
|