rvx
Version:
A signal based rendering library
1,008 lines (892 loc) • 20.5 kB
text/typescript
import { Context } from "../core/context.js";
import { HTML } from "../core/element-common.js";
import { isVoidTag, resolveNamespaceURI, XMLNS, XMLNS_HTML } from "./internals/element-info.js";
import { htmlEscapeAppendTo } from "./internals/html-escape.js";
import { WINDOW_MARKER } from "./internals/window-marker.js";
const NODE_LENGTH = Symbol("length");
const NODE_APPEND_HTML_TO = Symbol("appendHtmlTo");
class NodeListIterator implements Iterator<Node> {
#current: Node | null;
constructor(node: Node) {
this.#current = node.firstChild;
}
next(): IteratorResult<Node, any> {
const current = this.#current;
if (current === null) {
return { value: null, done: true };
}
this.#current = current.nextSibling;
return { value: current, done: false };
}
}
export class NodeList {
#node: Node;
constructor(node: Node) {
this.#node = node;
}
get length(): number {
return this.#node[NODE_LENGTH]();
}
forEach(cb: (node: Node, index: number, list: NodeList) => void, thisArg?: unknown): void {
let index = 0;
let node = this.#node.firstChild;
while (node !== null) {
cb.call(thisArg, node, index, this);
node = node.nextSibling;
index++;
}
}
[Symbol.iterator](): Iterator<Node> {
return new NodeListIterator(this.#node);
}
values(): Iterator<Node> {
return new NodeListIterator(this.#node);
}
}
export class Event {}
/**
* @deprecated Use {@link Event} instead.
*/
export const NoopEvent = Event;
export class EventTarget {
addEventListener(): void {
// noop
}
removeEventListener(): void {
// noop
}
dispatchEvent(): void {
throw new Error("dispatching events is not supported");
}
}
/**
* @deprecated Use {@link EventTarget} instead.
*/
export const NoopEventTarget = EventTarget;
export class Document extends EventTarget {
get body(): Element | null {
// noop
return null;
}
get activeElement(): Element | null {
// noop
return null;
}
createTextNode(data: string) {
return new Text(data);
}
createComment(data: string) {
return new Comment(data);
}
createDocumentFragment() {
return new DocumentFragment();
}
createElementNS(namespaceURI: string, tagName: string) {
return new Element(namespaceURI, tagName);
}
createElement(tagName: string) {
return new Element(HTML, tagName);
}
}
export class Node extends EventTarget {
#parent: Node | null = null;
#first: Node | null = null;
#last: Node | null = null;
#prev: Node | null = null;
#next: Node | null = null;
#length = 0;
#childNodes: NodeList | null = null;
get parentNode(): Node | null {
return this.#parent;
}
get firstChild(): Node | null {
return this.#first;
}
get lastChild(): Node | null {
return this.#last;
}
get previousSibling(): Node | null {
return this.#prev;
}
get nextSibling(): Node | null {
return this.#next;
}
get childNodes(): NodeList {
if (this.#childNodes === null) {
this.#childNodes = new NodeList(this);
}
return this.#childNodes;
}
/**
* Get the direct number of child nodes.
*/
[NODE_LENGTH](): number {
return this.#length;
}
/**
* Append the HTML representation of this node to the specified HTML string.
*
* @param html An existing HTML string.
* @returns The concatenated HTML string.
*/
[NODE_APPEND_HTML_TO](html: string): string {
let child = this.firstChild;
while (child !== null) {
html = child[NODE_APPEND_HTML_TO](html);
child = child.nextSibling;
}
return html;
}
contains(node: Node | null) {
if (node === null) {
return false;
}
do {
if (node === this) {
return true;
}
node = node.#parent;
} while (node !== null);
return false;
}
hasChildNodes(): boolean {
return this.#length > 0;
}
removeChild(node: Node): 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: Node): 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: Node | null = 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: Node, ref: Node): Node {
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: Node | null = 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: Node, ref: Node): Node {
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: Node | null = 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(): void {
this.#parent?.removeChild(this);
}
append(...nodes: (Node | string)[]): void {
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: (Node | string)[]): void {
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(): string {
let text = "";
let node = this.#first;
while (node !== null) {
if (node.nodeType !== 8) {
text += node.textContent;
}
node = node.#next;
}
return text;
}
get outerHTML(): string {
return this[NODE_APPEND_HTML_TO]("");
}
}
export interface Node {
nodeType: number;
nodeName: string;
}
export class DocumentFragment extends Node {
static {
this.prototype.nodeType = 11;
this.prototype.nodeName = "#document-fragment";
}
}
/**
* A context that controls if newly created comment nodes are visible in rendered html.
*
* **SECURITY:** Comment data is not escaped when rendering and can be used to produce invalid or malicious HTML.
*
* @default false
*/
export const VISIBLE_COMMENTS = new Context(false);
export class Comment extends Node {
static {
this.prototype.nodeType = 8;
this.prototype.nodeName = "#comment";
}
#data: string;
#visible = VISIBLE_COMMENTS.current;
constructor(data: string) {
super();
this.#data = String(data);
}
/**
* Get or set comment data.
*
* **SECURITY:** Comment data is not escaped when rendering and can be used to produce invalid or malicious HTML.
*/
get textContent() {
return this.#data;
}
set textContent(data: string) {
this.#data = String(data);
}
[NODE_APPEND_HTML_TO](html: string): string {
if (this.#visible) {
return html + "<!--" + this.#data + "-->";
} else {
return html;
}
}
}
/**
* @deprecated Use {@link Comment} instead.
*/
export const NoopComment = Comment;
export class Text extends Node {
static {
this.prototype.nodeType = 3;
this.prototype.nodeName = "#text";
}
#data: string;
constructor(data: string) {
super();
this.#data = String(data);
}
get textContent() {
return this.#data;
}
set textContent(data: string) {
this.#data = String(data);
}
[NODE_APPEND_HTML_TO](html: string): string {
return htmlEscapeAppendTo(html, this.#data);
}
}
const ATTR_CHANGED = Symbol("attrChanged");
interface Attribute {
name: string;
value: string;
stale: boolean;
}
export class ElementClassList {
#attrs: Attribute[];
#attr: Attribute | null = null;
#tokens: string[] | null = null;
constructor(attrs: Attribute[]) {
this.#attrs = attrs;
}
get length(): number {
return this.#parse().length;
}
get value(): string {
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(): string[] {
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(): void {
const attr = this.#attr;
if (attr === null) {
this.#attrs.push(this.#attr = { name: "class", value: "", stale: true });
} else {
attr.stale = true;
}
}
[ATTR_CHANGED](attr: Attribute | null): void {
this.#attr = attr;
this.#tokens = null;
}
add(...tokens: string[]): void {
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: string): boolean {
return this.#parse().includes(String(token));
}
remove(...tokens: string[]): void {
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: string, newToken: string): boolean {
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: string, force?: boolean): boolean {
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(): IterableIterator<string> {
return this.#parse()[Symbol.iterator]();
}
[Symbol.iterator](): IterableIterator<string> {
return this.#parse()[Symbol.iterator]();
}
}
interface StyleProp {
name: string;
value: string;
important: boolean;
}
export class ElementStyles {
#attrs: Attribute[];
#attr: Attribute | null = null;
#props: StyleProp[] | null = null;
constructor(attrs: Attribute[]) {
this.#attrs = attrs;
}
get cssText(): string {
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(): StyleProp[] {
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(): void {
const attr = this.#attr;
if (attr === null) {
this.#attrs.push(this.#attr = { name: "style", value: "", stale: true });
} else {
attr.stale = true;
}
}
[ATTR_CHANGED](attr: Attribute | null): void {
this.#attr = attr;
this.#props = null;
}
setProperty(name: string, value: string, priority?: "" | "important"): void {
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: string): string {
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: string): string {
const props = this.#parse();
for (let i = 0; i < props.length; i++) {
const prop = props[i];
if (prop.name === name) {
return prop.value;
}
}
return "";
}
}
export class Element extends Node {
static {
this.prototype.nodeType = 1;
}
#xmlns: XMLNS;
#namespaceURI: string;
#void: boolean | undefined;
#tagName: string;
#attrs: Attribute[] = [];
#classList = new ElementClassList(this.#attrs);
#styles = new ElementStyles(this.#attrs);
constructor(namespaceURI: string, tagName: string) {
super();
this.#xmlns = resolveNamespaceURI(namespaceURI);
this.#namespaceURI = namespaceURI;
this.#tagName = tagName;
}
get tagName(): string {
return this.#tagName;
}
get nodeName(): string {
return this.#tagName;
}
get namespaceURI(): string {
return this.#namespaceURI;
}
/**
* Get or set inner HTML of this element.
*
* When set to a non-empty string, all children are replaced with a {@link RawHTML} node.
*/
get innerHTML(): string {
let html = "";
let child = this.firstChild;
while (child !== null) {
html = child[NODE_APPEND_HTML_TO](html);
child = child.nextSibling;
}
return html;
}
set innerHTML(html: string) {
if (html === "") {
this.replaceChildren();
} else {
this.replaceChildren(new RawHTML(html));
}
}
get classList(): ElementClassList {
if (this.#classList === null) {
this.#classList = new ElementClassList(this.#attrs);
}
return this.#classList;
}
get style(): ElementStyles {
if (this.#styles === null) {
this.#styles = new ElementStyles(this.#attrs);
}
return this.#styles;
}
focus(): void {
// noop
}
blur(): void {
// noop
}
#attrChanged(name: string, attr: Attribute | null) {
switch (name) {
case "class":
this.#classList[ATTR_CHANGED](attr);
break;
case "style":
this.#styles[ATTR_CHANGED](attr);
break;
}
}
setAttribute(name: string, value: string): void {
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: Attribute = {
name,
value: String(value),
stale: false,
};
attrs.push(attr);
this.#attrChanged(name, attr);
}
removeAttribute(name: string): void {
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: string, force?: boolean): void {
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: Attribute = {
name,
value: "",
stale: false,
};
attrs.push(attr);
this.#attrChanged(name, attr);
}
}
getAttribute(name: string): string | null {
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: string): boolean {
const attrs = this.#attrs;
for (let i = 0; i < attrs.length; i++) {
if (attrs[i].name === name) {
return true;
}
}
return false;
}
#resolveAttr(attr: Attribute): string {
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(): boolean {
return this.#void ?? (this.#void = isVoidTag(this.#xmlns, this.#tagName));
}
[NODE_APPEND_HTML_TO](html: string): string {
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;
}
}
export class RawHTML extends Node {
static {
this.prototype.nodeType = 0;
this.prototype.nodeName = "#rvx-dom-raw-html";
}
#html: string;
constructor(html: string) {
super();
this.#html = html;
}
[NODE_APPEND_HTML_TO](html: string): string {
return html + this.#html;
}
}
export 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();
}
export interface Window {
[WINDOW_MARKER]: boolean;
Comment: typeof Comment;
CustomEvent: typeof Event;
Document: typeof Document;
DocumentFragment: typeof DocumentFragment;
Element: typeof Element;
Event: typeof Event;
Node: typeof Node;
Text: typeof Text;
}
/**
* A global default rvxdom window instance.
*/
export const WINDOW = new Window();