@barefootjs/jsx 0.18.5 → 0.19.0
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/expression-parser.d.ts +8 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1002 -284
- 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/phases/props-extraction.d.ts +0 -1
- package/dist/ir-to-client-js/phases/props-extraction.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__/client-js-generation.test.ts +40 -0
- 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__/props-destructuring.test.ts +61 -8
- 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 +36 -22
- package/src/expression-parser.ts +33 -0
- package/src/index.ts +20 -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/phases/props-extraction.ts +5 -9
- 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,126 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { parseExpression } from '../expression-parser'
|
|
3
|
+
import { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from '../static-literal'
|
|
4
|
+
import type { ConstantInfo } from '../types'
|
|
5
|
+
|
|
6
|
+
// #2208: a function-scope local const whose initializer is fully known at
|
|
7
|
+
// compile time (no prop/signal/function-call dependency) should bind as a
|
|
8
|
+
// loop source the same way a module-scope const already does.
|
|
9
|
+
describe('evaluateStaticLiteral (#2208)', () => {
|
|
10
|
+
test('scalars', () => {
|
|
11
|
+
expect(evaluateStaticLiteral(parseExpression("'Alpha'"))).toEqual({ value: 'Alpha' })
|
|
12
|
+
expect(evaluateStaticLiteral(parseExpression('42'))).toEqual({ value: 42 })
|
|
13
|
+
expect(evaluateStaticLiteral(parseExpression('true'))).toEqual({ value: true })
|
|
14
|
+
expect(evaluateStaticLiteral(parseExpression('null'))).toEqual({ value: null })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('a no-substitution template literal', () => {
|
|
18
|
+
expect(evaluateStaticLiteral(parseExpression('`hello`'))).toEqual({ value: 'hello' })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('a template literal whose substitutions are all static', () => {
|
|
22
|
+
expect(evaluateStaticLiteral(parseExpression("`a${1}b${'c'}`"))).toEqual({ value: 'a1bc' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('a template literal with a non-static substitution is not static', () => {
|
|
26
|
+
expect(evaluateStaticLiteral(parseExpression('`a${x}`'))).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('nested array and object literals', () => {
|
|
30
|
+
expect(evaluateStaticLiteral(parseExpression("[{ label: 'Alpha' }, { label: 'Beta' }]"))).toEqual({
|
|
31
|
+
value: [{ label: 'Alpha' }, { label: 'Beta' }],
|
|
32
|
+
})
|
|
33
|
+
expect(evaluateStaticLiteral(parseExpression("({ a: [1, 2, { b: 'c' }] })"))).toEqual({
|
|
34
|
+
value: { a: [1, 2, { b: 'c' }] },
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('unary minus/plus/not over a static operand', () => {
|
|
39
|
+
expect(evaluateStaticLiteral(parseExpression('-5'))).toEqual({ value: -5 })
|
|
40
|
+
expect(evaluateStaticLiteral(parseExpression('+5'))).toEqual({ value: 5 })
|
|
41
|
+
expect(evaluateStaticLiteral(parseExpression('!true'))).toEqual({ value: false })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an empty array literal is static', () => {
|
|
45
|
+
expect(evaluateStaticLiteral(parseExpression('[]'))).toEqual({ value: [] })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('object shorthand property is not static without bindings', () => {
|
|
49
|
+
// `{ label }` parses `label`'s value as an `identifier` — only
|
|
50
|
+
// resolvable when a caller supplies bindings for it.
|
|
51
|
+
expect(evaluateStaticLiteral(parseExpression('({ label })'))).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a call expression is not static', () => {
|
|
55
|
+
expect(evaluateStaticLiteral(parseExpression("Object.entries({ a: 1 })"))).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('a props member access is not static', () => {
|
|
59
|
+
expect(evaluateStaticLiteral(parseExpression('props.tags'))).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a bare identifier resolves only through bindings', () => {
|
|
63
|
+
const expr = parseExpression('item')
|
|
64
|
+
expect(evaluateStaticLiteral(expr)).toBeNull()
|
|
65
|
+
expect(evaluateStaticLiteral(expr, new Map([['item', 'Alpha']]))).toEqual({ value: 'Alpha' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a member access resolves against a bound object', () => {
|
|
69
|
+
const expr = parseExpression('item.label')
|
|
70
|
+
expect(evaluateStaticLiteral(expr, new Map([['item', { label: 'Alpha' }]]))).toEqual({ value: 'Alpha' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('isFullyStaticLiteral mirrors evaluateStaticLiteral', () => {
|
|
74
|
+
expect(isFullyStaticLiteral(parseExpression("[{ label: 'Alpha' }]"))).toBe(true)
|
|
75
|
+
expect(isFullyStaticLiteral(parseExpression('props.tags'))).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('resolveStaticLoopSource (#2208)', () => {
|
|
80
|
+
function constant(overrides: Partial<ConstantInfo>): ConstantInfo {
|
|
81
|
+
return {
|
|
82
|
+
name: 'items',
|
|
83
|
+
declarationKind: 'const',
|
|
84
|
+
type: null,
|
|
85
|
+
loc: { line: 0, column: 0 },
|
|
86
|
+
...overrides,
|
|
87
|
+
} as ConstantInfo
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('an inline array-literal loop source resolves directly (no named local needed)', () => {
|
|
91
|
+
const result = resolveStaticLoopSource(parseExpression("[{ label: 'Alpha' }, { label: 'Beta' }]"), [])
|
|
92
|
+
expect(result).toEqual([{ label: 'Alpha' }, { label: 'Beta' }])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a bare identifier resolves against a FUNCTION-scope local const with a static initializer', () => {
|
|
96
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]") })]
|
|
97
|
+
expect(resolveStaticLoopSource(parseExpression('items'), locals)).toEqual([{ label: 'Alpha' }])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('a MODULE-scope const is excluded (handled by the existing seeding path instead)', () => {
|
|
101
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]"), isModule: true })]
|
|
102
|
+
expect(resolveStaticLoopSource(parseExpression('items'), locals)).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a runtime-computed local const (#2069) still refuses', () => {
|
|
106
|
+
const locals = [constant({ name: 'entries', parsed: parseExpression('Object.entries(props.tags)') })]
|
|
107
|
+
expect(resolveStaticLoopSource(parseExpression('entries'), locals)).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('an identifier not naming any local const refuses', () => {
|
|
111
|
+
expect(resolveStaticLoopSource(parseExpression('items'), [])).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a shadowed name is excluded via isNameShadowed', () => {
|
|
115
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]") })]
|
|
116
|
+
const result = resolveStaticLoopSource(parseExpression('items'), locals, {
|
|
117
|
+
isNameShadowed: name => name === 'items',
|
|
118
|
+
})
|
|
119
|
+
expect(result).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a static value that is not an array refuses', () => {
|
|
123
|
+
const locals = [constant({ name: 'obj', parsed: parseExpression("{ a: 1 }") })]
|
|
124
|
+
expect(resolveStaticLoopSource(parseExpression('obj'), locals)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { isStringTypedOperand, isStringConcatBinary } from '../adapters/parsed-expr-emitter'
|
|
3
|
+
import { parseExpression } from '../expression-parser'
|
|
4
|
+
|
|
5
|
+
// #2212: `isStringTypedOperand` gained a bare-`identifier` arm — previously
|
|
6
|
+
// `a + b` where BOTH operands are plain identifiers (destructured props,
|
|
7
|
+
// same-file consts) fell through every existing arm (literal, template
|
|
8
|
+
// literal, zero-arg getter, `props.x` member) and was treated as numeric
|
|
9
|
+
// `+`, even when both operands are string-typed. Twig/Blade/Mojolicious/
|
|
10
|
+
// Xslate route a detected string-concat through their own concat operator
|
|
11
|
+
// instead of native `+` (numeric-only on all four); this pins the shared
|
|
12
|
+
// detection function itself, independent of any one adapter's emitter.
|
|
13
|
+
describe('isStringTypedOperand identifier arm (#2212)', () => {
|
|
14
|
+
const isStr = (n: string) => n === 'first' || n === 'last'
|
|
15
|
+
|
|
16
|
+
test('a bare identifier known to be string-typed is detected', () => {
|
|
17
|
+
const expr = parseExpression('first')
|
|
18
|
+
expect(isStringTypedOperand(expr, isStr)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('a bare identifier NOT in the known-string set is not detected', () => {
|
|
22
|
+
const expr = parseExpression('count')
|
|
23
|
+
expect(isStringTypedOperand(expr, isStr)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('isStringConcatBinary(+) is true when only ONE identifier operand is string-typed', () => {
|
|
27
|
+
const parsed = parseExpression('first + count')
|
|
28
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
29
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('isStringConcatBinary(+) is false when NEITHER identifier operand is known string-typed', () => {
|
|
33
|
+
const parsed = parseExpression('count + total')
|
|
34
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
35
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('two bare-identifier string operands: first + last', () => {
|
|
39
|
+
const parsed = parseExpression('first + last')
|
|
40
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
41
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a nested + chain of bare identifiers still resolves via the existing binary-recursion arm', () => {
|
|
45
|
+
const parsed = parseExpression('first + count + last')
|
|
46
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
47
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a non-+ binary op is never treated as string concat regardless of operand names', () => {
|
|
51
|
+
const parsed = parseExpression('first - last')
|
|
52
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
53
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -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',
|
|
@@ -3034,14 +3040,16 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
|
|
|
3034
3040
|
element.propertyName && ts.isIdentifier(element.propertyName)
|
|
3035
3041
|
? element.propertyName.text
|
|
3036
3042
|
: localName
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3043
|
+
const member = memberTypes?.get(sourcePropName)
|
|
3044
|
+
const resolvedType: TypeInfo = member?.type ?? { kind: 'unknown', raw: 'unknown' }
|
|
3039
3045
|
|
|
3040
3046
|
const defaultContainsArrow = element.initializer ? nodeContainsArrow(element.initializer) : false
|
|
3041
3047
|
ctx.propsParams.push({
|
|
3042
3048
|
name: localName,
|
|
3043
3049
|
type: resolvedType,
|
|
3044
|
-
|
|
3050
|
+
// The type's `?` and a destructure default (`{ x = 1 }`) both mean
|
|
3051
|
+
// the caller may omit the prop.
|
|
3052
|
+
optional: !!member?.optional || !!element.initializer,
|
|
3045
3053
|
defaultValue,
|
|
3046
3054
|
defaultContainsArrow: defaultContainsArrow || undefined,
|
|
3047
3055
|
})
|
|
@@ -3124,18 +3132,21 @@ function collectKeysFromMembers(
|
|
|
3124
3132
|
}
|
|
3125
3133
|
|
|
3126
3134
|
/**
|
|
3127
|
-
* Build a property-name ->
|
|
3128
|
-
* for destructured params (`{ value }: Props`) so they carry the
|
|
3129
|
-
*
|
|
3130
|
-
*
|
|
3135
|
+
* Build a property-name -> { type, optional } map from a props type
|
|
3136
|
+
* annotation, for destructured params (`{ value }: Props`) so they carry the
|
|
3137
|
+
* same per-prop TypeInfo and optionality the props-object path
|
|
3138
|
+
* (`props: Props`) already resolves (#2150, #2259). Returns null for
|
|
3139
|
+
* external/unresolvable types.
|
|
3131
3140
|
*
|
|
3132
|
-
* Scope
|
|
3133
|
-
* -
|
|
3134
|
-
*
|
|
3135
|
-
*
|
|
3136
|
-
*
|
|
3137
|
-
* -
|
|
3138
|
-
*
|
|
3141
|
+
* Scope:
|
|
3142
|
+
* - Optionality is collected for EVERY member — it feeds the type's `?` into
|
|
3143
|
+
* `propsParams[].optional` alongside a destructure default. Optional
|
|
3144
|
+
* members resolve like required ones: typed adapters no longer need them
|
|
3145
|
+
* degraded to `unknown` -> `interface{}` for attribute omission, because
|
|
3146
|
+
* #2252's nullish-flip machinery supplies the nillable representation
|
|
3147
|
+
* exactly where absence is semantically observable (#2259).
|
|
3148
|
+
* - Only PRIMITIVE members (string/number/boolean) resolve a type. Those are
|
|
3149
|
+
* the types that otherwise produce an unchecked scalar assertion (`in.X.(int)`)
|
|
3139
3150
|
* that panics. Arrays/objects/functions are left as `unknown` because typed
|
|
3140
3151
|
* adapters lower them through interface{}-based helpers (`bf_flat`, spread,
|
|
3141
3152
|
* `bf_json`); giving them concrete types would break that lowering and is a
|
|
@@ -3144,19 +3155,22 @@ function collectKeysFromMembers(
|
|
|
3144
3155
|
function collectMemberTypes(
|
|
3145
3156
|
typeNode: ts.TypeNode,
|
|
3146
3157
|
ctx: AnalyzerContext
|
|
3147
|
-
): Map<string, TypeInfo> | null {
|
|
3158
|
+
): Map<string, { type: TypeInfo | null; optional: boolean }> | null {
|
|
3148
3159
|
const isResolvablePrimitive = (info: TypeInfo): boolean =>
|
|
3149
3160
|
info.kind === 'primitive' &&
|
|
3150
3161
|
(info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean')
|
|
3151
3162
|
|
|
3152
|
-
const fromMembers = (
|
|
3153
|
-
|
|
3163
|
+
const fromMembers = (
|
|
3164
|
+
members: ts.NodeArray<ts.TypeElement>
|
|
3165
|
+
): Map<string, { type: TypeInfo | null; optional: boolean }> => {
|
|
3166
|
+
const map = new Map<string, { type: TypeInfo | null; optional: boolean }>()
|
|
3154
3167
|
for (const member of members) {
|
|
3155
|
-
if (ts.isPropertySignature(member) && member.name
|
|
3156
|
-
const info = typeNodeToTypeInfo(member.type, ctx.sourceFile)
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3168
|
+
if (ts.isPropertySignature(member) && member.name) {
|
|
3169
|
+
const info = member.type ? typeNodeToTypeInfo(member.type, ctx.sourceFile) : null
|
|
3170
|
+
map.set(member.name.getText(ctx.sourceFile), {
|
|
3171
|
+
type: info && isResolvablePrimitive(info) ? info : null,
|
|
3172
|
+
optional: !!member.questionToken,
|
|
3173
|
+
})
|
|
3160
3174
|
}
|
|
3161
3175
|
}
|
|
3162
3176
|
return map
|
package/src/expression-parser.ts
CHANGED
|
@@ -153,6 +153,39 @@ export type ParsedExpr =
|
|
|
153
153
|
}
|
|
154
154
|
| { kind: 'unsupported'; raw: string; reason: string }
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Runtime registry of every `ParsedExpr` kind — the denominator for
|
|
158
|
+
* coverage bookkeeping (`spec/subset-conformance.md`): a conformance
|
|
159
|
+
* fixture's exercised-kind set is computed against this list, and the
|
|
160
|
+
* ledger-floor meta-test requires each entry to be exercised or
|
|
161
|
+
* explicitly excluded.
|
|
162
|
+
*/
|
|
163
|
+
export const PARSED_EXPR_KINDS = [
|
|
164
|
+
'identifier',
|
|
165
|
+
'literal',
|
|
166
|
+
'call',
|
|
167
|
+
'member',
|
|
168
|
+
'index-access',
|
|
169
|
+
'binary',
|
|
170
|
+
'unary',
|
|
171
|
+
'conditional',
|
|
172
|
+
'logical',
|
|
173
|
+
'template-literal',
|
|
174
|
+
'arrow',
|
|
175
|
+
'regex',
|
|
176
|
+
'array-literal',
|
|
177
|
+
'object-literal',
|
|
178
|
+
'array-method',
|
|
179
|
+
'unsupported',
|
|
180
|
+
] as const satisfies ReadonlyArray<ParsedExpr['kind']>
|
|
181
|
+
|
|
182
|
+
// Exhaustiveness pin: adding a `ParsedExpr` kind without listing it in
|
|
183
|
+
// `PARSED_EXPR_KINDS` fails to compile here (the same drift defence the
|
|
184
|
+
// exhaustive adapter switches provide for behaviour).
|
|
185
|
+
type MissingFromKindRegistry = Exclude<ParsedExpr['kind'], (typeof PARSED_EXPR_KINDS)[number]>
|
|
186
|
+
const _kindRegistryIsExhaustive: MissingFromKindRegistry extends never ? true : never = true
|
|
187
|
+
void _kindRegistryIsExhaustive
|
|
188
|
+
|
|
156
189
|
/**
|
|
157
190
|
* One property of an `object-literal` `ParsedExpr`. The key is the
|
|
158
191
|
* resolved (non-computed) property name — for `{ a: 1 }` and shorthand
|