User:Chlod/Scripts/ParsoidDocument/v2.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. This MUST NOT end with a slash.     */    const restBaseRoot = window.restBaseRoot || '/api/rest_';    /**     * Encodes text for an API parameter. This performs both an encodeURIComponent     * and a string replace to change spaces into underscores.     * @param {string} text     * @returns {string}     */    function encodeAPIComponent(text) {        return encodeURIComponent(text.replace(/ /g, '_'));    }    /**     * Clones a regular expression.     * @param regex The regular expression to clone.     * @returns A new regular expression object.     */    function cloneRegex(regex) {        return new RegExp(regex.source, regex.flags);    }    /**     * 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 {        /**         * Creates a new ParsoidTransclusionTemplateNode. Can be used later on to add a template         * into wikitext. To have this node show up in wikitext, append the node's element (using * {@link ParsoidTransclusionTemplateNode.element}) to the document of a ParsoidDocument.        * @param document The document used to generate this node.         * @param template The template to create. If you wish to generate wikitext as a block-type         *   transclusion (as long as a format is not provided through TemplateData), append a "\n"         *   to the end of the template name.         * @param parameters The parameters to the template.         * @param autosave         * @returns A new ParsoidTransclusionTemplateNode.         */        static fromNew(document, template, parameters, autosave) {            const el = document.getDocument.createElement('span');            const target = { wt: template };            if (mw === null || mw === void 0 ? void 0 : mw.Title) {               // If `mediawiki.Title` is loaded, use it.                target.href = './' + new mw.Title(target.wt, mw.config.get('wgNamespaceIds').template).getPrefixedDb;            }            const data = {                target,                params: {},                i: 0            };            for (const param in (parameters !== null && parameters !== void 0 ? parameters : {})) {                const value = parameters[param];                data.params[param] = {                    wt: typeof value === 'string' ? value : value.toString                };            }            el.setAttribute('typeof', 'mw:Transclusion');            el.setAttribute('data-mw', JSON.stringify({                parts: [{                        template: data                    }]            }));            return new ParsoidTransclusionTemplateNode(document, el, data, data.i, autosave); }       /**         * Create a new ParsoidTransclusionTemplateNode. * @param {ParsoidDocument} parsoidDocument *    The document handling this transclusion node. * @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(parsoidDocument, originalElement, data, i, autosave = true) { this.parsoidDocument = parsoidDocument; this.element = originalElement; this.data = data; this.i = i;           this.autosave = autosave; }       /**         * Gets the target of this node. * @returns {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.wt = wikitext; if (mw === null || mw === void 0 ? void 0 : mw.Title) { // If `mediawiki.Title` is loaded, use it. this.data.target.href = './' + new mw.Title(wikitext, mw.config.get('wgNamespaceIds').template).getPrefixedDb; }           else { // Likely inaccurate. Just remove it to make sent data cleaner. delete this.data.target.href; }           if (this.autosave) { this.save; }       }        /**         * Gets the parameters of this node. * @returns The parameters of this node, in wikitext. */       getParameters { return this.data.params; }       /**         * Checks if a template has a parameter. * @param {string} key The key of the parameter to check. * @returns {boolean} `true` if the template has the given parameter */       hasParameter(key) { return this.data.params[key] != null; }       /**         * Gets the value of a parameter. * @param {string} key The key of the parameter to check. * @returns {string} The parameter value. */       getParameter(key) { var _a; return (_a = this.data.params[key]) === null || _a === void 0 ? void 0 : _a.wt; }       /**         * Sets the value for a specific parameter. If `value` is null or undefined, * the parameter is removed. * @param {string} key The parameter key to set. * @param {string} value The new value of the parameter. */       setParameter(key, value) { if (value != null) { this.data.params[key] = { wt: value }; if (this.autosave) { this.save; }           }            else { this.removeParameter(key); }       }        /**         * Removes a parameter from the template. * @param key The parameter key to remove. */       removeParameter(key) { if (this.data.params[key] != null) { delete this.data.params[key]; }           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 };               }            }        }        /**         * Removes this node from its element. This will prevent the node from being saved * again. * @param eraseLine For block templates. Setting this to `true` will also erase a newline * that immediately succeeds this template, if one exists. This is useful in ensuring that * there are no excesses of newlines in the document. */       destroy(eraseLine) { var _a; const existingData = JSON.parse(this.element.getAttribute('data-mw')); if (existingData.parts.length === 1) { const nodeElements = this.parsoidDocument.getNodeElements(this); const succeedingTextNode = (_a = nodeElements[nodeElements.length - 1]) === null || _a === void 0 ? void 0 : _a.nextSibling; // The element contains nothing else except this node. Destroy the element entirely. this.parsoidDocument.destroyParsoidNode(this.element); if (eraseLine && succeedingTextNode &&                   succeedingTextNode.nodeType === Node.TEXT_NODE) { // Erase a starting newline, if one exists succeedingTextNode.nodeValue = succeedingTextNode.nodeValue .replace(/^\n/, ''); }           }            else { const partToRemove = existingData.parts.find((part) => { var _a; return ((_a = part.template) === null || _a === void 0 ? void 0 : _a.i) === this.i; }); if (eraseLine) { const iFront = existingData.parts.indexOf(partToRemove) - 1; const iBack = existingData.parts.indexOf(partToRemove) + 1; let removed = false; if (iBack < existingData.parts.length &&                       typeof existingData.parts[iBack] === 'string') { // Attempt to remove whitespace from the string in front of the template. if (/^\r?\n/.test(existingData.parts[iBack])) { // Whitespace found, remove it. existingData.parts[iBack] = existingData.parts[iBack].replace(/^\r?\n/, ''); removed = true; }                   }                    if (!removed && iFront > -1 && typeof existingData.parts[iFront] === 'string') { // Attempt to remove whitespace from the string behind the template. if (/\r?\n$/.test(existingData.parts[iFront])) { // Whitespace found, remove it. existingData.parts[iFront] = existingData.parts[iFront].replace(/\r?\n$/, ''); }                   }                }                existingData.parts.splice(existingData.parts.indexOf(partToRemove), 1); this.element.setAttribute('data-mw', JSON.stringify(existingData)); }       }        /**         * Saves this node (including modifications) back into its element. */       save { this.cleanup; const existingData = JSON.parse(this.element.getAttribute('data-mw')); existingData.parts.find((part) => { var _a; return ((_a = part.template) === null || _a === void 0 ? void 0 : _a.i) === this.i; }).template = this.data; this.element.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 from a page on-wiki. * @param {string} page The page to load. * @param {number} revision The revision ID of 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, revision = null, options = {}) { const doc = new ParsoidDocument; await doc.loadPage(page, revision, 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 restBaseUri The relative URI to the RESTBase instance to be used for transforms. * @param {boolean} wrap Set to `false` to avoid wrapping the HTML within the body. */       static async fromHTML(page, html, restBaseUri, wrap = true) { const doc = new ParsoidDocument; await doc.loadHTML(page, wrap ? ParsoidDocument.blankDocument : html, restBaseUri); if (wrap) { doc.document.getElementsByTagName('body')[0].innerHTML = html; }           return doc; }       /**         * Creates a new ParsoidDocument from a blank page. * @param {string} page The name of the page. * @param restBaseUri */       static async fromBlank(page, restBaseUri) { const doc = new ParsoidDocument; await doc.loadHTML(page, ParsoidDocument.blankDocument, restBaseUri); return doc; }       /**         * Creates a new ParsoidDocument from wikitext. * @param {string} page The page of the document. * @param {string} wikitext The wikitext to load. * @param restBaseUri */       static async fromWikitext(page, wikitext, restBaseUri) { const doc = new ParsoidDocument; await doc.loadWikitext(page, wikitext, restBaseUri); return doc; }       /**         * Get additional request options to be patched onto RESTBase API calls. * Extend this class to modify this. * @protected */       getRequestOptions { return { headers: { 'Api-User-Agent': 'parsoid-document/2.0.0 (https://github.com/ChlodAlejandro/parsoid-document; chlod@chlod.net)' }           };        }        /**         * @returns `true` if the page is a redirect. `false` if otherwise. */       get redirect { return this.document && this.document.querySelector("[rel='mw:PageProp/redirect']") !== null; }       /**         * Create a new ParsoidDocument instance. */       constructor { super; this.iframe = document.createElement('iframe'); 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}                 * @protected                 */                this.document = this.iframe.contentWindow.document;                this.$document = $(this.document);                this.setupJquery(this.$document);                this.buildIndex;                if (this.observer) {                    // This very much assumes that the MutationObserver is still connected.                    // Yes, this is quite an assumption, but should not be a problem during normal use.                    // If only MutationObserver had a `.connected` field... this.observer.disconnect; }               this.observer = new MutationObserver( => {                    this.buildIndex;                }); this.observer.observe(this.document.getElementsByTagName('body')[0], {                   // Listen for ALL DOM mutations.                    attributes: true,                    childList: true,                    subtree: true                }); // Replace the page title. Handles redirects. if (this.document.title) { this.page = (mw === null || mw === void 0 ? void 0 : mw.Title) ? new mw.Title(this.document.title).getPrefixedText : this.document.title; }           });            document.getElementsByTagName('body')[0].appendChild(this.iframe);        }        /**         * Set up a JQuery object for this window.         * @param $doc The JQuery object to set up.         * @returns 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. * @returns 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(this, rootNode, part.template, part.template.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 === null || mw === void 0 ? void 0 : 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 {number} revision The revision ID of 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. * @param options.restBaseUri *  A relative or absolute URI to the wiki's RESTBase root. This is        *   `/api/rest_` by default, though the `window.restBaseRoot` variable *  can modify it. * @param options.requestOptions *  Options to pass to the `fetch` request. * @param options.followRedirects *  Whether to follow page redirects or not. */       async loadPage(page, revision = null, options = {}) { var _a, _b, _c; if (this.document && options.reload !== true) { throw new Error('Attempted to reload an existing frame.'); }           this.restBaseUri = (_a = options.restBaseUri) !== null && _a !== void 0 ? _a : restBaseRoot; return fetch(`${this.restBaseUri}v1/page/html/${encodeAPIComponent(page)}${revision ? `/${revision}` : ""}?stash=true&redirect=${options.followRedirects !== false ? 'true' : 'false'}&t=${Date.now}`, Object.assign({ cache: 'no-cache' }, (_b = this.getRequestOptions) !== null && _b !== void 0 ? _b : {}, (_c = options.requestOptions) !== null && _c !== void 0 ? _c : {})) .then((data) => {               /**                 * The ETag of this iframe's content.                 * @type {string}                 */                this.etag = data.headers.get('ETag');                this.revision = revision;                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, this.restBaseUri)) .catch(this.notifyLoadError); }       /**         * Load a document from wikitext. * @param {string} page The page title of this document. * @param {string} wikitext The wikitext to load. * @param restBaseUri */       async loadWikitext(page, wikitext, restBaseUri) { var _a; this.restBaseUri = restBaseUri !== null && restBaseUri !== void 0 ? restBaseUri : restBaseRoot; return fetch(`${this.restBaseUri}v1/transform/wikitext/to/html/${encodeAPIComponent(page)}?t=${Date.now}`, Object.assign((_a = this.getRequestOptions) !== null && _a !== void 0 ? _a : {}, { 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, this.restBaseUri)) .catch(this.notifyLoadError); }       /**         * Load a document from HTML. * @param {string} page The loaded page's name. * @param {string} html The page's HTML. * @param restBaseUri A relative or absolute URI to the wiki's RESTBase root. */       async loadHTML(page, html, restBaseUri) { this.restBaseUri = restBaseUri !== null && restBaseUri !== void 0 ? restBaseUri : restBaseRoot; // 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; }, { once: true });           }); }       /**         * 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; const revision = this.revision; this.page = undefined; this.revision = undefined; return this.loadPage(page, revision, { reload: true }); }       /**         * Clears the frame for a future reload. This will later permit `loadPage` and * other related functions to run without the `reload` option. */       reset { // Reset the page this.page = undefined; // Reset the element index this.elementIndex = undefined; // Reset DOM-related fields this.document = undefined; this.$document = undefined; this.etag = undefined; this.fromExisting = undefined; // Disconnect the mutation observer this.observer.disconnect; this.observer = undefined; // Reset the IFrame this.iframe.src = 'about:blank'; // By this point, this whole thing should be a clean state. }       /**         * 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); });           });        }        /**         * Gets the ` ` HTMLElement given a section ID. * @param id The ID of the section * @returns The HTMLElement of the section. If the section cannot be found, `null`. */       getSection(id) { return this.document.querySelector(`section[data-mw-section-id="${id}"]`); }       /**         * 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. * @returns {HTMLElement} A list of elements. */       findTemplate(templateName, hrefMode = false) { var _a; if (this.document == null) { throw new Error("Can't perform operations without a loaded page."); }           const templates = (_a = this.elementIndex) === null || _a === void 0 ? void 0 : _a.Transclusion; if (templates == null || templates.length === 0) { return []; }           return templates.map((node) => {                const mwData = JSON.parse(node.getAttribute('data-mw'));                const matching = mwData.parts.filter((part) => { var _a; if (part.template == null) { return false; }                   if (((_a = part.template.target) === null || _a === void 0 ? void 0 : _a.href) == null) { // Parser function or magic word, not a template transclusion return false; }                   const compareTarget = part.template.target[hrefMode ? 'href' : 'wt']; if (typeof templateName !== 'string') { return cloneRegex(templateName).test(compareTarget.trim); }                   else { return templateName === compareTarget.trim; }               });                if (matching.length > 0) {                    return matching.map((part) => { return new ParsoidTransclusionTemplateNode(this, node, part.template, part.template.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. * @returns {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]`); }       /**         * Get HTML elements that are associated to a specific Parsoid node using its * `about` attribute. * @param node The node to get the elements of        * @returns All elements that match the `about` of the given node. */       getNodeElements(node) { return Array.from(this.document.querySelectorAll(`[about="${(node instanceof ParsoidTransclusionTemplateNode ? node.element : node)               .getAttribute('about')}"]`)); }       /**         * Deletes all elements that have the same `about` attribute as the given element. * This effectively deletes an element, be it a transclusion set, file, section, * or otherwise. * @param element */       destroyParsoidNode(element) { if (element.hasAttribute('about')) { this.getNodeElements(element).forEach((nodeElement) => {                   nodeElement.parentElement.removeChild(nodeElement);                }); }           else { // No "about" attribute. Just remove that element only. element.parentElement.removeChild(element); }       }        /**         * Converts the contents of this document to wikitext. * @returns {Promise } The wikitext of this document. */       async toWikitext { var _a; // this.restBaseUri should be set. let target = `${this.restBaseUri}v1/transform/html/to/wikitext/${encodeAPIComponent(this.page)}`; if (this.fromExisting) { target += `/${+(/(\d+)$/.exec(this.document.documentElement.getAttribute('about'))[1])}`; }           const requestOptions = this.getRequestOptions; return fetch(target, Object.assign(requestOptions, { method: 'POST', headers: Object.assign((_a = requestOptions.headers) !== null && _a !== void 0 ? _a : {}, { '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. * @returns {Document} {@link ParsoidDocument#document} */       getDocument { return this.document; }       /**         * Get the JQuery object associated with this ParsoidDocument. * @returns {*} {@link ParsoidDocument#$document} */       getJQuery { return this.$document; }       /**         * Get the IFrame element of this ParsoidDocument. * @returns {HTMLIFrameElement} {@link ParsoidDocument#iframe} */       getIframe { return this.iframe; }       /**         * Get the page name of the currently-loaded page. * @returns {string} {@link ParsoidDocument#page} */       getPage { return this.page; }       /**         * Get the element index of this ParsoidDocument. * @returns {@link ParsoidDocument#elementIndex} */       getElementIndex { return this.elementIndex; }       /**         * Check if this element exists on-wiki or not. * @returns {boolean} {@link ParsoidDocument#fromExisting} */       isFromExisting { return this.fromExisting; }   }    ParsoidDocument.Node = ParsoidTransclusionTemplateNode; /**    * 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 * */