@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,235 @@
1
+ /**
2
+ * BladeAdapter — conditional-spread / nullish-omission / hyphenated-key
3
+ * regression tests (#textarea / #checkbox Phase 2b parity).
4
+ *
5
+ * Ported from `packages/adapter-twig/src/__tests__/twig-spread-attrs.test.ts`,
6
+ * translating each expected template string to Blade syntax. The shared
7
+ * adapter-conformance fixtures (`textarea`, `checkbox`) only exercise the
8
+ * falsy branch of the conditional spread and the unset branch of the
9
+ * optional attr, so these unit tests pin the truthy / present branches the
10
+ * fixtures can't reach.
11
+ *
12
+ * Translation notes beyond the mechanical Twig→Blade syntax swap (ternary
13
+ * unchanged; `bf.x(...)` → `$bf->x(...)`; `{'k': v}` hash → `['k' => v]`
14
+ * PHP array):
15
+ *
16
+ * 1. Array-literal keys are ALWAYS quoted in Blade too (`bladeHashKey`
17
+ * unconditionally single-quotes — a bareword key in a PHP array literal
18
+ * is a syntax error, not a variable lookup the way Twig's unquoted hash
19
+ * key would be, but this port quotes unconditionally anyway for the one
20
+ * uniform rule shared across the adapter family), so there is no "stays
21
+ * unquoted" case to assert for a bare-identifier-safe prop name like
22
+ * `size`; only that BOTH keys are correctly quoted (same note as the
23
+ * Twig/Jinja ports' file headers).
24
+ * 2. Twig's `is defined and is not null` null-guard on an `{% if %}` test
25
+ * becomes Blade's `isset($x)` — a SINGLE check (this file's port of the
26
+ * Twig adapter's divergence 6): PHP's `isset()` already returns `false`
27
+ * for both "never extracted into scope" and "extracted as null" in one
28
+ * call, verified empirically (see `blade-adapter.ts`'s file header).
29
+ */
30
+
31
+ import { test, expect, describe } from 'bun:test'
32
+ import { compileJSX } from '@barefootjs/jsx'
33
+ import type { ComponentIR } from '@barefootjs/jsx'
34
+ import { BladeAdapter } from '../adapter'
35
+
36
+ function compileToIR(source: string, adapter?: BladeAdapter): ComponentIR {
37
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
38
+ adapter: adapter ?? new BladeAdapter(),
39
+ outputIR: true,
40
+ })
41
+ const irFile = result.files.find(f => f.type === 'ir')
42
+ if (!irFile) throw new Error('No IR output')
43
+ return JSON.parse(irFile.content) as ComponentIR
44
+ }
45
+
46
+ function compileAndGenerate(source: string, adapter?: BladeAdapter) {
47
+ const a = adapter ?? new BladeAdapter()
48
+ const ir = compileToIR(source, a)
49
+ return a.generate(ir)
50
+ }
51
+
52
+ describe('BladeAdapter - conditional inline-object spread (textarea aria-describedby)', () => {
53
+ // `{...(cond ? { 'aria-describedby': cond } : {})}` lowers to a PHP inline
54
+ // ternary of arrays so the falsy `[]` branch OMITS the key
55
+ // ($bf->spread_attrs does not emit empty entries). The shared fixture only
56
+ // exercises the falsy branch; this pins the truthy one.
57
+ test('emits a PHP inline ternary of arrays through $bf->spread_attrs', () => {
58
+ const { template } = compileAndGenerate(`
59
+ function Box({ describedBy }: { describedBy?: string }) {
60
+ return <div {...(describedBy ? { 'aria-describedby': describedBy } : {})} />
61
+ }
62
+ `)
63
+ expect(template).toContain(
64
+ "$bf->spread_attrs(($bf->truthy($describedBy) ? ['aria-describedby' => $describedBy] : []))",
65
+ )
66
+ })
67
+
68
+ test('resolves the value reference and preserves the static key for a second prop', () => {
69
+ const { template } = compileAndGenerate(`
70
+ function Box({ label }: { label: string }) {
71
+ return <div {...(label ? { 'data-label': label } : {})} />
72
+ }
73
+ `)
74
+ expect(template).toContain(
75
+ "$bf->spread_attrs(($bf->truthy($label) ? ['data-label' => $label] : []))",
76
+ )
77
+ })
78
+
79
+ test('falls back to BF101 for a computed (non-static) object key', () => {
80
+ const adapter = new BladeAdapter()
81
+ const ir = compileToIR(`
82
+ function Box({ k, v }: { k?: string; v?: string }) {
83
+ return <div {...(v ? { [k]: v } : {})} />
84
+ }
85
+ `, adapter)
86
+ adapter.generate(ir)
87
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
88
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe('BladeAdapter - local-const conditional-spread resolution (#checkbox icon)', () => {
93
+ // A FUNCTION-scope const holding a `cond ? {…} : {}` ternary, spread as a bare
94
+ // identifier (`{...attrs}`), resolves through the same Blade
95
+ // ternary-of-arrays lowering as the inline form. CheckIcon's
96
+ // `const sizeAttrs = size ? {…} : {}` is exactly this shape.
97
+ test('resolves a bare-identifier spread of a function-scope conditional const', () => {
98
+ const { template } = compileAndGenerate(`
99
+ function Box({ flag }: { flag?: boolean }) {
100
+ const attrs = flag ? { 'data-on': 'yes' } : {}
101
+ return <div {...attrs} />
102
+ }
103
+ `)
104
+ expect(template).toContain(
105
+ "$bf->spread_attrs(($bf->truthy($flag) ? ['data-on' => 'yes'] : []))",
106
+ )
107
+ })
108
+
109
+ // A const that aliases another bare identifier must NOT be forwarded (loop
110
+ // guard): the resolver bails, so the spread falls through to the standard
111
+ // lowering emitting the bare `$attrs` variable.
112
+ test('does not forward a const that aliases another identifier (loop guard)', () => {
113
+ const { template } = compileAndGenerate(`
114
+ function Box({ other }: { other?: object }) {
115
+ const attrs = other
116
+ return <div {...attrs} />
117
+ }
118
+ `)
119
+ expect(template).toContain('$bf->spread_attrs($attrs)')
120
+ })
121
+ })
122
+
123
+ describe('BladeAdapter - Record<staticKeys,scalar>[propKey] spread value (#checkbox icon)', () => {
124
+ // `const sizeMap: Record<IconSize, number> = { sm: 16, ... }` indexed by a
125
+ // prop inside a conditional-spread object value lowers to an inline
126
+ // bracket-indexed PHP array `[...][$key]` — the SAME bracket-index syntax
127
+ // JS itself uses (unlike Kolon, which had to steer around Perl's
128
+ // arrow-deref `->{$key}` to the bracket form). This is CheckIcon's
129
+ // `{ width: sizeMap[size], height: sizeMap[size] }` shape.
130
+ test('lowers an indexed module-const map to an inline bracket-indexed array', () => {
131
+ const { template } = compileAndGenerate(`
132
+ const sizeMap: Record<string, number> = { sm: 16, md: 20, lg: 24, xl: 32 }
133
+ function Box({ size }: { size?: string }) {
134
+ const attrs = size ? { width: sizeMap[size] } : {}
135
+ return <div {...attrs} />
136
+ }
137
+ `)
138
+ expect(template).toContain(
139
+ "['sm' => 16, 'md' => 20, 'lg' => 24, 'xl' => 32][$size]",
140
+ )
141
+ })
142
+
143
+ test('lowers string-valued record maps too', () => {
144
+ const { template } = compileAndGenerate(`
145
+ const labelMap: Record<string, string> = { a: 'Alpha', b: 'Beta' }
146
+ function Box({ k }: { k?: string }) {
147
+ const attrs = k ? { 'data-label': labelMap[k] } : {}
148
+ return <div {...attrs} />
149
+ }
150
+ `)
151
+ expect(template).toContain("['a' => 'Alpha', 'b' => 'Beta'][$k]")
152
+ })
153
+
154
+ // A non-scalar record value (object) is out of shape: the spread object value
155
+ // can't lower, so the whole spread falls back to BF101.
156
+ test('refuses a non-scalar record value with BF101 (out-of-shape fallback)', () => {
157
+ const adapter = new BladeAdapter()
158
+ const ir = compileToIR(`
159
+ const sizeMap: Record<string, object> = { sm: { w: 1 } }
160
+ function Box({ size }: { size?: string }) {
161
+ const attrs = size ? { width: sizeMap[size] } : {}
162
+ return <div {...attrs} />
163
+ }
164
+ `, adapter)
165
+ adapter.generate(ir)
166
+ const errs = (adapter as unknown as { errors: { code: string }[] }).errors
167
+ expect(errs.some(e => e.code === 'BF101')).toBe(true)
168
+ })
169
+ })
170
+
171
+ describe('BladeAdapter - props-object inherited-attribute enumeration (#checkbox)', () => {
172
+ // A SolidJS props-object component reads inherited attributes (`props.id`)
173
+ // not enumerated in `propsParams`. The bare optional attribute must be
174
+ // guarded so it's omitted when unset (Hono parity), even though `id`
175
+ // isn't a declared param.
176
+ test('guards a props-object bare optional attr (props.id) with isset()', () => {
177
+ const { template } = compileAndGenerate(`
178
+ "use client"
179
+ interface P { tone?: string }
180
+ export function Widget(props: P) {
181
+ return <button id={props.id}>x</button>
182
+ }
183
+ `)
184
+ expect(template).toContain('@if(isset($id))')
185
+ expect(template).toContain('id="{!! e($bf->string($id)) !!}"')
186
+ })
187
+ })
188
+
189
+ describe('BladeAdapter - hyphenated child attr dict key (#checkbox)', () => {
190
+ // A child component prop whose JSX name isn't a bare identifier
191
+ // (`<CheckIcon data-slot="..."/>`) must be quoted in the `render_child`
192
+ // array — same as EVERY other key, since Blade array-literal keys are
193
+ // ALWAYS quoted (see the file header for why this diverges from the
194
+ // Kolon port's "only quote when non-bareword-safe" assertion).
195
+ test('quotes every child attribute name in render_child, hyphenated or not', () => {
196
+ const { template } = compileAndGenerate(`
197
+ "use client"
198
+ import { Leaf } from './leaf'
199
+ export function Host() {
200
+ return <div><Leaf data-slot="indicator" size="sm" /></div>
201
+ }
202
+ `)
203
+ expect(template).toContain("'data-slot' => 'indicator'")
204
+ expect(template).toContain("'size' => 'sm'")
205
+ })
206
+ })
207
+
208
+ describe('BladeAdapter - nullish optional-attribute omission (textarea rows)', () => {
209
+ // A no-destructure-default, nillable-typed prop is `null` when the caller
210
+ // omits it; guard its bare-reference attribute with Blade's `isset($x)`
211
+ // test so it DROPS instead of rendering `attr=""` — matching Hono's
212
+ // nullish-attribute omission. Concrete/defaulted props are never `null`
213
+ // and stay unconditional.
214
+ test('guards a no-default nillable attr with an isset() check', () => {
215
+ const { template } = compileAndGenerate(`
216
+ function C({ rows }: { rows?: number }) {
217
+ return <textarea rows={rows} />
218
+ }
219
+ `)
220
+ expect(template).toContain('@if(isset($rows))')
221
+ expect(template).toContain('rows="{!! e($bf->string($rows)) !!}"')
222
+ })
223
+
224
+ test('leaves a defaulted attr unconditional (scope did not widen)', () => {
225
+ const { template } = compileAndGenerate(`
226
+ function C({ value = '' }: { value?: string }) {
227
+ return <textarea value={value} />
228
+ }
229
+ `)
230
+ // `value` has a destructure default → never null → unconditional, exactly
231
+ // like Hono's value="".
232
+ expect(template).toContain('value="{!! e($bf->string($value)) !!}"')
233
+ expect(template).not.toContain('@if(isset(')
234
+ })
235
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Component-tree analysis for the Blade template adapter.
3
+ *
4
+ * Ported from `packages/adapter-jinja/src/adapter/analysis/component-tree.ts`.
5
+ * Pure functions over the IR — they read no adapter instance state.
6
+ * `collectImportedLoopChildComponentErrors` returns its diagnostics instead
7
+ * of pushing onto the adapter's error list, so the adapter stays the sole
8
+ * owner of `errors`.
9
+ */
10
+
11
+ import type {
12
+ ComponentIR,
13
+ IRNode,
14
+ IRComponent,
15
+ IRElement,
16
+ IRFragment,
17
+ IRConditional,
18
+ IRLoop,
19
+ IRIfStatement,
20
+ IRProvider,
21
+ IRAsync,
22
+ CompilerError,
23
+ } from '@barefootjs/jsx'
24
+
25
+ /**
26
+ * Whether the component needs the client runtime — it owns reactive state
27
+ * (signals / effects / onMount) or the analyzer flagged it as needing init.
28
+ */
29
+ export function hasClientInteractivity(ir: ComponentIR): boolean {
30
+ return (
31
+ ir.metadata.signals.length > 0 ||
32
+ ir.metadata.effects.length > 0 ||
33
+ ir.metadata.onMounts.length > 0 ||
34
+ (ir.metadata.clientAnalysis?.needsInit ?? false)
35
+ )
36
+ }
37
+
38
+ /**
39
+ * Build a `BF103` diagnostic for every component reference inside a loop body
40
+ * whose name is imported from a relative-path module. Mirror of the Go /
41
+ * Jinja adapter's check — the Blade adapter has the same
42
+ * cross-template-registration constraint at request time (each `.blade.php`
43
+ * component file must be registered with the shared Blade `Environment`
44
+ * loader alongside the parent). Returns the diagnostics so the caller pushes
45
+ * them onto its own error list.
46
+ */
47
+ export function collectImportedLoopChildComponentErrors(
48
+ ir: ComponentIR,
49
+ componentName: string,
50
+ ): CompilerError[] {
51
+ const errors: CompilerError[] = []
52
+ const relativeImports = new Set<string>()
53
+ for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
54
+ if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
55
+ if (imp.isTypeOnly) continue
56
+ for (const spec of imp.specifiers) {
57
+ relativeImports.add(spec.alias ?? spec.name)
58
+ }
59
+ }
60
+ if (relativeImports.size === 0) return errors
61
+
62
+ const loc = { file: componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
63
+ const visit = (node: IRNode, inLoop: boolean): void => {
64
+ switch (node.type) {
65
+ case 'component': {
66
+ const comp = node as IRComponent
67
+ if (inLoop && relativeImports.has(comp.name)) {
68
+ errors.push({
69
+ code: 'BF103',
70
+ severity: 'error',
71
+ message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Blade adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
72
+ loc: comp.loc ?? loc,
73
+ suggestion: {
74
+ message:
75
+ `Options:\n` +
76
+ ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Blade template alongside the parent at render time.\n` +
77
+ ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
78
+ ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
79
+ },
80
+ })
81
+ }
82
+ for (const child of comp.children) visit(child, inLoop)
83
+ break
84
+ }
85
+ case 'element':
86
+ for (const child of (node as IRElement).children) visit(child, inLoop)
87
+ break
88
+ case 'fragment':
89
+ for (const child of (node as IRFragment).children) visit(child, inLoop)
90
+ break
91
+ case 'conditional': {
92
+ const cond = node as IRConditional
93
+ visit(cond.whenTrue, inLoop)
94
+ if (cond.whenFalse) visit(cond.whenFalse, inLoop)
95
+ break
96
+ }
97
+ case 'loop':
98
+ for (const child of (node as IRLoop).children) visit(child, true)
99
+ break
100
+ case 'if-statement': {
101
+ const stmt = node as IRIfStatement
102
+ visit(stmt.consequent, inLoop)
103
+ if (stmt.alternate) visit(stmt.alternate, inLoop)
104
+ break
105
+ }
106
+ case 'provider':
107
+ for (const child of (node as IRProvider).children) visit(child, inLoop)
108
+ break
109
+ case 'async': {
110
+ const a = node as IRAsync
111
+ visit(a.fallback, inLoop)
112
+ for (const child of a.children) visit(child, inLoop)
113
+ break
114
+ }
115
+ }
116
+ }
117
+ visit(ir.root, false)
118
+ return errors
119
+ }