@barefootjs/xslate 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
  queryHrefArgs,
187309
187309
  isValidHelperId,
187310
187310
  sortComparatorFromArrow as sortComparatorFromArrow2,
187311
- isLowerableLoopDestructure
187311
+ isLowerableLoopDestructure,
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
@@ -187624,6 +187630,39 @@ function renderFlatMethod(recv, depth, emit) {
187624
187630
  return `$bf.flat(${recv}, ${d})`;
187625
187631
  }
187626
187632
 
187633
+ // src/adapter/lib/static-value.ts
187634
+ function staticValueToKolon(value) {
187635
+ if (value === null || value === undefined)
187636
+ return "nil";
187637
+ if (typeof value === "boolean")
187638
+ return null;
187639
+ if (typeof value === "number")
187640
+ return String(value);
187641
+ if (typeof value === "string")
187642
+ return `'${escapeKolonSingleQuoted(value)}'`;
187643
+ if (Array.isArray(value)) {
187644
+ const items = [];
187645
+ for (const el of value) {
187646
+ const serialized = staticValueToKolon(el);
187647
+ if (serialized === null)
187648
+ return null;
187649
+ items.push(serialized);
187650
+ }
187651
+ return `[${items.join(", ")}]`;
187652
+ }
187653
+ if (typeof value === "object") {
187654
+ const entries = [];
187655
+ for (const [key, val] of Object.entries(value)) {
187656
+ const serialized = staticValueToKolon(val);
187657
+ if (serialized === null)
187658
+ return null;
187659
+ entries.push(`${kolonHashKey(key)} => ${serialized}`);
187660
+ }
187661
+ return `{ ${entries.join(", ")} }`;
187662
+ }
187663
+ return null;
187664
+ }
187665
+
187627
187666
  // src/adapter/expr/emitters.ts
