-
Notifications
You must be signed in to change notification settings - Fork 1
/
server.js
407 lines (366 loc) · 9.99 KB
/
server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
const RENDER = 'render'
const REF_ATTR = /\s*ref=("|')?$/i
const ATTRIBUTE = /<[a-z-]+[^>]*?\s+(([^\t\n\f "'>/=]+)=("|')?)?$/i
const BOOL_PROPS = [
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
'readonly', 'required', 'reversed', 'selected'
]
/**
* @callback Store
* @param {object} state
* @param {Emitter} emitter
* @returns {any}
*/
/**
* @callback Initialize
* @param {object} state
* @param {Emit} emit
* @returns {any}
*/
/** @type {Context|null} */
let current
/** @type {WeakMap<object, Context>} */
const cache = new WeakMap()
/**
* @callback Store
* @param {object} state
* @param {Emitter} emitter
* @returns {any}
*/
/**
* Register a store function to be used for current component context
* @export
* @param {Store} fn Store function
* @returns {any}
*/
export function use (fn) {
return fn(current.state, current.emitter)
}
/**
* Create HTML partial
* @export
* @param {Array<string>} strings Template literal strings
* @param {...any} values Template literal values
* @returns {Partial}
*/
export function html (strings, ...values) {
return new Partial({ strings, values })
}
/**
* Create SVG partial
* @export
* @param {Array<string>} strings Template literal strings
* @param {...any} values Template literal values
* @returns {Partial}
*/
export const svg = html
/**
* Treat raw HTML string as partial, bypassing HTML escape behavior
* @export
* @param {any} value
* @returns {Partial}
*/
export function raw (value) {
return new Raw(value)
}
/**
* Declare where partial is to be mounted in DOM, useful for SSR
* @export
* @param {Node|string} node Any compatible node or node selector
* @param {Partial} partial The partial to mount
* @param {object} [state={}] Root state
* @returns {Partial}
*/
export function mount (selector, partial, state = {}) {
partial.selector = selector
partial.state = state
return partial
}
/**
* Render partial to promise
* @export
* @param {Partial} partial The partial to be rendered
* @param {object} [state={}] Root state
* @returns {Promise}
*/
export async function render (partial, state = {}) {
if (partial instanceof Component) partial = await unwrap(partial, state)
if (!(partial instanceof Partial)) return Promise.resolve(partial)
let string = ''
for await (const chunk of parse(partial, state)) string += chunk
return string
}
/**
* Create element reference
* @export
* @returns {Ref}
*/
export function ref () {
return new Ref()
}
/**
* Create a context object
* @class Context
* @param {object} [state={}] Initial state
*/
function Context (state = {}) {
const ctx = cache.get(state)
if (ctx) state = Object.create(state)
this.emitter = new Emitter(ctx?.emitter)
this.state = state
cache.set(state, this)
}
/**
* Holder of raw HTML value
* @class Raw
*/
class Raw extends String {}
/**
* Create a HTML partial object
* @export
* @class Partial
*/
export class Partial {
constructor ({ strings, values }) {
this.strings = strings
this.values = values
}
async * [Symbol.asyncIterator] (state = {}) {
yield * parse(this, state)
}
}
/**
* Creates a stateful component
* @export
* @param {Initialize} fn Component initialize function
* @param {...args} args Arguments forwarded to component render function
* @returns {function(...any): Component} Component render function
*/
export function Component (fn, ...args) {
const props = { fn, args, key: args[0]?.key || fn }
if (this instanceof Component) return Object.assign(this, props)
return Object.setPrototypeOf(Object.assign(function Render (..._args) {
if (!_args.length) _args = args
return new Component(fn, ..._args)
}, props), Component.prototype)
}
Component.prototype = Object.create(Partial.prototype)
Component.prototype.constructor = Component
Component.prototype[Symbol.asyncIterator] = async function * (state = {}) {
yield * await unwrap(this, state)
}
/**
* Create iterable for partial
* @param {Partial} partial The partial to parse
* @param {object} [state={}] Root state passed down to components
* @memberof Partial
* @returns {AsyncGenerator}
*/
async function * parse (partial, state = {}) {
const { strings, values } = partial
// Claim top level state to prevent mutations
if (!cache.has(state)) cache.set(state, partial)
let html = ''
for (let i = 0, len = strings.length; i < len; i++) {
const string = strings[i]
let value = await values[i]
// Aggregate HTML as we pass through
html += string
const isAttr = ATTRIBUTE.test(html)
// Flatten arrays
if (Array.isArray(value)) {
value = await Promise.all(value.flat())
}
if (isAttr) {
if (value instanceof Ref) {
const match = REF_ATTR.exec(string)
console.assert(match, !match && `yeet: Got a ref as value for \`${string.match(ATTRIBUTE)?.[2]}\`, use instead \`ref=\${myRef}\`.`)
yield string.replace(match[0], '')
continue
} else if (typeof value === 'boolean' || value == null) {
const [, attr, name, quote] = html.match(ATTRIBUTE)
if (attr && BOOL_PROPS.includes(name)) {
console.assert(!quote, quote && `yeet: Boolean attribute \`${name}\` should not be quoted, use instead \`${name}=\${${JSON.stringify(value)}}\`.`)
// Drop falsy boolean attributes altogether
if (!value) yield string.slice(0, (attr.length + 1) * -1)
// Leave only the attribute name in place for truthy attributes
else yield string.slice(0, (attr.length - name.length) * -1)
continue
}
} else if (Array.isArray(value)) {
value = await Promise.all(value.map(function (val) {
return isObject(val) ? objToAttrs(val) : val
}))
value = value.join(' ')
} else if (isObject(value)) {
value = await objToAttrs(value)
}
html += value
yield string + value
continue
}
// No use of aggregate outside attributes
html = ''
yield string
if (value != null) {
yield * resolve(value, state)
}
}
}
/**
* Resolve a value to string
* @param {any} value The value to resolve
* @param {object} state Current state
* @returns {AsyncGenerator}
*/
async function * resolve (value, state) {
if (Array.isArray(value)) {
for (const val of value) yield * resolve(val, state)
return
}
if (value instanceof Component) value = await unwrap(value, state)
if (value instanceof Partial) {
yield * parse(value, state)
} else {
yield value instanceof Raw ? value : escape(value)
}
}
/**
* Escape HTML characters
* @param {string} value
* @returns {string}
*/
function escape (value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* Unwrap Component value
* @param {Component} component
* @param {object} state
* @returns {any}
*/
function unwrap (component, state) {
const { fn, args } = component
const ctx = current = new Context(state)
const emit = ctx.emitter.emit.bind(ctx.emitter)
return unwind(fn(ctx.state, emit), ctx, args)
}
/**
* Serialize an object to HTML attributes
* @param {object} obj An object
* @returns {Promise<string>}
*/
async function objToAttrs (obj) {
const arr = []
for (let [key, value] of Object.entries(obj)) {
value = await value
arr.push(`${key}="${value}"`)
}
return arr.join(' ')
}
/**
* Unwind nested generators, awaiting yielded promises
* @param {any} value The value to unwind
* @param {Context} ctx Current context
* @param {Array} args Arguments to forward to setup functions
* @returns {Promise<*>}
*/
async function unwind (value, ctx, args) {
while (typeof value === 'function') {
current = ctx
value = value(...args)
args = []
}
if (value instanceof Component) {
value = await unwrap(value, ctx.state)
}
if (isGenerator(value)) {
let res = value.next()
while (!res.done && (!res.value || res.value instanceof Promise)) {
if (res.value instanceof Promise) {
res.value = await res.value
current = ctx
}
res = value.next(res.value)
}
return unwind(res.value, ctx, args)
}
return value
}
/**
* Determine whether value is a plain object
* @param {any} value
* @returns {boolean}
*/
function isObject (value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* Determine whether value is a generator object
* @param {any} obj
* @returns {boolean}
*/
function isGenerator (obj) {
return obj &&
typeof obj.next === 'function' &&
typeof obj.throw === 'function'
}
/**
* Create a reference to a element node (available in Browser only)
* @class Ref
*/
class Ref {}
/**
* Generic event emitter
* @class Emitter
* @extends {Map}
*/
class Emitter extends Map {
constructor (emitter) {
super()
if (emitter) {
// Forward all event to provided emitter
this.on('*', emitter.emit.bind(emitter))
}
}
/**
* Attach listener for event
* @param {string} event Event name
* @param {function(...any): void} fn Event listener function
* @memberof Emitter
*/
on (event, fn) {
const listeners = this.get(event)
if (listeners) listeners.add(fn)
else this.set(event, new Set([fn]))
}
/**
* Remove given listener for event
* @param {string} event Event name
* @param {function(...any): void} fn Registered listener
* @memberof Emitter
*/
removeListener (event, fn) {
const listeners = this.get(event)
if (listeners) listeners.delete(fn)
}
/**
* Emit event to all listeners
* @param {string} event Event name
* @param {...any} args Event parameters to be forwarded to listeners
* @memberof Emitter
*/
emit (event, ...args) {
if (event === RENDER) return
if (event !== '*') this.emit('*', event, ...args)
if (!this.has(event)) return
for (const fn of this.get(event)) fn(...args)
}
}