@barefootjs/mojolicious 0.18.3 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
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.3"
55
+ "@barefootjs/shared": "0.18.5"
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.3"
63
+ "@barefootjs/jsx": "0.18.5"
64
64
  }
65
65
  }
@@ -1163,7 +1163,7 @@ export function Foo(props: { v: string }) {
1163
1163
  // list rather than replace.
1164
1164
  const a = new MojoAdapter()
1165
1165
  const keys = Object.keys(a.templatePrimitives ?? {}).sort()
1166
- expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
1166
+ expect(keys).toEqual(['JSON.stringify', 'Math.abs', 'Math.ceil', 'Math.floor', 'Math.max', 'Math.min', 'Math.round', 'Number', 'String'])
1167
1167
  })
1168
1168
 
1169
1169
  test('unregistered identifier-path callee is NOT accepted', () => {
@@ -1902,3 +1902,23 @@ export function C() {
1902
1902
  expect(deferred.template).not.toContain('data-x')
1903
1903
  })
1904
1904
  })
1905
+
1906
+ describe('MojoAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
1907
+ // A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
1908
+ // attribute name) must not leak into the `begin %>...<% end` capture
1909
+ // variable — Perl variable tokens can't contain `-`. The capture
1910
+ // variable is purely counter-based (never derived from the prop name);
1911
+ // the hash KEY passed to `render_child` still carries the real name,
1912
+ // quoted via `perlHashKey`.
1913
+ test('a hyphenated prop name does not appear in the capture variable', () => {
1914
+ const { template } = compileAndGenerate(`
1915
+ function Card(props) { return null }
1916
+ export function Parent() {
1917
+ return <Card data-slot={<strong>Title</strong>}>text</Card>
1918
+ }
1919
+ `)
1920
+ expect(template).toContain('<% my $bf_prop_0 = begin %>')
1921
+ expect(template).toContain("'data-slot' => $bf_prop_0")
1922
+ expect(template).not.toContain('$bf_prop_data')
1923
+ })
1924
+ })
@@ -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
@@ -11,7 +11,9 @@
11
11
  * adapter only through the narrow `MojoEmitContext` seam.
12
12
  */
13
13
 
