a post-component library for building user-interfaces on the web.
at main 212 lines 5.1 kB view raw
1import { assert, is_html, is_iterable, is_renderable, lexer, single_part_template, type Displayable } from './shared.ts' 2 3interface PartRenderer { 4 replace_start: number 5 replace_end: number 6 render: (values: unknown[]) => string | Generator<string, void, void> 7} 8 9interface CompiledTemplate { 10 source: string 11 parts: PartRenderer[] 12 extra_parts: number 13} 14 15const WHITESPACE = /\s/ 16 17const templates = new WeakMap<TemplateStringsArray, CompiledTemplate>() 18function compile_template(statics: TemplateStringsArray): CompiledTemplate { 19 const cached = templates.get(statics) 20 if (cached) return cached 21 22 const compiled: CompiledTemplate = { 23 source: statics.join('\0'), 24 parts: [], 25 extra_parts: 0, 26 } 27 let offset = 0 28 let dyn_i = 0 29 30 let whitespace_count = 0 31 let prev_state: lexer.State | undefined 32 let attr_name: string = '' 33 let attr_start: number | undefined 34 35 function collapse_whitespace() { 36 if (whitespace_count > 1) { 37 compiled.extra_parts++ 38 compiled.parts.push({ 39 replace_start: offset - whitespace_count, 40 replace_end: offset, 41 render: () => ' ', 42 }) 43 } 44 whitespace_count = 0 45 } 46 47 for (const [char, state] of lexer.lex(statics)) { 48 if (state === lexer.ATTR_NAME) { 49 if (prev_state !== lexer.ATTR_NAME) { 50 attr_name = '' 51 attr_start = offset 52 } 53 attr_name += char 54 } 55 56 if (state === lexer.DATA && WHITESPACE.test(char)) { 57 whitespace_count++ 58 } else { 59 collapse_whitespace() 60 } 61 62 if (char === '\0') { 63 const i = dyn_i++ 64 65 switch (state) { 66 case lexer.DATA: 67 case lexer.COMMENT: 68 case lexer.COMMENT2: 69 compiled.parts.push({ 70 replace_start: offset, 71 replace_end: offset + 1, 72 render: values => render_child(values[i]), 73 }) 74 break 75 76 case lexer.ATTR_VALUE_UNQUOTED: 77 case lexer.ATTR_VALUE_DOUBLE_QUOTED: 78 case lexer.ATTR_VALUE_SINGLE_QUOTED: 79 const name = attr_name 80 assert(attr_start !== undefined) 81 compiled.parts.push({ 82 replace_start: attr_start, 83 replace_end: offset + 1 + (state === lexer.ATTR_VALUE_UNQUOTED ? 0 : 1), 84 render: values => render_attribute(name, values[i]), 85 }) 86 break 87 88 case lexer.ATTR_NAME: 89 compiled.parts.push({ 90 replace_start: offset, 91 replace_end: offset + 1, 92 render: values => render_directive(values[i]), 93 }) 94 break 95 96 default: 97 assert(false, `unexpected state ${state}`) 98 } 99 } 100 101 prev_state = state 102 offset++ 103 } 104 collapse_whitespace() 105 106 if (__DEV__) { 107 let prev_end = -1 108 for (const { replace_start, replace_end } of compiled.parts) { 109 assert(replace_start >= prev_end) 110 assert(replace_start < replace_end) 111 prev_end = replace_end 112 } 113 } 114 115 templates.set(statics, compiled) 116 return compiled 117} 118 119function render_directive(value: unknown) { 120 // Treat null/undefined as no-op, matching client behavior. 121 if (value == null) return '' 122 123 // In dev, ensure anything else is a function; on the server we don't execute it. 124 assert(typeof value === 'function') 125 return '' 126} 127 128function render_attribute(name: string, value: unknown) { 129 if (value === false || value == null || typeof value === 'function') { 130 return '' 131 } 132 if (value === true) return name 133 return `${name}="${escape(value)}"` 134} 135 136function* render_child(value: unknown): Generator<string, void, void> { 137 yield '<?[>' 138 139 if (is_renderable(value)) { 140 try { 141 value = value.render() 142 } catch (thrown) { 143 if (is_html(thrown)) { 144 value = thrown 145 } else { 146 throw thrown 147 } 148 } 149 150 if (is_renderable(value)) value = single_part_template(value) 151 } 152 153 if (is_iterable(value)) { 154 for (const item of value) yield* render_child(item) 155 } else if (is_html(value)) { 156 const { _statics: statics, _dynamics: dynamics } = value 157 const template = compile_template(statics) 158 159 assert( 160 template.parts.length - template.extra_parts === dynamics.length, 161 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 162 ) 163 164 let prev_end = 0 165 for (const { replace_start, replace_end, render } of template.parts) { 166 yield template.source.slice(prev_end, replace_start) 167 168 const out = render(dynamics) 169 if (typeof out === 'string') yield out 170 else yield* out 171 172 prev_end = replace_end 173 } 174 yield template.source.slice(prev_end) 175 } else if (value != null) { 176 yield escape(value) 177 } 178 179 yield '<?]>' 180} 181 182const ESCAPE_RE = /[&<>"']/g 183const ESCAPE_SUBSTITUTIONS = { 184 '&': '&amp;', 185 '<': '&lt;', 186 '>': '&gt;', 187 '"': '&quot;', 188 "'": '&#39;', 189} 190function escape(str: unknown) { 191 return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS]) 192} 193 194export function renderToString(value: Displayable): string { 195 let str = '' 196 for (const part of render_child(value)) str += part 197 return str 198} 199 200export function renderToReadableStream(value: Displayable): ReadableStream<Uint8Array> { 201 const iter = render_child(value) 202 return new ReadableStream<string>({ 203 pull(controller) { 204 const { done, value } = iter.next() 205 if (done) { 206 controller.close() 207 return 208 } 209 controller.enqueue(value) 210 }, 211 }).pipeThrough(new TextEncoderStream()) 212}