@barefootjs/blade 0.18.5 → 0.19.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.
@@ -6,7 +6,7 @@
6
6
  * sets the adapter consults during lowering. No adapter instance state.
7
7
  */
8
8
 
9
- import type { ComponentIR } from '@barefootjs/jsx'
9
+ import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
10
10
  import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
11
11
 
12
12
  /**
@@ -24,8 +24,9 @@ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
24
24
  }
25
25
 
26
26
  /**
27
- * Bare references to optional, no-default, non-primitive props (e.g.
28
- * textarea's `rows`) are `null` when omitted → guarded with
27
+ * Bare references to presence-uncertain no-default props (non-primitive
28
+ * typed OR declared optional, #2259 — e.g. textarea's `rows`) are
29
+ * `null` when omitted → guarded with
29
30
  * `is defined and is not null` in `emitExpression`. See the
30
31
  * `nullableOptionalProps` field docstring in `blade-adapter.ts`.
31
32
  */
@@ -36,21 +37,38 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
36
37
  p =>
37
38
  p.defaultValue === undefined &&
38
39
  !p.isRest &&
39
- p.type?.kind !== 'primitive',
40
+ (p.type?.kind !== 'primitive' || p.optional),
40
41
  )
41
42
  .map(p => p.name),
42
43
  )
43
44
  }
44
45
 
45
46
  /**
46
- * String-typed signals and props. A signal is string-typed when its inferred
47
- * type is `string` (or, defensively, when its initial value is a bare string
48
- * literal); a prop when its annotated type is `string`. In the Mojo adapter
49
- * this drives `eq`/`ne` selection for string equality; the Blade emitters
50
- * don't consume the distinction — `===`/`!==` ALWAYS route through
51
- * `bf.eq`/`bf.neq` regardless of operand type (see
52
- * `expr/emitters.ts`'s file header, divergence 4) so this set is carried
53
- * only for parity with the Perl-family adapters.
47
+ * String-typed signals, props, and same-file local consts (#2212). A
48
+ * signal is string-typed when its inferred type is `string` (or,
49
+ * defensively, when its initial value is a bare string literal); a prop
50
+ * when its annotated type is `string`; a local const the same way. Consumed
51
+ * by `isStringConcatBinary`/`isStringTypedOperand` (`@barefootjs/jsx`) to
52
+ * pick `.` over JS `+`'s numeric fallback (#2163, #2212) — including now
53
+ * for a bare identifier operand, not just a prop/getter/literal. In the
54
+ * Mojo adapter this ALSO drives `eq`/`ne` selection for string equality;
55
+ * the Blade emitters don't consume the distinction there — `===`/`!==`
56
+ * ALWAYS route through `bf.eq`/`bf.neq` regardless of operand type (see
57
+ * `expr/emitters.ts`'s file header, divergence 4) — so that half of this
58
+ * set is carried only for parity with the Perl-family adapters.
59
+ *
60
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
61
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
62
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
63
+ * loop param shadowing an outer string-typed binding of the same name
64
+ * (`items.map((name) => 1 + name)` inside a component that also has a
65
+ * string `name` prop) — left unguarded, that shadowed `name` would be
66
+ * misdetected as string-typed and `1 + name` would silently lower to `.`
67
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
68
+ * (it also suppresses a genuinely non-shadowed same-named string
69
+ * elsewhere in the component) but safe: the suppressed case just falls
70
+ * back to today's numeric `+` — the same, already-accepted residual as an
71
+ * unresolvable operand — never silently-wrong output.
54
72
  */
55
73
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
56
74
  const names = new Set<string>()
@@ -62,5 +80,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
62
80
  for (const p of ir.metadata.propsParams) {
63
81
  if (isStringTypeInfo(p.type)) names.add(p.name)
64
82
  }
83
+ for (const c of ir.metadata.localConstants) {
84
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
85
+ }
86
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
65
87
  return names
66
88
  }
@@ -11,15 +11,17 @@
11
11
  import type { ConformancePins } from '@barefootjs/jsx'
12
12
 
