@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/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
+ }
@@ -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
+ }