@barefootjs/jsx 0.18.4 → 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.
Files changed (37) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +2 -2
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/expression-parser.d.ts +2 -1
  4. package/dist/expression-parser.d.ts.map +1 -1
  5. package/dist/index.js +141 -44
  6. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  9. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/csr-substitute.d.ts +1 -0
  12. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +9 -0
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/utils.d.ts +25 -0
  17. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  18. package/dist/jsx-to-ir.d.ts.map +1 -1
  19. package/dist/types.d.ts +66 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  23. package/src/__tests__/expression-parser.test.ts +38 -0
  24. package/src/__tests__/ir-walker.test.ts +1 -0
  25. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  26. package/src/adapters/parsed-expr-emitter.ts +10 -1
  27. package/src/expression-parser.ts +59 -25
  28. package/src/ir-to-client-js/collect-elements.ts +3 -0
  29. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  30. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  31. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  32. package/src/ir-to-client-js/csr-substitute.ts +1 -1
  33. package/src/ir-to-client-js/html-template.ts +57 -11
  34. package/src/ir-to-client-js/types.ts +10 -0
  35. package/src/ir-to-client-js/utils.ts +34 -1
  36. package/src/jsx-to-ir.ts +92 -9
  37. package/src/types.ts +68 -0
package/src/jsx-to-ir.ts CHANGED
@@ -48,7 +48,7 @@ import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironme
48
48
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
49
49
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
50
50
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
51
- import { toHTMLAttrName } from '@barefootjs/shared'
51
+ import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
52
52
 
53
53
  // =============================================================================
54
54
  // Transform Context