14
- import {
14
+ import { groupBinaryOperand,
15
+ isStringTypedOperand,
16
+ isStringConcatBinary,
15
17
  type ParsedExprEmitter,
16
18
  type HigherOrderMethod,
17
19
  type ArrayMethod,
@@ -29,7 +31,7 @@ import {
29
31
 
30
32
  import type { MojoEmitContext } from '../emit-context.ts'
31
33
  import { MOJO_TEMPLATE_PRIMITIVES } from '../lib/constants.ts'
32
- import { emitIndexAccessPerl, isStringTypedOperand } from './operand.ts'
34
+ import { emitIndexAccessPerl } from './operand.ts'
33
35
  import {
34
36
  renderArrayMethod,
35
37
  renderSortMethod,
@@ -103,7 +105,7 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
103
105
  return String(value)
104
106
  }
105
107
 
106
- 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 {
107
109
  // `.length` on a higher-order result (e.g.
108
110
  // `x.tags.filter(t => t.active).length > 0` inside the outer
109
111
  // filter predicate, #1443). The higher-order emit produces an
@@ -141,8 +143,11 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
141
143
  }
142
144
 
143
145
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
144
- const l = emit(left)
145
- const r = emit(right)
146
+ // Preserve source grouping: a compound operand re-emitted as infix
147
+ // text is otherwise re-parsed under THIS language's precedence —
148
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
149
+ const l = groupBinaryOperand(left, emit(left))
150
+ const r = groupBinaryOperand(right, emit(right))
146
151
  // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
147
152
  // literal, a string signal getter, or a string prop. Numeric `==`/`!=`
148
153
  // would coerce both sides to 0 and match unrelated non-numeric strings (#1672).
@@ -154,6 +159,12 @@ export class MojoFilterEmitter implements ParsedExprEmitter {
154
159
  if ((op === '!==' || op === '!=') && stringCmp) {
155
160
  return `${l} ne ${r}`
156
161
  }
162
+ // JS `+` with a string-typed operand is CONCATENATION, not addition —
163
+ // Perl's numeric `+` coerces `'Hello, ' + $name` to 0 (#2176). Lower
164
+ // to Perl's `.` concat operator.
165
+ if (isStringConcatBinary(op, left, right, this.isStringName)) {
166
+ return `${l} . ${r}`
167
+ }
157
168
  const opMap: Record<string, string> = {
158
169
  '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
159
170
  '+': '+', '-': '-', '*': '*', '/': '/',
@@ -311,7 +322,7 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
311
322
  return String(value)
312
323
  }
313
324
 
314
- 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 {
315
326
  // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
316
327
  // prop to (props arrive as individual `my $x = ...` vars, not a
317
328
  // `$props` hashref).
@@ -327,7 +338,22 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
327
338
  if (staticValue !== null) return staticValue
328
339
  }
329
340
  const obj = emit(object)
330
- 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
+ }
331
357
  return `${obj}->{${property}}`
332
358
  }
333
359
 
@@ -389,8 +415,11 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
389
415
  }
390
416
 
391
417
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
392
- const l = emit(left)
393
- const r = emit(right)
418
+ // Preserve source grouping: a compound operand re-emitted as infix
419
+ // text is otherwise re-parsed under THIS language's precedence —
420
+ // `(count() + 2) * 3` would silently become `count + 2 * 3` (#2173).
421
+ const l = groupBinaryOperand(left, emit(left))
422
+ const r = groupBinaryOperand(right, emit(right))
394
423
  // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
395
424
  // literal (`role() === 'admin'`), a string signal getter (`sel()`), or a
396
425
  // string prop (`props.x`). Falling back to numeric `==`/`!=` would make
@@ -404,6 +433,12 @@ export class MojoTopLevelEmitter implements ParsedExprEmitter {
404
433
  if ((op === '!==' || op === '!=') && stringCmp) {
405
434
  return `${l} ne ${r}`
406
435
  }
436
+ // JS `+` with a string-typed operand is CONCATENATION, not addition —
437
+ // Perl's numeric `+` coerces `'Hello, ' + $name` to 0 (#2176). Lower
438
+ // to Perl's `.` concat operator.
439
+ if (isStringConcatBinary(op, left, right, n => this.ctx._isStringValueName(n))) {
440
+ return `${l} . ${r}`
441
+ }
407
442
  const opMap: Record<string, string> = {
408
443
  '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
409
444
  '+': '+', '-': '-', '*': '*',
@@ -1,39 +1,18 @@
1
1
  /**
2
- * Operand-type classification + index-access lowering for the Mojolicious
3
- * EP template adapter.
2
+ * Index-access lowering for the Mojolicious EP template adapter.
4
3
  *
5
4
  * Extracted from `mojo-adapter.ts` (domain-module refactor, issue #2018
6
- * track D). Pure functions over `ParsedExpr` — they take an `isStringName`
5
+ * track D). Pure function over `ParsedExpr` — it takes an `isStringName`
7
6
  * predicate (supplied by the emitter from adapter state) rather than reading
8
7
  * adapter instance state directly.
9
8
  *
10
- * SHARED CANDIDATE: `isStringTypedOperand` is byte-identical to the Xslate
11
- * adapter's copy and is adapter-agnostic an extraction candidate for a
12
- * shared Perl-family codegen module (groundwork for the future Perl evaluator
13
- * integration, issue #2018 track D). `emitIndexAccessPerl` stays Mojo-specific
9
+ * The string-typed-operand classifier that used to live here (marked
10
+ * SHARED CANDIDATE) was promoted to `@barefootjs/jsx` as
11
+ * `isStringTypedOperand` (#2176); `emitIndexAccessPerl` stays Mojo-specific
14
12
  * (Perl's `->[]` vs `->{}` split has no Kolon equivalent).
15
13
  */
16
14
 
17
- import type { ParsedExpr } from '@barefootjs/jsx'
18
-
19
- /**
20
- * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
21
- * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=` (#1672). Covers a
22
- * string literal, a string-signal getter call (`sel()`), and a string prop
23
- * access (`props.x`). `isStringName` reports whether a getter/prop name is
24
- * known-string. Loop-element fields (`t.id`) on untyped arrays have no known
25
- * type and stay undetected — a separate, narrower gap.
26
- */
27
- export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
28
- if (expr.kind === 'literal' && expr.literalType === 'string') return true
29
- if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
30
- return isStringName(expr.callee.name)
31
- }
32
- if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
33
- return isStringName(expr.property)
34
- }
35
- return false
36
- }
15
+ import { isStringTypedOperand, type ParsedExpr } from '@barefootjs/jsx'
37
16
 
