// 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( '@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 } 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('@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 + '"' } 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export { html }