2026-09-08 15:59:26 +02:00
|
|
|
// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml
|
2026-09-08 18:43:40 +02:00
|
|
|
// Server-side tagged template. The browserify/babel transform branches of
|
|
|
|
|
// the original are gone for good — this is a pure runtime tag.
|
|
|
|
|
//
|
|
|
|
|
// v8 shape: the tag builds a parts list — static/escaped string runs
|
|
|
|
|
// interleaved with unresolved async holes (promises or async iterables in
|
|
|
|
|
// child position). A fully-sync template collapses to one string and
|
|
|
|
|
// toString() behaves exactly like v7. Async holes make the result
|
|
|
|
|
// streamable: iterate it with `for await` (each chunk is a string) — that
|
|
|
|
|
// is what choo's toStream() does. toString() on async content throws.
|
|
|
|
|
// Async values are only allowed in child position; attribute values must
|
|
|
|
|
// be synchronous.
|
2026-09-08 15:59:26 +02:00
|
|
|
|
|
|
|
|
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')
|
2026-09-08 17:49:32 +02:00
|
|
|
const handlerRx = /[^-a-z](on[a-z]+)=$/i
|
2026-09-08 15:59:26 +02:00
|
|
|
const query = /(?:="|&)[^"]*=$/
|
|
|
|
|
|
2026-09-08 18:43:40 +02:00
|
|
|
// minimal parser state, only to distinguish child position from tag position
|
|
|
|
|
const TEXT = 0
|
|
|
|
|
const TAG = 1
|
|
|
|
|
const DQ = 2
|
|
|
|
|
const SQ = 3
|
|
|
|
|
const COMMENT = 4
|
|
|
|
|
|
|
|
|
|
function scan (s, state) {
|
|
|
|
|
for (let j = 0; j < s.length; j++) {
|
|
|
|
|
const c = s[j]
|
|
|
|
|
switch (state) {
|
|
|
|
|
case TEXT:
|
|
|
|
|
if (c === '<') {
|
|
|
|
|
if (s.startsWith('!--', j + 1)) { state = COMMENT; j += 3 } else state = TAG
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
case TAG:
|
|
|
|
|
if (c === '>') state = TEXT
|
|
|
|
|
else if (c === '"') state = DQ
|
|
|
|
|
else if (c === "'") state = SQ
|
|
|
|
|
break
|
|
|
|
|
case DQ:
|
|
|
|
|
if (c === '"') state = TAG
|
|
|
|
|
break
|
|
|
|
|
case SQ:
|
|
|
|
|
if (c === "'") state = TAG
|
|
|
|
|
break
|
|
|
|
|
case COMMENT:
|
|
|
|
|
if (c === '-' && s.startsWith('->', j + 1)) { state = TEXT; j += 2 }
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isThenable (value) {
|
|
|
|
|
return value && typeof value.then === 'function'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAsyncIterable (value) {
|
|
|
|
|
return value && typeof value[Symbol.asyncIterator] === 'function'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class HtmlChunks {
|
|
|
|
|
constructor (parts) {
|
|
|
|
|
this.parts = parts // strings and { async: value } holes
|
|
|
|
|
this.__encoded = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get async () {
|
|
|
|
|
return this.parts.some((p) => typeof p !== 'string')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toString () {
|
|
|
|
|
let out = ''
|
|
|
|
|
for (const part of this.parts) {
|
|
|
|
|
if (typeof part !== 'string') {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'@choojs/html: this template has async content and cannot render synchronously — stream it (choo: use toStream() instead of toString())'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
out += part
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async * [Symbol.asyncIterator] () {
|
|
|
|
|
for (const part of this.parts) {
|
|
|
|
|
if (typeof part === 'string') {
|
|
|
|
|
yield part
|
|
|
|
|
} else {
|
|
|
|
|
yield * resolveAsync(part.async)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve an async hole to string chunks, applying child-value semantics
|
|
|
|
|
// to whatever it produces.
|
|
|
|
|
async function * resolveAsync (value) {
|
|
|
|
|
if (isThenable(value)) {
|
|
|
|
|
yield * streamChild(await value)
|
|
|
|
|
} else {
|
|
|
|
|
for await (const item of value) {
|
|
|
|
|
yield * streamChild(item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function * streamChild (value) {
|
|
|
|
|
if (value === null || value === undefined) return
|
|
|
|
|
// HtmlChunks is itself async-iterable: its chunks are final HTML and
|
|
|
|
|
// must never re-enter escaping, so check it before the generic paths
|
|
|
|
|
if (value instanceof HtmlChunks) {
|
|
|
|
|
yield * value[Symbol.asyncIterator]()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (isThenable(value) || isAsyncIterable(value)) {
|
|
|
|
|
yield * resolveAsync(value)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
for (const item of value) yield * streamChild(item)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const str = handleChild(value)
|
|
|
|
|
if (str !== '') yield str
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 15:59:26 +02:00
|
|
|
export default function html (pieces, ...values) {
|
2026-09-08 18:43:40 +02:00
|
|
|
const parts = []
|
|
|
|
|
let acc = ''
|
2026-09-08 15:59:26 +02:00
|
|
|
let boolMatch
|
2026-09-08 18:43:40 +02:00
|
|
|
let state = TEXT
|
|
|
|
|
|
2026-09-08 15:59:26 +02:00
|
|
|
for (let i = 0; i < pieces.length; i++) {
|
|
|
|
|
const piece = pieces[i]
|
2026-09-08 18:43:40 +02:00
|
|
|
state = scan(piece, state)
|
|
|
|
|
if (i === pieces.length - 1) {
|
|
|
|
|
acc += piece
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-09-08 17:49:32 +02:00
|
|
|
|
2026-09-08 18:43:40 +02:00
|
|
|
const value = values[i]
|
|
|
|
|
|
|
|
|
|
if (state === TEXT) {
|
|
|
|
|
// child position: this is where async content may live
|
|
|
|
|
// (HtmlChunks is itself async-iterable, so check it first)
|
|
|
|
|
acc += piece
|
|
|
|
|
if (value instanceof HtmlChunks) {
|
|
|
|
|
// splice nested parts so inner async holes stream through
|
|
|
|
|
for (const part of value.parts) {
|
|
|
|
|
if (typeof part === 'string') acc += part
|
|
|
|
|
else { parts.push(acc, part); acc = '' }
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
2026-09-08 18:43:40 +02:00
|
|
|
} else if (isThenable(value) || isAsyncIterable(value)) {
|
|
|
|
|
parts.push(acc, { async: value })
|
|
|
|
|
acc = ''
|
2026-09-08 15:59:26 +02:00
|
|
|
} else {
|
2026-09-08 18:43:40 +02:00
|
|
|
acc += handleChild(value)
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
2026-09-08 18:43:40 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tag position: attributes are synchronous, always
|
|
|
|
|
if (isThenable(value) || isAsyncIterable(value)) {
|
|
|
|
|
throw new Error('@choojs/html: async values are only allowed in child position, not in attributes')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 value === 'function') {
|
|
|
|
|
acc += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((boolMatch = boolPropRx.exec(piece))) {
|
|
|
|
|
acc += piece.slice(0, boolMatch.index)
|
|
|
|
|
if (value) {
|
|
|
|
|
acc += boolMatch[1] + '="' + boolMatch[2] + '"'
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handled = handleValue(value)
|
|
|
|
|
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
|
|
|
|
|
acc += piece + '"' + handled + '"'
|
2026-09-08 15:59:26 +02:00
|
|
|
} else {
|
2026-09-08 18:43:40 +02:00
|
|
|
acc += piece + handled
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 18:43:40 +02:00
|
|
|
parts.push(acc)
|
|
|
|
|
return new HtmlChunks(parts)
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-08 18:43:40 +02:00
|
|
|
// child-position (text) value → string
|
|
|
|
|
function handleChild (value) {
|
|
|
|
|
if (Array.isArray(value)) return value.map(handleChild).join('')
|
|
|
|
|
if (typeof value === 'function') return ''
|
|
|
|
|
if (value === null || value === undefined) return ''
|
|
|
|
|
if (value.__encoded) return value.toString()
|
|
|
|
|
if (typeof value === 'object' && typeof value.outerHTML === 'string') return value.outerHTML
|
|
|
|
|
return escape(value.toString())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tag-position (attribute) value → string; keeps v7 semantics including
|
|
|
|
|
// object spread and arrays
|
2026-09-08 15:59:26 +02:00
|
|
|
function handleValue (value) {
|
|
|
|
|
// Handle each item in array as potential unescaped value
|
|
|
|
|
if (Array.isArray(value)) return value.map(handleValue).join('')
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'function') return ''
|
|
|
|
|
if (value === null || value === undefined) return ''
|
2026-09-08 18:43:40 +02:00
|
|
|
if (value.__encoded) return value.toString()
|
2026-09-08 15:59:26 +02:00
|
|
|
|
|
|
|
|
if (typeof value === 'object') {
|
|
|
|
|
if (typeof value.outerHTML === 'string') return value.outerHTML
|
|
|
|
|
return Object.keys(value).reduce(function (str, key) {
|
2026-09-08 17:49:32 +02:00
|
|
|
// handlers in spread objects are behavior too — never serialized
|
|
|
|
|
if (typeof value[key] === 'function') return str
|
|
|
|
|
|
2026-09-08 15:59:26 +02:00
|
|
|
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 + '"'
|
|
|
|
|
}, '')
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 18:43:40 +02:00
|
|
|
return escape(value.toString())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escape (str) {
|
2026-09-08 15:59:26 +02:00
|
|
|
return str
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { html }
|