@barefootjs/xslate 0.8.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/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 +1204 -0
- package/dist/adapter/xslate-adapter.d.ts +176 -0
- package/dist/adapter/xslate-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 +1224 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1204 -0
- package/lib/BarefootJS/Backend/Xslate.pm +152 -0
- package/package.json +82 -0
- package/src/__tests__/xslate-counter.test.ts +142 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/xslate-adapter.ts +1721 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
package BarefootJS::Backend::Xslate;
|
|
2
|
+
our $VERSION = "0.01";
|
|
3
|
+
use strict;
|
|
4
|
+
use warnings;
|
|
5
|
+
use utf8;
|
|
6
|
+
use feature 'signatures';
|
|
7
|
+
no warnings 'experimental::signatures';
|
|
8
|
+
|
|
9
|
+
use Text::Xslate ();
|
|
10
|
+
use JSON::PP ();
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Text::Xslate (Kolon) rendering backend for the BarefootJS runtime.
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
#
|
|
16
|
+
# The engine-agnostic runtime logic — the JS-compat value helpers, array/string
|
|
17
|
+
# methods, hydration markers, child rendering — lives in BarefootJS
|
|
18
|
+
# (@barefootjs/perl). This backend supplies the four engine-specific operations
|
|
19
|
+
# the runtime delegates to, targeting Text::Xslate's Kolon syntax:
|
|
20
|
+
#
|
|
21
|
+
# encode_json($data) -> JSON string (injectable encoder)
|
|
22
|
+
# mark_raw($str) -> Text::Xslate raw value (no re-escaping)
|
|
23
|
+
# materialize($value) -> resolve a captured-children value to a string
|
|
24
|
+
# render_named($name, $bf, \%v) -> render `<name>.tx` with `bf` + vars bound
|
|
25
|
+
#
|
|
26
|
+
# Pair it with the @barefootjs/xslate compile-time adapter, which emits Kolon
|
|
27
|
+
# `.tx` templates that call the runtime as a `$bf` object: `<: $bf.scope_attr()
|
|
28
|
+
# :>`, `<: $bf.json($x) :>`, `<: $bf.spread_attrs($bag) :>`. Kolon auto-escapes
|
|
29
|
+
# `<: ... :>` interpolations (`type => 'html'`); helpers that emit markup return
|
|
30
|
+
# `mark_raw` values (or the template adds `| mark_raw`), mirroring Mojo EP's
|
|
31
|
+
# `<%==` vs `<%=` distinction.
|
|
32
|
+
#
|
|
33
|
+
# Unlike the Mojo backend, this has no dependency on a web framework: a plain
|
|
34
|
+
# Text::Xslate instance renders templates from a path, so it runs under any
|
|
35
|
+
# PSGI / Plack app (or none at all).
|
|
36
|
+
|
|
37
|
+
sub new ($class, %args) {
|
|
38
|
+
my $json_encoder = $args{json_encoder} // do {
|
|
39
|
+
# Default pure-Perl encoder. `canonical` keeps key order deterministic
|
|
40
|
+
# (matching the runtime's sorted-key SSR policy); `allow_nonref` lets
|
|
41
|
+
# scalars / undef encode as `"x"` / `null`. Swap via `json_encoder`
|
|
42
|
+
# for a faster XS implementation.
|
|
43
|
+
my $j = JSON::PP->new->canonical->allow_nonref;
|
|
44
|
+
sub ($data) { return $j->encode($data) };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
# Accept a pre-built Text::Xslate instance, or build one from `path`
|
|
48
|
+
# (a dir of `.tx` templates) plus any extra `xslate_options`.
|
|
49
|
+
my $xslate = $args{xslate};
|
|
50
|
+
unless ($xslate) {
|
|
51
|
+
$xslate = Text::Xslate->new(
|
|
52
|
+
syntax => 'Kolon',
|
|
53
|
+
type => 'html',
|
|
54
|
+
($args{path} ? (path => $args{path}) : ()),
|
|
55
|
+
%{ $args{xslate_options} // {} },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return bless { xslate => $xslate, json_encoder => $json_encoder }, $class;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
sub xslate ($self) { return $self->{xslate} }
|
|
63
|
+
|
|
64
|
+
sub encode_json ($self, $data) {
|
|
65
|
+
return $self->{json_encoder}->($data);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Mark a string as already-safe so Kolon emits it verbatim (no auto-escape).
|
|
69
|
+
sub mark_raw ($self, $str) {
|
|
70
|
+
return Text::Xslate::mark_raw($str);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# JSX children captured by the adapter (a Kolon macro call yields a rendered
|
|
74
|
+
# string; some paths may pass a CODE ref) resolve to a string here.
|
|
75
|
+
sub materialize ($self, $value) {
|
|
76
|
+
return ref($value) eq 'CODE' ? $value->() : $value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Render `<name>.tx` with `$child_bf` bound as the `bf` object for the nested
|
|
80
|
+
# render, plus the supplied template vars. No stash juggling is needed: Kolon
|
|
81
|
+
# resolves `$bf` from the per-render vars, so each child render gets its own
|
|
82
|
+
# instance directly.
|
|
83
|
+
sub render_named ($self, $template_name, $child_bf, $vars) {
|
|
84
|
+
return $self->{xslate}->render("$template_name.tx", { %$vars, bf => $child_bf });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
1;
|
|
88
|
+
__END__
|
|
89
|
+
|
|
90
|
+
=encoding utf8
|
|
91
|
+
|
|
92
|
+
=head1 NAME
|
|
93
|
+
|
|
94
|
+
BarefootJS::Backend::Xslate - Text::Xslate (Kolon) rendering backend for BarefootJS
|
|
95
|
+
|
|
96
|
+
=head1 SYNOPSIS
|
|
97
|
+
|
|
98
|
+
use BarefootJS;
|
|
99
|
+
use BarefootJS::Backend::Xslate;
|
|
100
|
+
|
|
101
|
+
my $backend = BarefootJS::Backend::Xslate->new(path => ['templates']);
|
|
102
|
+
my $bf = BarefootJS->new(undef, { backend => $backend });
|
|
103
|
+
|
|
104
|
+
# Renders templates/counter.tx, binding the runtime as the `bf` object.
|
|
105
|
+
my $html = $backend->render_named('counter', $bf, { count => 0 });
|
|
106
|
+
|
|
107
|
+
=head1 DESCRIPTION
|
|
108
|
+
|
|
109
|
+
A rendering backend that lets the engine-agnostic L<BarefootJS> runtime render
|
|
110
|
+
its marked templates with L<Text::Xslate> (Kolon syntax). Because it has no
|
|
111
|
+
dependency on a web framework, a plain Text::Xslate instance renders templates
|
|
112
|
+
from a path, so it runs under any PSGI / Plack application (or none at all).
|
|
113
|
+
|
|
114
|
+
Pair it with the C<@barefootjs/xslate> compile-time adapter, which emits Kolon
|
|
115
|
+
C<.tx> templates that call the runtime as a C<bf> object — C<< <: $bf.scope_attr()
|
|
116
|
+
:> >>, C<< <: $bf.json($x) :> >>, C<< <: $bf.spread_attrs($h) :> >>. Kolon
|
|
117
|
+
auto-escapes C<< <: ... :> >> interpolations (the backend builds Text::Xslate
|
|
118
|
+
with C<< type => 'html' >>); helpers that emit markup return C<mark_raw> values,
|
|
119
|
+
mirroring Mojo EP's C<< <%== >> versus C<< <%= >>.
|
|
120
|
+
|
|
121
|
+
=head1 METHODS
|
|
122
|
+
|
|
123
|
+
=head2 new(%args)
|
|
124
|
+
|
|
125
|
+
Constructs a backend. Accepts a pre-built C<xslate> instance, or a C<path>
|
|
126
|
+
(arrayref of template directories) plus optional C<xslate_options> to build a
|
|
127
|
+
Kolon, html-escaping Text::Xslate. C<json_encoder> overrides the default
|
|
128
|
+
canonical L<JSON::PP> encoder.
|
|
129
|
+
|
|
130
|
+
=head2 encode_json($data) / mark_raw($str) / materialize($value) / render_named($name, $bf, \%vars)
|
|
131
|
+
|
|
132
|
+
The four engine-specific operations the runtime delegates to. C<mark_raw> uses
|
|
133
|
+
C<Text::Xslate::mark_raw>; C<render_named> renders C<< <name>.tx >> with C<$bf>
|
|
134
|
+
bound as the C<bf> variable plus C<\%vars>.
|
|
135
|
+
|
|
136
|
+
=head1 SEE ALSO
|
|
137
|
+
|
|
138
|
+
L<BarefootJS>, L<Text::Xslate>, L<Plack>,
|
|
139
|
+
L<https://github.com/piconic-ai/barefootjs>
|
|
140
|
+
|
|
141
|
+
=head1 AUTHOR
|
|
142
|
+
|
|
143
|
+
kobaken E<lt>kentafly88@gmail.comE<gt>
|
|
144
|
+
|
|
145
|
+
=head1 LICENSE
|
|
146
|
+
|
|
147
|
+
Copyright (c) 2025-present BarefootJS Contributors.
|
|
148
|
+
|
|
149
|
+
This library is free software; you can redistribute it and/or modify it under
|
|
150
|
+
the MIT License. See the F<LICENSE> file in the distribution for the full text.
|
|
151
|
+
|
|
152
|
+
=cut
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@barefootjs/xslate",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./adapter": {
|
|
14
|
+
"types": "./src/adapter/index.ts",
|
|
15
|
+
"import": "./src/adapter/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./build": {
|
|
18
|
+
"types": "./src/build.ts",
|
|
19
|
+
"import": "./src/build.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./adapter": {
|
|
29
|
+
"types": "./dist/adapter/index.d.ts",
|
|
30
|
+
"import": "./dist/adapter/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./build": {
|
|
33
|
+
"types": "./dist/build.d.ts",
|
|
34
|
+
"import": "./dist/build.js"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"lib",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "bun run build:js && bun run build:types",
|
|
46
|
+
"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",
|
|
47
|
+
"build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"clean": "rm -rf dist",
|
|
50
|
+
"prepack": "node ../../scripts/swap-publish-config.mjs pack",
|
|
51
|
+
"postpack": "node ../../scripts/swap-publish-config.mjs unpack"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"xslate",
|
|
55
|
+
"text-xslate",
|
|
56
|
+
"kolon",
|
|
57
|
+
"perl",
|
|
58
|
+
"plack",
|
|
59
|
+
"psgi",
|
|
60
|
+
"barefoot",
|
|
61
|
+
"ssr"
|
|
62
|
+
],
|
|
63
|
+
"author": "kobaken <kentafly88@gmail.com>",
|
|
64
|
+
"license": "MIT",
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "https://github.com/piconic-ai/barefootjs",
|
|
68
|
+
"directory": "packages/adapter-xslate"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@barefootjs/perl": "0.8.0",
|
|
72
|
+
"@barefootjs/shared": "0.8.0"
|
|
73
|
+
},
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"@barefootjs/jsx": ">=0.2.0"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@barefootjs/adapter-tests": "0.1.0",
|
|
79
|
+
"@barefootjs/jsx": "0.8.0",
|
|
80
|
+
"typescript": "^5.0.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
3
|
+
import { XslateAdapter } from '../adapter'
|
|
4
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const COUNTER_SRC = `"use client"
|
|
9
|
+
import { createSignal } from '@barefootjs/client'
|
|
10
|
+
export function Counter({ initial = 0 }: { initial?: number }) {
|
|
11
|
+
const [count, setCount] = createSignal(initial)
|
|
12
|
+
const doubled = () => count() * 2
|
|
13
|
+
return (
|
|
14
|
+
<div class="counter">
|
|
15
|
+
<p>count: {count()}</p>
|
|
16
|
+
<p>doubled: {doubled()}</p>
|
|
17
|
+
<button onClick={() => setCount(n => n + 1)}>+1</button>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}`
|
|
21
|
+
|
|
22
|
+
const PERL_CORE_LIB = resolve(import.meta.dir, '../../../adapter-perl/lib')
|
|
23
|
+
const XSLATE_LIB = resolve(import.meta.dir, '../../lib')
|
|
24
|
+
|
|
25
|
+
let _perlAvailable: boolean | null = null
|
|
26
|
+
async function isPerlXslateAvailable(): Promise<boolean> {
|
|
27
|
+
if (_perlAvailable !== null) return _perlAvailable
|
|
28
|
+
try {
|
|
29
|
+
const proc = Bun.spawn(['perl', '-MText::Xslate', '-e', 'print $Text::Xslate::VERSION'], {
|
|
30
|
+
stdout: 'pipe',
|
|
31
|
+
stderr: 'pipe',
|
|
32
|
+
})
|
|
33
|
+
await proc.exited
|
|
34
|
+
_perlAvailable = proc.exitCode === 0
|
|
35
|
+
} catch {
|
|
36
|
+
_perlAvailable = false
|
|
37
|
+
}
|
|
38
|
+
return _perlAvailable
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('Counter compiles to a Kolon .tx template', () => {
|
|
42
|
+
const result = compileJSX(COUNTER_SRC, 'Counter.tsx', {
|
|
43
|
+
adapter: new XslateAdapter(),
|
|
44
|
+
outputIR: true,
|
|
45
|
+
})
|
|
46
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
47
|
+
expect(errors).toEqual([])
|
|
48
|
+
|
|
49
|
+
const tpl = result.files.find(f => f.type === 'markedTemplate')
|
|
50
|
+
expect(tpl).toBeDefined()
|
|
51
|
+
const content = tpl!.content
|
|
52
|
+
// Script registration as silent Kolon line statements (bound to a throwaway
|
|
53
|
+
// `my` so the call's return value isn't printed into the HTML).
|
|
54
|
+
expect(content).toContain(`$bf.register_script('/static/components/barefoot.js')`)
|
|
55
|
+
expect(content).toContain(`$bf.register_script('/static/components/Counter.client.js')`)
|
|
56
|
+
// Hydration markers
|
|
57
|
+
expect(content).toContain(`bf-s="<: $bf.scope_attr() :>"`)
|
|
58
|
+
expect(content).toContain(`<: $bf.hydration_attrs() | mark_raw :>`)
|
|
59
|
+
expect(content).toContain(`<: $bf.props_attr() | mark_raw :>`)
|
|
60
|
+
// Text slots
|
|
61
|
+
expect(content).toContain(`<: $bf.text_start("s0") | mark_raw :><: $count :><: $bf.text_end() | mark_raw :>`)
|
|
62
|
+
expect(content).toContain(`<: $bf.text_start("s2") | mark_raw :><: $doubled :><: $bf.text_end() | mark_raw :>`)
|
|
63
|
+
// Button stays a plain element (onClick is client-only)
|
|
64
|
+
expect(content).toContain(`+1</button>`)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Counter renders through real Text::Xslate', async () => {
|
|
68
|
+
if (!(await isPerlXslateAvailable())) {
|
|
69
|
+
console.warn('perl with Text::Xslate not available — skipping render test')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const result = compileJSX(COUNTER_SRC, 'Counter.tsx', {
|
|
74
|
+
adapter: new XslateAdapter(),
|
|
75
|
+
outputIR: true,
|
|
76
|
+
})
|
|
77
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
78
|
+
expect(errors).toEqual([])
|
|
79
|
+
const tpl = result.files.find(f => f.type === 'markedTemplate')!
|
|
80
|
+
|
|
81
|
+
const dir = await mkdtemp(join(tmpdir(), 'xslate-counter-'))
|
|
82
|
+
try {
|
|
83
|
+
// Template file named `counter.tx` (snake_case of Counter).
|
|
84
|
+
await writeFile(join(dir, 'counter.tx'), tpl.content)
|
|
85
|
+
|
|
86
|
+
const renderScript = `#!/usr/bin/env perl
|
|
87
|
+
use strict;
|
|
88
|
+
use warnings;
|
|
89
|
+
use utf8;
|
|
90
|
+
use lib '${XSLATE_LIB}', '${PERL_CORE_LIB}';
|
|
91
|
+
use BarefootJS;
|
|
92
|
+
use BarefootJS::Backend::Xslate;
|
|
93
|
+
|
|
94
|
+
my $backend = BarefootJS::Backend::Xslate->new(path => ['${dir}']);
|
|
95
|
+
my $bf = BarefootJS->new(undef, { backend => $backend });
|
|
96
|
+
$bf->_scope_id('Counter_test');
|
|
97
|
+
|
|
98
|
+
binmode(STDOUT, ':utf8');
|
|
99
|
+
my \$html = \$backend->render_named('counter', \$bf, { count => 3, doubled => 6 });
|
|
100
|
+
print \$html;
|
|
101
|
+
# The template's register_script calls populated \$bf's script list during
|
|
102
|
+
# render; emit the resulting <script> tags so the test can assert them.
|
|
103
|
+
print "\\n";
|
|
104
|
+
print \$bf->scripts;
|
|
105
|
+
`
|
|
106
|
+
const scriptPath = join(dir, 'render.pl')
|
|
107
|
+
await writeFile(scriptPath, renderScript)
|
|
108
|
+
|
|
109
|
+
const proc = Bun.spawn(['perl', scriptPath], {
|
|
110
|
+
cwd: dir,
|
|
111
|
+
stdout: 'pipe',
|
|
112
|
+
stderr: 'pipe',
|
|
113
|
+
})
|
|
114
|
+
const [stdout, stderr] = await Promise.all([
|
|
115
|
+
new Response(proc.stdout).text(),
|
|
116
|
+
new Response(proc.stderr).text(),
|
|
117
|
+
])
|
|
118
|
+
const exitCode = await proc.exited
|
|
119
|
+
if (exitCode !== 0) {
|
|
120
|
+
throw new Error(`perl render failed (exit ${exitCode}):\n${stderr}\n--- template ---\n${tpl.content}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const html = stdout
|
|
124
|
+
console.log('=== RENDERED HTML ===')
|
|
125
|
+
console.log(html)
|
|
126
|
+
|
|
127
|
+
// Scope marker
|
|
128
|
+
expect(html).toContain('bf-s="Counter_test"')
|
|
129
|
+
// Text slots with comment markers and values
|
|
130
|
+
expect(html).toMatch(/count: <!--bf:s0-->3<!--\/-->/)
|
|
131
|
+
expect(html).toMatch(/doubled: <!--bf:s2-->6<!--\/-->/)
|
|
132
|
+
// Plain button (no handler at SSR)
|
|
133
|
+
expect(html).toMatch(/<button[^>]*>\+1<\/button>/)
|
|
134
|
+
// No leaked register_script return values before the root element.
|
|
135
|
+
expect(html).toMatch(/^\s*<div class="counter"/)
|
|
136
|
+
// Registered client JS surfaces as <script> tags via $bf->scripts.
|
|
137
|
+
expect(html).toContain('<script type="module" src="/static/components/barefoot.js"></script>')
|
|
138
|
+
expect(html).toContain('<script type="module" src="/static/components/Counter.client.js"></script>')
|
|
139
|
+
} finally {
|
|
140
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
141
|
+
}
|
|
142
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural classifier for JS expressions whose result is a boolean
|
|
3
|
+
* value (or unambiguously stringifies to "true"/"false" in JS).
|
|
4
|
+
*
|
|
5
|
+
* Used by the Mojo adapter's `emitExpression` to decide whether to
|
|
6
|
+
* route a reactive attribute binding through the `bf->bool_str` Perl
|
|
7
|
+
* runtime helper (#1466 follow-up). Perl has no native boolean type;
|
|
8
|
+
* `($count > 0)` evaluates to `''` / `1`, not `"false"` / `"true"`,
|
|
9
|
+
* so the literal stringification diverges from Hono / Go. Wrapping
|
|
10
|
+
* the value with `bool_str` realigns the serialised attribute with
|
|
11
|
+
* JS `String(boolean)` semantics.
|
|
12
|
+
*
|
|
13
|
+
* The classifier walks a `ParsedExpr` produced by
|
|
14
|
+
* `@barefootjs/jsx::parseExpression` — same AST the filter / loop
|
|
15
|
+
* lowerings already use — so detection is structural rather than
|
|
16
|
+
* regex-text-matching. Wrapped expression text is left to the
|
|
17
|
+
* caller's existing `convertExpressionToPerl` pipeline; this module
|
|
18
|
+
* only decides whether to wrap.
|
|
19
|
+
*
|
|
20
|
+
* Detected shapes:
|
|
21
|
+
* - `binary` with a comparison operator (`<`, `>`, `<=`, `>=`,
|
|
22
|
+
* `==`, `===`, `!=`, `!==`)
|
|
23
|
+
* - `unary` with logical `!`
|
|
24
|
+
* - `literal` with `literalType: 'boolean'`
|
|
25
|
+
* - `logical` (`&&` / `||` / `??`) when both sides are themselves
|
|
26
|
+
* boolean-result (catches `x > 0 && y < 10`; intentionally does
|
|
27
|
+
* NOT catch `x() || 'fallback'` whose right side stringifies as
|
|
28
|
+
* a regular value)
|
|
29
|
+
* - `conditional` (`?:`) when both branches are themselves
|
|
30
|
+
* boolean-result
|
|
31
|
+
*
|
|
32
|
+
* Anything else returns `false` — including bare identifiers
|
|
33
|
+
* (`accepted`) and call expressions (`accepted()`) whose return type
|
|
34
|
+
* the adapter has no way to infer from source text alone. Those
|
|
35
|
+
* carry their own (Perl-coerced) value through unchanged, which
|
|
36
|
+
* stays correct for non-boolean shapes and is handled by
|
|
37
|
+
* `normalizeHTML`'s `aria-*="0"` rule for the specific Mojo-Perl
|
|
38
|
+
* `aria-*={booleanFn()}` divergence.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { parseExpression, type ParsedExpr } from '@barefootjs/jsx'
|
|
42
|
+
|
|
43
|
+
const COMPARISON_OPS = new Set([
|
|
44
|
+
'<',
|
|
45
|
+
'>',
|
|
46
|
+
'<=',
|
|
47
|
+
'>=',
|
|
48
|
+
'==',
|
|
49
|
+
'===',
|
|
50
|
+
'!=',
|
|
51
|
+
'!==',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
function isBooleanResultParsed(node: ParsedExpr): boolean {
|
|
55
|
+
switch (node.kind) {
|
|
56
|
+
case 'literal':
|
|
57
|
+
return node.literalType === 'boolean'
|
|
58
|
+
case 'binary':
|
|
59
|
+
return COMPARISON_OPS.has(node.op)
|
|
60
|
+
case 'unary':
|
|
61
|
+
return node.op === '!'
|
|
62
|
+
case 'logical':
|
|
63
|
+
// `x > 0 && y < 10` is boolean; `x() || 'fallback'` is not.
|
|
64
|
+
// Only both-sides-boolean qualifies.
|
|
65
|
+
return (
|
|
66
|
+
isBooleanResultParsed(node.left) && isBooleanResultParsed(node.right)
|
|
67
|
+
)
|
|
68
|
+
case 'conditional':
|
|
69
|
+
// `cond ? bool : bool` is boolean; `cond ? 'a' : 'b'` is not.
|
|
70
|
+
return (
|
|
71
|
+
isBooleanResultParsed(node.consequent) &&
|
|
72
|
+
isBooleanResultParsed(node.alternate)
|
|
73
|
+
)
|
|
74
|
+
default:
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isBooleanResultExpr(expr: string): boolean {
|
|
80
|
+
const parsed = parseExpression(expr.trim())
|
|
81
|
+
if (!parsed) return false
|
|
82
|
+
return isBooleanResultParsed(parsed)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ARIA attributes whose spec values are `"true"`, `"false"`, and (for
|
|
87
|
+
* tri-state members) `"mixed"`. When a fixture binds one of these to
|
|
88
|
+
* an arbitrary JS expression (`aria-checked={accepted()}`), the
|
|
89
|
+
* expression's actual type isn't recoverable from source text — but
|
|
90
|
+
* the attribute name itself witnesses that the binding is
|
|
91
|
+
* boolean-shaped. Routing these through `bf->bool_str` produces the
|
|
92
|
+
* spec-canonical `"true"` / `"false"` even when the expression is
|
|
93
|
+
* opaque, eliminating the Mojo-only `aria-*="0"` divergence at the
|
|
94
|
+
* source rather than papering it over in `normalizeHTML`.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately conservative — only includes ARIA attributes whose
|
|
97
|
+
* spec value set is exactly `true | false` or `true | false | mixed`.
|
|
98
|
+
* Tokenised ARIA attributes (`aria-current` is `page | step | …`,
|
|
99
|
+
* `aria-sort` is `ascending | descending | …`) are intentionally
|
|
100
|
+
* excluded so a string-valued binding doesn't get coerced to
|
|
101
|
+
* `"true"` / `"false"`.
|
|
102
|
+
*/
|
|
103
|
+
const ARIA_BOOLEAN_ATTRS = new Set([
|
|
104
|
+
// Strict boolean state (true | false; some allow `undefined` =
|
|
105
|
+
// attribute absent, which the runtime emits as no-attr regardless).
|
|
106
|
+
'aria-atomic',
|
|
107
|
+
'aria-busy',
|
|
108
|
+
'aria-disabled',
|
|
109
|
+
'aria-hidden',
|
|
110
|
+
'aria-modal',
|
|
111
|
+
'aria-multiline',
|
|
112
|
+
'aria-multiselectable',
|
|
113
|
+
'aria-readonly',
|
|
114
|
+
'aria-required',
|
|
115
|
+
// Tri-state (true | false | mixed). The `bool_str` helper only
|
|
116
|
+
// maps Perl truthy / falsy to true / false — a fixture that wants
|
|
117
|
+
// the literal `"mixed"` would bind a string-valued JSX attr
|
|
118
|
+
// (`aria-checked="mixed"`), which lowers through the `literal` emit
|
|
119
|
+
// path and never touches this code.
|
|
120
|
+
'aria-checked',
|
|
121
|
+
'aria-pressed',
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
export function isAriaBooleanAttr(name: string): boolean {
|
|
125
|
+
return ARIA_BOOLEAN_ATTRS.has(name)
|
|
126
|
+
}
|