thi-ng/umbrella

View on GitHub
packages/hiccup/src/serialize.ts

Summary

Maintainability
A
35 mins
Test Coverage
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";
import { isFunction } from "@thi.ng/checks/is-function";
import { isNotStringAndIterable } from "@thi.ng/checks/is-not-string-iterable";
import { isPlainObject } from "@thi.ng/checks/is-plain-object";
import { isString } from "@thi.ng/checks/is-string";
import { illegalArgs } from "@thi.ng/errors/illegal-arguments";
import { escapeEntitiesNum } from "@thi.ng/strings/entities";
import {
    ATTRIB_JOIN_DELIMS,
    CDATA,
    COMMENT,
    NO_CLOSE_EMPTY,
    NO_SPANS,
    PROC_TAGS,
    VOID_TAGS,
} from "./api.js";
import { css } from "./css.js";
import type { derefContext } from "./deref.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;
    /**
     * If true, some serialization behaviors will be adjusted to target XML
     * output.
     *
     * @remarks
     * Currently, the following aspects are supported:
     *
     * - boolean attributes are serialized as `name=""` rather than just `name`
     *
     * @defaultValue false
     */
    xml: boolean;
}

/**
 * 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 "Emmet" 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` option is given it'll be passed to each embedded component fns.
 * Optionally call {@link derefContext} prior to `serialize()` to auto-deref
 * context keys with values implementing the
 * [`IDeref`](https://docs.thi.ng/umbrella/api/interfaces/IDeref.html)
 * interface.
 *
 * ```js
 * import { serialize } from "@thi.ng/hiccup";
 *
 * const foo = (ctx, a, b) => ["div#" + a, ctx.foo, b];
 *
 * serialize([foo, "id", "body"], { ctx: { 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).
 *
 * 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 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!).
 *
 * ```js
 * import { COMMENT } from "@thi.ng/hiccup";
 *
 * [COMMENT, "Hello world"]
 * // serializes to:
 * // <!-- 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):
 *
 * ```js
 * serialize(["?xml", { version: "1.0", standalone: "yes" }])
 * // <?xml version="1.0" standalone="yes"?>
 *
 * ["!DOCTYPE", "html"] // (also available as DOCTYPE_HTML)
 * // <!DOCTYPE html>
 * ```
 *
 * @param tree - hiccup elements / component tree
 * @param opts - options
 */
export const serialize = (
    tree: any,
    opts?: Partial<SerializeOpts>,
    path = [0]
) => {
    const $opts = {
        escape: false,
        escapeFn: escapeEntitiesNum,
        keys: false,
        span: false,
        xml: false,
        ...opts,
    };
    if (opts?.keys == null && $opts.span) $opts.keys = true;
    return _serialize(tree, $opts, path);
};

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

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

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

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]), opts);
        v != null && (res += v);
    }
    return res;
};

const serializeAttrib = (
    attribs: any,
    a: string,
    v: any,
    opts: SerializeOpts
) => {
    return v == null
        ? null
        : isFunction(v) && (/^on\w+/.test(a) || (v = v(attribs)) == null)
        ? null
        : v === true
        ? " " + a + (opts.xml ? `=""` : "")
        : v === false
        ? null
        : a === "data"
        ? serializeDataAttribs(v, opts)
        : attribPair(a, v, opts);
};

const attribPair = (a: string, v: any, opts: SerializeOpts) => {
    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}="${__escape(v, opts)}"` : null;
};

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}="${__escape(v, opts)}"`);
    }
    return res;
};

const serializeBody = (
    tag: string,
    body: any[],
    opts: SerializeOpts,
    path: any[]
) => {
    if (VOID_TAGS[tag]) {
        illegalArgs(`No body allowed in tag: ${tag}`);
    }
    const proc = PROC_TAGS[tag];
    let res = proc ? " " : ">";
    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], opts, [...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>,
    opts: SerializeOpts,
    path: any[]
) => {
    const res: any[] = [];
    const p = path.slice(0, path.length - 1);
    let k = 0;
    for (let i of iter) {
        res.push(_serialize(i, opts, [...p, k++]));
    }
    return res.join("");
};

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