13
13
  export const conformancePins: ConformancePins = {
14
- // Sibling-imported child component in a loop body: emits a
15
- // cross-template call needing separate registration. BF103 makes
16
- // the requirement loud (same as Jinja).
17
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
18
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
19
- // call it inside a keyed `.map`. Same BF103 (imported child in
20
- // `.map`) as Jinja.
21
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
22
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
14
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
15
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
16
+ // sibling `components`, matching `bf build`'s real semantics, so the
17
+ // BF103 loop-body cross-template check no longer fires spuriously. (Both
18
+ // fixtures are still skipped on this adapter via `render-divergences.ts`
19
+ // #2209 for an unrelated signal-seeding gap.)
20
+ // `static-array-children` no longer pinned (#2208) `items`'s
21
+ // array-literal initializer is now recognized as fully-static
22
+ // (`resolveStaticLoopSource`) and inlined as a native PHP array literal
23
+ // in the `@foreach` header, the same way a module-scope const's value is
24
+ // already seeded.
23
25
  // The `([emoji, users]) => …` array-destructure param itself now lowers
24
26
  // (#2087 Phase B — see the destructure comment below), but the loop
25
27
  // ARRAY is a function-scope computed const (`const entries =
@@ -28,12 +30,10 @@ export const conformancePins: ConformancePins = {
28
30
  // check and policy as Jinja / ERB) instead of silently iterating zero
29
31
  // times over an unbound name.
30
32
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
31
- // Both BF103 (imported child) and BF101 (computed local-const loop
32
- // array, as above) fire.
33
- 'static-array-from-props-with-component': [
34
- { code: 'BF103', severity: 'error' },
35
- { code: 'BF101', severity: 'error' },
36
- ],
33
+ // BF101 (computed local-const loop array, as above) fires; BF103
34
+ // (imported child in the loop body) no longer does now that the
35
+ // conformance harness passes `siblingTemplatesRegistered: true` (#2205).
36
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
37
37
  // #2087 Phase B: every `.map()` destructure shape in the shared corpus
38
38
  // now lowers on Blade via an `@php(...)` local built from the binding's
39
39
  // structured `segments` path (`bladeLoopBindingAccessor` in
@@ -76,17 +76,14 @@ export const conformancePins: ConformancePins = {
76
76
  // / etc. via the same evaluator-JSON mechanism as `.filter` / `.every` /
77
77
  // `.some`, so they render. Only the NESTED-in-a-predicate form above is
78
78
  // refused (#2038).
79
- // #2073 follow-up (same as Jinja): a function-reference `.map(format)`
80
- // callback has no arrow body to serialize — not a CALLBACK_METHODS shape
81
- // (`asCallbackMethodCall` requires an arrow argument) so the shared
82
- // `isSupported`'s `UNSUPPORTED_METHODS` gate refuses it with the generic
83
- // "Expression not supported" BF101 rather than emitting a broken
84
- // template.
85
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
86
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
87
- // deliberate raw-HTML (unescaped) output affordance in the target
88
- // template language. No lowering exists yet, so the compiler refuses
89
- // the shape loudly instead of emitting entity-escaped markup that
90
- // silently renders tags as text.
91
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
79
+ // `array-map-function-reference` no longer pinned a bare-identifier
80
+ // `.map(format)` callback now resolves one hop to its declaration
81
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
82
+ // #2090 established for `.sort(fnref)`.
83
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
84
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
85
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
86
+ // A dynamic/signal-derived value still refuses with BF101 — see the
87
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
88
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
92
89
  }
@@ -14,4 +14,9 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
- export const renderDivergences: RenderDivergences = {}
17
+ export const renderDivergences: RenderDivergences = {
18
+ // `todo-app` / `todo-app-ssr` no longer diverge (#2209) — the shared
19
+ // `evaluateSignalInit` (`@barefootjs/jsx`, sandboxed real-JS evaluation
20
+ // instead of a fixed regex-shape catalogue) now correctly seeds `todos`
21
+ // from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
22
+ }
@@ -38,7 +38,7 @@
38
38
  * caller's `props` and needs no JSON round-trip.
39
39
  */
40
40
 
41
- import { compileJSX, extractSsrDefaults, importsSearchParams } from '@barefootjs/jsx'
41
+ import { compileJSX, extractSsrDefaults, importsSearchParams, evaluateSignalInit } from '@barefootjs/jsx'
42
42
  import type { ComponentIR } from '@barefootjs/jsx'
43
43
  import { mkdir, rm } from 'node:fs/promises'
44
44
  import { resolve } from 'node:path'
@@ -157,8 +157,15 @@ export async function renderBladeComponent(options: RenderOptions): Promise<stri
157
157
  }
158
158
  }
159
159
 
160
- // Compile parent source.
161
- const result = compileJSX(source, 'component.tsx', { adapter, outputIR: true })
160
+ // Compile parent source. `siblingTemplatesRegistered: Boolean(components)`
161
+ // matches this harness's real behavior every sibling child template is registered
162
+ // alongside the parent before rendering, so a loop-body cross-template
163
+ // call resolves at render time (#2205).
164
+ const result = compileJSX(source, 'component.tsx', {
165
+ adapter,
166
+ outputIR: true,
167
+ siblingTemplatesRegistered: Boolean(components),
168
+ })
162
169
 
