@barefootjs/blade 0.18.5 → 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.
package/dist/index.js CHANGED
@@ -187308,7 +187308,13 @@ import {
187308
187308
  prepareLoweringMatchers,
187309
187309
  queryHrefArgs,
187310
187310
  sortComparatorFromArrow as sortComparatorFromArrow2,
187311
- isValidHelperId
187311
+ isValidHelperId,
187312
+ isDangerousInnerHtmlAttr,
187313
+ resolveDangerousInnerHtml,
187314
+ dangerousInnerHtmlMetacharViolation,
187315
+ dangerousInnerHtmlDiagnostic,
187316
+ resolveStaticLoopSource,
187317
+ collectLoopBoundNames as collectLoopBoundNames2
187312
187318
  } from "@barefootjs/jsx";
187313
187319
 
187314
187320
  // src/adapter/boolean-result.ts
@@ -187654,6 +187660,39 @@ function renderFlatMethod(recv, depth, emit) {
187654
187660
  return `$bf->flat(${recv}, ${d})`;
187655
187661
  }
187656
187662
 
187663
+ // src/adapter/lib/static-value.ts
187664
+ function staticValueToBlade(value) {
187665
+ if (value === null || value === undefined)
187666
+ return "null";
187667
+ if (typeof value === "boolean")
187668
+ return value ? "true" : "false";
187669
+ if (typeof value === "number")
187670
+ return String(value);
187671
+ if (typeof value === "string")
187672
+ return `'${escapeBladeSingleQuoted(value)}'`;
187673
+ if (Array.isArray(value)) {
187674
+ const items = [];
187675
+ for (const el of value) {
187676
+ const serialized = staticValueToBlade(el);
187677
+ if (serialized === null)
187678
+ return null;
187679
+ items.push(serialized);
187680
+ }
187681
+ return `[${items.join(", ")}]`;
187682
+ }
187683
+ if (typeof value === "object") {
187684
+ const entries = [];
187685
+ for (const [key, val] of Object.entries(value)) {
187686
+ const serialized = staticValueToBlade(val);
187687
+ if (serialized === null)
187688
+ return null;
187689
+ entries.push(`${bladeHashKey(key)} => ${serialized}`);
187690
+ }
187691
+ return `[${entries.join(", ")}]`;
187692
+ }
187693
+ return null;
187694
+ }
187695
+
187657
187696
  // src/adapter/expr/emitters.ts
