@barefootjs/mojolicious 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.
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.5";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  =head1 NAME
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.5";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  use Mojo::File qw(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,7 +52,7 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.18.5"
55
+ "@barefootjs/shared": "0.18.7"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@barefootjs/jsx": ">=0.2.0",
@@ -60,6 +60,6 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.18.5"
63
+ "@barefootjs/jsx": "0.18.7"
64
64
  }
65
65
  }
@@ -195,6 +195,166 @@ function Box({ other }: { other?: object }) {
195
195
  })
196
196
  })
197
197
 
198
+ // #2221: same class of hazard as the Twig-family `_resolveLiteralConst`
199
+ // flat-lookup bug, but this adapter's story is different. `resolveLiteralConst`
200
+ // / `resolveStaticRecordLiteral` (mojo-adapter.ts) already guard against it —
201
+ // they consult `loopBoundNames`, a LIVE ref-counted map that
202
+ // `renderLoop` populates/depopulates as it descends/ascends into each loop
203
+ // body (#1749), not a static whole-component set like the Twig family's
204
+ // `collectLoopBoundNames(ir)`. That makes the guard scope-PRECISE rather
205
+ // than coarse: a name loop-bound only inside one loop still inlines fine
206
+ // at a genuinely separate, non-shadowed occurrence elsewhere in the
207
+ // component (see the third test below) — the Twig-family's documented
208
+ // coarse trade-off (a same-named const anywhere else in the component also
209
+ // stops inlining) does not apply here. So no `staticLoopSourceBoundNames`
210
+ // field was added; the existing live tracking already covers this call
211
+ // site and is strictly more precise.
212
+ //
213
+ // The ONE actual gap found: `emitSpread`'s bare-identifier local-const
214
+ // spread resolution (mojo-adapter.ts, the `this.localConstants.find(...)`
215
+ // call keyed by `trimmed`, `{...attrs}` → `{ … }` hashref, #checkbox/icon)
216
+ // read `this.localConstants` directly with no `loopBoundNames` guard at
217
+ // all — a loop param named the same as an outer conditional-object const
218
+ // (`.map((attrs) => <li {...attrs} />)` shadowing `const attrs = cond ?
219
+ // {…} : {}`) incorrectly forwarded the outer object's literal hashref
220
+ // instead of falling through to the per-iteration `$attrs` value. Fixed
221
+ // with the same `loopBoundNames` guard as the other two call sites.
222
+ //
223
+ // Not covered here (upstream, shared-compiler hazard, out of this
224
+ // package's scope): `key={label}` shadowed by an enclosing loop param of
225
+ // the same name is folded to the OUTER const's literal at IR-generation
226
+ // time (`tryResolveIdentifierAsTemplateLiteral` → `findLocalConst` in
227
+ // `packages/jsx/src/jsx-to-ir.ts`), before any adapter runs — so this
228
+ // adapter (and every other adapter, including Hono's native JSX
229
+ // re-emission) still renders a `key`/`data-key` value shadowed this way
230
+ // as the outer literal, unconditionally, every iteration.
231
+ describe('MojoAdapter - const inlining vs loop-param shadowing (#2221)', () => {
232
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
233
+ const { template } = compileAndGenerate(`
234
+ function Widget() {
235
+ const label: string = 'x'
236
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
237
+ }
238
+ `)
239
+ expect(template).toContain('1 + $label')
240
+ expect(template).not.toContain("1 + 'x'")
241
+ })
242
+
243
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
244
+ const { template } = compileAndGenerate(`
245
+ function Widget() {
246
+ const count = 7
247
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
248
+ }
249
+ `)
250
+ expect(template).toContain('1 + $count')
251
+ expect(template).not.toContain('1 + 7')
252
+ })
253
+
254
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
255
+ const { template } = compileAndGenerate(`
256
+ function Widget({ values }: { values: number[] }) {
257
+ const totalPages = 5
258
+ return <div>
259
+ <p>Page 1 of {1 + totalPages}</p>
260
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
261
+ </div>
262
+ }
263
+ `)
264
+ expect(template).toContain('1 + 5')
265
+ })
266
+
267
+ // Unlike the Twig-family's coarse-but-safe `collectLoopBoundNames(ir)`
268
+ // exclusion, this adapter's LIVE `loopBoundNames` tracking is scoped to
269
+ // the actual render position: a name loop-bound ONLY inside the `.map`
270
+ // callback still inlines correctly at a separate, non-shadowed
271
+ // occurrence outside the loop — no accepted trade-off here.
272
+ test('a const referenced outside the loop whose name is loop-bound elsewhere still inlines (more precise than Twig family)', () => {
273
+ const { template } = compileAndGenerate(`
274
+ function Widget({ values }: { values: number[] }) {
275
+ const label: string = 'x'
276
+ return <div>
277
+ <p>{1 + label}</p>
278
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
279
+ </div>
280
+ }
281
+ `)
282
+ expect(template).toContain("1 + 'x'")
283
+ expect(template).toContain('2 + $label')
284
+ })
285
+
286
+ // The actual gap this issue found in this adapter: `emitSpread`'s
287
+ // bare-identifier local-const resolution (`{...attrs}` → the outer
288
+ // conditional object's hashref) had no `loopBoundNames` guard.
289
+ test('a loop param shadowing an outer conditional-object const spread emits the loop var, not the outer hashref', () => {
290
+ const { template } = compileAndGenerate(`
291
+ function Widget({ items }: { items: object[] }) {
292
+ const attrs = true ? { 'data-on': 'outer' } : {}
293
+ return <ul>{items.map((attrs) => <li {...attrs} />)}</ul>
294
+ }
295
+ `)
296
+ expect(template).toContain('bf->spread_attrs($attrs)')
297
+ expect(template).not.toContain("'data-on' => 'outer'")
298
+ })
299
+ })
300
+
301
+ // #2237: the record-literal sibling of #2221's `resolveLiteralConst` bug —
302
+ // `resolveStaticRecordLiteral` (`IDENT.key` on a module-scope object-literal
303
+ // const, e.g. `variantClasses.ghost` — #1896/#1897) is confirmed reproducible
304
+ // on the Twig-family adapters (flat `objectName` lookup with no notion of AST
305
+ // scope, so an enclosing loop callback's own param of the same name resolved
306
+ // to the OUTER const's member value at every iteration). This adapter's
307
+ // `resolveStaticRecordLiteral` already guards against it (mojo-adapter.ts:
308
+ // `if (this.loopBoundNames?.has?.(objectName)) return null`) — the same LIVE,
309
+ // ref-counted `loopBoundNames` map `resolveLiteralConst` consults (#1749),
310
+ // scope-precise rather than the Twig family's coarse whole-component set.
311
+ // Pinned here (mirroring the #2221 scope-precision pin above) rather than
312
+ // fixed, since no code change was needed.
313
+ describe('MojoAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
314
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
315
+ const { template } = compileAndGenerate(`
316
+ const cfg = { x: 'outer-lit' }
317
+ function Widget({ rows }: { rows: { x: string }[] }) {
318
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
319
+ }
320
+ `)
321
+ // The loop body must reference the per-iteration member access...
322
+ expect(template).toContain('$cfg->{x}')
323
+ // ...never the outer const's hard-coded value.
324
+ expect(template).not.toContain("'outer-lit'")
325
+ })
326
+
327
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
328
+ const { template } = compileAndGenerate(`
329
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
330
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
331
+ return <div>{variantClasses.ghost}</div>
332
+ }
333
+ `)
334
+ expect(template).toContain("'bg-ghost'")
335
+ })
336
+
337
+ // Unlike the Twig-family's coarse-but-safe `staticLoopSourceBoundNames`
338
+ // exclusion, this adapter's LIVE `loopBoundNames` tracking is scoped to
339
+ // the actual render position: an object name loop-bound ONLY inside the
340
+ // `.map` callback still inlines its member lookup correctly at a
341
+ // separate, non-shadowed occurrence outside the loop — no accepted
342
+ // trade-off here.
343
+ test('an object name loop-bound only inside the loop still inlines its member lookup outside it (more precise than Twig family)', () => {
344
+ const { template } = compileAndGenerate(`
345
+ const cfg = { x: 'outer-lit' }
346
+ function Widget({ rows }: { rows: { x: string }[] }) {
347
+ return <div>
348
+ <p>{cfg.x}</p>
349
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
350
+ </div>
351
+ }
352
+ `)
353
+ expect(template).toContain("<p><%= 'outer-lit' %></p>")
354
+ expect(template).toContain('$cfg->{x}')
355
+ })
356
+ })
357
+
198
358
  describe('MojoAdapter - Record<staticKeys,scalar>[propKey] spread value (#checkbox icon)', () => {
199
359
  // `const sizeMap: Record<IconSize, number> = { sm: 16, ... }` indexed by
200
360
  // a prop inside a conditional-spread object value lowers to an inline
@@ -1406,7 +1566,7 @@ describe('MojoAdapter - #1448 Tier C .flat(depth?)', () => {
1406
1566
  function emitFlat(expr: string): string {
1407
1567
  const a = new MojoAdapter()
1408
1568
  const ir = compileToIR(`
1409
- function C({ rows }: { rows: number[][] }) {
1569
+ function C({ rows }: { rows: { x: string }[][] }) {
1410
1570
  return <div>{${expr}}</div>
1411
1571
  }
1412
1572
  export { C }
@@ -1526,9 +1686,10 @@ export { C }
1526
1686
  expect(t).toContain(`"property":"name"`)
1527
1687
  })
1528
1688
 
1529
- // The function-reference `.map(format)` BF101 refusal is now covered
1530
- // cross-adapter by the `array-map-function-reference` shared fixture's
1531
- // `expectedDiagnostics` entry above.
1689
+ // The function-reference `.map(format)` case is now covered cross-adapter
1690
+ // by the `array-map-function-reference` shared fixture — `format` resolves
1691
+ // to its declaration (#2206) and the fixture compiles clean rather than
1692
+ // refusing with BF101.
1532
1693
  })
1533
1694
 
1534
1695
  describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
3
+ * `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
4
+ * Perl 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 the loop-bound expression, rather than requiring a bound
7
+ * template variable.
8
+ *
9
+ * Booleans deliberately return `null` (defer to the caller's BF101
10
+ * refusal) rather than baking `1`/`''` — Perl has no boolean literal, and
11
+ * that would diverge from JS's `String(true) === "true"` at render.
12
+ *
13
+ * Returns `null` for a value this adapter can't represent as a literal —
14
+ * the caller falls back to its existing BF101 refusal instead of guessing.
15
+ */
16
+
17
+ import { perlHashKey } from './perl-naming.ts'
18
+
19
+ function escapePerlSingleQuote(s: string): string {
20
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
21
+ }
22
+
23
+ export function staticValueToPerl(value: unknown): string | null {
24
+ if (value === null || value === undefined) return 'undef'
25
+ if (typeof value === 'boolean') return null
26
+ if (typeof value === 'number') return String(value)
27
+ if (typeof value === 'string') return `'${escapePerlSingleQuote(value)}'`
28
+ if (Array.isArray(value)) {
29
+ const items: string[] = []
30
+ for (const el of value) {
31
+ const serialized = staticValueToPerl(el)
32
+ if (serialized === null) return null
33
+ items.push(serialized)
34
+ }
35
+ return `[${items.join(', ')}]`
36
+ }
37
+ if (typeof value === 'object') {
38
+ const entries: string[] = []
39
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
40
+ const serialized = staticValueToPerl(val)
41
+ if (serialized === null) return null
42
+ entries.push(`${perlHashKey(key)} => ${serialized}`)
43
+ }
44
+ return `{ ${entries.join(', ')} }`
45
+ }
46
+ return null
47
+ }
@@ -59,6 +59,11 @@ import {
59
59
  isValidHelperId,
60
60
  sortComparatorFromArrow,
61
61
  isLowerableLoopDestructure,
62
+ isDangerousInnerHtmlAttr,
63
+ resolveDangerousInnerHtml,
64
+ dangerousInnerHtmlMetacharViolation,
65
+ dangerousInnerHtmlDiagnostic,
66
+ resolveStaticLoopSource,
62
67
  } from '@barefootjs/jsx'
63
68
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
64
69
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
@@ -72,6 +77,7 @@ import {
72
77
  collectRootScopeNodes,
73
78
  } from './lib/ir-scope.ts'
74
79
  import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
80
+ import { staticValueToPerl } from './lib/static-value.ts'
75
81
  import { MojoFilterEmitter, MojoTopLevelEmitter } from './expr/emitters.ts'
76
82
  import type { MojoEmitContext, MojoSpreadContext, MojoMemoContext } from './emit-context.ts'
77
83
  import {
@@ -415,6 +421,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
415
421
  * quoted string literal (`const totalPages = 5`, #1897 pagination) —
416
422
  * function-scope consts never reach the per-render stash, so a bare
417
423
  * `$totalPages` faults under strict mode.
424
+ *
425
+ * The `loopBoundNames` guard also covers the #2221 hazard (a loop
426
+ * callback's own param shadowing this outer const's name): unlike the
427
+ * Twig-family adapters' coarse, whole-component `collectLoopBoundNames(ir)`
428
+ * static set, this adapter's `loopBoundNames` is a LIVE ref-counted map
429
+ * `renderLoop` populates/depopulates as it descends/ascends into each
430
+ * loop body (#1749) — so it's already scope-precise for this call site;
431
+ * no separate `staticLoopSourceBoundNames`-style field is needed here.
418
432
  */
419
433
  private resolveLiteralConst(name: string): string | null {
420
434
  if (this.loopBoundNames?.has?.(name)) return null
@@ -617,7 +631,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
617
631
  renderElement(element: IRElement): string {
618
632
  const tag = element.tag
619
633
  const attrs = this.renderAttributes(element)
620
- const children = this.renderChildren(element.children)
634
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
635
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
621
636
 
622
637
  let hydrationAttrs = ''
623
638
  if (element.needsScope) {
@@ -653,6 +668,28 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
653
668
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
654
669
  }
655
670
 
671
+ /**
672
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
673
+ * adapter's identical helper for the full rationale. `null` means the
674
+ * attribute is absent (caller falls through to normal `renderChildren`);
675
+ * a non-`null` string (possibly `''`) replaces the children outright.
676
+ */
677
+ private renderDangerousInnerHtml(element: IRElement): string | null {
678
+ const resolution = resolveDangerousInnerHtml(element)
679
+ if (!resolution) return null
680
+ if (resolution.kind === 'dynamic') {
681
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
682
+ return ''
683
+ }
684
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
685
+ if (violation) {
686
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
687
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
688
+ return ''
689
+ }
690
+ return resolution.html
691
+ }
692
+
656
693
  // ===========================================================================
657
694
  // Expression Rendering
658
695
  // ===========================================================================
@@ -665,7 +702,12 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
665
702
  return ''
666
703
  }
667
704
 
668
- const perlExpr = this.convertExpressionToPerl(expr.expr)
705
+ // Thread the IR-carried `.parsed` tree through (mirrors go-template's
706
+ // `convertExpressionToGo(expr.expr, classify, expr.parsed)`) so a
707
+ // resolved bare-identifier `.map`/`.filter`/… callback
708
+ // (`resolveCallbackMethodFunctionReferences`, #2206) isn't lost to a
709
+ // fresh, unresolved re-parse of the raw string.
710
+ const perlExpr = this.convertExpressionToPerl(expr.expr, expr.parsed)
669
711
 
670
712
  if (expr.slotId) {
671
713
  return `<%== bf->text_start("${expr.slotId}") %><%= ${perlExpr} %><%== bf->text_end %>`
@@ -810,8 +852,24 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
810
852
  // reachable in this adapter's test corpus only because the widened
811
853
  // destructure gate (#2087 Phase A/B) no longer refuses this fixture's
812
854
  // `([emoji, users]) => ...` param first.
855
+ // #2208: a loop source that is a fully-static array literal — either
856
+ // inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
857
+ // bound to a FUNCTION-scope local const whose initializer has no
858
+ // prop/signal/function-call dependency — inlines as a native Perl
859
+ // arrayref/hashref literal below, the same way a module-scope const's
860
+ // value is already seeded. A runtime-computed local (#2069, e.g.
861
+ // `Object.entries(props.tags).filter(...)`) still refuses below.
862
+ // `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
863
+ // param shadowing this identifier (fable review) — reuses the same
864
+ // live `loopBoundNames` ref-counted tracking `resolveModuleStringConst`
865
+ // already consults for this hazard class (#1749).
866
+ const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
867
+ isNameShadowed: name => this.loopBoundNames.has(name),
868
+ })
869
+ const staticArray = staticItems !== null ? staticValueToPerl(staticItems) : null
870
+
813
871
  const arrayName = loop.array.trim()
814
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
872
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
815
873
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
816
874
  if (arrayConst && !arrayConst.isModule && this.resolveLiteralConst(arrayName) === null) {
817
875
  this.errors.push({
@@ -827,7 +885,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
827
885
  }
828
886
  }
829
887
 
830
- const rawArray = this.convertExpressionToPerl(loop.array)
888
+ const rawArray = staticArray ?? this.convertExpressionToPerl(loop.array)
831
889
  // Apply sort if present (#1448 Tier B): wrap the loop array in the
832
890
  // shared sort helper. The same `renderSortEval` / `renderSortMethod`
833
891
  // pair feeds both this loop-chain hoist and the emitter's
@@ -1411,7 +1469,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1411
1469
  // initializer text and route through the same conditional-spread
1412
1470
  // lowering. Only function-scope (`!isModule`) consts whose value is
1413
1471
  // NOT itself a bare identifier (loop guard) are considered.
1414
- if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
1472
+ //
1473
+ // `loopBoundNames` guard (#2221): an enclosing `.map()` callback's
1474
+ // own param can shadow this outer const's name (`.map((sizeAttrs)
1475
+ // => <li {...sizeAttrs} />)`) — without the guard this forwarded
1476
+ // the OUTER const's hashref at every iteration instead of the
1477
+ // per-item `$sizeAttrs` value. Same live ref-counted map
1478
+ // `resolveLiteralConst` / `resolveStaticRecordLiteral` already
1479
+ // consult for this hazard class (#1749).
1480
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && !this.loopBoundNames.has(trimmed)) {
1415
1481
  const localConst = this.localConstants.find(
1416
1482
  c => c.name === trimmed && !c.isModule,
1417
1483
  )
@@ -1488,6 +1554,12 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1488
1554
  // the unsupported-expression lowering is never reached for a deferred
1489
1555
  // predicate (no BF101 / BF102). #1966
1490
1556
  if (attr.clientOnly) continue
1557
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
1558
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
1559
+ // element's children. Skip it here so its `{ __html: ... }` object
1560
+ // literal never reaches the generic object-literal BF101 refusal
1561
+ // (which would double-report alongside the purpose-built one).
1562
+ if (isDangerousInnerHtmlAttr(attr)) continue
1491
1563
  // Rewrite JSX special-prop names to their HTML-attribute
1492
1564
  // counterparts (#1475). `className` → `class` was already
1493
1565
  // wired in; the `key` → `data-key` rewrite matches the
@@ -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
  /**
@@ -67,11 +67,28 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
67
67
  }
68
68
 
69
69
  /**
70
- * String-typed signals and props, so equality comparisons against them lower
71
- * to `eq`/`ne` (#1672). A signal is string-typed when its inferred type is
72
- * `string` (the analyzer infers this from a string-literal initial value) or,
73
- * defensively, when its initial value is a bare string literal; a prop when
74
- * its annotated type is `string`.
70
+ * String-typed signals, props, and same-file local consts, so equality
71
+ * comparisons against them lower to `eq`/`ne` (#1672) and `+` concatenation
72
+ * against them lowers to Perl's `.` instead of numeric `+` (#2163, #2212 —
73
+ * `isStringConcatBinary`/`isStringTypedOperand` in `@barefootjs/jsx`, which
74
+ * now also recognizes a bare identifier operand, not just a prop/getter/
75
+ * literal). A signal is string-typed when its inferred type is `string`
76
+ * (the analyzer infers this from a string-literal initial value) or,
77
+ * defensively, when its initial value is a bare string literal; a prop or
78
+ * local const when its annotated (or inferred) type is `string`.
79
+ *
80
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
81
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
82
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
83
+ * loop param shadowing an outer string-typed binding of the same name
84
+ * (`items.map((name) => 1 + name)` inside a component that also has a
85
+ * string `name` prop) — left unguarded, that shadowed `name` would be
86
+ * misdetected as string-typed and `1 + name` would silently lower to `.`
87
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
88
+ * (it also suppresses a genuinely non-shadowed same-named string
89
+ * elsewhere in the component) but safe: the suppressed case just falls
90
+ * back to today's numeric `+` — the same, already-accepted residual as an
91
+ * unresolvable operand — never silently-wrong output.
75
92
  */
76
93
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
77
94
  const names = new Set<string>()
@@ -83,5 +100,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
83
100
  for (const p of ir.metadata.propsParams) {
84
101
  if (isStringTypeInfo(p.type)) names.add(p.name)
85
102
  }
103
+ for (const c of ir.metadata.localConstants) {
104
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
105
+ }
106
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
86
107
  return names
87
108
  }
@@ -9,17 +9,17 @@
9
9
  import type { ConformancePins } from '@barefootjs/jsx'
10
10
 
11
11
  export const conformancePins: ConformancePins = {
12
- // Sibling-imported child component in a loop body: Mojo emits
13
- // a cross-template call that needs separate registration. BF103
14
- // makes the requirement loud. (The barefoot CLI passes
15
- // `siblingTemplatesRegistered: true` so CLI builds suppress it.)
16
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
17
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
18
- // call it inside a keyed `.map`. Same BF103 surface as the
19
- // synthetic `static-array-children` above pinned at adapter
20
- // level so the shared-component corpus stays adapter-neutral.
21
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
22
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
12
+ // `todo-app` / `todo-app-ssr` no longer pinned (#2205) the conformance
13
+ // harness now passes `siblingTemplatesRegistered: true` for fixtures with
14
+ // sibling `components`, matching `bf build`'s real semantics, so the
15
+ // BF103 loop-body cross-template check no longer fires spuriously. (Both
16
+ // fixtures are still skipped on this adapter via `render-divergences.ts`
17
+ // #2209 for an unrelated signal-seeding gap.)
18
+ // `static-array-children` no longer pinned (#2208) `items`'s
19
+ // array-literal initializer is now recognized as fully-static
20
+ // (`resolveStaticLoopSource`) and inlined as a native Perl
21
+ // arrayref/hashref literal in the loop-bound expression, the same way a
22
+ // module-scope const's value is already seeded.
23
23
  // `([emoji, users]) => ...` / `([id, t]) => ...` are plain array-index
24
24
  // (tuple) destructures, no rest — #2087 Phase B's `segments`-walking
25
25
  // accessor lowers both to `$__bf_item->[0]` / `$__bf_item->[1]` `my`
@@ -32,12 +32,11 @@ export const conformancePins: ConformancePins = {
32
32
  // check in `renderLoop`. This was always true; it was simply
33
33
  // unreachable before because BF104 refused the destructure shape first.
34
34
  'static-array-from-props': [{ code: 'BF101', severity: 'error' }],
35
- // Both BF103 (sibling-imported `<Tag>` child component) and the BF101
36
- // above fire; BF104 no longer does (see above).
37
- 'static-array-from-props-with-component': [
38
- { code: 'BF103', severity: 'error' },
39
- { code: 'BF101', severity: 'error' },
40
- ],
35
+ // The BF101 above fires; BF104 no longer does (see above), and BF103
36
+ // (sibling-imported `<Tag>` child component in the loop body) no longer
37
+ // does either now that the conformance harness passes
38
+ // `siblingTemplatesRegistered: true` (#2205).
39
+ 'static-array-from-props-with-component': [{ code: 'BF101', severity: 'error' }],
41
40
  // #1310 / #2087: rest destructure in .map() callback. All four shapes
42
41
  // now lower via #2087 Phase B's `segments`-walking accessor:
43
42
  // - object-rest read via member access (`rest-destructure-object-in-map`):
@@ -130,15 +129,14 @@ export const conformancePins: ConformancePins = {
130
129
  // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
131
130
  // (per-element coderef predicate), matching Xslate. `.join` was never
132
131
  // pinned (handled by `renderArrayMethod`'s `case 'join'`).
133
- // #2073 follow-up: a function-reference `.map(format)` callback has no
134
- // arrow body to serialize not a CALLBACK_METHODS shape — so the
135
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
136
- // a broken template.
137
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
138
- // Edge-case sweep (Priority 12): `dangerouslySetInnerHTML` requires a
139
- // deliberate raw-HTML (unescaped) output affordance in the target
140
- // template language. No lowering exists yet, so the compiler refuses
141
- // the shape loudly instead of emitting entity-escaped markup that
142
- // silently renders tags as text.
143
- 'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
132
+ // `array-map-function-reference` no longer pinned — a bare-identifier
133
+ // `.map(format)` callback now resolves one hop to its declaration
134
+ // (`resolveCallbackMethodFunctionReferences`, #2206), the same mechanism
135
+ // #2090 established for `.sort(fnref)`.
136
+ // `dangerous-inner-html` no longer pinned a compile-time string-literal
137
+ // `dangerouslySetInnerHTML={{ __html: '...' }}` is spliced directly into
138
+ // the template as trusted raw text (`resolveDangerousInnerHtml`, #2207).
139
+ // A dynamic/signal-derived value still refuses with BF101 see the
140
+ // `dangerous-inner-html-dynamic` fixture/pin below (tracked: #2215).
141
+ 'dangerous-inner-html-dynamic': [{ code: 'BF101', severity: 'error', issue: 'https://github.com/piconic-ai/barefootjs/issues/2215' }],
144
142
  }
@@ -14,4 +14,9 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
- export const renderDivergences: RenderDivergences = {}
17
+ export const renderDivergences: RenderDivergences = {
18
+ // `todo-app` / `todo-app-ssr` no longer diverge (#2209) — the shared
19
+ // `evaluateSignalInit` (`@barefootjs/jsx`, sandboxed real-JS evaluation
20
+ // instead of a fixed regex-shape catalogue) now correctly seeds `todos`
21
+ // from `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`.
22
+ }