WPMv2.js

/**
 * WPMv2 - Webstrate Package Manager
 * 
 * Copyright 2019 Rolf Bagge, Janus Bager Kristensen,
 * CAVI - Center for Advanced Visualisation and Interaction,
 * Aarhus University
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **/

/* global Promise, webstrate, eval */

//Encapsulate WPMv2, so we can decide which methods are public
((window) => {

    const WPM_ALIASES = "WPM.repoAliases";

    let allInstalledCallbacksStack = [];

    /**
     * WebstratePackageManager version 2
     *
     * It is used to install WPM packages into webstrates.
     *
     * <pre><code>WPMv2.require([
     *      {package: "somePackageName", repository: "/somewebstraterepo"},
     *      {package: "someOtherPackageName", repository: "/somewebstraterepo"}
     * ]).then(()=>{
     *     //Packages are now installed
     * });
     * </code></pre>
     * @hideconstructor
     */
    class WPMv2 {
        static async bootstrap(packageDom, options, requireToken, triggerOnPackageInstalled = false) {
            let wpmPackage = WPMv2.getWPMPackageFromDOM(packageDom);
            let promises = [];
            let alreadyLoadedExternals = new Set();

            /**
             * @class WPMInterface
             * @classdesc
             * Internal WPM interface that is provided for every package that is installed via WPMv2. Is accessed as just wpm, when inside package code.
             * @hideconstructor
             * @memberof WPMv2
             */
            let wpmInterface = {};

            /**
             * Reads metadata from the given package. If no packagename is given, metadata from the current package is read.
             *
             * @example
             * let metadata = wpm.readMetadata();
             *
             * @param {string} [packageName] - The package to read metadata from
             * @returns {json}
             * @memberof WPMv2.WPMInterface
             * @name readMetadata
             * @method
             */
            wpmInterface.readMetadata = (packageName = null) => {
                if (packageName == null) {
                    packageName = packageDom.getAttribute("id");
                }

                return WPMv2.readMetadata(packageName);
            };

            /**
             * Registers a callback to be called when this package is installed.
             *
             * @example
             * wpm.onInstalled(()=>{
             *     //Package is now installed
             * });
             *
             * @param {method} callback
             * @memberof WPMv2.WPMInterface
             * @name onInstalled
             * @method
             */
            wpmInterface.onInstalled = (callback) => {
                packageDom.addEventListener("wpm.packageInstalled", callback, {"once": true});
            };

            /**
             * Registers a callback to be called when all packages are installed. (When installing multiple packages at the same time.)
             *
             * @example
             * wpm.onAllInstalled(()=>{
             *     //All packages are installed
             * });
             *
             * @param {method} callback
             * @memberof WPMv2.WPMInterface
             * @name onAllInstalled
             * @method
             */
            wpmInterface.onAllInstalled = (callback) => {
                //Retrieve from stack
                let allInstalledCallbacks = allInstalledCallbacksStack[allInstalledCallbacksStack.length-1];
                if (allInstalledCallbacks){
                    allInstalledCallbacks.push(callback);
                } else {
                    // STUB: Remove FIXME if ok
                    console.log("FIXME: WPMv2 - No allInstalledCallbacks in allInstalledCallbacksStack, assuming no more packages? Is this ok?");
                    // Immediately call the callback since no more packages are left
                    callback();
                }
                
            };

            /**
             * Registers a callback to be called when this package is removed.
             *
             * @example
             * wpm.onRemoved(({detail: packageName})=>{
             *     //Package is removed, packageName is provided for ease of access, will be same as the package this callback was registered from.
             * });
             *
             * @param {method} callback
             * @memberof WPMv2.WPMInterface
             * @name onRemoved
             * @method
             */
            wpmInterface.onRemoved = (callback) => {
                packageDom.addEventListener("wpm.packageRemoved", callback, {"once": true});
            };

            /**
             * Registers a callback to be called when any package is removed.
             *
             * @example
             * wpm.onRemovedAny(({detail: packageName})=>{
             *      //Package with name "packageName" has been removed
             * });
             *
             * @param {method} callback
             * @memberof WPMv2.WPMInterface
             * @name onRemovedAny
             * @method
             */
            wpmInterface.onRemovedAny = (callback) => {
                document.addEventListener("wpm.packageRemovedAny", callback);
            };

            wpmInterface.require = async (packageRequests, extraOptions) => {
                let convertedPackages = [];

                if (packageRequests != null) {
                    if (!Array.isArray(packageRequests)) {
                        packageRequests = [packageRequests];
                    }

                    for (let packageRequest of packageRequests) {
                        if (typeof packageRequest === "string") {
                            //Shorthand for requiring dependency, lookup in our descriptor
                            let packageName = packageRequest;
                            let repo = wpmPackage.optionalDependencyMap.get(packageName);

                            convertedPackages.push({
                                package: packageName,
                                repository: repo
                            });
                        } else {
                            convertedPackages.push(packageRequest);
                        }
                    }
                } else {
                    //packages == null, means require all dependencies!
                    wpmPackage.optionalDependencyMap.forEach((repo, packageName) => {
                        convertedPackages.push({
                            package: packageName,
                            repository: repo
                        });
                    });
                }

                const combinedOptions = Object.assign({}, options, extraOptions);

                promises.push(WPMv2.require(convertedPackages, combinedOptions, requireToken));

                return Promise.all(promises);
            };

            async function loadExternalCSS(response) {
                let styleContent = await response.text();

                //Attempt linking stylesheets instead of inlining them
                let style = document.createElement("style");

                let transient = document.createElement("transient");
                transient.appendChild(style);

                //Disable sourcemap

                styleContent = styleContent.replace(/\/\*#\s*sourceMappingURL=\S+\s*\*\//, "");
                //styleContent = styleContent.replace(/\/\/#\s*sourceMappingURL=\S+/, "");

                style.innerHTML = styleContent;

                document.head.append(transient);
            }

            async function loadExternalJS(response) {
                let scriptContent = await response.text();

                //Hack to make requirejs work, and be able to hide it
                const origDefine = window.define;
                if (window.define != null) {
                    window.define = undefined;
                }

                //Disable sourcemap
                //scriptContent = scriptContent.replace(/\/\*#\s*sourceMappingURL=\S+\s*\*\//, "");
                scriptContent = scriptContent.replace(/\/\/# sourceMappingURL=\S+/, "");

                eval.call(null, scriptContent);

                //Restore previous define, if this script did not set define
                if (window.define == null && origDefine != null) {
                    window.define = origDefine;
                }
            }

            /**
             * Fetches and evaluates external javascript, or loads css.
             *
             * The server response header Content-Type will be used to determine if its a JS or CSS.
             *
             * @example
             * await wpm.requireExternal("https://some.site.com/someScript.js");
             * //someScript.js has now been parsed and evaluated
             *
             * @param {string|string[]} urls - The URLs to the wanted JS, CSS
             * @returns {Promise<void>} - Resolves when all scripts/styles are fetched and evaluated/loaded
             * @memberof WPMv2.WPMInterface
             * @name requireExternal
             * @method
             */
            wpmInterface.requireExternal = async (urls) => {
                if(!(urls instanceof Array)) {
                    urls = [urls];
                }

                for(let url of urls) {

                    let promise = new Promise(async (resolve, reject)=>{

                        let response = await fetch(url, {credentials: 'same-origin'});

                        let contentType = response.headers.get("Content-Type").trim();
                        let indexOfSemicolon = contentType.indexOf(";");
                        if(indexOfSemicolon !== -1) {
                            contentType = contentType.substring(0, indexOfSemicolon).trim();
                        }

                        switch(contentType) {
                            case "text/css":
                            {
                                await loadExternalCSS(response);
                                break;
                            }
                            case "text/javascript":
                            case "application/javascript":
                            case "application/x-javascript":
                            {
                                await loadExternalJS(response);
                                break;
                            }
                            default:
                                console.warn("Unhandled contentType:", contentType, url);
                                console.warn("Loading unknown as JS for know. please report...")
                                await loadExternalJS(response);
                        }
                        resolve();
                    });

                    promises.push(promise);

                    await promise;
                }
            };

            let scripts = packageDom.querySelectorAll("script[type='disabled']");

            for (let i = 0; i < scripts.length; i++) {
                let script = scripts[i];

                let scriptContent = "";

                if (script.src != null && script.src.length > 0) {
                    let response = await fetch(script.src, {credentials: 'same-origin'});
                    scriptContent = await response.text();
                } else {
                    scriptContent = script.innerText;
                }

                const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

                try {
                    let functionArgs = [];
                    functionArgs.push("wpm");

                    let functionArgValues = [];
                    functionArgValues.push(wpmInterface);

                    if(options.context != null) {
                        const contextKeys = Object.keys(options.context);
                        functionArgs.push(...contextKeys);

                        const contextValues = Object.values(options.context);
                        functionArgValues.push(...contextValues);
                    }

                    let wpmEval = new AsyncFunction(...functionArgs, scriptContent);
                    await wpmEval(...functionArgValues);
                } catch (e) {
                    console.groupCollapsed("Bootstrap error in " + packageDom.getAttribute("id"));
                    console.log(scriptContent);
                    console.groupEnd();
                    console.error(e);
                }
            }

            await Promise.all(promises);

            if (triggerOnPackageInstalled) {
                packageDom.dispatchEvent(new CustomEvent("wpm.packageInstalled"));
            }
        }

        /**
         * Installs all packages at the given repository into the current page
         *
         * @example
         * WPMv2.requireAll("https://some.site.com/myRepo");
         *
         * @param {string} repository - The repository to lookup packages from
         * @param {WPMv2~PackageOptions} options - options to use for overriding packages options, also applies for dependencies
         * @returns {Promise<void>} - Resolved when packages are installed
         */
        static async requireAll(repository, options = {}) {
            let packages = [];

            let wpmPackages = await WPMv2.getPackagesFromRepository(repository);

            wpmPackages.forEach((pkg) => {
                if(options.blacklist != null && options.blacklist instanceof Array && options.blacklist.includes(pkg.name)) {
                    //Skip this package
                    return;
                }
                
                let pkgOptions = {
                    repository: pkg.repository,
                    package: pkg.name
                };
                packages.push(pkgOptions);
            });

            return WPMv2.require(packages, options);
        }

        /**
         * @typedef {Object} WPMv2~PackageOptions
         * @property {string} [repository] - The repository to lookup the package inside. If left unset, the current page is used.
         * @property {HTMLElement|string} [appendTarget] - The dom node to append to. If a string, will be looked up by document.querySelector(appendTarget). Defaults to a transient div inside document.head
         * @property {('append'|'before'|'after'|'prepend')} [appendMethod] - How to append to the appendTarget. Defaults to 'append'.
         */

        /**
         * @typedef {Object} WPMv2~PackageJson
         * @property {string} package - The name of the package
         * @property {string} [repository] - The repository to lookup the package inside. If left unset, the current page is used.
         * @property {HTMLElement|string} [appendTarget] - The dom node to append to. If a string, will be looked up by document.querySelector(appendTarget). Defaults to a transient div inside document.head
         * @property {('append'|'before'|'after'|'prepend')} [appendMethod] - How to append to the appendTarget. Defaults to 'append'.
         */

        /**
         *
         * @param {WPMv2.WPMPackage[]}packages
         * @returns {Promise<void>}
         */
        static async findCompletePackageTreeSorted(packages = [], defaultOptions, overrideOptions = {}) {
            const numPackages = packages.length;

            if (!Array.isArray(packages)) {
                packages = [packages];
            }

            let convertedPackages = [];

            async function addRepo(repoUrl, options) {
                if(overrideOptions.repository != null) {
                    console.warn("Adding a full repository, does not atm support overriding options for repository...");
                }
                
                try {
                    let packages = await WPMv2.getPackagesFromRepository(repoUrl);
                    for(let pkg of packages) {
                        if(options != null) {
                            pkg.updateFromOptions(options);
                        }
                        await addPackage(pkg);
                    }
                } catch (ex){
                    console.error("WPMv2 very important error: Could not resolve repository. This will probably cause the site to fail horribly! ", repoUrl, ex);
                }
            }

            async function addPackage(wpmPackage) {
                if(!WPMv2.hasPackage(convertedPackages, wpmPackage)) {
                    try {
                        wpmPackage = await WPMv2.getLatestPackageFromPackage(wpmPackage);
                        let dependencies = await WPMv2.findAllDependencies(wpmPackage);

                        for(let dependency of dependencies) {
                            await addPackage(dependency);
                        }
                        convertedPackages.push(wpmPackage);
                    } catch (ex){
                        console.error("WPMv2 very important error: Could not resolve package. This will probably cause the site to fail horribly! ", wpmPackage, ex);
                    }
                }
            }

            for(let pkg of packages) {
                let wpmPackage = null;
                if (pkg instanceof WPMPackage) {
                    //Already a WPMPackage
                    wpmPackage = pkg;
                    wpmPackage.updateFromOptions(overrideOptions);
                } else if(typeof pkg === "string") {
                    if(pkg.startsWith("http") || (pkg.startsWith("/") && pkg.indexOf(" ") === 0)) {
                        //Full repository, http(s)://myrepourl or /my-relative-url
                        await addRepo(pkg);
                        continue;
                    } else {
                        //Single package, name or including repository
                        let split = pkg.split(" ");

                        if(split.length === 1) {
                            //Single local package
                            let options = Object.assign({}, defaultOptions, {
                                "package": split[0]
                            }, overrideOptions);
                            wpmPackage = new WPMPackage(options.package, options.repository);
                            wpmPackage.updateFromOptions(options);
                        } else if(split.length === 2) {
                            //Single package from given repository
                            let options = Object.assign({}, defaultOptions, {
                                "package": split[1].replace("#", ""),
                                "repository": split[0]
                            }, overrideOptions);
                            wpmPackage = new WPMPackage(options.package, options.repository);
                            wpmPackage.updateFromOptions(options);
                        } else {
                            console.warn("Unable to parse package from string:", pkg);
                        }
                    }
                } else {
                    if(pkg.repository != null && pkg.package != null) {
                        //Full package, add
                        let options = Object.assign({}, defaultOptions, pkg, overrideOptions);
                        wpmPackage = new WPMPackage(options.package, options.repository);
                        wpmPackage.updateFromOptions(options);
                    } else if(pkg.repository != null) {
                        //Full repo, add all
                        await addRepo(pkg.repository, pkg);
                        continue;
                    }
                }

                if(wpmPackage != null) {
                    await addPackage(wpmPackage);
                } else {
                    console.log("Was null:", pkg);
                }
            }

            const sortedPackages = [];
            let lastLength = convertedPackages.length;
            while(convertedPackages.length > 0) {
                let packagesWithDependenciesInstalled = convertedPackages.filter((pkg)=>{
                    let ready = true;

                    for(let dep of pkg.dependencyMap) {
                        //If any dependency is not sorted to be installed yet, this is not ready
                        if(!WPMv2.hasPackage(sortedPackages, {"package": dep[0]})) {
                            ready = false;
                            break;
                        }
                    }
                    for(let dep of pkg.optionalDependencyMap) {
                        //If not already sorted to be installed, and among packages to install, this is not ready yet
                        if(!WPMv2.hasPackage(sortedPackages, {"package": dep[0]}) && WPMv2.hasPackage(convertedPackages, {"package": dep[0]})) {
                            ready = false;
                            break;
                        }
                    }

                    return ready;
                });

                packagesWithDependenciesInstalled.forEach((pkg)=>{
                    sortedPackages.push(pkg);
                    convertedPackages.splice(convertedPackages.indexOf(pkg), 1);
                });

                if(convertedPackages.length === lastLength) {
                    console.warn("Not able to add any more packages:", convertedPackages);
                    break;
                }
                lastLength = convertedPackages.length;
            }

            return sortedPackages;
        }

        /**
         * Finds all dependencies of a package
         * @param pkg
         * @returns {Promise<WPMv2.WPMPackage[]>}
         * @private
         */
        static async findAllDependencies(pkg) {
            let dependencies = [];

            for(let dependencyEntry of pkg.dependencyMap) {
                let dependency = new WPMPackage(dependencyEntry[0], dependencyEntry[1]);
                dependencies.push(dependency);
            }

            return dependencies;
        }

        /**
         * Checks if the given array, contains the given package
         * @private
         */
        static hasPackage(packages, searchPackage) {
            return packages.find((pkg)=>{
                let pkgName = null;
                let searchPackageName = null;
                if(pkg instanceof WPMPackage) {
                    pkgName = pkg.name;
                } else if(pkg.package != null) {
                    pkgName = pkg.package;
                } else {
                    console.warn("Unable to infer package name from:", pkg);
                }

                if(searchPackage instanceof WPMPackage) {
                    searchPackageName = searchPackage.name;
                } else if(searchPackage.package != null) {
                    searchPackageName = searchPackage.package;
                } else {
                    console.warn("Unable to infer package name from:", searchPackage);
                }

                if(pkgName == null || searchPackageName == null) {
                    console.warn("Unable to compare as one was null");
                    return false;
                }

                return pkgName === searchPackageName;
            }) != null;
        }

        /**
         * Installs the given packages into the current document
         *
         * Override options set in overrideOptions, override the options given in packages.
         *
         * @example
         * WPMv2.require([{package: "myPackage", repository: "myRepositoryUrl"}]);
         *
         * @param {WPMv2.WPMPackage[]|WPMv2.WPMPackage|WPMv2~PackageJson[]|WPMv2~PackageJson} packages - the packages to install
         * @param {WPMv2~PackageOptions} overrideOptions - options to use for overriding packages options, also applies for dependencies
         * @returns {Promise<void>} - Resolves when the packages are done installing
         */
        static async require(packages = [], overrideOptions = {}, givenRequireToken = null) {
            const defaultOptions = {
                repository: WPMv2.getLocalRepositoryURL(),
                appendMethod: "append",
                appendTarget: null
            };

            //Make sure we dont override package
            if(overrideOptions.hasOwnProperty("package")) {
                console.warn("Overriding package...", overrideOptions);
                delete overrideOptions.package;
            }

            const completePackageTreeSorted = await WPMv2.findCompletePackageTreeSorted(packages, defaultOptions, overrideOptions);

            if (packages.length === 0) {
                return;
            }

            let requireToken = givenRequireToken;

            let timerId = [...Array(10)].map(_ => (Math.random() * 36 | 0).toString(36)).join``;
            let requireTimerId = "Require time [" +timerId +"]";

            if (givenRequireToken == null) {
                allInstalledCallbacksStack.push([]);
                requireToken = {};
                console.time(requireTimerId);
            }

            for (let pkg of completePackageTreeSorted) {

                let options = Object.assign({}, defaultOptions, pkg.getPackageOptions(), overrideOptions);

                //Check if package is in dom
                let packageDom = document.querySelector(".packages .package#" + pkg.name + ", wpm-package#" + pkg.name);

                let alreadyInstalled = false;

                let wpmPackage = null;

                let needsAppending = false;

                if (packageDom == null) {
                    //We need to fetch and install package to dom
                    let fetchedPackageDom = await WPMv2.getPackageDOM(pkg.repository, pkg.name);

                    //Rewrite packageDom to a wpm-package
                    packageDom = document.createElement("wpm-package");

                    for (let index = fetchedPackageDom.attributes.length - 1; index > -1; --index) {
                        let attribute = fetchedPackageDom.attributes[index];
                        packageDom.setAttribute(attribute.name, attribute.value);
                    }

                    // Instead of display:none, hide it otherwise due to Chrome bug for SVGs
                    packageDom.style.width = 0;
                    packageDom.style.height = 0;
                    packageDom.style.position = "absolute";
                    packageDom.style.visibility = "hidden";

                    Array.from(fetchedPackageDom.children).forEach((child) => {
                        packageDom.appendChild(child);
                    });

                    WPMv2.stripProtection(packageDom);

                    wpmPackage = WPMv2.getWPMPackageFromDOM(packageDom);

                    needsAppending = true;
                } else {
                    wpmPackage = WPMv2.getWPMPackageFromDOM(packageDom);
                    alreadyInstalled = true;
                }

                //Install into page
                if(needsAppending) {
                    let appendTarget = options.appendTarget;

                    if(typeof appendTarget === "string") {
                        appendTarget = document.querySelector(appendTarget);
                    }

                    if (appendTarget == null) {
                        appendTarget = document.createElement("div");
                        appendTarget.setAttribute("transient-element", "");
                        appendTarget.setAttribute("transient-wpmid", packageDom.id);
                        document.head.appendChild(appendTarget);
                    }

                    switch (options.appendMethod.toLowerCase()) {
                        case "before":
                            appendTarget.parentNode.insertBefore(packageDom, appendTarget);
                            break;

                        case "after":
                            appendTarget.parentNode.insertBefore(packageDom, appendTarget.nextSibling);
                            break;
                        case "prepend":
                            appendTarget.prepend(packageDom);
                            break;

                        case "append":
                        default:
                            appendTarget.append(packageDom);
                    }

                    // POST all assets to the target
                    if (wpmPackage.assets.length > 0) {
                        let repoAssetsUrl = wpmPackage.repository.substring(0, wpmPackage.repository.indexOf("?")) + "?assets&latest";
                        let repoAssets = await WPMv2.fetchAssets(repoAssetsUrl);

                        let localAssetsUrl = location.pathname + "?assets&latest";
                        let localAssets = await WPMv2.fetchAssets(localAssetsUrl);

                        let formData = new FormData();
                        let assetPromises = [];
                        wpmPackage.assets.forEach(function (asset) {
                            //If we already have same filehash of this asset, skip
                            let localAsset = localAssets.get(asset);
                            let repoAsset = repoAssets.get(asset);

                            if(localAsset != null && repoAsset != null && localAsset.fileHash === repoAsset.fileHash) {
                                return;
                            }

                            assetPromises.push(new Promise(async function (resolve, reject) {
                                // Construct URL
                                let assetUrl = wpmPackage.repository.substring(0, wpmPackage.repository.indexOf("?"));
                                if (!assetUrl.endsWith("/")) {
                                    assetUrl += "/";
                                }
                                assetUrl += asset;

                                // Fetch it and append to POST
                                let response = await fetch(assetUrl, {credentials: 'same-origin'});
                                let blob = await response.blob();
                                formData.append("file", blob, asset);
                                resolve();
                            }));
                        });

                        if(assetPromises.length > 0) {
                            await Promise.all(assetPromises);

                            await fetch(location.pathname, {
                                body: formData,
                                credentials: 'same-origin',
                                method: "post"
                            });
                        }
                    }
                }

                //Check if package is live
                if (packageDom.getAttribute("transient-wpm-live") == null) {
                    //Make package live
                    await WPMv2.bootstrap(packageDom, overrideOptions, requireToken, !alreadyInstalled);

                    packageDom.setAttribute("transient-wpm-live", "");
                } else {
                    //Already live
                }
            }

            //Only the first outer call to require, has givenAllInstalledCallbacks set to null
            if (givenRequireToken === null) {
                let allInstalledCallbacks = allInstalledCallbacksStack.pop();
                console.timeEnd(requireTimerId);

                let allInstalledTimerId = "All Installed [" + timerId + "]";

                console.time(allInstalledTimerId);
                for(let allInstalledCallback of allInstalledCallbacks) {
                    await allInstalledCallback();
                }
                console.timeEnd(allInstalledTimerId);
            }
        }

        /**
         * Get the package data based on the package DOM node
         * 
         * @param {Node} packageDOM the package dom node
         * @returns {WPMPackage} the package
         * @ignore
         */
        static getWPMPackageFromDOM(packageDOM) {
            try {
                let name = packageDOM.getAttribute("id");

                let descriptorDom = packageDOM.querySelector("script[type='descriptor'], wpm-descriptor");

                if (descriptorDom !== null) {                    
                    try {
                        let packageJson = JSON.parse(descriptorDom.textContent);
                        let repository = packageDOM.getAttribute("data-repository");

                        if(repository == null) {
                            repository = WPMv2.getLocalRepositoryURL();
                        }

                        return new WPMPackage(name, repository, packageJson);
                    } catch (e){
                        console.error("Erroneous package descriptor", e, descriptorDom.textContent, packageDOM);
                    }
                } else {
                    console.error("Missing package descriptor: ", packageDOM);
                }
            } catch (e) {
                console.error(e);
            }
        }

        static getLocalRepositoryURL() {
            return location.origin + location.pathname + "?raw";
        }

        /**
         * Retrieve the package dom from a repository
         * 
         * @param {String} repository the repository to retrieve from
         * @param {String} packageName the package to retrieve
         * @returns {Node} the package dom node
         * @ignore
         */
        static async getPackageDOM(repository, packageName) {
            let dom = await WPMv2.fetchDom(repository);

            let packageDOMSource = dom.querySelector(".packages .package#" + packageName + ", wpm-package#" + packageName);
            if (packageDOMSource === null) {
                throw new Error("Invalid package '" + packageName + "' specified, no such package in repository '" + repository + "'");
            }

            let packageDOM = packageDOMSource.cloneNode(true);
            packageDOM.setAttribute("data-repository", repository);

            return packageDOM;
        }

        /**
         * Get an array of all packages that is currently installed in the dom
         *
         * @example
         * let installedPackages = WPMv2.getCurrentlyInstalledPackages();
         *
         * @returns {WPMv2.WPMPackage[]}
         */
        static getCurrentlyInstalledPackages() {
            let packages = [];

            document.querySelectorAll(".packages .package, wpm-package").forEach(function (v) {
                packages.push(WPMv2.getWPMPackageFromDOM(v));
            });

            return packages;
        }

        /**
         * Retrieve the latest package data from the original repository this package is from
         *
         * @example
         * WPMv2.getCurrentlyInstalledPackages().forEach((pkg)=>{
         *     let package = WPMv2.getLatestPackageFromPackage(pkg);
         *     //package now holds the latest data retrieved from the original repo it was installed from: like version, dependencies, changelog etc.
         * });
         *
         * @param {WPMv2.WPMPackage} p - The package to update package data for
         * @returns {Promise<WPMv2.WPMPackage>}
         */
        static async getLatestPackageFromPackage(p) {
            let packageDOM = await WPMv2.getPackageDOM(p.repository, p.name);

            let updatedPackage = WPMv2.getWPMPackageFromDOM(packageDOM);

            updatedPackage.updateFromOptions(p.getPackageOptions());

            return updatedPackage;
        }

        /**
         * Find all packages at a repository
         *
         * @example
         * WPMv2.getPackagesFromRepository("some.site.com/myRepo").then((packages)=>{
         *     console.log("Packages at repo:");
         *     packages.forEach((pkg)=>{
         *         console.log(pkg);
         *     }):
         * });
         *
         * @param {String} repositoryUrl the repository to search
         * @returns {WPMv2.WPMPackage[]} the packages found
         */
        static async getPackagesFromRepository(repositoryUrl) {
            let packages = [];

            let dom = await WPMv2.fetchDom(repositoryUrl);

            dom.querySelectorAll(".packages .package, wpm-package").forEach(function (v) {
                v.setAttribute("data-repository", repositoryUrl);
                packages.push(WPMv2.getWPMPackageFromDOM(v));
            });

            return packages;
        }

        static readMetadata(packageName) {
            let packageDom = document.querySelector(".packages .package#" + packageName + ", wpm-package#" + packageName);

            if(packageDom != null) {
                let metadataDom = packageDom.querySelector("script[type='descriptor'], wpm-descriptor");

                if (metadataDom != null) {
                    return JSON.parse(metadataDom.textContent);
                }

            }
            return null;
        }

        static async fetchAssets(url) {
            if (WPMv2.assetsCache[url] != null) {
                if (Date.now() - WPMv2.assetsCache[url].timestamp < WPMv2.cacheTimeout) {
                    return WPMv2.assetsCache[url].assets;
                }
            }

            let response = await fetch(url, {credentials: 'same-origin'});

            let assetsJson = await response.json();

            let assetResult = new Map();

            assetsJson.forEach((asset)=>{
                let current = assetResult.get(asset.fileName);

                if(current == null || current.v < asset.v) {
                    assetResult.set(asset.fileName, asset);
                }
            });

            WPMv2.assetsCache[url] = {
                assets: assetResult,
                timestamp: Date.now()
            };

            return assetResult;
        }

        static lookupRepoAlias(alias) {
            let localStorageAliases = {};
            let sessionStorageAliases = {};
            try {
                localStorageAliases = JSON.parse(localStorage.getItem(WPM_ALIASES));
            } catch (ex){
                console.warn("Unparseable localStorage.repositoryAliases", ex);
            }
            try {
                sessionStorageAliases = JSON.parse(sessionStorage.getItem(WPM_ALIASES));
            } catch (ex){
                console.warn("Unparseable sessionStorage.repositoryAliases", ex);
            }

            if(localStorageAliases?.hasOwnProperty(alias)) {
                let result = localStorageAliases[alias];
                return result;
            } else if(sessionStorageAliases?.hasOwnProperty(alias)) {
                let result = sessionStorageAliases[alias];
                return result;
            } else {
                //Check if alias might be an url already?
                if(alias.startsWith("http") || alias.startsWith("/")) {
                    //Probabely an url
                    return alias;
                }
            }

            return ["/"+alias+"/?raw", "/"+alias+"/index.html"];
        }

        static async fetchDom(url) {
            //Lookup repos aliases
            url = WPMv2.lookupRepoAlias(url);

            if (WPMv2.domCache[url] != null) {
                if (Date.now() - WPMv2.domCache[url].timestamp < WPMv2.cacheTimeout) {
                    return WPMv2.domCache[url].dom;
                }
            }

            let response = null;

            if(Array.isArray(url)) {
                for(let u of url) {
                    try {
                        response = await fetch(u, {credentials: 'same-origin'});
                        if(response != null && response.status === 200) {
                            //We got a correct response, break
                            break;
                        }
                        response = null;
                    } catch(e) {
                        //Ignore?
                        console.warn(e);
                    }
                }
            } else {
                if(url.endsWith("?raw") && !url.endsWith("/?raw")) {
                    url = url.substring(0, url.lastIndexOf("?raw")) + "/?raw";
                }

                response = await fetch(url, {credentials: 'same-origin'});
            }

            if(response != null) {

                let documentText = await response.text();

                let parsedDom = WPMv2.parser.parseFromString(documentText, "text/html");

                if (parsedDom.readyState === "loading") {
                    await new Promise((resolve, reject) => {
                        parsedDom.addEventListener("DOMContentLoaded", () => {
                            resolve();
                        });
                    });
                }

                WPMv2.domCache[url] = {
                    dom: parsedDom,
                    timestamp: Date.now()
                };

                return parsedDom;
            } else {
                console.error("Unable to fetchDOM from: ", url);
            }
        }

        /**
         * Strips all Webstrate protection from the given dom element and its children.
         *
         * @example
         * WPMv2.stripProtection(document.querySelector("#myElement"));
         *
         * @param {HTMLElement} html - The element to strip protection from
         */
        static stripProtection(html) {
            function stripAttributeProtection(elm) {
                if (!elm.__approvedAttributes) {
                    try {
                        elm.__approvedAttributes = new Set();
                    } catch (e) {
                    }
                }

                if (elm.attributes != null) {
                    for (let i = 0, atts = elm.attributes, n = atts.length; i < n; i++) {
                        elm.__approvedAttributes.add(atts[i].nodeName);
                    }
                }
            }

            if (html instanceof Array) {
                html.forEach((entry) => {
                    if (entry != null) {
                        WPMv2.stripProtection(entry);
                    }
                });
                return;
            }

            if (!html.__approved) {
                try {
                    html.__approved = true;
                } catch (e) {
                }
            }

            if (html.removeAttribute != null) {
                html.removeAttribute("unapproved");
            }

            stripAttributeProtection(html);

            if (html.childNodes != null) {
                Array.from(html.childNodes).forEach((child) => {
                    WPMv2.stripProtection(child);
                });
            } else if (html.children != null) {
                Array.from(html.children).forEach((child) => {
                    WPMv2.stripProtection(child);
                });
            }

            if (html.content != null) {
                WPMv2.stripProtection(html.content);
            }
        }

        /**
         * Updates the version of WPMv2 in the current page, with the version in the given url
         *
         * @example
         * await WPMv2.updateWPM("https://some.site.com/containsLatestWPMv2");
         * //WPMv2 is now updated
         *
         * @param {string} url - URL to the webstrate to update WPMv2 from
         * @returns {Promise<void>} - Resolves when WPMv2 is updated
         */
        static async updateWPM(url) {
            console.group("Updating WPM...");
            if(url == null) {
                console.log("No repository given for update, defaulting to \"/wpm/?raw\"");
                url = "/wpm/?raw";
            }

            console.log("Version before update:", window.WPMv2.version);

            let dom = await WPMv2.fetchDom(url);

            let newWpm = dom.querySelector("#WPMv2-script");

            let ourWpm = document.querySelector("#WPMv2-script");

            ourWpm.textContent = newWpm.textContent;

            if(ourWpm.hasAttribute("src")) {
                ourWpm.removeAttribute("src");
                console.warn("Removed src attribute on WPMScript, now inlined instead!");
            }
            eval.call(null, ourWpm.textContent);
            console.log("Version after update:", window.WPMv2.version);
            console.groupEnd();
        }

        /**
         * Installs WPMv2 into the given webstrate. Can be given as an iframe that already points to a transcluded webstrate, or the url to a webstrate.
         *
         * @example
         * await WPMv2.installWPMInto("https://some.site.com/myWebstrate");
         * //WPMv2 is now installed
         *
         * @param {HTMLIFrameElement|string} iframeOrUrl - The iframe or url that WPMv2 should be installed into
         * @returns {Promise<void>} - Resolves when WPMv2 is done installing.
         */
        static async installWPMInto(iframeOrUrl) {
            let iframe = null;
            let transient = null;

            if (typeof iframeOrUrl === "string") {
                iframe = document.createElement("iframe");
                iframe.src = iframeOrUrl;
                let promise = new Promise((resolve, reject) => {
                    iframe.webstrate.on("transcluded", function once() {
                        iframe.webstrate.off("transcluded", once);
                        resolve();
                    });
                });

                transient = document.createElement("transient");
                transient.append(iframe);
                document.body.append(transient);

                await promise;
            } else {
                //Attempt to unpack cQuery/jQuery objects
                if (iframeOrUrl[0] != null) {
                    iframeOrUrl = iframeOrUrl[0];
                }

                if (iframeOrUrl instanceof HTMLIFrameElement) {
                    iframe = iframeOrUrl;
                } else {
                    console.log("Unknown iframe/url: ", iframeOrUrl);
                    return;
                }
            }

            let targetHead = iframe.contentDocument.head;

            //Remove old WPMv2 if present
            let oldWpm = iframe.contentDocument.querySelector("#WPMv2-script");
            if (oldWpm != null) {
                oldWpm.parentNode.removeChild(oldWpm);
            }

            let clonedScript = document.querySelector("#WPMv2-script").cloneNode(true);

            if (clonedScript.src != null && clonedScript.src.length > 0) {
                let response = await fetch(clonedScript.src, {credentials: 'same-origin'});
                let scriptContent = await response.text();

                clonedScript.removeAttribute("src");
                clonedScript.textContent = scriptContent;
            }

            WPMv2.stripProtection(clonedScript);
            targetHead.insertBefore(clonedScript, targetHead.firstChild);

            iframe.contentWindow.eval.call(null, clonedScript.textContent);

            await iframe.contentWindow.webstrate.dataSaved();

            if (transient != null) {
                document.body.removeChild(transient);
            }
        }

        static notifyRemove(packageName, packageDom) {
            console.log("Removed package: ", packageName);

            let event = new CustomEvent("wpm.packageRemoved", {detail: packageName});
            packageDom.dispatchEvent(event);

            let eventAny = new CustomEvent("wpm.packageRemovedAny", {detail: packageName});
            document.dispatchEvent(eventAny);
        }

        static getRegisteredRepositories(useLocalStorage) {
            let currentAliases = null;

            try {
                if(useLocalStorage) {
                    currentAliases = JSON.parse(localStorage.getItem(WPM_ALIASES));
                } else {
                    currentAliases = JSON.parse(sessionStorage.getItem(WPM_ALIASES));
                }
            } catch (ex){}
            if (currentAliases == null || typeof currentAliases !== "object"){
                currentAliases = {};
            }

            return currentAliases;
        }

        /**
         * Registers a repository alias
         * @param alias The alias to register
         * @param repository The repository to register the alias to
         * @param useLocalStorage If true, the registered alias is registered in localStorage, if not, in sessionStorage
         */
        static registerRepository(alias, repository, useLocalStorage = false) {
            let currentAliases = null;

            try {
                if(useLocalStorage) {
                    currentAliases = JSON.parse(localStorage.getItem(WPM_ALIASES));
                } else {
                    currentAliases = JSON.parse(sessionStorage.getItem(WPM_ALIASES));
                }
            } catch (ex){}
            if (currentAliases == null || typeof currentAliases !== "object"){
                currentAliases = {};
            }

            currentAliases[alias] = repository;

            if(useLocalStorage) {
                localStorage.setItem(WPM_ALIASES, JSON.stringify(currentAliases));
            } else {
                sessionStorage.setItem(WPM_ALIASES, JSON.stringify(currentAliases));
            }
        }

        /**
         * Unregisters a repository alias
         * @param alias The alias to unregister
         * @param useLocalStorage If true, the alias is removed from localStorage, if not, from sessionStorage
         */
        static unregisterRepository(alias, useLocalStorage = false) {
            let currentAliases = null;

            try {
                if(useLocalStorage) {
                    currentAliases = JSON.parse(localStorage.getItem(WPM_ALIASES));
                } else {
                    currentAliases = JSON.parse(sessionStorage.getItem(WPM_ALIASES));
                }
            } catch (ex){}
            if (currentAliases != null || typeof currentAliases !== "object"){
                currentAliases = {};
            }

            delete currentAliases[alias];

            if(useLocalStorage) {
                localStorage.setItem(WPM_ALIASES, JSON.stringify(currentAliases));
            } else {
                sessionStorage.setItem(WPM_ALIASES, JSON.stringify(currentAliases));
            }
        }

        /**
         * Clears all registered aliases from storage
         * @param useLocalStorage If true, clears from localStorage, if not, from sessionStorage
         */
        static clearRegisteredRepositories(useLocalStorage = false) {
            if(useLocalStorage) {
                localStorage.setItem(WPM_ALIASES, "{}");
            } else {
                sessionStorage.setItem(WPM_ALIASES, "{}");
            }
        }
    }

    WPMv2.domCache = {};
    WPMv2.assetsCache = {};
    WPMv2.parser = new DOMParser();
    WPMv2.cacheTimeout = 5000;

    /**
     * A WPM package
     * @memberof WPMv2
     */
    class WPMPackage {
        /**
         * Create a new WPMPackage
         * @param {string} name - The package name
         * @param {string} repository - The repository that the package should be fetched from
         * @param {json} [descriptorJson] - Package Descriptor
         */
        constructor(name, repository, descriptorJson = {}) {
            /**
             * The name of the package
             * @type {string}
             */
            this.name = name;
            /**
             * The repository the package is fetched from
             * @type {string}
             */
            this.repository = repository;

            /**
             * The version of the package
             * @type {number}
             */
            this.version = -1;
            /**
             * Package dependencies that will be installed when the package is installed
             * @type {string[]}
             */
            this.dependencies = [];
            /**
             * Optional Package dependencies
             * @type {string[]}
             */
            this.optionalDependencies = [];
            /**
             * Assets that the package uses, will be copied over to the webstrate where the package is installed
             * @type {Array.<string>}
             */
            this.assets = [];
            /**
             * A description of the package
             * @type {string}
             */
            this.description = "";
            /**
             * A human friendly name for the package
             * @type {string}
             */
            this.friendlyName = "";
            /**
             * Changelog, holding any changelog information for the package
             * @type {object}
             */
            this.changelog = {};
            /**
             * Link to documentation of the package if any exists
             * @type {string}
             */
            this.documentationLink = "";

            this.dependencyMap = new Map();
            this.optionalDependencyMap = new Map();

            this.appendMethod = "append";
            this.appendTarget = null;

            this.updateFromJson(descriptorJson);
        }

        updateFromOptions(options) {
            ["appendMethod", "appendTarget", "repository"].forEach((optionProperty)=>{
                if(options.hasOwnProperty(optionProperty)) {
                    this[optionProperty] = options[optionProperty];
                }
            })
        }

        getPackageOptions() {
            let options = {};

            ["appendMethod", "appendTarget", "repository"].forEach((optionProperty)=>{
                options[optionProperty] = this[optionProperty];
            });

            return options;
        }

        updateFromJson(packageJson) {
            let self = this;

            this.descriptor = packageJson;
            
            ["version", "friendlyName", "dependencies", "optionalDependencies", "description", "changelog", "documentationLink", "license"].forEach((packageProperty)=>{
                if (packageJson.hasOwnProperty(packageProperty)){
                    this[packageProperty] = packageJson[packageProperty];
                }
            });

            if (packageJson.hasOwnProperty("assets")) {
                packageJson.assets.forEach((asset) => {
                    if (asset.src != null) {
                        self.assets.push(asset.src);
                    } else {
                        self.assets.push(asset);
                    }
                });
            }

            this.dependencies.forEach((dep) => {
                let split = dep.split(" ");

                let repo = null;
                let packageName = null;

                if (split.length === 1) {
                    packageName = split[0].replace("#", "");
                    repo = this.repository;
                } else {
                    packageName = split[1].replace("#", "");
                    repo = split[0];
                }

                self.dependencyMap.set(packageName, repo);
            });

            this.optionalDependencies.forEach((dep) => {
                let split = dep.split(" ");

                let repo = null;
                let packageName = null;

                if (split.length === 1) {
                    packageName = split[0].replace("#", "");
                    repo = this.repository;
                } else {
                    packageName = split[1].replace("#", "");
                    repo = split[0];
                }

                self.optionalDependencyMap.set(packageName, repo);
            });
        }

        toString() {
            return this.name + "[" + this.version + "]";
        }
    }

    let removedObserver = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            mutation.removedNodes.forEach((node) => {
                if (node.matches != null && node.matches(".packages .package, wpm-package")) {
                    WPMv2.notifyRemove(node.id, node);
                } else if (node.querySelectorAll != null) {
                    node.querySelectorAll(".packages .package, wpm-package").forEach((child) => {
                        WPMv2.notifyRemove(child.id, child);
                    });
                }
            });
        });
    });

    removedObserver.observe(document, {
        childList: true,
        attributes: false,
        subtree: true
    });

    //Setup attribute "transient-element" that marks a DOM element as transient
    if(typeof webstrate !== "undefined") {
        let oldIsTransientElement = webstrate.config.isTransientElement;
        webstrate.config.isTransientElement = (node) => {
            if (node.hasAttribute("transient-element")) {
                return true;
            }

            return oldIsTransientElement(node);
        };
    }

    //WPMv2 Interface to the world!
    window.WPMv2 = {
        require: WPMv2.require,
        requireAll: WPMv2.requireAll,
        installWPMInto: WPMv2.installWPMInto,
        stripProtection: WPMv2.stripProtection,
        updateWPM: WPMv2.updateWPM,
        getPackagesFromRepository: WPMv2.getPackagesFromRepository,
        getCurrentlyInstalledPackages: WPMv2.getCurrentlyInstalledPackages,
        getLatestPackageFromPackage: WPMv2.getLatestPackageFromPackage,
        registerRepository: WPMv2.registerRepository,
        unregisterRepository: WPMv2.unregisterRepository,
        clearRegisteredRepositories: WPMv2.clearRegisteredRepositories,
        getRegisteredRepositories: WPMv2.getRegisteredRepositories,
        version: 2.27,
        revision: "$Id: WPMv2.js 905 2022-04-28 06:34:15Z au182811@uni.au.dk $"
    };
    
    window.WPM = window.WPMv2;
    window.WPMPackage = WPMPackage;
})(window);

