@barefootjs/mojolicious 0.18.4 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1163,7 +1323,7 @@ export function Foo(props: { v: string }) {
1163
1323
  // list rather than replace.
1164
1324
  const a = new MojoAdapter()
1165
1325
  const keys = Object.keys(a.templatePrimitives ?? {}).sort()
1166
- expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
1326
+ expect(keys).toEqual(['JSON.stringify', 'Math.abs', 'Math.ceil', 'Math.floor', 'Math.max', 'Math.min', 'Math.round', 'Number', 'String'])
1167
1327
  })
1168
1328
 
1169
1329
  test('unregistered identifier-path callee is NOT accepted', () => {
@@ -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)', () => {
@@ -1902,3 +2063,23 @@ export function C() {
1902
2063
  expect(deferred.template).not.toContain('data-x')
1903
2064
  })
1904
2065
  })
2066
+
2067
+ describe('MojoAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
2068
+ // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
2069
+ // attribute name) must not leak into the `begin %>...<% end` capture
2070
+ // variable — Perl variable tokens can't contain `-`. The capture
2071
+ // variable is purely counter-based (never derived from the prop name);
2072
+ // the hash KEY passed to `render_child` still carries the real name,
2073
+ // quoted via `perlHashKey`.
2074
+ test('a hyphenated prop name does not appear in the capture variable', () => {
2075
+ const { template } = compileAndGenerate(`
2076
+ function Card(props) { return null }
2077
+ export function Parent() {
2078
+ return <Card data-slot={<strong>Title</strong>}>text</Card>
2079
+ }
2080
+ `)
2081
+ expect(template).toContain('<% my $bf_prop_0 = begin %>')
2082
+ expect(template).toContain("'data-slot' => $bf_prop_0")
2083
+ expect(template).not.toContain('$bf_prop_data')
2084
+ })
2085
+ })
@@ -139,6 +139,15 @@ export function renderArrayMethod(
139
139
  const recv = emit(object)
140
140
  return `bf->trim(${recv})`
141
141
  }
142
+ case 'trimStart':
143
+ case 'trimEnd': {
144
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
145
+ // `.trim()` (#2183 follow-up). Dedicated `bf->trim_start` /
146
+ // `bf->trim_end` helpers, not `bf->trim` with a flag.
147
+ const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
148
+ const recv = emit(object)
149
+ return `bf->${fn}(${recv})`
150
+ }
142
151
  case 'toFixed': {
143
152
  // `.toFixed(digits?)` — Number → fixed-decimal string. `bf->to_fixed`
144
153
  // mirrors JS rounding + zero-padding (default 0 digits). #1897.
@@ -195,6 +204,16 @@ export function renderArrayMethod(
195
204
  const newS = emit(args[1])
196
205
  return `bf->replace(${recv}, ${oldS}, ${newS})`
197
206
  }
207
+ case 'replaceAll': {
208
+ // `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
209
+ // via the dedicated `bf->replace_all` helper (not `bf->replace`
210
+ // with a flag) — the regex-pattern form is refused upstream at
211
+ // the parser, same as `.replace`. See #2182.
212
+ const recv = emit(object)
213
+ const oldS = emit(args[0])
214
+ const newS = emit(args[1])
215
+ return `bf->replace_all(${recv}, ${oldS}, ${newS})`
216
+ }
198
217
  case 'repeat': {
199
218
  // `.repeat(n)` — string repeated `n` times. The `bf->repeat`
200
219
  // helper wraps Perl's `x` operator with the same negative-count
@@ -105,7 +105,7 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
105
105
  return String(value)
106
106
  }
107
107
 
108
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
108
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
109
109
  // `.length` on a higher-order result (e.g.
110
110
  // `x.tags.filter(t => t.active).length > 0` inside the outer
111
111
  // filter predicate, #1443). The higher-order emit produces an
@@ -322,7 +322,7 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
322
322
  return String(value)
323
323
  }
324
324
 
325
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
325
+ member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
326
326
  // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
327
327
  // prop to (props arrive as individual `my $x = ...` vars, not a
328
328
  // `$props` hashref).
@@ -338,7 +338,22 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
338
338
  if (staticValue !== null) return staticValue
