export type Vode = FullVode | JustTagVode | NoPropsVode; export type FullVode = [tag: Tag, props: Props, ...children: ChildVode[]]; export type NoPropsVode = [tag: Tag, ...children: ChildVode[]] | (TextVode[]); export type JustTagVode = [tag: Tag]; export type ChildVode = Vode | TextVode | NoVode | Component; export type TextVode = string & {}; export type NoVode = undefined | null | number | boolean | bigint | void; export type AttachedVode = Vode & { node: ChildNode } | Text & { node?: never }; export type Tag = keyof (HTMLElementTagNameMap & SVGElementTagNameMap & MathMLElementTagNameMap) | (string & {}); export type Component = (s: S) => ChildVode; export type Patch = | IgnoredPatch // ignored | RenderPatch // updates state, causes render | Promise> | Effect; // is executed, awaited, results in patches export type IgnoredPatch = undefined | null | number | boolean | bigint | string | symbol | void; export type RenderPatch = {} | DeepPartial; export type AnimatedPatch = Array>; export type DeepPartial = { [P in keyof S]?: S[P] extends Array ? Array> : DeepPartial }; export type Effect = | (() => Patch) | EventFunction | Generator> | AsyncGenerator>; export type EventFunction = (state: S, evt: Event) => Patch; export interface Props extends Partial< Omit & { [K in keyof EventsMap]: EventFunction | Patch } // all on* events > { [_: string]: unknown, xmlns?: string | null, class?: ClassProp, style?: StyleProp, /** called after the element was attached */ onMount?: MountFunction, /** called before the element is detached */ onUnmount?: MountFunction, /** used instead of original vode when an error occurs during rendering */ catch?: ((s: S, error: any) => ChildVode) | ChildVode; }; export type MountFunction = | ((s: S, node: HTMLElement) => Patch) | ((s: S, node: SVGSVGElement) => Patch) | ((s: S, node: MathMLElement) => Patch); export type ClassProp = | "" | false | null | undefined // no class | string // "class1 class2" | string[] // ["class1", "class2"] | Record; // { class1: true, class2: false } export type StyleProp = | (Record & { [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] | null }) | string | "" | null | undefined; // no style type EventsMapBase = & { [K in keyof HTMLElementEventMap as `on${K}`]: HTMLElementEventMap[K] } & { [K in keyof WindowEventMap as `on${K}`]: WindowEventMap[K] } & { [K in keyof SVGElementEventMap as `on${K}`]: SVGElementEventMap[K] }; export interface EventsMap extends EventsMapBase { } export type PropertyValue = | string | boolean | null | undefined | void | StyleProp | ClassProp | Patch; export type Dispatch = (action: Patch) => void; export interface Patchable { patch: Dispatch; } export type PatchableState = S & Patchable; export const globals = { currentViewTransition: undefined, requestAnimationFrame: !!window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : ((cb: () => void) => cb()), startViewTransition: !!document.startViewTransition ? document.startViewTransition.bind(document) : null, }; export interface ContainerNode extends HTMLElement { /** the `_vode` property is added to the container in `app()`. * it contains all necessary stuff for the vode app to function. * remove the container node to clear vodes resources */ _vode: { state: PatchableState, vode: AttachedVode, renderSync: () => void, renderAsync: () => Promise, syncRenderer: (cb: () => void) => void, asyncRenderer: ((cb: () => void) => ViewTransition) | null | undefined, qSync: {} | undefined | null, // next patch aggregate to be applied qAsync: {} | undefined | null, // next render-patches to be animated after another isRendering: boolean, isAnimating: boolean, /** stats about the overall patches & last render time */ stats: { patchCount: number, liveEffectCount: number, syncRenderPatchCount: number, asyncRenderPatchCount: number, syncRenderCount: number, asyncRenderCount: number, lastSyncRenderTime: number, lastAsyncRenderTime: number, }, } }; /** type-safe way to create a vode. useful for type inference and autocompletion. * * - just a tag: `vode("div")` => `["div"]` --*rendered*-> `
` * - tag and props: `vode("div", { class: "foo" })` => `["div", { class: "foo" }]` --*rendered*-> `
` * - tag, props and children: `vode("div", { class: "foo" }, ["span", "bar"])` => `["div", { class: "foo" }, ["span", "bar"]]` --*rendered*-> `
bar
` * - identity: `vode(["div", ["span", "bar"]])` => `["div", ["span", "bar"]]` --*rendered*-> `
bar
` */ export function vode(tag: Tag | Vode, props?: Props | ChildVode, ...children: ChildVode[]): Vode { if (!tag) throw new Error("first argument to vode() must be a tag name or a vode"); if (Array.isArray(tag)) return tag; else if (props) return [tag, props as Props, ...children]; else return [tag, ...children]; } /** create a vode app inside a container element * @param container will use this container as root and places the result of the dom function and further renderings in it * @param state the state object that is used as singleton state bound to the vode app and is updated with `patch()` * @param dom function is alled every render and returnes the vode-dom that is updated incrementally to the DOM based on the state. * @param initialPatches variadic list of patches that are applied after the first render * @returns a patch function that can be used to update the state */ export function app( container: Element, state: Omit, dom: (s: S) => Vode, ...initialPatches: Patch[] ): Dispatch { if (!container?.parentElement) throw new Error("first argument to app() must be a valid HTMLElement inside the document"); if (!state || typeof state !== "object") throw new Error("second argument to app() must be a state object"); if (typeof dom !== "function") throw new Error("third argument to app() must be a function that returns a vode"); const _vode = {} as ContainerNode["_vode"]; _vode.syncRenderer = globals.requestAnimationFrame; _vode.asyncRenderer = globals.startViewTransition; _vode.qSync = null; _vode.qAsync = null; _vode.stats = { lastSyncRenderTime: 0, lastAsyncRenderTime: 0, syncRenderCount: 0, asyncRenderCount: 0, liveEffectCount: 0, patchCount: 0, syncRenderPatchCount: 0, asyncRenderPatchCount: 0 }; const patchableState = state as PatchableState & { patch: (action: Patch, animate?: boolean) => void }; if ("patch" in state && typeof state.patch === "function" && Array.isArray((state as any).patch.initialPatches)) { initialPatches = [...(state as any).patch.initialPatches, ...initialPatches]; } Object.defineProperty(state, "patch", { enumerable: false, configurable: true, writable: false, value: async (action: Patch, isAsync?: boolean) => { if (!action || (typeof action !== "function" && typeof action !== "object")) return; _vode.stats.patchCount++; if ((action as AsyncGenerator>)?.next) { const generator = action as AsyncGenerator>; _vode.stats.liveEffectCount++; try { let v = await generator.next(); while (v.done === false) { _vode.stats.liveEffectCount++; try { patchableState.patch(v.value, isAsync); v = await generator.next(); } finally { _vode.stats.liveEffectCount--; } } patchableState.patch(v.value as Patch, isAsync); } finally { _vode.stats.liveEffectCount--; } } else if ((action as Promise).then) { _vode.stats.liveEffectCount++; try { const resolvedPatch = await (action as Promise); patchableState.patch(>resolvedPatch, isAsync); } finally { _vode.stats.liveEffectCount--; } } else if (Array.isArray(action)) { if (action.length > 0) { for (const p of action) { patchableState.patch(p, !document.hidden && !!_vode.asyncRenderer); } } else { //when [] is patched: 1. skip current animation 2. merge all queued async patches into synced queue _vode.qSync = mergeState(_vode.qSync || {}, _vode.qAsync, false); _vode.qAsync = null; try { globals.currentViewTransition?.skipTransition(); } catch { } _vode.stats.syncRenderPatchCount++; _vode.renderSync(); } } else if (typeof action === "function") { patchableState.patch((<(s: S) => unknown>action)(_vode.state), isAsync); } else { if (isAsync) { _vode.stats.asyncRenderPatchCount++; _vode.qAsync = mergeState(_vode.qAsync || {}, action, false); await _vode.renderAsync(); } else { _vode.stats.syncRenderPatchCount++; _vode.qSync = mergeState(_vode.qSync || {}, action, false); _vode.renderSync(); } } } }); function renderDom(isAsync: boolean) { const sw = Date.now(); const vom = dom(_vode.state); _vode.vode = render(_vode.state, container.parentElement as Element, 0, 0, _vode.vode, vom)!; if ((>container).tagName.toUpperCase() !== (vom[0] as Tag).toUpperCase()) { //the tag name was changed during render -> update reference to vode-app-root container = _vode.vode.node as Element; (>container)._vode = _vode } if (!isAsync) { _vode.stats.lastSyncRenderTime = Date.now() - sw; _vode.stats.syncRenderCount++; _vode.isRendering = false; if (_vode.qSync) _vode.renderSync(); } } const sr = renderDom.bind(null, false); const ar = renderDom.bind(null, true); Object.defineProperty(_vode, "renderSync", { enumerable: false, configurable: true, writable: false, value: () => { if (_vode.isRendering || !_vode.qSync) return; _vode.isRendering = true; _vode.state = mergeState(_vode.state, _vode.qSync, true); _vode.qSync = null; _vode.syncRenderer(sr); } }); Object.defineProperty(_vode, "renderAsync", { enumerable: false, configurable: true, writable: false, value: async () => { if (_vode.isAnimating || !_vode.qAsync) return; await globals.currentViewTransition?.updateCallbackDone; //sandwich if (_vode.isAnimating || !_vode.qAsync || document.hidden) return; _vode.isAnimating = true; const sw = Date.now(); try { _vode.state = mergeState(_vode.state, _vode.qAsync, true); _vode.qAsync = null; globals.currentViewTransition = _vode.asyncRenderer!(ar) as ViewTransition | undefined; await globals.currentViewTransition?.updateCallbackDone; } finally { _vode.stats.lastAsyncRenderTime = Date.now() - sw; _vode.stats.asyncRenderCount++; _vode.isAnimating = false; } if (_vode.qAsync) _vode.renderAsync(); } }); _vode.state = patchableState; const root = container as ContainerNode; root._vode = _vode; const indexInParent = Array.from(container.parentElement.children).indexOf(container); _vode.vode = render( state, container.parentElement, indexInParent, indexInParent, hydrate(container, true) as AttachedVode, dom(state) )!; for (const effect of initialPatches) { patchableState.patch(effect); } return (action: Patch) => patchableState.patch(action); } /** unregister vode app from container and free resources * of all vodes inside the container. * removes all event listeners registered by vode * removes patch function from state object * leaves the DOM as is */ export function defuse(container: ContainerNode) { if (container?._vode) { function clearEvents(av: AttachedVode) { if (!av?.node) return; const p = props(av); if (p) { for (const key in p) { if (key[0] === 'o' && key[1] === 'n') { (av.node)[key] = null; } } (av.node)['catch'] = null; } if ((av.node as ContainerNode)._vode) { defuse(av.node as ContainerNode); } else { const kids = children(av); if (kids) { for (let child of kids) { clearEvents(child as AttachedVode); } } } } const v = container._vode; delete (container)["_vode"]; Object.defineProperty(v.state, "patch", { value: undefined }); Object.defineProperty(v, "renderSync", { value: () => { } }); Object.defineProperty(v, "renderAsync", { value: () => { } }); clearEvents(v.vode); } else { for (let child of container.children) { defuse(child as ContainerNode); } } } /** return vode representation of given DOM node */ export function hydrate(element: Element | Text, prepareForRender?: boolean): Vode | string | AttachedVode | undefined { if ((element as Text)?.nodeType === Node.TEXT_NODE) { if ((element as Text).nodeValue?.trim() !== "") return prepareForRender ? element as Text : (element as Text).nodeValue!; return undefined; //ignore (mostly html whitespace) } else if (element.nodeType === Node.COMMENT_NODE) { return undefined; //ignore (not interesting) } else if (element.nodeType === Node.ELEMENT_NODE) { const tag: Tag = (element).tagName.toLowerCase(); const root: Vode = [tag]; if (prepareForRender) (>root).node = element; if ((element as HTMLElement)?.hasAttributes()) { const props: Props = {}; const attr = (element).attributes; for (let a of attr) { props[a.name] = a.value; } (>root).push(props as any); } if (element.hasChildNodes()) { const remove: ChildNode[] = []; for (let child of element.childNodes) { const wet = child && hydrate(child as Element | Text, prepareForRender)! as ChildVode; if (wet) root.push(wet as any); else if (child && prepareForRender) remove.push(child); } for (let child of remove) { child.remove(); } } return root; } else { return undefined; } } /** memoizes the resulting component or props by comparing element by element (===) with the * `compare` of the previous render. otherwise skips the render step (not calling `componentOrProps`)*/ export function memo(compare: any[], componentOrProps: Component | ((s: S) => Props)): typeof componentOrProps extends ((s: S) => Props) ? ((s: S) => Props) : Component { if (!compare || !Array.isArray(compare)) throw new Error("first argument to memo() must be an array of values to compare"); if (typeof componentOrProps !== "function") throw new Error("second argument to memo() must be a function that returns a vode or props object"); (componentOrProps).__memo = compare; return componentOrProps as typeof componentOrProps extends ((s: S) => Props) ? ((s: S) => Props) : Component; } /** * create a patchable state object for a vode-app. * calls to `patch()` prior to `app()` initialization will queue the patches and apply them before the initial patches. * calls to `patch()` after `app()` initialization will apply the patch immediately and trigger a render as usual. */ export function createState(state: S): PatchableState { if (!state || typeof state !== "object") throw new Error("createState() must be called with a state object"); if (!("patch" in state)) { Object.defineProperty(state, "patch", { enumerable: false, configurable: true, writable: false, value: (action: Patch) => { const futureState = (state as any); if (!Array.isArray(futureState.patch.initialPatches)) { futureState.patch.initialPatches = []; } futureState.patch.initialPatches.push(action); } }); } return state as PatchableState; } /** type safe way to create a patch. useful for type inference and autocompletion. */ export function createPatch(p: DeepPartial | Effect | IgnoredPatch): typeof p { return p; } /** html tag of the vode or `#text` if it is a text node */ export function tag(v: Vode | TextVode | NoVode | AttachedVode): Tag | "#text" | undefined { return !!v ? (Array.isArray(v) ? v[0] : (typeof v === "string" || (v).nodeType === Node.TEXT_NODE) ? "#text" : undefined) as Tag : undefined; } /** get properties object of a vode, if there is any */ export function props(vode: ChildVode | AttachedVode): Props | undefined { if (Array.isArray(vode) && vode.length > 1 && vode[1] && !Array.isArray(vode[1]) ) { if ( typeof vode[1] === "object" && (vode[1] as unknown as Node).nodeType !== Node.TEXT_NODE ) { return vode[1]; } } return undefined; } /** get a slice of all children of a vode, if there are any */ export function children(vode: ChildVode | AttachedVode): ChildVode[] | null { const start = childrenStart(vode); if (start > 0) { return (>vode).slice(start) as Vode[]; } return null; } export function childCount(vode: Vode) { const start = childrenStart(vode); if (start < 0) return 0; return vode.length - start; } export function child(vode: Vode, index: number): ChildVode | undefined { const start = childrenStart(vode); if (start > 0) return vode[index + start] as ChildVode; else return undefined; } /** index in vode at which child-vodes start */ export function childrenStart(vode: ChildVode | AttachedVode): 1 | 2 | -1 { return props(vode) ? (vode).length > 2 ? 2 : -1 : (Array.isArray(vode) && vode.length > 1 ? 1 : -1); } function mergeState(target: any, source: any, allowDeletion: boolean) { if (!source) return target; for (const key in source) { const value = source[key]; if (value && typeof value === "object") { const targetValue = target[key]; if (targetValue) { if (Array.isArray(value)) { target[key] = [...value]; } else if (value instanceof Date && targetValue !== value) { target[key] = new Date(value); } else { if (Array.isArray(targetValue)) target[key] = mergeState({}, value, allowDeletion); else if (typeof targetValue === "object") mergeState(target[key], value, allowDeletion); else target[key] = mergeState({}, value, allowDeletion); } } else if (Array.isArray(value)) { target[key] = [...value]; } else if (value instanceof Date) { target[key] = new Date(value); } else { target[key] = mergeState({}, value, allowDeletion); } } else if (value === undefined && allowDeletion) { delete target[key]; } else { target[key] = value; } } return target; }; function render(state: S, parent: Element, childIndex: number, indexInParent: number, oldVode: AttachedVode | undefined, newVode: ChildVode, xmlns?: string | null): AttachedVode | undefined { try { // unwrap component if it is memoized newVode = remember(state, newVode, oldVode) as ChildVode; const isNoVode = !newVode || typeof newVode === "number" || typeof newVode === "boolean"; if (newVode === oldVode || (!oldVode && isNoVode)) { return oldVode; } const oldIsText = (oldVode as Text)?.nodeType === Node.TEXT_NODE; const oldNode: ChildNode | undefined = oldIsText ? oldVode as Text : oldVode?.node; // falsy|text|element(A) -> undefined if (isNoVode) { (oldNode)?.onUnmount && state.patch((oldNode).onUnmount(oldNode)); oldNode?.remove(); return undefined; } const isText = !isNoVode && isTextVode(newVode); const isNode = !isNoVode && isNaturalVode(newVode); const alreadyAttached = !!newVode && typeof newVode !== "string" && !!((newVode)?.node || (newVode)?.nodeType === Node.TEXT_NODE); if (!isText && !isNode && !alreadyAttached && !oldVode) { throw new Error("Invalid vode: " + typeof newVode + " " + JSON.stringify(newVode)); } else if (alreadyAttached && isText) { newVode = (newVode).wholeText; } else if (alreadyAttached && isNode) { newVode = [...>newVode]; } // text -> text if (oldIsText && isText) { if ((oldNode).nodeValue !== newVode) { (oldNode).nodeValue = newVode; } return oldVode; } // falsy|element -> text if (isText && (!oldNode || !oldIsText)) { const text = document.createTextNode(newVode as string) if (oldNode) { (oldNode).onUnmount && state.patch((oldNode).onUnmount(oldNode)); oldNode.replaceWith(text); } else { let inserted = false; for (let i = indexInParent; i < parent.childNodes.length; i++) { const nextSibling = parent.childNodes[i]; if (nextSibling) { nextSibling.before(text, nextSibling); inserted = true; break; } } if (!inserted) { parent.appendChild(text); } } return text as Text; } // falsy|text|element(A) -> element(B) if ( (isNode && (!oldNode || oldIsText || (>oldVode)[0] !== (>newVode)[0])) ) { const newvode = >newVode; if (1 in newvode) { newvode[1] = remember(state, newvode[1], undefined) as Vode; } const properties = props(newVode); if (properties?.xmlns !== undefined) xmlns = properties.xmlns; const newNode: ChildNode = xmlns ? document.createElementNS(xmlns, (>newVode)[0]) : document.createElement((>newVode)[0]); (>newVode).node = newNode; //set properties for new child in xml mode to prevent using the dom properties patchProperties(state, newNode, undefined, properties, xmlns ?? null); if (!!properties && 'catch' in properties) { (newVode).node['catch'] = null; (newVode).node.removeAttribute('catch'); } if (oldNode) { (oldNode).onUnmount && state.patch((oldNode).onUnmount(oldNode)); oldNode.replaceWith(newNode); } else { let inserted = false; for (let i = indexInParent; i < parent.childNodes.length; i++) { const nextSibling = parent.childNodes[i]; if (nextSibling) { nextSibling.before(newNode, nextSibling); inserted = true; break; } } if (!inserted) { parent.appendChild(newNode); } } const newKids = children(newVode); if (newKids) { const childOffset = !!properties ? 2 : 1; let indexP = 0; for (let i = 0; i < newKids.length; i++) { const child = newKids[i]; // render child in xml mode to prevent using the dom properties const attached = render(state, newNode as Element, i, indexP, undefined, child, xmlns ?? null); (>newVode!)[i + childOffset] = >attached; if (attached) indexP++; } } (newNode).onMount && state.patch((newNode).onMount(newNode)); return >newVode; } //element(A) -> element(A) if (!oldIsText && isNode && (>oldVode)[0] === (>newVode)[0]) { (>newVode).node = oldNode; const newvode = >newVode; const oldvode = >oldVode; const properties = props(newVode); const oldProps = props(oldVode); if (properties?.xmlns !== undefined) xmlns = properties.xmlns; if ((newvode[1])?.__memo) { const prev = newvode[1] as any; newvode[1] = remember(state, newvode[1], oldvode[1]) as Vode; if (prev !== newvode[1]) { patchProperties(state, oldNode!, oldProps, properties, xmlns); } } else { patchProperties(state, oldNode!, oldProps, properties, xmlns); } if (!!properties?.catch && oldProps?.catch !== properties.catch) { (newVode).node['catch'] = null; (newVode).node.removeAttribute('catch'); } const newKids = children(newVode); const oldKids = children(oldVode) as AttachedVode[]; if (newKids) { const childOffset = !!properties ? 2 : 1; let indexP = 0; for (let i = 0; i < newKids.length; i++) { const child = newKids[i]; const oldChild = oldKids && oldKids[i]; const attached = render(state, oldNode as Element, i, indexP, oldChild, child, xmlns); (>newVode)[i + childOffset] = >attached; if (attached) indexP++; } } if (oldKids) { const newKidsCount = newKids ? newKids.length : 0; for (let i = oldKids.length - 1; i >= newKidsCount; i--) { render(state, oldNode as Element, i, i, oldKids[i], undefined, xmlns); } } return >newVode; } } catch (error) { const catchVode = props(newVode)?.catch; if (catchVode) { const handledVode = typeof catchVode === "function" ? (<(s: S, error: any) => ChildVode>catchVode)(state, error) : catchVode; return render(state, parent, childIndex, indexInParent, hydrate(((>newVode)?.node || oldVode?.node) as Element, true) as AttachedVode, handledVode, xmlns); } else { throw error; } } return undefined; } function isNaturalVode(x: ChildVode): x is Vode { return Array.isArray(x) && x.length > 0 && typeof x[0] === "string"; } function isTextVode(x: ChildVode): x is TextVode { return typeof x === "string" || (x)?.nodeType === Node.TEXT_NODE; } function remember(state: S, present: any, past: any): ChildVode | AttachedVode { if (typeof present !== "function") return present; const presentMemo = present?.__memo; const pastMemo = past?.__memo; if (Array.isArray(presentMemo) && Array.isArray(pastMemo) && presentMemo.length === pastMemo.length ) { let same = true; for (let i = 0; i < presentMemo.length; i++) { if (presentMemo[i] !== pastMemo[i]) { same = false; break; } } if (same) return past; } const newRender = unwrap(present, state); if (typeof newRender === "object") { (newRender).__memo = present?.__memo; } return newRender; } function unwrap(c: Component | ChildVode, s: S): ChildVode { if (typeof c === "function") { return unwrap(c(s), s); } else { return c; } } function patchProperties( s: S, node: ChildNode, oldProps: Props | null | undefined, newProps: Props | null | undefined, xmlns: string | null | undefined ) { if (!newProps && !oldProps) return; const xmlMode = xmlns !== undefined; // match existing properties if (oldProps) { for (const key in oldProps) { const oldValue = oldProps[key as keyof Props] as PropertyValue; const newValue = newProps?.[key as keyof Props] as PropertyValue; if (oldValue !== newValue) { if (newProps) newProps[key as keyof Props] = patchProperty(s, node, key, oldValue, newValue, xmlMode); else patchProperty(s, node, key, oldValue, undefined, xmlMode); } } } //new properties that weren't in oldProps if (newProps && oldProps) { for (const key in newProps) { if (!(key in oldProps)) { const newValue = newProps[key as keyof Props] as PropertyValue; newProps[key as keyof Props] = patchProperty(s, node, key, undefined, newValue, xmlMode); } } } // only new props else if (newProps) { for (const key in newProps) { const newValue = newProps[key as keyof Props] as PropertyValue; newProps[key as keyof Props] = patchProperty(s, node, key, undefined, newValue, xmlMode); } } } function patchProperty(s: S, node: ChildNode, key: string | keyof ElementEventMap, oldValue: PropertyValue, newValue: PropertyValue, xmlMode: boolean) { if (key === "style") { if (!newValue) { (node as HTMLElement).style.cssText = ""; } else if (typeof newValue === "string") { if (oldValue !== newValue) (node as HTMLElement).style.cssText = newValue; } else if (oldValue && typeof oldValue === "object") { for (let k in oldValue) { const nv = newValue[k as keyof PropertyValue]; if (!nv) { ((node as HTMLElement).style)[k as keyof PropertyValue] = null; } } for (let k in (newValue as Record)) { const ov = oldValue[k as keyof PropertyValue]; const nv = newValue[k as keyof PropertyValue]; if (ov !== nv) { ((node as HTMLElement).style)[k as keyof PropertyValue] = nv; } } } else { for (let k in (newValue as Props)) { (node as HTMLElement).style[k as keyof PropertyValue] = newValue[k as keyof PropertyValue]; } } } else if (key === "class") { if (newValue) { (node).setAttribute("class", classString(newValue as ClassProp)); } else { (node).removeAttribute("class"); } } else if (key[0] === "o" && key[1] === "n") { if (newValue) { let eventHandler: Function | null = null; if (typeof newValue === "function") { const action = newValue as EventFunction; eventHandler = (evt: Event) => s.patch(action(s, evt)); } else if (typeof newValue === "object") { eventHandler = () => s.patch(newValue as Patch); } (node)[key] = eventHandler; } else { (node)[key] = null; } } else { if (!xmlMode) (node)[key] = newValue; if (newValue === undefined || newValue === null || newValue === false) (node).removeAttribute(key); else (node).setAttribute(key, newValue); } return newValue; } function classString(classProp: ClassProp): string { if (typeof classProp === "string") return classProp; else if (Array.isArray(classProp)) return classProp.map(classString).join(" "); else if (typeof classProp === "object") return Object.keys(classProp!).filter(k => classProp![k]).join(" "); else return ''; }