@barefootjs/mojolicious 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"}
@@ -42,9 +42,4 @@ export interface RenderOptions {
42
42
  componentName?: string;
43
43
  }
44
44
  export declare function renderMojoComponent(options: RenderOptions): Promise<string>;
45
- /**
46
- * Evaluate a signal initializer expression using provided props.
47
- * Handles patterns like: props.initial ?? 0, props.value, literal values.
48
- */
49
- export declare function evaluateSignalInit(expr: string, props?: Record<string, unknown>): unknown;
50
45
  //# sourceMappingURL=test-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CA6NjF;AAqWD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAuBT"}
1
+ {"version":3,"file":"test-render.d.ts","sourceRoot":"","sources":["../src/test-render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKxE;AAkCD,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,8BAA8B;IAC9B,OAAO,EAAE,OAAO,iBAAiB,EAAE,eAAe,CAAA;IAClD,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAoOjF"}
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::Backend::Mojo;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.7";
3
3
  use Mojo::Base -base, -signatures;
4
4
 
5
5
  use Mojo::ByteStream qw(b);
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS::DevReload;
2
- our $VERSION = "0.18.4";
2
+ our $VERSION = "0.18.7";
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.7";
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.19.0",
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.19.0"
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.19.0"
64
64
  }
65
65
  }
@@ -59,6 +59,28 @@ runAdapterConformanceTests({
59
59
  // #1467 Phase 2e: same `/* @client */` keyed-map elision (data-table).
60
60
  'data-table',
61
61
  ]),
62
+ skipDataPoints: new Set<string>([
63
+ // #2255 — Perl length() counts codepoints under utf8; JS counts
64
+ // UTF-16 code units, so a surrogate-pair character is 2 in JS, 1 here.
65
+ 'string-length-text:astral',
66
+ // #2260 — controlled boolean props: the SSR seed evaluates only the
67
+ // static fallback of `props.X ?? internal()` chains.
68
+ 'toggle:gen:pressed:true',
69
+ 'switch:gen:checked:true',
70
+ 'checkbox:gen:checked:true',
71
+ // #2261 — invalid dynamic CSS value kept (escaped) where the oracle
72
+ // drops the property.
73
+ 'style-object-dynamic:gen:color:markup',
74
+ // #2262 — dynamic `.flat` depth 0/negative violates the documented
75
+ // shallow-copy contract.
76
+ 'array-flat-dynamic-depth:gen:depth:zero',
77
+ 'array-flat-dynamic-depth:gen:depth:negative',
78
+ // #2266 — asChild=true with plain-text children breaks the Slot
79
+ // rendering path (same class as the Go Slot define; observed on the
80
+ // real-Perl CI run).
81
+ 'button:gen:asChild:true',
82
+ 'kbd:gen:asChild:true',
83
+ ]),
62
84
  onRenderError: (err, id) => {
63
85
  if (err instanceof PerlNotAvailableError) {
64
86
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -195,6 +217,166 @@ function Box({ other }: { other?: object }) {
195
217
  })
196
218
  })
197
219
 
