import { assert, is_html, is_iterable, is_renderable, lexer, single_part_template, type Displayable } from './shared.ts'
interface PartRenderer {
replace_start: number
replace_end: number
render: (values: unknown[]) => string | Generator
}
interface CompiledTemplate {
source: string
parts: PartRenderer[]
extra_parts: number
}
const WHITESPACE = /\s/
const templates = new WeakMap()
function compile_template(statics: TemplateStringsArray): CompiledTemplate {
const cached = templates.get(statics)
if (cached) return cached
const compiled: CompiledTemplate = {
source: statics.join('\0'),
parts: [],
extra_parts: 0,
}
let offset = 0
let dyn_i = 0
let whitespace_count = 0
let prev_state: lexer.State | undefined
let attr_name: string = ''
let attr_start: number | undefined
function collapse_whitespace() {
if (whitespace_count > 1) {
compiled.extra_parts++
compiled.parts.push({
replace_start: offset - whitespace_count,
replace_end: offset,
render: () => ' ',
})
}
whitespace_count = 0
}
for (const [char, state] of lexer.lex(statics)) {
if (state === lexer.ATTR_NAME) {
if (prev_state !== lexer.ATTR_NAME) {
attr_name = ''
attr_start = offset
}
attr_name += char
}
if (state === lexer.DATA && WHITESPACE.test(char)) {
whitespace_count++
} else {
collapse_whitespace()
}
if (char === '\0') {
const i = dyn_i++
switch (state) {
case lexer.DATA:
case lexer.COMMENT:
case lexer.COMMENT2:
compiled.parts.push({
replace_start: offset,
replace_end: offset + 1,
render: values => render_child(values[i]),
})
break
case lexer.ATTR_VALUE_UNQUOTED:
case lexer.ATTR_VALUE_DOUBLE_QUOTED:
case lexer.ATTR_VALUE_SINGLE_QUOTED:
const name = attr_name
assert(attr_start !== undefined)
compiled.parts.push({
replace_start: attr_start,
replace_end: offset + 1 + (state === lexer.ATTR_VALUE_UNQUOTED ? 0 : 1),
render: values => render_attribute(name, values[i]),
})
break
case lexer.ATTR_NAME:
compiled.parts.push({
replace_start: offset,
replace_end: offset + 1,
render: values => render_directive(values[i]),
})
break
default:
assert(false, `unexpected state ${state}`)
}
}
prev_state = state
offset++
}
collapse_whitespace()
if (__DEV__) {
let prev_end = -1
for (const { replace_start, replace_end } of compiled.parts) {
assert(replace_start >= prev_end)
assert(replace_start < replace_end)
prev_end = replace_end
}
}
templates.set(statics, compiled)
return compiled
}
function render_directive(value: unknown) {
// Treat null/undefined as no-op, matching client behavior.
if (value == null) return ''
// In dev, ensure anything else is a function; on the server we don't execute it.
assert(typeof value === 'function')
return ''
}
function render_attribute(name: string, value: unknown) {
if (value === false || value == null || typeof value === 'function') {
return ''
}
if (value === true) return name
return `${name}="${escape(value)}"`
}
function* render_child(value: unknown): Generator {
yield '[>'
if (is_renderable(value)) {
try {
value = value.render()
} catch (thrown) {
if (is_html(thrown)) {
value = thrown
} else {
throw thrown
}
}
if (is_renderable(value)) value = single_part_template(value)
}
if (is_iterable(value)) {
for (const item of value) yield* render_child(item)
} else if (is_html(value)) {
const { _statics: statics, _dynamics: dynamics } = value
const template = compile_template(statics)
assert(
template.parts.length - template.extra_parts === dynamics.length,
'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?',
)
let prev_end = 0
for (const { replace_start, replace_end, render } of template.parts) {
yield template.source.slice(prev_end, replace_start)
const out = render(dynamics)
if (typeof out === 'string') yield out
else yield* out
prev_end = replace_end
}
yield template.source.slice(prev_end)
} else if (value != null) {
yield escape(value)
}
yield ']>'
}
const ESCAPE_RE = /[&<>"']/g
const ESCAPE_SUBSTITUTIONS = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}
function escape(str: unknown) {
return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS])
}
export function renderToString(value: Displayable): string {
let str = ''
for (const part of render_child(value)) str += part
return str
}
export function renderToReadableStream(value: Displayable): ReadableStream {
const iter = render_child(value)
return new ReadableStream({
pull(controller) {
const { done, value } = iter.next()
if (done) {
controller.close()
return
}
controller.enqueue(value)
},
}).pipeThrough(new TextEncoderStream())
}