@barefootjs/mojolicious 0.1.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,136 @@
1
+ /**
2
+ * MojoAdapter - Streaming SSR Tests
3
+ *
4
+ * Tests the renderAsync method and streaming-related output.
5
+ */
6
+
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { MojoAdapter } from '../adapter/mojo-adapter'
9
+ import type { IRAsync, IRElement, IRComponent } from '@barefootjs/jsx'
10
+
11
+ describe('MojoAdapter - Streaming SSR', () => {
12
+ const adapter = new MojoAdapter()
13
+
14
+ const loc = { file: '', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } } as const
15
+
16
+ function asyncNode(id: string, fallbackText: string): IRAsync {
17
+ return {
18
+ type: 'async',
19
+ id,
20
+ fallback: {
21
+ type: 'element',
22
+ tag: 'p',
23
+ attrs: [],
24
+ events: [],
25
+ ref: null,
26
+ children: [{ type: 'text', value: fallbackText, loc }],
27
+ slotId: null,
28
+ needsScope: false,
29
+ loc,
30
+ } as IRElement,
31
+ children: [
32
+ {
33
+ type: 'component',
34
+ name: 'ProductDetail',
35
+ props: [],
36
+ template: 'ProductDetail',
37
+ slotId: null,
38
+ children: [],
39
+ loc,
40
+ } as IRComponent,
41
+ ],
42
+ loc,
43
+ }
44
+ }
45
+
46
+ test('renderAsync generates bf->async_boundary call with fallback', () => {
47
+ const output = adapter.renderAsync(asyncNode('a0', 'Loading...'))
48
+
49
+ // Should contain the async_boundary call with the ID
50
+ expect(output).toContain("async_boundary('a0'")
51
+ // Should contain the fallback content
52
+ expect(output).toContain('Loading...')
53
+ })
54
+
55
+ // #1298: the previous renderAsync emitted
56
+ // `<%== bf->async_boundary('a0', begin %>…<% end) %>`
57
+ // which split the call across template-text and `<%== %>` regions —
58
+ // the inner `%>` closed the outer interpolation, leaving the trailing
59
+ // `)` in plain template text and breaking Mojo's lexer. The fix is to
60
+ // capture the fallback into a CODE ref in its own action and pass the
61
+ // variable to async_boundary. These tests pin the structural
62
+ // invariants of the captured-variable shape so a regression to the
63
+ // inlined form (or any shape that re-introduces unbalanced `%>`)
64
+ // fails here, not at template-parse time.
65
+ describe('renderAsync emits balanced Mojo syntax (#1298)', () => {
66
+ test('captures fallback into its own `begin %>…<% end %>` action before calling async_boundary', () => {
67
+ const output = adapter.renderAsync(asyncNode('a0', 'Loading...'))
68
+
69
+ // A capture action precedes the call.
70
+ expect(output).toMatch(/<%\s*my\s+\$bf_async_fallback_a0\s*=\s*begin\s*%>/)
71
+ expect(output).toContain('<% end %>')
72
+ // The call site references the variable, not an inlined `begin`.
73
+ expect(output).toMatch(/<%==\s*bf->async_boundary\('a0',\s*\$bf_async_fallback_a0\s*\)\s*%>/)
74
+ // No nested `begin` inside the `<%== ... %>` block — that was the
75
+ // exact malformation #1298 fixed.
76
+ expect(output).not.toMatch(/<%==\s*bf->async_boundary\([^)]*begin/)
77
+ })
78
+
79
+ test('every `<%` / `<%==` opener has a matching `%>` closer', () => {
80
+ const output = adapter.renderAsync(asyncNode('a0', 'Loading...'))
81
+
82
+ // Count `<%`-prefixed openers (including `<%==`) vs `%>` closers.
83
+ // Unbalanced counts indicate a stray `%>` (the #1298 failure mode)
84
+ // or a stray opener.
85
+ const openers = (output.match(/<%/g) ?? []).length
86
+ const closers = (output.match(/%>/g) ?? []).length
87
+ expect(openers).toBe(closers)
88
+ })
89
+
90
+ test('multiple <Async> boundaries get distinct capture variable names', () => {
91
+ const a = adapter.renderAsync(asyncNode('a0', 'first'))
92
+ const b = adapter.renderAsync(asyncNode('a1', 'second'))
93
+
94
+ expect(a).toContain('$bf_async_fallback_a0')
95
+ expect(b).toContain('$bf_async_fallback_a1')
96
+ // Cross-contamination would let one boundary's resolve overwrite
97
+ // another's fallback variable on the page.
98
+ expect(a).not.toContain('$bf_async_fallback_a1')
99
+ expect(b).not.toContain('$bf_async_fallback_a0')
100
+ })
101
+ })
102
+
103
+ test('renderNode dispatches async type correctly', () => {
104
+ const asyncNode: IRAsync = {
105
+ type: 'async',
106
+ id: 'a1',
107
+ fallback: {
108
+ type: 'text',
109
+ value: 'Please wait...',
110
+ loc: { file: '', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
111
+ },
112
+ children: [],
113
+ loc: { file: '', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
114
+ }
115
+
116
+ const output = adapter.renderNode(asyncNode)
117
+
118
+ expect(output).toContain("'a1'")
119
+ expect(output).toContain('Please wait...')
120
+ })
121
+
122
+ test('renderNode dispatches provider type (transparent)', () => {
123
+ const providerNode = {
124
+ type: 'provider' as const,
125
+ contextName: 'ThemeContext',
126
+ valueProp: { name: 'value', value: 'dark', dynamic: false },
127
+ children: [
128
+ { type: 'text' as const, value: 'child content', loc: { file: '', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } } },
129
+ ],
130
+ loc: { file: '', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
131
+ }
132
+
133
+ const output = adapter.renderNode(providerNode)
134
+ expect(output).toBe('child content')
135
+ })
136
+ })
@@ -0,0 +1,224 @@
1
+ // Specification-as-test for `bun create barefootjs --adapter mojo <project-name>`.
2
+ //
3
+ // This file verifies that the Mojo scaffold satisfies both the
4
+ // cross-adapter contract defined in `create-barefootjs` and the
5
+ // Mojo-specific wiring:
6
+ //
7
+ // - `barefoot.config.ts` targets `@barefootjs/mojolicious/build` and
8
+ // uses `clientJsBasePath: '/static/components/'`.
9
+ // - `app.pl` forwards `/static/*` URLs to the on-disk static paths
10
+ // (Mojolicious's built-in dispatcher does not honour URL prefixes,
11
+ // so the explicit routes are load-bearing — without them every
12
+ // stylesheet and client bundle 404s in the browser).
13
+ // - `lib/BarefootJS.pm` is vendored, `cpanfile` lists Mojolicious,
14
+ // and `app.pl` uses `register_components_from_manifest` so the
15
+ // manifest-driven child rendering works without per-component wire-up.
16
+ //
17
+ // BAREFOOT_CREATE_INTEGRATION=1 bun test src/__tests__/scaffold.test.ts
18
+
19
+ import { describe, test, expect, beforeAll } from 'bun:test'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import path from 'node:path'
22
+ import { spawnSync } from 'node:child_process'
23
+ import { mkdtempSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { fileURLToPath } from 'node:url'
26
+ import {
27
+ assertScaffoldContract,
28
+ ensureCreateCli,
29
+ type ScaffoldFacts,
30
+ } from '@barefootjs/adapter-tests'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers (thin wrappers around the compiled create-barefootjs CLI)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const CREATE_PKG_DIR = path.join(
37
+ fileURLToPath(new URL('.', import.meta.url)),
38
+ '../../../create-barefootjs',
39
+ )
40
+ const CREATE_CLI = path.join(CREATE_PKG_DIR, 'dist', 'index.js')
41
+
42
+ function mktmp(): string {
43
+ return mkdtempSync(path.join(tmpdir(), 'bf-mojo-scaffold-test-'))
44
+ }
45
+
46
+ interface RunResult {
47
+ exitCode: number | null
48
+ stdout: string
49
+ stderr: string
50
+ }
51
+
52
+ function runCreate(
53
+ args: string[],
54
+ opts: { cwd: string; env?: Record<string, string> },
55
+ ): RunResult {
56
+ ensureCreateCli(CREATE_PKG_DIR)
57
+ const result = spawnSync('node', [CREATE_CLI, ...args], {
58
+ cwd: opts.cwd,
59
+ env: {
60
+ ...process.env,
61
+ ...opts.env,
62
+ npm_config_user_agent: undefined,
63
+ },
64
+ encoding: 'utf-8',
65
+ })
66
+ return {
67
+ exitCode: result.status,
68
+ stdout: result.stdout ?? '',
69
+ stderr: result.stderr ?? '',
70
+ }
71
+ }
72
+
73
+ const INTEGRATION = process.env.BAREFOOT_CREATE_INTEGRATION === '1'
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Happy-path scenario — `bun create barefootjs --adapter mojo mojo-app`
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe.skipIf(!INTEGRATION)(
80
+ 'Scenario: bun create barefootjs --adapter mojo <project-name>',
81
+ () => {
82
+ let result: RunResult
83
+ let projectDir: string
84
+
85
+ beforeAll(() => {
86
+ const cwd = mktmp()
87
+ result = runCreate(['mojo-app', '--adapter', 'mojo'], { cwd })
88
+ projectDir = path.join(cwd, 'mojo-app')
89
+ })
90
+
91
+ test('satisfies the cross-adapter scaffold contract', () => {
92
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
93
+ assertScaffoldContract({
94
+ exitCode: result.exitCode,
95
+ stdout: result.stdout,
96
+ projectDir,
97
+ adapterPackageName: '@barefootjs/mojolicious',
98
+ devReload: {
99
+ // The `BarefootJS::DevReload` plugin registers `/_bf/reload` as
100
+ // an SSE endpoint and exposes `bf_dev_snippet` to embed the
101
+ // reload subscriber in the layout. The plugin self-disables when
102
+ // `app->mode eq 'production'`, so the production gate is
103
+ // handled at the library layer.
104
+ subscribesBrowserInDev:
105
+ app.includes("plugin 'BarefootJS::DevReload'") &&
106
+ app.includes('bf_dev_snippet'),
107
+ gatedToDev: true,
108
+ sentinelSseEndpoint: '/_bf/reload',
109
+ },
110
+ } satisfies ScaffoldFacts)
111
+ })
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Mojo-specific: static asset routing
115
+ // -------------------------------------------------------------------------
116
+
117
+ describe('app.pl serves static assets', () => {
118
+ // Mojolicious's built-in static dispatcher does not honour URL
119
+ // prefixes. The scaffold's `barefoot.config.ts` and layout `<link>`s
120
+ // all reference `/static/*` URLs, so `app.pl` needs explicit
121
+ // forwarding routes — without them every stylesheet and client bundle
122
+ // 404s in the browser even though the SSR HTML rendered correctly.
123
+ test('forwards /static/components/* to dist/client/* (clientJsBasePath)', () => {
124
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
125
+ expect(app).toMatch(/get\s+'\/static\/components\/\*asset'/)
126
+ expect(app).toContain("reply->static('client/'")
127
+ })
128
+
129
+ test('forwards /static/* to public/* (handwritten stylesheets)', () => {
130
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
131
+ expect(app).toMatch(/get\s+'\/static\/\*asset'/)
132
+ })
133
+
134
+ test('mounts public/ and dist/ on app->static->paths', () => {
135
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
136
+ expect(app).toContain("app->home->child('public')")
137
+ expect(app).toContain("app->home->child('dist')")
138
+ })
139
+ })
140
+
141
+ // -------------------------------------------------------------------------
142
+ // Mojo-specific: plugin wiring and Perl deps
143
+ // -------------------------------------------------------------------------
144
+
145
+ describe('mojo wiring', () => {
146
+ test('lib/BarefootJS.pm is vendored', () => {
147
+ expect(existsSync(path.join(projectDir, 'lib', 'BarefootJS.pm'))).toBe(true)
148
+ })
149
+
150
+ test('lib/Mojolicious/Plugin/BarefootJS/DevReload.pm is vendored', () => {
151
+ // The dev-reload plugin lives under a nested namespace so
152
+ // `plugin 'BarefootJS::DevReload'` resolves to its file. A
153
+ // missing vendor copy would crash app.pl at boot.
154
+ expect(
155
+ existsSync(
156
+ path.join(projectDir, 'lib', 'Mojolicious', 'Plugin', 'BarefootJS', 'DevReload.pm'),
157
+ ),
158
+ ).toBe(true)
159
+ })
160
+
161
+ test('cpanfile lists Mojolicious', () => {
162
+ const cp = readFileSync(path.join(projectDir, 'cpanfile'), 'utf-8')
163
+ expect(cp).toMatch(/Mojolicious/)
164
+ })
165
+
166
+ test('plugin auto-loads manifest — no per-route register_components_from_manifest call', () => {
167
+ // After #1416, `Mojolicious::Plugin::BarefootJS` reads the
168
+ // build manifest at plugin-register time and installs a
169
+ // `before_render` hook that wires up child renderers and
170
+ // seeds the stash automatically. The scaffold's `app.pl`
171
+ // therefore no longer mentions either symbol directly — the
172
+ // user can `bf add <component>` and refresh the browser
173
+ // without touching the Perl file.
174
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
175
+ expect(app).not.toContain('register_components_from_manifest')
176
+ expect(app).not.toContain("app->home->child('dist/templates/manifest.json')")
177
+ })
178
+
179
+ test('barefoot.config.ts targets the mojolicious adapter', () => {
180
+ const cfg = readFileSync(path.join(projectDir, 'barefoot.config.ts'), 'utf-8')
181
+ expect(cfg).toContain("from '@barefootjs/mojolicious/build'")
182
+ expect(cfg).toContain("clientJsBasePath: '/static/components/'")
183
+ })
184
+
185
+ test('layout stylesheets point at /static/*.css', () => {
186
+ // The forwarding `/static/*asset` route serves these from
187
+ // `public/`, so the `<link href>`s in the rendered HTML must
188
+ // match. A drift here would make every page render unstyled.
189
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
190
+ expect(app).toContain('/static/tokens.css')
191
+ expect(app).toContain('/static/styles.css')
192
+ expect(app).toContain('/static/uno.css')
193
+ })
194
+ })
195
+
196
+ // -------------------------------------------------------------------------
197
+ // Mojo-specific: dev-reload wiring (detailed)
198
+ // -------------------------------------------------------------------------
199
+
200
+ describe('dev reload wiring', () => {
201
+ test('app.pl registers the DevReload plugin', () => {
202
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
203
+ expect(app).toContain("plugin 'BarefootJS::DevReload'")
204
+ })
205
+
206
+ test('layout calls bf_dev_snippet inside <body>', () => {
207
+ // Snippet must land inside `<body>` so the inline `<script>`
208
+ // executes after the page elements are parsed; emitting it
209
+ // in `<head>` would race scroll-restoration against the page content.
210
+ const app = readFileSync(path.join(projectDir, 'app.pl'), 'utf-8')
211
+ const body = app.match(/<body>([\s\S]*?)<\/body>/)?.[1] ?? ''
212
+ expect(body).toContain('bf_dev_snippet')
213
+ })
214
+ })
215
+
216
+ // -------------------------------------------------------------------------
217
+ // Next-step instructions
218
+ // -------------------------------------------------------------------------
219
+
220
+ test('the printed next-step uses the chosen target directory', () => {
221
+ expect(result.stdout).toContain('cd mojo-app')
222
+ })
223
+ },
224
+ )
@@ -0,0 +1,26 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { templateBaseName } from '../test-render'
3
+
4
+ describe('templateBaseName (#1297)', () => {
5
+ test('strips Mojo adapter `.html.ep` extension, not just the last dot segment', () => {
6
+ // The crux of the #1297 follow-up: a naive `/\.[^.]+$/` would leave
7
+ // `Counter.html` here, which would miss the `irsByName` lookup
8
+ // (componentName is `Counter`) and pair every sibling template to
9
+ // the entry-point IR.
10
+ expect(templateBaseName('component/Counter.html.ep', '.html.ep')).toBe('Counter')
11
+ expect(templateBaseName('theme/ThemeLabel.html.ep', '.html.ep')).toBe('ThemeLabel')
12
+ expect(templateBaseName('src/very/nested/path/Outer.html.ep', '.html.ep')).toBe('Outer')
13
+ })
14
+
15
+ test('works for single-segment extensions too', () => {
16
+ expect(templateBaseName('dir/Counter.tmpl', '.tmpl')).toBe('Counter')
17
+ })
18
+
19
+ test('returns the bare filename when the extension does not match', () => {
20
+ expect(templateBaseName('dir/Counter.txt', '.html.ep')).toBe('Counter.txt')
21
+ })
22
+
23
+ test('handles paths with no directory component', () => {
24
+ expect(templateBaseName('Counter.html.ep', '.html.ep')).toBe('Counter')
25
+ })
26
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { isAriaBooleanAttr, isBooleanResultExpr } from '../boolean-result'
3
+
4
+ describe('isBooleanResultExpr', () => {
5
+ describe('detected as boolean-result', () => {
6
+ test.each([
7
+ // Top-level comparison
8
+ ['count() > 0'],
9
+ ['x === y'],
10
+ ['a !== b'],
11
+ ['x >= 10'],
12
+ ['a == b'],
13
+ // Unary logical NOT
14
+ ['!accepted()'],
15
+ ['!ok'],
16
+ // Boolean literals
17
+ ['true'],
18
+ ['false'],
19
+ // Logical combinator with both sides boolean
20
+ ['x > 0 && y < 10'],
21
+ ['!a || b === c'],
22
+ // Conditional with both branches boolean
23
+ ['cond ? true : false'],
24
+ ['x > 0 ? x === 1 : !y'],
25
+ ])('"%s" is boolean-result', expr => {
26
+ expect(isBooleanResultExpr(expr)).toBe(true)
27
+ })
28
+ })
29
+
30
+ describe('not boolean-result', () => {
31
+ test.each([
32
+ // Bare identifier — could be anything; the adapter has no
33
+ // type info from source text. Leave unwrapped.
34
+ ['accepted'],
35
+ ['count'],
36
+ // Call expression — same reason.
37
+ ['accepted()'],
38
+ ['user.isAdmin()'],
39
+ // Member access
40
+ ['props.checked'],
41
+ // Numeric / string / null literal
42
+ ['0'],
43
+ ['"hello"'],
44
+ ['null'],
45
+ // Template literal (handled by a separate emit path)
46
+ ['`${name}`'],
47
+ // Logical fallback whose right side is non-boolean — `||
48
+ // 'fallback'` returns a string, not a boolean
49
+ ['x() || "fallback"'],
50
+ // Conditional with non-boolean branches
51
+ ['cond ? "yes" : "no"'],
52
+ ['ok() ? count() : 0'],
53
+ // Arithmetic — `+` is not a comparison
54
+ ['a + b'],
55
+ ])('"%s" is NOT boolean-result', expr => {
56
+ expect(isBooleanResultExpr(expr)).toBe(false)
57
+ })
58
+ })
59
+
60
+ test('returns false for unparseable input', () => {
61
+ // `parseExpression` always returns a `ParsedExpr` — for shapes it
62
+ // can't categorise it lands on `{ kind: 'unsupported' }`, which
63
+ // the classifier falls through to the default case and declines
64
+ // to wrap. The contract here is "do not throw, do not wrap".
65
+ expect(isBooleanResultExpr('???invalid<<')).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('isAriaBooleanAttr', () => {
70
+ test.each([
71
+ // Strict boolean state.
72
+ 'aria-atomic',
73
+ 'aria-busy',
74
+ 'aria-disabled',
75
+ 'aria-hidden',
76
+ 'aria-modal',
77
+ 'aria-multiline',
78
+ 'aria-multiselectable',
79
+ 'aria-readonly',
80
+ 'aria-required',
81
+ // Tri-state.
82
+ 'aria-checked',
83
+ 'aria-pressed',
84
+ ])('%s is recognised as ARIA boolean', (name) => {
85
+ expect(isAriaBooleanAttr(name)).toBe(true)
86
+ })
87
+
88
+ test.each([
89
+ // String-valued / token-valued ARIA attributes — wrapping with
90
+ // `bool_str` would coerce a user-supplied string to "true"/"false".
91
+ 'aria-label',
92
+ 'aria-labelledby',
93
+ 'aria-describedby',
94
+ 'aria-current', // page | step | location | … | true | false
95
+ 'aria-sort', // ascending | descending | none | other
96
+ 'aria-haspopup', // false | true | menu | listbox | tree | grid | dialog
97
+ 'aria-invalid', // false | true | grammar | spelling
98
+ // Non-ARIA attributes — should also fall through.
99
+ 'disabled',
100
+ 'data-active',
101
+ 'class',
102
+ 'id',
103
+ ])('%s is NOT recognised as ARIA boolean', (name) => {
104
+ expect(isAriaBooleanAttr(name)).toBe(false)
105
+ })
106
+ })
@@ -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
+ * Mojolicious EP Template Adapter Exports
3
+ */
4
+
5
+ export { MojoAdapter, mojoAdapter } from './mojo-adapter'
6
+ export type { MojoAdapterOptions } from './mojo-adapter'