@barefootjs/jsx 0.18.5 → 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.
Files changed (88) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +77 -0
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts +22 -0
  4. package/dist/adapters/loop-bound-names.d.ts.map +1 -0
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/index.d.ts +14 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +977 -273
  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-inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  17. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
  19. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
  27. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  28. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
  30. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +42 -0
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
  36. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  37. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
  39. package/dist/ir-to-client-js/types.d.ts +21 -0
  40. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  41. package/dist/jsx-to-ir.d.ts.map +1 -1
  42. package/dist/signal-init-eval.d.ts +82 -0
  43. package/dist/signal-init-eval.d.ts.map +1 -0
  44. package/dist/static-literal.d.ts +45 -0
  45. package/dist/static-literal.d.ts.map +1 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
  48. package/src/__tests__/create-selector.test.ts +110 -0
  49. package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
  50. package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
  51. package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
  52. package/src/__tests__/loop-bound-names.test.ts +102 -0
  53. package/src/__tests__/loop-plan-classification.test.ts +2 -0
  54. package/src/__tests__/map-function-reference.test.ts +303 -0
  55. package/src/__tests__/nested-loop-index-param.test.ts +285 -0
  56. package/src/__tests__/signal-init-eval.test.ts +138 -0
  57. package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
  58. package/src/__tests__/static-literal.test.ts +126 -0
  59. package/src/__tests__/string-concat-identifier.test.ts +55 -0
  60. package/src/adapters/dangerous-inner-html.ts +186 -0
  61. package/src/adapters/loop-bound-names.ts +80 -0
  62. package/src/adapters/parsed-expr-emitter.ts +9 -0
  63. package/src/analyzer.ts +6 -0
  64. package/src/index.ts +19 -3
  65. package/src/ir-to-client-js/collect-elements.ts +15 -3
  66. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
  67. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
  68. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
  70. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  71. package/src/ir-to-client-js/control-flow/shared.ts +103 -6
  72. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
  73. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
  74. package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
  75. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
  76. package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
  78. package/src/ir-to-client-js/csr-substitute.ts +53 -0
  79. package/src/ir-to-client-js/html-template.ts +235 -2
  80. package/src/ir-to-client-js/imports.ts +1 -0
  81. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
  82. package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
  83. package/src/ir-to-client-js/reactivity.ts +6 -0
  84. package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
  85. package/src/ir-to-client-js/types.ts +21 -0
  86. package/src/jsx-to-ir.ts +230 -39
  87. package/src/signal-init-eval.ts +165 -0
  88. package/src/static-literal.ts +128 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Shared `dangerouslySetInnerHTML={{ __html: expr }}` recognition + policy