220
+ // #2221: same class of hazard as the Twig-family `_resolveLiteralConst`
221
+ // flat-lookup bug, but this adapter's story is different. `resolveLiteralConst`
222
+ // / `resolveStaticRecordLiteral` (mojo-adapter.ts) already guard against it —
223
+ // they consult `loopBoundNames`, a LIVE ref-counted map that
224
+ // `renderLoop` populates/depopulates as it descends/ascends into each loop
225
+ // body (#1749), not a static whole-component set like the Twig family's
226
+ // `collectLoopBoundNames(ir)`. That makes the guard scope-PRECISE rather
227
+ // than coarse: a name loop-bound only inside one loop still inlines fine
228
+ // at a genuinely separate, non-shadowed occurrence elsewhere in the
229
+ // component (see the third test below) — the Twig-family's documented
230
+ // coarse trade-off (a same-named const anywhere else in the component also
231
+ // stops inlining) does not apply here. So no `staticLoopSourceBoundNames`
232
+ // field was added; the existing live tracking already covers this call
233
+ // site and is strictly more precise.
234
+ //
235
+ // The ONE actual gap found: `emitSpread`'s bare-identifier local-const
236
+ // spread resolution (mojo-adapter.ts, the `this.localConstants.find(...)`
237
+ // call keyed by `trimmed`, `{...attrs}` → `{ … }` hashref, #checkbox/icon)
238
+ // read `this.localConstants` directly with no `loopBoundNames` guard at
239
+ // all — a loop param named the same as an outer conditional-object const
240
+ // (`.map((attrs) => <li {...attrs} />)` shadowing `const attrs = cond ?
241
+ // {…} : {}`) incorrectly forwarded the outer object's literal hashref
242
+ // instead of falling through to the per-iteration `$attrs` value. Fixed
243
+ // with the same `loopBoundNames` guard as the other two call sites.
244
+ //
245
+ // Not covered here (upstream, shared-compiler hazard, out of this
246
+ // package's scope): `key={label}` shadowed by an enclosing loop param of
247
+ // the same name is folded to the OUTER const's literal at IR-generation
248
+ // time (`tryResolveIdentifierAsTemplateLiteral` → `findLocalConst` in
249
+ // `packages/jsx/src/jsx-to-ir.ts`), before any adapter runs — so this
250
+ // adapter (and every other adapter, including Hono's native JSX
251
+ // re-emission) still renders a `key`/`data-key` value shadowed this way
252
+ // as the outer literal, unconditionally, every iteration.
253
+ describe('MojoAdapter - const inlining vs loop-param shadowing (#2221)', () => {
254
+ test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
255
+ const { template } = compileAndGenerate(`
256
+ function Widget() {
257
+ const label: string = 'x'
258
+ return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
259
+ }
260
+ `)
261
+ expect(template).toContain('1 + $label')
262
+ expect(template).not.toContain("1 + 'x'")
263
+ })
264
+
265
+ test('a numeric const shadowed by a loop param emits the identifier too', () => {
266
+ const { template } = compileAndGenerate(`
267
+ function Widget() {
268
+ const count = 7
269
+ return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
270
+ }
271
+ `)
272
+ expect(template).toContain('1 + $count')
273
+ expect(template).not.toContain('1 + 7')
274
+ })
275
+
276
+ test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
277
+ const { template } = compileAndGenerate(`
278
+ function Widget({ values }: { values: number[] }) {
279
+ const totalPages = 5
280
+ return <div>
281
+ <p>Page 1 of {1 + totalPages}</p>
282
+ <ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
283
+ </div>
284
+ }
285
+ `)
286
+ expect(template).toContain('1 + 5')
287
+ })
288
+
289
+ // Unlike the Twig-family's coarse-but-safe `collectLoopBoundNames(ir)`
290
+ // exclusion, this adapter's LIVE `loopBoundNames` tracking is scoped to
291
+ // the actual render position: a name loop-bound ONLY inside the `.map`
292
+ // callback still inlines correctly at a separate, non-shadowed
293
+ // occurrence outside the loop — no accepted trade-off here.
294
+ test('a const referenced outside the loop whose name is loop-bound elsewhere still inlines (more precise than Twig family)', () => {
295
+ const { template } = compileAndGenerate(`
296
+ function Widget({ values }: { values: number[] }) {
297
+ const label: string = 'x'
298
+ return <div>
299
+ <p>{1 + label}</p>
300
+ <ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
301
+ </div>
302
+ }
303
+ `)
304
+ expect(template).toContain("1 + 'x'")
305
+ expect(template).toContain('2 + $label')
306
+ })
307
+
308
+ // The actual gap this issue found in this adapter: `emitSpread`'s
309
+ // bare-identifier local-const resolution (`{...attrs}` → the outer
310
+ // conditional object's hashref) had no `loopBoundNames` guard.
311
+ test('a loop param shadowing an outer conditional-object const spread emits the loop var, not the outer hashref', () => {
312
+ const { template } = compileAndGenerate(`
313
+ function Widget({ items }: { items: object[] }) {
314
+ const attrs = true ? { 'data-on': 'outer' } : {}
315
+ return <ul>{items.map((attrs) => <li {...attrs} />)}</ul>
316
+ }
317
+ `)
318
+ expect(template).toContain('bf->spread_attrs($attrs)')
319
+ expect(template).not.toContain("'data-on' => 'outer'")
320
+ })
321
+ })
322
+
323
+ // #2237: the record-literal sibling of #2221's `resolveLiteralConst` bug —
324
+ // `resolveStaticRecordLiteral` (`IDENT.key` on a module-scope object-literal
325
+ // const, e.g. `variantClasses.ghost` — #1896/#1897) is confirmed reproducible
326
+ // on the Twig-family adapters (flat `objectName` lookup with no notion of AST
327
+ // scope, so an enclosing loop callback's own param of the same name resolved
328
+ // to the OUTER const's member value at every iteration). This adapter's
329
+ // `resolveStaticRecordLiteral` already guards against it (mojo-adapter.ts:
330
+ // `if (this.loopBoundNames?.has?.(objectName)) return null`) — the same LIVE,
331
+ // ref-counted `loopBoundNames` map `resolveLiteralConst` consults (#1749),
332
+ // scope-precise rather than the Twig family's coarse whole-component set.
333
+ // Pinned here (mirroring the #2221 scope-precision pin above) rather than
334
+ // fixed, since no code change was needed.
335
+ describe('MojoAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
336
+ test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
337
+ const { template } = compileAndGenerate(`
338
+ const cfg = { x: 'outer-lit' }
339
+ function Widget({ rows }: { rows: { x: string }[] }) {
340
+ return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
341
+ }
342
+ `)
343
+ // The loop body must reference the per-iteration member access...
344
+ expect(template).toContain('$cfg->{x}')
345
+ // ...never the outer const's hard-coded value.
346
+ expect(template).not.toContain("'outer-lit'")
347
+ })
348
+
349
+ test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
350
+ const { template } = compileAndGenerate(`
351
+ const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
352
+ function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
353
+ return <div>{variantClasses.ghost}</div>
354
+ }
355
+ `)
356
+ expect(template).toContain("'bg-ghost'")
357
+ })
358
+
359
+ // Unlike the Twig-family's coarse-but-safe `staticLoopSourceBoundNames`
360
+ // exclusion, this adapter's LIVE `loopBoundNames` tracking is scoped to
361
+ // the actual render position: an object name loop-bound ONLY inside the
362
+ // `.map` callback still inlines its member lookup correctly at a
363
+ // separate, non-shadowed occurrence outside the loop — no accepted
364
+ // trade-off here.
365
+ test('an object name loop-bound only inside the loop still inlines its member lookup outside it (more precise than Twig family)', () => {
366
+ const { template } = compileAndGenerate(`
367
+ const cfg = { x: 'outer-lit' }
368
+ function Widget({ rows }: { rows: { x: string }[] }) {
369
+ return <div>
370
+ <p>{cfg.x}</p>
371
+ <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
372
+ </div>
373
+ }
374
+ `)
375
+ expect(template).toContain("<p><%= 'outer-lit' %></p>")
376
+ expect(template).toContain('$cfg->{x}')
377
+ })
378
+ })
379
+
198
380
  describe('MojoAdapter - Record<staticKeys,scalar>[propKey] spread value (#checkbox icon)', () => {
199
381
  // `const sizeMap: Record<IconSize, number> = { sm: 16, ... }` indexed by
200
382
  // a prop inside a conditional-spread object value lowers to an inline
@@ -1406,7 +1588,7 @@ describe('MojoAdapter - #1448 Tier C .flat(depth?)', () => {
1406
1588
  function emitFlat(expr: string): string {
1407
1589
  const a = new MojoAdapter()
1408
1590
  const ir = compileToIR(`
1409
- function C({ rows }: { rows: number[][] }) {
1591
+ function C({ rows }: { rows: { x: string }[][] }) {
1410
1592
  return <div>{${expr}}</div>
1411
1593
  }
1412
1594
  export { C }
@@ -1526,9 +1708,10 @@ export { C }
1526
1708
  expect(t).toContain(`"property":"name"`)
1527
1709
  })
1528
1710
 
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.
1711
+ // The function-reference `.map(format)` case is now covered cross-adapter
1712
+ // by the `array-map-function-reference` shared fixture — `format` resolves
1713
+ // to its declaration (#2206) and the fixture compiles clean rather than
1714
+ // refusing with BF101.
1532
1715
  })
1533
1716
 
1534
1717
  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
  /**
@@ -47,11 +47,10 @@ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
47
47
  * resolved `interface{}` field type; here it is the absence of a default (the
48
48
  * analyzer reports `rows` — a `TextareaHTMLAttributes` member destructured
49
49
  * without a default — as no-default, `type.kind: 'unknown'`).
50
- * Excludes concrete-primitive types (`string`/`number`/`boolean`) to match
51
- * the Go adapter's scope, which guards only `interface{}` (nillable) fields
52
- * and leaves concrete fields unconditional. So a required, no-default
53
- * `string` prop still emits `attr=""` like Hono, and only nillable
54
- * (`unknown`/object/array) no-default props guard.
50
+ * A REQUIRED concrete-primitive prop (`string`/`number`/`boolean`) is
51
+ * excluded — the caller always supplies it, so it emits `attr=""` like Hono
52
+ * but an OPTIONAL primitive is presence-uncertain and stays guarded
53
+ * (#2259).
55
54
  */
56
55
  export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
57
56
  return new Set(
@@ -60,18 +59,35 @@ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
60
59
  p =>
61
60
  p.defaultValue === undefined &&
62
61
  !p.isRest &&
63
- p.type?.kind !== 'primitive',
62
+ (p.type?.kind !== 'primitive' || p.optional),
64
63
  )
65
64
  .map(p => p.name),
66
65
  )
67
66
  }
68
67
 
69
68
  /**
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`.
69
+ * String-typed signals, props, and same-file local consts, so equality
70
+ * comparisons against them lower to `eq`/`ne` (#1672) and `+` concatenation
71
+ * against them lowers to Perl's `.` instead of numeric `+` (#2163, #2212 —
72
+ * `isStringConcatBinary`/`isStringTypedOperand` in `@barefootjs/jsx`, which
73
+ * now also recognizes a bare identifier operand, not just a prop/getter/
74
+ * literal). A signal is string-typed when its inferred type is `string`
75
+ * (the analyzer infers this from a string-literal initial value) or,
76
+ * defensively, when its initial value is a bare string literal; a prop or
77
+ * local const when its annotated (or inferred) type is `string`.
78
+ *
79
+ * Excludes any name bound as a `.map()`/`.filter()` loop callback's item
80
+ * or index parameter ANYWHERE in the component (Fable review, #2212): the
81
+ * lookup below is a flat, scope-blind `Set<string>` with no notion of a
82
+ * loop param shadowing an outer string-typed binding of the same name
83
+ * (`items.map((name) => 1 + name)` inside a component that also has a
84
+ * string `name` prop) — left unguarded, that shadowed `name` would be
85
+ * misdetected as string-typed and `1 + name` would silently lower to `.`
86
+ * instead of staying numeric `+`. Subtracting loop-bound names is coarse
87
+ * (it also suppresses a genuinely non-shadowed same-named string
88
+ * elsewhere in the component) but safe: the suppressed case just falls
89
+ * back to today's numeric `+` — the same, already-accepted residual as an
90
+ * unresolvable operand — never silently-wrong output.
75
91
  */
76
92
  export function collectStringValueNames(ir: ComponentIR): Set<string> {
77
93
  const names = new Set<string>()
@@ -83,5 +99,9 @@ export function collectStringValueNames(ir: ComponentIR): Set<string> {
83
99
  for (const p of ir.metadata.propsParams) {
84
100
  if (isStringTypeInfo(p.type)) names.add(p.name)
85
101
  }
102
+ for (const c of ir.metadata.localConstants) {
103
+ if (isStringTypeInfo(c.type ?? undefined) || isBareStringLiteral(c.value)) names.add(c.name)
104
+ }
105
+ for (const bound of collectLoopBoundNames(ir)) names.delete(bound)
86
106
  return names
87
107
  }
@@ -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
  }