Source: utils/template/resolveComplexTemplate.js

import { Converter } from 'showdown';
import { sanitizeHTML } from "../utils.js";
import * as functions from "./functions/index.js";
import { isFunction } from 'lodash';

export { resolveComplexTemplate };

// static markdown converter and regex options
let markdownConverter_ = null;
const regex = {
    templateContent: /\{(([^\{\}]+|("[^"]*"))+)\}/g,
    propertyValidation: /^[^\[\]()"., \n]+(?:(?:\.[^\[\]()"., \n]+)|(?:\["[^"\n]*"\]))*$/,
    propertyGroups: /([^\[\]()"., \n]+)|(?:\["([^"\n]*)"\])/g,
    functionValidation: /^([^\[\]()"., \n]+)\(([^\n]*)\)$/
};

let idCounter = 0;

/**
 * Helper method to output console warings
 * @private
 * @param {string} property 
 * @param {object} options 
 */
function unresolved_(property, options) {
    if (options.verbose) console.warn("could not resolve: " + property);
}

/**
 * Helper method to resolve a property
 * @private
 * @param {string} property 
 * @param {object} data 
 * @returns {string} resolved value
 */
function resolveProperty_(property, data, options) {
    const matches = property.matchAll(regex.propertyGroups);
    const path = [];

    for (const match of matches) {
        if (typeof match[1] == "string") {
            path.push(match[1]);
        } else if (typeof match[2] == "string") {
            path.push(match[2]);
        }
    }

    let result = data
    for (let i = 0; i < path.length; ++i) {
        if ((result !== undefined) && (path[i] in result)) {
            result = result[path[i]];
        } else {
            unresolved_(property, options);
            return undefined;
        }
    }

    return result;
};

/**
 * Helper method to resolve functions
 * @private
 * @param {string} property 
 * @param {string} match 
 * @param {object} data 
 * @param {object} options 
 * @returns {string} result
 */
function resolveFunction_(property, match, data, postProcessingQueue, options) {
    if (!(match[1] in functions)) {
        unresolved_(property, options);
        return undefined;
    }

    const fn = functions[match[1]];
    const input = match[2];

    const myArgs = [];

    if (typeof input == "string") {

        let isString = false;
        let parenthesisCount = 0;
        let currentWord = "";

        const addWord = () => {
            currentWord = currentWord.trim();
            if ((currentWord.length >= 2) && currentWord.startsWith('"')) {
                if (currentWord.endsWith('"')) {
                    currentWord = currentWord.slice(1, -1);
                } else {
                    unresolved_("could not parse argument '" + currentWord + "' for function '" + match[1] + "'", options);
                    return false;
                }
            } else if ((currentWord.length >= 2) && currentWord.endsWith(')')) {
                const functionMatch = currentWord.match(regex.functionValidation);
                if (functionMatch) {
                    currentWord = resolveFunction_(currentWord, functionMatch, data, postProcessingQueue, options)
                } else {
                    unresolved_(property, options);
                    return false;
                }
            } else {
                const isProperty = regex.propertyValidation.test(currentWord);
                if (isProperty) {
                    currentWord = resolveProperty_(currentWord, data, options);
                } else {
                    unresolved_(property, options);
                    return false;
                }
            }
            myArgs.push(currentWord);
            currentWord = "";
        };

        for (let i = 0; i < input.length; ++i) {
            switch (input[i]) {
                case '"':
                    if (parenthesisCount == 0) isString = !isString;
                    currentWord += input[i];
                    break;
                case '(':
                    ++parenthesisCount
                    currentWord += input[i];
                    break;
                case ')':
                    --parenthesisCount
                    currentWord += input[i];
                    break;
                case ',':
                    if (isString || (parenthesisCount > 0)) {
                        currentWord += input[i];
                    } else {
                        addWord();
                    }
                    break;
                default:
                    currentWord += input[i];
            }

            if (i == (input.length - 1)) {
                if (addWord() === false) {
                    return undefined;
                }
            }
        }
    }

    let result = undefined;

    try {
        result = fn(...myArgs);

        if (result instanceof HTMLElement) {
            const id = "template_element_placeholder_" + idCounter++;
            const element = result;
            result = `<span id="${id}"></span>`
            postProcessingQueue.push(parent => {
                const wrapper = parent.querySelector("#" + id);
                if (!wrapper) return;
                wrapper.insertAdjacentElement("afterend", element);
                wrapper.remove();

                const event = new Event("append");
                element.dispatchEvent(event);
            })
        } else if (isFunction(result)) {
            postProcessingQueue.push(result);
            result = "";
        }
    } catch (msg) {
        if (msg == "discard_template") {
            throw msg;
        } else {
            console.error("Error executing function '" + match[1] + "'", msg);
        }
    }

    return result;
};

/**
 * Fills key-value pairs from object map into the given template string.
 * Supports more complex syntax including objects and basic function calls
 * 
 * @param {string} template 
 * @param {object} data 
 * @param {object} options default: { verbose: false, printUndefined: false, markdown: false, html: false, postProcess: false }
 * 
 * @memberof vef.utils.template
 * @returns {string} resolved template
 */
function resolveComplexTemplate(template, data, options) {
    if (!template) throw Error('Template is not defined!');

    options = Object.assign({
        verbose: false,
        printUndefined: false,
        markdown: false,
        html: false,
        postProcess: false,
        returnAsString: false
    }, options);

    const postProcessingQueue = [];

    const resultList = [];
    const resultPrefix = "ResultSTART"
    const resultSuffix = "ResultEND";
    let resultIndex = 0;

    // resolve content of curly brackets - nesting is not allowed
    let result = template.replace(regex.templateContent, (match, property) => {
        property = property.trim();
        let resolved = false;

        const isProperty = regex.propertyValidation.test(property);
        if (isProperty) {
            property = resolveProperty_(property, data, options);
            resolved = true;
        } else {
            const functionMatch = property.match(regex.functionValidation);
            if (functionMatch) {
                property = resolveFunction_(property, functionMatch, data, postProcessingQueue, options);
                resolved = true;
            }
        }

        if (!resolved) {
            unresolved_(property, options);
            property = undefined;
        }

        if (!options.printUndefined && ((property === null) || (property === undefined))) {
            property = "";
        }

        resultList.push(property);
        return resultPrefix + (resultIndex++) + resultSuffix;
    });

    // apply options after the template was resolved

    if (options.markdown) {
        if (!markdownConverter_) markdownConverter_ = new Converter({
            tables: true,
            noHeaderId: true,
            literalMidWordUnderscores: true
        });
        result = markdownConverter_.makeHtml(result);
    }

    // resolve results after the markdown was parsed to avoid issues with special markdown characters
    for (let i = 0; i < resultList.length; ++i) {
        result = result.replaceAll(resultPrefix + i + resultSuffix, resultList[i]);
    }

    if (!options.returnAsString && (options.markdown || options.html)) {
        const div = document.createElement("div");
        const nodes = sanitizeHTML(result);
        div.append(...nodes);
        result = div;
    }

    if (options.postProcess && (result instanceof HTMLElement)) {
        for (let i = 0; i < postProcessingQueue.length; ++i) {
            try {
                postProcessingQueue[i](result);
            } catch (e) {
                console.warn(e);
            }
        }
    }

    return result;
}