187628
187667
  import {
187629
187668
  groupBinaryOperand,
@@ -188167,6 +188206,9 @@ function generateDerivedMemoSeed(ctx, ir) {
188167
188206
  ` : "";
188168
188207
  }
188169
188208
 
188209
+ // src/adapter/props/prop-classes.ts
188210
+ import { collectLoopBoundNames } from "@barefootjs/jsx";
188211
+
188170
188212
  // src/adapter/value/parsed-literal.ts
188171
188213
  import { evalStringArrayJoin } from "@barefootjs/jsx";
188172
188214
  function isStringTypeInfo(type2) {
@@ -188197,6 +188239,12 @@ function collectStringValueNames(ir) {
188197
188239
  if (isStringTypeInfo(p.type))
188198
188240
  names.add(p.name);
188199
188241
  }
188242
+ for (const c of ir.metadata.localConstants) {
188243
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value))
188244
+ names.add(c.name);
188245
+ }
188246
+ for (const bound of collectLoopBoundNames(ir))
188247
+ names.delete(bound);
188200
188248
  return names;
188201
188249
  }
188202
188250
 
@@ -188232,6 +188280,7 @@ class XslateAdapter extends BaseAdapter {
188232
188280
  _searchParamsLocals = new Set;
188233
188281
  _loweringMatchers = [];
188234
188282
  localConstants = [];
188283
+ staticLoopSourceBoundNames = new Set;
188235
188284
  nullableOptionalProps = new Set;
188236
188285
  constructor(options = {}) {
188237
188286
  super();
@@ -188247,6 +188296,7 @@ class XslateAdapter extends BaseAdapter {
188247
188296
  this.propsParams = ir.metadata.propsParams.map((p) => ({ name: p.name }));
188248
188297
  this.booleanTypedProps = collectBooleanTypedProps(ir);
188249
188298
  this.localConstants = ir.metadata.localConstants ?? [];
188299
+ this.staticLoopSourceBoundNames = collectLoopBoundNames2(ir);
188250
188300
  this.nullableOptionalProps = collectNullableOptionalProps(ir);
188251
188301
  this.stringValueNames = collectStringValueNames(ir);
188252
188302
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants);
@@ -188363,7 +188413,8 @@ class XslateAdapter extends BaseAdapter {
188363
188413
  renderElement(element) {
188364
188414
  const tag = element.tag;
188365
188415
  const attrs = this.renderAttributes(element);
188366
- const children = this.renderChildren(element.children);
188416
+ const dangerousHtml = this.renderDangerousInnerHtml(element);
188417
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children);
188367
188418
  let hydrationAttrs = "";
188368
188419
  if (element.needsScope) {
188369
188420
  hydrationAttrs += ` ${this.renderScopeMarker("")}`;
@@ -188398,6 +188449,22 @@ class XslateAdapter extends BaseAdapter {
188398
188449
  }
188399
188450
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`;
188400
188451
  }
188452
+ renderDangerousInnerHtml(element) {
188453
+ const resolution = resolveDangerousInnerHtml(element);
188454
+ if (!resolution)
188455
+ return null;
188456
+ if (resolution.kind === "dynamic") {
188457
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc));
188458
+ return "";
188459
+ }
188460
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name);
188461
+ if (violation) {
188462
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr);
188463
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation));
188464
+ return "";
188465
+ }
188466
+ return resolution.html;
188467
+ }
188401
188468
  renderExpression(expr) {
188402
188469
  if (expr.clientOnly) {
188403
188470
  if (expr.slotId) {
@@ -188405,7 +188472,7 @@ class XslateAdapter extends BaseAdapter {
188405
188472
  }
188406
188473
  return "";
188407
188474
  }
188408
- const perlExpr = this.convertExpressionToKolon(expr.expr);
188475
+ const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed);
188409
188476
  if (expr.slotId) {
188410
188477
  return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`;
188411
188478
  }
@@ -188497,8 +188564,12 @@ ${whenTrue}
188497
188564
  }
188498
188565
  });
188499
188566
  }
188567
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
188568
+ isNameShadowed: (name) => this.staticLoopSourceBoundNames.has(name)
188569
+ });
188570
+ const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null;
188500
188571
  const arrayName = loop.array.trim();
188501
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188572
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
188502
188573
  const arrayConst = (this.localConstants ?? []).find((c) => c.name === arrayName);
188503
188574
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
188504
188575
  this.errors.push({
@@ -188512,7 +188583,7 @@ ${whenTrue}
188512
188583
  });
188513
188584
  }
188514
188585
  }
188515
- const rawArray = this.convertExpressionToKolon(loop.array);
188586
+ const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array);
188516
188587
  let array = rawArray;
188517
188588
  if (loop.sortComparator) {
188518
188589
  const sort = loop.sortComparator;
@@ -188825,6 +188896,8 @@ ${name}="<: ${val} :>"
188825
188896
  for (const attr of element.attrs) {
188826
188897
  if (attr.clientOnly)
188827
188898
  continue;
188899
+ if (isDangerousInnerHtmlAttr(attr))
188900
+ continue;
188828
188901
  let attrName;
188829
188902
  if (attr.name === "className")
188830
188903
  attrName = "class";
@@ -189008,6 +189081,8 @@ Options:
189008
189081
  return this.booleanTypedProps.has(bare);
189009
189082
  }
189010
189083
  _resolveLiteralConst(name) {
189084
+ if (this.staticLoopSourceBoundNames.has(name))
189085
+ return null;
189011
189086
  const c = (this.localConstants ?? []).find((lc) => lc.name === name);
189012
189087
  if (c?.value === undefined)
189013
189088
  return null;
@@ -189020,6 +189095,8 @@ Options:
189020
189095
  return null;
189021
189096
  }
189022
189097
  _resolveStaticRecordLiteral(objectName, key) {
189098
+ if (this.staticLoopSourceBoundNames.has(objectName))
189099
+ return null;
189023
189100
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants);
189024
189101
  if (!hit)
189025
189102
  return null;
@@ -189057,22 +189134,15 @@ Options:
189057
189134
  var xslateAdapter = new XslateAdapter;
189058
189135
  // src/conformance-pins.ts
189059
189136
  var conformancePins = {
189060
- "static-array-children": [{ code: "BF103", severity: "error" }],
189061
- "todo-app": [{ code: "BF103", severity: "error" }],
189062
- "todo-app-ssr": [{ code: "BF103", severity: "error" }],
189063
189137
  "static-array-from-props": [{ code: "BF101", severity: "error" }],
189064
- "static-array-from-props-with-component": [
189065
- { code: "BF103", severity: "error" },
189066
- { code: "BF101", severity: "error" }
189067
- ],
189138
+ "static-array-from-props-with-component": [{ code: "BF101", severity: "error" }],
189068
189139
  "filter-nested-callback-predicate": [
189069
189140
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189070
189141
  ],
189071
189142
  "filter-nested-find-predicate": [
189072
189143
  { code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2038" }
189073
189144
  ],
189074
- "array-map-function-reference": [{ code: "BF101", severity: "error" }],
189075
- "dangerous-inner-html": [{ code: "BF101", severity: "error" }]
189145
+ "dangerous-inner-html-dynamic": [{ code: "BF101", severity: "error", issue: "https://github.com/piconic-ai/barefootjs/issues/2215" }]
189076
189146
  };
189077
189147
  // src/render-divergences.ts
189078
189148
  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"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Xslate;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.5";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/xslate",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Text::Xslate (Kolon) adapter for BarefootJS — compiles IR to .tx templates and ships the Xslate rendering backend; runs under any PSGI/Plack app",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,14 +55,14 @@
55
55
  "directory": "packages/adapter-xslate"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.18.5"
58
+ "@barefootjs/shared": "0.18.7"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@barefootjs/adapter-tests": "0.1.0",
65
- "@barefootjs/jsx": "0.18.5",
65
+ "@barefootjs/jsx": "0.18.7",
66
66
  "typescript": "^5.0.0"
67
67
  }
68
68
  }
@@ -421,3 +421,122 @@ export function Parent() {
421
421
  // `filter-nested-find-predicate` (BF101 via `expectedDiagnostics` above) and
422
422
  // `filter-nested-callback-predicate-client` (the `/* @client */` suppression
423
423
  // twin, which must render clean).
424
+
425
+ // #2221: `_resolveLiteralConst` is a flat name lookup against
426
+ // `ir.metadata.localConstants` with no notion of AST scope — it used to
427
+ // substitute an outer const's literal value even at an occurrence that is
428
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
429
+ // iteration rendered the same hard-coded literal. Guarded with the same
430
+ // coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
431
+ // anywhere in the component never inlines, falling back to the bare
432
+ // identifier.
433
+ describe('XslateAdapter - const inlining vs loop-param shadowing (#2221)', () => {
434
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
435
+ const { template } = compileAndGenerate(`
436
+ function Widget() {
437
+ const label: string = 'x'
438
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
439
+ }
440
+ `)
441
+ // The loop body must reference the per-iteration loop var...
442
+ expect(template).toContain('1 + $label')
443
+ // ...never the outer const's hard-coded value.
444
+ expect(template).not.toContain("1 + 'x'")
445
+ })
446
+
447
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
448
+ const { template } = compileAndGenerate(`
449
+ function Widget() {
450
+ const count = 7
451
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
452
+ }
453
+ `)
454
+ expect(template).toContain('1 + $count')
455
+ expect(template).not.toContain('1 + 7')
456
+ })
457
+
458
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
459
+ const { template } = compileAndGenerate(`
460
+ function Widget({ values }: { values: number[] }) {
461
+ const totalPages = 5
462
+ return <div>
463
+ <p>Page 1 of {1 + totalPages}</p>
464
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
465
+ </div>
466
+ }
467
+ `)
468
+ expect(template).toContain('1 + 5')
469
+ })
470
+
471
+ // The accepted coarse-exclusion trade-off (same as #2212): a name that is
472
+ // loop-bound ANYWHERE in the component never inlines, even at a genuinely
473
+ // non-shadowed occurrence outside the loop — the bare identifier is
474
+ // emitted instead of the value.
475
+ test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
476
+ const { template } = compileAndGenerate(`
477
+ function Widget({ values }: { values: number[] }) {
478
+ const label: string = 'x'
479
+ return <div>
480
+ <p>{1 + label}</p>
481
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
482
+ </div>
483
+ }
484
+ `)
485
+ expect(template).not.toContain("1 + 'x'")
486
+ expect(template).toContain('2 + $label')
487
+ })
488
+ })
489
+
490
+ // #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
491
+ // object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
492
+ // flat name lookup on `objectName` with no notion of AST scope, the
493
+ // record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
494
+ // substitute the outer const's member value even at an occurrence that is
495
+ // actually an enclosing loop callback's own (shadowing) parameter, so every
496
+ // iteration rendered the same hard-coded literal instead of the per-item
497
+ // value. Guarded with the same coarse `staticLoopSourceBoundNames`
498
+ // exclusion as #2221: any name a loop binds anywhere in the component
499
+ // never inlines, falling back to the bare `$cfg.x` member expression.
500
+ describe('XslateAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
501
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
502
+ const { template } = compileAndGenerate(`
503
+ const cfg = { x: 'outer-lit' }
504
+ function Widget({ rows }: { rows: { x: string }[] }) {
505
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
506
+ }
507
+ `)
508
+ // The loop body must reference the per-iteration member access...
509
+ expect(template).toContain('<: $cfg.x :>')
510
+ // ...never the outer const's hard-coded value.
511
+ expect(template).not.toContain("<: 'outer-lit' :>")
512
+ })
513
+
514
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
515
+ const { template } = compileAndGenerate(`
516
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
517
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
518
+ return <div>{variantClasses.ghost}</div>
519
+ }
520
+ `)
521
+ expect(template).toContain("<: 'bg-ghost' :>")
522
+ })
523
+
524
+ // The accepted coarse-exclusion trade-off (same as #2221/#2212): an
525
+ // object name that is loop-bound ANYWHERE in the component never
526
+ // inlines its member lookups, even at a genuinely non-shadowed
527
+ // occurrence outside the loop — the bare member expression is emitted
528
+ // instead of the value.
529
+ test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
530
+ const { template } = compileAndGenerate(`
531
+ const cfg = { x: 'outer-lit' }
532
+ function Widget({ rows }: { rows: { x: string }[] }) {
533
+ return <div>
534
+ <p>{cfg.x}</p>
535
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
536
+ </div>
537
+ }
538
+ `)
539
+ expect(template).not.toContain("<: 'outer-lit' :>")
540
+ expect(template).toContain('<: $cfg.x :>')
541
+ })
542
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Text::Xslate (Kolon) literal. Used to inline a fully-static loop source
5
+ * (an inline array literal, or a function-scope local const with a static
6
+ * initializer) directly in a `for EXPR -> $item { ... }` header, rather
7
+ * than requiring a bound template variable.
8
+ *
9
+ * Booleans deliberately return `null` (defer to the caller's BF101
10
+ * refusal) rather than baking a `1`/absent-value stand-in — Kolon has no
11
+ * native boolean literal in this position, and guessing one would diverge
12
+ * from JS's `String(true) === "true"` at render.
13
+ *
14
+ * Returns `null` for a value this adapter can't represent as a literal —
15
+ * the caller falls back to its existing BF101 refusal instead of guessing.
16
+ */
17
+
18
+ import { escapeKolonSingleQuoted, kolonHashKey } from './kolon-naming.ts'
19
+
20
+ export function staticValueToKolon(value: unknown): string | null {
21
+ if (value === null || value === undefined) return 'nil'
22
+ if (typeof value === 'boolean') return null
23
+ if (typeof value === 'number') return String(value)
24
+ if (typeof value === 'string') return `'${escapeKolonSingleQuoted(value)}'`
25
+ if (Array.isArray(value)) {
26
+ const items: string[] = []
27
+ for (const el of value) {
28
+ const serialized = staticValueToKolon(el)
29
+ if (serialized === null) return null
30
+ items.push(serialized)
31
+ }
32
+ return `[${items.join(', ')}]`
33
+ }
34
+ if (typeof value === 'object') {
35
+ const entries: string[] = []
36
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
37
+ const serialized = staticValueToKolon(val)
38
+ if (serialized === null) return null
39
+ entries.push(`${kolonHashKey(key)} => ${serialized}`)
40
+ }
41
+ return `{ ${entries.join(', ')} }`
42
+ }
43
+ return null
44
+ }
@@ -7,7 +7,7 @@
7
7
  * adapter's `props/prop-types.ts`. No adapter instance state.
8
8
  */
9
9
 
10
- import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { collectLoopBoundNames, type ComponentIR } from '@barefootjs/jsx'
11
11
  import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
12
 
13
13
  /**
@@ -43,12 +43,30 @@ 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 Kolon emitters
50
- * don't consume the distinction (Kolon's `==`/`!=` compare strings and numbers
51
- * correctly), so this set is carried for parity with the Mojo adapter.
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 Kolon's `~` over JS `+`'s numeric fallback (#2163, #2212)
52
+ * including now for a bare identifier operand, not just a prop/getter/
53
+ * literal. In the Mojo adapter this ALSO drives `eq`/`ne` selection for
54
+ * string equality; the Kolon emitters don't consume that distinction
55
+ * (Kolon's `==`/`!=` compare strings and numbers correctly), so that half
56
+ * of this set is carried only for parity with the Mojo adapter.
57
+ *
58
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
59
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
60
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
61
+ * loop param shadowing an outer string-typed binding of the same name
62
+ * (`items.map((name) => 1 + name)` inside a component that also has a
63
+ * string `name` prop) — left unguarded, that shadowed `name` would be
64
+ * misdetected as string-typed and `1 + name` would silently lower to `~`
65
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
66
+ * (it also suppresses a genuinely non-shadowed same-named string
67
+ * elsewhere in the component) but safe: the suppressed case just falls
68
+ * back to today's numeric `+` — the same, already-accepted residual as an
69
+ * unresolvable operand — never silently-wrong output.
52
70
  */
53
71
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
54
72
  const names = new Set<string>()
@@ -60,5 +78,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
60
78
  for (const p of ir.metadata.propsParams) {
61
79
  if (isStringTypeInfo(p.type)) names.add(p.name)
62
80
  }
81
+ for (const c of ir.metadata.localConstants) {
82
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
83
+ }
84
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
63
85
  return names
64
86
  }
@@ -73,6 +73,12 @@ import {
73
73
  isValidHelperId,
74
74
  sortComparatorFromArrow,
75
75
  isLowerableLoopDestructure,
76
+ isDangerousInnerHtmlAttr,
77
+ resolveDangerousInnerHtml,
78
+ dangerousInnerHtmlMetacharViolation,
79
+ dangerousInnerHtmlDiagnostic,
80
+ resolveStaticLoopSource,
81
+ collectLoopBoundNames,
76
82
  } from '@barefootjs/jsx'
77
83
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
78
84
  import ts from 'typescript'
@@ -87,6 +93,7 @@ import {
87
93
  collectRootScopeNodes,
88
94
  } from './lib/ir-scope.ts'
89
95
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
96
+ import { staticValueToKolon } from './lib/static-value.ts'
90
97
  import { XslateFilterEmitter, XslateTopLevelEmitter } from './expr/emitters.ts'
91
98
  import type { XslateEmitContext, XslateSpreadContext, XslateMemoContext } from './emit-context.ts'
92
99
  import {
@@ -240,6 +247,17 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
240
247
  */
241
248
  private localConstants: IRMetadata['localConstants'] = []
242
249
 
250
+ /**
251
+ * Every name a `.map()`/`.filter()` loop callback binds as its item/index
252
+ * parameter anywhere in the component (#2208 fable review). A static
253
+ * loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
254
+ * never resolve through `resolveStaticLoopSource` at a use site where a
255
+ * DIFFERENT, enclosing loop's own callback param shadows it — same
256
+ * shadowing hazard, and same coarse-but-safe mitigation, as #2212's
257
+ * `collectLoopBoundNames` use in `collectStringValueNames`.
258
+ */
259
+ private staticLoopSourceBoundNames: Set<string> = new Set()
260
+
243
261
  /**
244
262
  * Optional, no-default props that are `undef` when the caller omits them.
245
263
  * Their bare-reference attribute emission is guarded with Kolon `defined` so
@@ -272,6 +290,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
272
290
  // Per-compile prop classifications (see `props/prop-classes.ts`).
273
291
  this.booleanTypedProps = collectBooleanTypedProps(ir)
274
292
  this.localConstants = ir.metadata.localConstants ?? []
293
+ this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
275
294
  this.nullableOptionalProps = collectNullableOptionalProps(ir)
276
295
  this.stringValueNames = collectStringValueNames(ir)
277
296
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
@@ -494,7 +513,8 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
494
513
  renderElement(element: IRElement): string {
495
514
  const tag = element.tag
496
515
  const attrs = this.renderAttributes(element)
497
- const children = this.renderChildren(element.children)
516
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
517
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
498
518
 
499
519
  let hydrationAttrs = ''
500
520
  if (element.needsScope) {
@@ -529,6 +549,28 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
529
549
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
530
550
  }
531
551
 
552
+ /**
553
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
554
+ * adapter's identical helper for the full rationale. `null` means the
555
+ * attribute is absent (caller falls through to normal `renderChildren`);
556
+ * a non-`null` string (possibly `''`) replaces the children outright.
557
+ */
558
+ private renderDangerousInnerHtml(element: IRElement): string | null {
559
+ const resolution = resolveDangerousInnerHtml(element)
560
+ if (!resolution) return null
561
+ if (resolution.kind === 'dynamic') {
562
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
563
+ return ''
564
+ }
565
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
566
+ if (violation) {
567
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
568
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
569
+ return ''
570
+ }
571
+ return resolution.html
572
+ }
573
+
532
574
  // ===========================================================================
533
575
  // Expression Rendering
534
576
  // ===========================================================================
@@ -541,7 +583,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
541
583
  return ''
542
584
  }
543
585
 
544
- const perlExpr = this.convertExpressionToKolon(expr.expr)
586
+ // Thread the IR-carried `.parsed` tree through (mirrors go-template's
587
+ // `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
588
+ // resolved bare-identifier `.map`/`.filter`/… callback
589
+ // (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
590
+ // fresh, unresolved re-parse of the raw string.
591
+ const perlExpr = this.convertExpressionToKolon(expr.expr, expr.parsed)
545
592
 
546
593
  if (expr.slotId) {
547
594
  return `<: $bf.text_start("${expr.slotId}") | mark_raw :><: ${perlExpr} :><: $bf.text_end() | mark_raw :>`
@@ -687,8 +734,27 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
687
734
  // this adapter's test corpus only because the widened destructure gate
688
735
  // (#2087 Phase A/B) no longer refuses this fixture's `([emoji, users])
689
736
  // => ...` param first.
737
+ // #2208: a loop source that is a fully-static array literal — either
738
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
739
+ // bound to a FUNCTION-scope local const whose initializer has no
740
+ // prop/signal/function-call dependency — inlines as a native Kolon
741
+ // array/hash literal below, the same way a module-scope const's value
742
+ // is already seeded. A runtime-computed local (#2069, e.g.
743
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
744
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
745
+ // param shadowing this identifier (fable review) — never resolve the
746
+ // static const in that case. `rawArray` then falls through to the
747
+ // bare identifier expression below, same as before #2208 — which
748
+ // still trips the pre-existing BF101 gate for an unresolvable local
749
+ // const reference (a loud, conservative refusal, not a silent wrong
750
+ // value).
751
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
752
+ isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
753
+ })
754
+ const staticArray = staticItems !== null ? staticValueToKolon(staticItems) : null
755
+
690
756
  const arrayName = loop.array.trim()
