@barefootjs/jsx 0.5.1 → 0.5.3

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 (51) hide show
  1. package/dist/analyzer-context.d.ts +8 -1
  2. package/dist/analyzer-context.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/combine-client-js.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +239 -65
  9. package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  13. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/imports.d.ts +2 -2
  17. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/types.d.ts +26 -4
  21. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/utils.d.ts +19 -1
  23. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  28. package/src/__tests__/child-components-in-map.test.ts +376 -0
  29. package/src/__tests__/combine-client-js.test.ts +47 -0
  30. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  31. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  32. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  33. package/src/analyzer-context.ts +59 -13
  34. package/src/analyzer.ts +8 -0
  35. package/src/combine-client-js.ts +66 -22
  36. package/src/expression-parser.ts +16 -1
  37. package/src/index.ts +2 -0
  38. package/src/ir-to-client-js/collect-elements.ts +191 -34
  39. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  41. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  43. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  44. package/src/ir-to-client-js/html-template.ts +82 -10
  45. package/src/ir-to-client-js/imports.ts +1 -1
  46. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  47. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  48. package/src/ir-to-client-js/types.ts +27 -4
  49. package/src/ir-to-client-js/utils.ts +41 -1
  50. package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
  51. package/src/types.ts +6 -0
@@ -32,7 +32,7 @@
32
32
  * sentinel before destructuring (#951 TDZ-safe).
33
33
  */
34
34
 
35
- import { toDomEventName, varSlotId, substituteLoopBindings, DATA_KEY, keyAttrName } from '../../utils'
35
+ import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils'
36
36
  import type {
37
37
  EventDelegationPlan,
38
38
  KeyedItemLookup,
@@ -187,11 +187,11 @@ function emitStaticIndexLookup(
187
187
  lookup: StaticIndexItemLookup,
188
188
  containerVar: string,
189
189
  ): void {
190
- const { arrayExpr, param, mapPreamble, siblingOffset } = lookup
190
+ const { arrayExpr, param, mapPreamble, offset } = lookup
191
191
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
192
192
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
193
193
  ls.push(` if (__el.parentElement === ${containerVar}) {`)
194
- const idxOffset = siblingOffset ? ` - ${siblingOffset}` : ''
194
+ const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
195
195
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
196
196
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
197
197
  if (mapPreamble) ls.push(` ${mapPreamble}`)
@@ -17,6 +17,15 @@ import { createTemplateAwareStringProtector } from './html-template'
17
17
  */
18
18
  export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
19
19
  const htmlName = toHtmlAttrName(attrName)
20
+ if (attrName === 'dangerouslySetInnerHTML' || htmlName === 'dangerouslySetInnerHTML') {
21
+ // `{ __html }` is not an attribute — it replaces the element's content.
22
+ // Assign `innerHTML` (raw, intentional escape hatch) to mirror the SSR
23
+ // adapters' native `dangerouslySetInnerHTML` handling instead of
24
+ // stringifying the object into a bogus attribute.
25
+ return [
26
+ `{ const __v = ${expression}; ${target}.innerHTML = __v != null && __v.__html != null ? String(__v.__html) : '' }`,
27
+ ]
28
+ }
20
29
  if (htmlName === 'style') {
21
30
  return [
22
31
  `{ const __v = styleToCss(${expression}); if (__v != null) ${target}.setAttribute('style', __v); else ${target}.removeAttribute('style') }`,
@@ -192,11 +192,16 @@ const UNSAFE_TEMPLATE_EXPR = 'undefined'
192
192
  * @param attr - Attribute metadata (only presenceOrUndefined flag is used)
193
193
  */
194
194
  function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined?: boolean): string {
195
+ // `dangerouslySetInnerHTML={{ __html }}` is not an attribute — its value
196
+ // becomes the element's raw innerHTML (emitted as element content). Never
197
+ // serialise the `{ __html }` object into a `dangerouslySetInnerHTML="…"`
198
+ // attribute.
199
+ if (attrName === 'dangerouslySetInnerHTML') return ''
195
200
  if (isBooleanAttr(attrName) || presenceOrUndefined) {
196
201
  return `\${${valExpr} ? '${attrName}' : ''}`
197
202
  }
198
203
  if (attrName === 'style') {
199
- return `\${((v) => v != null ? 'style="' + v + '"' : '')(styleToCss(${valExpr}))}`
204
+ return `\${((v) => v != null ? 'style="' + ${escapeAttrValueExpr('v')} + '"' : '')(styleToCss(${valExpr}))}`
200
205
  }
201
206
  // `data-key` / `data-key-N` is a reconciliation contract — every loop item
202
207
  // must carry one. Emit unconditionally; if the user passes `key={undefined}`
@@ -205,7 +210,60 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
205
210
  if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
206
211
  return `${attrName}="\${${valExpr}}"`
207
212
  }
208
- return `\${(${valExpr}) != null ? '${attrName}="' + (${valExpr}) + '"' : ''}`
213
+ return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
214
+ }
215
+
216
+ /**
217
+ * Build a runtime expression that HTML-escapes an interpolated attribute
218
+ * value, matching the SSR adapters' attribute escaping (Hono escapes
219
+ * `& " ' < >`) via the `escapeAttr` runtime helper. The client template
220
+ * assembles an HTML string inserted via `innerHTML`, so an unescaped `"`
221
+ * / `<` / `>` in a value — e.g. UnoCSS arbitrary variants like
222
+ * `[class*="size-"]` or `has-[>svg]` — corrupts attribute parsing (and
223
+ * diverges from the SSR-rendered bytes). Escaping at interpolation time
224
+ * is the only correct layer: a post-assembly pass can't tell a delimiter
225
+ * `"` from a value `"`.
226
+ */
227
+ function escapeAttrValueExpr(valExpr: string): string {
228
+ return `escapeAttr(${valExpr})`
229
+ }
230
+
231
+ /**
232
+ * Build a runtime expression that HTML-escapes an interpolated **text
233
+ * content** slot, via the `escapeText` runtime helper. Only the
234
+ * `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
235
+ * runtime treats whatever sits between the markers as the slot's text, so
236
+ * a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
237
+ * to parse correctly under `innerHTML` and to match the SSR-rendered
238
+ * bytes. Bare `${...}` interpolations — `{children}` passthrough and
239
+ * `renderChild(...)` output — are pre-rendered HTML and must NOT be
240
+ * escaped, so this is applied only at the four text-marker emit sites.
241
+ * Hono escapes text content with the same set as attribute values
242
+ * (`& " ' < >`), so `escapeText` delegates to the same operation.
243
+ */
244
+ function escapeTextSlotExpr(innerExpr: string): string {
245
+ return `escapeText(${innerExpr})`
246
+ }
247
+
248
+ /**
249
+ * `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
250
+ * raw innerHTML — the intentional, React-style escape hatch. Returns the
251
+ * raw-content template expression to use *instead of* the element's normal
252
+ * children (emitted UNescaped by design, mirroring the SSR adapters'
253
+ * native handling), or `null` when the element carries no such attribute.
254
+ * The attribute itself is suppressed in `templateAttrExpr`, and the
255
+ * matching reactive update is emitted by `emitAttrUpdate` (assigns
256
+ * `innerHTML`). `toExpr` is the walker's value transform (`wrapExpr` /
257
+ * `transformExpr`) so the `{ __html }` object is lowered the same way an
258
+ * attribute value would be.
259
+ */
260
+ function dangerouslyHtmlChildren(
261
+ attrs: ReadonlyArray<IRAttribute>,
262
+ toExpr: (v: { expr: string; templateExpr?: string }) => string,
263
+ ): string | null {
264
+ const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
265
+ if (!attr || attr.value.kind !== 'expression') return null
266
+ return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
209
267
  }
210
268
 
211
269
  /**
@@ -322,6 +380,12 @@ export interface MergeContext {
322
380
  function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
323
381
  if (ctx.honorClientOnly && a.clientOnly) return false
324
382
  if (a.name === 'key') return false
383
+ // `dangerouslySetInnerHTML` is not an attribute — its `{ __html }` value
384
+ // becomes the element's raw innerHTML (emitted as content, set via
385
+ // `innerHTML` in init). Keep it out of the `spreadAttrs({...})` merge so
386
+ // it isn't serialised back into a bogus `dangerouslySetInnerHTML="…"`
387
+ // attribute when the element also carries a spread.
388
+ if (a.name === 'dangerouslySetInnerHTML') return false
325
389
  const v = a.value
326
390
  if (v.kind === 'jsx-children') return false
327
391
  if (v.kind === 'boolean-shorthand') return false
@@ -476,7 +540,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
476
540
 
477
541
  const attrs = attrParts.join(' ')
478
542
  const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
479
- const children = node.children.map(childrenRecurse).join('')
543
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
480
544
 
481
545
  // Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
482
546
  if (children || !VOID_ELEMENTS.has(node.tag)) {
@@ -491,7 +555,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
491
555
  case 'expression':
492
556
  if (node.expr === 'null' || node.expr === 'undefined') return ''
493
557
  if (node.slotId) {
494
- return `<!--bf:${node.slotId}-->\${${wrapInterpolation(wrapExpr(node.expr))}}<!--/-->`
558
+ const inner = wrapInterpolation(wrapExpr(node.expr))
559
+ // In branch-slot context `wrapInterpolation` routes the value
560
+ // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
561
+ // for live `Node` values (spliced back by `insert()`). Escaping
562
+ // would corrupt those markers and drop slotted content (#1694
563
+ // regression). `__bfSlot` owns coercion of its own value, so the
564
+ // text-escape applies only to the non-slot (plain text) form.
565
+ const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
566
+ return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
495
567
  }
496
568
  return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
497
569
 
@@ -656,7 +728,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
656
728
  }
657
729
 
658
730
  const attrs = attrParts.join(' ')
659
- const children = node.children.map(recurse).join('')
731
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(recurse).join('')
660
732
 
661
733
  if (children || !VOID_ELEMENTS.has(node.tag)) {
662
734
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -670,7 +742,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
670
742
  case 'expression':
671
743
  if (node.expr === 'null' || node.expr === 'undefined') return ''
672
744
  if (node.slotId) {
673
- return `<!--bf:${node.slotId}-->\${${wrapExpr(node.expr)}}<!--/-->`
745
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
674
746
  }
675
747
  return `\${${wrapExpr(node.expr)}}`
676
748
 
@@ -1041,7 +1113,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1041
1113
  }
1042
1114
 
1043
1115
  const attrs = attrParts.join(' ')
1044
- const children = node.children.map(childrenRecurse).join('')
1116
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1045
1117
 
1046
1118
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1047
1119
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1055,7 +1127,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1055
1127
  case 'expression':
1056
1128
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1057
1129
  if (node.slotId) {
1058
- return `<!--bf:${node.slotId}-->\${${transformExpr(node.expr, node.templateExpr)}}<!--/-->`
1130
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
1059
1131
  }
1060
1132
  return `\${${transformExpr(node.expr, node.templateExpr)}}`
1061
1133
 
@@ -1417,7 +1489,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1417
1489
  }
1418
1490
 
1419
1491
  const attrs = attrParts.join(' ')
1420
- const children = node.children.map(childrenRecurse).join('')
1492
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1421
1493
 
1422
1494
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1423
1495
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1440,7 +1512,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1440
1512
  // an empty placeholder instead.
1441
1513
  const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
1442
1514
  if (node.slotId) {
1443
- return `<!--bf:${node.slotId}-->\${${expr}}<!--/-->`
1515
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
1444
1516
  }
1445
1517
  return `\${${expr}}`
1446
1518
  }
@@ -11,7 +11,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
11
11
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
12
12
  'createPortal',
13
13
  'provideContext', 'createContext', 'useContext',
14
- 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
14
+ 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
15
15
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
16
16
  ] as const
17
17
 
@@ -16,7 +16,7 @@
16
16
  import type { IRLoopChildComponent } from '../../types'
17
17
  import type { NestedLoop, TopLevelLoop } from '../types'
18
18
  import type { ClientJsContext } from '../types'
19
- import { quotePropName, varSlotId, attrValueToString } from '../utils'
19
+ import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils'
20
20
  import { irChildrenToJsExpr } from '../html-template'
21
21
  import { buildCompSelector } from '../control-flow/shared'
22
22
 
@@ -98,7 +98,7 @@ function buildOuterNestedPlan(
98
98
  arrayExpr: elem.array,
99
99
  param: elem.param,
100
100
  indexParam,
101
- offsetExpr: elem.siblingOffset ? `${indexParam} + ${elem.siblingOffset}` : indexParam,
101
+ offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
102
102
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
103
103
  propsExpr: buildStaticPropsExpr(comp.props),
104
104
  }
@@ -122,16 +122,12 @@ function buildInnerLoopNestedPlan(
122
122
  outerArrayExpr: elem.array,
123
123
  outerParam: elem.param,
124
124
  outerIndexParam,
125
- outerOffsetExpr: elem.siblingOffset
126
- ? `${outerIndexParam} + ${elem.siblingOffset}`
127
- : outerIndexParam,
125
+ outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
128
126
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
129
127
  innerContainerSlotId: innerLoop.containerSlotId ?? null,
130
128
  innerArrayExpr: innerLoop.array,
131
129
  innerParam: innerLoop.param,
132
- innerOffsetExpr: innerLoop.siblingOffset
133
- ? `__innerIdx + ${innerLoop.siblingOffset}`
134
- : '__innerIdx',
130
+ innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
135
131
  innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
136
132
  depth: innerLoop.depth,
137
133
  comps,
@@ -75,7 +75,7 @@ export interface OuterNestedInitPlan {
75
75
  arrayExpr: string
76
76
  param: string
77
77
  indexParam: string
78
- /** `indexParam` or `${indexParam} + ${siblingOffset}` — already substituted. */
78
+ /** `indexParam` plus any sibling-offset terms (`+ 1 + (arr).length`) — already substituted. */
79
79
  offsetExpr: string
80
80
  /**
81
81
  * Outer `.map()` callback preamble locals (#1064), emitted inside the
@@ -99,7 +99,7 @@ export interface InnerLoopNestedInitPlan {
99
99
  outerArrayExpr: string
100
100
  outerParam: string
101
101
  outerIndexParam: string
102
- /** Outer offset — `outerIndexParam` or `${outerIndexParam} + ${siblingOffset}`. */
102
+ /** Outer offset — `outerIndexParam` plus any sibling-offset terms. */
103
103
  outerOffsetExpr: string
104
104
  /**
105
105
  * Outer `.map()` callback preamble locals, emitted after the
@@ -115,7 +115,7 @@ export interface InnerLoopNestedInitPlan {
115
115
  innerContainerSlotId: string | null
116
116
  innerArrayExpr: string
117
117
  innerParam: string
118
- /** Inner offset — `__innerIdx` or `__innerIdx + ${siblingOffset}`. */
118
+ /** Inner offset — `__innerIdx` plus any sibling-offset terms. */
119
119
  innerOffsetExpr: string
120
120
  /**
121
121
  * Inner `.map()` callback preamble locals, emitted after the
@@ -166,6 +166,29 @@ export interface ConditionalBranchReactiveAttr extends AttrMeta {
166
166
  expression: string
167
167
  }
168
168
 
169
+ /**
170
+ * How many element children precede a loop's items in its container — the
171
+ * offset applied to `container.children[idx]` so each item resolves to the
172
+ * right element during hydration.
173
+ *
174
+ * Two contributions, kept distinct because they codegen differently:
175
+ * - `staticCount`: siblings of statically-known element count, folded to a
176
+ * compile-time integer (`+ 1`).
177
+ * - `dynamicTerms`: one JS expression per sibling whose element count is
178
+ * only known at runtime — a preceding `.map()` (`(arr).length`) or a
179
+ * preceding conditional (`(cond ? 1 : 0)`) — each added as `+ <term>`.
180
+ *
181
+ * Carried as one value object end-to-end (collect → IR loop → plan → codegen)
182
+ * so a future offset contributor is added in one place, not threaded as a new
183
+ * field through every layer (#1693).
184
+ */
185
+ export interface LoopOffset {
186
+ /** Folded element count of statically-sized preceding siblings. `0` = none. */
187
+ staticCount: number
188
+ /** Runtime element-count expressions of dynamic preceding siblings; `[]` = none. */
189
+ dynamicTerms: readonly string[]
190
+ }
191
+
169
192
  /**
170
193
  * Fields shared by every flavour of collected loop (top-level, branch-scoped, nested).
171
194
  * The three loop-info variants (`TopLevelLoop`, `BranchLoop`, `NestedLoop`) each extend
@@ -363,8 +386,8 @@ export interface NestedLoop extends LoopCore {
363
386
  childComponents?: import('../types').IRLoopChildComponent[]
364
387
  /** True when this loop is inside a conditional branch (handled by insert() bindEvents instead) */
365
388
  insideConditional?: boolean
366
- /** Number of non-loop DOM siblings before this loop in its container element */
367
- siblingOffset?: number
389
+ /** Offset of this loop's items past its preceding container siblings (#1693). */
390
+ offset?: LoopOffset
368
391
  // Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
369
392
  // now live on `LoopCore.bindings` — see issue #1244 §B.
370
393
  }
@@ -468,8 +491,8 @@ export interface TopLevelLoop extends LoopCore {
468
491
  useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
469
492
  /** Inner loop metadata for composite element reconciliation (array, param, key, container) */
470
493
  innerLoops?: NestedLoop[]
471
- /** Number of non-loop DOM siblings before this loop in its parent element. Used to offset children[idx] access. */
472
- siblingOffset?: number
494
+ /** Offset of this loop's items past its preceding container siblings (#1693). */
495
+ offset?: LoopOffset
473
496
  filterPredicate?: {
474
497
  param: string
475
498
  raw: string // Original filter predicate expression or block body
@@ -5,7 +5,7 @@
5
5
 
6
6
  import ts from 'typescript'
7
7
  import type { AttrValue, IRTemplatePart, LoopParamBinding, FreeReference, IRNode } from '../types'
8
- import type { TopLevelLoop, BranchLoop } from './types'
8
+ import type { TopLevelLoop, BranchLoop, LoopOffset } from './types'
9
9
  import { buildLoopChainExpr } from '../loop-chain'
10
10
  import {
11
11
  iterateJsTokens,
@@ -145,6 +145,46 @@ export function buildChainedArrayExpr(elem: TopLevelLoop | BranchLoop): string {
145
145
  })
146
146
  }
147
147
 
148
+ /**
149
+ * The single source of truth for what contributes to a loop's child-index
150
+ * offset: the static sibling count (a folded integer) followed by one
151
+ * `(arr).length` term per preceding sibling loop. The additive and
152
+ * subtractive forms below are thin projections over this list, so they can
153
+ * never drift in which terms they include, and a new offset contributor is
154
+ * added here once rather than in every consumer (#1693).
155
+ */
156
+ function loopOffsetTerms(offset: LoopOffset | undefined): string[] {
157
+ if (!offset) return []
158
+ const terms: string[] = []
159
+ if (offset.staticCount) terms.push(String(offset.staticCount))
160
+ terms.push(...offset.dynamicTerms)
161
+ return terms
162
+ }
163
+
164
+ /**
165
+ * Build the additive `children[idx]` access expression for a loop's items —
166
+ * `indexParam` plus every offset term.
167
+ *
168
+ * Examples:
169
+ * - no offset → `__idx`
170
+ * - one static sibling → `__idx + 1`
171
+ * - one preceding `.map()` → `__idx + (arr).length`
172
+ * - static sibling + 2 `.map()`→ `__idx + 1 + (a).length + (b).length`
173
+ */
174
+ export function buildLoopChildIndexExpr(indexParam: string, offset: LoopOffset | undefined): string {
175
+ return [indexParam, ...loopOffsetTerms(offset)].join(' + ')
176
+ }
177
+
178
+ /**
179
+ * Build the subtractive counterpart of `buildLoopChildIndexExpr` — used by
180
+ * event delegation to recover a loop item's array index from its DOM child
181
+ * index. Returns the trailing `` - <static> - (arr).length …`` suffix (empty
182
+ * when there is no offset) appended after `…indexOf(__el)`.
183
+ */
184
+ export function buildLoopChildIndexSubtraction(offset: LoopOffset | undefined): string {
185
+ return loopOffsetTerms(offset).map(term => ` - ${term}`).join('')
186
+ }
187
+
148
188
  /**
149
189
  * Map of JSX event names to DOM event property names.
150
190
  * JSX uses React-style naming (e.g., onDoubleClick) which gets converted to
@@ -0,0 +1,202 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ replaceInExprContexts,
4
+ findInterpolationEnd,
5
+ findTopLevelTemplateLiterals,
6
+ } from '../js-scanner'
7
+ import { tokenContainsIdent, wrapLoopParamAsAccessor } from '../../ir-to-client-js/utils'
8
+
9
+ /**
10
+ * Fuzz harness for the shared `ts.createScanner`-based JS-text scanner (#1370).
11
+ *
12
+ * The bug class this issue targets — hand-rolled char-by-char scanners that
13
+ * disagree about where "code context" ends — only shows up on inputs that mix
14
+ * quotes, comments, escapes and regex tokens in adversarial ways. The
15
+ * example-based suite in `js-scanner.test.ts` pins specific known cases; this
16
+ * file generates random combinations and asserts that *every* consumer of the
17
+ * shared lexer honours the same invariant:
18
+ *
19
+ * a bare `MARK` identifier is a real code reference **iff** it sits in
20
+ * expression context — never when it appears inside a string, template
21
+ * string body, comment or regex literal.
22
+ *
23
+ * Each generated input is paired with an exact oracle computed from the atoms
24
+ * it was assembled from, so the assertions are precise (not just "didn't
25
+ * throw"). Generation is seeded per-iteration with a deterministic PRNG, and
26
+ * the seed + input are surfaced on failure so any regression reproduces.
27
+ */
28
+
29
+ // --- deterministic PRNG (mulberry32) ---------------------------------------
30
+ function mulberry32(seed: number): () => number {
31
+ let a = seed >>> 0
32
+ return () => {
33
+ a = (a + 0x6d2b79f5) | 0
34
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
35
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
36
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
37
+ }
38
+ }
39
+
40
+ const pick = <T>(rng: () => number, arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)]!
41
+
42
+ // Alphabet deliberately loaded with the delimiters that fooled the old
43
+ // per-consumer scanners: every quote flavour, comment markers, regex slashes,
44
+ // braces, escapes and the marker letters themselves.
45
+ const NOISE_ALPHABET = [
46
+ 'a', 'M', 'A', 'R', 'K', 'q', 'z', ' ',
47
+ "'", '"', '`', '/', '*', '{', '}', '$', '\\', ';', '(', ')', '[', ']',
48
+ ] as const
49
+
50
+ function randNoise(rng: () => number, maxLen = 10): string {
51
+ const len = Math.floor(rng() * (maxLen + 1))
52
+ let s = ''
53
+ for (let i = 0; i < len; i++) s += pick(rng, NOISE_ALPHABET)
54
+ return s
55
+ }
56
+
57
+ // --- per-context sanitizers: make `raw` safe to embed without changing how it
58
+ // lexes, so the embedded text round-trips byte-for-byte in the oracle. ---
59
+ const escDouble = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '')
60
+ const escSingle = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/[\r\n]/g, '')
61
+ // No-substitution template: kill backticks and any `${` interpolation opener.
62
+ const escTemplate = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/`/g, '').replace(/\$/g, '').replace(/[\r\n]/g, '')
63
+ const escLine = (raw: string) => raw.replace(/[\r\n]/g, '')
64
+ const escBlock = (raw: string) => raw.replace(/[\r\n]/g, '').replace(/\*\//g, '* /')
65
+ // Regex body: escape the delimiter and drop char-class brackets so the literal
66
+ // always terminates at the next `/`. Prefix a literal char so it is never empty
67
+ // (which would read as `//` line comment) nor start with `*`.
68
+ const escRegex = (raw: string) =>
69
+ 'x' + raw.replace(/\\/g, '\\\\').replace(/\//g, '\\/').replace(/\[/g, '\\[').replace(/\]/g, '\\]').replace(/[\r\n]/g, '')
70
+
71
+ interface Atom {
72
+ /** Source text of the atom as it appears in the input. */
73
+ src: string
74
+ /** What this atom becomes after a `MARK` -> `MARK()` expression-context rewrite. */
75
+ rewritten: string
76
+ /** True only for a bare-identifier `MARK` sitting in code context. */
77
+ isCodeMarker: boolean
78
+ }
79
+
80
+ // Plain code fragments that carry no `MARK` and no structural `{`/`}` (so the
81
+ // only top-level braces in any input come from the `${...}` wrap we add later).
82
+ const CODE_PLAIN = ['a', 'b.c', 'foo(1)', 'arr[0]', 'n / 2', 'x + y', 'obj.prop', '1.5'] as const
83
+
84
+ function makeAtom(rng: () => number): Atom {
85
+ const kind = pick(rng, [
86
+ 'codeMarker', 'codeMarker', 'codePlain',
87
+ 'dq', 'sq', 'tmpl', 'line', 'block', 'regex',
88
+ ] as const)
89
+ switch (kind) {
90
+ case 'codeMarker':
91
+ return { src: 'MARK', rewritten: 'MARK()', isCodeMarker: true }
92
+ case 'codePlain': {
93
+ const src = pick(rng, CODE_PLAIN)
94
+ return { src, rewritten: src, isCodeMarker: false }
95
+ }
96
+ case 'dq': {
97
+ const src = '"' + escDouble(randNoise(rng)) + '"'
98
+ return { src, rewritten: src, isCodeMarker: false }
99
+ }
100
+ case 'sq': {
101
+ const src = "'" + escSingle(randNoise(rng)) + "'"
102
+ return { src, rewritten: src, isCodeMarker: false }
103
+ }
104
+ case 'tmpl': {
105
+ const src = '`' + escTemplate(randNoise(rng)) + '`'
106
+ return { src, rewritten: src, isCodeMarker: false }
107
+ }
108
+ case 'line': {
109
+ // Trailing newline terminates the comment so the joining ` + ` that
110
+ // follows stays in code context.
111
+ const src = '// ' + escLine(randNoise(rng)) + '\n'
112
+ return { src, rewritten: src, isCodeMarker: false }
113
+ }
114
+ case 'block': {
115
+ const src = '/* ' + escBlock(randNoise(rng)) + ' */'
116
+ return { src, rewritten: src, isCodeMarker: false }
117
+ }
118
+ case 'regex': {
119
+ const src = '/' + escRegex(randNoise(rng)) + '/' + pick(rng, ['', 'g', 'i', 'gi', 'm'])
120
+ return { src, rewritten: src, isCodeMarker: false }
121
+ }
122
+ }
123
+ }
124
+
125
+ interface Generated {
126
+ input: string
127
+ /** Input after every code-context `MARK` is rewritten to `MARK()`. */
128
+ expected: string
129
+ /** Whether at least one `MARK` reference lives in code context. */
130
+ hasCodeMarker: boolean
131
+ }
132
+
133
+ function generate(rng: () => number): Generated {
134
+ const count = 1 + Math.floor(rng() * 8)
135
+ const atoms: Atom[] = []
136
+ for (let i = 0; i < count; i++) atoms.push(makeAtom(rng))
137
+ // ` + ` keeps the stream lexable and puts every regex atom in a
138
+ // regex-start position (after an operator).
139
+ const input = atoms.map(a => a.src).join(' + ')
140
+ const expected = atoms.map(a => a.rewritten).join(' + ')
141
+ const hasCodeMarker = atoms.some(a => a.isCodeMarker)
142
+ return { input, expected, hasCodeMarker }
143
+ }
144
+
145
+ const ITERATIONS = 600
146
+
147
+ describe('js-scanner fuzz: code-context opacity holds across all consumers', () => {
148
+ test('replaceInExprContexts rewrites MARK only outside strings/comments/regex/templates', () => {
149
+ for (let i = 0; i < ITERATIONS; i++) {
150
+ const seed = (0x9e3779b9 ^ i) >>> 0
151
+ const { input, expected } = generate(mulberry32(seed))
152
+ const got = replaceInExprContexts(input, /\bMARK\b/g, 'MARK()')
153
+ expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(expected)
154
+ }
155
+ })
156
+
157
+ test('wrapLoopParamAsAccessor agrees with replaceInExprContexts on the same inputs', () => {
158
+ for (let i = 0; i < ITERATIONS; i++) {
159
+ const seed = (0x85ebca6b ^ i) >>> 0
160
+ const { input, expected } = generate(mulberry32(seed))
161
+ const got = wrapLoopParamAsAccessor(input, 'MARK')
162
+ expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(expected)
163
+ }
164
+ })
165
+
166
+ test('tokenContainsIdent reports MARK iff a code-context occurrence exists', () => {
167
+ for (let i = 0; i < ITERATIONS; i++) {
168
+ const seed = (0xc2b2ae35 ^ i) >>> 0
169
+ const { input, hasCodeMarker } = generate(mulberry32(seed))
170
+ const got = tokenContainsIdent(input, 'MARK')
171
+ expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(hasCodeMarker)
172
+ }
173
+ })
174
+
175
+ test('findInterpolationEnd finds the real closing brace despite braces in noise', () => {
176
+ for (let i = 0; i < ITERATIONS; i++) {
177
+ const seed = (0x27d4eb2f ^ i) >>> 0
178
+ const { input } = generate(mulberry32(seed))
179
+ // Braces only appear inside opaque tokens (strings/regex/comments/
180
+ // templates) of `input`; the wrap adds the single top-level pair.
181
+ const wrapped = '${' + input + '}'
182
+ const end = findInterpolationEnd(wrapped, 2)
183
+ expect(end, `seed=${seed} wrapped=${JSON.stringify(wrapped)}`).toBe(wrapped.length - 1)
184
+ }
185
+ })
186
+ })
187
+
188
+ // findTopLevelTemplateLiterals operates on a ternary shape; fuzz its branch
189
+ // bodies with noise that must stay inside the backtick literals.
190
+ describe('js-scanner fuzz: findTopLevelTemplateLiterals extracts noisy branches', () => {
191
+ test('returns both backtick branches verbatim regardless of embedded delimiters', () => {
192
+ for (let i = 0; i < ITERATIONS; i++) {
193
+ const seed = (0x165667b1 ^ i) >>> 0
194
+ const rng = mulberry32(seed)
195
+ const a = escTemplate(randNoise(rng))
196
+ const b = escTemplate(randNoise(rng))
197
+ const src = 'cond ? `' + a + '` : `' + b + '`'
198
+ const got = findTopLevelTemplateLiterals(src)
199
+ expect(got, `seed=${seed} src=${JSON.stringify(src)}`).toEqual([a, b])
200
+ }
201
+ })
202
+ })
package/src/types.ts CHANGED
@@ -1291,6 +1291,12 @@ export interface TypeDefinition {
1291
1291
  kind: 'interface' | 'type'
1292
1292
  name: string
1293
1293
  definition: string // Original TypeScript definition
1294
+ /**
1295
+ * Structured fields for object/interface shapes, so adapters can consume the
1296
+ * field set (names + types) without re-parsing `definition`. Absent for
1297
+ * type aliases that aren't object types (e.g. string-literal unions).
1298
+ */
1299
+ properties?: PropertyInfo[]
1294
1300
  loc: SourceLocation
1295
1301
  }
1296
1302