UNPKG

rvx

Version:

A signal based rendering library

1,004 lines (991 loc) 20.8 kB
/*! MIT License Copyright (c) 2025 Max J. Polster Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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"); 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 { } const NoopEvent = Event; class EventTarget { addEventListener() { } removeEventListener() { } dispatchEvent() { throw new Error("dispatching events is not supported"); } } const NoopEventTarget = EventTarget; 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.firstChild; while (child !== null) { html = child[NODE_APPEND_HTML_TO](html); child = child.nextSibling; } return html; } 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.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"; } } 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); } [NODE_APPEND_HTML_TO](html) { if (this.#visible) { return html + "<!--" + this.#data + "-->"; } else { return html; } } } const NoopComment = Comment; 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); } [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; } 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; } 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 = String(value); prop.important = priority === "important"; this.#setAttrStale(); return; } } props.push({ name, value: 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; } 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; } [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.inject(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.inject([ 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, NoopComment, NoopEvent, NoopEventTarget, RawHTML, Text, VISIBLE_COMMENTS, WINDOW, Window, isRvxDom, renderToString, renderToStringAsync };