Files

256 lines
7.4 KiB
JavaScript
Raw Permalink Normal View History

// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml
// 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.
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 = /(?:="|&)[^"]*=$/
// 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(
'@uhhm/buuh-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
}
export default function html (pieces, ...values) {
const parts = []
let acc = ''
let boolMatch
let state = TEXT
for (let i = 0; i < pieces.length; i++) {
const piece = pieces[i]
state = scan(piece, state)
if (i === pieces.length - 1) {
acc += piece
break
}
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 = '' }
}
} else if (isThenable(value) || isAsyncIterable(value)) {
parts.push(acc, { async: value })
acc = ''
} else {
acc += handleChild(value)
}
continue
}
// tag position: attributes are synchronous, always
if (isThenable(value) || isAsyncIterable(value)) {
throw new Error('@uhhm/buuh-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 + '"'
} else {
acc += piece + handled
}
}
parts.push(acc)
return new HtmlChunks(parts)
}
// 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
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 ''
if (value.__encoded) return value.toString()
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 + '"'
}, '')
}
return escape(value.toString())
}
function escape (str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export { html }