diff --git a/packages/html/server.js b/packages/html/server.js index 50807ae..7be734f 100644 --- a/packages/html/server.js +++ b/packages/html/server.js @@ -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(/ new Promise((resolve) => setTimeout(() => resolve(value), ms)) + +test('sync templates stream as a single chunk equal to toString()', async () => { + const res = html`

hello ${'world'}

` + const chunks = await collect(res) + assert.deepStrictEqual(chunks, ['

hello world

']) + assert.strictEqual(chunks.join(''), res.toString()) +}) + +test('a promise child splits the stream at the hole', async () => { + const res = html`

shell

${wait(10, 'late')}
` + const chunks = await collect(res) + assert.strictEqual(chunks[0], '

shell

', 'everything before the hole flushes first') + assert.strictEqual(chunks.join(''), '

shell

late
end
') +}) + +test('toString() on async content throws with streaming guidance', () => { + const res = html`

${Promise.resolve('x')}

` + assert.throws(() => res.toString(), /toStream/) +}) + +test('resolved promises can carry templates, arrays, and raw', async () => { + const res = html`` + const chunks = await collect(res) + assert.strictEqual(chunks.join(''), '') +}) + +test('nested templates with async holes stream through their parents', async () => { + const inner = html`
${wait(5, 'inner-late')}
` + const res = html`

early

${inner}
` + const chunks = await collect(res) + assert.strictEqual(chunks[0], '

early

', 'parent flushes up to the nested hole') + assert.strictEqual(chunks.join(''), '

early

inner-late
') +}) + +test('async iterable children stream chunk by chunk', async () => { + async function * rows () { + for (let i = 0; i < 3; i++) yield html`
  • ${i}
  • ` + } + const res = html`
      ${rows()}
    ` + const chunks = await collect(res) + assert.strictEqual(chunks.join(''), '
    • 0
    • 1
    • 2
    ') + assert.ok(chunks.length >= 4, 'each yielded row is its own chunk') +}) + +test('multiple async holes resolve in document order', async () => { + // the second promise resolves first; output order must follow the document + const res = html`
    ${wait(20, 'A')}|${wait(5, 'B')}
    ` + const chunks = await collect(res) + assert.strictEqual(chunks.join(''), '
    A|B
    ') +}) + +test('async values in attribute position throw immediately', () => { + assert.throws( + () => html`
    `, + /child position/ + ) +}) + +test('resolved async strings are escaped like any child', async () => { + const res = html`

    ${wait(5, '