feat(html): add @choojs/html — server renderer, raw(), morph
ESM ports with attribution: nanohtml 1.10.0 server tag (browserify/babel transform branches removed — pure runtime), raw-server, and nanomorph 5.4.3 consolidated into one module. browser.js is a Phase 2 placeholder that throws loudly. Covered by test/server.test.js on node:test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
co-authored by
Claude Fable 5
parent
3d6b474621
commit
90cca611c9
@@ -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 <input> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user