691
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
757
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
692
758
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
693
759
  if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
694
760
  this.errors.push({
@@ -704,7 +770,7 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
704
770
  }
705
771
  }
706
772
 
707
- const rawArray = this.convertExpressionToKolon(loop.array)
773
+ const rawArray = staticArray ?? this.convertExpressionToKolon(loop.array)
708
774
  // Apply sort if present: wrap the loop array in the shared `$bf.sort`
709
775
  // helper, binding the sorted result to a per-iteration local so the
710
776
  // helper runs once.
@@ -1344,6 +1410,12 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1344
1410
  // the unsupported-expression lowering is never reached for a deferred
1345
1411
  // predicate (no BF101 / BF102). #1966
1346
1412
  if (attr.clientOnly) continue
1413
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1414
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1415
+ // element's children. Skip it here so its `{ __html: ... }` object
1416
+ // literal never reaches the generic object-literal BF101 refusal
1417
+ // (which would double-report alongside the purpose-built one).
1418
+ if (isDangerousInnerHtmlAttr(attr)) continue
1347
1419
  // Rewrite JSX special-prop names to their HTML-attribute counterparts.
1348
1420
  let attrName: string
1349
1421
  if (attr.name === 'className') attrName = 'class'
@@ -1672,8 +1744,19 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1672
1744
  * single-quoted string literal (`const totalPages = 5`, #1897
1673
1745
  * pagination) — function-scope consts never reach the per-render
