feat(html): streamable server templates — async holes in child position
The server tag now builds a parts list (string runs + async holes) instead of an eager string. Sync templates collapse to one string and toString() is byte-identical to before. Promises and async iterables in child position make the result streamable: iterating it yields everything before a hole immediately, then each hole as it resolves, in document order — resolved values get full child semantics (templates, arrays, raw, escaping). toString() on async content throws with streaming guidance; async values in attribute position throw immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
co-authored by
Claude Fable 5
parent
c9f5ba2fda
commit
9cf9e51ac9
+192
-35
@@ -1,7 +1,15 @@
|
||||
// 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.
|
||||
// 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',
|
||||
@@ -14,57 +22,203 @@ const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$',
|
||||
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 output = ''
|
||||
let state = TEXT
|
||||
|
||||
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
|
||||
}
|
||||
state = scan(piece, state)
|
||||
if (i === pieces.length - 1) {
|
||||
acc += piece
|
||||
break
|
||||
}
|
||||
|
||||
if ((boolMatch = boolPropRx.exec(piece))) {
|
||||
output += piece.slice(0, boolMatch.index)
|
||||
if (values[i]) {
|
||||
output += boolMatch[1] + '="' + boolMatch[2] + '"'
|
||||
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 = '' }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const value = handleValue(values[i])
|
||||
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
|
||||
output += piece + '"' + value + '"'
|
||||
} else if (isThenable(value) || isAsyncIterable(value)) {
|
||||
parts.push(acc, { async: value })
|
||||
acc = ''
|
||||
} else {
|
||||
output += piece + value
|
||||
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 {
|
||||
output += piece
|
||||
acc += piece + handled
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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('')
|
||||
|
||||
// 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 (value.__encoded) return value.toString()
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value.outerHTML === 'string') return value.outerHTML
|
||||
@@ -86,7 +240,10 @@ function handleValue (value) {
|
||||
}, '')
|
||||
}
|
||||
|
||||
const str = value.toString()
|
||||
return escape(value.toString())
|
||||
}
|
||||
|
||||
function escape (str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
||||
Reference in New Issue
Block a user