# Tagged DOM

Experimental DocumentFragment update library, done with:

# Code

interface TaggedDOM {
    fragment: DocumentFragment;
    render: (Node?) => Promise<any>;
}

/**
 * DocumentFragment Tagged templates
 */
export default function dom(
    strings: TemplateStringsArray,
    ...expressions
): TaggedDOM {
    // Use template instead of document.createDocumentFragment() because it needs a parentNode
    /** @type HTMLTemplateElement */
    const template = document.createElement("template");
    const tagPromise = promiseTagger();
    const proxyProperties: Map<PropertyKey, HTMLElement> = new Map();
    let html = [strings[0]];

    const proxy = new Proxy(
        {},
        {
            get(_obj, prop) {
                return proxyProperties.get(prop);
            },
            set(_obj, prop, value) {
                const parent = proxyProperties.get(prop);

                if (parent) {
                    value = isDomObject(value) ? value : dom`${value}`;
                    value.render(parent);

                    return true;
                }

                return false;
            },
        },
    );

    /* --- Create DOM --- */
    if (expressions.length > 0) {
        const resolvedExpressions = resolveExpressions(expressions, tagPromise);

        resolvedExpressions.forEach((expr, i) =>
            html.push(expr, strings[i + 1]),
        );
    }

    template.innerHTML = flatten(html).join("");

    /* --- Resolve promises --- */
    const fragment = template.content;
    const promisesMap = tagPromise() as Map<string, Promise<any>>;;

    for (const [tag, promise] of promisesMap) {
        const element = fragment.querySelector(tag);

        // element doesn't exist or has been replaced by the proxy
        if (!element) continue;

        // resolve the Promises - async
        promise.then(value => {
            if (
                value == undefined || // null or undefined
                (Array.isArray(value) && value.length === 0) // empty Array
            ) {
                return;
            }

            if (typeof value !== "object" && typeof value !== "function") {
                // this is a simple text node but we parse it. !createTextNode
                value = dom`${value}`.fragment;
            } else if (!(value instanceof DocumentFragment)) {
                const { fragment } = isDomObject(value)
                    ? value
                    : dom`${typeof value === "function" ? value() : value}`;

                value = fragment;
            }

            // replace the promise node
            const parent = element.parentNode as HTMLElement;

            if (parent) {
                if (parent.nodeType === 1) {
                    const proxyPropName = parent.getAttribute(":proxy");

                    if (proxyPropName) {
                        parent.removeAttribute(":proxy");
                        proxyProperties.set(proxyPropName, parent);
                    }
                }

                parent.replaceChild(value, element);
            }
        });
    }

    return {
        fragment,
        render,
    };

    /**
     * @param {HTMLElement} element
     * @returns {Promise<*>}
     */
    async function render(element) {
        if (element != undefined) {
            element.innerHTML = "";
            element.append(fragment);
        }

        return Promise.all(promisesMap.values()).then(() => proxy);
    }
}

/**
 * Specific tag replaced by the promise result
 * @returns {Function}
 */
function promiseTagger() {
    const map: Map<string, Promise<any>> = new Map();

    return function(promise?: Promise<any>): string | Map<string, Promise<any>> {
        const tag = `tagdom-${uid()}`;

        if (promise == undefined) {
            return map;
        }

        map.set(tag, promise);

        return `<${tag}></${tag}>`;
    };
}

/**
 * @param expressions string literals expressions
 * @param tagPromise promiseTagger return
 */
function resolveExpressions(expressions: any[], tagPromise: Function): string[] {
    const result: string[] = [];

    expressions.forEach((entry: any) => {
        // null or undefined
        if (
            entry == undefined ||
            (Array.isArray(entry) && entry.length === 0)
        ) {
            return;
        }

        if (Array.isArray(entry)) {
            // resolve each expression on the array
            entry = entry.map(value => resolveExpressions([value], tagPromise));
        } else if (entry instanceof Promise) {
            // create a tag
            entry = tagPromise(entry);
        } else if (typeof entry === "function") {
            // resolve the result of the function
            entry = resolveExpressions([entry()], tagPromise);
        } else {
            entry =
                typeof entry !== "object"
                    ? entry // String
                    : tagPromise(Promise.resolve(entry)); // DOM -> create a tag
        }

        result.push(entry);
    });

    return result;
}

function isDomObject(obj): boolean {
    return typeof obj === "object" && "fragment" in obj && "render" in obj;
}

/**
 * Flatten an Array or a Set
 * @param arr An array may hide other arrays
 */
function flatten(arr: any[] | Set<any> = []): any[] {
    let list = [];

    for (let v of arr) {
        list = list.concat(Array.isArray(v) ? flatten(v) : v);
    }

    return list;
}

function uid(): string {
    return Math.random()
        .toString(36)
        .substr(2);
}

# Usage

The render function renders the DocumentFragment into the Node and the resolution of Promises will live replace the tags and so it can create lots of reflow. The Promise support is only available for Nodes.

The expressions of the tagged template can be:

  • a function that returns anything from this list
  • a Promise that resolves to anything from this list
  • another dom object
  • a DocumentFragment or a Node
  • a string or a number
  • undefined or null
import dom from "./tagged-dom.js";

let salutation = "Hello";
let who = [
    new Promise(resolve => setTimeout(() => resolve("world"), 250)),
    () => new Promise(resolve => setTimeout(() => resolve("!"), 800)),
];

const partial = dom`<p>${salutation}, ${who}</p>`;
partial.render(document.body); // => <p>Hello, world!</p>

In order to update a part of any Fragment you can create a hole by returning a Promise within a Node and give that Node a :proxy attribute. Use it as a property name of the returned proxy. You have access to the proxy holes created only on this dom object.

const partial = dom`
    <p>
        Woohoo
        <span :proxy="rocks">
            ${Promise.resolve("")} <!-- or ${dom``} -->
        </span>
        !
    </p>
`;

partial.render(document.body).then(proxy =>
    proxy.rocks = dom`, <strong>Proxy rocks</strong>`
);

In case you want to have access to the proxy in an inner part of the rendering tree, you still need to render the fragment but you can't append it to any element. Simply call the render function without parameters.

fragment.render().then(proxy => [..]);

In order to add event listeners or to do whatever you like to the DOM, you will have access to the created DocumentFragment. As it is live, all modifications done asynchronously will be available too.

const partial = dom`<div role="button" class="button">click here</div>`;

partial.fragment.querySelector(".button").addEventListener("click", event => [..]);

# Example

import dom from "./tagged-dom.js";

const app = document.getElementById("app");

let salutation = "Hello";
let who = [
    new Promise(resolve => setTimeout(() => resolve("world"), 250)),
    () => new Promise(resolve => setTimeout(() => resolve("!"), 800)),
];

const partial =
    dom`<p :proxy="woohoo">${salutation}, ${who}</p>`

if (app) {
    partial.render(app, true)
        .then(proxy => {
            const happyPartial = dom`
                <span style="color:hotpink">
                    Woohoo<span :proxy="rocks">${Promise.resolve("")}</span>!
                </span>
            `;

            happyPartial.render().then(proxy => setTimeout(() => {
                proxy.rocks =
                    ", <strong>Proxy rocks</strong>";
            }, 2500));

            setTimeout(() => {
                proxy.woohoo = happyPartial;
            }, 1500);
        });
}