1674
1746
  * stash, so a bare `$totalPages` renders empty.
1747
+ *
1748
+ * The lookup is a flat name match with no notion of AST scope, so a
1749
+ * name that any loop callback binds as its item/index param never
1750
+ * inlines (#2221) — the occurrence may be the loop's own (shadowing)
1751
+ * binding, and substituting the outer const's value there renders every
1752
+ * iteration with the same hard-coded literal. Coarse (a genuinely
1753
+ * non-shadowed same-named const elsewhere in the component also stops
1754
+ * inlining, falling back to the bare identifier) but safe — the same
1755
+ * trade-off as #2212's `collectLoopBoundNames` use in
1756
+ * `collectStringValueNames`.
1675
1757
  */
1676
1758
  private _resolveLiteralConst(name: string): string | null {
1759
+ if (this.staticLoopSourceBoundNames.has(name)) return null
1677
1760
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
1678
1761
  if (c?.value === undefined) return null
1679
1762
  const v = c.value.trim()
@@ -1683,7 +1766,22 @@ export class XslateAdapter extends BaseAdapter implements IRNodeEmitter<XslateRe
1683
1766
  return null
1684
1767
  }
1685
1768
 
1769
+ /**
1770
+ * Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
1771
+ * (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
1772
+ *
1773
+ * The lookup is a flat name match on `objectName` with no notion of AST
1774
+ * scope, so an enclosing loop callback's own param of the same name
1775
+ * (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
1776
+ * still resolved to the OUTER const's member value at every iteration
1777
+ * (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
1778
+ * coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
1779
+ * binds anywhere in the component never inlines, falling back to the bare
1780
+ * `$cfg.x` member expression (which an Xslate `: for` loop binds
1781
+ * correctly at the shadowed occurrences).
1782
+ */
1686
1783
  private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
1784
+ if (this.staticLoopSourceBoundNames.has(objectName)) return null
1687
1785
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
1688
1786
  if (!hit) return null
1689
1787
  return hit.kind === 'number'