163
170
  const errors = result.errors.filter(e => e.severity === 'error')
164
171
  if (errors.length > 0) {
@@ -585,122 +592,6 @@ function buildBladeProps(
585
592
  return { data, literalAssignments }
586
593
  }
587
594
 
588
- /**
589
- * Evaluate a signal initializer expression using provided props.
590
- * Handles: props.initial ?? 0, props.value, literal values.
591
- *
592
- * Language-agnostic (returns a real JS value, not source text) — shared
593
- * verbatim with the Jinja harness's helper of the same name.
594
- */
595
- export function evaluateSignalInit(
596
- expr: string,
597
- props?: Record<string, unknown>,
598
- ): unknown {
599
- const nullishMatch = expr.match(/^props\.(\w+)\s*\?\?\s*(.+)$/)
600
- if (nullishMatch) {
601
- const propName = nullishMatch[1]
602
- const defaultExpr = nullishMatch[2].trim()
603
- if (props && propName in props) return props[propName]
604
- return parseLiteral(defaultExpr)
605
- }
606
-
607
- const propsMatch = expr.match(/^props\.(\w+)$/)
608
- if (propsMatch) {
609
- if (props && propsMatch[1] in props) return props[propsMatch[1]]
610
- return null
611
- }
612
-
613
- return parseLiteral(expr)
614
- }
615
-
616
- function parseLiteral(expr: string): unknown {
617
- if (/^-?\d+(\.\d+)?$/.test(expr)) return Number(expr)
618
- if (expr === 'true') return true
619
- if (expr === 'false') return false
620
- if (expr === '[]') return []
621
-
622
- {
623
- const t = expr.trim()
624
- if (t.startsWith('[') && t.endsWith(']')) {
625
- const inner = t.slice(1, -1).trim()
626
- if (!inner) return []
627
- const out: unknown[] = []
628
- for (const seg of splitTopLevelCommas(inner)) {
629
- if (!seg.trim()) continue
630
- const parsed = parseLiteral(seg.trim())
631
- if (parsed === null && seg.trim() !== 'null') return null
632
- out.push(parsed)
633
- }
634
- return out
635
- }
636
- }
637
-
638
- const stringMatch = expr.match(/^(['"])(.*)\1$/s)
639
- if (stringMatch) return unescapeJsString(stringMatch[2])
640
-
641
- const trimmed = expr.trim()
642
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
643
- const inner = trimmed.slice(1, -1).trim()
644
- if (!inner) return {}
645
- const obj: Record<string, unknown> = {}
646
- for (const pair of splitTopLevelCommas(inner)) {
647
- if (!pair.trim()) continue
648
- const colonIdx = pair.indexOf(':')
649
- if (colonIdx < 0) return null
650
- let key = pair.slice(0, colonIdx).trim()
651
- const val = pair.slice(colonIdx + 1).trim()
652
- const keyMatch = key.match(/^(['"])(.*)\1$/s)
653
- if (keyMatch) key = unescapeJsString(keyMatch[2])
654
- const parsedVal = parseLiteral(val)
655
- if (parsedVal === null && val !== 'null') return null
656
- obj[key] = parsedVal
657
- }
658
- return obj
659
- }
660
- return null
661
- }
662
-
663
- function splitTopLevelCommas(inner: string): string[] {
664
- const segments: string[] = []
665
- let depth = 0
666
- let start = 0
667
- let quote: string | null = null
668
- for (let i = 0; i < inner.length; i++) {
669
- const c = inner[i]
670
- if (quote) {
671
- if (c === quote) {
672
- let backslashes = 0
673
- for (let j = i - 1; j >= 0 && inner[j] === '\\'; j--) backslashes++
674
- if (backslashes % 2 === 0) quote = null
675
- }
676
- continue
677
- }
678
- if (c === '"' || c === "'") {
679
- quote = c
680
- continue
681
- }
682
- if (c === '{' || c === '[') depth++
683
- else if (c === '}' || c === ']') depth--
684
- else if (c === ',' && depth === 0) {
685
- segments.push(inner.slice(start, i))
686
- start = i + 1
687
- }
688
- }
689
- segments.push(inner.slice(start))
690
- return segments
691
- }
692
-
693
- function unescapeJsString(s: string): string {
694
- return s.replace(/\\(.)/g, (_, c) => {
695
- switch (c) {
696
- case 'n': return '\n'
697
- case 'r': return '\r'
698
- case 't': return '\t'
699
- case '0': return '\0'
700
- default: return c
701
- }
702
- })
703
- }
704
595
 
705
596
  /**
706
597
  * Convert PascalCase to snake_case for template naming (matches the