Skip to content

Commit

Permalink
feat(hiccup): add SerializeOpts, update serialize()
Browse files Browse the repository at this point in the history
BREAKING CHANGE: update serialize() args, replace with options object

- only a breaking change for "advanced" use cases
- add SerializeOpts to simplify serialize() args
- add customizable entity escaping (via new opts)
- add/update tests
  • Loading branch information
postspectacular committed Sep 19, 2023
1 parent 467d49b commit 442d777
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 106 deletions.
215 changes: 111 additions & 104 deletions packages/hiccup/src/serialize.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FnU } from "@thi.ng/api";
import { deref, isDeref } from "@thi.ng/api/deref";
import { implementsFunction } from "@thi.ng/checks/implements-function";
import { isArray } from "@thi.ng/checks/is-array";
Expand All @@ -20,6 +21,45 @@ import { css } from "./css.js";
import { normalize } from "./normalize.js";
import { formatPrefixes } from "./prefix.js";

/**
* Options to customize the behavior of {@link serialize}.
*/
export interface SerializeOpts {
/**
* Arbitrary user context object
*/
ctx?: any;
/**
* If true, auto-escape entities via {@link SerializeOpts.escapeFn}.
*
* @defaultValue false
*/
escape: boolean;
/**
* Only used if {@link SerializeOpts.escape} is enabled. Function to escape
* entities. By default uses
* [`escapeEntitiesNum()`](https://docs.thi.ng/umbrella/strings/functions/escapeEntitiesNum.html).
*/
escapeFn: FnU<string>;
/**
* If true (default: false), all text content will be wrapped in `<span>`
* elements (to ensure DOM compatibility with hdom). The only elements for
* spans are never created are listed in {@link NO_SPANS}.
*
* @defaultValue false
*/
span: boolean;
/**
* If true (default: false), all elements will have an autogenerated `key`
* attribute injected. If {@link SerializeOpts.span} is enabled, `keys` will
* be enabled by default too (since in this case we assume the output is
* meant to be compatible with [`thi.ng/hdom`](https://thi.ng/hdom)).
*
* @defaultValue false
*/
keys: boolean;
}

/**
* Recursively normalizes and serializes given tree as HTML/SVG/XML string.
* Expands any embedded component functions with their results.
Expand All @@ -37,7 +77,7 @@ import { formatPrefixes } from "./prefix.js";
* iterable
* ```
*
* Tags can be defined in "Zencoding" convention, e.g.
* Tags can be defined in "Emmet" convention, e.g.
*
* ```js
* ["div#foo.bar.baz", "hi"] // <div id="foo" class="bar baz">hi</div>
Expand All @@ -59,7 +99,7 @@ import { formatPrefixes } from "./prefix.js";
* 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 style="color:red;background:#000;"></div>
* ```
*
Expand All @@ -76,7 +116,7 @@ import { formatPrefixes } from "./prefix.js";
* 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.
* If the `ctx` option is given 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
* [`IDeref`](https://docs.thi.ng/umbrella/api/interfaces/IDeref.html)
Expand All @@ -85,7 +125,7 @@ import { formatPrefixes } from "./prefix.js";
* ```js
* const foo = (ctx, a, b) => ["div#" + a, ctx.foo, b];
*
* serialize([foo, "id", "body"], { foo: { class: "black" } })
* serialize([foo, "id", "body"], { ctx: { foo: { class: "black" } } })
* // <div id="id" class="black">body</div>
* ```
*
Expand All @@ -94,33 +134,25 @@ import { formatPrefixes } from "./prefix.js";
* 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 [`thi.ng/hdom`](https://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.
* is `__serialize`. If set to `false`, the entire tree branch below (and
* including) the element with that attrib will be excluded from the output.
*
* **See {@link SerializeOpts} for further available options.**
*
* Single or multiline comments can be included using the special `COMMENT` tag
* (`__COMMENT__`) (always WITHOUT attributes!).
* (`"__COMMENT__"`) (always WITHOUT attributes!).
*
* ```
* ```js
* [COMMENT, "Hello world"]
* // <!-- Hello world -->
*
* [COMMENT, "Hello", "world"]
* <!--
* Hello
* world
* -->
* // <!--
* // Hello
* // world
* // -->
* ```
*
* Currently, the only processing / DTD instructions supported are:
Expand All @@ -134,125 +166,103 @@ import { formatPrefixes } from "./prefix.js";
* 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" }]
* ```js
* serialize(["?xml", { version: "1.0", standalone: "yes" }])
* // <?xml version="1.0" standalone="yes"?>
*
* ["!DOCTYPE", "html"]
* ["!DOCTYPE", "html"] // (also available as 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
* @param opts - options
*/
export const serialize = (
tree: any,
ctx?: any,
escape = false,
span = false,
keys = span,
opts?: Partial<SerializeOpts>,
path = [0]
) => _serialize(tree, ctx, escape, span, keys, path);
) => {
const $opts = {
escape: false,
escapeFn: escapeEntitiesNum,
span: false,
keys: false,
...opts,
};
if (opts?.keys == null && $opts.span) $opts.keys = true;
return _serialize(tree, $opts, path);
};

