User:Chlod/Scripts/ParsoidDocument/2.0.0-rc1.js

/* * Easy-to-use and extend ES9+ ParsoidDocument class for managing Parsoid-compatible HTML * by placing the data in an IFrame, making it accessible via DOM functions. * * Fires "parsoidDocument:load" event on load. * * This JavaScript file is used as a library. For more information, see * User:Chlod/Scripts/ParsoidDocument. A declaration file for TypeScript * can be found at User:Chlod/Scripts/ParsoidDocument.d.ts */ // ( => {    /**     * The root of this wiki's RestBase endpoint.     */    const restBaseRoot = window.restBaseRoot || '/api/rest_v1';    /**     * Encodes text for an API parameter. This performs both an encodeURIComponent     * and a string replace to change spaces into underscores.     *     * @param {string} text     * @return {string}     */    function encodeAPIComponent(text) {        return encodeURIComponent(text.replace(/ /g, '_'));    }    /**     * A class denoting a transclusion template node (a transcluded template, barring any included * text or inline parameters) inside an element with [typeof="mw:Transclusion"].    */    class ParsoidTransclusionTemplateNode {        /**         * Create a new ParsoidTransclusionTemplateNode.         *         * @param {HTMLElement} originalElement         *     The original element where the `data-mw` of this node is found.         * @param {*} data         *     The `data-mw` `part.template` of this node.         * @param {number} i         *     The `i` property of this node.         * @param {boolean} autosave         *     Whether to automatically save parameter and target changes or not.         */        constructor(originalElement, data, i, autosave = true) {            this.originalElement = originalElement;            this.data = data;            this.i = i;            this.autosave = autosave;        }        /**         * Gets the target of this node.         *         * @return {Object} The target of this node, in wikitext and href (for links). */       getTarget { return this.data.target; }       /**         * Sets the target of this template (in wikitext). *        * @param {string} wikitext *  The target template (in wikitext, e.g. `Test/`). */       setTarget(wikitext) { this.data.target = wikitext; if (this.autosave) { this.save; }       }        /**         * Gets the parameters of this node. *        * @return {Object.} The parameters of this node, in wikitext. */       getParameters { return this.data.params; }       /**         * Gets the value of a parameter. *        * @param {string} key The key of the parameter to check. * @return {string} The parameter value. */       getParameter(key) { return this.data.params[key].wt; }       /**         * Sets the value for a specific parameter. *        * @param {string} key The parameter key to set. * @param {string} value The new value of the parameter. */       setParameter(key, value) { this.data.params[key] = { wt: value }; if (this.autosave) { this.save; }       }        /**         * Fix improperly-set parameters. */       cleanup { for (const key of Object.keys(this.data.params)) { const param = this.data.params[key]; if (typeof param === 'string') { this.data.params[key] = { wt: param };               }            }        }        /**         * Saves this node (including modifications) back into its element. */       save { this.cleanup; const existingData = JSON.parse(this.originalElement.getAttribute('data-mw')); existingData.parts.find((part) => part.i === this.i).template = this.data; this.originalElement.setAttribute('data-mw', JSON.stringify(existingData)); }   }    /**     * A class containing an {@link HTMLIFrameElement} along with helper functions * to make manipulation easier. */   class ParsoidDocument extends EventTarget { /**        * Create a new ParsoidDocument instance. */       constructor { super; this.iframe = document.createElement('iframe'); this.iframe.id = 'coordinatorFrame'; Object.assign(this.iframe.style, {               width: '0',                height: '0',                border: '0',                position: 'fixed',                top: '0',                left: '0'            }); this.iframe.addEventListener('load', => {                if (this.iframe.contentWindow.document.URL === 'about:blank') {                    // Blank document loaded. Ignore.                    return;                }                /**                 * The document of this ParsoidDocument's IFrame.                 *                 * @type {Document}                 * @private                 */                this.document = this.iframe.contentWindow.document;                this.$document = $(this.document);                this.setupJquery(this.$document);                this.buildIndex;            }); document.getElementsByTagName('body')[0].appendChild(this.iframe); }       /**         * Create a new ParsoidDocument instance from a page on-wiki. *        * @param {string} page The page to load. * @param {Object} options Options for frame loading. * @param {boolean} options.reload *  Whether the current page should be discarded and reloaded. * @param options.allowMissing *  Set to `false` to avoid loading a blank document if the page does not exist. */       static async fromPage(page, options) { const doc = new ParsoidDocument; await doc.loadPage(page, options); return doc; }       /**         * Create a new ParsoidDocument instance from plain HTML. *        * @param {string} page The name of the page. * @param {string} html The HTML to use. * @param {boolean} wrap Set to `false` to avoid wrapping the HTML within the body. */       static async fromHTML(page, html, wrap = true) { const doc = new ParsoidDocument; await doc.loadHTML(page, wrap ? ParsoidDocument.blankDocument : html); if (wrap) { doc.document.body.innerHTML = html; }           return doc; }       /**         * Creates a new ParsoidDocument from a blank page. *        * @param {string} page The name of the page. */       static async fromBlank(page) { const doc = new ParsoidDocument; await doc.loadHTML(page, ParsoidDocument.blankDocument); return doc; }       /**         * Creates a new ParsoidDocument from wikitext. *        * @param {string} page The page of the document. * @param {string} wikitext The wikitext to load. */       static async fromWikitext(page, wikitext) { const doc = new ParsoidDocument; await doc.loadWikitext(page, wikitext); return doc; }       /**         * Set up a JQuery object for this window. *        * @param $doc The JQuery object to set up. * @return The JQuery object. */       setupJquery($doc) { // noinspection JSPotentiallyInvalidConstructorUsage const $proto = $doc.constructor.prototype; /* eslint-disable-next-line @typescript-eslint/no-this-alias */ const doc = this; $proto.parsoidNode = function { if (this.length === 1) { return doc.findParsoidNode(this[0]); }               else { return this.map((node) => doc.findParsoidNode(node)); }           };            $proto.parsoid = function  { /**                * Processes an element and extracts its transclusion parts. *                * @param {HTMLElement} element Element to process. * @return The transclusion parts. */               function process(element) { const rootNode = doc.findParsoidNode(element); const mwData = JSON.parse(rootNode.getAttribute('data-mw')); return mwData.parts.map((part) => {                       if (part.template) {                            return new ParsoidTransclusionTemplateNode(rootNode, part.template, part.i);                        }                        else {                            return part;                        }                    }); }               if (this.length === 1) { return process(this[0]); }               else { return this.map((element) => process(element)); }           };            return $doc; }       /**         * Notify the user of a document loading error. *        * @param {Error} error An error object. */       notifyLoadError(error) { if (mw.notify) { mw.notify([                   ( => { const a = document.createElement('span'); a.innerText = 'An error occurred while loading a Parsoid document: '; return a;                   }),                    ( => { const b = document.createElement('b'); b.innerText = error.message; return b;                   })                ], {                    tag: 'parsoidDocument-error',                    type: 'error'                }); }           throw error; }       /**         * Loads a wiki page with this ParsoidDocument. *        * @param {string} page The page to load. *        * @param {Object} options Options for frame loading. * @param {boolean} options.reload *  Whether the current page should be discarded and reloaded. * @param options.allowMissing *  Set to `false` to avoid loading a blank document if the page does not exist. */       async loadPage(page, options = {}) { if (this.document && options.reload !== true) { throw new Error('Attempted to reload an existing frame.'); }           return fetch(`${restBaseRoot}/page/html/${encodeAPIComponent(page)}?stash=true&t=${Date.now}`, {                cache: 'no-cache'            }) .then((data) => {               /**                 * The ETag of this iframe's content.                 *                 * @type {string}                 */                this.etag = data.headers.get('ETag');                if (data.status === 404 && options.allowMissing !== false) {                    this.fromExisting = false;                    // A Blob is used in order to allow cross-frame access without changing                    // the origin of the frame.                    return Promise.resolve(ParsoidDocument.defaultDocument);                }                else {                    this.fromExisting = true;                    return data.text;                }            }) .then((html) => this.loadHTML(page, html)) .catch(this.notifyLoadError); }       /**         * Load a document from wikitext. *        * @param {string} page The page title of this document. * @param {string} wikitext The wikitext to load. */       async loadWikitext(page, wikitext) { return fetch(`${restBaseRoot}/transform/wikitext/to/html/${encodeAPIComponent(page)}?t=${Date.now}`, {               cache: 'no-cache',                method: 'POST',                body: ( => { const formData = new FormData; formData.set('wikitext', wikitext); formData.set('body_only', 'false'); return formData; })           })                .then((data) => {                /**                 * The ETag of this iframe's content.                 *                 * @type {string}                 */                this.etag = data.headers.get('ETag');                this.fromExisting = false;                return data.text;            }) .then((html) => this.loadHTML(page, html)) .catch(this.notifyLoadError); }       /**         * Load a document from HTML. *        * @param {string} page The loaded page's name. * @param {string} html The page's HTML. */       async loadHTML(page, html) { // A Blob is used in order to allow cross-frame access without changing // the origin of the frame. this.iframe.src = URL.createObjectURL(new Blob([html], { type: 'text/html' })); this.page = page; return new Promise((res) => {               this.iframe.addEventListener('load',  => { res; });           });        }        /**         * Destroys the frame and pops it off of the DOM (if inserted). * Silently fails if the frame has not yet been built. */       destroy { if (this.iframe && this.iframe.parentElement) { this.iframe.parentElement.removeChild(this.iframe); this.iframe = undefined; }       }        /**         * Reloads the page. This will destroy any modifications made to the document. */       async reload { const page = this.page; this.page = undefined; return this.loadPage(page); }       /**         * Clears the frame for a future reload. */       reset { this.page = undefined; }       /**         * Constructs the {@link ParsoidDocument#elementIndex} from the current document. */       buildIndex { if (this.document == null) { throw new Error("Can't perform operations without a loaded page."); }           this.elementIndex = {}; const nodes = this.document.querySelectorAll('[typeof^=\'mw:\']'); nodes.forEach((node) => {               node.getAttribute('typeof')                    .split(/\s+/g)                    .map((type) => type.replace(/^mw:/, ''))                    .forEach((type) => { if (this.elementIndex[type] == null) { this.elementIndex[type] = []; }                   this.elementIndex[type].push(node); });           });        }        /**         * Finds a template in the loaded document. *        * @param {string|RegExp} templateName The name of the template to look for. * @param {boolean} hrefMode Use the href instead of the wikitext to search for templates. * @return {HTMLElement} A list of elements. */       findTemplate(templateName, hrefMode = false) { if (this.document == null) { throw new Error("Can't perform operations without a loaded page."); }           const templates = this.elementIndex.Transclusion; if (templates.length === 0) { return []; }           return templates.map((node) => {                const mwData = JSON.parse(node.getAttribute('data-mw'));                const matching = mwData.parts.filter((part) => { if (part.template == null) { return false; }                   const compareTarget = part.template.target[hrefMode ? 'href' : 'wt']; if (typeof templateName !== 'string') { return templateName.test(compareTarget.trim); }                   else { return templateName === compareTarget.trim; }               });                if (matching.length > 0) {                    return matching.map((part) => { return new ParsoidTransclusionTemplateNode(node, part.template, part.i); });               }                else {                    return [];                }            }).reduce((a, b) => a.concat(b), []); }       /**         * Finds the element with the "data-mw" attribute containing the element * passed into the function. *        * @param {HTMLElement} element *  The element to find the parent of. This must be a member of the *  ParsoidDocument's document. * @return {HTMLElement} The element responsible for showing the given element. */       findParsoidNode(element) { let pivot = element; while (pivot.getAttribute('about') == null) { if (pivot.parentElement == null) { // Dead end. throw new Error('Reached root of DOM while looking for original Parsoid node.'); }               pivot = pivot.parentElement; }           return this.document.querySelector(`[about="${pivot.getAttribute('about')}"][data-mw]`); }       /**         * Converts the contents of this document to wikitext. *        * @return {Promise } The wikitext of this document. */       async toWikitext { let target = `${restBaseRoot}/transform/html/to/wikitext/${encodeAPIComponent(this.page)}`; if (this.fromExisting) { target += `/${+(/(\d+)$/.exec(this.document.documentElement.getAttribute('about'))[1])}`; }           return fetch(target, {                method: 'POST',                headers: {                    'If-Match': this.fromExisting ? this.etag : undefined                },                body: ( => { const data = new FormData; data.set('html', this.document.documentElement.outerHTML); data.set('scrub_wikitext', 'true'); data.set('stash', 'true'); return data; })           }).then((data) => data.text); }       /**         * Get the {@link Document} object of this ParsoidDocument. *        * @return {Document} {@link ParsoidDocument#document} */       getDocument { return this.document; }       /**         * Get the JQuery object associated with this ParsoidDocument. *        * @return {*} {@link ParsoidDocument#$document} */       getJQuery { return this.$document; }       /**         * Get the IFrame element of this ParsoidDocument. *        * @return {HTMLIFrameElement} {@link ParsoidDocument#iframe} */       getIframe { return this.iframe; }       /**         * Get the page name of the currently-loaded page. *        * @return {string} {@link ParsoidDocument#page} */       getPage { return this.page; }       /**         * Get the element index of this ParsoidDocument. *        * @return {Object.} {@link ParsoidDocument#elementIndex} */       getElementIndex { return this.elementIndex; }       /**         * Check if this element exists on-wiki or not. *        * @return {boolean} {@link ParsoidDocument#fromExisting} */       isFromExisting { return this.fromExisting; }   }    /**     * A blank Parsoid document, with a section 0. *    * @type {string} */   ParsoidDocument.blankDocument = '     '; /**    * The default document to create if a page was not found. *    * @type {string} */   ParsoidDocument.defaultDocument = ParsoidDocument.blankDocument; window.ParsoidDocument = ParsoidDocument; } ); // /* * Copyright 2021 Chlod * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the * Software, and to permit persons to whom the Software is furnished to do so, subject * to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies * or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE * OR OTHER DEALINGS IN THE SOFTWARE. * * Also licensed under the Creative Commons Attribution-ShareAlike 3.0 * Unported License, a copy of which is available at * *   https://creativecommons.org/licenses/by-sa/3.0 * */