187658
187697
  import {
187659
187698
  groupBinaryOperand,
@@ -188193,6 +188232,9 @@ function generateDerivedMemoSeed(ctx, ir) {
188193
188232
  ` : "";
188194
188233
  }
188195
188234
 
188235
+ // src/adapter/props/prop-classes.ts
188236
+ import { collectLoopBoundNames } from "@barefootjs/jsx";
188237
+
188196
188238
  // src/adapter/value/parsed-literal.ts
188197
188239
  import { evalStringArrayJoin } from "@barefootjs/jsx";
188198
188240
  function isStringTypeInfo(type2) {
@@ -188223,6 +188265,12 @@ function collectStringValueNames(ir) {
188223
188265
  if (isStringTypeInfo(p.type))
188224
188266
  names.add(p.name);
188225
188267
  }
188268
+ for (const c of ir.metadata.localConstants) {
188269
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value))
188270
+ names.add(c.name);
188271
+ }
188272
+ for (const bound of collectLoopBoundNames(ir))
188273
+ names.delete(bound);
188226
188274
  return names;
188227
188275
  }
188228
188276
 
@@ -188247,6 +188295,7 @@ class BladeAdapter extends BaseAdapter {
188247
188295
  _searchParamsLocals = new Set;
188248
188296
  _loweringMatchers = [];
188249
188297
  localConstants = [];
188298
+ staticLoopSourceBoundNames = new Set;
188250
188299
  nullableOptionalProps = new Set;
188251
188300
  constructor(options = {}) {
188252
188301
  super();
@@ -188262,6 +188311,7 @@ class BladeAdapter extends BaseAdapter {
188262
188311
  this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
188263
188312
  this.booleanTypedProps = collectBooleanTypedProps(ir);
188264
188313
  this.localConstants = ir.metadata.localConstants ?? [];
188314
+ this.staticLoopSourceBoundNames = collectLoopBoundNames2(ir);
188265
188315
  this.nullableOptionalProps = collectNullableOptionalProps(ir);
188266
188316
  this.stringValueNames = collectStringValueNames(ir);
188267
188317
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants);
@@ -188383,7 +188433,8 @@ class BladeAdapter extends BaseAdapter {
188383
188433
  renderElement(element) {
188384
188434
  const tag = element.tag;
188385
188435
  const attrs = this.renderAttributes(element);
188386
- const children = this.renderChildren(element.children);
188436
+ const dangerousHtml = this.renderDangerousInnerHtml(element);
188437
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
188387
188438
  let hydrationAttrs = "";
188388
188439
  if (element.needsScope) {
188389
188440
  hydrationAttrs += ` ${this.renderScopeMarker("")}`;
@@ -188418,6 +188469,22 @@ class BladeAdapter extends BaseAdapter {
188418
188469
  }
188419
188470
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
188420
188471
  }
188472
+ renderDangerousInnerHtml(element) {
188473
+ const resolution = resolveDangerousInnerHtml(element);
188474
+ if (!resolution)
188475
+ return null;
188476
+ if (resolution.kind === "dynamic") {
188477
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
188478
+ return "";
188479
+ }
188480
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
188481
+ if (violation) {
188482
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr);
188483
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
188484
+ return "";
188485
+ }
188486
+ return resolution.html;
188487
+ }
188421
188488
  renderExpression(expr) {
188422
188489
  if (expr.clientOnly) {
188423
188490
  if (expr.slotId) {
@@ -188425,7 +188492,7 @@ class BladeAdapter extends BaseAdapter {
188425
188492
  }
188426
188493
  return "";
188427
188494
  }
188428
- const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr)})`;
188495
+ const bladeExpr = `$bf->string(${this.convertExpressionToBlade(expr.expr, expr.parsed)})`;
188429
188496
  if (expr.slotId) {
188430
188497
  return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`;
188431
188498
  }
@@ -188516,8 +188583,12 @@ ${whenTrue}
188516
188583
  }
188517
188584
  });
188518
188585
  }
188586
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
188587
+ isNameShadowed: (name) => this.staticLoopSourceBoundNames.has(name)
188588
+ });
188589
+ const staticArray = staticItems !== null ? staticValueToBlade(staticItems) : null;
188519
188590
  const arrayName = loop.array.trim();
188520
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188591
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188521
188592
  const arrayConst = (this.localConstants ?? []).find((c) => c.name === arrayName);
188522
188593
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
188523
188594
  this.errors.push({
@@ -188531,7 +188602,7 @@ ${whenTrue}
188531
188602
  });
188532
188603
  }
188533
188604
  }
188534
- const rawArray = this.convertExpressionToBlade(loop.array);
188605
+ const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array);
188535
188606
  let array = rawArray;
188536
188607
  if (loop.sortComparator) {
188537
188608
  const sort = loop.sortComparator;
@@ -188850,6 +188921,8 @@ ${name}="{!! e($bf->string(${val})) !!}"
188850
188921
  for (const attr of element.attrs) {
188851
188922
  if (attr.clientOnly)
188852
188923
  continue;
188924
+ if (isDangerousInnerHtmlAttr(attr))
188925
+ continue;
188853
188926
  let attrName;
188854
188927
  if (attr.name === "className")
188855
188928
  attrName = "class";
@@ -189050,6 +189123,8 @@ Options:
189050
189123
  return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr);
189051
189124
  }
189052
189125
  _resolveLiteralConst(name) {
189126
+ if (this.staticLoopSourceBoundNames.has(name))
189127
+ return null;
189053
189128
  const c = (this.localConstants ?? []).find((lc) => lc.name === name);
189054
189129
  if (c?.value === undefined)
189055
189130
  return null;
@@ -189062,6 +189137,8 @@ Options:
189062
189137
  return null;
189063
189138
  }
189064
189139
  _resolveStaticRecordLiteral(objectName, key) {
189140
+ if (this.staticLoopSourceBoundNames.has(objectName))
189141
+ return null;
189065
189142
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants);
189066
189143
  if (!hit)
189067
189144
  return null;
@@ -189099,22 +189176,15 @@ Options:
189099
189176
  var bladeAdapter = new BladeAdapter;
189100
189177
  // src/conformance-pins.ts
189101
189178
  var conformancePins = {
189102
- "static-array-children": [{ code: "BF103", severity: "error" }],
189103
- "todo-app": [{ code: "BF103", severity: "error" }],
189104
- "todo-app-ssr": [{ code: "BF103", severity: "error" }],
189105
189179
  "static-array-from-props": [{ code: "BF101", severity: "error" }],
189106
- "static-array-from-props-with-component": [
189107
- { code: "BF103", severity: "error" },
189108
- { code: "BF101", severity: "error" }
189109
- ],
189180
+ "static-array-from-props-with-component": [{ code: "BF101", severity: "error" }],
189110
189181
  "filter-nested-callback-predicate": [
189111
189182
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189112
189183
  ],
189113
189184
  "filter-nested-find-predicate": [
189114
189185
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189115
189186
  ],
189116
- "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189117
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189187
+ "dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
189118
189188
  };
189119
189189
  // src/render-divergences.ts
189120
189190
  var renderDivergences = {};
@@ -1 +1 @@
1
- {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAAsB,CAAA"}
1
+ {"version":3,"file":"render-divergences.d.ts","sourceRoot":"","sources":["../src/render-divergences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAExD,eAAO,MAAM,iBAAiB,EAAE,iBAK/B,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/blade",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Laravel Blade (PHP) adapter for BarefootJS — compiles IR to .blade.php templates and ships the PHP BarefootJS rendering runtime on illuminate/view standalone",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,14 +54,14 @@
54
54
  "directory": "packages/adapter-blade"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.18.5"
57
+ "@barefootjs/shared": "0.18.7"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@barefootjs/jsx": ">=0.2.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@barefootjs/adapter-tests": "0.1.0",
64
- "@barefootjs/jsx": "0.18.5",
64
+ "@barefootjs/jsx": "0.18.7",
65
65
  "typescript": "^5.0.0"
66
66
  }
67
67
  }
@@ -406,6 +406,126 @@ export function Parent() {
406
406
  })
407
407
  })
408
408
 
409
+ // #2221: `_resolveLiteralConst` is a flat name lookup against
410
+ // `ir.metadata.localConstants` with no notion of AST scope — it used to
411
+ // substitute an outer const's literal value even at an occurrence that is
412
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
413
+ // iteration rendered the same hard-coded literal. Guarded with the same
414
+ // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
415
+ // anywhere in the component never inlines, falling back to the bare
416
+ // identifier.
417
+ describe('BladeAdapter - const inlining vs loop-param shadowing (#2221)', () => {
418
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
419
+ const { template } = compileAndGenerate(`
420
+ function Widget() {
421
+ const label: string = 'x'
422
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
423
+ }
424
+ `)
425
+ // The loop body must reference the per-iteration loop var...
426
+ expect(template).toContain('1 + $label')
427
+ // ...never the outer const's hard-coded value.
428
+ expect(template).not.toContain("1 + 'x'")
429
+ })
430
+
431
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
432
+ const { template } = compileAndGenerate(`
433
+ function Widget() {
434
+ const count = 7
435
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
436
+ }
437
+ `)
438
+ expect(template).toContain('1 + $count')
439
+ expect(template).not.toContain('1 + 7')
440
+ })
441
+
442
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
443
+ const { template } = compileAndGenerate(`
444
+ function Widget({ values }: { values: number[] }) {
445
+ const totalPages = 5
446
+ return <div>
447
+ <p>Page 1 of {1 + totalPages}</p>
448
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
449
+ </div>
450
+ }
451
+ `)
452
+ expect(template).toContain('1 + 5')
453
+ })
454
+
455
+ // The accepted coarse-exclusion trade-off (same as #2212): a name that is
456
+ // loop-bound ANYWHERE in the component never inlines, even at a genuinely
457
+ // non-shadowed occurrence outside the loop — the bare identifier is
458
+ // emitted instead of the value.
459
+ test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
460
+ const { template } = compileAndGenerate(`
461
+ function Widget({ values }: { values: number[] }) {
462
+ const label: string = 'x'
463
+ return <div>
464
+ <p>{1 + label}</p>
465
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
466
+ </div>
467
+ }
468
+ `)
469
+ expect(template).not.toContain("1 + 'x'")
470
+ expect(template).toContain('2 + $label')
471
+ })
472
+ })
473
+
474
+ // #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
475
+ // object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
476
+ // flat name lookup on `objectName` with no notion of AST scope, the
477
+ // record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
478
+ // substitute the outer const's member value even at an occurrence that is
479
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
480
+ // iteration rendered the same hard-coded literal instead of the per-item
481
+ // value. Guarded with the same coarse `staticLoopSourceBoundNames`
482
+ // exclusion as #2221: any name a loop binds anywhere in the component
483
+ // never inlines, falling back to the bare `data_get($cfg, 'x')` member
484
+ // expression.
485
+ describe('BladeAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
486
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
487
+ const { template } = compileAndGenerate(`
488
+ const cfg = { x: 'outer-lit' }
489
+ function Widget({ rows }: { rows: { x: string }[] }) {
490
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
491
+ }
492
+ `)
493
+ // The loop body must reference the per-iteration member access...
494
+ expect(template).toContain("bf->string(data_get($cfg, 'x'))")
495
+ // ...never the outer const's hard-coded value.
496
+ expect(template).not.toContain("bf->string('outer-lit')")
497
+ })
498
+
499
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
500
+ const { template } = compileAndGenerate(`
501
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
502
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
503
+ return <div>{variantClasses.ghost}</div>
504
+ }
505
+ `)
506
+ expect(template).toContain("bf->string('bg-ghost')")
507
+ })
508
+
509
+ // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
510
+ // object name that is loop-bound ANYWHERE in the component never
511
+ // inlines its member lookups, even at a genuinely non-shadowed
512
+ // occurrence outside the loop — the bare member expression is emitted
513
+ // instead of the value.
514
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
515
+ const { template } = compileAndGenerate(`
516
+ const cfg = { x: 'outer-lit' }
517
+ function Widget({ rows }: { rows: { x: string }[] }) {
518
+ return <div>
519
+ <p>{cfg.x}</p>
520
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
521
+ </div>
522
+ }
523
+ `)
524
+ expect(template).not.toContain("bf->string('outer-lit')")
525
+ expect(template).toContain("bf->string(data_get($cfg, 'x'))")
526
+ })
527
+ })
528
+
409
529
  // #2038 nested-callback-predicate loudness is pinned at the shared
410
530
  // conformance layer (workstream C): `filter-nested-callback-predicate` /
411
531
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
@@ -289,6 +289,12 @@ 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'
@@ -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 {
@@ -417,6 +424,17 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
417
424
  */
418
425
  private localConstants: IRMetadata['localConstants'] = []
419
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
+
420
438
  /**
421
439
  * Optional, no-default props that are `None` when the caller omits them.
422
440
  * Their bare-reference attribute emission is guarded with a Blade
@@ -449,6 +467,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
449
467
  // ("1"/"") (#1897, pagination's data-active).
450
468
  this.booleanTypedProps = collectBooleanTypedProps(ir)
451
469
  this.localConstants = ir.metadata.localConstants ?? []
470
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
452
471
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
453
472
  this.stringValueNames = collectStringValueNames(ir)
454
473
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -669,7 +688,8 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
669
688
  renderElement(element: IRElement): string {
670
689
  const tag = element.tag
671
690
  const attrs = this.renderAttributes(element)
672
- const children = this.renderChildren(element.children)
691
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
692
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
673
693
 
674
694
  let hydrationAttrs = ''
675
695
  if (element.needsScope) {
@@ -704,6 +724,35 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
704
724
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
705
725
  }
706
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
+
707
756
  // ===========================================================================
708
757
  // Expression Rendering
709
758
  // ===========================================================================
@@ -717,8 +766,13 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
717
766
  }
718
767
 
719
768
  // Text-position interpolation of a possibly-non-string value — see the
720
- // file header, "Stringification" (carried unchanged from Twig).
721
- 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)})`
722
776
 
723
777
  if (expr.slotId) {
724
778
  return `{!! $bf->text_start("${expr.slotId}") !!}{!! e(${bladeExpr}) !!}{!! $bf->text_end() !!}`
@@ -863,8 +917,27 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
863
917
  // corpus only because the widened destructure gate (#2087 Phase A/B)
864
918
  // no longer refuses this fixture's `([emoji, users]) => ...` param
865
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
+
866
939
  const arrayName = loop.array.trim()
867
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
940
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
868
941
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
869
942
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
870
943
  this.errors.push({
@@ -880,7 +953,7 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
880
953
  }
881
954
  }
882
955
 
883
- const rawArray = this.convertExpressionToBlade(loop.array)
956
+ const rawArray = staticArray ?? this.convertExpressionToBlade(loop.array)
884
957
  // Apply sort if present: wrap the loop array in the shared `$bf->sort`
885
958
  // helper, binding the sorted result to a per-iteration local so the
886
959
  // helper runs once.
@@ -1554,6 +1627,12 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1554
1627
  // the unsupported-expression lowering is never reached for a deferred
1555
1628
  // predicate (no BF101 / BF102). #1966
1556
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
1557
1636
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1558
1637
  let attrName: string
1559
1638
  if (attr.name === 'className') attrName = 'class'
@@ -1924,8 +2003,19 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1924
2003
  * single-quoted string literal (`const totalPages = 5`, #1897
1925
2004
  * pagination) — function-scope consts never reach the per-render
1926
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`.
1927
2016
  */
1928
2017
  private _resolveLiteralConst(name: string): string | null {
2018
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1929
2019
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1930
2020
  if (c?.value === undefined) return null
1931
2021
  const v = c.value.trim()
@@ -1935,7 +2025,22 @@ export class BladeAdapter extends BaseAdapter implements IRNodeEmitter<BladeRend
1935
2025
  return null
1936
2026
  }
1937
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
+ */
1938
2042
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
2043
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1939
2044
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1940
2045
  if (!hit) return null
1941
2046
  return hit.kind === 'number'
@@ -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
  }