38
17
  /**
39
18
  * Lower `arr[index]` to a Perl deref. Perl distinguishes array
@@ -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
  /**
@@ -62,7 +62,7 @@ import {
62
62
  } from '@barefootjs/jsx'
63
63
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
64
64
  import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
65
- import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
65
+ import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
66
66
 
67
67
  import type { MojoRenderCtx } from './lib/types.ts'
68
68
  import { MOJO_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
@@ -165,6 +165,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
165
165
  private options: Required<MojoAdapterOptions>
166
166
  private errors: CompilerError[] = []
167
167
  private inLoop: boolean = false
168
+ /**
169
+ * `IRLoop.depth` of the loop currently being rendered (save/restore
170
+ * around `renderChildren(loop.children)`, mirroring `inLoop` above).
171
+ * `renderAttributes` reads this to derive the `key` → `data-key`/
172
+ * `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
173
+ * re-derived here (#2168 nested-loop-outer-binding).
174
+ */
175
+ private currentLoopKeyDepth = 0
168
176
  /**
169
177
  * SolidJS-style props identifier (`function(props: P)`) and the
170
178
  * analyzer-extracted prop names. Stashed at `generate()` entry so
@@ -478,7 +486,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
478
486
  }
479
487
 
480
488
  emitText(node: IRText): string {
481
- return node.value
489
+ // IRText carries the entity-DECODED value (Phase 1 decodes JSX
490
+ // character references); re-escape for direct HTML emission.
491
+ return escapeHtml(node.value)
482
492
  }
483
493
 
484
494
  emitExpression(node: IRExpression): string {
@@ -846,17 +856,22 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
846
856
  // the whole body (children + key + filter) so a same-named loop variable
847
857
  // isn't replaced by the const literal (#1749 review). Ref-counted for
848
858
  // 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']
859
+ const loopBound = loop.objectIteration === 'entries'
860
+ ? [param, loop.index ?? '_k']
861
+ : loop.objectIteration === 'keys' || loop.objectIteration === 'values' || loop.iterationShape === 'keys'
862
+ ? [param]
863
+ : supportableDestructure
864
+ ? ['__bf_item', ...(loop.paramBindings ?? []).map(b => b.name), loop.index ?? '_i']
865
+ : [param, loop.index ?? '_i']
854
866
  for (const n of loopBound) {
855
867
  this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
856
868
  }
857
869
  const prevInLoop = this.inLoop
858
870
  this.inLoop = true
871
+ const prevLoopKeyDepth = this.currentLoopKeyDepth
872
+ this.currentLoopKeyDepth = loop.depth
859
873
  const renderedChildren = this.renderChildren(loop.children)
874
+ this.currentLoopKeyDepth = prevLoopKeyDepth
860
875
  this.inLoop = prevInLoop
861
876
 
862
877
  // Whole-item conditional (#1665): prepend an always-present
@@ -918,6 +933,21 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
918
933
  }
919
934
  lines.push(`% my $${sortedHoist} = ${sorted};`)
920
935
  }
936
+ if (loop.objectIteration) {
937
+ // `objectIteration` (#2168 object-entries-map): a Perl hash has no
938
+ // native insertion-order guarantee (unlike Ruby's `Hash`/Python's
939
+ // `dict`) — this codebase's own runtime already works around this
940
+ // elsewhere with `sort keys %$hash` (`BarefootJS.pm`'s
941
+ // `spread_attrs`/`_style_to_css`), so this reuses that exact
942
+ // convention for a deterministic, alphabetically-sorted iteration
943
+ // (not JS insertion order — a documented known limitation for
944
+ // out-of-alphabetical-order data, same as Go/Rust/Xslate).
945
+ const keyVar = loop.objectIteration === 'values' ? '$__bf_k' : `$${loop.index ?? param}`
946
+ lines.push(`% for my ${keyVar} (sort keys %{${array}}) {`)
947
+ if (loop.objectIteration === 'entries' || loop.objectIteration === 'values') {
948
+ lines.push(`% my $${param} = ${array}->{${keyVar}};`)
949
+ }
950
+ } else {
921
951
  lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
922
952
  if (loop.iterationShape !== 'keys') {
923
953
  if (supportableDestructure) {
@@ -951,6 +981,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
951
981
  lines.push(`% my $${param} = ${array}->[${indexVar}];`)
952
982
  }
953
983
  }
984
+ }
954
985
 
955
986
  // Handle filter().map() pattern by wrapping children in if-condition
956
987
  if (loop.filterPredicate) {
@@ -1049,10 +1080,36 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1049
1080
 
1050
1081
  renderComponent(comp: IRComponent): string {
1051
1082
  const propParts: string[] = []
1083
+ // Named JSX-valued props OTHER than the reserved `children`
1084
+ // (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) get the
1085
+ // same `begin %>…<% end` capture as the reserved children slot below,
1086
+ // just keyed by the prop's own name. `render_child` (BarefootJS.pm)
1087
+ // materializes every prop value that's a CODE ref — not only
1088
+ // `children` — into the Mojo::ByteStream the capture block produces,
1089
+ // so the child's read of the slot back out (`<%= $header %>`) sees an
1090
+ // already-safe ByteStream and Mojo::Template's auto-escape passes it
1091
+ // through unescaped, the same way it already does for `children`.
1092
+ const namedSlotCaptures: string[] = []
1052
1093
  for (const p of comp.props) {
1053
1094
  // Skip callback props (onXxx) and `ref` — both are client-only for
1054
1095
  // SSR (Hono renders neither; the client JS wires them at hydration).
1055
1096
  if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
1097
+ if (p.value.kind === 'jsx-children' && p.name !== 'children') {
1098
+ const prevInLoop = this.inLoop
1099
+ this.inLoop = false
1100
+ const slotBody = this.renderChildren(p.value.children)
1101
+ this.inLoop = prevInLoop
1102
+ // Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
1103
+ // A JSX prop name can contain characters (`data-slot`) that aren't a
1104
+ // valid Perl variable token, and `comp.slotId` alone would collide
1105
+ // across two named-slot props on the same component invocation
1106
+ // (unlike the reserved children slot, there's only ever one of
1107
+ // those per invocation).
1108
+ const varName = `$bf_prop_${this.childrenCaptureCounter++}`
1109
+ namedSlotCaptures.push(`<% my ${varName} = begin %>${slotBody}<% end %>`)
1110
+ propParts.push(`${perlHashKey(p.name)} => ${varName}`)
1111
+ continue
1112
+ }
1056
1113
  const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
1057
1114
  if (lowered) propParts.push(lowered)
1058
1115
  }
@@ -1090,9 +1147,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1090
1147
  const childrenBody = this.renderChildren(effectiveChildren)
1091
1148
  this.inLoop = prevInLoop
1092
1149
  const varName = `$bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
1093
- return `<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
1150
+ return `${namedSlotCaptures.join('')}<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
1094
1151
  }
1095
- return `<%== bf->render_child('${tplName}'${propsStr}) %>`
1152
+ return `${namedSlotCaptures.join('')}<%== bf->render_child('${tplName}'${propsStr}) %>`
1096
1153
  }
1097
1154
 
1098
1155
  private childrenCaptureCounter = 0
@@ -1178,7 +1235,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1178
1235
  * template). Routed through the shared dispatcher (#1290 step 2).
1179
1236
  */
