UNPKG

uhtml

Version:

A minimalistic library to create fast and reactive Web pages

1,699 lines (1,568 loc) 56.3 kB
var ReactiveFlags; (function (ReactiveFlags) { ReactiveFlags[ReactiveFlags["None"] = 0] = "None"; ReactiveFlags[ReactiveFlags["Mutable"] = 1] = "Mutable"; ReactiveFlags[ReactiveFlags["Watching"] = 2] = "Watching"; ReactiveFlags[ReactiveFlags["RecursedCheck"] = 4] = "RecursedCheck"; ReactiveFlags[ReactiveFlags["Recursed"] = 8] = "Recursed"; ReactiveFlags[ReactiveFlags["Dirty"] = 16] = "Dirty"; ReactiveFlags[ReactiveFlags["Pending"] = 32] = "Pending"; })(ReactiveFlags || (ReactiveFlags = {})); function createReactiveSystem({ update, notify, unwatched, }) { let version = 0; return { link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate, }; function link(dep, sub) { const prevDep = sub.depsTail; if (prevDep !== undefined && prevDep.dep === dep) { return; } let nextDep; if (sub.flags & 4) { nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps; if (nextDep !== undefined && nextDep.dep === dep) { nextDep.version = version; sub.depsTail = nextDep; return; } } const prevSub = dep.subsTail; if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub) { return; } const newLink = sub.depsTail = dep.subsTail = { version, dep, sub, prevDep, nextDep, prevSub, nextSub: undefined, }; if (nextDep !== undefined) { nextDep.prevDep = newLink; } if (prevDep !== undefined) { prevDep.nextDep = newLink; } else { sub.deps = newLink; } if (prevSub !== undefined) { prevSub.nextSub = newLink; } else { dep.subs = newLink; } } function unlink(link, sub = link.sub) { const dep = link.dep; const prevDep = link.prevDep; const nextDep = link.nextDep; const nextSub = link.nextSub; const prevSub = link.prevSub; if (nextDep !== undefined) { nextDep.prevDep = prevDep; } else { sub.depsTail = prevDep; } if (prevDep !== undefined) { prevDep.nextDep = nextDep; } else { sub.deps = nextDep; } if (nextSub !== undefined) { nextSub.prevSub = prevSub; } else { dep.subsTail = prevSub; } if (prevSub !== undefined) { prevSub.nextSub = nextSub; } else if ((dep.subs = nextSub) === undefined) { unwatched(dep); } return nextDep; } function propagate(link) { let next = link.nextSub; let stack; top: do { const sub = link.sub; let flags = sub.flags; if (flags & 3) { if (!(flags & 60)) { sub.flags = flags | 32; } else if (!(flags & 12)) { flags = 0; } else if (!(flags & 4)) { sub.flags = (flags & -9) | 32; } else if (!(flags & 48) && isValidLink(link, sub)) { sub.flags = flags | 40; flags &= 1; } else { flags = 0; } if (flags & 2) { notify(sub); } if (flags & 1) { const subSubs = sub.subs; if (subSubs !== undefined) { link = subSubs; if (subSubs.nextSub !== undefined) { stack = { value: next, prev: stack }; next = link.nextSub; } continue; } } } if ((link = next) !== undefined) { next = link.nextSub; continue; } while (stack !== undefined) { link = stack.value; stack = stack.prev; if (link !== undefined) { next = link.nextSub; continue top; } } break; } while (true); } function startTracking(sub) { ++version; sub.depsTail = undefined; sub.flags = (sub.flags & -57) | 4; } function endTracking(sub) { const depsTail = sub.depsTail; let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps; while (toRemove !== undefined) { toRemove = unlink(toRemove, sub); } sub.flags &= -5; } function checkDirty(link, sub) { let stack; let checkDepth = 0; top: do { const dep = link.dep; const depFlags = dep.flags; let dirty = false; if (sub.flags & 16) { dirty = true; } else if ((depFlags & 17) === 17) { if (update(dep)) { const subs = dep.subs; if (subs.nextSub !== undefined) { shallowPropagate(subs); } dirty = true; } } else if ((depFlags & 33) === 33) { if (link.nextSub !== undefined || link.prevSub !== undefined) { stack = { value: link, prev: stack }; } link = dep.deps; sub = dep; ++checkDepth; continue; } if (!dirty && link.nextDep !== undefined) { link = link.nextDep; continue; } while (checkDepth) { --checkDepth; const firstSub = sub.subs; const hasMultipleSubs = firstSub.nextSub !== undefined; if (hasMultipleSubs) { link = stack.value; stack = stack.prev; } else { link = firstSub; } if (dirty) { if (update(sub)) { if (hasMultipleSubs) { shallowPropagate(firstSub); } sub = link.sub; continue; } } else { sub.flags &= -33; } sub = link.sub; if (link.nextDep !== undefined) { link = link.nextDep; continue top; } dirty = false; } return dirty; } while (true); } function shallowPropagate(link) { do { const sub = link.sub; const nextSub = link.nextSub; const subFlags = sub.flags; if ((subFlags & 48) === 32) { sub.flags = subFlags | 16; if (subFlags & 2) { notify(sub); } } link = nextSub; } while (link !== undefined); } function isValidLink(checkLink, sub) { const depsTail = sub.depsTail; if (depsTail !== undefined) { let link = sub.deps; do { if (link === checkLink) { return true; } if (link === depsTail) { break; } link = link.nextDep; } while (link !== undefined); } return false; } } const pauseStack = []; const queuedEffects = []; const { link, unlink, propagate, checkDirty, endTracking, startTracking, shallowPropagate, } = createReactiveSystem({ update(signal) { if ('getter' in signal) { return updateComputed(signal); } else { return updateSignal(signal, signal.value); } }, notify, unwatched(node) { if ('getter' in node) { let toRemove = node.deps; if (toRemove !== undefined) { node.flags = 17; do { toRemove = unlink(toRemove, node); } while (toRemove !== undefined); } } else if (!('previousValue' in node)) { effectOper.call(node); } }, }); let batchDepth = 0; let notifyIndex = 0; let queuedEffectsLength = 0; let activeSub; let activeScope; function setCurrentSub(sub) { const prevSub = activeSub; activeSub = sub; return prevSub; } function setCurrentScope(scope) { const prevScope = activeScope; activeScope = scope; return prevScope; } function startBatch() { ++batchDepth; } function endBatch() { if (!--batchDepth) { flush(); } } function pauseTracking() { pauseStack.push(setCurrentSub(undefined)); } function resumeTracking() { setCurrentSub(pauseStack.pop()); } function signal$2(initialValue) { return signalOper.bind({ previousValue: initialValue, value: initialValue, subs: undefined, subsTail: undefined, flags: 1, }); } function computed$1(getter) { return computedOper.bind({ value: undefined, subs: undefined, subsTail: undefined, deps: undefined, depsTail: undefined, flags: 17, getter: getter, }); } function effect(fn) { const e = { fn, subs: undefined, subsTail: undefined, deps: undefined, depsTail: undefined, flags: 2, }; if (activeSub !== undefined) { link(e, activeSub); } else if (activeScope !== undefined) { link(e, activeScope); } const prev = setCurrentSub(e); try { e.fn(); } finally { setCurrentSub(prev); } return effectOper.bind(e); } function effectScope(fn) { const e = { deps: undefined, depsTail: undefined, subs: undefined, subsTail: undefined, flags: 0, }; if (activeScope !== undefined) { link(e, activeScope); } const prevSub = setCurrentSub(undefined); const prevScope = setCurrentScope(e); try { fn(); } finally { setCurrentScope(prevScope); setCurrentSub(prevSub); } return effectOper.bind(e); } function updateComputed(c) { const prevSub = setCurrentSub(c); startTracking(c); try { const oldValue = c.value; return oldValue !== (c.value = c.getter(oldValue)); } finally { setCurrentSub(prevSub); endTracking(c); } } function updateSignal(s, value) { s.flags = 1; return s.previousValue !== (s.previousValue = value); } function notify(e) { const flags = e.flags; if (!(flags & 64)) { e.flags = flags | 64; const subs = e.subs; if (subs !== undefined) { notify(subs.sub); } else { queuedEffects[queuedEffectsLength++] = e; } } } function run(e, flags) { if (flags & 16 || (flags & 32 && checkDirty(e.deps, e))) { const prev = setCurrentSub(e); startTracking(e); try { e.fn(); } finally { setCurrentSub(prev); endTracking(e); } return; } else if (flags & 32) { e.flags = flags & -33; } let link = e.deps; while (link !== undefined) { const dep = link.dep; const depFlags = dep.flags; if (depFlags & 64) { run(dep, dep.flags = depFlags & -65); } link = link.nextDep; } } function flush() { while (notifyIndex < queuedEffectsLength) { const effect = queuedEffects[notifyIndex]; queuedEffects[notifyIndex++] = undefined; run(effect, effect.flags &= -65); } notifyIndex = 0; queuedEffectsLength = 0; } function computedOper() { const flags = this.flags; if (flags & 16 || (flags & 32 && checkDirty(this.deps, this))) { if (updateComputed(this)) { const subs = this.subs; if (subs !== undefined) { shallowPropagate(subs); } } } else if (flags & 32) { this.flags = flags & -33; } if (activeSub !== undefined) { link(this, activeSub); } else if (activeScope !== undefined) { link(this, activeScope); } return this.value; } function signalOper(...value) { if (value.length) { const newValue = value[0]; if (this.value !== (this.value = newValue)) { this.flags = 17; const subs = this.subs; if (subs !== undefined) { propagate(subs); if (!batchDepth) { flush(); } } } } else { const value = this.value; if (this.flags & 16) { if (updateSignal(this, value)) { const subs = this.subs; if (subs !== undefined) { shallowPropagate(subs); } } } if (activeSub !== undefined) { link(this, activeSub); } return value; } } function effectOper() { let dep = this.deps; while (dep !== undefined) { dep = unlink(dep, this); } const sub = this.subs; if (sub !== undefined) { unlink(sub); } this.flags = 0; } const defaults = { greedy: false }; const computed = value => new Computed(value); const signal$1 = (value, { greedy = false } = defaults) => greedy ? new Greedy(value) : new Signal(signal$2, value); /** * @template T * @param {function(): T} fn * @returns {T} */ const untracked = fn => { pauseTracking(); try { return fn() } finally { resumeTracking(); } }; /** * @template T */ class Signal { /** * @param {(value: T) => T} fn * @param {T} value */ constructor(fn, value) { this._ = fn(value); } /** @returns {T} */ get value() { return this._(); } /** @param {T} value */ set value(value) { this._(value); } /** @returns {T} */ peek() { return untracked(this._); } /** @returns {T} */ valueOf() { return this.value; } } /** * @template T */ class Computed extends Signal { /** @param {T} value */ constructor(value) { super(computed$1, value); } /** @returns {T} */ get value() { return this._() } set value(_) { throw new Error('Computed values are read-only') } } class Greedy extends Signal { constructor(value) { super(signal$2, [value]); } get value() { return super.value[0] } set value(value) { super.value = [value]; } peek() { return super.peek()[0] } } const batch = fn => { startBatch(); try { return fn() } finally { endBatch(); } }; let $ = signal$1; function signal() { return $.apply(null, arguments); } const _get$1 = () => $; const _set$1 = fn => { $ = fn; }; const { isArray } = Array; const { assign, defineProperties, entries, freeze} = Object; class Unsafe { #data; constructor(data) { this.#data = data; } valueOf() { return this.#data; } toString() { return String(this.#data); } } const unsafe = data => new Unsafe(data); const createComment = value => document.createComment(value); /* c8 ignore stop */ // this is an essential ad-hoc DOM facade const ELEMENT = 1; const ATTRIBUTE$1 = 2; const TEXT$1 = 3; const COMMENT$1 = 8; const DOCUMENT_TYPE = 10; const FRAGMENT = 11; const COMPONENT$1 = 42; const TEXT_ELEMENTS = new Set([ 'plaintext', 'script', 'style', 'textarea', 'title', 'xmp', ]); const VOID_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr', ]); const props$1 = freeze({}); const children = freeze([]); const append = (node, child) => { if (node.children === children) node.children = []; node.children.push(child); child.parent = node; return child; }; const prop = (node, name, value) => { if (node.props === props$1) node.props = {}; node.props[name] = value; }; const addJSON = (value, comp, json) => { if (value !== comp) json.push(value); }; class Node { constructor(type) { this.type = type; this.parent = null; } toJSON() { //@ts-ignore return [this.type, this.data]; } } class Comment extends Node { constructor(data) { super(COMMENT$1); this.data = data; } toString() { return `<!--${this.data}-->`; } } class DocumentType extends Node { constructor(data) { super(DOCUMENT_TYPE); this.data = data; } toString() { return `<!${this.data}>`; } } class Text extends Node { constructor(data) { super(TEXT$1); this.data = data; } toString() { return this.data; } } class Component extends Node { constructor() { super(COMPONENT$1); this.name = 'template'; this.props = props$1; this.children = children; } toJSON() { const json = [COMPONENT$1]; addJSON(this.props, props$1, json); addJSON(this.children, children, json); return json; } toString() { let attrs = ''; for (const key in this.props) { const value = this.props[key]; if (value != null) { /* c8 ignore start */ if (typeof value === 'boolean') { if (value) attrs += ` ${key}`; } else attrs += ` ${key}="${value}"`; /* c8 ignore stop */ } } return `<template${attrs}>${this.children.join('')}</template>`; } } class Element extends Node { constructor(name, xml = false) { super(ELEMENT); this.name = name; this.xml = xml; this.props = props$1; this.children = children; } toJSON() { const json = [ELEMENT, this.name, +this.xml]; addJSON(this.props, props$1, json); addJSON(this.children, children, json); return json; } toString() { const { xml, name, props, children } = this; const { length } = children; let html = `<${name}`; for (const key in props) { const value = props[key]; if (value != null) { if (typeof value === 'boolean') { if (value) html += xml ? ` ${key}=""` : ` ${key}`; } else html += ` ${key}="${value}"`; } } if (length) { html += '>'; for (let text = !xml && TEXT_ELEMENTS.has(name), i = 0; i < length; i++) html += text ? children[i].data : children[i]; html += `</${name}>`; } else if (xml) html += ' />'; else html += VOID_ELEMENTS.has(name) ? '>' : `></${name}>`; return html; } } class Fragment extends Node { constructor() { super(FRAGMENT); this.name = '#fragment'; this.children = children; } toJSON() { const json = [FRAGMENT]; addJSON(this.children, children, json); return json; } toString() { return this.children.join(''); } } /* c8 ignore start */ const asTemplate = template => (template?.raw || template)?.join?.(',') || 'unknown'; /* c8 ignore stop */ var errors = { text: (template, tag, value) => new SyntaxError(`Mixed text and interpolations found in text only <${tag}> element ${JSON.stringify(String(value))} in template ${asTemplate(template)}`), unclosed: (template, tag) => new SyntaxError(`The text only <${tag}> element requires explicit </${tag}> closing tag in template ${asTemplate(template)}`), unclosed_element: (template, tag) => new SyntaxError(`Unclosed element <${tag}> found in template ${asTemplate(template)}`), invalid_content: template => new SyntaxError(`Invalid content "<!" found in template: ${asTemplate(template)}`), invalid_closing: template => new SyntaxError(`Invalid closing tag: </... found in template: ${asTemplate(template)}`), invalid_nul: template => new SyntaxError(`Invalid content: NUL char \\x00 found in template: ${asTemplate(template)}`), invalid_comment: template => new SyntaxError(`Invalid comment: no closing --> found in template ${asTemplate(template)}`), invalid_layout: template => new SyntaxError(`Too many closing tags found in template ${asTemplate(template)}`), invalid_doctype: (template, value) => new SyntaxError(`Invalid doctype: ${value} found in template ${asTemplate(template)}`), // DOM ONLY /* c8 ignore start */ invalid_template: template => new SyntaxError(`Invalid template - the amount of values does not match the amount of updates: ${asTemplate(template)}`), invalid_path: (template, path) => new SyntaxError(`Invalid path - unreachable node at the path [${path.join(', ')}] found in template ${asTemplate(template)}`), invalid_attribute: (template, kind) => new SyntaxError(`Invalid ${kind} attribute in template definition\n${asTemplate(template)}`), invalid_interpolation: (template, value) => new SyntaxError(`Invalid interpolation - expected hole or array: ${String(value)} found in template ${asTemplate(template)}`), invalid_hole: value => new SyntaxError(`Invalid interpolation - expected hole: ${String(value)}`), invalid_key: value => new SyntaxError(`Invalid key attribute or position in template: ${String(value)}`), invalid_array: value => new SyntaxError(`Invalid array - expected html/svg but found something else: ${String(value)}`), invalid_component: value => new SyntaxError(`Invalid component: ${String(value)}`), }; //@ts-check const NUL = '\x00'; const DOUBLE_QUOTED_NUL = `"${NUL}"`; const SINGLE_QUOTED_NUL = `'${NUL}'`; const NEXT = /\x00|<[^><\s]+/g; const ATTRS = /([^\s/>=]+)(?:=(\x00|(?:(['"])[\s\S]*?\3)))?/g; // // YAGNI: NUL char in the wild is a shenanigan // // usage: template.map(safe).join(NUL).trim() // const NUL_RE = /\x00/g; // const safe = s => s.replace(NUL_RE, '&#0;'); /** @typedef {import('../dom/ish.js').Node} Node */ /** @typedef {import('../dom/ish.js').Element} Element */ /** @typedef {import('../dom/ish.js').Component} Component */ /** @typedef {(node: import('../dom/ish.js').Node, type: typeof ATTRIBUTE | typeof TEXT | typeof COMMENT | typeof COMPONENT, path: number[], name: string, hint: unknown) => unknown} update */ /** @typedef {Element | Component} Container */ /** @type {update} */ const defaultUpdate = (_, type, path, name, hint) => [type, path, name]; /** * @param {Node} node * @returns {number[]} */ const path = node => { const insideout = []; while (node.parent) { switch (node.type) { /* c8 ignore start */ case COMPONENT$1: // fallthrough /* c8 ignore stop */ case ELEMENT: { if (/** @type {Container} */(node).name === 'template') insideout.push(-1); break; } } insideout.push(node.parent.children.indexOf(node)); node = node.parent; } return insideout; }; /** * @param {Node} node * @param {Set<Node>} ignore * @returns {Node} */ const parent = (node, ignore) => { do { node = node.parent; } while (ignore.has(node)); return node; }; var parser = ({ Comment: Comment$1 = Comment, DocumentType: DocumentType$1 = DocumentType, Text: Text$1 = Text, Fragment: Fragment$1 = Fragment, Element: Element$1 = Element, Component: Component$1 = Component, update = defaultUpdate, }) => /** * Parse a template string into a crawable JS literal tree and provide a list of updates. * @param {TemplateStringsArray|string[]} template * @param {unknown[]} holes * @param {boolean} xml * @returns {[Node, unknown[]]} */ (template, holes, xml) => { if (template.some(chunk => chunk.includes(NUL))) throw errors.invalid_nul(template); const content = template.join(NUL).trim(); if (content.replace(/(\S+)=(['"])([\S\s]+?)\2/g, (...a) => /^[^\x00]+\x00|\x00[^\x00]+$/.test(a[3]) ? (xml = a[1]) : a[0]) !== content) throw errors.invalid_attribute(template, xml); const ignore = new Set; const values = []; let node = new Fragment$1, pos = 0, skip = 0, hole = 0, resolvedPath = children; for (const match of content.matchAll(NEXT)) { // already handled via attributes or text content nodes if (0 < skip) { skip--; continue; } const chunk = match[0]; const index = match.index; // prepend previous content, if any if (pos < index) append(node, new Text$1(content.slice(pos, index))); // holes if (chunk === NUL) { if (node.name === 'table') { node = append(node, new Element$1('tbody', xml)); ignore.add(node); } const comment = append(node, new Comment$1('◦')); values.push(update(comment, COMMENT$1, path(comment), '', holes[hole++])); pos = index + 1; } // comments or doctype else if (chunk.startsWith('<!')) { const i = content.indexOf('>', index + 2); if (i < 0) throw errors.invalid_content(template); if (content.slice(i - 2, i + 1) === '-->') { if ((i - index) < 6) throw errors.invalid_comment(template); const data = content.slice(index + 4, i - 2); if (data[0] === '!') append(node, new Comment$1(data.slice(1).replace(/!$/, ''))); } else { if (!content.slice(index + 2, i).toLowerCase().startsWith('doctype')) throw errors.invalid_doctype(template, content.slice(index + 2, i)); append(node, new DocumentType$1(content.slice(index + 2, i))); } pos = i + 1; } // closing tag </> or </name> else if (chunk.startsWith('</')) { const i = content.indexOf('>', index + 2); if (i < 0) throw errors.invalid_closing(template); if (xml && node.name === 'svg') xml = false; node = /** @type {Container} */(parent(node, ignore)); if (!node) throw errors.invalid_layout(template); pos = i + 1; } // opening tag <name> or <name /> else { const i = index + chunk.length; const j = content.indexOf('>', i); const name = chunk.slice(1); if (j < 0) throw errors.unclosed_element(template, name); let tag = name; // <${Component} ... /> if (name === NUL) { tag = 'template'; node = append(node, new Component$1); resolvedPath = path(node).slice(1); //@ts-ignore values.push(update(node, COMPONENT$1, resolvedPath, '', holes[hole++])); } // any other element else { if (!xml) { tag = tag.toLowerCase(); // patch automatic elements insertion with <table> // or path will fail once live on the DOM if (node.name === 'table' && (tag === 'tr' || tag === 'td')) { node = append(node, new Element$1('tbody', xml)); ignore.add(node); } if (node.name === 'tbody' && tag === 'td') { node = append(node, new Element$1('tr', xml)); ignore.add(node); } } node = append(node, new Element$1(tag, xml ? tag !== 'svg' : false)); resolvedPath = children; } // attributes if (i < j) { let dot = false; for (const [_, name, value] of content.slice(i, j).matchAll(ATTRS)) { if (value === NUL || value === DOUBLE_QUOTED_NUL || value === SINGLE_QUOTED_NUL || (dot = name.endsWith(NUL))) { const p = resolvedPath === children ? (resolvedPath = path(node)) : resolvedPath; //@ts-ignore values.push(update(node, ATTRIBUTE$1, p, dot ? name.slice(0, -1) : name, holes[hole++])); dot = false; skip++; } else prop(node, name, value ? value.slice(1, -1) : true); } resolvedPath = children; } pos = j + 1; // to handle self-closing tags const closed = 0 < j && content[j - 1] === '/'; if (xml) { if (closed) { node = node.parent; /* c8 ignore start unable to reproduce, still worth a guard */ if (!node) throw errors.invalid_layout(template); /* c8 ignore stop */ } } else if (closed || VOID_ELEMENTS.has(tag)) { // void elements are never td or tr node = closed ? parent(node, ignore) : node.parent; /* c8 ignore start unable to reproduce, still worth a guard */ if (!node) throw errors.invalid_layout(); /* c8 ignore stop */ } // <svg> switches to xml mode else if (tag === 'svg') xml = true; // text content / data elements content handling else if (TEXT_ELEMENTS.has(tag)) { const index = content.indexOf(`</${name}>`, pos); if (index < 0) throw errors.unclosed(template, tag); const value = content.slice(pos, index); // interpolation as text if (value.trim() === NUL) { skip++; values.push(update(node, TEXT$1, path(node), '', holes[hole++])); } else if (value.includes(NUL)) throw errors.text(template, tag, value); else append(node, new Text$1(value)); // text elements are never td or tr node = node.parent; /* c8 ignore start unable to reproduce, still worth a guard */ if (!node) throw errors.invalid_layout(template); /* c8 ignore stop */ pos = index + name.length + 3; // ignore the closing tag regardless of the content skip++; continue; } } } if (pos < content.length) append(node, new Text$1(content.slice(pos))); /* c8 ignore start */ if (hole < holes.length) throw errors.invalid_template(template); /* c8 ignore stop */ return [node, values]; }; const tree = ((node, i) => i < 0 ? node?.content : node?.childNodes?.[i]) ; var resolve = (root, path) => path.reduceRight(tree, root); //@ts-check let checkType = false, range; /** * @param {DocumentFragment} fragment * @returns {Node | Element} */ const drop = ({ firstChild, lastChild }) => { const r = range || (range = document.createRange()); r.setStartAfter(firstChild); r.setEndAfter(lastChild); r.deleteContents(); //@ts-ignore return firstChild; }; /** * @param {Node} node * @param {1 | 0 | -0 | -1} operation * @returns {Node} */ const diffFragment = (node, operation) => ( checkType && node.nodeType === 11 ? ((1 / operation) < 0 ? //@ts-ignore (operation ? drop(node) : node.lastChild) : //@ts-ignore (operation ? node.valueOf() : node.firstChild)) : node ); const nodes = Symbol('nodes'); const parentNode = { get() { return this.firstChild.parentNode } }; //@ts-ignore const replaceWith = { value(node) { drop(this).replaceWith(node); } }; //@ts-ignore const remove = { value() { drop(this).remove(); } }; const valueOf = { value() { const { parentNode } = this; if (parentNode === this) { if (this[nodes] === children) this[nodes] = [...this.childNodes]; } else { // TODO: verify fragments in lists don't call this twice if (parentNode) { let { firstChild, lastChild } = this; this[nodes] = [firstChild]; while (firstChild !== lastChild) this[nodes].push((firstChild = firstChild.nextSibling)); } this.replaceChildren(...this[nodes]); } return this; } }; /** * @param {DocumentFragment} fragment * @returns {DocumentFragment} */ function PersistentFragment(fragment) { const firstChild = createComment('<>'), lastChild = createComment('</>'); //@ts-ignore fragment.replaceChildren(firstChild, ...fragment.childNodes, lastChild); checkType = true; return defineProperties(fragment, { [nodes]: { writable: true, value: children }, firstChild: { value: firstChild }, lastChild: { value: lastChild }, parentNode, valueOf, replaceWith, remove, }); } PersistentFragment.prototype = DocumentFragment.prototype; // @ts-check /** * @param {Document} document * @returns */ var creator = (document = /** @type {Document} */(globalThis.document)) => { let tpl = document.createElement('template'), range; /** * @param {string} content * @param {boolean} [xml=false] * @returns {DocumentFragment} */ return (content, xml = false) => { if (xml) { if (!range) { range = document.createRange(); range.selectNodeContents( document.createElementNS('http://www.w3.org/2000/svg', 'svg') ); } return range.createContextualFragment(content); } tpl.innerHTML = content; const fragment = tpl.content; tpl = /** @type {HTMLTemplateElement} */(tpl.cloneNode(false)); return fragment; }; }; // @see https://github.com/WebReflection/udomdiff /** * ISC License * * Copyright (c) 2020, Andrea Giammarchi, @WebReflection * * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE * OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR * PERFORMANCE OF THIS SOFTWARE. */ /** * @param {Node[]} a The list of current/live children * @param {Node[]} b The list of future children * @param {(entry: Node, action: number) => Node} get * The callback invoked per each entry related DOM operation. * @param {Node} [before] The optional node used as anchor to insert before. * @returns {Node[]} The same list of future children. */ var diff = (a, b, get, before) => { const parentNode = before.parentNode; const bLength = b.length; let aEnd = a.length; let bEnd = bLength; let aStart = 0; let bStart = 0; let map = null; while (aStart < aEnd || bStart < bEnd) { // append head, tail, or nodes in between: fast path if (aEnd === aStart) { // we could be in a situation where the rest of nodes that // need to be added are not at the end, and in such case // the node to `insertBefore`, if the index is more than 0 // must be retrieved, otherwise it's gonna be the first item. const node = bEnd < bLength ? (bStart ? (get(b[bStart - 1], -0).nextSibling) : get(b[bEnd], 0)) : before; while (bStart < bEnd) parentNode.insertBefore(get(b[bStart++], 1), node); } // remove head or tail: fast path else if (bEnd === bStart) { while (aStart < aEnd) { // remove the node only if it's unknown or not live if (!map || !map.has(a[aStart])) //@ts-ignore get(a[aStart], -1).remove(); aStart++; } } // same node: fast path else if (a[aStart] === b[bStart]) { aStart++; bStart++; } // same tail: fast path else if (a[aEnd - 1] === b[bEnd - 1]) { aEnd--; bEnd--; } // The once here single last swap "fast path" has been removed in v1.1.0 // https://github.com/WebReflection/udomdiff/blob/single-final-swap/esm/index.js#L69-L85 // reverse swap: also fast path else if ( a[aStart] === b[bEnd - 1] && b[bStart] === a[aEnd - 1] ) { // this is a "shrink" operation that could happen in these cases: // [1, 2, 3, 4, 5] // [1, 4, 3, 2, 5] // or asymmetric too // [1, 2, 3, 4, 5] // [1, 2, 3, 5, 6, 4] const node = get(a[--aEnd], -0).nextSibling; parentNode.insertBefore( get(b[bStart++], 1), get(a[aStart++], -0).nextSibling ); parentNode.insertBefore(get(b[--bEnd], 1), node); // mark the future index as identical (yeah, it's dirty, but cheap 👍) // The main reason to do this, is that when a[aEnd] will be reached, // the loop will likely be on the fast path, as identical to b[bEnd]. // In the best case scenario, the next loop will skip the tail, // but in the worst one, this node will be considered as already // processed, bailing out pretty quickly from the map index check a[aEnd] = b[bEnd]; } // map based fallback, "slow" path else { // the map requires an O(bEnd - bStart) operation once // to store all future nodes indexes for later purposes. // In the worst case scenario, this is a full O(N) cost, // and such scenario happens at least when all nodes are different, // but also if both first and last items of the lists are different if (!map) { map = new Map; let i = bStart; while (i < bEnd) map.set(b[i], i++); } const index = map.get(a[aStart]) ?? -1; // this node has no meaning in the future list, so it's more than safe // to remove it, and check the next live node out instead, meaning // that only the live list index should be forwarded //@ts-ignore if (index < 0) get(a[aStart++], -1).remove(); // it's a future node, hence it needs some handling else { // if it's not already processed, look on demand for the next LCS if (bStart < index && index < bEnd) { let i = aStart; // counts the amount of nodes that are the same in the future let sequence = 1; while (++i < aEnd && i < bEnd && map.get(a[i]) === (index + sequence)) sequence++; // effort decision here: if the sequence is longer than replaces // needed to reach such sequence, which would brings again this loop // to the fast path, prepend the difference before a sequence, // and move only the future list index forward, so that aStart // and bStart will be aligned again, hence on the fast path. // An example considering aStart and bStart are both 0: // a: [1, 2, 3, 4] // b: [7, 1, 2, 3, 6] // this would place 7 before 1 and, from that time on, 1, 2, and 3 // will be processed at zero cost if (sequence > (index - bStart)) { const node = get(a[aStart], 0); while (bStart < index) parentNode.insertBefore(get(b[bStart++], 1), node); } // if the effort wasn't good enough, fallback to a replace, // moving both source and target indexes forward, hoping that some // similar node will be found later on, to go back to the fast path else { // TODO: benchmark replaceWith instead parentNode.replaceChild( get(b[bStart++], 1), get(a[aStart++], -1) ); } } // otherwise move the source forward, 'cause there's nothing to do else aStart++; } } } return b; }; const ARRAY = 1 << 0; const ARIA = 1 << 1; const ATTRIBUTE = 1 << 2; const COMMENT = 1 << 3; const COMPONENT = 1 << 4; const DATA = 1 << 5; const DIRECT = 1 << 6; const DOTS = 1 << 7; const EVENT = 1 << 8; const KEY = 1 << 9; const PROP = 1 << 10; const TEXT = 1 << 11; const TOGGLE = 1 << 12; const UNSAFE = 1 << 13; const REF = 1 << 14; const SIGNAL = 1 << 15; // COMPONENT flags const COMPONENT_DIRECT = COMPONENT | DIRECT; const COMPONENT_DOTS = COMPONENT | DOTS; const COMPONENT_PROP = COMPONENT | PROP; const EVENT_ARRAY = EVENT | ARRAY; const COMMENT_ARRAY = COMMENT | ARRAY; const fragment = creator(document); const ref = Symbol('ref'); const aria = (node, values) => { for (const [key, value] of entries(values)) { const name = key === 'role' ? key : `aria-${key.toLowerCase()}`; if (value == null) node.removeAttribute(name); else node.setAttribute(name, value); } }; const attribute = name => (node, value) => { if (value == null) node.removeAttribute(name); else node.setAttribute(name, value); }; const comment_array = (node, value) => { node[nodes] = diff( node[nodes] || children, value, diffFragment, node ); }; const text = new WeakMap; const getText = (ref, value) => { let node = text.get(ref); if (node) node.data = value; else text.set(ref, (node = document.createTextNode(value))); return node; }; const comment_hole = (node, value) => { const current = typeof value === 'object' ? (value ?? node) : getText(node, value); const prev = node[nodes] ?? node; if (current !== prev) prev.replaceWith(diffFragment(node[nodes] = current, 1)); }; const comment_unsafe = xml => (node, value) => { const prev = node[ref] ?? (node[ref] = {}); if (prev.v !== value) { prev.f = PersistentFragment(fragment(value, xml)); prev.v = value; } comment_hole(node, prev.f); }; const comment_signal = (node, value) => { comment_hole(node, value instanceof Signal ? value.value : value); }; const data = ({ dataset }, values) => { for (const [key, value] of entries(values)) { if (value == null) delete dataset[key]; else dataset[key] = value; } }; /** @type {Map<string|Symbol, Function>} */ const directRefs = new Map; /** * @param {string|Symbol} name * @returns {Function} */ const directFor = name => { let fn = directRefs.get(name); if (!fn) directRefs.set(name, (fn = direct$1(name))); return fn; }; const direct$1 = name => (node, value) => { node[name] = value; }; const dots = (node, values) => { for (const [name, value] of entries(values)) attribute(name)(node, value); }; const event = (type, at, array) => array ? ((node, value) => { const prev = node[at]; if (prev?.length) node.removeEventListener(type, ...prev); if (value) node.addEventListener(type, ...value); node[at] = value; }) : ((node, value) => { const prev = node[at]; if (prev) node.removeEventListener(type, prev); if (value) node.addEventListener(type, value); node[at] = value; }) ; const toggle = name => (node, value) => { node.toggleAttribute(name, !!value); }; let k = false; const isKeyed = () => { const wasKeyed = k; k = false; return wasKeyed; }; const update = (node, type, path, name, hint) => { switch (type) { case COMPONENT$1: return [path, hint, COMPONENT]; case COMMENT$1: { if (isArray(hint)) return [path, comment_array, COMMENT_ARRAY]; if (hint instanceof Unsafe) return [path, comment_unsafe(node.xml), UNSAFE]; if (hint instanceof Signal) return [path, comment_signal, COMMENT | SIGNAL]; return [path, comment_hole, COMMENT]; } case TEXT$1: return [path, directFor('textContent'), TEXT]; case ATTRIBUTE$1: { const isComponent = node.type === COMPONENT$1; switch (name.at(0)) { case '@': { if (isComponent) throw errors.invalid_attribute([], name); const array = isArray(hint); return [path, event(name.slice(1), Symbol(name), array), array ? EVENT_ARRAY : EVENT]; } case '?': if (isComponent) throw errors.invalid_attribute([], name); return [path, toggle(name.slice(1)), TOGGLE]; case '.': { return name === '...' ? [path, isComponent ? assign : dots, isComponent ? COMPONENT_DOTS : DOTS] : [path, direct$1(name.slice(1)), isComponent ? COMPONENT_DIRECT : DIRECT] ; } default: { if (isComponent) return [path, direct$1(name), COMPONENT_PROP]; if (name === 'aria') return [path, aria, ARIA]; if (name === 'data' && !/^object$/i.test(node.name)) return [path, data, DATA]; if (name === 'key') { if (1 < path.length) throw errors.invalid_key(hint); return [path, (k = true), KEY]; } if (name === 'ref') return [path, directFor(ref), REF]; if (name.startsWith('on')) return [path, directFor(name.toLowerCase()), DIRECT]; return [path, attribute(name), ATTRIBUTE]; } } } } }; let direct = true; /** @param {boolean} value */ const _set = value => { direct = value; }; /** @returns {boolean} */ const _get = () => direct; //@ts-nocheck /** * @param {Hole} hole * @returns */ const dom = hole => diffFragment(hole.n ? hole.update(hole) : hole.valueOf(false), 1); const holed = (prev, current) => { const changes = [], h = prev.length, l = current.length; for (let c, p, j = 0, i = 0; i < l; i++) { c = current[i]; changes[i] = j < h && (p = prev[j++]).t === c.t ? (current[i] = p).update(c) : c.valueOf(false); } return changes; }; /** * @param {Hole} hole * @param {unknown} value * @returns {Node} */ const keyed$1 = (hole, value) => /** @type {import('./keyed.js').Keyed} */(hole.t[2]).get(value)?.update(hole) ?? hole.valueOf(false); /** * * @param {Function} Component * @param {Object} obj * @param {unknown[]} signals * @returns {Hole} */ const component = (Component, obj, signals) => { const signal = _get$1(); const length = signals.length; let i = 0; _set$1(/** @param {unknown} value */ value => i < length ? signals[i++] : (signals[i++] = value instanceof Signal ? value : signal(value))); const wasDirect = _get(); if (wasDirect) _set(!wasDirect); try { return Component(obj, global); } finally { if (wasDirect) _set(wasDirect); _set$1(signal); } }; /** * @param {Hole} hole * @param {Hole} value * @returns {Hole} */ const getHole = (hole, value) => { if (hole.t === value.t) { hole.update(value); } else { hole.n.replaceWith(dom(value)); hole = value; } return hole; }; const createEffect = (node, value, obj) => { let signals = [], entry = [COMPONENT, null, obj], bootstrap = true, hole; effect(() => { if (bootstrap) { bootstrap = false; hole = component(value, obj, signals); if (!signals.length) signals = children; if (hole) { node.replaceWith(dom(hole)); entry[1] = hole; } else node.remove(); } else { const result = component(value, obj, signals); if (hole) { if (!(result instanceof Hole)) throw errors.invalid_component(value); if (getHole(hole, /** @type {Hole} */(result)) === result) entry[2] = (hole = result); } } }); return entry; }; const updateRefs = refs => { for (const node of refs) { const value = node[ref]; if (typeof value === 'function') value(node); else if (value instanceof Signal) value.value = node; else if (value) value.current = node; } }; const props = Symbol(); const global = {}; class Hole { /** * @param {[DocumentFragment, unknown[], import('./keyed.js').Keyed?]} template * @param {unknown[]} values */ constructor(template, values) { this.t = template; this.v = values; this.n = null; this.k = -1; } /** * @param {boolean} [direct] * @returns {Node} */ valueOf(direct = _get()) { const [fragment, updates, keys] = this.t; const root = document.importNode(fragment, true); const values = this.v; let length = values.length; let changes = children; let node, prev, refs; if (length !== updates.length) throw errors.invalid_interpolation(this.t[3], values); if (0 < length) { changes = updates.slice(0); while (length--) { const [path, update, type] = updates[length]; const value = values[length]; if (prev !== path) { node = resolve(root, path); prev = path; if (!node) throw errors.invalid_path(this.t[3], path); } if (type & COMPONENT) { const obj = node[props] || (node[props] = {}); if (type === COMPONENT) { for (const { name, value } of node.attributes) obj[name] ??= value; obj.children ??= [...node.content.childNodes]; changes[length] = createEffect(node, value, obj); } else { update(obj, value); changes[length] = [type, update, obj]; } } else { let commit = true; if ((type & ARRAY) && !isArray(value)) throw errors.invalid_interpolation(this.t[3], value); if (!direct && (type & COMMENT) && !(type & SI