339
339
  }
340
340
  const obj = emit(object)
341
- if (property === 'length') return `scalar(@{${obj}})`
341
+ if (property === 'length') {
342
+ // `.length` dispatches on receiver type: a STRING receiver needs
343
+ // Perl's scalar `length($x)`, while the array lowering
344
+ // `scalar(@{$x})` dereferences the value as an array ref and
345
+ // returns 0 for a scalar string (the `string-length-text`
346
+ // divergence). The receiver is string-typed when it's a known
347
+ // string prop/getter (`isStringTypedOperand`) or a bare
348
+ // identifier bound to one — the same `_isStringValueName` witness
349
+ // the `eq`/concat lowering already consults.
350
+ const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.ctx._isStringValueName(n))
351
+ const isStringReceiver =
352
+ isStr(object) ||
353
+ (object.kind === 'identifier' && this.ctx._isStringValueName(object.name))
354
+ if (isStringReceiver) return `length(${obj})`
355
+ return `scalar(@{${obj}})`
356
+ }
342
357
  return `${obj}->{${property}}`
343
358
  }
344
359
 
@@ -26,6 +26,9 @@ export const MOJO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
26
26
  'Math.floor': { arity: 1, emit: (args) => `bf->floor(${args[0]})` },
27
27
  'Math.ceil': { arity: 1, emit: (args) => `bf->ceil(${args[0]})` },
28
28
  'Math.round': { arity: 1, emit: (args) => `bf->round(${args[0]})` },
29
+ 'Math.min': { arity: 2, emit: (args) => `bf->min(${args[0]}, ${args[1]})` },
30
+ 'Math.max': { arity: 2, emit: (args) => `bf->max(${args[0]}, ${args[1]})` },
31
+ 'Math.abs': { arity: 1, emit: (args) => `bf->abs(${args[0]})` },
29
32
  }
30
33
 
31
34
  /**
@@ -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,10 +59,15 @@ 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'
65
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
70
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
66
71
 
67
72
  import type { MojoRenderCtx } from './lib/types.ts'
68
73
  import { MOJO_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -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 {
@@ -165,6 +171,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
165
171
  private options: Required<MojoAdapterOptions>
166
172
  private errors: CompilerError[] = []
167
173
  private inLoop: boolean = false
174
+ /**
175
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
176
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
177
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
178
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
179
+ * re-derived here (#2168 nested-loop-outer-binding).
180
+ */
181
+ private currentLoopKeyDepth = 0
168
182
  /**
169
183
  * SolidJS-style props identifier (`function(props: P)`) and the
170
184
  * analyzer-extracted prop names. Stashed at `generate()` entry so
@@ -407,6 +421,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
407
421
  * quoted string literal (`const totalPages = 5`, #1897 pagination) —
408
422
  * function-scope consts never reach the per-render stash, so a bare
409
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.
410
432
  */
411
433
  private resolveLiteralConst(name: string): string | null {
412
434
  if (this.loopBoundNames?.has?.(name)) return null
@@ -478,7 +500,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
478
500
  }
479
501
 
480
502
  emitText(node: IRText): string {
481
- return node.value
503
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
504
+ // character references); re-escape for direct HTML emission.
505
+ return escapeHtml(node.value)
482
506
  }
483
507
 
484
508
  emitExpression(node: IRExpression): string {
@@ -607,7 +631,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
607
631
  renderElement(element: IRElement): string {
608
632
  const tag = element.tag
609
633
  const attrs = this.renderAttributes(element)
610
- const children = this.renderChildren(element.children)
634
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
635
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
611
636
 
612
637
  let hydrationAttrs = ''
613
638
  if (element.needsScope) {
@@ -643,6 +668,28 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
643
668
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
644
669
  }
645
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
+
646
693
  // ===========================================================================
647
694
  // Expression Rendering
648
695
  // ===========================================================================
@@ -655,7 +702,12 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
655
702
  return ''
656
703
  }
657
704
 
658
- 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)
659
711
 
