@barefootjs/xslate 0.8.0 → 0.9.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.
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.01";
2
+ our $VERSION = "0.8.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
@@ -45,7 +45,10 @@ sub new ($class, %args) {
45
45
  };
46
46
 
47
47
  # Accept a pre-built Text::Xslate instance, or build one from `path`
48
- # (a dir of `.tx` templates) plus any extra `xslate_options`.
48
+ # (a dir of `.tx` templates) plus any extra `xslate_options`. The adapter
49
+ # calls every runtime helper as a `$bf` method (`$bf.filter`, `$bf.lc`, …)
50
+ # or a Kolon builtin (`.join`, `.size`), so no custom `function` map is
51
+ # needed here — a plain Kolon, html-escaping instance suffices.
49
52
  my $xslate = $args{xslate};
50
53
  unless ($xslate) {
51
54
  $xslate = Text::Xslate->new(
@@ -127,6 +130,11 @@ Constructs a backend. Accepts a pre-built C<xslate> instance, or a C<path>
127
130
  Kolon, html-escaping Text::Xslate. C<json_encoder> overrides the default
128
131
  canonical L<JSON::PP> encoder.
129
132
 
133
+ No custom Kolon C<function> map is needed: the C<@barefootjs/xslate> adapter
134
+ calls every runtime helper as a C<$bf> method (C<< $bf.filter($arr, -> $x {
135
+ ... }) >>, C<< $bf.lc($s) >>, …) or a Kolon builtin (C<< $arr.join(", ") >>,
136
+ C<< $arr.size() >>), so a plain instance renders the emitted templates.
137
+
130
138
  =head2 encode_json($data) / mark_raw($str) / materialize($value) / render_named($name, $bf, \%vars)
131
139
 
132
140
  The four engine-specific operations the runtime delegates to. C<mark_raw> uses
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,28 +11,15 @@
11
11
  "import": "./dist/index.js"
12
12
  },
13
13
  "./adapter": {
14
- "types": "./src/adapter/index.ts",
15
- "import": "./src/adapter/index.ts"
14
+ "types": "./dist/adapter/index.d.ts",
15
+ "import": "./dist/adapter/index.js"
16
+ },
17
+ "./test-render": {
18
+ "bun": "./src/test-render.ts"
16
19
  },
17
20
  "./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
- }
21
+ "types": "./dist/build.d.ts",
22
+ "import": "./dist/build.js"
36
23
  }
37
24
  },
38
25
  "files": [
@@ -68,15 +55,15 @@
68
55
  "directory": "packages/adapter-xslate"
69
56
  },
70
57
  "dependencies": {
71
- "@barefootjs/perl": "0.8.0",
72
- "@barefootjs/shared": "0.8.0"
58
+ "@barefootjs/perl": "0.9.0",
59
+ "@barefootjs/shared": "0.9.0"
73
60
  },
74
61
  "peerDependencies": {
75
62
  "@barefootjs/jsx": ">=0.2.0"
76
63
  },
77
64
  "devDependencies": {
78
65
  "@barefootjs/adapter-tests": "0.1.0",
79
- "@barefootjs/jsx": "0.8.0",
66
+ "@barefootjs/jsx": "0.9.0",
80
67
  "typescript": "^5.0.0"
81
68
  }
