@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.
- package/dist/adapters/dangerous-inner-html.d.ts +77 -0
- package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts +22 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -0
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +977 -273
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +45 -3
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +23 -0
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +42 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
- package/dist/ir-to-client-js/types.d.ts +21 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/signal-init-eval.d.ts +82 -0
- package/dist/signal-init-eval.d.ts.map +1 -0
- package/dist/static-literal.d.ts +45 -0
- package/dist/static-literal.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
- package/src/__tests__/create-selector.test.ts +110 -0
- package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
- package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
- package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
- package/src/__tests__/loop-bound-names.test.ts +102 -0
- package/src/__tests__/loop-plan-classification.test.ts +2 -0
- package/src/__tests__/map-function-reference.test.ts +303 -0
- package/src/__tests__/nested-loop-index-param.test.ts +285 -0
- package/src/__tests__/signal-init-eval.test.ts +138 -0
- package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
- package/src/__tests__/static-literal.test.ts +126 -0
- package/src/__tests__/string-concat-identifier.test.ts +55 -0
- package/src/adapters/dangerous-inner-html.ts +186 -0
- package/src/adapters/loop-bound-names.ts +80 -0
- package/src/adapters/parsed-expr-emitter.ts +9 -0
- package/src/analyzer.ts +6 -0
- package/src/index.ts +19 -3
- package/src/ir-to-client-js/collect-elements.ts +15 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/shared.ts +103 -6
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
- package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
- package/src/ir-to-client-js/csr-substitute.ts +53 -0
- package/src/ir-to-client-js/html-template.ts +235 -2
- package/src/ir-to-client-js/imports.ts +1 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
- package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
- package/src/ir-to-client-js/types.ts +21 -0
- package/src/jsx-to-ir.ts +230 -39
- package/src/signal-init-eval.ts +165 -0
- 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
|
|
283
|
-
* from hand-written page scripts the CLI never
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
/**
|