@barefootjs/blade 0.18.4 → 0.18.7

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.
@@ -289,10 +289,16 @@ import {
289
289
  queryHrefArgs,
290
290
  sortComparatorFromArrow,
291
291
  isValidHelperId,
292
+ isDangerousInnerHtmlAttr,
293
+ resolveDangerousInnerHtml,
294
+ dangerousInnerHtmlMetacharViolation,
295
+ dangerousInnerHtmlDiagnostic,
296
+ resolveStaticLoopSource,
297
+ collectLoopBoundNames,
292
298
  } from '@barefootjs/jsx'
293
299
  import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
294
300
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
295
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
301
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
296
302
 
297
303
  import type { BladeRenderCtx } from './lib/types.ts'
298
304
  import { BLADE_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -308,6 +314,7 @@ import {
308
314
  collectRootScopeNodes,
309
315
  } from './lib/ir-scope.ts'
310
316
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
317
+ import { staticValueToBlade } from './lib/static-value.ts'
311
318
  import { BladeFilterEmitter, BladeTopLevelEmitter, truthyTest } from './expr/emitters.ts'
312
319
  import type { BladeEmitContext, BladeSpreadContext, BladeMemoContext } from './emit-context.ts'
313
320
  import {
@@ -356,6 +363,14 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
356
363
  private options: Required<BladeAdapterOptions>
357
364
  private errors: CompilerError[] = []
358
365
  private inLoop: boolean = false
366
+ /**
367
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
368
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
369
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
370
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
371
+ * re-derived here (#2168 nested-loop-outer-binding).
372
+ */
373
+ private currentLoopKeyDepth = 0
359
374
  /**
360
375
  * SolidJS-style props identifier (`function(props: P)`) and the
361
376
  * analyzer-extracted prop names. Stashed at `generate()` entry so the
@@ -409,6 +424,17 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
409
424
  */
410
425
  private localConstants: IRMetadata['localConstants'] = []
411
426
 
427
+ /**
428
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
429
+ * parameter anywhere in the component (#2208 fable review). A static
430
+ * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
431
+ * never resolve through `resolveStaticLoopSource` at a use site where a
432
+ * DIFFERENT, enclosing loop's own callback param shadows it — same
433
+ * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
434
+ * `collectLoopBoundNames` use in `collectStringValueNames`.
435
+ */
436
+ private staticLoopSourceBoundNames: Set<string> = new Set()
437
+
412
438
  /**
413
439
  * Optional, no-default props that are `None` when the caller omits them.
414
440
  * Their bare-reference attribute emission is guarded with a Blade
@@ -441,6 +467,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
441
467
  // ("1"/"") (#1897, pagination's data-active).
442
468
  this.booleanTypedProps = collectBooleanTypedProps(ir)
443
469
  this.localConstants = ir.metadata.localConstants ?? []
470
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
444
471
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
445
472
  this.stringValueNames = collectStringValueNames(ir)
446
473
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -550,7 +577,9 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
550
577
  }
551
578
 
552
579
  emitText(node: IRText): string {
553
- return node.value
580
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
581
+ // character references); re-escape for direct HTML emission.
582
+ return escapeHtml(node.value)
554
583
  }
555
584
 
556
585
  emitExpression(node: IRExpression): string {
@@ -659,7 +688,8 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
659
688
  renderElement(element: IRElement): string {
660
689
  const tag = element.tag
661
690
  const attrs = this.renderAttributes(element)
662
- const children = this.renderChildren(element.children)
691
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
692
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
663
693
 
664
694
  let hydrationAttrs = ''
665
695
  if (element.needsScope) {
@@ -694,6 +724,35 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
694
724
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
695
725
  }
696
726
 
727
+ /**
728
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — replaces the
729
+ * element's normal children with a compile-time-literal raw-HTML string,
730
+ * spliced directly as template text (never through a `{!! !!}`-style
731
+ * runtime raw-output primitive: the value is already fully known at
732
+ * compile time, so no runtime escape hatch is needed, and using one would
733
+ * reopen a template-source injection surface for no benefit). Returns
734
+ * `null` when the attribute is absent (caller falls through to normal
735
+ * `renderChildren`); a non-`null` string (possibly `''`) means the caller
736
+ * must use it as-is instead — including the dynamic/guard-violation
737
+ * refusal cases, where the empty string is safe filler for a compile that
738
+ * fails anyway.
739
+ */
740
+ private renderDangerousInnerHtml(element: IRElement): string | null {
741
+ const resolution = resolveDangerousInnerHtml(element)
742
+ if (!resolution) return null
743
+ if (resolution.kind === 'dynamic') {
744
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
745
+ return ''
746
+ }
747
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
748
+ if (violation) {
749
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
750
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
751
+ return ''
752
+ }
753
+ return resolution.html
754
+ }
755
+
697
756
  // ===========================================================================
698
757
  // Expression Rendering
699
758
  // ===========================================================================
@@ -707,8 +766,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
707
766
  }
708
767
 
709
768
  // Text-position interpolation of a possibly-non-string value — see the
710
- // file header, "Stringification" (carried unchanged from Twig).
711
- const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr)})`
769
+ // file header, "Stringification" (carried unchanged from Twig). Thread
770
+ // the IR-carried `.parsed` tree through (mirrors go-template's
771
+ // `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
772
+ // resolved bare-identifier `.map`/`.filter`/… callback
773
+ // (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
774
+ // fresh, unresolved re-parse of the raw string.
775
+ const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr, expr.parsed)})`
712
776
 
713
777
  if (expr.slotId) {
714
778
  return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`
