/
serialize.ts
342 lines (330 loc) · 9.98 KB
/
serialize.ts
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
import { deref, isDeref } from "@thi.ng/api";
import {
implementsFunction,
isArray,
isFunction,
isNotStringAndIterable,
isPlainObject,
isString,
} from "@thi.ng/checks";
import { illegalArgs } from "@thi.ng/errors";
import {
ATTRIB_JOIN_DELIMS,
CDATA,
COMMENT,
NO_SPANS,
PROC_TAGS,
NO_CLOSE_EMPTY,
VOID_TAGS,
} from "./api";
import { css } from "./css";
import { escape } from "./escape";
import { normalize } from "./normalize";
import { formatPrefixes } from "./prefix";
/**
* Recursively normalizes and serializes given tree as HTML/SVG/XML
* string. Expands any embedded component functions with their results.
*
* @remarks
* Each node of the input tree can have one of the following input
* forms:
*
* ```js
* ["tag", ...]
* ["tag#id.class1.class2", ...]
* ["tag", {other: "attrib"}, ...]
* ["tag", {...}, "body", function, ...]
* [function, arg1, arg2, ...]
* [{render: (ctx,...) => [...]}, args...]
* iterable
* ```
*
* Tags can be defined in "Zencoding" convention, e.g.
*
* ```js
* ["div#foo.bar.baz", "hi"] // <div id="foo" class="bar baz">hi</div>
* ```
*
* The presence of the attributes object (2nd array index) is optional.
* Any attribute values, incl. functions are allowed. If the latter, the
* function is called with the full attribs object as argument and the
* return value is used for the attribute. This allows for the dynamic
* creation of attrib values based on other attribs. The only exception
* to this are event attributes, i.e. attribute names starting with
* "on". Function values assigned to event attributes will be omitted
* from the output.
*
* ```js
* ["div#foo", { bar: (attribs) => attribs.id + "-bar" }]
* // <div id="foo" bar="foo-bar"></div>
* ```
*
* The `style` attribute can ONLY be defined as string or object.
*
* ```js
* ["div", {style: {color: "red", background: "#000"}}]
* // <div style="color:red;background:#000;"></div>
* ```
*
* Boolean attribs are serialized in HTML5 syntax (present or not).
* `null`, `undefined` or empty string attrib values are ignored.
*
* Any `null` or `undefined` array values (other than in head position)
* will also be removed, unless a function is in head position.
*
* A function in head position of a node acts as a mechanism for
* component composition & delayed execution. The function will only be
* executed at serialization time. In this case the optional global
* context object and all other elements of that node / array are passed
* as arguments when that function is called. The return value the
* function MUST be a valid new tree (or `undefined`).
*
* If the `ctx` object it'll be passed to each embedded component fns.
* Optionally call {@link derefContext} prior to {@link serialize} to
* auto-deref context keys with values implementing the
* {@link @thi.ng/api#IDeref} interface.
*
* ```js
* const foo = (ctx, a, b) => ["div#" + a, ctx.foo, b];
*
* serialize([foo, "id", "body"], { foo: { class: "black" } })
* // <div id="id" class="black">body</div>
* ```
*
* Functions located in other positions are called ONLY with the global
* context arg and can return any (serializable) value (i.e. new trees,
* strings, numbers, iterables or any type with a suitable
* `.toString()`, `.toHiccup()` or `.deref()` implementation).
*
* If the optional `span` flag is true (default: false), all text
* content will be wrapped in <span> elements (this is to ensure DOM
* compatibility with hdom). The only elements for spans are never
* created are listed in `NO_SPANS` in `api.ts`.
*
* If the optional `keys` flag is true (default: false), all elements
* will have an autogenerated `key` attribute injected. If `span` is
* enabled, `keys` will be enabled by default too (since in this case we
* assume the output is meant to be compatible with
* {@link @thi.ng/hdom# | @thi.ng/hdom}).
*
* hiccup & hdom control attributes (i.e. attrib names prefixed with
* `__`) will be omitted from the output. The only control attrib
* supported by this package is `__serialize`. If set to `false`, the
* entire tree branch will be excluded from the output.
*
* Single or multiline comments can be included using the special
* `COMMENT` tag (`__COMMENT__`) (always WITHOUT attributes!).
*
* ```
* [COMMENT, "Hello world"]
* // <!-- Hello world -->
*
* [COMMENT, "Hello", "world"]
* <!--
* Hello
* world
* -->
* ```
*
* Currently, the only processing / DTD instructions supported are:
*
* - `?xml`
* - `!DOCTYTPE`
* - `!ELEMENT`
* - `!ENTITY`
* - `!ATTLIST`
*
* These are used as follows (attribs are only allowed for `?xml`, all
* others only accept a body string which is taken as is):
*
* ```
* ["?xml", { version: "1.0", standalone: "yes" }]
* // <?xml version="1.0" standalone="yes"?>
*
* ["!DOCTYPE", "html"]
* // <!DOCTYPE html>
* ```
*
* @param tree - hiccup elements / component tree
* @param ctx - arbitrary user context object
* @param escape - auto-escape entities
* @param span - use spans for text content
* @param keys - attach key attribs
*/
export const serialize = (
tree: any,
ctx?: any,
escape = false,
span = false,
keys = span,
path = [0]
) => _serialize(tree, ctx, escape, span, keys, path);
const _serialize = (
tree: any,
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
): string =>
tree == null
? ""
: Array.isArray(tree)
? serializeElement(tree, ctx, esc, span, keys, path)
: isFunction(tree)
? _serialize(tree(ctx), ctx, esc, span, keys, path)
: implementsFunction(tree, "toHiccup")
? _serialize(tree.toHiccup(ctx), ctx, esc, span, keys, path)
: isDeref(tree)
? _serialize(tree.deref(), ctx, esc, span, keys, path)
: isNotStringAndIterable(tree)
? serializeIter(tree, ctx, esc, span, keys, path)
: ((tree = esc ? escape(String(tree)) : String(tree)), span)
? `<span${keys ? ` key="${path.join("-")}"` : ""}>${tree}</span>`
: tree;
const serializeElement = (
tree: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
let tag = tree[0];
return !tree.length
? ""
: isFunction(tag)
? _serialize(
tag.apply(null, [ctx, ...tree.slice(1)]),
ctx,
esc,
span,
keys,
path
)
: implementsFunction(tag, "render")
? _serialize(
tag.render.apply(null, [ctx, ...tree.slice(1)]),
ctx,
esc,
span,
keys,
path
)
: tag === COMMENT
? serializeComment(tree)
: tag == CDATA
? serializeCData(tree)
: isString(tag)
? serializeTag(tree, ctx, esc, span, keys, path)
: isNotStringAndIterable(tree)
? serializeIter(tree, ctx, esc, span, keys, path)
: illegalArgs(`invalid tree node: ${tree}`);
};
const serializeTag = (
tree: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
tree = normalize(tree);
const attribs = tree[1];
if (attribs.__skip || attribs.__serialize === false) return "";
keys && attribs.key === undefined && (attribs.key = path.join("-"));
const tag = tree[0];
const body = tree[2]
? serializeBody(tag, tree[2], ctx, esc, span, keys, path)
: !VOID_TAGS[tag] && !NO_CLOSE_EMPTY[tag]
? `></${tag}>`
: PROC_TAGS[tag] || "/>";
return `<${tag}${serializeAttribs(attribs, esc)}${body}`;
};
const serializeAttribs = (attribs: any, esc: boolean) => {
let res = "";
for (let a in attribs) {
if (a.startsWith("__")) continue;
const v = serializeAttrib(attribs, a, deref(attribs[a]), esc);
v != null && (res += v);
}
return res;
};
const serializeAttrib = (attribs: any, a: string, v: any, esc: boolean) => {
return v == null
? null
: isFunction(v) && (/^on\w+/.test(a) || (v = v(attribs)) == null)
? null
: v === true
? " " + a
: v === false
? null
: a === "data"
? serializeDataAttribs(v, esc)
: attribPair(a, v, esc);
};
const attribPair = (a: string, v: any, esc: boolean) => {
v =
a === "style" && isPlainObject(v)
? css(v)
: a === "prefix" && isPlainObject(v)
? formatPrefixes(v)
: isArray(v)
? v.join(ATTRIB_JOIN_DELIMS[a] || " ")
: v.toString();
return v.length ? ` ${a}="${esc ? escape(v) : v}"` : null;
};
const serializeDataAttribs = (data: any, esc: boolean) => {
let res = "";
for (let id in data) {
let v = deref(data[id]);
isFunction(v) && (v = v(data));
v != null && (res += ` data-${id}="${esc ? escape(v) : v}"`);
}
return res;
};
const serializeBody = (
tag: string,
body: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
if (VOID_TAGS[tag]) {
illegalArgs(`No body allowed in tag: ${tag}`);
}
const proc = PROC_TAGS[tag];
let res = proc ? " " : ">";
span = span && !proc && !NO_SPANS[tag];
for (let i = 0, n = body.length; i < n; i++) {
res += _serialize(body[i], ctx, esc, span, keys, [...path, i]);
}
return res + (proc || `</${tag}>`);
};
const serializeComment = (tree: any[]) =>
tree.length > 2
? `\n<!--\n${tree
.slice(1)
.map((x) => " " + x)
.join("\n")}\n-->\n`
: `\n<!-- ${tree[1]} -->\n`;
const serializeCData = (tree: any[]) =>
`<![CDATA[\n${tree.slice(1).join("\n")}\n]]>`;
const serializeIter = (
iter: Iterable<any>,
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
const res = [];
const p = path.slice(0, path.length - 1);
let k = 0;
for (let i of iter) {
res.push(_serialize(i, ctx, esc, span, keys, [...p, k++]));
}
return res.join("");
};