a post-component library for building user-interfaces on the web.
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 '&': '&',
186 '<': '<',
187 '>': '>',
188 '"': '"',
189 "'": ''',
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// }