660
712
  if (expr.slotId) {
661
713
  return `<%== bf->text_start("${expr.slotId}") %><%= ${perlExpr} %><%== bf->text_end %>`
@@ -800,8 +852,24 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
800
852
  // reachable in this adapter's test corpus only because the widened
801
853
  // destructure gate (#2087 Phase A/B) no longer refuses this fixture's
802
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
+
803
871
  const arrayName = loop.array.trim()
804
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
872
+ if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
805
873
  const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
806
874
  if (arrayConst && !arrayConst.isModule && this.resolveLiteralConst(arrayName) === null) {
807
875
  this.errors.push({
@@ -817,7 +885,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
817
885
  }
818
886
  }
819
887
 
820
- const rawArray = this.convertExpressionToPerl(loop.array)
888
+ const rawArray = staticArray ?? this.convertExpressionToPerl(loop.array)
821
889
  // Apply sort if present (#1448 Tier B): wrap the loop array in the
822
890
  // shared sort helper. The same `renderSortEval` / `renderSortMethod`
823
891
  // pair feeds both this loop-chain hoist and the emitter's
@@ -846,17 +914,22 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
846
914
  // the whole body (children + key + filter) so a same-named loop variable
847
915
  // isn't replaced by the const literal (#1749 review). Ref-counted for
848
916
  // nested loops; released after the body lines are assembled below.
849
- const loopBound = loop.iterationShape === 'keys'
850
- ? [param]
851
- : supportableDestructure
852
- ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
853
- : [param, loop.index ?? '_i']
917
+ const loopBound = loop.objectIteration === 'entries'
918
+ ? [param, loop.index ?? '_k']
919
+ : loop.objectIteration === 'keys' || loop.objectIteration === 'values' || loop.iterationShape === 'keys'
920
+ ? [param]
921
+ : supportableDestructure
922
+ ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
923
+ : [param, loop.index ?? '_i']
854
924
  for (const n of loopBound) {
855
925
  this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
856
926
  }
857
927
  const prevInLoop = this.inLoop
858
928
  this.inLoop = true
929
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
930
+ this.currentLoopKeyDepth = loop.depth
859
931
  const renderedChildren = this.renderChildren(loop.children)
932
+ this.currentLoopKeyDepth = prevLoopKeyDepth
860
933
  this.inLoop = prevInLoop
861
934
 
862
935
  // Whole-item conditional (#1665): prepend an always-present
@@ -918,6 +991,21 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
918
991
  }
919
992
  lines.push(`% my $${sortedHoist} = ${sorted};`)
920
993
  }
994
+ if (loop.objectIteration) {
995
+ // `objectIteration` (#2168 object-entries-map): a Perl hash has no
996
+ // native insertion-order guarantee (unlike Ruby's `Hash`/Python's
997
+ // `dict`) — this codebase's own runtime already works around this
998
+ // elsewhere with `sort keys %$hash` (`BarefootJS.pm`'s
999
+ // `spread_attrs`/`_style_to_css`), so this reuses that exact
1000
+ // convention for a deterministic, alphabetically-sorted iteration
1001
+ // (not JS insertion order — a documented known limitation for
1002
+ // out-of-alphabetical-order data, same as Go/Rust/Xslate).
1003
+ const keyVar = loop.objectIteration === 'values' ? '$__bf_k' : `$${loop.index ?? param}`
1004
+ lines.push(`% for my ${keyVar} (sort keys %{${array}}) {`)
1005
+ if (loop.objectIteration === 'entries' || loop.objectIteration === 'values') {
1006
+ lines.push(`% my $${param} = ${array}->{${keyVar}};`)
1007
+ }
1008
+ } else {
921
1009
  lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
922
1010
  if (loop.iterationShape !== 'keys') {
923
1011
  if (supportableDestructure) {
@@ -951,6 +1039,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
951
1039
  lines.push(`% my $${param} = ${array}->[${indexVar}];`)
952
1040
  }
953
1041
  }
1042
+ }
954
1043
 
955
1044
  // Handle filter().map() pattern by wrapping children in if-condition