1180
1237
  private readonly elementAttrEmitter: AttrValueEmitter = {
1181
- emitLiteral: (value, name) => `${name}="${value.value}"`,
1238
+ emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1182
1239
  emitExpression: (value, name) => {
1183
1240
  // `style={{ … }}` object literal → a CSS string with dynamic values
1184
1241
  // interpolated, instead of refusing the bare object with BF101 (#1322).
@@ -1440,7 +1497,10 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1440
1497
  // rewrite happens at attribute-emit time.
1441
1498
  let attrName: string
1442
1499
  if (attr.name === 'className') attrName = 'class'
1443
- else if (attr.name === 'key') attrName = 'data-key'
1500
+ else if (attr.name === 'key') {
1501
+ const depth = this.currentLoopKeyDepth
1502
+ attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
1503
+ }
1444
1504
  else attrName = attr.name
1445
1505
  const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
1446
1506
  if (lowered) parts.push(lowered)
@@ -141,9 +141,4 @@ export const conformancePins: ConformancePins = {
141
141
  // the shape loudly instead of emitting entity-escaped markup that
142
142
  // silently renders tags as text.
143
143
  'dangerous-inner-html': [{ code: 'BF101', severity: 'error' }],
144
- // Edge-case sweep (Priority 12): `.replaceAll` has no lowering yet —
145
- // only first-occurrence `.replace` is wired to the runtime helpers.
146
- // Refused with BF101 rather than reusing the first-only lowering,
147
- // which would silently change semantics.
148
- 'string-replaceall': [{ code: 'BF101', severity: 'error' }],
149
144
  }
@@ -14,35 +14,4 @@
14
14
 
15
15
  import type { RenderDivergences } from '@barefootjs/jsx'
16
16
 
17
- export const renderDivergences: RenderDivergences = {
18
- 'arithmetic-text':
19
- '`(count() + 2) * 3` renders 10 instead of 18 — the parenthesised sub-expression loses its grouping (silent wrong arithmetic)',
20
- 'string-concat-plus':
21
- "`'Hello, ' + name` renders \"0\" — Perl's numeric `+` coerces the strings; JS string-concat `+` needs Perl's `.`",
22
- 'string-length-text':
23
- '`.length` on a STRING prop diverges (array-length lowering misapplied to a scalar)',
24
- 'number-tofixed':
25
- 'the literal `¥` in template text reaches the output as U+FFFD — a UTF-8 encoding gap for non-ASCII literal text adjacent to a dynamic slot',
26
- 'html-entity-text':
27
- '`&copy;` in JSX literal text: Hono decodes to `©`, this adapter re-emits the raw entity — same DOM, different bytes',
28
- 'math-methods':
29
- 'Math.min/max/abs over a signal render empty (only Math.floor is in the template-primitive registry)',
30
- 'boolean-attr-literals':
31
- 'camelCase boolean alias `readOnly`: Hono SSRs `readOnly="true"`, this adapter emits bare presence',
32
- 'camelcase-attributes':
33
- '`htmlFor` is not lowered to `for` (Hono maps it)',
34
- 'static-attr-escape':
35
- 'static attribute values are not HTML-escaped (`title="Fish & Chips"` emitted raw; Hono escapes)',
36
- 'svg-icon':
37
- 'SVG camelCase presentation attrs (`strokeWidth`, `strokeLinecap`) pass through unmapped; Hono lowers to kebab-case',
38
- 'object-entries-map':
39
- '`Object.entries(prop).map(([k, v]) => …)` renders an EMPTY list — the object-shaped prop silently produces zero iterations',
40
- 'nested-loop-outer-binding':
41
- 'nested-loop inner items carry `data-key` where the reference emits the depth-suffixed `data-key-1`',
42
- 'jsx-element-prop':
43
- 'a JSX element passed as a NON-children prop renders an empty slot — the element value is silently dropped',
44
- 'string-slice':
45
- '`.slice()` on a STRING misfires through the array slice helper',
46
- 'string-trim-sided':
47
- '`.trimStart()` / `.trimEnd()` render empty (no lowering)',
48
- }
17
+ export const renderDivergences: RenderDivergences = {}
@@ -240,6 +240,11 @@ export async function renderMojoComponent(options: RenderOptions): Promise<strin
240
240
  use strict;
241
241
  use warnings;
242
242
  use utf8;
243
+ # The template is read through :utf8, so $output holds decoded wide
244
+ # characters; without a matching layer on STDOUT Perl emits them as
245
+ # latin-1 bytes and the harness reads U+FFFD (©/¥ mangling). A real
246
+ # Mojolicious response encodes on the way out — this is harness-only.
247
+ binmode STDOUT, ':encoding(UTF-8)';
243
248
 
244
249
  use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
245
250
  use Mojolicious;