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