82
69
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * XslateAdapter — Conformance Tests
3
+ *
4
+ * Runs the shared adapter conformance corpus (JSX fixtures, template
5
+ * primitives, marker conformance) against the Text::Xslate (Kolon)
6
+ * adapter, rendering each fixture end-to-end through real Text::Xslate +
7
+ * `BarefootJS::Backend::Xslate` via `renderXslateComponent`.
8
+ *
9
+ * The Xslate adapter was ported from the Mojolicious adapter and shares
10
+ * its Perl-scoping + SSR-context limitations, so the skip / diagnostic
11
+ * sets below start from mojo's and diverge only where the engine
12
+ * genuinely differs. Every divergence carries a one-line rationale.
13
+ */
14
+
15
+ import {
16
+ runAdapterConformanceTests,
17
+ TemplatePrimitiveCaseId,
18
+ } from '@barefootjs/adapter-tests'
19
+ import { XslateAdapter } from '../adapter'
20
+ import { renderXslateComponent, XslateNotAvailableError } from '../test-render'
21
+
22
+ runAdapterConformanceTests({
23
+ name: 'xslate',
24
+ factory: () => new XslateAdapter(),
25
+ render: renderXslateComponent,
26
+ // Skips here are VERIFIED, not inherited from mojo. Notably, the six
27
+ // fixtures mojo skips for Perl-EP scoping faults — `logical-or-jsx`,
28
+ // `nullish-coalescing-jsx`, `branch-map`, `return-logical-or`,
29
+ // `return-nullish-coalescing`, `return-map` (bare `$label` / `$items`
30
+ // without a `my` binding) — all PASS on Xslate, because Kolon resolves
31
+ // `$label` from the per-render vars rather than a Perl lexical, so there
32
+ // is no undefined-symbol fault. Xslate therefore skips strictly fewer
33
+ // fixtures than mojo. Each entry below was confirmed to fail with
34
+ // skipJsx emptied.
35
+ skipJsx: [
36
+ // SSR context propagation (`<Ctx.Provider value>` → `useContext`): the
37
+ // template reads a stash key that's never seeded. Implemented on Go; the
38
+ // Perl stash-seed path is a follow-up port, so Xslate stays skipped (#1297).
39
+ 'context-provider',
40
+ // Multi-component shared-state pairs whose children render inside a keyed
41
+ // `.map` (loop children, no `_bf_slot`): the test harness derives a
42
+ // non-deterministic `<child>_<rand>` scope id instead of the canonical
43
+ // `<ChildName>_*` the reference HTML pins, and per-item loop state isn't
44
+ // seeded server-side. Harness-level scope-id plumbing; single-component
45
+ // `reactive-props` passes. (Same pair mojo skips.)
46
+ 'toggle-shared',
47
+ 'props-reactivity-comparison',
48
+ // (`kbd` is not skipped here — it's a BF101 refusal pinned in
49
+ // `expectedDiagnostics` below, not a render-mismatch.)
50
+ ],
51
+ // Per-fixture build-time contracts for shapes the Xslate adapter
52
+ // intentionally refuses to lower. Mirrors mojo's set — the lowering
53
+ // gates are shared code paths in the ported adapter.
54
+ expectedDiagnostics: {
55
+ // Sibling-imported child component in a loop body: emits a
56
+ // cross-template call needing separate registration. BF103 makes
57
+ // the requirement loud (same as mojo).
58
+ 'static-array-children': [{ code: 'BF103', severity: 'error' }],
59
+ // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
60
+ // call it inside a keyed `.map`. With the standalone-filter fix in
61
+ // place these reach the SAME BF103 (imported child in `.map`) as
62
+ // mojo — NOT BF101 — confirming the `.filter(...)` chain itself now
63
+ // lowers and the only remaining gate is the imported-child one.
64
+ 'todo-app': [{ code: 'BF103', severity: 'error' }],
65
+ 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
66
+ // Array-destructure loop param (`([k, v]) => …`) can't lower to a
67
+ // single Kolon loop variable (same BF104 as mojo).
68
+ 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
69
+ // Both BF103 (imported child) and BF104 (destructure) fire.
70
+ 'static-array-from-props-with-component': [
71
+ { code: 'BF103', severity: 'error' },
72
+ { code: 'BF104', severity: 'error' },
73
+ ],
74
+ // Rest-destructure `.map()` callbacks — the loop emitter raises the
75
+ // generic BF104 destructure refusal regardless of rest-vs-plain
76
+ // (same surface as mojo).
77
+ 'rest-destructure-object-in-map': [{ code: 'BF104', severity: 'error' }],
78
+ 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
79
+ 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
80
+ 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
81
+ // XSLATE-SPECIFIC (mojo passes this): the site/ui Button auto-infers a
82
+ // `<Slot>` sibling that spreads `{...props}` / `{...children.props}`
83
+ // onto its root element. Kolon hashref method args can't splat a
84
+ // runtime hash into named entries (no `%$h`-into-call-args form), so
85
+ // the adapter refuses the spread with BF101 rather than emit a broken
86
+ // render_child call. Mojo's EP `%= include` accepts a flat stash, so it
87
+ // lowers the same shape; this is a genuine engine divergence, pinned
88
+ // declaratively here.
89
+ 'button': [{ code: 'BF101', severity: 'error' }],
90
+ // `kbd` auto-infers the same `<Slot>` `{...props}` spread as `button`
91
+ // above — refused with BF101 for the identical Kolon engine reason, not a
92
+ // render-mismatch (so it's pinned here, not in `skipJsx`).
93
+ 'kbd': [{ code: 'BF101', severity: 'error' }],
94
+ // JS object literal in an attribute value (`style={{ … }}`) has no
95
+ // Kolon form — refused via the same gate as mojo (BF101).
96
+ 'style-3-signals': [{ code: 'BF101', severity: 'error' }],
97
+ // Dynamic `style={{ … }}` object: the Xslate adapter cleanly refuses it
98
+ // with BF101 (no idiomatic Kolon form). mojo *skips* this fixture because
99
+ // its EP path emits invalid Perl silently — Xslate's build-time diagnostic
100
+ // is the stronger contract, so it's pinned here rather than skipped.
101
+ 'style-object-dynamic': [{ code: 'BF101', severity: 'error' }],
102
+ // Tagged-template-literal call in a className — same family, same
103
+ // refusal (BF101).
104
+ 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
105
+ // NB: `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are NOT
106
+ // pinned here — unlike mojo (which refuses them), Xslate lowers them to
107
+ // `$bf.find` / `find_index` / `find_last` / `find_last_index` via the same
108
+ // Kolon-lambda mechanism as `.filter` / `.every` / `.some`, so they render.
109
+ },
110
+ // Template-primitive registry parity: same V1 surface as mojo, so the
111
+ // same two cases stay skipped (bespoke user import + customSerialize
112
+ // can't render server-side without user-supplied helper mappings).
113
+ skipTemplatePrimitives: new Set([
114
+ TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
115
+ TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
116
+ ]),
117
+ // Loop boundary markers for `@client` loops aren't emitted by the
118
+ // Xslate adapter yet (ported from mojo, which skips the same set).
119
+ skipMarkerConformance: new Set([
120
+ 'client-only',
121
+ 'client-only-loop-with-sibling-cond',
122
+ 'todo-app',
123
+ ]),
124
+ onRenderError: (err, id) => {
125
+ if (err instanceof XslateNotAvailableError) {
126
+ console.log(`Skipping [${id}]: ${err.message}`)
127
+ return true
128
+ }
129
+ return false
130
+ },
131
+ })
@@ -0,0 +1,218 @@
1
+ /**
2
+ * XslateAdapter — conditional-spread / nullish-omission / hyphenated-key
3
+ * regression tests (#textarea / #checkbox Phase 2b parity).
4
+ *
5
+ * Mirrors the Mojo adapter's same-named suites
6
+ * (`packages/adapter-mojolicious/src/__tests__/mojo-adapter.test.ts`), pinning
7
+ * the Kolon form of each ported shape. The shared adapter-conformance fixtures
8
+ * (`textarea`, `checkbox`) only exercise the falsy branch of the conditional
9
+ * spread and the unset branch of the optional attr, so these unit tests pin the
10
+ * truthy / present branches the fixtures can't reach.
11
+ */
12
+
13
+ import { test, expect, describe } from 'bun:test'
14
+ import { compileJSX } from '@barefootjs/jsx'
15
+ import type { ComponentIR } from '@barefootjs/jsx'
16
+ import { XslateAdapter } from '../adapter'
17
+
18
+ function compileToIR(source: string, adapter?: XslateAdapter): ComponentIR {
19
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
20
+ adapter: adapter ?? new XslateAdapter(),
21
+ outputIR: true,
22
+ })
23
+ const irFile = result.files.find(f => f.type === 'ir')
24
+ if (!irFile) throw new Error('No IR output')
25
+ return JSON.parse(irFile.content) as ComponentIR
26
+ }
27
+
28
+ function compileAndGenerate(source: string, adapter?: XslateAdapter) {
29
+ const a = adapter ?? new XslateAdapter()
30
+ const ir = compileToIR(source, a)
31
+ return a.generate(ir)
32
+ }
33
+
34
+ describe('XslateAdapter - conditional inline-object spread (textarea aria-describedby)', () => {
35
+ // `{...(cond ? { 'aria-describedby': cond } : {})}` lowers to a Kolon inline
36
+ // ternary of hashrefs so the falsy `{}` branch OMITS the key
37
+ // ($bf.spread_attrs does not emit empty entries). The shared fixture only
38
+ // exercises the falsy branch; this pins the truthy one.
39
+ test('emits a Kolon inline ternary of hashrefs through $bf.spread_attrs', () => {
40
+ const { template } = compileAndGenerate(`
41
+ function Box({ describedBy }: { describedBy?: string }) {
42
+ return <div {...(describedBy ? { 'aria-describedby': describedBy } : {})} />
43
+ }
44
+ `)
45
+ expect(template).toContain(
46
+ "$bf.spread_attrs($describedBy ? { 'aria-describedby' => $describedBy } : {})",
47
+ )
48
+ })
49
+
50
+ test('resolves the value reference and preserves the static key for a second prop', () => {
51
+ const { template } = compileAndGenerate(`
52
+ function Box({ label }: { label: string }) {
53
+ return <div {...(label ? { 'data-label': label } : {})} />
54
+ }
55
+ `)
56
+ expect(template).toContain(
57
+ "$bf.spread_attrs($label ? { 'data-label' => $label } : {})",
58
+ )
59
+ })
60
+
61
+ test('falls back to BF101 for a computed (non-static) object key', () => {
62
+ const adapter = new XslateAdapter()
63
+ const ir = compileToIR(`
64
+ function Box({ k, v }: { k?: string; v?: string }) {
65
+ return <div {...(v ? { [k]: v } : {})} />
66
+ }
67
+ `, adapter)
68
+ adapter.generate(ir)
69
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
70
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('XslateAdapter - local-const conditional-spread resolution (#checkbox icon)', () => {
75
+ // A FUNCTION-scope const holding a `cond ? {…} : {}` ternary, spread as a bare
76
+ // identifier (`{...attrs}`), resolves through the same Kolon
77
+ // ternary-of-hashrefs lowering as the inline form. CheckIcon's
78
+ // `const sizeAttrs = size ? {…} : {}` is exactly this shape.
79
+ test('resolves a bare-identifier spread of a function-scope conditional const', () => {
80
+ const { template } = compileAndGenerate(`
81
+ function Box({ flag }: { flag?: boolean }) {
82
+ const attrs = flag ? { 'data-on': 'yes' } : {}
83
+ return <div {...attrs} />
84
+ }
85
+ `)
86
+ expect(template).toContain(
87
+ "$bf.spread_attrs($flag ? { 'data-on' => 'yes' } : {})",
88
+ )
89
+ })
90
+
91
+ // A const that aliases another bare identifier must NOT be forwarded (loop
92
+ // guard): the resolver bails, so the spread falls through to the standard
93
+ // lowering emitting the bare `$attrs` variable.
94
+ test('does not forward a const that aliases another identifier (loop guard)', () => {
95
+ const { template } = compileAndGenerate(`
96
+ function Box({ other }: { other?: object }) {
97
+ const attrs = other
98
+ return <div {...attrs} />
99
+ }
100
+ `)
101
+ expect(template).toContain('$bf.spread_attrs($attrs)')
102
+ })
103
+ })
104
+
105
+ describe('XslateAdapter - Record<staticKeys,scalar>[propKey] spread value (#checkbox icon)', () => {
106
+ // `const sizeMap: Record<IconSize, number> = { sm: 16, ... }` indexed by a
107
+ // prop inside a conditional-spread object value lowers to an inline indexed
108
+ // Kolon hashref `{ ... }[$key]` (bracket index — Kolon rejects Perl's
109
+ // `->{$key}` arrow-deref). This is CheckIcon's
110
+ // `{ width: sizeMap[size], height: sizeMap[size] }` shape.
111
+ test('lowers an indexed module-const map to an inline bracket-indexed hashref', () => {
112
+ const { template } = compileAndGenerate(`
113
+ const sizeMap: Record<string, number> = { sm: 16, md: 20, lg: 24, xl: 32 }
114
+ function Box({ size }: { size?: string }) {
115
+ const attrs = size ? { width: sizeMap[size] } : {}
116
+ return <div {...attrs} />
117
+ }
118
+ `)
119
+ expect(template).toContain(
120
+ "{ 'sm' => 16, 'md' => 20, 'lg' => 24, 'xl' => 32 }[$size]",
121
+ )
122
+ // Must NOT emit the Perl arrow-deref form (Kolon rejects it).
123
+ expect(template).not.toContain('->{$size}')
124
+ })
125
+
126
+ test('lowers string-valued record maps too', () => {
127
+ const { template } = compileAndGenerate(`
128
+ const labelMap: Record<string, string> = { a: 'Alpha', b: 'Beta' }
129
+ function Box({ k }: { k?: string }) {
130
+ const attrs = k ? { 'data-label': labelMap[k] } : {}
131
+ return <div {...attrs} />
132
+ }
133
+ `)
134
+ expect(template).toContain("{ 'a' => 'Alpha', 'b' => 'Beta' }[$k]")
135
+ })
136
+
137
+ // A non-scalar record value (object) is out of shape: the spread object value
138
+ // can't lower, so the whole spread falls back to BF101.
139
+ test('refuses a non-scalar record value with BF101 (out-of-shape fallback)', () => {
140
+ const adapter = new XslateAdapter()
141
+ const ir = compileToIR(`
142
+ const sizeMap: Record<string, object> = { sm: { w: 1 } }
143
+ function Box({ size }: { size?: string }) {
144
+ const attrs = size ? { width: sizeMap[size] } : {}
145
+ return <div {...attrs} />
146
+ }
147
+ `, adapter)
148
+ adapter.generate(ir)
149
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
150
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
151
+ })
152
+ })
153
+
154
+ describe('XslateAdapter - props-object inherited-attribute enumeration (#checkbox)', () => {
155
+ // A SolidJS props-object component reads inherited attributes (`props.id`)
156
+ // not enumerated in `propsParams`. The bare optional attribute must be guarded
157
+ // with Kolon `defined` so it's omitted when unset (Hono parity), even though
158
+ // `id` isn't a declared param.
159
+ test('guards a props-object bare optional attr (props.id) with defined', () => {
160
+ const { template } = compileAndGenerate(`
161
+ "use client"
162
+ interface P { tone?: string }
163
+ export function Widget(props: P) {
164
+ return <button id={props.id}>x</button>
165
+ }
166
+ `)
167
+ expect(template).toContain(': if (defined $id) {')
168
+ expect(template).toContain('id="<: $id :>"')
169
+ })
170
+ })
171
+
172
+ describe('XslateAdapter - hyphenated child attr hash key (#checkbox)', () => {
173
+ // A child component prop whose JSX name isn't a bare Kolon identifier
174
+ // (`<CheckIcon data-slot="..."/>`) must be quoted in the `render_child`
175
+ // hashref — an unquoted `data-slot => ...` parses as `data - slot`.
176
+ test('quotes a hyphenated child attribute name in render_child', () => {
177
+ const { template } = compileAndGenerate(`
178
+ "use client"
179
+ import { Leaf } from './leaf'
180
+ export function Host() {
181
+ return <div><Leaf data-slot="indicator" size="sm" /></div>
182
+ }
183
+ `)
184
+ expect(template).toContain("'data-slot' => 'indicator'")
185
+ // A bare-identifier name stays unquoted.
186
+ expect(template).toContain('size => ')
187
+ expect(template).not.toContain('data-slot => ')
188
+ })
189
+ })
190
+
191
+ describe('XslateAdapter - nullish optional-attribute omission (textarea rows)', () => {
192
+ // A no-destructure-default, nillable-typed prop is `undef` when the caller
193
+ // omits it; guard its bare-reference attribute with Kolon `defined` so it
194
+ // DROPS instead of rendering `attr=""` — matching Hono's nullish-attribute
195
+ // omission. Concrete/defaulted props are never `undef` and stay
196
+ // unconditional.
197
+ test('guards a no-default nillable attr with a Kolon defined check', () => {
198
+ const { template } = compileAndGenerate(`
199
+ function C({ rows }: { rows?: number }) {
200
+ return <textarea rows={rows} />
201
+ }
202
+ `)
203
+ expect(template).toContain(': if (defined $rows) {')
204
+ expect(template).toContain('rows="<: $rows :>"')
205
+ })
206
+
207
+ test('leaves a defaulted attr unconditional (scope did not widen)', () => {
208
+ const { template } = compileAndGenerate(`
209
+ function C({ value = '' }: { value?: string }) {
210
+ return <textarea value={value} />
211
+ }
212
+ `)
213
+ // `value` has a destructure default → never undef → unconditional, exactly
214
+ // like Hono's value="".
215
+ expect(template).toContain('value="<: $value :>"')
216
+ expect(template).not.toContain('defined $value')
217
+ })
218
+ })