diff --git a/packages/html/browser.js b/packages/html/browser.js new file mode 100644 index 0000000..65c97db --- /dev/null +++ b/packages/html/browser.js @@ -0,0 +1,15 @@ +// Phase 2 (rendering core) lands here: a runtime-only tagged template that +// parses each template once (keyed by its strings array in a WeakMap) and +// instantiates DOM by cloning. No compile step, ever. +// +// Until then, importing @choojs/html in a browser build fails loudly rather +// than silently rendering nothing. + +export default function html () { + throw new Error( + '@choojs/html: the browser renderer is not implemented yet (v8 Phase 2). ' + + 'Server-side rendering via the "node" condition works today.' + ) +} + +export { html } diff --git a/packages/html/morph.js b/packages/html/morph.js new file mode 100644 index 0000000..46c4c66 --- /dev/null +++ b/packages/html/morph.js @@ -0,0 +1,322 @@ +// Ported from nanomorph 5.4.3 (MIT) — https://github.com/choojs/nanomorph +// Morph one DOM tree into another. Single-module consolidation of +// index.js + lib/morph.js + lib/events.js. + +/* eslint-disable eqeqeq */ +function equal (a, b, message) { + if (a != b) throw new Error(message) +} +function notEqual (a, b, message) { + if (a == b) throw new Error(message) +} +/* eslint-enable eqeqeq */ + +const ELEMENT_NODE = 1 +const TEXT_NODE = 3 +const COMMENT_NODE = 8 + +const events = [ + // attribute events (can be set with attributes) + 'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover', + 'onmousemove', 'onmouseout', 'onmouseenter', 'onmouseleave', + 'ontouchcancel', 'ontouchend', 'ontouchmove', 'ontouchstart', + 'ondragstart', 'ondrag', 'ondragenter', 'ondragleave', 'ondragover', + 'ondrop', 'ondragend', 'onkeydown', 'onkeypress', 'onkeyup', 'onunload', + 'onabort', 'onerror', 'onresize', 'onscroll', 'onselect', 'onchange', + 'onsubmit', 'onreset', 'onfocus', 'onblur', 'oninput', + 'onanimationend', 'onanimationiteration', 'onanimationstart', + // other common events + 'oncontextmenu', 'onfocusin', 'onfocusout' +] + +// Morph one tree into another tree +// +// no parent +// -> same: diff and walk children +// -> not same: replace and return +// old node doesn't exist +// -> insert new node +// new node doesn't exist +// -> delete old node +// nodes are not the same +// -> diff nodes and apply patch to old node +// nodes are the same +// -> walk all child nodes and append to old node +export default function nanomorph (oldTree, newTree, options) { + equal(typeof oldTree, 'object', 'nanomorph: oldTree should be an object') + equal(typeof newTree, 'object', 'nanomorph: newTree should be an object') + + if (options && options.childrenOnly) { + updateChildren(newTree, oldTree) + return oldTree + } + + notEqual( + newTree.nodeType, + 11, + 'nanomorph: newTree should have one root node (which is not a DocumentFragment)' + ) + + return walk(newTree, oldTree) +} + +// Walk and morph a dom tree +function walk (newNode, oldNode) { + if (!oldNode) { + return newNode + } else if (!newNode) { + return null + } else if (newNode.isSameNode && newNode.isSameNode(oldNode)) { + return oldNode + } else if (newNode.tagName !== oldNode.tagName || getComponentId(newNode) !== getComponentId(oldNode)) { + return newNode + } else { + morph(newNode, oldNode) + updateChildren(newNode, oldNode) + return oldNode + } +} + +function getComponentId (node) { + return node.dataset ? node.dataset.nanomorphComponentId : undefined +} + +// Update the children of elements +function updateChildren (newNode, oldNode) { + let oldChild, newChild, morphed, oldMatch + + // The offset is only ever increased, and used for [i - offset] in the loop + let offset = 0 + + for (let i = 0; ; i++) { + oldChild = oldNode.childNodes[i] + newChild = newNode.childNodes[i - offset] + + // Both nodes are empty, do nothing + if (!oldChild && !newChild) { + break + + // There is no new child, remove old + } else if (!newChild) { + oldNode.removeChild(oldChild) + i-- + + // There is no old child, add new + } else if (!oldChild) { + oldNode.appendChild(newChild) + offset++ + + // Both nodes are the same, morph + } else if (same(newChild, oldChild)) { + morphed = walk(newChild, oldChild) + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild) + offset++ + } + + // Both nodes do not share an ID or a placeholder, try reorder + } else { + oldMatch = null + + // Try and find a similar node somewhere in the tree + for (let j = i; j < oldNode.childNodes.length; j++) { + if (same(oldNode.childNodes[j], newChild)) { + oldMatch = oldNode.childNodes[j] + break + } + } + + // If there was a node with the same ID or placeholder in the old list + if (oldMatch) { + morphed = walk(newChild, oldMatch) + if (morphed !== oldMatch) offset++ + oldNode.insertBefore(morphed, oldChild) + + // It's safe to morph two nodes in-place if neither has an ID + } else if (!newChild.id && !oldChild.id) { + morphed = walk(newChild, oldChild) + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild) + offset++ + } + + // Insert the node at the index if we couldn't morph or find a matching node + } else { + oldNode.insertBefore(newChild, oldChild) + offset++ + } + } + } +} + +function same (a, b) { + if (a.id) return a.id === b.id + if (a.isSameNode) return a.isSameNode(b) + if (a.tagName !== b.tagName) return false + if (a.type === TEXT_NODE) return a.nodeValue === b.nodeValue + return false +} + +// diff elements and apply the resulting patch to the old node +function morph (newNode, oldNode) { + const nodeType = newNode.nodeType + const nodeName = newNode.nodeName + + if (nodeType === ELEMENT_NODE) { + copyAttrs(newNode, oldNode) + } + + if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue + } + } + + // Some DOM nodes are weird + // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + if (nodeName === 'INPUT') updateInput(newNode, oldNode) + else if (nodeName === 'OPTION') updateOption(newNode, oldNode) + else if (nodeName === 'TEXTAREA') updateTextarea(newNode, oldNode) + + copyEvents(newNode, oldNode) +} + +function copyAttrs (newNode, oldNode) { + const oldAttrs = oldNode.attributes + const newAttrs = newNode.attributes + let attrNamespaceURI = null + let attrValue = null + let fromValue = null + let attrName = null + let attr = null + + for (let i = newAttrs.length - 1; i >= 0; --i) { + attr = newAttrs[i] + attrName = attr.name + attrNamespaceURI = attr.namespaceURI + attrValue = attr.value + if (attrNamespaceURI) { + attrName = attr.localName || attrName + fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrName) + if (fromValue !== attrValue) { + oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue) + } + } else { + if (!oldNode.hasAttribute(attrName)) { + oldNode.setAttribute(attrName, attrValue) + } else { + fromValue = oldNode.getAttribute(attrName) + if (fromValue !== attrValue) { + // apparently values are always cast to strings, ah well + if (attrValue === 'null' || attrValue === 'undefined') { + oldNode.removeAttribute(attrName) + } else { + oldNode.setAttribute(attrName, attrValue) + } + } + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + for (let j = oldAttrs.length - 1; j >= 0; --j) { + attr = oldAttrs[j] + if (attr.specified !== false) { + attrName = attr.name + attrNamespaceURI = attr.namespaceURI + + if (attrNamespaceURI) { + attrName = attr.localName || attrName + if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) { + oldNode.removeAttributeNS(attrNamespaceURI, attrName) + } + } else { + if (!newNode.hasAttributeNS(null, attrName)) { + oldNode.removeAttribute(attrName) + } + } + } + } +} + +function copyEvents (newNode, oldNode) { + for (let i = 0; i < events.length; i++) { + const ev = events[i] + if (newNode[ev]) { // if new element has a whitelisted attribute + oldNode[ev] = newNode[ev] // update existing element + } else if (oldNode[ev]) { // if existing element has it and new one doesnt + oldNode[ev] = undefined // remove it from existing element + } + } +} + +function updateOption (newNode, oldNode) { + updateAttribute(newNode, oldNode, 'selected') +} + +// The "value" attribute is special for the element since it sets the +// initial value. Changing the "value" attribute without changing the "value" +// property will have no effect since it is only used to the set the initial +// value. Similar for the "checked" attribute, and "disabled". +function updateInput (newNode, oldNode) { + const newValue = newNode.value + const oldValue = oldNode.value + + updateAttribute(newNode, oldNode, 'checked') + updateAttribute(newNode, oldNode, 'disabled') + + // The "indeterminate" property can not be set using an HTML attribute. + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox + if (newNode.indeterminate !== oldNode.indeterminate) { + oldNode.indeterminate = newNode.indeterminate + } + + // Persist file value since file inputs can't be changed programatically + if (oldNode.type === 'file') return + + if (newValue !== oldValue) { + oldNode.setAttribute('value', newValue) + oldNode.value = newValue + } + + if (newValue === 'null') { + oldNode.value = '' + oldNode.removeAttribute('value') + } + + if (!newNode.hasAttributeNS(null, 'value')) { + oldNode.removeAttribute('value') + } else if (oldNode.type === 'range') { + // this is so elements like slider move their UI thingy + oldNode.value = newValue + } +} + +function updateTextarea (newNode, oldNode) { + const newValue = newNode.value + if (newValue !== oldNode.value) { + oldNode.value = newValue + } + + if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) { + // Needed for IE. Apparently IE sets the placeholder as the + // node value and vise versa. This ignores an empty update. + if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) { + return + } + + oldNode.firstChild.nodeValue = newValue + } +} + +function updateAttribute (newNode, oldNode, name) { + if (newNode[name] !== oldNode[name]) { + oldNode[name] = newNode[name] + if (newNode[name]) { + oldNode.setAttribute(name, '') + } else { + oldNode.removeAttribute(name) + } + } +} diff --git a/packages/html/package.json b/packages/html/package.json new file mode 100644 index 0000000..3296bb6 --- /dev/null +++ b/packages/html/package.json @@ -0,0 +1,31 @@ +{ + "name": "@choojs/html", + "version": "8.0.0-dev", + "description": "HTML template literals that render to DOM in the browser and strings on the server", + "type": "module", + "exports": { + ".": { + "browser": "./browser.js", + "default": "./server.js" + }, + "./raw": "./raw.js", + "./morph": "./morph.js" + }, + "files": [ + "browser.js", + "server.js", + "raw.js", + "morph.js" + ], + "engines": { + "node": ">=24" + }, + "repository": "choojs/choo", + "keywords": [ + "html", + "template", + "tagged-template", + "server-side-rendering" + ], + "license": "MIT" +} diff --git a/packages/html/raw.js b/packages/html/raw.js new file mode 100644 index 0000000..b61b33b --- /dev/null +++ b/packages/html/raw.js @@ -0,0 +1,11 @@ +// Ported from nanohtml 1.10.0 lib/raw-server.js (MIT) +// Mark a string as pre-encoded so the html tag won't escape it. + +export default function raw (tag) { + // eslint-disable-next-line no-new-wrappers + const wrapper = new String(tag) + wrapper.__encoded = true + return wrapper +} + +export { raw } diff --git a/packages/html/server.js b/packages/html/server.js new file mode 100644 index 0000000..309b084 --- /dev/null +++ b/packages/html/server.js @@ -0,0 +1,85 @@ +// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml +// Server-side tagged template: renders straight to an escaped HTML string. +// The browserify/babel transform branches of the original are gone for good — +// this is a pure runtime tag. + +const BOOL_PROPS = [ + 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', + 'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden', + 'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline', + 'readonly', 'required', 'reversed', 'selected' +] + +const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i') +const query = /(?:="|&)[^"]*=$/ + +export default function html (pieces, ...values) { + let boolMatch + let output = '' + for (let i = 0; i < pieces.length; i++) { + const piece = pieces[i] + if (i < pieces.length - 1) { + if ((boolMatch = boolPropRx.exec(piece))) { + output += piece.slice(0, boolMatch.index) + if (values[i]) { + output += boolMatch[1] + '="' + boolMatch[2] + '"' + } + continue + } + + const value = handleValue(values[i]) + if (piece[piece.length - 1] === '=' && !query.test(piece)) { + output += piece + '"' + value + '"' + } else { + output += piece + value + } + } else { + output += piece + } + } + + // HACK: Avoid double encoding by marking encoded string + // You cannot add properties to string literals + // eslint-disable-next-line no-new-wrappers + const wrapper = new String(output) + wrapper.__encoded = true + return wrapper +} + +function handleValue (value) { + // Handle each item in array as potential unescaped value + if (Array.isArray(value)) return value.map(handleValue).join('') + + // Ignore event handlers. `onclick=${(e) => doSomething(e)}` + // will become. `onclick=""` + if (typeof value === 'function') return '' + if (value === null || value === undefined) return '' + if (value.__encoded) return value + + if (typeof value === 'object') { + if (typeof value.outerHTML === 'string') return value.outerHTML + return Object.keys(value).reduce(function (str, key) { + if (str.length > 0) str += ' ' + + if (BOOL_PROPS.indexOf(key) !== -1) { + if (value[key]) { + return str + key + '="' + key + '"' + } + return str + } + + const handled = handleValue(value[key]) + return str + key + '="' + handled + '"' + }, '') + } + + const str = value.toString() + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export { html } diff --git a/packages/html/test/server.test.js b/packages/html/test/server.test.js new file mode 100644 index 0000000..8f29295 --- /dev/null +++ b/packages/html/test/server.test.js @@ -0,0 +1,56 @@ +// Server renderer behavior, matching nanohtml 1.x server semantics. + +import { test } from 'node:test' +import assert from 'node:assert' + +import html from '@choojs/html' +import raw from '@choojs/html/raw' + +test('renders a template to a string', () => { + const res = html`

