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,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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export { html }
|
||||
@@ -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`<p>hello</p>`
|
||||
assert.strictEqual(res.toString(), '<p>hello</p>')
|
||||
})
|
||||
|
||||
test('escapes interpolated text', () => {
|
||||
const res = html`<p>${'<script>alert(1)</script>'}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><script>alert(1)</script></p>')
|
||||
})
|
||||
|
||||
test('raw() bypasses escaping', () => {
|
||||
const res = html`<p>${raw('<em>hi</em>')}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><em>hi</em></p>')
|
||||
})
|
||||
|
||||
test('nested templates are not double-encoded', () => {
|
||||
const inner = html`<em>&</em>`
|
||||
const res = html`<p>${inner}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><em>&</em></p>')
|
||||
})
|
||||
|
||||
test('arrays of children are joined', () => {
|
||||
const items = ['a', 'b'].map((x) => html`<li>${x}</li>`)
|
||||
const res = html`<ul>${items}</ul>`
|
||||
assert.strictEqual(res.toString(), '<ul><li>a</li><li>b</li></ul>')
|
||||
})
|
||||
|
||||
test('unquoted attribute interpolation is quoted', () => {
|
||||
const res = html`<div class=${'a b'}></div>`
|
||||
assert.strictEqual(res.toString(), '<div class="a b"></div>')
|
||||
})
|
||||
|
||||
test('boolean attributes render when truthy and drop when falsy', () => {
|
||||
const on = html`<input disabled=${true} />`
|
||||
const off = html`<input disabled=${false} />`
|
||||
assert.strictEqual(on.toString(), '<input disabled="disabled" />')
|
||||
assert.strictEqual(off.toString(), '<input />'.replace(' ', ' '))
|
||||
})
|
||||
|
||||
test('event handler functions are stripped', () => {
|
||||
const res = html`<button onclick=${() => {}}>go</button>`
|
||||
assert.strictEqual(res.toString(), '<button onclick="">go</button>')
|
||||
})
|
||||
|
||||
test('spread-style object interpolation renders attributes', () => {
|
||||
const res = html`<div ${{ class: 'x', hidden: true }}></div>`
|
||||
assert.strictEqual(res.toString(), '<div class="x" hidden="hidden"></div>')
|
||||
})
|
||||
Reference in New Issue
Block a user