UNPKG

uhtml

Version:

A minimalistic library to create fast and reactive Web pages

676 lines (597 loc) 20.4 kB
/* 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)}`), }; const { isArray } = Array; const { assign, freeze, keys } = Object; /* 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 = 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) node.props = {}; node.props[name] = value; }; const addJSON = (value, comp, json) => { if (value !== comp) json.push(value); }; const setChildren = (node, json) => { node.children = json.map(revive, node); }; const setJSON = (node, json, index) => { switch (json.length) { case index: setChildren(node, json[index - 1]); case index - 1: { const value = json[index - 2]; if (isArray(value)) setChildren(node, value); else node.props = assign({}, value); } } return node; }; function revive(json) { const node = fromJSON(json); node.parent = this; return node; } const fromJSON = json => { switch (json[0]) { case COMMENT$1: return new Comment(json[1]); case DOCUMENT_TYPE: return new DocumentType(json[1]); case TEXT$1: return new Text(json[1]); case COMPONENT$1: return setJSON(new Component, json, 3); case ELEMENT: return setJSON(new Element(json[1], !!json[2]), json, 5); case FRAGMENT: { const node = new Fragment; if (1 < json.length) node.children = json[1].map(revive, node); return node; } } }; 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; this.children = children; } toJSON() { const json = [COMPONENT$1]; addJSON(this.props, props, 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; this.children = children; } toJSON() { const json = [ELEMENT, this.name, +this.xml]; addJSON(this.props, props, 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(''); } } //@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 : node?.children?.[i]) ; var resolve = (root, path) => path.reduceRight(tree, root); const get = node => { if (node.props === props) node.props = {}; return node.props; }; const set = (props, name, value) => { if (value == null) delete props[name]; else props[name] = value; }; const ARIA = 0; const aria = (node, values) => { const props = get(node); for (const key in values) { const name = key === 'role' ? key : `aria-${key}`; const value = values[key]; set(props, name, value); } if (keys(props).length === 0) node.props = props; }; const ATTRIBUTE = 1; const attribute = name => (node, value) => { const props = get(node); set(props, name, value); if (keys(props).length === 0) node.props = props; }; const COMMENT = 2; const comment = (node, value) => { const { children } = node.parent; const i = children.indexOf(node); if (isArray(value)) { const fragment = new Fragment; fragment.children = value; value = fragment; } else if (!(value instanceof Node)) value = new Text(value == null ? '' : value); children[i] = value; }; const COMPONENT = 3; const component = (node, value) => [node, value]; const DATA = 4; const data = (node, values) => { const props = get(node); for (const key in values) { const name = `data-${key}`; const value = values[key]; set(props, name, value); } if (keys(props).length === 0) node.props = props; }; const DIRECT = 5; const direct = name => (node, value) => { const props = get(node); set(props, name, value); if (keys(props).length === 0) node.props = props; }; const DOTS = 6; const dots = isComponent => (node, value) => { }; const EVENT = 7; const event = at => (node, value) => { const props = get(node); if (value == null) delete props[at]; else props[at] = value; }; const KEY = 8; const TEXT = 9; const text = (node, value) => { if (value == null) node.children = children; else node.children = [new Text(value)]; }; const TOGGLE = 10; const toggle = name => (node, value) => { const props = get(node); if (!value) { delete props[name]; if (keys(props).length === 0) node.props = props; } else props[name] = !!value; }; const update = (node, type, path, name) => { switch (type) { case COMPONENT$1: { return [path, component, COMPONENT]; } case COMMENT$1: { return [path, comment, COMMENT]; } case ATTRIBUTE$1: { switch (name.at(0)) { case '@': return [path, event(Symbol(name)), EVENT]; case '?': return [path, toggle(name.slice(1)), TOGGLE]; case '.': return name === '...' ? [path, dots(node.type === COMPONENT$1), DOTS] : [path, direct(name.slice(1)), DIRECT] ; case 'a': if (name === 'aria') return [path, aria, ARIA]; case 'd': if (name === 'data') return [path, data, DATA]; case 'k': if (name === 'key') return [path, Object, KEY]; default: return [path, attribute(name), ATTRIBUTE]; } } case TEXT$1: return [path, text, TEXT]; } }; const textParser = parser({ Comment, DocumentType, Text, Fragment, Element, Component, update, }); const { parse, stringify } = JSON; const create = xml => { const twm = new WeakMap; const cache = (template, values) => { const parsed = textParser(template, values, xml); parsed[0] = parse(stringify(parsed[0])); twm.set(template, parsed); return parsed; }; return (template, ...values) => { const [json, updates] = twm.get(template) || cache(template, values); const root = fromJSON(json); const length = values.length; if (length === updates.length) { const components = []; for (let node, prev, i = 0; i < length; i++) { const [path, update, type] = updates[i]; const value = values[i]; if (prev !== path) { node = resolve(root, path); prev = path; if (!node) throw errors.invalid_path(path); } if (type === KEY) continue; if (type === COMPONENT) components.push(update(node, value)); else update(node, value); } for (const [node, Component] of components) { const props = assign({ children: node.children }, node.props); comment(node, Component(props)); } } else throw errors.invalid_template(); return root; }; }; const html = create(false); const svg = create(true); export { html, svg };