hydrate(oldNode, newNode) normalizes the client tree to parser text-node granularity, walks both trees to report the first server/client markup disagreement (node, text, attribute, or child count, with a path), then morphs — matching nodes are adopted in place, the client render wins. choo.mount() now hydrates and console.warns on mismatch. Building this surfaced two real isomorphism divergences, both fixed: adjacent text nodes from template holes vs the parser's merged runs (folded via Node.normalize), and the server serializing onclick="" where the browser sets a property — event handlers now leave no trace in server markup at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
99 lines
3.2 KiB
JavaScript
99 lines
3.2 KiB
JavaScript
// 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 handlerRx = /[^-a-z](on[a-z]+)=$/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) {
|
|
// Event handlers are behavior, not markup: `onclick=${fn}` renders
|
|
// nothing at all, matching the browser renderer (which sets the
|
|
// handler as a property). v7 serialized a useless onclick="".
|
|
const handlerMatch = handlerRx.exec(piece)
|
|
if (handlerMatch && typeof values[i] === 'function') {
|
|
output += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
|
|
continue
|
|
}
|
|
|
|
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) {
|
|
// handlers in spread objects are behavior too — never serialized
|
|
if (typeof value[key] === 'function') return str
|
|
|
|
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 }
|