const _serialize = (
tree: any,
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
): string =>
const _serialize = (tree: any, opts: SerializeOpts, path: any[]): string =>
tree == null
? ""
: Array.isArray(tree)
? serializeElement(tree, ctx, esc, span, keys, path)
? serializeElement(tree, opts, path)
: isFunction(tree)
? _serialize(tree(ctx), ctx, esc, span, keys, path)
? _serialize(tree(opts.ctx), opts, path)
: implementsFunction(tree, "toHiccup")
? _serialize(tree.toHiccup(ctx), ctx, esc, span, keys, path)
? _serialize(tree.toHiccup(opts.ctx), opts, path)
: isDeref(tree)
? _serialize(tree.deref(), ctx, esc, span, keys, path)
? _serialize(tree.deref(), opts, path)
: isNotStringAndIterable(tree)
? serializeIter(tree, ctx, esc, span, keys, path)
: ((tree = esc ? escapeEntitiesNum(String(tree)) : String(tree)), span)
? `<span${keys ? ` key="${path.join("-")}"` : ""}>${tree}</span>`
? serializeIter(tree, opts, path)
: ((tree = __escape(String(tree), opts)), opts.span)
? `<span${opts.keys ? ` key="${path.join("-")}"` : ""}>${tree}</span>`
: tree;

const serializeElement = (
tree: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
const serializeElement = (tree: any[], opts: SerializeOpts, path: any[]) => {
let tag = tree[0];
return !tree.length
? ""
: isFunction(tag)
? _serialize(
tag.apply(null, [ctx, ...tree.slice(1)]),
ctx,
esc,
span,
keys,
path
)
? _serialize(tag.apply(null, [opts.ctx, ...tree.slice(1)]), opts, path)
: implementsFunction(tag, "render")
? _serialize(
tag.render.apply(null, [ctx, ...tree.slice(1)]),
ctx,
esc,
span,
keys,
tag.render.apply(null, [opts.ctx, ...tree.slice(1)]),
opts,
path
)
: tag === COMMENT
? serializeComment(tree)
: tag == CDATA
? serializeCData(tree)
: isString(tag)
? serializeTag(tree, ctx, esc, span, keys, path)
? serializeTag(tree, opts, path)
: isNotStringAndIterable(tree)
? serializeIter(tree, ctx, esc, span, keys, path)
? serializeIter(tree, opts, path)
: illegalArgs(`invalid tree node: ${tree}`);
};

const serializeTag = (
tree: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
path: any[]
) => {
const serializeTag = (tree: any[], opts: SerializeOpts, path: any[]) => {
tree = normalize(tree);
const attribs = tree[1];
if (attribs.__skip || attribs.__serialize === false) return "";
keys && attribs.key === undefined && (attribs.key = path.join("-"));
opts.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)
? serializeBody(tag, tree[2], opts, path)
: !VOID_TAGS[tag] && !NO_CLOSE_EMPTY[tag]
? `></${tag}>`
: PROC_TAGS[tag] || "/>";
return `<${tag}${serializeAttribs(attribs, esc)}${body}`;
return `<${tag}${serializeAttribs(attribs, opts)}${body}`;
};

const serializeAttribs = (attribs: any, esc: boolean) => {
const serializeAttribs = (attribs: any, opts: SerializeOpts) => {
let res = "";
for (let a in attribs) {
if (a.startsWith("__")) continue;
const v = serializeAttrib(attribs, a, deref(attribs[a]), esc);
const v = serializeAttrib(attribs, a, deref(attribs[a]), opts);
v != null && (res += v);
}
return res;
};

const serializeAttrib = (attribs: any, a: string, v: any, esc: boolean) => {
const serializeAttrib = (
attribs: any,
a: string,
v: any,
opts: SerializeOpts
) => {
return v == null
? null
: isFunction(v) && (/^on\w+/.test(a) || (v = v(attribs)) == null)
Expand All @@ -262,11 +272,11 @@ const serializeAttrib = (attribs: any, a: string, v: any, esc: boolean) => {
: v === false
? null
: a === "data"
? serializeDataAttribs(v, esc)
: attribPair(a, v, esc);
? serializeDataAttribs(v, opts)
: attribPair(a, v, opts);
};

const attribPair = (a: string, v: any, esc: boolean) => {
const attribPair = (a: string, v: any, opts: SerializeOpts) => {
v =
a === "style" && isPlainObject(v)
? css(v)
Expand All @@ -275,36 +285,33 @@ const attribPair = (a: string, v: any, esc: boolean) => {
: isArray(v)
? v.join(ATTRIB_JOIN_DELIMS[a] || " ")
: v.toString();
return v.length ? ` ${a}="${esc ? escapeEntitiesNum(v) : v}"` : null;
return v.length ? ` ${a}="${__escape(v, opts)}"` : null;
};

const serializeDataAttribs = (data: any, esc: boolean) => {
const serializeDataAttribs = (data: any, opts: SerializeOpts) => {
let res = "";
for (let id in data) {
let v = deref(data[id]);
isFunction(v) && (v = v(data));
v != null && (res += ` data-${id}="${esc ? escapeEntitiesNum(v) : v}"`);
v != null && (res += ` data-${id}="${__escape(v, opts)}"`);
}
return res;
};

const serializeBody = (
tag: string,
body: any[],
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
opts: SerializeOpts,
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];
if (opts.span && !proc && !NO_SPANS[tag]) opts = { ...opts, span: true };
for (let i = 0, n = body.length; i < n; i++) {
res += _serialize(body[i], ctx, esc, span, keys, [...path, i]);
res += _serialize(body[i], opts, [...path, i]);
}
return res + (proc || `</${tag}>`);
};
Expand All @@ -322,17 +329,17 @@ const serializeCData = (tree: any[]) =>

const serializeIter = (
iter: Iterable<any>,
ctx: any,
esc: boolean,
span: boolean,
keys: boolean,
opts: SerializeOpts,
path: any[]
) => {
const res = [];
const res: any[] = [];
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++]));
res.push(_serialize(i, opts, [...p, k++]));
}
return res.join("");
};

const __escape = (x: string, opts: SerializeOpts) =>
opts.escape ? opts.escapeFn(x) : x;
Loading

0 comments on commit 442d777

Please sign in to comment.