// Provide bootloader functionality
class WPMBoot {
    static loadedCallbacks = [];
    static isLoaded = false;
    
    static async wpmv2_bootloader(){
        document.querySelector("html").setAttribute("transient-wpm2-bootloader", "loading");
        
        let bootConfigElement = document.querySelector("head script[type='text/json+bootconfig']");
        if (!bootConfigElement){
            return;
        }

        let bootConfig = null;
        try {
            bootConfig = JSON.parse(bootConfigElement.textContent);
        } catch (ex){
            console.error("WPM bootloader cannot parse boot config", bootConfigElement.textContent, ex);
            return;
        }

        if (!bootConfig.require){
            console.warn("WPM bootloader did not find required 'require' section in boot config, ignoring");
            return;
        }

        if (!Array.isArray(bootConfig.require)){
            console.warn("WPM bootloader 'require' section in boot config is not an array, ignoring");
            return;
        }

        // Load all required packages with WPM
        for (let requireStep of bootConfig.require){
            if (!(requireStep.dependencies && Array.isArray(requireStep.dependencies))){
                console.warn("WPM bootloader skipping incorrect requirestep, dependency list is missing", requireStep);
                continue;
            }
            if (requireStep.repositories){
                if (typeof requireStep.repositories !== "object"){
                    console.warn("WPM bootloader skipping registration of repositories because requireStep.repositories isn't an object map of name->url", requireStep);
                } else {
                    for (const [key, value] of Object.entries(requireStep.repositories)) {
                        WPMv2.registerRepository(key, value);
                    }
                }
            }

            if (requireStep.options){
                await WPMv2.require(requireStep.dependencies, requireStep.options);
            } else {
                await WPMv2.require(requireStep.dependencies);
            }
        }
               
        // Fire loaded events
        document.querySelector("html").setAttribute("transient-wpm2-bootloader", "initializing");
        while (WPMBoot.loadedCallbacks.length>0){
            let callback = WPMBoot.loadedCallbacks.pop();
            try {
                await callback();
            } catch (ex){
                console.error("WPMv2 Bootloader exception in WPMBoot.onLoaded(...) callback", ex, callback);
            }
        }
        document.querySelector("html").setAttribute("transient-wpm2-bootloader", "loaded");
    }
    
    static async onLoaded(callback){
        if (WPMBoot.isLoaded){
            await callback();
        } else {
            WPMBoot.loadedCallbacks.push(callback);
        }
    }
}
document.querySelector("html").setAttribute("transient-wpm2-bootloader", "waiting");
if(typeof webstrate  !== "undefined") {
    // Webstrate mode
    webstrate.on("loaded", async function wpmv2_bootloader_loader() {
        await WPMBoot.wpmv2_bootloader();
    });
} else {
    // Standalone mode
    document.addEventListener("DOMContentLoaded", async function(event) {
        await WPMBoot.wpmv2_bootloader();
    });
}