micra.js 1.0.0 → 1.1.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/README.md +2 -2
- package/dist/dom/events.d.ts +9 -4
- package/dist/micra.cjs.js +192 -47
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +192 -47
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +192 -47
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +24 -3
- package/dist/utils/expr.d.ts +12 -1
- package/package.json +2 -2
- package/src/core/bus.ts +4 -1
- package/src/core/mount.ts +54 -3
- package/src/dom/directives.ts +67 -24
- package/src/dom/each.ts +10 -1
- package/src/dom/events.ts +49 -19
- package/src/types.ts +26 -3
- package/src/utils/expr.ts +119 -7
package/src/dom/directives.ts
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import type {
|
|
17
17
|
CachedBinding,
|
|
18
|
+
CachedPairBinding,
|
|
18
19
|
DirectiveCache,
|
|
19
20
|
InternalInstance,
|
|
20
21
|
MicraElement,
|
|
22
|
+
MicraTemplate,
|
|
21
23
|
StateRecord,
|
|
22
24
|
} from '../types'
|
|
23
25
|
import { evalExpr, warn } from '../utils/expr'
|
|
@@ -31,6 +33,13 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
|
31
33
|
if (el.textContent !== text) el.textContent = text
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* data-html — writes the expression value as innerHTML.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ XSS WARNING: the value is rendered as raw HTML. Never bind untrusted
|
|
40
|
+
* input here — use `data-text` (textContent) instead. See docs/directives.md
|
|
41
|
+
* for the full security model.
|
|
42
|
+
*/
|
|
34
43
|
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
35
44
|
el.innerHTML = String(evalExpr(expr, state) ?? '')
|
|
36
45
|
}
|
|
@@ -39,13 +48,13 @@ function applyIf(el: Element, expr: string, state: StateRecord): void {
|
|
|
39
48
|
(el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
function applyBind(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const val
|
|
51
|
+
function applyBind(
|
|
52
|
+
el: Element,
|
|
53
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
54
|
+
state: StateRecord,
|
|
55
|
+
): void {
|
|
56
|
+
for (const [attr, valExpr] of pairs) {
|
|
57
|
+
const val = evalExpr(valExpr, state)
|
|
49
58
|
|
|
50
59
|
if (attr === 'class') {
|
|
51
60
|
(el as HTMLElement).className = String(val ?? '')
|
|
@@ -76,26 +85,42 @@ function applyBind(el: Element, expr: string, state: StateRecord): void {
|
|
|
76
85
|
* @example
|
|
77
86
|
* <div data-class="active:tab === 'home', hidden:!loaded">
|
|
78
87
|
*/
|
|
79
|
-
function applyClass(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (!cls) continue
|
|
88
|
+
function applyClass(
|
|
89
|
+
el: Element,
|
|
90
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
91
|
+
state: StateRecord,
|
|
92
|
+
): void {
|
|
93
|
+
for (const [cls, valExpr] of pairs) {
|
|
86
94
|
el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/** @internal Parse a comma+colon spec like `href:url, disabled:loading` once. */
|
|
99
|
+
function parsePairs(expr: string): Array<readonly [string, string]> {
|
|
100
|
+
const out: Array<readonly [string, string]> = []
|
|
101
|
+
for (const part of expr.split(',')) {
|
|
102
|
+
const colonIdx = part.indexOf(':')
|
|
103
|
+
if (colonIdx === -1) continue
|
|
104
|
+
const left = part.slice(0, colonIdx).trim()
|
|
105
|
+
const right = part.slice(colonIdx + 1).trim()
|
|
106
|
+
if (!left) continue
|
|
107
|
+
out.push([left, right])
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
function applyModel(
|
|
91
113
|
el: Element,
|
|
92
114
|
key: string,
|
|
93
115
|
rawState: StateRecord,
|
|
94
116
|
): void {
|
|
95
117
|
const html = el as HTMLInputElement
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
const stateVal = rawState[key]
|
|
119
|
+
const desired = stateVal == null ? '' : String(stateVal)
|
|
120
|
+
// Only write when out of sync. This is a no-op during live typing (the input
|
|
121
|
+
// event already drove state to match el.value) but still propagates
|
|
122
|
+
// programmatic resets such as `this.state.q = ''` on focused inputs.
|
|
123
|
+
if (html.value !== desired) html.value = desired
|
|
99
124
|
// listener is attached separately in events.ts — this only syncs the value
|
|
100
125
|
}
|
|
101
126
|
|
|
@@ -111,14 +136,16 @@ function buildCache(root: Element): DirectiveCache {
|
|
|
111
136
|
.filter(el => !el.closest('template'))
|
|
112
137
|
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
113
138
|
}
|
|
139
|
+
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
140
|
+
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
114
141
|
return {
|
|
115
142
|
text: pick('data-text'),
|
|
116
143
|
html: pick('data-html'),
|
|
117
144
|
if: pick('data-if'),
|
|
118
145
|
show: pick('data-show'),
|
|
119
|
-
bind:
|
|
146
|
+
bind: pickPairs('data-bind'),
|
|
120
147
|
model: pick('data-model'),
|
|
121
|
-
class:
|
|
148
|
+
class: pickPairs('data-class'),
|
|
122
149
|
}
|
|
123
150
|
}
|
|
124
151
|
|
|
@@ -165,9 +192,9 @@ function applyFromList(
|
|
|
165
192
|
cache.html.forEach(b => applyHtml(b.el, b.expr, state))
|
|
166
193
|
cache.if.forEach(b => applyIf(b.el, b.expr, state))
|
|
167
194
|
cache.show.forEach(b => applyIf(b.el, b.expr, state))
|
|
168
|
-
cache.bind.forEach(b => applyBind(b.el, b.
|
|
195
|
+
cache.bind.forEach(b => applyBind(b.el, b.pairs, state))
|
|
169
196
|
cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
|
|
170
|
-
cache.class.forEach(b => applyClass(b.el, b.
|
|
197
|
+
cache.class.forEach(b => applyClass(b.el, b.pairs, state))
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
/** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
|
|
@@ -176,14 +203,16 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
|
176
203
|
queryAll(frag, `[${attr}]`)
|
|
177
204
|
.filter(el => !el.closest('template'))
|
|
178
205
|
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
206
|
+
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
207
|
+
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
179
208
|
return {
|
|
180
209
|
text: pick('data-text'),
|
|
181
210
|
html: pick('data-html'),
|
|
182
211
|
if: pick('data-if'),
|
|
183
212
|
show: pick('data-show'),
|
|
184
|
-
bind:
|
|
213
|
+
bind: pickPairs('data-bind'),
|
|
185
214
|
model: pick('data-model'),
|
|
186
|
-
class:
|
|
215
|
+
class: pickPairs('data-class'),
|
|
187
216
|
}
|
|
188
217
|
}
|
|
189
218
|
|
|
@@ -197,10 +226,24 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
|
197
226
|
*/
|
|
198
227
|
export function validateDirectives(root: Element): void {
|
|
199
228
|
queryOwn(root, 'data-each').forEach(el => {
|
|
200
|
-
|
|
229
|
+
const tmpl = el as MicraTemplate
|
|
230
|
+
if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
|
|
231
|
+
tmpl.__micraNoKeyWarned = true
|
|
201
232
|
warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
|
|
202
233
|
}
|
|
203
234
|
})
|
|
235
|
+
|
|
236
|
+
// data-bind="class:..." replaces className wholesale, which fights with
|
|
237
|
+
// data-class on the same element. Warn so the developer picks one.
|
|
238
|
+
const bindEls = queryOwn(root, 'data-bind')
|
|
239
|
+
if ((root as HTMLElement).hasAttribute?.('data-bind') && !bindEls.includes(root)) bindEls.unshift(root)
|
|
240
|
+
for (const el of bindEls) {
|
|
241
|
+
const spec = el.getAttribute('data-bind') ?? ''
|
|
242
|
+
const hasClassBind = spec.split(',').some(p => p.trim().split(':')[0]?.trim() === 'class')
|
|
243
|
+
if (hasClassBind && el.hasAttribute('data-class')) {
|
|
244
|
+
warn(`element has both data-bind="class:..." and data-class — they fight on every render. Use one.`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
204
247
|
}
|
|
205
248
|
|
|
206
249
|
// Re-export warn for use in other modules
|
package/src/dom/each.ts
CHANGED
|
@@ -86,10 +86,19 @@ function renderKeyed<S extends StateRecord>(
|
|
|
86
86
|
): void {
|
|
87
87
|
const nextKeys = new Set<unknown>()
|
|
88
88
|
const nextNodes: MicraElement[] = []
|
|
89
|
+
let warnedNullKey = false
|
|
90
|
+
let warnedDupKey = false
|
|
89
91
|
|
|
90
92
|
for (const [index, item] of items.entries()) {
|
|
91
93
|
const key = item[keyAttr]
|
|
92
|
-
if (key == null
|
|
94
|
+
if (key == null && !warnedNullKey) {
|
|
95
|
+
warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
|
|
96
|
+
warnedNullKey = true
|
|
97
|
+
}
|
|
98
|
+
if (nextKeys.has(key) && !warnedDupKey) {
|
|
99
|
+
warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} — rows will collide`)
|
|
100
|
+
warnedDupKey = true
|
|
101
|
+
}
|
|
93
102
|
nextKeys.add(key)
|
|
94
103
|
|
|
95
104
|
let node = keyMap.get(key) as MicraElement | undefined
|
package/src/dom/events.ts
CHANGED
|
@@ -3,16 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - Bind `data-on="event:method"` listeners (once per element)
|
|
6
|
-
* - Bind `@event="method"` shorthand (
|
|
6
|
+
* - Bind `@event="method"` shorthand (once per element)
|
|
7
|
+
* - Bind `data-model` two-way input listeners (once per element)
|
|
7
8
|
*
|
|
8
|
-
* LLM NOTE:
|
|
9
|
-
*
|
|
9
|
+
* LLM NOTE: Every listener attached here is also recorded in
|
|
10
|
+
* instance.__micraListeners so destroy() can remove it cleanly.
|
|
11
|
+
* Re-render skips already-bound elements via per-element __micra* flags.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import type { InternalInstance, MicraElement, StateRecord } from '../types'
|
|
13
|
-
import {
|
|
15
|
+
import { warn } from '../utils/expr'
|
|
14
16
|
import { queryOwn, queryAll } from './query'
|
|
15
17
|
|
|
18
|
+
/** @internal Attach a DOM listener and track it on the instance for destroy(). */
|
|
19
|
+
function track<S extends StateRecord>(
|
|
20
|
+
instance: InternalInstance<S>,
|
|
21
|
+
el: Element,
|
|
22
|
+
type: string,
|
|
23
|
+
fn: EventListener,
|
|
24
|
+
): void {
|
|
25
|
+
el.addEventListener(type, fn)
|
|
26
|
+
;(instance.__micraListeners ??= []).push({ el, type, fn })
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
// ── data-on ───────────────────────────────────────────────────────────────────
|
|
17
30
|
|
|
18
31
|
/**
|
|
@@ -50,7 +63,7 @@ export function bindDataOn<S extends StateRecord>(
|
|
|
50
63
|
|
|
51
64
|
const [evName, ...mods] = evSpec.split('.')
|
|
52
65
|
|
|
53
|
-
el
|
|
66
|
+
track(instance, el, evName!, (e: Event) => {
|
|
54
67
|
if (mods.includes('prevent')) e.preventDefault()
|
|
55
68
|
if (mods.includes('stop')) e.stopPropagation()
|
|
56
69
|
if (mods.includes('self') && e.target !== el) return
|
|
@@ -67,7 +80,7 @@ export function bindDataOn<S extends StateRecord>(
|
|
|
67
80
|
|
|
68
81
|
/**
|
|
69
82
|
* Bind `@event="method"` shorthand attributes (Stimulus-style).
|
|
70
|
-
*
|
|
83
|
+
* Bound once per element via `__micraAtBound` — re-renders are no-ops.
|
|
71
84
|
* Supports the same modifiers as data-on: `@click.prevent="submit"`.
|
|
72
85
|
*
|
|
73
86
|
* @example
|
|
@@ -78,18 +91,25 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
78
91
|
root: Element,
|
|
79
92
|
instance: InternalInstance<S>,
|
|
80
93
|
): void {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
const isFragment = root.nodeType === 11
|
|
95
|
+
const all = isFragment
|
|
96
|
+
? queryAll(root as unknown as ParentNode, '*')
|
|
97
|
+
: queryAll(root, '*')
|
|
98
|
+
|
|
99
|
+
// Include root itself for the regular-element case
|
|
100
|
+
if (!isFragment && !all.includes(root)) all.unshift(root)
|
|
84
101
|
|
|
85
|
-
const all = queryAll(root, '*')
|
|
86
102
|
for (const el of all) {
|
|
103
|
+
const mEl = el as MicraElement
|
|
104
|
+
if (mEl.__micraAtBound) continue
|
|
105
|
+
|
|
106
|
+
let bound = false
|
|
87
107
|
for (const attr of Array.from(el.attributes)) {
|
|
88
108
|
if (!attr.name.startsWith('@')) continue
|
|
89
109
|
const [evSpec, ...rest] = attr.name.slice(1).split('.')
|
|
90
110
|
const method = attr.value.trim()
|
|
91
111
|
|
|
92
|
-
el
|
|
112
|
+
track(instance, el, evSpec!, (e: Event) => {
|
|
93
113
|
if (rest.includes('prevent')) e.preventDefault()
|
|
94
114
|
if (rest.includes('stop')) e.stopPropagation()
|
|
95
115
|
if (rest.includes('self') && e.target !== el) return
|
|
@@ -98,7 +118,9 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
98
118
|
if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
|
|
99
119
|
else warn(`method "${method}" not found`)
|
|
100
120
|
})
|
|
121
|
+
bound = true
|
|
101
122
|
}
|
|
123
|
+
if (bound) mEl.__micraAtBound = true
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
@@ -108,6 +130,9 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
108
130
|
* Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
|
|
109
131
|
* to `state[key]`. Binding is attached once per element.
|
|
110
132
|
*
|
|
133
|
+
* Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
|
|
134
|
+
* Checkbox inputs write booleans. Everything else writes strings.
|
|
135
|
+
*
|
|
111
136
|
* @example
|
|
112
137
|
* <input data-model="search"> // updates state.search on every keystroke
|
|
113
138
|
* <select data-model="sortBy"> // updates state.sortBy on change
|
|
@@ -128,18 +153,23 @@ export function bindModels<S extends StateRecord>(
|
|
|
128
153
|
|
|
129
154
|
const key = (el as HTMLInputElement).dataset['model'] ?? ''
|
|
130
155
|
const tag = el.tagName
|
|
156
|
+
const inputEl = el as HTMLInputElement
|
|
157
|
+
const inputType = inputEl.type
|
|
131
158
|
|
|
132
159
|
const update = () => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
let val: unknown
|
|
161
|
+
if (tag === 'INPUT' && inputType === 'checkbox') {
|
|
162
|
+
val = inputEl.checked
|
|
163
|
+
} else if (tag === 'INPUT' && (inputType === 'number' || inputType === 'range')) {
|
|
164
|
+
// Empty string → NaN; preserve raw empty as null so state stays "unfilled"
|
|
165
|
+
val = inputEl.value === '' ? null : inputEl.valueAsNumber
|
|
166
|
+
} else {
|
|
167
|
+
val = inputEl.value
|
|
168
|
+
}
|
|
136
169
|
;(instance.state as StateRecord)[key] = val
|
|
137
170
|
}
|
|
138
171
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: 'input',
|
|
142
|
-
update,
|
|
143
|
-
)
|
|
172
|
+
const evType = tag === 'SELECT' || inputType === 'radio' ? 'change' : 'input'
|
|
173
|
+
track(instance, el, evType, update)
|
|
144
174
|
}
|
|
145
175
|
}
|
package/src/types.ts
CHANGED
|
@@ -105,12 +105,21 @@ export type ComponentDefinition<S extends StateRecord = StateRecord> = {
|
|
|
105
105
|
export interface MicraElement extends HTMLElement {
|
|
106
106
|
__micraModel?: true // data-model listener bound
|
|
107
107
|
__micraEvents?: true // data-on listeners bound
|
|
108
|
-
|
|
108
|
+
__micraAtBound?: true // @event shorthand bound (per-element)
|
|
109
109
|
__micraKey?: unknown // keyed-diff key
|
|
110
110
|
__micraEach?: true // belongs to a no-key each list
|
|
111
111
|
__micraCache?: DirectiveCache // cached directive scan result
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* @internal A DOM listener tracked for cleanup on destroy().
|
|
116
|
+
*/
|
|
117
|
+
export interface TrackedListener {
|
|
118
|
+
el: Element
|
|
119
|
+
type: string
|
|
120
|
+
fn: EventListener
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
/**
|
|
115
124
|
* @internal Extended HTMLTemplateElement with keyed-diff state.
|
|
116
125
|
*/
|
|
@@ -118,6 +127,7 @@ export interface MicraTemplate extends HTMLTemplateElement {
|
|
|
118
127
|
__micraMarker?: Comment
|
|
119
128
|
__micraNodes: Map<unknown, MicraElement>
|
|
120
129
|
__micraList: ChildNode[]
|
|
130
|
+
__micraNoKeyWarned?: true
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
/**
|
|
@@ -128,6 +138,17 @@ export interface CachedBinding {
|
|
|
128
138
|
expr: string
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @internal Per-element directive binding with pre-parsed pairs.
|
|
143
|
+
* Used by `data-bind` and `data-class` — both share the
|
|
144
|
+
* `name:expression[, name:expression…]` syntax.
|
|
145
|
+
*/
|
|
146
|
+
export interface CachedPairBinding {
|
|
147
|
+
el: Element
|
|
148
|
+
expr: string
|
|
149
|
+
pairs: ReadonlyArray<readonly [string, string]>
|
|
150
|
+
}
|
|
151
|
+
|
|
131
152
|
/**
|
|
132
153
|
* @internal Directive scan result — built once per Element, reused every render.
|
|
133
154
|
* This is the core of the performance optimization.
|
|
@@ -140,9 +161,9 @@ export interface DirectiveCache {
|
|
|
140
161
|
html: CachedBinding[]
|
|
141
162
|
if: CachedBinding[]
|
|
142
163
|
show: CachedBinding[]
|
|
143
|
-
bind:
|
|
164
|
+
bind: CachedPairBinding[]
|
|
144
165
|
model: CachedBinding[]
|
|
145
|
-
class:
|
|
166
|
+
class: CachedPairBinding[]
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
/**
|
|
@@ -153,5 +174,7 @@ export interface DirectiveCache {
|
|
|
153
174
|
export interface InternalInstance<S extends StateRecord = StateRecord>
|
|
154
175
|
extends ComponentInstance<S> {
|
|
155
176
|
__micraSubs?: UnsubFn[]
|
|
177
|
+
__micraListeners?: TrackedListener[]
|
|
178
|
+
__micraDestroyed?: true
|
|
156
179
|
[key: string]: unknown
|
|
157
180
|
}
|
package/src/utils/expr.ts
CHANGED
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
* - Compile expression strings into cached functions
|
|
6
6
|
* - Evaluate them against a state object
|
|
7
7
|
* - Fast-path for simple property lookups
|
|
8
|
+
* - Shadow non-state identifiers so directive expressions cannot reach
|
|
9
|
+
* globals like `window`, `fetch`, `constructor`, etc. A small whitelist
|
|
10
|
+
* of utility globals (Math, JSON, Date, ...) remains accessible.
|
|
8
11
|
*
|
|
9
12
|
* LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
|
|
10
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* Security model:
|
|
15
|
+
* Directive expressions are JavaScript — they are compiled via `new Function`
|
|
16
|
+
* and run with full JS capability except that bare identifiers must resolve
|
|
17
|
+
* to either a state key, a component instance method, or one of
|
|
18
|
+
* ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
|
|
19
|
+
* and accidental access to `window` / `document` / `fetch`. It does NOT
|
|
20
|
+
* sandbox method calls — if a component method itself touches `window`,
|
|
21
|
+
* that still works. Treat directive templates as trusted code regardless.
|
|
11
22
|
*/
|
|
12
23
|
|
|
13
24
|
import type { StateRecord } from '../types'
|
|
@@ -18,12 +29,105 @@ import type { StateRecord } from '../types'
|
|
|
18
29
|
|
|
19
30
|
// LLM NOTE: exprCache is module-level (shared across all components).
|
|
20
31
|
// This is intentional — most apps reuse the same expressions.
|
|
21
|
-
|
|
32
|
+
type Compiled = (state: object, safe: object) => unknown
|
|
33
|
+
const exprCache = new Map<string, Compiled>()
|
|
34
|
+
// Expressions whose runtime error we have already warned about. Prevents log spam
|
|
35
|
+
// when the same `data-text="item.naame"` typo fires every render.
|
|
36
|
+
const warnedRuntime = new Set<string>()
|
|
22
37
|
|
|
23
38
|
// Simple identifier or dot-path: "count", "user.name", "item.email"
|
|
24
39
|
// Matches: letter/$/_ followed by word chars, optionally with .property chains
|
|
25
40
|
const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
26
41
|
|
|
42
|
+
// ── Safe scope ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Globals reachable from directive expressions. Anything else (window, fetch,
|
|
46
|
+
* constructor, eval, ...) is shadowed by SAFE_OUTER and resolves to undefined.
|
|
47
|
+
*/
|
|
48
|
+
const ALLOWED_GLOBALS = new Set<string>([
|
|
49
|
+
'Math', 'JSON', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
|
|
50
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'NaN', 'Infinity', 'undefined',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
|
|
55
|
+
* is "in scope" so the JS engine resolves the read on this Proxy (which returns
|
|
56
|
+
* undefined) instead of walking up to the global object. Whitelisted names fall
|
|
57
|
+
* through to globalThis.
|
|
58
|
+
*/
|
|
59
|
+
// Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
|
|
60
|
+
// shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
|
|
61
|
+
const PARAM_S = '$s'
|
|
62
|
+
const PARAM_SAFE = '$safe'
|
|
63
|
+
|
|
64
|
+
const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
|
|
65
|
+
has(_target, key): boolean {
|
|
66
|
+
if (typeof key !== 'string') return false
|
|
67
|
+
if (key === PARAM_S || key === PARAM_SAFE) return false
|
|
68
|
+
return !ALLOWED_GLOBALS.has(key)
|
|
69
|
+
},
|
|
70
|
+
get(): undefined {
|
|
71
|
+
return undefined
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @internal Per-state safe wrappers — one per source state object. WeakMap so
|
|
77
|
+
* short-lived itemStates get GC'd with their wrappers.
|
|
78
|
+
*/
|
|
79
|
+
const safeWrapCache = new WeakMap<object, object>()
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @internal Pre-computed names that live on `Object.prototype`
|
|
83
|
+
* (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
|
|
84
|
+
* built-in keys without re-walking the chain on every call.
|
|
85
|
+
*/
|
|
86
|
+
const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap a state object so its `has` trap reports only "real" keys — own
|
|
90
|
+
* properties or keys reachable up to (but not including) `Object.prototype`.
|
|
91
|
+
* This blocks `'constructor' in state` from leaking the prototype.
|
|
92
|
+
*/
|
|
93
|
+
function safeStateWrap(state: object): object {
|
|
94
|
+
const cached = safeWrapCache.get(state)
|
|
95
|
+
if (cached) return cached
|
|
96
|
+
const wrapped = new Proxy(state, {
|
|
97
|
+
has(target, key) {
|
|
98
|
+
return safeStateHas(target, key)
|
|
99
|
+
},
|
|
100
|
+
get(target, key) {
|
|
101
|
+
return Reflect.get(target, key)
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
safeWrapCache.set(state, wrapped)
|
|
105
|
+
return wrapped
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Return true iff `key` is reachable on `state` without walking into
|
|
110
|
+
* `Object.prototype`. Works for plain objects, prototype-chained objects, and
|
|
111
|
+
* Proxies with their own `has` trap.
|
|
112
|
+
*/
|
|
113
|
+
function safeStateHas(state: object, key: PropertyKey): boolean {
|
|
114
|
+
if (typeof key !== 'string') return false
|
|
115
|
+
if (!Reflect.has(state, key)) return false
|
|
116
|
+
// Identifiers that are NOT on Object.prototype are always safe — accept them
|
|
117
|
+
// immediately without walking the chain.
|
|
118
|
+
if (!OBJ_PROTO_KEYS.has(key)) return true
|
|
119
|
+
// Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
|
|
120
|
+
// are only accepted when they have been explicitly placed on the state chain.
|
|
121
|
+
let obj: object | null = state
|
|
122
|
+
while (obj && obj !== Object.prototype) {
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) return true
|
|
124
|
+
obj = Object.getPrototypeOf(obj) as object | null
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── evalExpr ──────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
27
131
|
/**
|
|
28
132
|
* Evaluate a JS expression string against a state object.
|
|
29
133
|
*
|
|
@@ -37,9 +141,12 @@ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
|
37
141
|
* evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
|
|
38
142
|
*/
|
|
39
143
|
export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
40
|
-
// Fast-path: simple property access — no Function() needed
|
|
144
|
+
// Fast-path: simple property access — no Function() needed.
|
|
145
|
+
// Still guarded so bare access to Object.prototype names returns undefined.
|
|
41
146
|
if (SIMPLE_PATH.test(expr)) {
|
|
42
|
-
|
|
147
|
+
const parts = expr.split('.')
|
|
148
|
+
if (!safeStateHas(state, parts[0]!)) return undefined
|
|
149
|
+
return parts.reduce<unknown>((obj, key) =>
|
|
43
150
|
obj != null ? (obj as StateRecord)[key] : undefined,
|
|
44
151
|
state,
|
|
45
152
|
)
|
|
@@ -47,9 +154,10 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
|
47
154
|
|
|
48
155
|
if (!exprCache.has(expr)) {
|
|
49
156
|
try {
|
|
157
|
+
// Two with() statements: $s wins for state keys; $safe shadows globals.
|
|
50
158
|
exprCache.set(
|
|
51
159
|
expr,
|
|
52
|
-
new Function('$s', `with($s){return (${expr})}`) as
|
|
160
|
+
new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
|
|
53
161
|
)
|
|
54
162
|
} catch {
|
|
55
163
|
warn(`invalid expression "${expr}"`)
|
|
@@ -58,8 +166,12 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
|
58
166
|
}
|
|
59
167
|
|
|
60
168
|
try {
|
|
61
|
-
return exprCache.get(expr)!(state)
|
|
62
|
-
} catch {
|
|
169
|
+
return exprCache.get(expr)!(safeStateWrap(state), SAFE_OUTER)
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (!warnedRuntime.has(expr)) {
|
|
172
|
+
warnedRuntime.add(expr)
|
|
173
|
+
warn(`runtime error in "${expr}": ${(e as Error).message}`)
|
|
174
|
+
}
|
|
63
175
|
return undefined
|
|
64
176
|
}
|
|
65
177
|
}
|