956
1045
  if (loop.filterPredicate) {
@@ -1049,10 +1138,36 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1049
1138
 
1050
1139
  renderComponent(comp: IRComponent): string {
1051
1140
  const propParts: string[] = []
1141
+ // Named JSX-valued props OTHER than the reserved `children`
1142
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) get the
1143
+ // same `begin %>…<% end` capture as the reserved children slot below,
1144
+ // just keyed by the prop's own name. `render_child` (BarefootJS.pm)
1145
+ // materializes every prop value that's a CODE ref — not only
1146
+ // `children` — into the Mojo::ByteStream the capture block produces,
1147
+ // so the child's read of the slot back out (`<%= $header %>`) sees an
1148
+ // already-safe ByteStream and Mojo::Template's auto-escape passes it
1149
+ // through unescaped, the same way it already does for `children`.
1150
+ const namedSlotCaptures: string[] = []
1052
1151
  for (const p of comp.props) {
1053
1152
  // Skip callback props (onXxx) and `ref` — both are client-only for
1054
1153
  // SSR (Hono renders neither; the client JS wires them at hydration).
1055
1154
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1155
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1156
+ const prevInLoop = this.inLoop
1157
+ this.inLoop = false
1158
+ const slotBody = this.renderChildren(p.value.children)
1159
+ this.inLoop = prevInLoop
1160
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1161
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1162
+ // valid Perl variable token, and `comp.slotId` alone would collide
1163
+ // across two named-slot props on the same component invocation
1164
+ // (unlike the reserved children slot, there's only ever one of
1165
+ // those per invocation).
1166
+ const varName = `$bf_prop_${this.childrenCaptureCounter++}`
1167
+ namedSlotCaptures.push(`<% my ${varName} = begin %>${slotBody}<% end %>`)
1168
+ propParts.push(`${perlHashKey(p.name)} => ${varName}`)
1169
+ continue
1170
+ }
1056
1171
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1057
1172
  if (lowered) propParts.push(lowered)
1058
1173
  }
@@ -1090,9 +1205,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1090
1205
  const childrenBody = this.renderChildren(effectiveChildren)
1091
1206
  this.inLoop = prevInLoop
1092
1207
  const varName = `$bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1093
- return `<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
1208
+ return `${namedSlotCaptures.join('')}<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
1094
1209
  }
1095
- return `<%== bf->render_child('${tplName}'${propsStr}) %>`
1210
+ return `${namedSlotCaptures.join('')}<%== bf->render_child('${tplName}'${propsStr}) %>`
1096
1211
  }
1097
1212
 
1098
1213
  private childrenCaptureCounter = 0
@@ -1178,7 +1293,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1178
1293
  * template). Routed through the shared dispatcher (#1290 step 2).
1179
1294
  */
1180
1295
  private readonly elementAttrEmitter: AttrValueEmitter = {
1181
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1296
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1182
1297
  emitExpression: (value, name) => {
1183
1298
  // `style={{ … }}` object literal → a CSS string with dynamic values
1184
1299
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1354,7 +1469,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1354
1469
  // initializer text and route through the same conditional-spread
1355
1470
  // lowering. Only function-scope (`!isModule`) consts whose value is
1356
1471
  // NOT itself a bare identifier (loop guard) are considered.
1357
- 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)) {
1358
1481
  const localConst = this.localConstants.find(
1359
1482
  c => c.name === trimmed && !c.isModule,
1360
1483
  )
@@ -1431,6 +1554,12 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1431
1554
  // the unsupported-expression lowering is never reached for a deferred
1432
1555
  // predicate (no BF101 / BF102). #1966
1433
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
1434
1563
  // Rewrite JSX special-prop names to their HTML-attribute
1435
1564
  // counterparts (#1475). `className` → `class` was already
1436
1565
  // wired in; the `key` → `data-key` rewrite matches the
@@ -1440,7 +1569,10 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1440
1569
  // rewrite happens at attribute-emit time.
1441
1570
  let attrName: string
1442
1571
  if (attr.name === 'className') attrName = 'class'
1443
- else if (attr.name === 'key') attrName = 'data-key'
1572
+ else if (attr.name === 'key') {
1573
+ const depth = this.currentLoopKeyDepth
1574
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1575
+ }
1444
1576
  else attrName = attr.name
1445
1577
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1446
1578
  if (lowered) parts.push(lowered)