@barefootjs/blade 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.
Files changed (72) hide show
  1. package/README.md +73 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/blade-adapter.d.ts +537 -0
  5. package/dist/adapter/blade-adapter.d.ts.map +1 -0
  6. package/dist/adapter/boolean-result.d.ts +76 -0
  7. package/dist/adapter/boolean-result.d.ts.map +1 -0
  8. package/dist/adapter/emit-context.d.ts +105 -0
  9. package/dist/adapter/emit-context.d.ts.map +1 -0
  10. package/dist/adapter/expr/array-method.d.ts +74 -0
  11. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  12. package/dist/adapter/expr/emitters.d.ts +176 -0
  13. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  14. package/dist/adapter/expr/operand.d.ts +25 -0
  15. package/dist/adapter/expr/operand.d.ts.map +1 -0
  16. package/dist/adapter/index.d.ts +6 -0
  17. package/dist/adapter/index.d.ts.map +1 -0
  18. package/dist/adapter/index.js +189060 -0
  19. package/dist/adapter/lib/blade-naming.d.ts +128 -0
  20. package/dist/adapter/lib/blade-naming.d.ts.map +1 -0
  21. package/dist/adapter/lib/constants.d.ts +21 -0
  22. package/dist/adapter/lib/constants.d.ts.map +1 -0
  23. package/dist/adapter/lib/ir-scope.d.ts +48 -0
  24. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +84 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +34 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +72 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +27 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189080 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189079 -0
  43. package/package.json +67 -0
  44. package/php/composer.json +32 -0
  45. package/php/src/BladeBackend.php +197 -0
  46. package/php/src/naming.php +84 -0
  47. package/php/tests/test_render.php +159 -0
  48. package/src/__tests__/blade-adapter-unit.test.ts +392 -0
  49. package/src/__tests__/blade-adapter.test.ts +52 -0
  50. package/src/__tests__/blade-counter.test.ts +63 -0
  51. package/src/__tests__/blade-query-href.test.ts +113 -0
  52. package/src/__tests__/blade-spread-attrs.test.ts +235 -0
  53. package/src/adapter/analysis/component-tree.ts +119 -0
  54. package/src/adapter/blade-adapter.ts +1912 -0
  55. package/src/adapter/boolean-result.ts +168 -0
  56. package/src/adapter/emit-context.ts +117 -0
  57. package/src/adapter/expr/array-method.ts +345 -0
  58. package/src/adapter/expr/emitters.ts +636 -0
  59. package/src/adapter/expr/operand.ts +35 -0
  60. package/src/adapter/index.ts +6 -0
  61. package/src/adapter/lib/blade-naming.ts +180 -0
  62. package/src/adapter/lib/constants.ts +33 -0
  63. package/src/adapter/lib/ir-scope.ts +83 -0
  64. package/src/adapter/lib/types.ts +30 -0
  65. package/src/adapter/memo/seed.ts +135 -0
  66. package/src/adapter/props/prop-classes.ts +66 -0
  67. package/src/adapter/spread/spread-codegen.ts +179 -0
  68. package/src/adapter/value/parsed-literal.ts +75 -0
  69. package/src/build.ts +37 -0
  70. package/src/conformance-pins.ts +86 -0
  71. package/src/index.ts +9 -0
  72. package/src/test-render.ts +782 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Ported from `packages/adapter-twig/src/__tests__/twig-adapter-unit.test.ts`
