UNPKG

rvx

Version:

A signal based rendering library

1,077 lines (1,067 loc) 22.3 kB
/*! This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { ENV, MATHML, SVG, HTML, Context, capture, render } from './rvx.js'; import { AsyncContext, ASYNC } from './rvx.async.js'; const WINDOW_MARKER = Symbol.for("rvx:rvx-dom-window"); function isRvxDom() { const current = ENV.current; return current !== null && typeof current === "object" && current[WINDOW_MARKER] === true; } const XMLNS_HTML = 0; const XMLNS_SVG = 1; const XMLNS_MATHML = 2; function resolveNamespaceURI(uri) { switch (uri) { case HTML: return XMLNS_HTML; case SVG: return XMLNS_SVG; case MATHML: return XMLNS_MATHML; default: throw new Error("unsupported namespace uri"); } } function isVoidTag(xmlns, name) { if (xmlns !== XMLNS_HTML) { return false; } switch (name) { case "area": case "base": case "br": case "col": case "embed": case "hr": case "img": case "input": case "link": case "meta": case "param": case "source": case "track": case "wbr": return true; default: return false; } } const HTML_ESCAPE_REGEX = /["'<>&]/; function htmlEscapeAppendTo(html, data) { const firstMatch = HTML_ESCAPE_REGEX.exec(data); if (firstMatch === null) { return html + data; } let last = 0; let index = firstMatch.index; let escape; chars: while (index < data.length) { switch (data.charCodeAt(index)) { case 34: escape = "&#34;"; break; case 38: escape = "&amp;"; break; case 39: escape = "&#39;"; break; case 60: escape = "&lt;"; break; case 62: escape = "&gt;"; break; default: index++; continue chars; } if (index !== last) { html += data.slice(last, index); } html += escape; index++; last = index; } if (index !== last) { html += data.slice(last, index); } return html; } const NODE_LENGTH = Symbol("length"); const NODE_APPEND_HTML_TO = Symbol("appendHtmlTo"); const NODE_CLONE_CHILDREN = Symbol("cloneChildren"); const INIT_CLONED_FROM = Symbol("clone"); class NodeListIterator { #current; constructor(node) { this.#current = node.firstChild; } next() { const current = this.#current; if (current === null) { return { value: null, done: true }; } this.#current = current.nextSibling; return { value: current, done: false }; } } class NodeList { #node; constructor(node) { this.#node = node; } get length() { return this.#node[NODE_LENGTH](); } forEach(cb, thisArg) { let index = 0; let node = this.#node.firstChild; while (node !== null) { cb.call(thisArg, node, index, this); node = node.nextSibling; index++; } } [Symbol.iterator]() { return new NodeListIterator(this.#node); } values() { return new NodeListIterator(this.#node); } } class Event { } class EventTarget { addEventListener() { } removeEventListener() { } dispatchEvent() { throw new Error("dispatching events is not supported"); } } class Document extends EventTarget { get body() { return null; } get activeElement() { return null; } createTextNode(data) { return new Text(data); } createComment(data) { return new Comment(data); } createDocumentFragment() { return new DocumentFragment(); } createElementNS(namespaceURI, tagName) { return new Element(namespaceURI, tagName); } createElement(tagName) { return new Element(HTML, tagName); } } class Node extends EventTarget { #parent = null; #first = null; #last = null; #prev = null; #next = null; #length = 0; #childNodes = null; get parentNode() { return this.#parent; } get firstChild() { return this.#first; } get lastChild() { return this.#last; } get previousSibling() { return this.#prev; } get nextSibling() { return this.#next; } get childNodes() { if (this.#childNodes === null) { this.#childNodes = new NodeList(this); } return this.#childNodes; } [NODE_LENGTH]() { return this.#length; } [NODE_APPEND_HTML_TO](html) { let child = this.#first; while (child !== null) { html = child[NODE_APPEND_HTML_TO](html); child = child.#next; } return html; } [NODE_CLONE_CHILDREN](target) { let child = this.#first; while (child !== null) { const clone = child.cloneNode(true); const prev = target.#last; if (prev === null) { target.#first = clone; } else { prev.#next = clone; } clone.#prev = prev; clone.#parent = target; target.#last = clone; target.#length++; child = child.#next; } } cloneNode(_deep) { throw new Error("not supported"); } contains(node) { if (node === null) { return false; } do { if (node === this) { return true; } node = node.#parent; } while (node !== null); return false; } hasChildNodes() { return this.#length > 0; } removeChild(node) { if (node.#parent !== this) { throw new Error("node is not a child of this node"); } const prev = node.#prev; const next = node.#next; if (prev === null) { this.#first = next; } else { prev.#next = next; } if (next === null) { this.#last = prev; } else { next.#prev = prev; } node.#prev = null; node.#next = null; node.#parent = null; this.#length--; return node; } appendChild(node) { if (node.nodeType === 11) { if (node.#length === 0) { return node; } const prev = this.#last; const first = node.#first; if (prev === null) { this.#first = first; } else { prev.#next = first; } first.#prev = prev; this.#last = node.#last; this.#length += node.#length; let child = first; while (child !== null) { child.#parent = this; child = child.#next; } node.#first = null; node.#last = null; node.#length = 0; return node; } node.#parent?.removeChild(node); const prev = this.#last; if (prev === null) { this.#first = node; } else { prev.#next = node; } node.#prev = prev; node.#parent = this; this.#last = node; this.#length++; return node; } insertBefore(node, ref) { if (ref.#parent !== this) { throw new Error("ref must be a child of this node"); } if (node === ref) { return node; } if (node.nodeType === 11) { if (node.#length === 0) { return node; } const prev = ref.#prev; const first = node.#first; const last = node.#last; if (prev === null) { this.#first = first; } else { prev.#next = first; } first.#prev = prev; last.#next = ref; ref.#prev = last; this.#length += node.#length; let child = first; while (child !== null) { child.#parent = this; child = child.#next; } node.#first = null; node.#last = null; node.#length = 0; return node; } node.#parent?.removeChild(node); const prev = ref.#prev; if (prev === null) { this.#first = node; } else { prev.#next = node; } ref.#prev = node; node.#parent = this; node.#prev = prev; node.#next = ref; this.#length++; return node; } replaceChild(node, ref) { if (ref.#parent !== this) { throw new Error("ref must be a child of this node"); } if (node.nodeType === 11) { if (node.#length === 0) { const prev = ref.#prev; const next = ref.#next; if (prev === null) { this.#first = next; } else { prev.#next = next; } if (next === null) { this.#last = prev; } else { next.#prev = prev; } ref.#parent = null; ref.#prev = null; ref.#next = null; this.#length--; } else { const first = node.#first; const last = node.#last; const prev = ref.#prev; const next = ref.#next; if (prev === null) { this.#first = first; } else { prev.#next = first; first.#prev = prev; } if (next === null) { this.#last = last; } else { next.#prev = last; last.#next = next; } ref.#parent = null; ref.#prev = null; ref.#next = null; this.#length = this.#length - 1 + node.#length; node.#first = null; node.#last = null; node.#length = 0; let child = first; while (child !== null) { child.#parent = this; child = child.#next; } } return ref; } const prev = ref.#prev; const next = ref.#next; if (prev === null) { this.#first = node; } else { prev.#next = node; } if (next === null) { this.#last = node; } else { next.#prev = node; } node.#parent = this; node.#prev = prev; node.#next = next; ref.#parent = null; ref.#prev = null; ref.#next = null; return ref; } remove() { this.#parent?.removeChild(this); } append(...nodes) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (typeof node === "string") { this.appendChild(new Text(node)); } else { this.appendChild(node); } } } replaceChildren(...nodes) { let child = this.#first; while (child !== null) { const next = child.#next; child.#parent = null; child.#prev = null; child.#next = null; child = next; } this.#length = 0; this.#first = null; this.#last = null; this.append(...nodes); } get textContent() { let text = ""; let node = this.#first; while (node !== null) { if (node.nodeType !== 8) { text += node.textContent; } node = node.#next; } return text; } get outerHTML() { return this[NODE_APPEND_HTML_TO](""); } } class DocumentFragment extends Node { static { this.prototype.nodeType = 11; this.prototype.nodeName = "#document-fragment"; } cloneNode(deep) { const clone = new DocumentFragment(); if (deep) { this[NODE_CLONE_CHILDREN](clone); } return clone; } } const VISIBLE_COMMENTS = new Context(false); class Comment extends Node { static { this.prototype.nodeType = 8; this.prototype.nodeName = "#comment"; } #data; #visible = VISIBLE_COMMENTS.current; constructor(data) { super(); this.#data = String(data); } get textContent() { return this.#data; } set textContent(data) { this.#data = String(data); } cloneNode(_deep) { const clone = new Comment(this.#data); clone.#visible = this.#visible; return clone; } [NODE_APPEND_HTML_TO](html) { if (this.#visible) { return html + "<!--" + this.#data + "-->"; } else { return html; } } } class Text extends Node { static { this.prototype.nodeType = 3; this.prototype.nodeName = "#text"; } #data; constructor(data) { super(); this.#data = String(data); } get textContent() { return this.#data; } set textContent(data) { this.#data = String(data); } cloneNode(_deep) { return new Text(this.#data); } [NODE_APPEND_HTML_TO](html) { return htmlEscapeAppendTo(html, this.#data); } } const ATTR_CHANGED = Symbol("attrChanged"); class ElementClassList { #attrs; #attr = null; #tokens = null; constructor(attrs) { this.#attrs = attrs; } [INIT_CLONED_FROM](from) { if (from.#attr !== null) { this.#attr = this.#attrs.find(a => a.name === "class"); } if (from.#tokens !== null) { this.#tokens = Array.from(from.#tokens); } } get length() { return this.#parse().length; } get value() { const attr = this.#attr; if (attr === null || attr.stale) { const tokens = this.#tokens; if (tokens === null) { return ""; } let value = ""; for (let i = 0; i < tokens.length; i++) { if (i > 0) { value += " "; } value += tokens[i]; } attr.value = value; attr.stale = false; return value; } return attr.value; } #parse() { let tokens = this.#tokens; if (tokens === null) { const attr = this.#attr; if (attr === null || attr.stale) { tokens = []; } else { tokens = attr.value.split(" "); } this.#tokens = tokens; } return tokens; } #setAttrStale() { const attr = this.#attr; if (attr === null) { this.#attrs.push(this.#attr = { name: "class", value: "", stale: true }); } else { attr.stale = true; } } [ATTR_CHANGED](attr) { this.#attr = attr; this.#tokens = null; } add(...tokens) { const set = this.#parse(); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (!set.includes(token)) { set.push(token); } } this.#setAttrStale(); } contains(token) { return this.#parse().includes(String(token)); } remove(...tokens) { const set = this.#parse(); for (let i = 0; i < tokens.length; i++) { const token = String(tokens[i]); const index = set.indexOf(token); if (index >= 0) { set.splice(index, 1); } } this.#setAttrStale(); } replace(oldToken, newToken) { const set = this.#parse(); const index = set.indexOf(String(oldToken)); if (index >= 0) { set[index] = String(newToken); this.#setAttrStale(); return true; } return false; } toggle(token, force) { token = String(token); const set = this.#parse(); const index = set.indexOf(token); let exists = false; if (force === undefined) { if (index < 0) { set.push(token); exists = true; } else { set.splice(index, 1); } } else if (force) { if (index < 0) { set.push(token); } exists = true; } else if (index >= 0) { set.splice(index, 1); } this.#setAttrStale(); return exists; } values() { return this.#parse()[Symbol.iterator](); } [Symbol.iterator]() { return this.#parse()[Symbol.iterator](); } } class ElementStyles { #attrs; #attr = null; #props = null; constructor(attrs) { this.#attrs = attrs; } [INIT_CLONED_FROM](from) { if (from.#attr !== null) { this.#attr = this.#attrs.find(a => a.name === "style"); } if (from.#props !== null) { this.#props = from.#props.map(prop => { return { name: prop.name, value: prop.value, important: prop.important, }; }); } } get cssText() { const attr = this.#attr; if (attr === null || attr.stale) { const props = this.#props; if (props === null) { return ""; } let cssText = ""; for (let i = 0; i < props.length; i++) { const prop = props[i]; if (i > 0) { cssText += "; "; } cssText = cssText + prop.name + ": " + prop.value; if (prop.important) { cssText += " !important"; } } attr.stale = false; attr.value = cssText; return cssText; } return attr.value; } #parse() { let props = this.#props; if (props === null) { const attr = this.#attr; if (attr === null || attr.stale || attr.value === "") { this.#props = props = []; } else { throw new Error("style attribute parsing is not supported"); } } return props; } #setAttrStale() { const attr = this.#attr; if (attr === null) { this.#attrs.push(this.#attr = { name: "style", value: "", stale: true }); } else { attr.stale = true; } } [ATTR_CHANGED](attr) { this.#attr = attr; this.#props = null; } setProperty(name, value, priority) { const props = this.#parse(); for (let i = 0; i < props.length; i++) { const prop = props[i]; if (prop.name === name) { prop.value = value === null ? "" : String(value); prop.important = priority === "important"; this.#setAttrStale(); return; } } props.push({ name, value: value === null ? "" : String(value), important: priority === "important", }); this.#setAttrStale(); } removeProperty(name) { const props = this.#parse(); for (let i = 0; i < props.length; i++) { const prop = props[i]; if (prop.name === name) { props.splice(i, 1); this.#setAttrStale(); return prop.value; } } return ""; } getPropertyValue(name) { const props = this.#parse(); for (let i = 0; i < props.length; i++) { const prop = props[i]; if (prop.name === name) { return prop.value; } } return ""; } } class Element extends Node { static { this.prototype.nodeType = 1; } #xmlns; #namespaceURI; #void; #tagName; #attrs = []; #classList = new ElementClassList(this.#attrs); #styles = new ElementStyles(this.#attrs); constructor(namespaceURI, tagName) { super(); this.#xmlns = resolveNamespaceURI(namespaceURI); this.#namespaceURI = namespaceURI; this.#tagName = tagName; } get tagName() { return this.#tagName; } get nodeName() { return this.#tagName; } get namespaceURI() { return this.#namespaceURI; } get innerHTML() { let html = ""; let child = this.firstChild; while (child !== null) { html = child[NODE_APPEND_HTML_TO](html); child = child.nextSibling; } return html; } set innerHTML(html) { if (html === "") { this.replaceChildren(); } else { this.replaceChildren(new RawHTML(html)); } } get classList() { if (this.#classList === null) { this.#classList = new ElementClassList(this.#attrs); } return this.#classList; } get style() { if (this.#styles === null) { this.#styles = new ElementStyles(this.#attrs); } return this.#styles; } cloneNode(deep) { const clone = new Element(this.#namespaceURI, this.#tagName); clone.#void = this.#void; const attrs = this.#attrs; const cloneAttrs = clone.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; cloneAttrs.push({ name: attr.name, value: attr.value, stale: attr.stale, }); } clone.#classList[INIT_CLONED_FROM](this.#classList); clone.#styles[INIT_CLONED_FROM](this.#styles); if (deep) { this[NODE_CLONE_CHILDREN](clone); } return clone; } focus() { } blur() { } #attrChanged(name, attr) { switch (name) { case "class": this.#classList[ATTR_CHANGED](attr); break; case "style": this.#styles[ATTR_CHANGED](attr); break; } } setAttribute(name, value) { const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name === name) { attr.value = String(value); attr.stale = false; this.#attrChanged(name, attr); return; } } const attr = { name, value: String(value), stale: false, }; attrs.push(attr); this.#attrChanged(name, attr); } removeAttribute(name) { const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name === name) { attrs.splice(i, 1); this.#attrChanged(name, null); return; } } } toggleAttribute(name, force) { const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name === name) { if (force === undefined || !force) { attrs.splice(i, 1); this.#attrChanged(name, null); } return; } } if (force === undefined || force) { const attr = { name, value: "", stale: false, }; attrs.push(attr); this.#attrChanged(name, attr); } } getAttribute(name) { const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name === name) { return this.#resolveAttr(attr); } } return null; } hasAttribute(name) { const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { if (attrs[i].name === name) { return true; } } return false; } #resolveAttr(attr) { if (attr.stale) { switch (attr.name) { case "class": return this.#classList.value; case "style": return this.#styles.cssText; default: throw new Error("invalid internal state"); } } return attr.value; } #isVoidTag() { return this.#void ?? (this.#void = isVoidTag(this.#xmlns, this.#tagName)); } [NODE_APPEND_HTML_TO](html) { html = html + "<" + this.#tagName; const attrs = this.#attrs; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; html = htmlEscapeAppendTo(html + " " + attr.name + "=\"", this.#resolveAttr(attr)) + "\""; } if (this.#isVoidTag()) { html += ">"; } else if (this.hasChildNodes() || this.#xmlns === XMLNS_HTML) { html = super[NODE_APPEND_HTML_TO](html + ">") + "</" + this.#tagName + ">"; } else { html += "/>"; } return html; } } class RawHTML extends Node { static { this.prototype.nodeType = 0; this.prototype.nodeName = "#rvx-dom-raw-html"; } #html; constructor(html) { super(); this.#html = html; } cloneNode(_deep) { return new RawHTML(this.#html); } [NODE_APPEND_HTML_TO](html) { return html + this.#html; } } class Window extends EventTarget { static { this.prototype[WINDOW_MARKER] = true; this.prototype.Comment = Comment; this.prototype.CustomEvent = Event; this.prototype.Document = Document; this.prototype.DocumentFragment = DocumentFragment; this.prototype.Element = Element; this.prototype.Event = Event; this.prototype.Node = Node; this.prototype.Text = Text; } window = this; document = new Document(); } const WINDOW = new Window(); function renderDetachedView(view) { const { first, last } = view; if (!(first instanceof Node)) { throw new Error("root is not an rvx dom node"); } if (first === last) { return first.outerHTML; } else { return first.parentNode.outerHTML; } } function renderToString(component, props) { let html; capture(() => { ENV.provide(WINDOW, () => { const view = render(component(props)); html = renderDetachedView(view); }); })(); return html; } async function renderToStringAsync(component, props) { const asyncCtx = new AsyncContext(); let view; const dispose = capture(() => { Context.provide([ ENV.with(WINDOW), ASYNC.with(asyncCtx), ], () => { view = render(component(props)); }); }); try { await asyncCtx.complete(); return renderDetachedView(view); } finally { dispose(); } } export { Comment, Document, DocumentFragment, Element, ElementClassList, ElementStyles, Event, EventTarget, Node, NodeList, RawHTML, Text, VISIBLE_COMMENTS, WINDOW, Window, isRvxDom, renderToString, renderToStringAsync };