@@ -853,8 +917,27 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
853
917
  // corpus only because the widened destructure gate (#2087 Phase A/B)
854
918
  // no longer refuses this fixture's `([emoji, users]) => ...` param
855
919
  // first. Same policy and shape as the Jinja / ERB adapters' check.
920
+ // #2208: a loop source that is a fully-static array literal — either
921
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
922
+ // bound to a FUNCTION-scope local const whose initializer has no
923
+ // prop/signal/function-call dependency — inlines as a native PHP
924
+ // array literal below, the same way a module-scope const's value is
925
+ // already seeded. A runtime-computed local (#2069, e.g.
926
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
927
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
928
+ // param shadowing this identifier (fable review) — never resolve the
929
+ // static const in that case. `rawArray` then falls through to the
930
+ // bare identifier expression below, same as before #2208 — which
931
+ // still trips the pre-existing BF101 gate for an unresolvable local
932
+ // const reference (a loud, conservative refusal, not a silent wrong
933
+ // value).
934
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
935
+ isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
936
+ })
937
+ const staticArray = staticItems !== null ? staticValueToBlade(staticItems) : null
938
+
856
939
  const arrayName = loop.array.trim()
857
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
940
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
858
941
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
859
942
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
860
943
  this.errors.push({
@@ -870,7 +953,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
870
953
  }
871
954
  }
872
955
 
873
- const rawArray = this.convertExpressionToBlade(loop.array)
956
+ const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array)
874
957
  // Apply sort if present: wrap the loop array in the shared `$bf->sort`
875
958
  // helper, binding the sorted result to a per-iteration local so the
876
959
  // helper runs once.
@@ -922,7 +1005,11 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
922
1005
  // other than member-access / spread (already refused by the gate)
923
1006
  // can't observe a sibling field the pattern destructured explicitly.
924
1007
  const indexLocalLines: string[] = []
