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:
Bendik Aagaard Lynghaug
2026-09-08 15:59:26 +02:00
co-authored by Claude Fable 5
parent 3d6b474621
commit 90cca611c9
6 changed files with 520 additions and 0 deletions
+85
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export { html }