@pyreon/runtime-dom 0.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/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +1909 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +1845 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +355 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/devtools.ts +304 -0
- package/src/hydrate.ts +385 -0
- package/src/hydration-debug.ts +39 -0
- package/src/index.ts +43 -0
- package/src/keep-alive.ts +71 -0
- package/src/mount.ts +367 -0
- package/src/nodes.ts +741 -0
- package/src/props.ts +328 -0
- package/src/template.ts +81 -0
- package/src/tests/coverage-gaps.test.ts +2488 -0
- package/src/tests/coverage.test.ts +1123 -0
- package/src/tests/mount.test.ts +3098 -0
- package/src/tests/setup.ts +3 -0
- package/src/transition-group.ts +264 -0
- package/src/transition.ts +184 -0
package/src/props.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { Props } from "@pyreon/core"
|
|
2
|
+
import { batch, renderEffect } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
type Cleanup = () => void
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Directive function signature.
|
|
8
|
+
* Receives the element and an `addCleanup` callback to register teardown logic.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const nFocus: Directive = (el) => { el.focus() }
|
|
12
|
+
*
|
|
13
|
+
* // With reactive value (via closure):
|
|
14
|
+
* const nTooltip = (text: () => string): Directive => (el, addCleanup) => {
|
|
15
|
+
* const e = effect(() => { el.title = text() })
|
|
16
|
+
* addCleanup(() => e.dispose())
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* // Usage:
|
|
20
|
+
* h("input", { "n-focus": nFocus })
|
|
21
|
+
* h("div", { "n-tooltip": nTooltip(() => label()) })
|
|
22
|
+
*/
|
|
23
|
+
export type Directive = (el: HTMLElement, addCleanup: (fn: Cleanup) => void) => void
|
|
24
|
+
|
|
25
|
+
const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
|
|
26
|
+
|
|
27
|
+
// ─── Configurable sanitizer ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export type SanitizeFn = (html: string) => string
|
|
30
|
+
|
|
31
|
+
let _customSanitizer: SanitizeFn | null = null
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set a custom HTML sanitizer used by `innerHTML` and `sanitizeHtml()`.
|
|
35
|
+
* Overrides both the Sanitizer API and the built-in fallback.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // With DOMPurify:
|
|
39
|
+
* import DOMPurify from "dompurify"
|
|
40
|
+
* setSanitizer((html) => DOMPurify.sanitize(html))
|
|
41
|
+
*
|
|
42
|
+
* // With sanitize-html:
|
|
43
|
+
* import sanitize from "sanitize-html"
|
|
44
|
+
* setSanitizer((html) => sanitize(html))
|
|
45
|
+
*
|
|
46
|
+
* // Reset to built-in:
|
|
47
|
+
* setSanitizer(null)
|
|
48
|
+
*/
|
|
49
|
+
export function setSanitizer(fn: SanitizeFn | null): void {
|
|
50
|
+
_customSanitizer = fn
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Safe HTML tags allowed by the fallback sanitizer (block + inline, no scripts/embeds/forms)
|
|
54
|
+
const SAFE_TAGS = new Set([
|
|
55
|
+
"a",
|
|
56
|
+
"abbr",
|
|
57
|
+
"address",
|
|
58
|
+
"article",
|
|
59
|
+
"aside",
|
|
60
|
+
"b",
|
|
61
|
+
"bdi",
|
|
62
|
+
"bdo",
|
|
63
|
+
"blockquote",
|
|
64
|
+
"br",
|
|
65
|
+
"caption",
|
|
66
|
+
"cite",
|
|
67
|
+
"code",
|
|
68
|
+
"col",
|
|
69
|
+
"colgroup",
|
|
70
|
+
"dd",
|
|
71
|
+
"del",
|
|
72
|
+
"details",
|
|
73
|
+
"dfn",
|
|
74
|
+
"div",
|
|
75
|
+
"dl",
|
|
76
|
+
"dt",
|
|
77
|
+
"em",
|
|
78
|
+
"figcaption",
|
|
79
|
+
"figure",
|
|
80
|
+
"footer",
|
|
81
|
+
"h1",
|
|
82
|
+
"h2",
|
|
83
|
+
"h3",
|
|
84
|
+
"h4",
|
|
85
|
+
"h5",
|
|
86
|
+
"h6",
|
|
87
|
+
"header",
|
|
88
|
+
"hr",
|
|
89
|
+
"i",
|
|
90
|
+
"ins",
|
|
91
|
+
"kbd",
|
|
92
|
+
"li",
|
|
93
|
+
"main",
|
|
94
|
+
"mark",
|
|
95
|
+
"nav",
|
|
96
|
+
"ol",
|
|
97
|
+
"p",
|
|
98
|
+
"pre",
|
|
99
|
+
"q",
|
|
100
|
+
"rp",
|
|
101
|
+
"rt",
|
|
102
|
+
"ruby",
|
|
103
|
+
"s",
|
|
104
|
+
"samp",
|
|
105
|
+
"section",
|
|
106
|
+
"small",
|
|
107
|
+
"span",
|
|
108
|
+
"strong",
|
|
109
|
+
"sub",
|
|
110
|
+
"summary",
|
|
111
|
+
"sup",
|
|
112
|
+
"table",
|
|
113
|
+
"tbody",
|
|
114
|
+
"td",
|
|
115
|
+
"tfoot",
|
|
116
|
+
"th",
|
|
117
|
+
"thead",
|
|
118
|
+
"time",
|
|
119
|
+
"tr",
|
|
120
|
+
"u",
|
|
121
|
+
"ul",
|
|
122
|
+
"var",
|
|
123
|
+
"wbr",
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
// Attributes that can carry executable code
|
|
127
|
+
const UNSAFE_ATTR_RE = /^on/i
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fallback tag-stripping sanitizer for environments without the Sanitizer API.
|
|
131
|
+
* Removes all tags not in SAFE_TAGS, strips event handler attributes,
|
|
132
|
+
* and blocks javascript:/data: URLs in href/src/action attributes.
|
|
133
|
+
*/
|
|
134
|
+
function fallbackSanitize(html: string): string {
|
|
135
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
136
|
+
sanitizeNode(doc.body)
|
|
137
|
+
return doc.body.innerHTML
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Strip unsafe attributes from a single element. */
|
|
141
|
+
function stripUnsafeAttrs(el: Element): void {
|
|
142
|
+
const attrs = Array.from(el.attributes)
|
|
143
|
+
for (const attr of attrs) {
|
|
144
|
+
if (UNSAFE_ATTR_RE.test(attr.name)) {
|
|
145
|
+
el.removeAttribute(attr.name)
|
|
146
|
+
} else if (URL_ATTRS.has(attr.name) && UNSAFE_URL_RE.test(attr.value)) {
|
|
147
|
+
el.removeAttribute(attr.name)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sanitizeNode(node: Node): void {
|
|
153
|
+
const children = Array.from(node.childNodes)
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
if (child.nodeType !== 1) continue
|
|
156
|
+
const el = child as Element
|
|
157
|
+
const tag = el.tagName.toLowerCase()
|
|
158
|
+
if (!SAFE_TAGS.has(tag)) {
|
|
159
|
+
const text = document.createTextNode(el.textContent as string)
|
|
160
|
+
node.replaceChild(text, el)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
stripUnsafeAttrs(el)
|
|
164
|
+
sanitizeNode(el)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sanitize an HTML string using the browser Sanitizer API (Chrome 105+).
|
|
170
|
+
* Falls back to a tag-allowlist sanitizer that strips unsafe elements and attributes.
|
|
171
|
+
*/
|
|
172
|
+
export function sanitizeHtml(html: string): string {
|
|
173
|
+
// User-provided sanitizer takes priority (e.g. DOMPurify)
|
|
174
|
+
if (_customSanitizer) return _customSanitizer(html)
|
|
175
|
+
// DOM-based allowlist sanitizer — DOMParser is available in all browser targets.
|
|
176
|
+
// sanitizeHtml is only called for innerHTML (DOM-only), so SSR fallback is not needed.
|
|
177
|
+
return fallbackSanitize(html)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Matches onClick, onInput, onMouseEnter, etc.
|
|
181
|
+
const EVENT_RE = /^on[A-Z]/
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Apply all props to a DOM element.
|
|
185
|
+
* Returns a single chained cleanup (or null if no props need teardown).
|
|
186
|
+
* Uses for-in instead of Object.keys() to avoid allocating a keys array.
|
|
187
|
+
*/
|
|
188
|
+
export function applyProps(el: Element, props: Props): Cleanup | null {
|
|
189
|
+
let cleanup: Cleanup | null = null
|
|
190
|
+
for (const key in props) {
|
|
191
|
+
if (key === "key" || key === "ref") continue
|
|
192
|
+
const c = applyProp(el, key, props[key])
|
|
193
|
+
if (c) {
|
|
194
|
+
if (!cleanup) {
|
|
195
|
+
cleanup = c
|
|
196
|
+
} else {
|
|
197
|
+
const prev = cleanup
|
|
198
|
+
cleanup = () => {
|
|
199
|
+
prev()
|
|
200
|
+
c()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return cleanup
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Apply a single prop.
|
|
210
|
+
*
|
|
211
|
+
* - `onXxx` → addEventListener
|
|
212
|
+
* - `() => value` (non-event function) → reactive via effect
|
|
213
|
+
* - anything else → static attribute / DOM property
|
|
214
|
+
*/
|
|
215
|
+
export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
|
|
216
|
+
// Event listener: onClick → "click"
|
|
217
|
+
// Wrapped in batch() so multiple signal writes from one handler coalesce into one DOM update.
|
|
218
|
+
if (EVENT_RE.test(key)) {
|
|
219
|
+
const eventName = key[2]?.toLowerCase() + key.slice(3)
|
|
220
|
+
const handler = value as EventListener
|
|
221
|
+
const batched: EventListener = (e) => batch(() => handler(e))
|
|
222
|
+
el.addEventListener(eventName, batched)
|
|
223
|
+
return () => el.removeEventListener(eventName, batched)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
|
|
227
|
+
if (key === "innerHTML") {
|
|
228
|
+
if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === "function") {
|
|
229
|
+
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(value as string)
|
|
230
|
+
} else {
|
|
231
|
+
;(el as HTMLElement).innerHTML = sanitizeHtml(value as string)
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
// dangerouslySetInnerHTML — intentionally raw, developer owns sanitization (same as React)
|
|
236
|
+
if (key === "dangerouslySetInnerHTML") {
|
|
237
|
+
if (__DEV__) {
|
|
238
|
+
console.warn(
|
|
239
|
+
"[Pyreon] dangerouslySetInnerHTML bypasses sanitization. Ensure the HTML is trusted.",
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
;(el as HTMLElement).innerHTML = (value as { __html: string }).__html
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// n-show: toggle display based on a reactive boolean
|
|
247
|
+
if (key === "n-show") {
|
|
248
|
+
const dispose = renderEffect(() => {
|
|
249
|
+
const visible = (value as () => boolean)()
|
|
250
|
+
;(el as HTMLElement).style.display = visible ? "" : "none"
|
|
251
|
+
})
|
|
252
|
+
return dispose
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Custom directive: n-* keys call the directive function with (el, addCleanup)
|
|
256
|
+
if (key.startsWith("n-")) {
|
|
257
|
+
const directive = value as Directive
|
|
258
|
+
const cleanups: Cleanup[] = []
|
|
259
|
+
directive(el as HTMLElement, (fn) => cleanups.push(fn))
|
|
260
|
+
return cleanups.length > 0
|
|
261
|
+
? () => {
|
|
262
|
+
for (const fn of cleanups) fn()
|
|
263
|
+
}
|
|
264
|
+
: null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Reactive prop — function that returns the actual value
|
|
268
|
+
// Uses renderEffect (lighter than effect — no scope registration, no WeakMap)
|
|
269
|
+
// since lifecycle is managed by mountElement's cleanup array.
|
|
270
|
+
if (typeof value === "function") {
|
|
271
|
+
const dispose = renderEffect(() => setStaticProp(el, key, (value as () => unknown)()))
|
|
272
|
+
return dispose
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
setStaticProp(el, key, value)
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Attributes that carry URLs and must be guarded against javascript:/data: injection.
|
|
280
|
+
const URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "cite", "data"])
|
|
281
|
+
const UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
|
|
282
|
+
|
|
283
|
+
/** Apply a style prop (string or object). */
|
|
284
|
+
function applyStyleProp(el: HTMLElement, value: unknown): void {
|
|
285
|
+
if (typeof value === "string") {
|
|
286
|
+
el.style.cssText = value
|
|
287
|
+
} else if (value != null && typeof value === "object") {
|
|
288
|
+
Object.assign(el.style, value)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function setStaticProp(el: Element, key: string, value: unknown): void {
|
|
293
|
+
// Block javascript:/data: URI injection in URL-bearing attributes.
|
|
294
|
+
if (URL_ATTRS.has(key) && typeof value === "string" && UNSAFE_URL_RE.test(value)) {
|
|
295
|
+
if (__DEV__) {
|
|
296
|
+
console.warn(`[Pyreon] Blocked unsafe URL in "${key}" attribute: ${value}`)
|
|
297
|
+
}
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (key === "class" || key === "className") {
|
|
302
|
+
el.setAttribute("class", value == null ? "" : String(value))
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (key === "style") {
|
|
307
|
+
applyStyleProp(el as HTMLElement, value)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (value == null) {
|
|
312
|
+
el.removeAttribute(key)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (typeof value === "boolean") {
|
|
317
|
+
if (value) el.setAttribute(key, "")
|
|
318
|
+
else el.removeAttribute(key)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (key in el) {
|
|
323
|
+
;(el as unknown as Record<string, unknown>)[key] = value
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
el.setAttribute(key, String(value))
|
|
328
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { NativeItem } from "@pyreon/core"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a row/item factory backed by HTML template cloning.
|
|
5
|
+
*
|
|
6
|
+
* - The HTML string is parsed exactly once via <template>.innerHTML.
|
|
7
|
+
* - Each call to the returned factory clones the root element via
|
|
8
|
+
* cloneNode(true) — ~5-10x faster than createElement + setAttribute.
|
|
9
|
+
* - `bind` receives the cloned element and the item; it should wire up
|
|
10
|
+
* reactive effects and return a cleanup function.
|
|
11
|
+
* - Returns a NativeItem directly (no VNode wrapper) — saves 2 allocations
|
|
12
|
+
* per row vs the old VNode + props-object + children-array approach.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const rowTemplate = createTemplate<Row>(
|
|
16
|
+
* "<tr><td></td><td></td></tr>",
|
|
17
|
+
* (el, row) => {
|
|
18
|
+
* const td1 = el.firstChild as HTMLElement
|
|
19
|
+
* const td2 = td1.nextSibling as HTMLElement
|
|
20
|
+
* td1.textContent = String(row.id)
|
|
21
|
+
* const text = td2.firstChild as Text
|
|
22
|
+
* text.data = row.label()
|
|
23
|
+
* const unsub = row.label.subscribe(() => { text.data = row.label() })
|
|
24
|
+
* return unsub
|
|
25
|
+
* }
|
|
26
|
+
* )
|
|
27
|
+
*/
|
|
28
|
+
export function createTemplate<T>(
|
|
29
|
+
html: string,
|
|
30
|
+
bind: (el: HTMLElement, item: T) => (() => void) | null,
|
|
31
|
+
): (item: T) => NativeItem {
|
|
32
|
+
const tmpl = document.createElement("template")
|
|
33
|
+
tmpl.innerHTML = html
|
|
34
|
+
const proto = tmpl.content.firstElementChild as HTMLElement
|
|
35
|
+
|
|
36
|
+
return (item: T): NativeItem => {
|
|
37
|
+
const el = proto.cloneNode(true) as HTMLElement
|
|
38
|
+
const cleanup = bind(el, item)
|
|
39
|
+
return { __isNative: true, el, cleanup }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Compiler-facing template API ─────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// Cache parsed <template> elements by HTML string — parse once, clone many.
|
|
46
|
+
const _tplCache = new Map<string, HTMLTemplateElement>()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compiler-emitted template instantiation.
|
|
50
|
+
*
|
|
51
|
+
* Parses `html` into a <template> element once (cached), then cloneNode(true)
|
|
52
|
+
* for each call. The `bind` function wires up dynamic attributes, text content,
|
|
53
|
+
* and event listeners on the cloned element tree. Returns a NativeItem that
|
|
54
|
+
* mountChild can insert directly — no VNode allocation.
|
|
55
|
+
*
|
|
56
|
+
* This is the runtime half of the compiler's template optimisation. The compiler
|
|
57
|
+
* detects static JSX element trees and emits `_tpl(html, bindFn)` instead of
|
|
58
|
+
* nested `h()` calls. Benefits:
|
|
59
|
+
* - cloneNode(true) is ~5-10x faster than sequential createElement + setAttribute
|
|
60
|
+
* - Zero VNode / props-object / children-array allocations per instance
|
|
61
|
+
* - Static attributes are baked into the HTML string (no runtime prop application)
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* // Compiler output for: <div class="box"><span>{text()}</span></div>
|
|
65
|
+
* _tpl('<div class="box"><span></span></div>', (__root) => {
|
|
66
|
+
* const __e0 = __root.children[0];
|
|
67
|
+
* const __d0 = _re(() => { __e0.textContent = text(); });
|
|
68
|
+
* return () => { __d0(); };
|
|
69
|
+
* })
|
|
70
|
+
*/
|
|
71
|
+
export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | null): NativeItem {
|
|
72
|
+
let tpl = _tplCache.get(html)
|
|
73
|
+
if (!tpl) {
|
|
74
|
+
tpl = document.createElement("template")
|
|
75
|
+
tpl.innerHTML = html
|
|
76
|
+
_tplCache.set(html, tpl)
|
|
77
|
+
}
|
|
78
|
+
const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
|
|
79
|
+
const cleanup = bind(el)
|
|
80
|
+
return { __isNative: true, el, cleanup }
|
|
81
|
+
}
|