@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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Text::Xslate (Kolon) Template Adapter Exports
3
+ */
4
+
5
+ export { XslateAdapter, xslateAdapter } from './xslate-adapter'
6
+ export type { XslateAdapterOptions } from './xslate-adapter'