3
+ * (itself ported from the Jinja-specific describes, ultimately from
4
+ * Xslate's): SSR context propagation, memo seeding, signal seeding +
5
+ * data-key, #1966 @client attribute deferral, #2073 map_eval, #2018 P2
6
+ * predicate lowering, #2075 searchParams()-derived memo seeding. Expected
7
+ * template strings are translated to Blade syntax (`$bf->method(...)`,
8
+ * `@php($x = ...)`, `[]` empty-env arrays instead of Twig's `{}`).
9
+ * `runAdapterConformanceTests` itself and `src/test-render.ts` are
10
+ * workstream C and are NOT ported here (they live in this same package,
11
+ * but as their own files).
12
+ *
13
+ * One divergence from the byte-for-byte Twig port: `emitEvalEnvArg`'s empty
14
+ * capture set is PHP's empty ARRAY `[]`, not Twig's empty HASH `{}` (see
15
+ * `expr/array-method.ts`'s docstring) — every `", true, {})"` / `", false,
16
+ * {})"` / `"{'tag': tag}"` expectation below becomes `", true, [])"` / `",
17
+ * false, [])"` / `"['tag' => $tag]"`.
18
+ *
19
+ * The "in-template self-reference seeding" behaviour is UNCHANGED from Twig
20
+ * (not inverted back to Kolon's skip): verified empirically (this package's
21
+ * `php/` smoke tests) that Blade's `@php($x = $x + 1)` resolves the
22
+ * right-hand `$x` from the enclosing scope before assignment (PHP has no
23
+ * block-level shadowing the way Kolon's `my` does) — so a same-name signal
24
+ * IS seeded in-template here too.
25
+ */
26
+
27
+ import { describe, test, expect } from 'bun:test'
28
+ import { BladeAdapter } from '../adapter'
29
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
30
+
31
+ // =============================================================================
32
+ // Helpers
33
+ // =============================================================================
34
+
35
+ function compileToIR(source: string): ComponentIR {
36
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
37
+ adapter: new BladeAdapter(),
38
+ outputIR: true,
39
+ })
40
+ const irFile = result.files.find(f => f.type === 'ir')
41
+ if (!irFile) throw new Error('No IR output')
42
+ return JSON.parse(irFile.content) as ComponentIR
43
+ }
44
+
45
+ function compileAndGenerate(source: string) {
46
+ return new BladeAdapter().generate(compileToIR(source))
47
+ }
48
+
49
+ // =============================================================================
50
+ // Blade-Specific Tests
51
+ // =============================================================================
52
+
53
+ describe('BladeAdapter - SSR context propagation (#1297)', () => {
54
+ // `<Ctx.Provider value>` brackets its children with inline provide/revoke
55
+ // calls (both return '' so the `{{ … }}` interpolation discards them);
56
+ // descendant `useContext` consumers read the value during the same
57
+ // render.
58
+ test('provider brackets children with provide_context / revoke_context', () => {
59
+ const { template } = compileAndGenerate(`
60
+ 'use client'
61
+ import { createContext, useContext } from '@barefootjs/client'
62
+ const ThemeContext = createContext('light')
63
+ export function ThemeRoot() {
64
+ return <div><ThemeContext.Provider value="dark"><ThemeLabel /></ThemeContext.Provider></div>
65
+ }
66
+ function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span> }
67
+ `)
68
+ expect(template).toContain("$bf->provide_context('ThemeContext', 'dark')")
69
+ expect(template).toContain("$bf->revoke_context('ThemeContext')")
70
+ expect(template.indexOf('provide_context')).toBeLessThan(template.indexOf('render_child'))
71
+ expect(template.indexOf('render_child')).toBeLessThan(template.indexOf('revoke_context'))
72
+ })
73
+
74
+ test('consumer seeds its local from use_context with the createContext default', () => {
75
+ const { template } = compileAndGenerate(`
76
+ 'use client'
77
+ import { createContext, useContext } from '@barefootjs/client'
78
+ const ThemeContext = createContext('light')
79
+ export function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span> }
80
+ `)
81
+ expect(template).toContain("@php($theme = $bf->use_context('ThemeContext', 'light'))")
82
+ })
83
+ })
84
+
85
+ describe('BladeAdapter - prop-derived memo SSR seeding (#1297)', () => {
86
+ // A memo whose body can't be statically folded (`props.value * 10`) gets a
87
+ // `null` SSR default; the adapter computes it in-template from the seeded
88
+ // prop var so the child renders the value instead of empty.
89
+ test('seeds a prop-derived memo from the prop var', () => {
90
+ const { template } = compileAndGenerate(`
91
+ 'use client'
92
+ import { createMemo } from '@barefootjs/client'
93
+ export function Child(props: { value: number }) {
94
+ const displayValue = createMemo(() => props.value * 10)
95
+ return <span>{displayValue()}</span>
96
+ }
97
+ `)
98
+ expect(template).toContain('@php($displayValue = $value * 10)')
99
+ })
100
+
101
+ test('seeds a memo over a destructured prop', () => {
102
+ const { template } = compileAndGenerate(`
103
+ 'use client'
104
+ import { createMemo } from '@barefootjs/client'
105
+ export function Child({ value }: { value: number }) {
106
+ const displayValue = createMemo(() => value * 10)
107
+ return <span>{displayValue()}</span>
108
+ }
109
+ `)
110
+ expect(template).toContain('@php($displayValue = $value * 10)')
111
+ })
112
+ })
113
+
114
+ describe('BladeAdapter - prop-derived signal SSR seeding + data-key (#1297, toggle-shared)', () => {
115
+ test('seeds a prop-derived (different-name) signal from the prop var', () => {
116
+ const { template } = compileAndGenerate(`
117
+ 'use client'
118
+ import { createSignal } from '@barefootjs/client'
119
+ export function Item(props: { defaultOn?: boolean }) {
120
+ const [on, setOn] = createSignal(props.defaultOn ?? false)
121
+ return <button>{on() ? 'ON' : 'OFF'}</button>
122
+ }
123
+ `)
124
+ expect(template).toContain('@php($on = ($defaultOn ?? false))')
125
+ })
126
+
127
+ // Verified empirically (this package's `php/` smoke tests): a plain PHP
128
+ // assignment `@php($x = $x + 1)` reads the SAME `$x` the render already
129
+ // extracted, no shadowing hazard — so a same-name signal IS seeded
130
+ // in-template here — strictly more correct than leaving it on the static
131
+ // default. See `memo/seed.ts`'s file header.
132
+ test('DOES in-template-seed a same-name signal (no shadow hazard in Blade)', () => {
133
+ const { template } = compileAndGenerate(`
134
+ 'use client'
135
+ import { createSignal } from '@barefootjs/client'
136
+ export function C(props: { x?: number }) {
137
+ const [x, setX] = createSignal(props.x ?? 7)
138
+ return <span>{x()}</span>
139
+ }
140
+ `)
141
+ expect(template).toContain('@php($x = ($x ?? 7))')
142
+ })
143
+
144
+ test('emits data_key_attr on the component root', () => {
145
+ const { template } = compileAndGenerate(`
146
+ export function Item() { return <div class="x">hi</div> }
147
+ `)
148
+ expect(template).toContain('$bf->data_key_attr()')
149
+ })
150
+
151
+ test('emits data_key_attr on each branch root of an if-statement root', () => {
152
+ const { template } = compileAndGenerate(`
153
+ export function Item({ on }: { on?: boolean }) {
154
+ if (on) return <div class="a">A</div>
155
+ return <div class="b">B</div>
156
+ }
157
+ `)
158
+ const count = (template.match(/\$bf->data_key_attr\(\)/g) ?? []).length
159
+ expect(count).toBe(2)
160
+ })
161
+ })
162
+
163
+ // =============================================================================
164
+ // #1966 — `/* @client */` defers ATTRIBUTE bindings (not just child/text)
165
+ // =============================================================================
166
+ //
167
+ // `renderAttributes` skips SSR emission for `attr.clientOnly`, so a
168
+ // deferred attribute predicate is omitted from the Blade template (and the
169
+ // unsupported-expression lowering is never reached → no BF101/BF102). The
170
+ // client runtime sets the attribute on hydrate. Mirrors the Go / Twig / Jinja
171
+ // pins.
172
+ describe('BladeAdapter - #1966 @client defers attribute bindings', () => {
173
+ function compileAttr(attrExpr: string) {
174
+ const adapter = new BladeAdapter()
175
+ const ir = compileToIR(`
176
+ "use client"
177
+ import { createSignal } from "@barefootjs/client"
178
+ export function C() {
179
+ const [sel] = createSignal(0)
180
+ const pred = (n: number) => sel() === n
181
+ return <div data-x={${attrExpr}}>hi</div>
182
+ }
183
+ `)
184
+ const template = adapter.generate(ir).template ?? ''
185
+ const errors = (adapter as unknown as { errors: { code: string }[] }).errors ?? []
186
+ return { errors, template }
187
+ }
188
+
189
+ test('bare emits data-x; @client omits it from SSR', () => {
190
+ expect(compileAttr('pred(1)').template).toContain('data-x')
191
+ const deferred = compileAttr('/* @client */ pred(1)')
192
+ expect(deferred.errors).toEqual([])
193
+ expect(deferred.template).not.toContain('data-x')
194
+ })
195
+ })
196
+
197
+ // #2018 P2: higher-order predicates lower through the runtime evaluator
198
+ // (`$bf->*_eval`), isomorphic with the Go / Jinja / Twig / Xslate `*_eval`
199
+ // helpers. Blade has no lambda fallback (`expr/emitters.ts`'s file header,
200
+ // divergence 9) — a predicate the evaluator can't model surfaces BF101
201
+ // instead.
202
+ describe('BladeAdapter - #2073 value-producing .map(cb)', () => {
203
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
204
+ // projection, not JSX) lowers through the evaluator — `$bf->map_eval`
205
+ // projects each element (no flatten) and composes through `$bf->join`.
206
+ test('.map(t => `#${t}`).join(" ") emits $bf->map_eval composed into $bf->join', () => {
207
+ const { template } = compileAndGenerate(`
208
+ function TagLine({ tags }: { tags: string[] }) {
209
+ return <p>{tags.map((t) => \`#\${t}\`).join(' ')}</p>
210
+ }
211
+ export { TagLine }
212
+ `)
213
+ expect(template).toContain("$bf->join($bf->map_eval($tags,")
214
+ expect(template).toContain('"kind":"template-literal"')
215
+ })
216
+
217
+ test('.map(u => u.name) emits $bf->map_eval with the field projection', () => {
218
+ const { template } = compileAndGenerate(`
219
+ function NameList({ users }: { users: { name: string }[] }) {
220
+ return <div>{users.map((u) => u.name).join(', ')}</div>
221
+ }
222
+ export { NameList }
223
+ `)
224
+ expect(template).toContain('$bf->map_eval($users,')
225
+ expect(template).toContain('"property":"name"')
226
+ })
227
+ })
228
+
229
+ describe('BladeAdapter - higher-order predicate lowering (#2018 P2)', () => {
230
+ test('a serializable predicate lowers to $bf->filter_eval with the JSON body + env', () => {
231
+ // A standalone `.filter().length` exercises the higher-order emitter (the
232
+ // `.filter().map()` form is a loop-hoist with an inline `@if(...)`,
233
+ // handled by renderLoop, not this emitter).
234
+ const { template } = compileAndGenerate(`
235
+ function A({ items }: { items: { done: boolean }[] }) {
236
+ return <div>{items.filter(x => x.done).length}</div>
237
+ }
238
+ export { A }
239
+ `)
240
+ expect(template).toContain('$bf->filter_eval(')
241
+ expect(template).toContain('"property":"done"')
242
+ expect(template).toContain("'x'")
243
+ })
244
+
245
+ test('.find / .findLast share $bf->find_eval, distinguished by the forward flag', () => {
246
+ const find = compileAndGenerate(`
247
+ function A({ items }: { items: { done: boolean }[] }) {
248
+ return <div>{items.find(x => x.done) ? 'y' : 'n'}</div>
249
+ }
250
+ export { A }
251
+ `).template
252
+ expect(find).toContain('$bf->find_eval(')
253
+ // `forward` is a genuine boolean literal here (`true`/`false`).
254
+ expect(find).toContain(", true, [])")
255
+
256
+ const findLast = compileAndGenerate(`
257
+ function A({ items }: { items: { done: boolean }[] }) {
258
+ return <div>{items.findLast(x => x.done) ? 'y' : 'n'}</div>
259
+ }
260
+ export { A }
261
+ `).template
262
+ expect(findLast).toContain('$bf->find_eval(')
263
+ expect(findLast).toContain(", false, [])")
264
+ })
265
+
266
+ // #2075: `.includes(x)` joined the evaluator's `array-method` surface
267
+ // (shared with the Perl/Go evaluator runtimes), so a predicate built from
268
+ // it now routes through `$bf->every_eval` like any other pure predicate —
269
+ // no BF101, despite being a method-call predicate.
270
+ test('.includes() in a predicate now lowers via the evaluator, not a BF101 refusal', () => {
271
+ const { template } = compileAndGenerate(`
272
+ function A({ items }: { items: { name: string }[] }) {
273
+ return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
274
+ }
275
+ export { A }
276
+ `)
277
+ expect(template).toContain('$bf->every_eval(')
278
+ expect(template).toContain('"method":"includes"')
279
+ expect(template).not.toContain("$bf->truthy('')")
280
+ })
281
+
282
+ // `expr/emitters.ts`'s file header, divergence 9: Blade has no lambda
283
+ // expression, so a predicate the evaluator can't serialize (a method call
284
+ // outside the evaluator's `array-method` gate — only `includes` is
285
+ // recognized there) has NO fallback — same as Jinja/Twig, unlike Kolon,
286
+ // which falls back to a Perl lambda passed to `$bf.every`. This surfaces
287
+ // BF101 instead.
288
+ test('a method-call predicate outside the evaluator surface has no lambda fallback — surfaces BF101', () => {
289
+ const adapter = new BladeAdapter()
290
+ const ir = compileToIR(`
291
+ function A({ items }: { items: { name: string }[] }) {
292
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
293
+ }
294
+ export { A }
295
+ `)
296
+ const { template } = adapter.generate(ir)
297
+ const errors = (adapter as unknown as { errors: { code: string; message: string }[] }).errors
298
+ expect(errors.some(e => e.code === 'BF101' && e.message.includes("'.every(...)'"))).toBe(true)
299
+ // No evaluator helper, and — critically — no lambda syntax either;
300
+ // the condition falls back to the safe `$bf->truthy('')` empty-string
301
+ // sentinel (see `convertExpressionToBlade`'s BF101 path).
302
+ expect(template).not.toContain('every_eval')
303
+ expect(template).not.toContain('$bf->every(')
304
+ expect(template).toContain("$bf->truthy('')")
305
+ })
306
+ })
307
+
308
+ // #2075: derived signal/memo seeding now consumes the shared
309
+ // `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts) instead of
310
+ // re-deriving scope/support locally — same plan Jinja/Twig/Xslate/Mojo
311
+ // consume, ported to `@php(...)` + `??` syntax. Mirrors
312
+ // `packages/adapter-twig/src/__tests__/twig-adapter-unit.test.ts`'s
313
+ // "#2075 searchParams()-derived memo seeding" describe.
314
+ describe('BladeAdapter - #2075 searchParams()-derived memo seeding', () => {
315
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
316
+ const { template } = compileAndGenerate(`
317
+ 'use client'
318
+ import { createMemo, createSearchParams } from '@barefootjs/client'
319
+ export function SortStatus() {
320
+ const [sp] = createSearchParams()
321
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
322
+ return <p>sort: {sort()}</p>
323
+ }
324
+ `)
325
+ expect(template).toContain(
326
+ "@php($sort = ($searchParams->get('sort') ?? 'date'))",
327
+ )
328
+ })
329
+
330
+ // The evaluator-JSON `param` and the `bf` runtime object are
331
+ // lowering-internal, not out-of-scope template vars (#2075). Blade routes
332
+ // the filter predicate through `$bf->filter_eval` (no lambda fallback),
333
+ // materializing the sibling getter call `tag()` into a bare free-var read
334
+ // (`materializeGetterCalls`) so the evaluator can serialize it at all; the
335
+ // captured env then resolves against the `$tag` seed line emitted just
336
+ // above (see `memo/seed.ts`'s file header).
337
+ test('seeds a filter memo chained off the derived memo', () => {
338
+ const { template } = compileAndGenerate(`
339
+ 'use client'
340
+ import { createMemo, createSearchParams } from '@barefootjs/client'
341
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
342
+ const [searchParams] = createSearchParams()
343
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
344
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
345
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
346
+ }
347
+ `)
348
+ expect(template).toContain(
349
+ "@php($tag = ($searchParams->get('tag') ?? ''))",
350
+ )
351
+ expect(template).toContain('@php($visible = $bf->filter_eval($items,')
352
+ expect(template).toContain("['tag' => $tag]")
353
+ })
354
+
355
+ // The seed-scope guard used to scan the LOWERED template text for
356
+ // bare-word tokens. That let an outer, unbound `p` (shadowed only inside
357
+ // the callback) slip past as if it were the callback's own bound `p` —
358
+ // emitting a bogus seed line. The guard now walks the parsed SOURCE tree
359
+ // with proper lexical scoping (`freeIdentifiers`, inside
360
+ // `computeSsrSeedPlan`), so this shape seeds nothing and falls back to the
361
+ // static ssr-defaults path.
362
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
363
+ const { template } = compileAndGenerate(`
364
+ 'use client'
365
+ import { createMemo } from '@barefootjs/client'
366
+ export function C(props: { items: { ok: boolean }[] }) {
367
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
368
+ return <div>{String(visible())}</div>
369
+ }
370
+ `)
371
+ expect(template).not.toContain('@php($visible')
372
+ })
373
+
374
+ // An out-of-scope bare `_` reference must not seed either.
375
+ test('an out-of-scope bare `_` reference does not seed', () => {
376
+ const { template } = compileAndGenerate(`
377
+ 'use client'
378
+ import { createMemo } from '@barefootjs/client'
379
+ export function C(props: { count: number }) {
380
+ const doubled = createMemo(() => props.count * 2 + _)
381
+ return <div>{doubled()}</div>
382
+ }
383
+ `)
384
+ expect(template).not.toContain('@php($doubled')
385
+ })
386
+ })
387
+
388
+ // #2038 nested-callback-predicate loudness is pinned at the shared
389
+ // conformance layer (workstream C): `filter-nested-callback-predicate` /
390
+ // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
391
+ // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
392
+ // twin, which must render clean).
@@ -0,0 +1,52 @@
1
+ /**
2
+ * BladeAdapter — Conformance Tests
3
+ *
4
+ * Runs the shared adapter conformance corpus (JSX fixtures, template
5
+ * primitives, marker conformance) against the Blade adapter, rendering
6
+ * each fixture end-to-end through real Blade + the bundled
7
+ * `Barefoot\BladeBackend` PHP runtime via `renderBladeComponent`.
8
+ *
9
+ * The Blade adapter was ported from the Twig adapter, so the skip /
10
+ * diagnostic sets below start from Twig's and diverge only where the
11
+ * engine genuinely differs. Every divergence carries a one-line rationale.
12
+ */
13
+
14
+ import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
15
+ import { BladeAdapter } from '../adapter'
16
+ import { renderBladeComponent, BladeNotAvailableError } from '../test-render'
17
+ import { conformancePins } from '../conformance-pins'
18
+
19
+ runAdapterConformanceTests({
20
+ name: 'blade',
21
+ factory: () => new BladeAdapter(),
22
+ render: renderBladeComponent,
23
+ // Per-fixture build-time contracts for shapes the Blade adapter
24
+ // intentionally refuses to lower. Lives in `../conformance-pins` —
25
+ // mirrors Twig's set (the lowering gates are shared code paths in
26
+ // the ported adapter; BF103/BF104 are structural: cross-template
27
+ // child registration / destructure-loop-param limits that apply
28
+ // identically regardless of target template language).
29
+ expectedDiagnostics: conformancePins,
30
+ // Template-primitive registry: `USER_IMPORT_VIA_CONST` and
31
+ // `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now pass (#2069) — a bespoke user
32
+ // import can never be added to the string-keyed registry, but the
33
+ // shared `RelocateEnv.loweringMatchers` acceptance path recognises it
34
+ // via a `LoweringPlugin` the case setup registers around the compile
35
+ // (see `packages/adapter-tests/src/cases/template-primitives.ts`). No
36
+ // skips left, so `skipTemplatePrimitives` is omitted entirely.
37
+ skipMarkerConformance: new Set([
38
+ // Same as Hono / Twig / Jinja: `/* @client */` markers on TodoApp's keyed
39
+ // `.map` intentionally elide a slot id from the SSR template that
40
+ // the IR still declares (s6). See hono-adapter.test for the contract.
41
+ 'todo-app',
42
+ // Same `/* @client */` keyed-map elision (data-table).
43
+ 'data-table',
44
+ ]),
45
+ onRenderError: (err, id) => {
46
+ if (err instanceof BladeNotAvailableError) {
47
+ console.log(`Skipping [${id}]: ${err.message}`)
48
+ return true
49
+ }
50
+ return false
51
+ },
52
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Ported from `packages/adapter-jinja/src/__tests__/jinja-counter.test.ts`
3
+ * (itself ported from Xslate's counter test).
4
+ *
5
+ * Only the FIRST (structural "compiles to a template") test is ported here.
6
+ * The Jinja analog's second test renders the compiled template through REAL
7
+ * Jinja2 via `renderJinjaComponent`; the Blade analog of that harness
8
+ * (`renderBladeComponent`, spawning `php` against the bundled PHP runtime) is
9
+ * `src/test-render.ts` — workstream C, exercised end-to-end by
10
+ * `blade-adapter.test.ts`'s conformance suite, not duplicated here.
11
+ */
12
+
13
+ import { test, expect } from 'bun:test'
14
+ import { compileJSX } from '@barefootjs/jsx'
15
+ import { BladeAdapter } from '../adapter'
16
+
17
+ const COUNTER_SRC = `"use client"
18
+ import { createSignal } from '@barefootjs/client'
19
+ export function Counter({ initial = 0 }: { initial?: number }) {
20
+ const [count, setCount] = createSignal(initial)
21
+ const doubled = () => count() * 2
22
+ return (
23
+ <div class="counter">
24
+ <p>count: {count()}</p>
25
+ <p>doubled: {doubled()}</p>
26
+ <button onClick={() => setCount(n => n + 1)}>+1</button>
27
+ </div>
28
+ )
29
+ }`
30
+
31
+ test('Counter compiles to a Blade .blade.php template', () => {
32
+ const result = compileJSX(COUNTER_SRC, 'Counter.tsx', {
33
+ adapter: new BladeAdapter(),
34
+ outputIR: true,
35
+ })
36
+ const errors = result.errors.filter(e => e.severity === 'error')
37
+ expect(errors).toEqual([])
38
+
39
+ const tpl = result.files.find(f => f.type === 'markedTemplate')
40
+ expect(tpl).toBeDefined()
41
+ const content = tpl!.content
42
+ // Script registration as `@php(...)` statements (`@php(...)` never
43
+ // prints, so — unlike Kolon's bare `:` line — no throwaway-bind trick is
44
+ // needed to suppress a leaked return value; see `blade-adapter.ts`'s
45
+ // `generateScriptRegistrations`).
46
+ expect(content).toContain(`@php($bf->register_script('/static/components/barefoot.js'))`)
47
+ expect(content).toContain(`@php($bf->register_script('/static/components/Counter.client.js'))`)
48
+ // Hydration markers
49
+ expect(content).toContain(`bf-s="{!! e($bf->scope_attr()) !!}"`)
50
+ expect(content).toContain(`{!! $bf->hydration_attrs() !!}`)
51
+ expect(content).toContain(`{!! $bf->props_attr() !!}`)
52
+ // Text slots — every text interpolation routes through `$bf->string(...)`,
53
+ // wrapped in `{!! e(...) !!}` rather than Blade's own `{{ }}` (its
54
+ // regex-based tag matcher breaks on evaluator-JSON payload text containing
55
+ // a literal `}}` — see `blade-adapter.ts`'s file header, divergence 0).
56
+ // ("Stringification": PHP's default stringification diverges from JS's
57
+ // further than Perl's does, so this port wraps every text-position value,
58
+ // unlike the Kolon port's bare `<: $count :>`).
59
+ expect(content).toContain(`{!! $bf->text_start("s0") !!}{!! e($bf->string($count)) !!}{!! $bf->text_end() !!}`)
60
+ expect(content).toContain(`{!! $bf->text_start("s2") !!}{!! e($bf->string($doubled)) !!}{!! $bf->text_end() !!}`)
61
+ // Button stays a plain element (onClick is client-only)
62
+ expect(content).toContain(`+1</button>`)
63
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `queryHref(base, { … })` → `$bf->query(...)` lowering for the Blade
3
+ * adapter (#2042). Ported from
4
+ * `packages/adapter-twig/src/__tests__/twig-query-href.test.ts`. Parity
5
+ * with the go-template / Jinja / Twig / Xslate lowering: the call + object
6
+ * literal are structured IR, so it lowers directly. The shared `query`
7
+ * runtime helper (the PHP `Barefoot\BarefootJS` runtime) includes a pair iff
8
+ * its guard is truthy AND its value is a non-empty string, so a plain
9
+ * `key: v` passes guard `1` (true) and a conditional `key: cond ? v :
10
+ * undefined` passes the lowered condition.
11
+ *
12
+ * One test (the `!==`-derived guard) diverges from the byte-for-byte Jinja
13
+ * port (unchanged from the Twig port): Jinja's native `!=` is Python
14
+ * value-equality, so it's safe to route straight through. PHP's own `!=`/
15
+ * `!==` are either LOOSE inequality (`'0' != 0` is false, even though JS
16
+ * `'0' !== 0` is true) or number-representation-sensitive (`1 !== 1.0` is
17
+ * true in PHP; false in JS) — wrong for a JS strict-inequality source
18
+ * operator either way. Per the adapter's uniform emit policy
19
+ * (`expr/emitters.ts`'s file header, divergence 8), EVERY `===`/`!==` —
20
+ * including ones the URL-guard lowering (`lowerUrlGuard`) synthesizes
21
+ * internally for a bare-value guard's non-empty-string test — routes
22
+ * through `$bf->eq`/`$bf->neq` regardless of operand type, so both
23
+ * queryHref guard shapes below assert `$bf->neq(...)` rather than a bare
24
+ * `!=`.
25
+ */
26
+ import { describe, test, expect } from 'bun:test'
27
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
28
+ import { BladeAdapter } from '../adapter/blade-adapter'
29
+
30
+ function template(src: string): string {
31
+ const a = new BladeAdapter()
32
+ const r = compileJSX(src.trimStart(), 'T.tsx', { adapter: a, outputIR: true })
33
+ const ir = JSON.parse(r.files.find(f => f.type === 'ir')!.content) as ComponentIR
34
+ return a.generate(ir).template
35
+ }
36
+
37
+ describe('queryHref → $bf->query (Blade, #2042)', () => {
38
+ test('a plain value passes guard true', () => {
39
+ const t = template(`
40
+ 'use client'
41
+ import { queryHref } from '@barefootjs/client'
42
+ export function P(props: { base: string; tag: string }) {
43
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
44
+ }
45
+ `)
46
+ expect(t).toContain("$bf->query($base, 1, 'tag', $tag)")
47
+ })
48
+
49
+ test('a conditional include passes the lowered condition as the guard', () => {
50
+ const t = template(`
51
+ 'use client'
52
+ import { queryHref } from '@barefootjs/client'
53
+ export function P(props: { base: string; sort: string; tag: string }) {
54
+ return <a href={queryHref(props.base, { sort: props.sort !== 'date' ? props.sort : undefined, tag: props.tag })}>x</a>
55
+ }
56
+ `)
57
+ expect(t).toContain("$bf->query($base, ($bf->neq($sort, 'date')), 'sort', $sort, 1, 'tag', $tag)")
58
+ })
59
+
60
+ // A bare-value guard (`flag ? v : undefined`) is JS *string* truthiness —
61
+ // `'0'` is a truthy string in JS. The lowering must normalise it to a
62
+ // non-empty-string test so SSR matches the client / go (where
63
+ // `lowerUrlGuard` emits `ne <value> ""`). The Blade adapter's uniform
64
+ // strict-equality policy routes this synthesized inequality through
65
+ // `$bf->neq` too, same as any other `!==`.
66
+ test('a bare-value guard is normalised to a non-empty-string test', () => {
67
+ const t = template(`
68
+ 'use client'
69
+ import { queryHref } from '@barefootjs/client'
70
+ export function P(props: { base: string; flag: string; val: string }) {
71
+ return <a href={queryHref(props.base, { q: props.flag ? props.val : undefined })}>x</a>
72
+ }
73
+ `)
74
+ expect(t).toContain("$bf->query($base, ($bf->neq($flag, '')), 'q', $val)")
75
+ })
76
+
77
+ // An array value (`{ tag: props.tags }`) lowers to the bare receiver
78
+ // expression; the shared `query` runtime helper detects the list at
79
+ // runtime and appends one pair per non-empty member (#2048). No
80
+ // adapter-side change beyond passing the value through.
81
+ test('an array value passes the receiver expression for the helper to append', () => {
82
+ const t = template(`
83
+ 'use client'
84
+ import { queryHref } from '@barefootjs/client'
85
+ export function P(props: { base: string; tags: string[] }) {
86
+ return <a href={queryHref(props.base, { tag: props.tags })}>x</a>
87
+ }
88
+ `)
89
+ expect(t).toContain("$bf->query($base, 1, 'tag', $tags)")
90
+ })
91
+
92
+ test('an aliased import is recognised', () => {
93
+ const t = template(`
94
+ 'use client'
95
+ import { queryHref as qh } from '@barefootjs/client'
96
+ export function P(props: { base: string; tag: string }) {
97
+ return <a href={qh(props.base, { tag: props.tag })}>x</a>
98
+ }
99
+ `)
100
+ expect(t).toContain("$bf->query($base, 1, 'tag', $tag)")
101
+ })
102
+
103
+ test('a dynamic (non-literal) params object falls back (no $bf->query)', () => {
104
+ const t = template(`
105
+ 'use client'
106
+ import { queryHref } from '@barefootjs/client'
107
+ export function P(props: { base: string; q: Record<string, string> }) {
108
+ return <a href={queryHref(props.base, props.q)}>x</a>
109
+ }
110
+ `)
111
+ expect(t).not.toContain('->query')
112
+ })
113
+ })