3
+ * (#2207). Single place every template adapter calls from `renderElement`,
4
+ * so the injection-safety-relevant policy — "only a compile-time string
5
+ * literal is lowered; anything else refuses loudly" — lives in exactly one
6
+ * reviewable spot instead of being re-derived independently in 8 adapters.
7
+ *
8
+ * Scope, deliberately narrow for v1 (see #2207 / #2215 for the follow-up):
9
+ * a static literal is spliced directly into the adapter's OWN template
10
+ * source as trusted text (same trust domain as hand-writing the HTML into
11
+ * the template) — never routed through a `|safe`/`|raw`/`{!! !!}`-style
12
+ * runtime raw-output primitive, which would reopen a template-source
13
+ * injection surface for no benefit (the value is already fully known at
14
+ * compile time). A DYNAMIC value (signal, prop, a template literal WITH a
15
+ * substitution, local const, anything non-literal) is refused with
16
+ * `BF101` — a no-substitution template literal is NOT dynamic; the parser
17
+ * normalizes it to the same `{kind:'literal', literalType:'string'}` shape
18
+ * as a plain string literal, so it's treated identically (see
19
+ * `staticHtmlLiteral` below). Hono/CSR already support it (the client
20
+ * drives a `createEffect`-based `el.innerHTML = …`
21
+ * assignment — see `ir-to-client-js/emit-reactive.ts`), so this is a
22
+ * template-adapter-only gap, tracked separately (#2215) rather than folded
23
+ * into this literal-only cut.
24
+ */
25
+
26
+ import type { CompilerError, IRAttribute, IRElement, SourceLocation } from '../types.ts'
27
+ import { parseExpression, type ParsedExpr } from '../expression-parser.ts'
28
+
29
+ const DANGEROUS_INNER_HTML_ATTR = 'dangerouslySetInnerHTML'
30
+
31
+ export function isDangerousInnerHtmlAttr(attr: IRAttribute): boolean {
32
+ return attr.name === DANGEROUS_INNER_HTML_ATTR
33
+ }
34
+
35
+ export type DangerousInnerHtmlResolution =
36
+ | { kind: 'static'; html: string }
37
+ | { kind: 'dynamic'; expr: string; loc: SourceLocation }
38
+
39
+ /**
40
+ * Resolve `element`'s `dangerouslySetInnerHTML` attribute, if present.
41
+ * Returns `null` when the attribute is absent (the overwhelmingly common
42
+ * case — callers should treat `null` as "render this element normally") —
43
+ * or when it carries `/* @client *\/` (Fable review, #2217): a `clientOnly`
44
+ * attr is already deferred to hydrate by every adapter's `renderAttributes`
45
+ * (`if (attr.clientOnly) continue`, unrelated to this module), which is
46
+ * itself a working escape hatch for a dynamic `__html` — the client's
47
+ * `createEffect`-driven `el.innerHTML = …` assignment (`emit-reactive.ts`)
48
+ * runs regardless of what SSR does. Treating `clientOnly` as "render this
49
+ * element normally" here (rather than refusing with BF101) preserves that
50
+ * escape hatch instead of regressing it.
51
+ * A present-but-non-`expression` value (e.g. a bare boolean/spread — not a
52
+ * shape `{ __html }` can ever legitimately take) is treated as `dynamic`
53
+ * so it refuses rather than silently doing nothing.
54
+ */
55
+ export function resolveDangerousInnerHtml(element: IRElement): DangerousInnerHtmlResolution | null {
56
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)
57
+ if (!attr) return null
58
+ if (attr.clientOnly) return null
59
+ if (attr.value.kind !== 'expression') {
60
+ return { kind: 'dynamic', expr: '', loc: attr.loc }
61
+ }
62
+ const parsed = attr.value.parsed ?? parseExpression(attr.value.expr.trim())
63
+ const html = staticHtmlLiteral(parsed)
64
+ if (html !== null) return { kind: 'static', html }
65
+ return { kind: 'dynamic', expr: attr.value.expr, loc: attr.loc }
66
+ }
67
+
68
+ /**
69
+ * `{ __html: '<b>bold</b>' }` → the literal string, nothing else. Exactly
70
+ * one property, key `__html` (identifier or string form — `{ __html: … }`
71
+ * and `{ '__html': … }` are equivalent JS), non-shorthand (`{ __html }`
72
+ * would read a variable, not a literal), value a compile-time STRING
73
+ * LITERAL `ParsedExpr` (`{kind:'literal', literalType:'string'}`) — which
74
+ * a no-substitution template literal (`` `<b>bold</b>` ``) already parses
75
+ * to, so it's accepted the same as a quoted string. A template literal
76
+ * WITH a substitution, a local `const`, string concatenation, or any other
77
+ * non-literal shape all fall through to `null` — no const-folding, no
78
+ * partial evaluation. Widening this is a single-place change here,
79
+ * deliberately not attempted in v1 (#2207).
80
+ */
81
+ function staticHtmlLiteral(parsed: ParsedExpr): string | null {
82
+ if (parsed.kind !== 'object-literal') return null
83
+ if (parsed.properties.length !== 1) return null
84
+ const [prop] = parsed.properties
85
+ if (prop.shorthand) return null
86
+ if (prop.key !== '__html') return null
87
+ if (prop.value.kind !== 'literal' || prop.value.literalType !== 'string') return null
88
+ return prop.value.value as string
89
+ }
90
+
91
+ /**
92
+ * Per-adapter template-metacharacter guard for the STATIC-literal path.
93
+ * The literal is spliced into the adapter's own template source as plain
94
+ * text — safe against HTML injection (it's the developer's own compile-time
95
+ * string, same trust boundary as writing the HTML by hand), but NOT
96
+ * automatically safe against TEMPLATE-source injection: a literal
97
+ * containing `{{ … }}` (Go/Jinja/minijinja/Twig), `<% … %>` (ERB/
98
+ * Mojolicious), `{!! !!}`/`@directive`/`<?php`/`<x-…>` (Blade — the last is
99
+ * Laravel's component-tag syntax, on by default; see below), or `<: … :>`
100
+ * (Xslate) would be interpreted as a live template construct instead of
101
+ * inert text once spliced in. Mojolicious and Xslate also support
102
+ * whole-line "line code" (a line starting with `%`/`:`, possibly indented —
103
+ * `\s` covers the same whitespace class Mojo::Template/Xslate strip),
104
+ * checked with `m`. Refuses (via {@link dangerousInnerHtmlDiagnostic})
105
+ * rather than escaping — escaping a supposedly-raw-HTML literal would
106
+ * silently corrupt the developer's own markup, which is worse than a loud
107
+ * compile-time refusal.
108
+ *
109
+ * Blade's `<x-…>`/`</x-…>`: this project's `BladeBackend` constructs a
110
+ * plain `Illuminate\View\Compilers\BladeCompiler`, whose component-tag
111
+ * compiler (`$compilesComponentTags = true` by default) resolves and
112
+ * renders a live Blade component for `<x-foo>` — NOT template-substitution,
113
+ * full component-class/view resolution (Fable review, #2217). This is the
114
+ * single sharpest case of "not inert text once spliced" in the whole guard
115
+ * table, sharper than a bare `{{ }}` interpolation.
116
+ *
117
+ * Go's `html/template` context-aware escaper is a DOCUMENTED caveat, not a
118
+ * guarded case: it parses the spliced literal to pick escaping contexts for
119
+ * SUBSEQUENT `{{ }}` actions in the same template, so malformed markup (an
120
+ * unclosed `<script>`/`<style>`) can produce a template `Parse` error or
121
+ * mis-contexted escaping of later actions — output corruption, never
122
+ * injection, and no different from hand-writing the same malformed HTML
123
+ * into the template (this module's stated trust domain). The `{{` guard
124
+ * below still catches the actual injection vector (a literal containing a
125
+ * live action).
126
+ */
127
+ const TEMPLATE_METACHAR_PATTERNS: Readonly<Record<string, RegExp>> = {
128
+ blade: /\{\{|\{!!|<\?|@\w|<\/?\s*x[-:]/,
129
+ erb: /<%/,
130
+ 'go-template': /\{\{/,
131
+ jinja: /\{\{|\{%|\{#/,
132
+ minijinja: /\{\{|\{%|\{#/,
133
+ mojolicious: /<%|^\s*%/m,
134
+ twig: /\{\{|\{%|\{#/,
135
+ xslate: /<:|^\s*:/m,
136
+ }
137
+
138
+ /**
139
+ * `null` when `html` is safe to splice as-is into `adapterId`'s template
140
+ * source; otherwise a human-readable reason naming the offending adapter's
141
+ * metacharacter family (or the missing-guard case below), for the refusal
142
+ * message.
143
+ *
144
+ * FAILS CLOSED for an `adapterId` with no entry in
145
+ * {@link TEMPLATE_METACHAR_PATTERNS} (Fable review, #2217): a 9th template
146
+ * adapter added later — e.g. by following the repo's own `add-adapter`
147
+ * playbook, which will tell it to wire the same
148
+ * `resolveDangerousInnerHtml`/`dangerousInnerHtmlMetacharViolation` calls
149
+ * every existing adapter does — must not silently get an unguarded splice
150
+ * just because nobody remembered to extend this table. Refusing loudly is
151
+ * the safe default; only the 8 adapters actually verified against their
152
+ * own template compiler's syntax get to skip the refusal.
153
+ */
154
+ export function dangerousInnerHtmlMetacharViolation(html: string, adapterId: string): string | null {
155
+ const pattern = TEMPLATE_METACHAR_PATTERNS[adapterId]
156
+ if (!pattern) {
157
+ return `no template-metacharacter guard is defined for adapter '${adapterId}' — refusing rather than splicing unguarded`
158
+ }
159
+ if (!pattern.test(html)) return null
160
+ return `the literal HTML contains a sequence ${adapterId}'s own template compiler would interpret (not inert text once spliced into the template)`
161
+ }
162
+
163
+ /**
164
+ * Purpose-built `BF101` for a `dangerouslySetInnerHTML` value this adapter
165
+ * can't lower — either genuinely dynamic (not a literal) or a static
166
+ * literal that fails the metachar guard above. Named `reason` so both
167
+ * refusal paths funnel through the same message shape rather than growing
168
+ * two near-identical adapter-side error strings.
169
+ */
170
+ export function dangerousInnerHtmlDiagnostic(
171
+ expr: string,
172
+ loc: SourceLocation,
173
+ reason?: string,
174
+ ): CompilerError {
175
+ const detail = reason ? ` — ${reason}.` : ''
176
+ return {
177
+ code: 'BF101',
178
+ severity: 'error',
179
+ message: `dangerouslySetInnerHTML requires a compile-time string literal __html value on template adapters (e.g. { __html: '...' })${expr ? `: ${expr.trim()}` : ''}${detail}`,
180
+ loc,
181
+ suggestion: {
182
+ message:
183
+ 'Dynamic or signal-derived HTML for dangerouslySetInnerHTML is only supported on Hono/CSR today (tracked separately: https://github.com/piconic-ai/barefootjs/issues/2215). Use an inline string literal, or defer it to the client with /* @client */ (e.g. dangerouslySetInnerHTML={/* @client */ { __html: expr }}) so hydration sets it instead of SSR.',
184
+ },
185
+ }
186
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Every name a `.map()`/`.filter()` loop callback binds as its item or
3
+ * index parameter, anywhere in a component's IR tree (#2212 review
4
+ * finding). `isStringTypedOperand`'s identifier arm resolves a bare
5
+ * identifier against `collectStringValueNames`'s flat, scope-BLIND
6
+ * `Set<string>` — so a loop callback whose param happens to reuse a
7
+ * string prop's name (`items.map((name) => ... 1 + name ...)`, where the
8
+ * component also has a string `name` prop) would otherwise be
9
+ * misdetected as string-typed, silently lowering a NUMERIC `+` to the
10
+ * adapter's string-concat operator instead of leaving it numeric — wrong
11
+ * rendered output, not a refusal. Subtracting every loop-bound name from
12
+ * the string-name set (in each adapter's `collectStringValueNames`) is a
13
+ * coarse but SAFE mitigation: a name used as a loop param anywhere in the
14
+ * component never gets string-concat treatment anywhere in the
15
+ * component, even at a non-shadowed use site outside that loop — which
16
+ * degrades to the ALREADY-accepted residual (`+` falls back to numeric,
17
+ * same as before #2212 for an unresolvable operand) rather than ever
18
+ * producing silently-wrong output.
19
+ */
20
+
21
+ import type { ComponentIR, IRNode } from '../types.ts'
22
+
23
+ export function collectLoopBoundNames(ir: ComponentIR): Set<string> {
24
+ const names = new Set<string>()
25
+ const visit = (node: IRNode | null | undefined): void => {
26
+ if (!node) return
27
+ switch (node.type) {
28
+ case 'element':
29
+ case 'component':
30
+ case 'fragment':
31
+ case 'provider':
32
+ for (const child of node.children) visit(child)
33
+ break
34
+ case 'async':
35
+ visit(node.fallback)
36
+ for (const child of node.children) visit(child)
37
+ break
38
+ case 'loop':
39
+ names.add(node.param)
40
+ if (node.index) names.add(node.index)
41
+ // A destructured callback param (`.map(({ name }) => ...)`) binds
42
+ // its extracted names via `paramBindings`, not `param` itself
43
+ // (`param` holds the raw pattern text there) — adapters that lower
44
+ // the destructure to a `{% set name = __bf_item.name %}`-style
45
+ // local (#2087) leave `name` reachable as a bare identifier in the
46
+ // body, so it needs the same exclusion as a plain loop param.
47
+ for (const binding of node.paramBindings ?? []) names.add(binding.name)
48
+ // A `.filter(pred).map(cb)` chain's filter predicate is emitted
49
+ // through the same binary/string-name machinery, using its OWN
50
+ // param (which may differ from the map callback's `param`) before
51
+ // any rename to the loop param happens.
52
+ if (node.filterPredicate) names.add(node.filterPredicate.param)
53
+ for (const child of node.children) visit(child)
54
+ if (node.childComponent) {
55
+ for (const child of node.childComponent.children) visit(child)
56
+ }
57
+ for (const nested of node.nestedComponents ?? []) {
58
+ for (const child of nested.children) visit(child)
59
+ }
60
+ for (const frag of node.flatMapCallback?.fragments ?? []) {
61
+ visit(frag.ir)
62
+ }
63
+ break
64
+ case 'conditional':
65
+ visit(node.whenTrue)
66
+ visit(node.whenFalse)
67
+ break
68
+ case 'if-statement':
69
+ visit(node.consequent)
70
+ if (node.alternate) visit(node.alternate)
71
+ break
72
+ case 'text':
73
+ case 'expression':
74
+ case 'slot':
75
+ break
76
+ }
77
+ }
78
+ visit(ir.root)
79
+ return names
80
+ }
@@ -228,6 +228,15 @@ export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string)
228
228
  if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
229
229
  return isStringName(expr.property)
230
230
  }
231
+ // A bare identifier (#2212): a destructured prop param or a same-file
232
+ // local const, string-typed per the caller's `isStringName` set — each
233
+ // adapter's `collectStringValueNames` already tracks `propsParams`, so a
234
+ // component's own `{ a, b }: { a: string; b: string }` destructure was
235
+ // only unreachable here for lack of this arm; extending
236
+ // `collectStringValueNames` to also walk `ir.metadata.localConstants`
237
+ // (adapter-side change) closes the "two same-file string consts" shape
238
+ // the same way.
239
+ if (expr.kind === 'identifier') return isStringName(expr.name)
231
240
  if (expr.kind === 'binary' && expr.op === '+') {
232
241
  return isStringTypedOperand(expr.left, isStringName) || isStringTypedOperand(expr.right, isStringName)
233
242
  }
package/src/analyzer.ts CHANGED
@@ -50,6 +50,11 @@ export function needsTypeBasedDetection(source: string): boolean {
50
50
  if (REACTIVE_BRAND_PACKAGES.some(pkg => source.includes(pkg))) return true
51
51
  // BF023/BF024 nullable-key check needs getTypeAtLocation() on the key expression.
52
52
  if (/\.map\s*\(/.test(source)) return true
53
+ // createSelector's returned accessor is Reactive<>-branded like a library
54
+ // accessor above — a selector call outside any `.map()` (no loop in the
55
+ // file at all) would otherwise skip the TypeChecker entirely and miss the
56
+ // brand.
57
+ if (source.includes('createSelector')) return true
53
58
  return false
54
59
  }
55
60
 
@@ -1828,6 +1833,7 @@ function collectLocalDeclarations(root: ts.Node): Set<string> {
1828
1833
  // and are emitted by the compiler for 'use client' components.
1829
1834
  const CLIENT_EXPORTS = new Set([
1830
1835
  'createSignal', 'createEffect', 'createDisposableEffect', 'createMemo',
1836
+ 'createSelector',
1831
1837
  'createRoot', 'onCleanup', 'onMount', 'untrack', 'batch', 'splitProps',
1832
1838
  'forwardProps', 'unwrap', '__slot',
1833
1839
  'createContext', 'useContext', 'provideContext',
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  ParamInfo,
50
50
  PropertyInfo,
51
51
  MemoInfo,
52
+ ConstantInfo,
52
53
  TypeInfo,
53
54
  TypeDefinition,
54
55
  SourceLocation,
@@ -87,6 +88,9 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
87
88
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
88
89
  export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
89
90
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
91
+ export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
92
+ export { evaluateSignalInit, tryEvaluateSignalInit, type SignalInitEvalResult } from './signal-init-eval.ts'
93
+ export { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from './static-literal.ts'
90
94
  export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
91
95
  export type { EnvSignalReader } from './adapters/env-signal.ts'
92
96
  export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
@@ -116,6 +120,13 @@ export { emitIRNode } from './adapters/ir-node-emitter.ts'
116
120
  export type { IRNodeEmitter, EmitIRNode } from './adapters/ir-node-emitter.ts'
117
121
  export { emitAttrValue } from './adapters/attr-value-emitter.ts'
118
122
  export type { AttrValueEmitter } from './adapters/attr-value-emitter.ts'
123
+ export {
124
+ isDangerousInnerHtmlAttr,
125
+ resolveDangerousInnerHtml,
126
+ dangerousInnerHtmlMetacharViolation,
127
+ dangerousInnerHtmlDiagnostic,
128
+ } from './adapters/dangerous-inner-html.ts'
129
+ export type { DangerousInnerHtmlResolution } from './adapters/dangerous-inner-html.ts'
119
130
 
120
131
  // Client JS Generator
121
132
  export { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
@@ -272,15 +283,20 @@ export interface BuildOptions {
272
283
  * - `'treeshake'` (default) — bundle only the runtime exports this
273
284
  * project's compiled client JS actually imports, plus a small
274
285
  * always-kept public mount API (`render`, `hydrate`, etc.).
286
+ * - `'treeshake-exact'` — same collection, but without the always-kept
287
+ * set. Smaller output; a hand-written page script the CLI never
288
+ * compiles must list any runtime names it calls directly in
289
+ * `runtimeKeep`, or they're silently dropped.
275
290
  * - `'full'` — copy the entire prebuilt runtime bundle verbatim.
276
291
  * See `@barefootjs/cli`'s `runtime-treeshake.ts` for the collector and
277
292
  * `ALWAYS_KEEP_RUNTIME_EXPORTS` for the always-kept names.
278
293
  */
279
- runtimeBundle?: 'treeshake' | 'full'
294
+ runtimeBundle?: 'treeshake' | 'treeshake-exact' | 'full'
280
295
  /**
281
296
  * Extra `@barefootjs/client*` export names to force-keep in `barefoot.js`
282
- * under `runtimeBundle: 'treeshake'` — for names only ever referenced
283
- * from hand-written page scripts the CLI never compiles.
297
+ * under `runtimeBundle: 'treeshake'` or `'treeshake-exact'` — for names
298
+ * only ever referenced from hand-written page scripts the CLI never
299
+ * compiles.
284
300
  */
285
301
  runtimeKeep?: string[]
286
302
  }
@@ -6,7 +6,8 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate, computeSkeletonSlotPaths, type SkeletonSlotPaths } from './html-template.ts'
10
+ import { templateRootIsSvg } from './control-flow/stringify/template-parse.ts'
10
11
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
11
12
  import { walkIR, stopAt } from './walker.ts'
12
13
  import { buildLoopChainExpr } from '../loop-chain.ts'
@@ -385,6 +386,7 @@ export function collectInnerLoops(
385
386
  arrayFreeIdentifiers: n.arrayFreeIdentifiers,
386
387
  param: n.param,
387
388
  paramBindings: n.paramBindings,
389
+ index: n.index,
388
390
  key: n.key,
389
391
  markerId: n.markerId,
390
392
  bodyIsMultiRoot: n.bodyIsMultiRoot,
@@ -673,6 +675,7 @@ export function collectElements(
673
675
  let template = ''
674
676
  let staticItemTemplate: string | undefined
675
677
  let skeletonTemplate: string | undefined
678
+ let skeletonPaths: SkeletonSlotPaths | undefined
676
679
  if (l.childComponent) {
677
680
  template = '' // childComponent path uses createComponent directly
678
681
  // CSR materialize fallback (#1268): when the loop array references an
@@ -724,10 +727,18 @@ export function collectElements(
724
727
  // expressions, …) and returns `null` for anything it can't prove
725
728
  // safe; the plan builder (`build-loop.ts`) falls back to the
726
729
  // per-row `template` above whenever this stays `undefined`.
727
- skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], {
730
+ const skeletonSafeSlots = {
728
731
  reactiveAttrKeys: new Set(bindings.reactiveAttrs.map(a => `${a.childSlotId}::${a.attrName}`)),
729
732
  reactiveTextSlotIds: new Set(bindings.reactiveTexts.map(t => t.slotId)),
730
- }) ?? undefined
733
+ }
734
+ skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], skeletonSafeSlots) ?? undefined
735
+ // Direct child-index paths (perf, #2143): only attempted when the
736
+ // skeleton itself hoisted, and skipped for SVG roots for now (the
737
+ // `<svg>`-wrap namespace fix-up is orthogonal and untested against
738
+ // this path model — safe fallback to qsa/$t for those loops).
739
+ if (skeletonTemplate && !templateRootIsSvg(skeletonTemplate)) {
740
+ skeletonPaths = computeSkeletonSlotPaths(l.children[0], skeletonSafeSlots) ?? undefined
741
+ }
731
742
  }
732
743
  }
733
744
 
@@ -748,6 +759,7 @@ export function collectElements(
748
759
  template,
749
760
  staticItemTemplate,
750
761
  skeletonTemplate,
762
+ skeletonPaths,
751
763
  childEventHandlers: childHandlers,
752
764
  bindings,
753
765
  childComponent: l.childComponent,
@@ -58,6 +58,7 @@ import type { DepthLevel } from '../shared.ts'
58
58
  import {
59
59
  destructureLoopParam,
60
60
  loopKeyFn,
61
+ nestedLoopIndexAlias,
61
62
  } from '../shared.ts'
62
63
  import type {
63
64
  InnerLoopPlan,
@@ -114,8 +115,8 @@ export function buildInnerLoopsPlan(args: BuildInnerLoopsArgs): InnerLoopsPlan {
114
115
  const useReactive = refsParent && !!inner.template
115
116
 
116
117
  const emit: InnerLoopReactiveEmit | InnerLoopStaticEmit = useReactive
117
- ? buildReactiveEmit(inner, level, wrapOuter)
118
- : buildStaticEmit(inner, level)
118
+ ? buildReactiveEmit(inner, level, wrapOuter, uidSuffix)
119
+ : buildStaticEmit(inner, level, uidSuffix)
119
120
 
120
121
  const arrayExpr = useReactive ? wrapOuter(inner.array) : inner.array
121
122
 
@@ -153,6 +154,7 @@ function buildReactiveEmit(
153
154
  inner: NestedLoop,
154
155
  level: DepthLevel,
155
156
  wrapOuter: (expr: string) => string,
157
+ uidSuffix: string,
156
158
  ): InnerLoopReactiveEmit {
157
159
  const wrapInner = (expr: string) => wrapLoopParamAsAccessor(expr, inner.param, inner.paramBindings)
158
160
  const { head: paramHead, unwrap: paramUnwrap } = destructureLoopParam(inner.param, inner.paramBindings)
@@ -218,7 +220,14 @@ function buildReactiveEmit(
218
220
  // The destructure unwrap (when `inner.param` is a binding pattern)
219
221
  // has to land before the preamble so the preamble's bare-binding
220
222
  // references resolve.
223
+ //
224
+ // The index alias (#2218) lands first — before both the unwrap and the
225
+ // preamble — since either may reference the user's index name (e.g. a
226
+ // `const rowTag = \`row-${i}\`` preamble line), and both run before the
227
+ // cloned-template IIFE that may also reference it.
221
228
  const preludeStatements: string[] = []
229
+ const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, paramHead, level.comps, level.events)
230
+ if (indexAlias) preludeStatements.push(indexAlias)
222
231
  if (paramUnwrap) preludeStatements.push(paramUnwrap)
223
232
  if (inner.mapPreamble) preludeStatements.push(wrapInner(wrapOuter(inner.mapPreamble)))
224
233
 
@@ -240,7 +249,7 @@ function buildReactiveEmit(
240
249
  }
241
250
  }
242
251
 
243
- function buildStaticEmit(inner: NestedLoop, level: DepthLevel): InnerLoopStaticEmit {
252
+ function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string): InnerLoopStaticEmit {
244
253
  // Static `forEach` iterates with the literal item as its first param, so
245
254
  // no signal-accessor rewrite is needed — emit the preamble verbatim
246
255
  // before the component/event setup so prop getters and event handlers
@@ -248,7 +257,15 @@ function buildStaticEmit(inner: NestedLoop, level: DepthLevel): InnerLoopStaticE
248
257
  // wrapping the callback would rewrite `s.x` to `s().x` and throw at
249
258
  // runtime when the callback closes over the static inner param (#1244,
250
259
  // PR #1352 Copilot review).
251
- const preludeStatements: string[] = inner.mapPreamble ? [inner.mapPreamble] : []
260
+ //
261
+ // The index alias (#2218) lands first, same rationale as the reactive
262
+ // path: `forEach`'s second param is the synthetic `__innerIdx<uid>`, not
263
+ // the user's index name, so a preamble/prop/event that reads it needs the
264
+ // alias bound before anything else runs.
265
+ const preludeStatements: string[] = []
266
+ const indexAlias = nestedLoopIndexAlias(inner, `__innerIdx${uidSuffix}`, inner.param, level.comps, level.events)
267
+ if (indexAlias) preludeStatements.push(indexAlias)
268
+ if (inner.mapPreamble) preludeStatements.push(inner.mapPreamble)
252
269
  return {
253
270
  mode: 'static',
254
271
  rawKey: inner.key ?? null,
@@ -57,7 +57,7 @@ function wrapAttrValueExpression(value: AttrValue, wrap: (s: string) => string):
57
57
  return AttrValueOf.spread(wrap(value.expr), value.templateExpr ? wrap(value.templateExpr) : undefined)
58
58
  }
59
59
  }
60
- import { destructureLoopParam, loopKeyFn, buildCompSelector } from '../shared.ts'
60
+ import { destructureLoopParam, loopKeyFn, buildCompSelector, nestedLoopIndexAlias } from '../shared.ts'
61
61
  import { BF_HOST, BF_AT } from '@barefootjs/shared'
62
62
  import type {
63
63
  BranchChildComponentInit,
@@ -269,6 +269,17 @@ export function buildBranchInnerLoopsPlan(
269
269
  handler: wrapInner(ev.handler),
270
270
  }))
271
271
 
272
+ // Index-param gating runs against the RAW (pre-wrap) component/event
273
+ // data — wrapping only rewrites loop-param references, never the
274
+ // index name, so it doesn't affect whether the index is referenced.
275
+ const indexAlias = nestedLoopIndexAlias(
276
+ inner,
277
+ `__bidxbr_${i}`,
278
+ paramHead,
279
+ inner.childComponents ?? [],
280
+ inner.bindings.events,
281
+ )
282
+
272
283
  const reactiveTexts: BranchInnerLoopText[] = inner.bindings.reactiveTexts.map(text => ({
273
284
  slotId: text.slotId,
274
285
  wrappedExpression: wrapBoth(text.expression),
@@ -286,6 +297,7 @@ export function buildBranchInnerLoopsPlan(
286
297
  keyFn: loopKeyFn(inner),
287
298
  paramHead,
288
299
  paramUnwrap,
300
+ indexAlias,
289
301
  wrappedTemplate: inner.template!,
290
302
  wrappedKey,
291
303
  keyDepth: 1,
@@ -103,6 +103,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
103
103
  mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
104
104
  template: elem.template,
105
105
  skeletonTemplate: elem.skeletonTemplate,
106
+ skeletonPaths: elem.skeletonPaths,
106
107
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
107
108
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
108
109
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -108,6 +108,15 @@ export interface BranchInnerLoop {
108
108
  paramHead: string
109
109
  /** Body-entry unwrap statement (empty when no destructured param). */
110
110
  paramUnwrap: string
111
+ /**
112
+ * Body-entry index-alias statement (#2218), or `null` when the inner
113
+ * loop declares no index param or never references it. Binds the user's
114
+ * index name to the synthetic `__bidx<uid>` renderItem param — mirrors
115
+ * `InnerLoopReactiveEmit`'s prelude alias in `plan/inner-loop.ts`. Must
116
+ * be emitted before `paramUnwrap` and the template clone, since either
117
+ * may reference the index.
118
+ */
119
+ indexAlias: string | null
111
120
  /** Already-wrapped HTML template for one inner-loop item. */
112
121
  wrappedTemplate: string
113
122
  /** Pre-wrapped key expression for setAttribute, or null when no key. */
@@ -23,6 +23,7 @@ import type {
23
23
  TopLevelLoop,
24
24
  } from '../../types.ts'
25
25
  import type { IRLoopChildComponent } from '../../../types.ts'
26
+ import type { SkeletonSlotPaths } from '../../html-template.ts'
26
27
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
27
28
  import type { InnerLoopsPlan } from './inner-loop.ts'
28
29
 
@@ -103,6 +104,14 @@ interface PlainLoopVariant extends DynamicLoopCommon {
103
104
  * stringifier falls back to the legacy per-row `emitTemplateCloneInline`.
104
105
  */
105
106
  skeletonTemplate?: string
107
+ /**
108
+ * Compile-time child-index paths for `skeletonTemplate`'s dynamic slots
109
+ * (perf, #2143). When present, the stringifier resolves attr/text/ref
110
+ * slots via direct `.firstChild`/`.nextSibling` property chains on a
111
+ * fresh clone instead of `qsa`/`$t`, falling back to the runtime lookup
112
+ * only for hydration (`__existing`) or any slot missing from the map.
113
+ */
114
+ skeletonPaths?: SkeletonSlotPaths
106
115
  /** Resolved reactive-effects plan — null forces the single-line renderItem shape. */
107
116
  reactiveEffects: ReactiveEffectsPlan | null
108
117
  /**