a post-component library for building user-interfaces on the web.
at push-txxtpymvvxno 254 lines 6.5 kB view raw
1import type { Displayable } from 'dhtml' 2import { Tokenizer } from 'htmlparser2' 3import { assert, is_html, is_iterable, is_renderable, single_part_template } from './shared.ts' 4 5type PartRenderer = (values: unknown[]) => string | Generator<string, void, void> 6 7interface CompiledTemplate { 8 statics: string[] 9 parts: PartRenderer[] 10 extra_parts: number 11} 12 13const WHITESPACE_WHOLE = /^\s+$/ 14const DYNAMIC_WHOLE = /^dyn-\$(\d+)\$$/i 15const DYNAMIC_GLOBAL = /dyn-\$(\d+)\$/gi 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 html = statics.reduce((a, v, i) => a + v + (i === statics.length - 1 ? '' : `dyn-$${i}$`), '') 23 const parts: { 24 start: number 25 end: number 26 render: PartRenderer 27 }[] = [] 28 let attribname: [start: number, end: number] | null = null 29 function noop() {} 30 31 // count of parts that don't directly correspond to inputs 32 let extra_parts = 0 33 34 const tokenizer = new Tokenizer( 35 {}, 36 { 37 onattribname(start, end) { 38 const name = html.slice(start, end) 39 const match = name.match(DYNAMIC_WHOLE) 40 if (match) { 41 const idx = parseInt(match[1]) 42 parts.push({ start, end, render: values => render_directive(values[idx]) }) 43 return 44 } 45 46 // assert(!DYNAMIC_GLOBAL.test(name), `expected a whole dynamic value for ${name}, got a partial one`) 47 48 attribname = [start, end] 49 }, 50 onattribdata(start, end) { 51 assert(attribname) 52 53 const [nameStart, nameEnd] = attribname 54 const name = html.slice(nameStart, nameEnd) 55 const value = html.slice(start, end) 56 57 const match = value.match(DYNAMIC_WHOLE) 58 if (match) { 59 const idx = parseInt(match[1]) 60 parts.push({ start: nameStart, end, render: values => render_attribute(name, values[idx]) }) 61 return 62 } 63 64 // assert(!DYNAMIC_GLOBAL.test(value), `expected a whole dynamic value for ${name}, got a partial one`) 65 }, 66 onattribentity: noop, 67 onattribend() { 68 attribname = null 69 }, 70 71 onopentagname(start, end) {}, 72 onopentagend() {}, 73 onclosetag(start, end) {}, 74 onselfclosingtag: noop, 75 76 ontext(start, end) { 77 const value = html.slice(start, end) 78 79 for (const match of value.matchAll(DYNAMIC_GLOBAL)) { 80 const idx = parseInt(match[1]) 81 parts.push({ 82 start: start + match.index, 83 end: start + match.index + match[0].length, 84 render: values => render_child(values[idx]), 85 }) 86 } 87 88 if (WHITESPACE_WHOLE.test(value)) { 89 extra_parts++ 90 parts.push({ start, end, render: () => ' ' }) 91 return 92 } 93 }, 94 ontextentity: noop, 95 96 oncomment(start, end) { 97 const value = html.slice(start, end) 98 99 for (const match of value.matchAll(DYNAMIC_GLOBAL)) { 100 const idx = parseInt(match[1]) 101 parts.push({ 102 start: start + match.index, 103 end: start + match.index + match[0].length, 104 render: values => escape(values[idx]), 105 }) 106 } 107 }, 108 109 oncdata(start, end) {}, 110 ondeclaration(start, end) {}, 111 onprocessinginstruction(start, end) {}, 112 113 onend: noop, 114 }, 115 ) 116 117 tokenizer.write(html) 118 tokenizer.end() 119 120 const compiled: CompiledTemplate = { 121 statics: [], 122 parts: [], 123 extra_parts, 124 } 125 126 compiled.statics.push(html.slice(0, parts[0]?.start)) 127 128 for (let i = 0; i < parts.length; i++) { 129 const part = parts[i] 130 const next_part = parts[i + 1] 131 compiled.parts.push(part.render) 132 compiled.statics.push(html.slice(part.end, next_part?.start)) 133 } 134 135 templates.set(statics, compiled) 136 return compiled 137} 138 139function render_directive(value: unknown) { 140 if (value === null) return '' 141 142 assert(typeof value === 'function') 143 console.log('directive returned:', value()) 144 145 return '' 146} 147 148function render_attribute(name: string, value: unknown) { 149 if (value === false || value === null || typeof value === 'function') { 150 return '' 151 } 152 if (value === true) return name 153 return `${name}="${escape(value)}"` 154} 155 156function* render_child(value: unknown) { 157 const seen = new Map<object, number>() 158 159 while (is_renderable(value)) 160 try { 161 const times = seen.get(value) ?? 0 162 if (times > 100) throw new Error('circular render') 163 seen.set(value, times + 1) 164 165 value = value.render() 166 } catch (thrown) { 167 if (is_html(thrown)) { 168 value = thrown 169 } else { 170 throw thrown 171 } 172 } 173 174 if (is_iterable(value)) { 175 for (const item of value) yield* render_to_iterable(item as Displayable) 176 } else if (is_html(value)) { 177 yield* render_to_iterable(value) 178 } else if (value !== null) { 179 yield escape(value) 180 } 181} 182 183const ESCAPE_RE = /[&<>"']/g 184const ESCAPE_SUBSTITUTIONS = { 185 '&': '&amp;', 186 '<': '&lt;', 187 '>': '&gt;', 188 '"': '&quot;', 189 "'": '&#39;', 190} 191function escape(str: unknown) { 192 return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS]) 193} 194 195function* render_to_iterable(value: Displayable) { 196 const { _statics: statics, _dynamics: dynamics } = is_html(value) ? value : single_part_template(value) 197 const template = compile_template(statics) 198 199 assert( 200 template.parts.length - template.extra_parts === dynamics.length, 201 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 202 ) 203 204 for (let i = 0; i < template.statics.length - 1; i++) { 205 yield template.statics[i] 206 yield* template.parts[i](dynamics) 207 } 208 yield template.statics[template.statics.length - 1] 209} 210 211export function renderToString(value: Displayable): string { 212 let str = '' 213 for (const part of render_to_iterable(value)) str += part 214 return str 215} 216 217export function renderToReadableStream(value: Displayable): ReadableStream { 218 const iter = render_to_iterable(value)[Symbol.iterator]() 219 return new ReadableStream({ 220 pull(controller) { 221 const { done, value } = iter.next() 222 if (done) { 223 controller.close() 224 return 225 } 226 controller.enqueue(value) 227 }, 228 }) 229} 230 231// { 232// const displayable = html` 233// <!-- ${'z'} --> 234// <p>a${'text'}b</p> 235// <a href=${'attr'} onclick=${() => {}}></a> 236// <button ${() => 'directive'}>but</button> 237// <script> 238// ;<span>z</span> 239// </script> 240// ${{ 241// render() { 242// return html`<div>${[1, 2, 3]}</div>` 243// }, 244// }} 245// ${html`[${'A'}|${'B'}]`} 246// ` 247 248// const stream = renderToReadableStream(displayable).pipeThrough(new TextEncoderStream()) 249 250// new Response(stream).text().then(rendered => { 251// console.log(rendered) 252// console.log(rendered === renderToString(displayable)) 253// }) 254// }