@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.
- package/dist/adapter/__tests__/boolean-result.test.d.ts +2 -0
- package/dist/adapter/__tests__/boolean-result.test.d.ts.map +1 -0
- 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 +1143 -0
- package/dist/adapter/mojo-adapter.d.ts +219 -0
- package/dist/adapter/mojo-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 +1163 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1143 -0
- package/dist/test-render.d.ts +38 -0
- package/dist/test-render.d.ts.map +1 -0
- package/lib/BarefootJS.pm +745 -0
- package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +150 -0
- package/lib/Mojolicious/Plugin/BarefootJS.pm +104 -0
- package/package.json +65 -0
- package/src/__tests__/mojo-adapter.test.ts +940 -0
- package/src/__tests__/mojo-streaming.test.ts +136 -0
- package/src/__tests__/scaffold.test.ts +224 -0
- package/src/__tests__/template-base-name.test.ts +26 -0
- package/src/adapter/__tests__/boolean-result.test.ts +106 -0
- package/src/adapter/boolean-result.ts +126 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/mojo-adapter.ts +1931 -0
- package/src/build.ts +37 -0
- package/src/index.ts +8 -0
- package/src/test-render.ts +704 -0
|
@@ -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
|
+
}
|