@@ -82,6 +82,17 @@ interface TransformContext {
82
82
  _destructuredPropNames?: Set<string> | null
83
83
  /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
84
84
  loopParams: Set<string>
85
+ /**
86
+ * Count of enclosing `.map()` loops (0 = outermost), incremented/
87
+ * decremented in lockstep with entering/leaving `transformMapCall`.
88
+ * Unlike `loopParams` (a name Set that can gain several entries for
89
+ * ONE loop level via destructuring), this is a plain per-level
90
+ * counter — the single source of truth `IRLoop.depth` is stamped
91
+ * from, so every adapter's `data-key`/`data-key-N` suffix derives
92
+ * from one IR-computed value instead of each adapter re-deriving
93
+ * nesting depth its own way (#2168 nested-loop-outer-binding).
94
+ */
95
+ loopDepth: number
85
96
  /** Counter for async boundary IDs (a0, a1, ...) */
86
97
  asyncIdCounter: number
87
98
  /** Counter for <Region> structural index (0, 1, ...) within a file. */
@@ -395,6 +406,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
395
406
  isRoot: true,
396
407
  insideComponentChildren: false,
397
408
  loopParams: new Set(),
409
+ loopDepth: 0,
398
410
  patterns: {
399
411
  signals: analyzer.signals.map(s => ({
400
412
  getter: s.getter,
@@ -1563,7 +1575,12 @@ function transformText(node: ts.JsxText, ctx: TransformContext): IRText | null {
1563
1575
 
1564
1576
  return {
1565
1577
  type: 'text',
1566
- value: text,
1578
+ // JSX decodes character references at parse time (`&copy;` IS the
1579
+ // text `©`), so the IR carries the DECODED value — the semantics —
1580
+ // and each adapter re-escapes for its own emission context.
1581
+ // Decode AFTER whitespace normalization: `&nbsp;` yields U+00A0,
1582
+ // which `\s+` would otherwise collapse into a plain space.
1583
+ value: decodeEntities(text),
1567
1584
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1568
1585
  }
1569
1586
  }
@@ -2483,6 +2500,31 @@ function isIteratorShapeCall(
2483
2500
  return { array: node.expression.expression, shape: name }
2484
2501
  }
2485
2502
 
2503
+ /**
2504
+ * Check if a node is the STATIC `Object.entries(x)` / `Object.keys(x)` /
2505
+ * `Object.values(x)` call form (#2168 object-entries-map) — the
2506
+ * one-argument form where `x` is a plain object/Record being iterated,
2507
+ * as opposed to {@link isIteratorShapeCall}'s zero-arg instance-method
2508
+ * form (`arr.entries()`) on an actual array. Returns the object
2509
+ * expression (any expression — `props.x`, `x ?? {}`, not just a bare
2510
+ * identifier) and the iteration shape so `transformMapCall` can strip
2511
+ * the `Object.<method>(...)` wrapper and record it on the IRLoop as
2512
+ * `objectIteration` (see that field's docstring in `types.ts` for why
2513
+ * this is a distinct field from `iterationShape`, not a shared one).
2514
+ */
2515
+ function isObjectIteratorCall(
2516
+ node: ts.Expression,
2517
+ ): { object: ts.Expression; shape: 'entries' | 'keys' | 'values' } | null {
2518
+ if (!ts.isCallExpression(node)) return null
2519
+ if (!ts.isPropertyAccessExpression(node.expression)) return null
2520
+ if (!ts.isIdentifier(node.expression.expression)) return null
2521
+ if (node.expression.expression.text !== 'Object') return null
2522
+ if (node.arguments.length !== 1) return null
2523
+ const name = node.expression.name.text
2524
+ if (name !== 'entries' && name !== 'keys' && name !== 'values') return null
2525
+ return { object: node.arguments[0], shape: name }
2526
+ }
2527
+
2486
2528
  type SortExtractionResult = {
2487
2529
  result: IRLoopSort | null
2488
2530
  unsupportedReason?: string
@@ -3246,6 +3288,10 @@ function transformMapCall(
3246
3288
  // Capture nesting depth before we register this map's own params.
3247
3289
  // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3248
3290
  const isNested = ctx.loopParams.size > 0
3291
+ // This loop's own depth (0 = outermost) is however many enclosing
3292
+ // loops are already active, captured before `ctx.loopDepth` below is
3293
+ // bumped for THIS loop's own descendants.
3294
+ const depth = ctx.loopDepth
3249
3295
 
3250
3296
  const propAccess = node.expression as ts.PropertyAccessExpression
3251
3297
  const mapSource = propAccess.expression
@@ -3271,6 +3317,7 @@ function transformMapCall(
3271
3317
  let templateMapPreamble: string | undefined
3272
3318
  let typedMapPreamble: string | undefined
3273
3319
  let iterationShape: 'entries' | 'keys' | undefined
3320
+ let objectIteration: 'entries' | 'keys' | 'values' | undefined
3274
3321
 
3275
3322
  // Helper to set both array and templateArray
3276
3323
  const setArray = (node: ts.Expression) => {
@@ -3284,8 +3331,11 @@ function transformMapCall(
3284
3331
  // adapters emit the right loop variable bindings. `.values()` is a
3285
3332
  // no-op (same as plain `.map()`) so it's stripped but not recorded.
3286
3333
  // The inner expression (after stripping) feeds into the standard
3287
- // filter/sort chain detection below.
3288
- let chainSource = mapSource
3334
+ // filter/sort chain detection below. Widened to `ts.Expression` (not
3335
+ // narrowed to `mapSource`'s own `LeftHandSideExpression` type) since
3336
+ // `isObjectIteratorCall`'s stripped argument can be any expression
3337
+ // (`x ?? {}`, not just a `LeftHandSideExpression`).
3338
+ let chainSource: ts.Expression = mapSource
3289
3339
  const iteratorInfo = isIteratorShapeCall(mapSource)
3290
3340
  if (iteratorInfo) {
3291
3341
  chainSource = iteratorInfo.array
@@ -3295,6 +3345,18 @@ function transformMapCall(
3295
3345
  iterationShape = 'keys'
3296
3346
  }
3297
3347
  // 'values' is a no-op — same as plain .map()
3348
+ } else {
3349
+ // Detect the STATIC `Object.entries(x)` / `.keys(x)` / `.values(x)`
3350
+ // form (#2168 object-entries-map) — see `isObjectIteratorCall`'s and
3351
+ // `IRLoop.objectIteration`'s docstrings for why this is a SEPARATE
3352
+ // shape from the array-instance-method case above, not a shared one.
3353
+ // Unlike that case, `'values'` DOES need recording here (it isn't a
3354
+ // no-op: `x` itself isn't iterable as a plain object).
3355
+ const objectIteratorInfo = isObjectIteratorCall(mapSource)
3356
+ if (objectIteratorInfo) {
3357
+ chainSource = objectIteratorInfo.object
3358
+ objectIteration = objectIteratorInfo.shape
3359
+ }
3298
3360
  }
3299
3361
 
3300
3362
  const filterInfo = isFilterCall(chainSource)
@@ -3445,8 +3507,12 @@ function transformMapCall(
3445
3507
  // `.entries()` synthesises `[index, value]` — when the callback
3446
3508
  // destructures exactly two array elements, extract the names into
3447
3509
  // `index` and `param` so the loop renders with proper bindings and
3448
- // the BF104 destructure-param refusal doesn't fire.
3449
- if (iterationShape === 'entries' && ts.isArrayBindingPattern(firstParam.name)) {
3510
+ // the BF104 destructure-param refusal doesn't fire. `Object.entries(x)`
3511
+ // (`objectIteration === 'entries'`) synthesises the SAME `[key,
3512
+ // value]` 2-tuple shape — `index` just holds a string key instead
3513
+ // of a numeric position — so it reuses this exact extraction.
3514
+ const isEntriesShape = iterationShape === 'entries' || objectIteration === 'entries'
3515
+ if (isEntriesShape && ts.isArrayBindingPattern(firstParam.name)) {
3450
3516
  const elements = firstParam.name.elements.filter(
3451
3517
  el => !ts.isOmittedExpression(el),
3452
3518
  )
@@ -3485,7 +3551,7 @@ function transformMapCall(
3485
3551
  }
3486
3552
  }
3487
3553
  }
3488
- if (callback.parameters.length > 1 && iterationShape !== 'entries') {
3554
+ if (callback.parameters.length > 1 && iterationShape !== 'entries' && objectIteration !== 'entries') {
3489
3555
  const secondParam = callback.parameters[1]
3490
3556
  index = secondParam.name.getText(ctx.sourceFile)
3491
3557
  if (secondParam.type) {
@@ -3504,6 +3570,7 @@ function transformMapCall(
3504
3570
  ctx.loopParams.add(param)
3505
3571
  }
3506
3572
  if (index) ctx.loopParams.add(index)
3573
+ ctx.loopDepth++
3507
3574
 
3508
3575
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
3509
3576
  // body. This is not a JSX literal, ternary, or block, so without this
@@ -3626,6 +3693,7 @@ function transformMapCall(
3626
3693
  ctx.loopParams.delete(param)
3627
3694
  }
3628
3695
  if (index) ctx.loopParams.delete(index)
3696
+ ctx.loopDepth--
3629
3697
  }
3630
3698
 
3631
3699
  // If no JSX children were found (e.g., callback returns a function call),
@@ -3705,6 +3773,16 @@ function transformMapCall(
3705
3773
  !isSignalOrMemoArray(array, ctx)
3706
3774
  && !isDirectPropArray
3707
3775
  && !hasCalls
3776
+ // `objectIteration` (#2168 object-entries-map): `array` here is the
3777
+ // STRIPPED object expression (`Object.entries(x)`'s `x`), which can
3778
+ // itself be a static module-scope const object literal and would
3779
+ // otherwise satisfy every check above — but a plain OBJECT has no
3780
+ // `.forEach()`/`.map()` (the static-array client codegen's own
3781
+ // methods), unlike an actual array literal. Force the dynamic
3782
+ // `mapArray()` path instead, which this shape's client-JS array-expr
3783
+ // reconstruction (`applyObjectIterationWrap`, `ir-to-client-js/utils.ts`)
3784
+ // already handles correctly.
3785
+ && !objectIteration
3708
3786
 
3709
3787
  // Collect nested components for both static and dynamic arrays.
3710
3788
  // Static arrays: needed for initChild hydration.
@@ -3744,6 +3822,8 @@ function transformMapCall(
3744
3822
  sortComparator,
3745
3823
  chainOrder,
3746
3824
  iterationShape,
3825
+ objectIteration,
3826
+ depth,
3747
3827
  clientOnly: isClientOnly || undefined,
3748
3828
  mapPreamble,
3749
3829
  templateMapPreamble,
@@ -4157,9 +4237,12 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
4157
4237
  return AttrValueOf.booleanAttr()
4158
4238
  }
4159
4239
 
4160
- // String literal: <div id="main" />
4240
+ // String literal: <div id="main" />. JSX decodes character references
4241
+ // in quoted attribute values just like in text children, so the IR
4242
+ // carries the decoded string (`title="Fish &amp; Chips"` IS the value
4243
+ // `Fish & Chips`); adapters re-escape on emit.
4161
4244
  if (ts.isStringLiteral(attr.initializer)) {
4162
- return AttrValueOf.literal(attr.initializer.text)
4245
+ return AttrValueOf.literal(decodeEntities(attr.initializer.text))
4163
4246
  }
4164
4247
 
4165
4248
  // Expression: <div class={className} />
package/src/types.ts CHANGED
@@ -574,6 +574,74 @@ export interface IRLoop {
574
574
  */
575
575
  iterationShape?: 'entries' | 'keys'
576
576
 
577
+ /**
578
+ * Pre-`.map()` object iteration (#2168 object-entries-map). Distinct
579
+ * from {@link iterationShape}, which is scoped ENTIRELY to an array's
580
+ * own zero-arg `.entries()`/`.keys()`/`.values()` methods — those
581
+ * synthesize a real numeric index off the array's position, and every
582
+ * adapter's consumption of `iterationShape` assumes an actual
583
+ * array/slice underneath.
584
+ *
585
+ * `objectIteration` instead records the STATIC `Object.entries(x)` /
586
+ * `Object.keys(x)` / `Object.values(x)` call form, where `x` is a
587
+ * plain object/Record (not an array): the "index" bound for `'entries'`
588
+ * is a STRING KEY, not a numeric position, and the collection an
589
+ * adapter must iterate is its native map/dict/hash type, not an
590
+ * array/slice. `transformMapCall` strips the `Object.<method>(...)`
591
+ * wrapper the same way it strips `arr.entries()` — `array`/`arrayParsed`
592
+ * end up holding just `x` — and records the shape here so each
593
+ * adapter's loop renderer picks the right native construct:
594
+ *
595
+ * - `'entries'` → both `index` (bound to the KEY) and `param` (bound
596
+ * to the VALUE), synthesized from the 2-element destructure the
597
+ * same way `iterationShape: 'entries'` is (see `jsx-to-ir.ts`'s
598
+ * `transformMapCall`) — e.g. Jinja `for k, v in x.items()`.
599
+ * - `'keys'` → `param` bound to the key only — e.g. Jinja
600
+ * `for k in x.keys()`.
601
+ * - `'values'` → `param` bound to the value only — e.g. Jinja
602
+ * `for v in x.values()`. Unlike the array case, `'values'` is NOT
603
+ * a no-op here: `Object.values(x)` genuinely differs from
604
+ * iterating `x` itself (`x` isn't iterable at all as a plain
605
+ * object), so it must be recorded.
606
+ *
607
+ * Iteration ORDER is native-map-dependent: Python `dict`/PHP
608
+ * array-object/Ruby `Hash` preserve the source object's insertion
609
+ * order (matching JS `Object.entries()` semantics exactly), so Jinja,
610
+ * Twig, Blade, and ERB lower directly to their native map/dict/hash
611
+ * iteration. Go's `map[string]T`, Rust's `BTreeMap` (deliberate design,
612
+ * see `num.rs`), and Perl's hash (Xslate/Mojolicious) have NO
613
+ * order-preserving native map type, so those four instead lower to a
614
+ * DETERMINISTIC SORTED-BY-KEY iteration — Go's `{{range}}` (the
615
+ * stdlib's own `fmtsort`), minijinja's `BTreeMap` (already sorted),
616
+ * Kolon's `.kv()`/`.keys()`/`.values()` (verified empirically sorted),
617
+ * and Mojolicious's explicit `sort keys %$hash` (mirroring the
618
+ * existing `spread_attrs`/`_style_to_css` convention in
619
+ * `BarefootJS.pm`). This is a documented, permanent known limitation
620
+ * relative to JS's insertion-order guarantee (not a follow-up TODO) —
621
+ * true insertion order is physically unrecoverable from those
622
+ * languages' native map types once constructed, so sorted order is the
623
+ * best available deterministic approximation, not an interim refusal.
624
+ *
625
+ * Only the OUTERMOST, unchained `Object.<method>(x).map(cb)` shape is
626
+ * recognized — mirroring `iterationShape`'s own scope, chaining
627
+ * (`Object.entries(x).filter(pred).map(cb)`) is not (yet) recognized
628
+ * either, same as the array case.
629
+ */
630
+ objectIteration?: 'entries' | 'keys' | 'values'
631
+
632
+ /**
633
+ * Count of enclosing `.map()` loops (0 = outermost, 1 = nested one
634
+ * level deep, ...). Adapters use this to derive the loop body's
635
+ * `key`/`data-key` attribute suffix — `depth > 0 ? 'data-key-' +
636
+ * depth : 'data-key'` — matching `keyAttrName()` in
637
+ * `ir-to-client-js/utils.ts`, which the CSR path and the Hono SSR
638
+ * adapter each already derive independently (a recursion counter and
639
+ * a push/pop stack respectively). Before this field, the 8 template
640
+ * (non-JS) adapters had no depth awareness at all and always emitted
641
+ * plain `data-key` on nested-loop items (#2168 nested-loop-outer-binding).
642
+ */
643
+ depth: number
644
+
577
645
  /**
578
646
  * When true, loop should be evaluated on client side only.
579
647
  * SSR adapters should skip rendering and output placeholder markers.