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