hello

` + assert.strictEqual(res.toString(), '

hello

') +}) + +test('escapes interpolated text', () => { + const res = html`

${''}

` + assert.strictEqual(res.toString(), '

<script>alert(1)</script>

') +}) + +test('raw() bypasses escaping', () => { + const res = html`

${raw('hi')}

` + assert.strictEqual(res.toString(), '

hi

') +}) + +test('nested templates are not double-encoded', () => { + const inner = html`&` + const res = html`

${inner}

` + assert.strictEqual(res.toString(), '

&

') +}) + +test('arrays of children are joined', () => { + const items = ['a', 'b'].map((x) => html`
  • ${x}
  • `) + const res = html`` + assert.strictEqual(res.toString(), '') +}) + +test('unquoted attribute interpolation is quoted', () => { + const res = html`
    ` + assert.strictEqual(res.toString(), '
    ') +}) + +test('boolean attributes render when truthy and drop when falsy', () => { + const on = html`` + const off = html`` + assert.strictEqual(on.toString(), '') + assert.strictEqual(off.toString(), ''.replace(' ', ' ')) +}) + +test('event handler functions are stripped', () => { + const res = html`` + assert.strictEqual(res.toString(), '') +}) + +test('spread-style object interpolation renders attributes', () => { + const res = html`
    ` + assert.strictEqual(res.toString(), '') +})