925
- if (loop.iterationShape === 'keys') {
1008
+ if (loop.objectIteration) {
1009
+ // `key`/`value` bind directly in the `@foreach` header (see below)
1010
+ // via `$bf->entries`/`$bf->keys`/`$bf->values` — no derived
1011
+ // `$loop->index` local needed, unlike the array `iterationShape` cases.
1012
+ } else if (loop.iterationShape === 'keys') {
926
1013
  indexLocalLines.push(`@php(${bladeVar(param)} = $loop->index)`)
927
1014
  } else if (loop.index) {
928
1015
  indexLocalLines.push(`@php(${bladeVar(loop.index)} = $loop->index)`)
@@ -950,10 +1037,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
950
1037
 
951
1038
  const prevInLoop = this.inLoop
952
1039
  this.inLoop = true
1040
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
1041
+ this.currentLoopKeyDepth = loop.depth
953
1042
  // Re-render children now that inLoop is set (so nested components use the
954
1043
  // loop-child naming convention). renderedChildren above was computed with
955
1044
  // the previous flag; recompute under the loop flag.
956
1045
  const childrenUnderLoop = this.renderChildren(loop.children)
1046
+ this.currentLoopKeyDepth = prevLoopKeyDepth
957
1047
  this.inLoop = prevInLoop
958
1048
  void renderedChildren
959
1049
 
@@ -973,7 +1063,23 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
973
1063
  // See this file's header, divergence 2: raw PHP `foreach` over a
974
1064
  // null/undefined array raises, unlike Twig's `strict_variables: false`
975
1065
  // tolerance — the `?? []` guard restores that same tolerance uniformly.
976
- lines.push(`@foreach((${array} ?? []) as ${bladeVar(loopVar)})`)
1066
+ //
1067
+ // `objectIteration` (#2168 object-entries-map): PHP's native `foreach`
1068
+ // CAN iterate a `stdClass` object's public properties directly (unlike
1069
+ // Twig's `for` tag, which needs `Traversable`) — but this still routes
1070
+ // through `$bf->entries`/`$bf->keys`/`$bf->values` for the same
1071
+ // defensive non-object guard Twig needs and for a single source of
1072
+ // truth on "is this a JS object" (`isJsObject`, `BarefootJS.php`); the
1073
+ // `(array)` cast those methods do preserves the object's own insertion
1074
+ // order either way.
1075
+ const forHeader = loop.objectIteration === 'entries'
1076
+ ? `@foreach($bf->entries(${array} ?? []) as ${bladeVar(loop.index ?? param)} => ${bladeVar(param)})`
1077
+ : loop.objectIteration === 'keys'
1078
+ ? `@foreach($bf->keys(${array} ?? []) as ${bladeVar(param)})`
1079
+ : loop.objectIteration === 'values'
1080
+ ? `@foreach($bf->values(${array} ?? []) as ${bladeVar(param)})`
1081
+ : `@foreach((${array} ?? []) as ${bladeVar(loopVar)})`
1082
+ lines.push(forHeader)
977
1083
  for (const il of indexLocalLines) lines.push(il)
978
1084
 
979
1085
  // Handle filter().map() pattern by wrapping children in if-condition
@@ -1114,11 +1220,37 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1114
1220
  type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
1115
1221
  const segments: Segment[] = [{ kind: 'entries', parts: [] }]
1116
1222
  const currentEntries = () => this.componentPropSegmentEntries(segments)
1223
+ // Named JSX-valued props OTHER than the reserved `children`
1224
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
1225
+ // their own output-buffering capture, prepended to the final returned
1226
+ // string below — same mechanism as the reserved children capture,
1227
+ // just keyed by the prop's own name instead of `children`.
1228
+ const namedSlotCaptures: string[] = []
1117
1229
 
1118
1230
  for (const p of comp.props) {
1119
1231
  // Skip callback props (onXxx) and `ref` — both are client-only for
1120
1232
  // SSR (Hono renders neither; the client JS wires them at hydration).
1121
1233
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1234
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1235
+ const prevInLoop = this.inLoop
1236
+ this.inLoop = false
1237
+ const slotBody = this.renderChildren(p.value.children)
1238
+ this.inLoop = prevInLoop
1239
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1240
+ // A JSX prop name can contain characters (`data-slot`) `bladeIdent`
1241
+ // doesn't sanitize (it only guards reserved words), producing an
1242
+ // invalid PHP variable name; `comp.slotId` alone would also collide
1243
+ // across two named-slot props on the same component invocation
1244
+ // (unlike the reserved children slot, there's only ever one of
1245
+ // those per invocation).
1246
+ const captureVar = bladeVar(`bf_prop_${this.childrenCaptureCounter++}`)
1247
+ namedSlotCaptures.push(
1248
+ `@php(ob_start())\n${slotBody}\n` +
1249
+ `@php(${captureVar} = $bf->backend->mark_raw(preg_replace('/\\n\\z/', '', ob_get_clean(), 1)))`,
1250
+ )
1251
+ currentEntries().push(`${bladeHashKey(p.name)} => ${captureVar}`)
1252
+ continue
1253
+ }
1122
1254
  if (p.value.kind === 'spread') {
1123
1255
  const trimmed = p.value.expr.trim()
1124
1256
  // SolidJS-style props identifier (`function(props: P)`) has no
@@ -1193,6 +1325,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1193
1325
  currentEntries().push(`${bladeHashKey('children')} => ${captureVar}`)
1194
1326
  const dict = this.combineComponentPropSegments(segments)
1195
1327
  return (
1328
+ namedSlotCaptures.join('') +
1196
1329
  `@php(ob_start())\n${childrenBody}\n` +
1197
1330
  `@php(${captureVar} = $bf->backend->mark_raw(preg_replace('/\\n\\z/', '', ob_get_clean(), 1)))` +
1198
1331
  `{!! $bf->render_child('${tplName}', ${dict}) !!}`
@@ -1201,7 +1334,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1201
1334
 
1202
1335
  const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
1203
1336
  const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
1204
- return `{!! $bf->render_child('${tplName}'${dictEntries}) !!}`
1337
+ return `${namedSlotCaptures.join('')}{!! $bf->render_child('${tplName}'${dictEntries}) !!}`
1205
1338
  }
1206
1339
 
1207
1340
  private childrenCaptureCounter = 0
@@ -1291,7 +1424,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1291
1424
  * AttrValue lowering for intrinsic-element attributes (Blade).
1292
1425
  */
1293
1426
  private readonly elementAttrEmitter: AttrValueEmitter = {
1294
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1427
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1295
1428
  emitExpression: (value, name) => {
1296
1429
  // `style={{ … }}` object literal → a CSS string with dynamic values
1297
1430
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1494,10 +1627,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1494
1627
  // the unsupported-expression lowering is never reached for a deferred
1495
1628
  // predicate (no BF101 / BF102). #1966
1496
1629
  if (attr.clientOnly) continue
1630
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1631
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1632
+ // element's children. Skip it here so its `{ __html: ... }` object
1633
+ // literal never reaches `refuseUnsupportedAttrExpression`'s generic
1634
+ // BF101 (which would double-report alongside the purpose-built one).
1635
+ if (isDangerousInnerHtmlAttr(attr)) continue
1497
1636
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1498
1637
  let attrName: string
1499
1638
  if (attr.name === 'className') attrName = 'class'
1500
- else if (attr.name === 'key') attrName = 'data-key'
1639
+ else if (attr.name === 'key') {
1640
+ const depth = this.currentLoopKeyDepth
1641
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1642
+ }
1501
1643
  else attrName = attr.name
1502
1644
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1503
1645
  if (lowered) parts.push(lowered)
@@ -1861,8 +2003,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1861
2003
  * single-quoted string literal (`const totalPages = 5`, #1897
1862
2004
  * pagination) — function-scope consts never reach the per-render
1863
2005
  * context, so a bare reference would resolve to an undefined variable.
2006
+ *
2007
+ * The lookup is a flat name match with no notion of AST scope, so a
2008
+ * name that any loop callback binds as its item/index param never
2009
+ * inlines (#2221) — the occurrence may be the loop's own (shadowing)
2010
+ * binding, and substituting the outer const's value there renders every
2011
+ * iteration with the same hard-coded literal. Coarse (a genuinely
2012
+ * non-shadowed same-named const elsewhere in the component also stops
2013
+ * inlining, falling back to the bare identifier) but safe — the same
2014
+ * trade-off as #2212's `collectLoopBoundNames` use in
2015
+ * `collectStringValueNames`.
1864
2016
  */
1865
2017
  private _resolveLiteralConst(name: string): string | null {
2018
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1866
2019
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1867
2020
  if (c?.value === undefined) return null
1868
2021
  const v = c.value.trim()
@@ -1872,7 +2025,22 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1872
2025
  return null
1873
2026
  }
1874
2027
 
2028
+ /**
2029
+ * Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
2030
+ * (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
2031
+ *
2032
+ * The lookup is a flat name match on `objectName` with no notion of AST
2033
+ * scope, so an enclosing loop callback's own param of the same name
2034
+ * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
2035
+ * still resolved to the OUTER const's member value at every iteration
2036
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
2037
+ * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
2038
+ * binds anywhere in the component never inlines, falling back to the bare
2039
+ * `data_get($cfg, 'x')` member expression (which a Blade `@foreach`
2040
+ * binds correctly at the shadowed occurrences).
2041
+ */
1875
2042
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
2043
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1876
2044
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1877
2045
  if (!hit) return null
1878
2046
  return hit.kind === 'number'
@@ -90,6 +90,15 @@ export function renderArrayMethod(
90
90
  const recv = emit(object)
91
91
  return `$bf->trim(${recv})`
92
92
  }
93
+ case 'trimStart':
94
+ case 'trimEnd': {
95
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
96
+ // `.trim()` (#2183 follow-up). Dedicated `$bf->trim_start` /
97
+ // `$bf->trim_end` helpers, not `$bf->trim` with a flag.
98
+ const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
99
+ const recv = emit(object)
100
+ return `$bf->${fn}(${recv})`
101
+ }
93
102
  case 'toFixed': {
94
103
  // `.toFixed(digits?)` — `$bf->to_fixed` mirrors JS rounding +
95
104
  // zero-padding (default 0 digits). #1897.
@@ -125,6 +134,16 @@ export function renderArrayMethod(
125
134
  const newS = emit(args[1])
126
135
  return `$bf->replace(${recv}, ${oldS}, ${newS})`
127
136
  }
137
+ case 'replaceAll': {
138
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
139
+ // via the dedicated `$bf->replace_all` helper (not `$bf->replace`
140
+ // with a flag) — the regex-pattern form is refused upstream at
141
+ // the parser, same as `.replace`. See #2182.
142
+ const recv = emit(object)
143
+ const oldS = emit(args[0])
144
+ const newS = emit(args[1])
145
+ return `$bf->replace_all(${recv}, ${oldS}, ${newS})`
146
+ }
128
147
  case 'repeat': {
129
148
  const recv = emit(object)
130
149
  const count = args.length === 0 ? '0' : emit(args[0])
@@ -176,7 +176,7 @@ export class BladeFilterEmitter implements ParsedExprEmitter {
176
176
  return String(value)
177
177
  }
178
178
 
179
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
179
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
180
180
  // `.length` — route through `$bf->length` (handles both array element
181
181
  // count and string char count, JS-compatibly).
182
182
  if (property === 'length') {
@@ -346,7 +346,7 @@ export class BladeTopLevelEmitter implements ParsedExprEmitter {
346
346
  return String(value)
347
347
  }
348
348
 
349
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
349
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
350
350
  // `props.x` flattens to the bare context var the SSR caller binds each
351
351
  // prop to (props arrive as individual top-level context entries, not a
352
352
  // nested `props` hash).
@@ -22,6 +22,9 @@ export const BLADE_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
22
22
  'Math.floor': { arity: 1, emit: (args) => `$bf->floor(${args[0]})` },
23
23
  'Math.ceil': { arity: 1, emit: (args) => `$bf->ceil(${args[0]})` },
24
24
  'Math.round': { arity: 1, emit: (args) => `$bf->round(${args[0]})` },
25
+ 'Math.min': { arity: 2, emit: (args) => `$bf->min(${args[0]}, ${args[1]})` },
26
+ 'Math.max': { arity: 2, emit: (args) => `$bf->max(${args[0]}, ${args[1]})` },
27
+ 'Math.abs': { arity: 1, emit: (args) => `$bf->abs(${args[0]})` },
25
28
  }
26
29
 
27
30
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * PHP literal. Used to inline a fully-static loop source (an inline array
5
+ * literal, or a function-scope local const with a static initializer)
6
+ * directly in a `@foreach` header, rather than requiring a bound template
7
+ * variable.
8
+ *
9
+ * Returns `null` for a value this adapter can't represent as a literal —
10
+ * the caller falls back to its existing BF101 refusal instead of guessing.
11
+ */
12
+
13
+ import { escapeBladeSingleQuoted, bladeHashKey } from './blade-naming.ts'
14
+
15
+ export function staticValueToBlade(value: unknown): string | null {
16
+ if (value === null || value === undefined) return 'null'
17
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
18
+ if (typeof value === 'number') return String(value)
19
+ if (typeof value === 'string') return `'${escapeBladeSingleQuoted(value)}'`
20
+ if (Array.isArray(value)) {
21
+ const items: string[] = []
22
+ for (const el of value) {
23
+ const serialized = staticValueToBlade(el)
24
+ if (serialized === null) return null
25
+ items.push(serialized)
26
+ }
27
+ return `[${items.join(', ')}]`
28
+ }
29
+ if (typeof value === 'object') {
30
+ const entries: string[] = []
31
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
32
+ const serialized = staticValueToBlade(val)
33
+ if (serialized === null) return null
34
+ entries.push(`${bladeHashKey(key)} => ${serialized}`)
35
+ }
36
+ return `[${entries.join(', ')}]`
37
+ }
38
+ return null
39
+ }
@@ -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
  /**
@@ -43,14 +43,31 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
43
43
  }
44
44
 
45
45
  /**
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.
46
+ * String-typed signals, props, and same-file local consts (#2212). A
47
+ * signal is string-typed when its inferred type is `string` (or,
48
+ * defensively, when its initial value is a bare string literal); a prop
49
+ * when its annotated type is `string`; a local const the same way. Consumed
50
+ * by `isStringConcatBinary`/`isStringTypedOperand` (`@barefootjs/jsx`) to
51
+ * pick `.` over JS `+`'s numeric fallback (#2163, #2212) — including now
52
+ * for a bare identifier operand, not just a prop/getter/literal. In the
53
+ * Mojo adapter this ALSO drives `eq`/`ne` selection for string equality;
54
+ * the Blade emitters don't consume the distinction there — `===`/`!==`
55
+ * ALWAYS route through `bf.eq`/`bf.neq` regardless of operand type (see
56
+ * `expr/emitters.ts`'s file header, divergence 4) — so that half of this
57
+ * set is carried only for parity with the Perl-family adapters.
58
+ *
59
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
60
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
61
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
62
+ * loop param shadowing an outer string-typed binding of the same name
63
+ * (`items.map((name) => 1 + name)` inside a component that also has a
64
+ * string `name` prop) — left unguarded, that shadowed `name` would be
65
+ * misdetected as string-typed and `1 + name` would silently lower to `.`
66
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
67
+ * (it also suppresses a genuinely non-shadowed same-named string
68
+ * elsewhere in the component) but safe: the suppressed case just falls
69
+ * back to today's numeric `+` — the same, already-accepted residual as an
70
+ * unresolvable operand — never silently-wrong output.
54
71
  */
55
72
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
56
73
  const names = new Set<string>()
@@ -62,5 +79,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
62
79
  for (const p of ir.metadata.propsParams) {
63
80
  if (isStringTypeInfo(p.type)) names.add(p.name)
64
81
  }
82
+ for (const c of ir.metadata.localConstants) {
83
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
84
+ }
85
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
65
86
  return names
66
87
  }
@@ -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,22 +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' }],
92
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
93
- // only first-occurrence `.replace` is wired to the runtime helpers.
94
- // Refused with BF101 rather than reusing the first-only lowering,
95
- // which would silently change semantics.
96
- 'string-replaceall': [{ 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' }],
97
89
  }
@@ -15,20 +15,8 @@
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
17
  export const renderDivergences: RenderDivergences = {
18
- 'html-entity-text':
19
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
20
- 'math-methods':
21
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
22
- 'static-attr-escape':
23
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
24
- 'object-entries-map':
25
- '`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
26
- 'nested-loop-outer-binding':
27
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
28
- 'jsx-element-prop':
29
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
30
- 'string-slice':
31
- '`.slice()` on a STRING renders empty (array-slice helper misfires on strings)',
32
- 'string-trim-sided':
33
- '`.trimStart()` / `.trimEnd()` render empty (no lowering)',
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 }))`.
34
22
  }