From 3f47f8881ecc7c6c42baec6edd38ba6bfc6b48bf Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 29 Oct 2026 03:01:05 -0400 Subject: [PATCH 01/11] Add doc packs: documentation added by link, per browser A doc pack is a folder laid out like public/docs (docs.json, and per doc index.json, db.json and the pages), served from GitHub or any HTTPS site that allows cross-origin reads. Packs are added under Preferences > Doc packs, stored in localStorage, and their docs are listed, enabled, installed offline and updated like built-in docs. - Pack docs get the pack's code in their slug, so they can't replace a built-in doc or another pack's, and are fetched from the pack's folder. - Everything from a pack is checked before it's shown: names and attributions lose markup, links must be http(s), unsafe entry paths are dropped, and pages go through DOMPurify (vendored, 3.4.17). - The server serves the full app for pack-doc URLs, which it can't know. - Type names are now escaped on the type page. Co-Authored-By: Claude Opus 5.5 --- assets/javascripts/app/app.js | 7 +- assets/javascripts/app/doc_packs.js | 157 + assets/javascripts/globals.d.ts | 12 + assets/javascripts/lib/doc_packs.js | 384 +++ assets/javascripts/models/doc.js | 25 +- .../templates/pages/settings_tmpl.js | 32 + .../javascripts/templates/pages/type_tmpl.js | 2 +- assets/javascripts/vendor/purify.js | 2543 +++++++++++++++++ .../javascripts/views/content/entry_page.js | 5 +- .../views/content/settings_page.js | 87 +- assets/stylesheets/components/_settings.scss | 21 + assets/stylesheets/global/_icons.scss.erb | 10 + docs/doc-packs.md | 66 + lib/app.rb | 12 +- test/app_test.rb | 20 + test/assets/doc_packs_test.js | 227 ++ 16 files changed, 3597 insertions(+), 13 deletions(-) create mode 100644 assets/javascripts/app/doc_packs.js create mode 100644 assets/javascripts/lib/doc_packs.js create mode 100644 assets/javascripts/vendor/purify.js create mode 100644 docs/doc-packs.md create mode 100644 test/assets/doc_packs_test.js diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 7dcd06bdf1..1a6518ba1c 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -2,6 +2,7 @@ import { config } from "./config.js"; import { DB } from "./db.js"; +import { DocPacks } from "./doc_packs.js"; import { Router } from "./router.js"; import { AppServiceWorker } from "./serviceworker.js"; import { Settings } from "./settings.js"; @@ -81,6 +82,7 @@ export class App extends Events { this.serviceWorker = new AppServiceWorker(); } this.settings = new Settings(); + this.docPacks = new DocPacks(this.localStorage); this.db = new DB(); this.settings.initLayout(); @@ -181,10 +183,10 @@ export class App extends Events { delete this.DOC; } - /** Boots with every doc in the manifest, enabled or not. */ + /** Boots with every doc in the manifest and the added packs, enabled or not. */ async bootAll() { const docs = this.settings.getDocs(); - for (var doc of this.DOCS) { + for (var doc of this.DOCS.concat(this.docPacks.docs())) { (docs.includes(/** @type {string} */ (doc.slug)) ? this.docs : this.disabledDocs).add(doc); } delete this.DOCS; @@ -400,6 +402,7 @@ export class App extends Events { } new News(); new Updates(); + this.docPacks.refresh(); return (this.updateChecker = new UpdateChecker()); } diff --git a/assets/javascripts/app/doc_packs.js b/assets/javascripts/app/doc_packs.js new file mode 100644 index 0000000000..6b96e7225a --- /dev/null +++ b/assets/javascripts/app/doc_packs.js @@ -0,0 +1,157 @@ +// @ts-check + +import { ajax } from "../lib/ajax.js"; +import { + DocPackError, + MANIFEST_FILENAME, + packDocs, + packUrl, + readManifest, +} from "../lib/doc_packs.js"; +/** @import { StoredPack } from "../lib/doc_packs.js" */ +/** @import { LocalStorageStore } from "../lib/local_storage_store.js" */ + +/** + * The doc packs this person has added (see lib/doc_packs.js), kept in their + * browser's local storage with each pack's manifest, so that the app can list + * the packs' docs at boot without waiting on the network. + */ +export class DocPacks { + static STORAGE_KEY = "docPacks"; + + /** How long a downloaded manifest is used before it's downloaded again. */ + static REFRESH_INTERVAL = 24 * 60 * 60 * 1000; + + /** @param {LocalStorageStore} store */ + constructor(store) { + this.store = store; + } + + /** @returns {StoredPack[]} */ + all() { + const packs = this.store.get(DocPacks.STORAGE_KEY); + return Array.isArray(packs) + ? packs.filter((pack) => pack && typeof pack.url === "string" && Array.isArray(pack.docs)) + : []; + } + + /** @returns {Record[]} Every pack's docs, as manifest attributes. */ + docs() { + return this.all().flatMap(packDocs); + } + + /** + * Downloads a pack's manifest and adds the pack. + * + * @param {string} input What the person typed or pasted. + * @param {(error: DocPackError | null, pack?: StoredPack) => void} callback + */ + add(input, callback) { + const url = packUrl(input); + if (!url) { + callback( + new DocPackError( + /^\s*http:\/\//i.test(input) + ? "The address has to start with https://, so that nobody can change the pack on its way to you." + : "That isn't a web address. Paste a link such as github.com/someone/their-docs.", + ), + ); + return; + } + if (this.all().some((pack) => pack.url === url)) { + callback(new DocPackError("That pack is already added.")); + return; + } + + this.fetch(url, (error, pack) => { + if (error) { + callback(error); + return; + } + if (!this.save([...this.all(), pack])) { + callback( + new DocPackError( + "The pack couldn't be saved, because this browser blocks local storage for DevDocs.", + ), + ); + return; + } + callback(null, pack); + }); + } + + /** + * @param {string} url + * @returns {StoredPack | undefined} The pack that was removed. + */ + remove(url) { + const packs = this.all(); + const pack = packs.find((pack) => pack.url === url); + if (pack) { + this.save(packs.filter((other) => other !== pack)); + } + return pack; + } + + /** + * Downloads again the manifests that are older than a day, in the + * background. Changes take effect the next time the app loads, like updates + * to the built-in docs. + */ + refresh() { + for (const pack of this.all()) { + if (Date.now() - (pack.fetchedAt || 0) < DocPacks.REFRESH_INTERVAL) { + continue; + } + this.fetch(pack.url, (error, fresh) => { + if (error) { + return; + } + // Read again: the list may have changed while the request ran. + this.save( + this.all().map((other) => (other.url === pack.url ? fresh : other)), + ); + }); + } + } + + /** + * @param {string} url A URL returned by `packUrl`. + * @param {(error: DocPackError | null, pack?: StoredPack) => void} callback + */ + fetch(url, callback) { + const manifestUrl = url + MANIFEST_FILENAME; + ajax({ + url: manifestUrl, + dataType: "json", + success: (json) => { + let pack; + try { + pack = readManifest(json, url); + } catch (error) { + callback(error); + return; + } + pack.fetchedAt = Date.now(); + callback(null, pack); + }, + error: (type) => { + callback( + new DocPackError( + type === "invalid" + ? `${manifestUrl} isn't valid JSON.` + : `Couldn't download ${manifestUrl}. Check the address, and that the site lets other sites read its files (GitHub does).`, + ), + ); + }, + }); + } + + /** + * @param {StoredPack[]} packs + * @returns {boolean} Whether the packs were stored. + */ + save(packs) { + return !!this.store.set(DocPacks.STORAGE_KEY, packs); + } +} diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index eb5036f9e4..9ebd78edd3 100644 --- a/assets/javascripts/globals.d.ts +++ b/assets/javascripts/globals.d.ts @@ -24,6 +24,11 @@ declare global { captureMessage(message: string, options?: Record): void; }; + /** DOMPurify — cleans the HTML of pages from doc packs. */ + const DOMPurify: { + sanitize(html: string, config?: Record): string; + }; + /** Prism.js — only the parts the app uses. */ const Prism: { highlightElement(element: Element, async?: boolean): void; @@ -128,6 +133,13 @@ declare module "./models/doc.js" { */ alias: string | null; + /** For a doc from a doc pack: the folder its files are served from. */ + origin?: string; + /** For a doc from a doc pack: the pack's id (see lib/doc_packs.js). */ + pack?: string; + /** For a doc from a doc pack: its slug in the pack, before the pack's id was added. */ + source_slug?: string; + /** Derived: the slug without its version. */ slug_without_version: string; /** Derived: the name with the version appended. */ diff --git a/assets/javascripts/lib/doc_packs.js b/assets/javascripts/lib/doc_packs.js new file mode 100644 index 0000000000..c407568c6e --- /dev/null +++ b/assets/javascripts/lib/doc_packs.js @@ -0,0 +1,384 @@ +// @ts-check + +/** + * Doc packs: documentation published by anyone, outside DevDocs' own catalog. + * + * A pack is a folder served over HTTPS — a Git repository on GitHub, or any + * website that lets other sites read its files (CORS) — laid out like DevDocs' + * own docs folder: a docs.json manifest listing the docs, and one folder per + * doc slug holding index.json, db.json and the pages as .html files. A copy of + * the public/docs folder that `thor docs:generate` and `thor docs:manifest` + * produce is therefore a pack as it is. + * + * Nothing in a pack is trusted. Its docs get slugs of their own, so they can't + * replace a built-in doc or one from another pack, and everything that ends up + * in the page is checked here first: names and versions lose markup, links + * have to be http(s), entry paths can't break out of an attribute, and pages + * go through DOMPurify before they're shown. + * + * This module has no dependency on the app, so it can be tested under Node. + */ + +export const MANIFEST_FILENAME = "docs.json"; + +/** A pack may list at most this many docs; anything past it is ignored. */ +export const MAX_DOCS = 500; + +/** + * Doc slugs as DevDocs writes them: lower case, with an optional version after + * a tilde, e.g. `html~5` or `node~10_lts`. No hyphen, which the router reads as + * the start of a type (`/:doc-:type/`), and no slash, which separates the + * enabled slugs in the settings. + */ +const SLUG_RGX = /^[a-z0-9_.]+(?:~[a-z0-9_.]+)?$/; + +/** The scraper types that pick a page's styling, e.g. `mdn`. */ +const TYPE_RGX = /^[a-z_]+$/; + +/** Type slugs go into a URL and an attribute. */ +const TYPE_SLUG_RGX = /^[\w.-]+$/; + +/** Characters that could end an attribute or open a tag. */ +const MARKUP_RGX = /[<>"'`\u0000-\u001f]/g; + +/** + * An entry path is appended to `//` in links, and to the pack's address + * when the page is fetched. + */ +const SAFE_PATH_RGX = /^[^\s"'`<>\\]+$/; + +/** A problem with a pack, worded for the person who added it. */ +export class DocPackError extends Error { + name = "DocPackError"; +} + +/** + * @typedef {object} StoredPack + * @property {string} url The pack's folder, ending in a slash. + * @property {string} id Short code that makes the pack's slugs unique. + * @property {string} name Shown in the preferences. + * @property {Record[]} docs The manifest as downloaded. + * @property {number} [fetchedAt] When the manifest was last downloaded. + */ + +/** + * Turns what someone pastes into the pack's folder URL, ending in a slash. + * + * - `github.com/owner/repo` reads the default branch from + * raw.githubusercontent.com, which serves the files with CORS headers; + * `/tree//` picks a branch and a folder in the repository. + * - Any other address is used as it is, minus a trailing `docs.json`. + * + * @param {unknown} input + * @returns {string | null} `null` when it isn't a usable address. + */ +export function packUrl(input) { + let text = String(input ?? "").trim(); + if (!text) { + return null; + } + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) { + text = `https://${text}`; + } + + let url; + try { + url = new URL(text); + } catch (error) { + return null; + } + // Plain http only for a pack being tested on this computer. + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal(url))) { + return null; + } + if (url.username || url.password) { + return null; + } + + const segments = url.pathname.split("/").filter(Boolean).map(decodeSegment); + if (segments.includes(null) || segments.includes("..")) { + return null; + } + if (segments.at(-1) === MANIFEST_FILENAME) { + segments.pop(); + } + + if (url.hostname === "github.com" || url.hostname === "www.github.com") { + const [owner, repo, kind, ref, ...folder] = segments; + if (!owner || !repo) { + return null; + } + const inTree = (kind === "tree" || kind === "blob") && ref; + return rawGitHubUrl(owner, repo.replace(/\.git$/, ""), inTree ? ref : "HEAD", inTree ? folder : []); + } + + return url.origin + "/" + segments.map((s) => encodeURIComponent(s) + "/").join(""); +} + +/** + * @param {string} owner + * @param {string} repo + * @param {string} ref A branch, tag or commit; `HEAD` is the default branch. + * @param {string[]} folder + * @returns {string} + */ +function rawGitHubUrl(owner, repo, ref, folder) { + return ( + "https://raw.githubusercontent.com/" + + [owner, repo, ref, ...folder].map((s) => encodeURIComponent(s) + "/").join("") + ); +} + +/** @param {URL} url */ +function isLocal(url) { + return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); +} + +/** @param {string} segment */ +function decodeSegment(segment) { + try { + return decodeURIComponent(segment); + } catch (error) { + return null; + } +} + +/** + * A short code for the pack, from its URL, so the same pack always gets the + * same slugs (and keeps its enabled and installed docs across visits). FNV-1a, + * in base 36. + * + * @param {string} url + * @returns {string} + */ +export function packId(url) { + let hash = 0x811c9dc5; + for (let i = 0; i < url.length; i++) { + hash ^= url.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(36).padStart(7, "0").slice(-6); +} + +/** + * The name a pack is shown under when its manifest doesn't give one: the + * GitHub owner and repository, or the host and folder. + * + * @param {string} url A URL returned by `packUrl`. + * @returns {string} + */ +export function defaultPackName(url) { + const { hostname, pathname } = new URL(url); + const segments = pathname.split("/").filter(Boolean).map((s) => decodeSegment(s) || s); + if (hostname === "raw.githubusercontent.com") { + const [owner, repo, ref, ...folder] = segments; + return [`${owner}/${repo}`, ...folder].join("/") + (ref === "HEAD" ? "" : ` (${ref})`); + } + return [hostname, ...segments].join("/"); +} + +/** + * Checks a downloaded docs.json and turns it into a pack to store. + * + * The manifest is either DevDocs' own format, an array of docs, or an object + * with a `docs` array and an optional `name`. + * + * @param {unknown} json + * @param {string} url A URL returned by `packUrl`. + * @returns {StoredPack} + * @throws {DocPackError} When it isn't a pack, or lists no usable doc. + */ +export function readManifest(json, url) { + const list = Array.isArray(json) + ? json + : isObject(json) && Array.isArray(json.docs) + ? json.docs + : null; + if (!list) { + throw new DocPackError( + `${MANIFEST_FILENAME} should list the pack's docs, but it doesn't look like a doc pack.`, + ); + } + + const docs = list.filter(isObject).slice(0, MAX_DOCS); + const pack = { + url, + id: packId(url), + name: cleanText(isObject(json) && !Array.isArray(json) ? json.name : null) || defaultPackName(url), + docs, + }; + if (packDocs(pack).length === 0) { + throw new DocPackError( + `${MANIFEST_FILENAME} lists no usable docs. Each needs a name and a slug such as "my_notes" or "my_notes~2".`, + ); + } + return pack; +} + +/** + * The pack's docs as the app's manifest attributes, with the docs that aren't + * valid left out. + * + * @param {StoredPack} pack + * @returns {Record[]} + */ +export function packDocs(pack) { + const seen = new Set(); + const docs = []; + for (const raw of pack.docs || []) { + const doc = packDoc(raw, pack); + if (doc && !seen.has(doc.slug)) { + seen.add(doc.slug); + docs.push(doc); + } + } + return docs; +} + +/** + * @param {Record} raw One doc from the pack's manifest. + * @param {StoredPack} pack + * @returns {Record | null} + */ +function packDoc(raw, pack) { + const sourceSlug = typeof raw.slug === "string" ? raw.slug.trim() : ""; + const name = cleanText(raw.name); + if (!SLUG_RGX.test(sourceSlug) || !name) { + return null; + } + const [base, version] = sourceSlug.split("~"); + + return { + name, + // The pack's code goes before the version, which has to stay last. + slug: `${base}_${pack.id}` + (version ? `~${version}` : ""), + // Absent means unversioned and empty means the latest version, so the + // difference is kept. + version: typeof raw.version === "string" ? cleanText(raw.version) : undefined, + release: cleanText(raw.release) || undefined, + type: typeof raw.type === "string" && TYPE_RGX.test(raw.type) ? raw.type : undefined, + mtime: nonNegative(raw.mtime), + db_size: nonNegative(raw.db_size), + links: cleanLinks(raw.links), + attribution: cleanText(stripTags(raw.attribution), 1000) || undefined, + alias: null, + origin: `${pack.url}${encodeURIComponent(sourceSlug)}/`, + pack: pack.id, + source_slug: sourceSlug, + }; +} + +/** + * Keeps the entries and types of a pack's index.json that are safe to put in + * a link. + * + * @param {unknown} data + * @returns {{ entries: Record[], types: Record[] }} + */ +export function cleanIndex(data) { + const index = isObject(data) ? data : {}; + const entries = (Array.isArray(index.entries) ? index.entries : []) + .filter(isObject) + .filter((entry) => typeof entry.name === "string" && isSafePath(entry.path)) + .map((entry) => ({ + name: entry.name, + path: entry.path, + type: typeof entry.type === "string" ? entry.type : undefined, + })); + const types = (Array.isArray(index.types) ? index.types : []) + .filter(isObject) + .filter( + (type) => + typeof type.name === "string" && + typeof type.slug === "string" && + TYPE_SLUG_RGX.test(type.slug), + ) + .map((type) => ({ name: type.name, slug: type.slug, count: nonNegative(type.count) })); + return { entries, types }; +} + +/** + * @param {unknown} path + * @returns {path is string} + */ +function isSafePath(path) { + return ( + typeof path === "string" && + SAFE_PATH_RGX.test(path) && + !path.startsWith("/") && + !path.split(/[/#]/).includes("..") + ); +} + +/** + * Cleans a page from a pack before it's shown: no scripts, event handlers, + * `javascript:` links, forms or styles that could restyle the app around it. + * Pages of DevDocs' own docs are cleaned when they're scraped, so only pack + * pages go through this. + * + * @param {string} html + * @returns {string} + */ +export function cleanPage(html) { + return DOMPurify.sanitize(html, { + FORBID_TAGS: ["style", "form", "input", "button", "select", "textarea"], + FORBID_ATTR: ["style"], + }); +} + +/** + * @param {unknown} value + * @param {number} [maxLength] + * @returns {string} + */ +function cleanText(value, maxLength = 100) { + if (typeof value !== "string" && typeof value !== "number") { + return ""; + } + return String(value).replace(MARKUP_RGX, "").trim().slice(0, maxLength); +} + +/** @param {unknown} value */ +function stripTags(value) { + return typeof value === "string" ? value.replace(/<[^>]*>/g, "") : value; +} + +/** + * @param {unknown} value + * @returns {Record | undefined} The `home` and `code` links + * that are http(s) URLs. + */ +function cleanLinks(value) { + if (!isObject(value)) { + return undefined; + } + const links = {}; + for (const key of ["home", "code"]) { + const link = value[key]; + if (typeof link !== "string") { + continue; + } + try { + const url = new URL(link); + if (url.protocol === "https:" || url.protocol === "http:") { + // Serializing percent-encodes quotes and angle brackets. + links[key] = url.href; + } + } catch (error) {} + } + return Object.keys(links).length ? links : undefined; +} + +/** @param {unknown} value */ +function nonNegative(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0; +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isObject(value) { + return typeof value === "object" && value !== null; +} diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index b6579d731d..924b61a3bf 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -5,6 +5,7 @@ import { config } from "../app/config.js"; import { Entries } from "../collections/entries.js"; import { Types } from "../collections/types.js"; import { ajax } from "../lib/ajax.js"; +import { cleanIndex } from "../lib/doc_packs.js"; import { Entry } from "./entry.js"; import { Model } from "./model.js"; @@ -42,7 +43,9 @@ export class Doc extends Model { this.reset(this); this.slug_without_version = this.slug.split("~")[0]; this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : ""); - this.icon = this.slug_without_version; + // Pack docs share one plain icon (see _icons.scss.erb): the sprite only + // holds DevDocs' own docs. Built-in slugs never contain a hyphen. + this.icon = this.pack ? "doc-pack" : this.slug_without_version; if (this.version) { this.short_version = this.version.split(" ")[0]; } @@ -56,6 +59,9 @@ export class Doc extends Model { * data, or the doc itself when its attributes carry the index. */ reset(data) { + if (this.pack) { + data = cleanIndex(data); + } this.resetEntries(data.entries); this.resetTypes(data.types); } @@ -92,24 +98,31 @@ export class Doc extends Model { return `/${this.slug}${path}`; } + /** + * @returns {string} The folder the doc's files are served from, ending in a + * slash: DevDocs' own docs server, or the doc's pack. + */ + baseUrl() { + return this.origin || `${config.docs_origin}/${this.slug}/`; + } + /** * @param {string} [path] * @returns {string} Where the page's HTML is served from. */ fileUrl(path) { - return `${config.docs_origin}${this.fullPath(path)}?${this.mtime}`; + const relativePath = this.fullPath(path).slice(this.slug.length + 2); + return `${this.baseUrl()}${relativePath}?${this.mtime}`; } /** @returns {string} Where the doc's offline database is served from. */ dbUrl() { - return `${config.docs_origin}/${this.slug}/${config.db_filename}?${this.mtime}`; + return `${this.baseUrl()}${config.db_filename}?${this.mtime}`; } /** @returns {string} Where the doc's entry index is served from. */ indexUrl() { - return `${config.docs_origin}/${this.slug}/${ - config.index_filename - }?${this.mtime}`; + return `${this.baseUrl()}${config.index_filename}?${this.mtime}`; } /** diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index e9cbdd3bd5..920d1c2655 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -18,6 +18,20 @@ const themeOption = ({ label, value }, settings) => `\ \ `; +/** + * One added doc pack, with the button that removes it. + * + * @param {{ url: string, name: string, count: number }} pack + * @returns {string} + */ +const docPack = ({ url, name, count }) => `\ +
+ ${$.escape(name)} + + ${count} ${count === 1 ? "doc" : "docs"} · ${$.escape(url)} +
\ +`; + /** * @param {Record} settings The user's current preferences. * @returns {string} @@ -113,6 +127,24 @@ export const settingsPage = (settings) => `\ +
+

Doc packs:

+ +
+ ${/** @type {{ url: string, name: string, count: number }[]} */ ( + settings.docPacks + ) + .map(docPack) + .join("")} +
+ + + + Documentation shared by anyone as a Git repository or a website. Its docs then appear in the list on the left, ready to enable. +
+
+
+

Scrolling:

diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js index 562ca90a63..11b602a630 100644 --- a/assets/javascripts/templates/pages/type_tmpl.js +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -12,7 +12,7 @@ import { render } from "../base.js"; * @returns {string} */ export const typePage = (type) => { - return `

${type.doc.fullName} / ${type.name}

+ return `

${type.doc.fullName} / ${$.escape(type.name)}

    ${render( "typePageEntry", type.entries(), diff --git a/assets/javascripts/vendor/purify.js b/assets/javascripts/vendor/purify.js new file mode 100644 index 0000000000..ce59cba5b4 --- /dev/null +++ b/assets/javascripts/vendor/purify.js @@ -0,0 +1,2543 @@ +/*! @license DOMPurify 3.4.17 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.17/LICENSE */ +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define([], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, global.DOMPurify = factory()); +})(this, function() { + "use strict"; + function _OverloadYield(e, d) { + this.v = e, this.k = d; + } + function _arrayLikeToArray(r, a) { + (null == a || a > r.length) && (a = r.length); + for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; + return n; + } + function _arrayWithHoles(r) { + if (Array.isArray(r)) return r; + } + function _iterableToArrayLimit(r, l) { + var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; + if (null != t) { + var e, n, i, u, a = [], f = !0, o = !1; + try { + if (i = (t = t.call(r)).next, 0 === l) { + if (Object(t) !== t) return; + f = !1; + } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); + } catch (r) { + o = !0, n = r; + } finally { + try { + if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; + } finally { + if (o) throw n; + } + } + return a; + } + } + function _nonIterableRest() { + throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ + function _slicedToArray(r, e) { + return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); + } + function _unsupportedIterableToArray(r, a) { + if (r) { + if ("string" == typeof r) return _arrayLikeToArray(r, a); + var t = {}.toString.call(r).slice(8, -1); + return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; + } + } + function AsyncGenerator(e) { + var t, n; + function resume(t, n) { + try { + var r = e[t](n), o = r.value, u = o instanceof _OverloadYield; + Promise.resolve(u ? o.v : o).then(function(n) { + if (u) { + var i = "return" === t && o.k ? t : "next"; + if (!o.k || n.done) return resume(i, n); + n = e[i](n).value; + } + settle(!!r.done, n); + }, function(e) { + resume("throw", e); + }); + } catch (e) { + settle(2, e); + } + } + function settle(e, r) { + 2 === e ? t.reject(r) : t.resolve({ + value: r, + done: e + }), (t = t.next) ? resume(t.key, t.arg) : n = null; + } + this._invoke = function(e, r) { + return new Promise(function(o, u) { + var i = { + key: e, + arg: r, + resolve: o, + reject: u, + next: null + }; + n ? n = n.next = i : (t = n = i, resume(e, r)); + }); + }, "function" != typeof e.return && (this.return = void 0); + } + AsyncGenerator.prototype["function" == typeof Symbol && Symbol.asyncIterator || "@@asyncIterator"] = function() { + return this; + }, AsyncGenerator.prototype.next = function(e) { + return this._invoke("next", e); + }, AsyncGenerator.prototype.throw = function(e) { + return this._invoke("throw", e); + }, AsyncGenerator.prototype.return = function(e) { + return this._invoke("return", e); + }; + const entries = Object.entries; + const setPrototypeOf = Object.setPrototypeOf; + const isFrozen = Object.isFrozen; + const getPrototypeOf = Object.getPrototypeOf; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + let freeze = Object.freeze; + let seal = Object.seal; + let create = Object.create; + let _ref = typeof Reflect !== "undefined" && Reflect; + let apply = _ref.apply; + let construct = _ref.construct; + if (!freeze) freeze = function freeze(x) { + return x; + }; + if (!seal) seal = function seal(x) { + return x; + }; + if (!apply) apply = function apply(func, thisArg) { + for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) args[_key - 2] = arguments[_key]; + return func.apply(thisArg, args); + }; + if (!construct) construct = function construct(Func) { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) args[_key2 - 1] = arguments[_key2]; + return new Func(...args); + }; + const arrayForEach = unapply(Array.prototype.forEach); + Array.prototype.indexOf; + const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); + const arrayPop = unapply(Array.prototype.pop); + const arrayPush = unapply(Array.prototype.push); + const arraySlice = unapply(Array.prototype.slice); + const arraySplice = unapply(Array.prototype.splice); + const arrayIsArray = Array.isArray; + const setCreate = unconstruct(Set); + const setAdd = unapply(Set.prototype.add); + const setHas = unapply(Set.prototype.has); + const stringToLowerCase = unapply(String.prototype.toLowerCase); + const stringToString = unapply(String.prototype.toString); + const stringMatch = unapply(String.prototype.match); + const stringReplace = unapply(String.prototype.replace); + const stringIndexOf = unapply(String.prototype.indexOf); + const stringTrim = unapply(String.prototype.trim); + const numberToString = unapply(Number.prototype.toString); + const booleanToString = unapply(Boolean.prototype.toString); + const bigintToString = typeof BigInt === "undefined" ? null : unapply(BigInt.prototype.toString); + const symbolToString = typeof Symbol === "undefined" ? null : unapply(Symbol.prototype.toString); + const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); + const objectToString = unapply(Object.prototype.toString); + const regExpTest = unapply(RegExp.prototype.test); + const typeErrorCreate = unconstruct(TypeError); + /** + * Creates a new function that calls the given function with a specified thisArg and arguments. + * + * @param func - The function to be wrapped and called. + * @returns A new function that calls the given function with a specified thisArg and arguments. + */ + function unapply(func) { + return function(thisArg) { + if (thisArg instanceof RegExp) thisArg.lastIndex = 0; + for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) args[_key3 - 1] = arguments[_key3]; + return apply(func, thisArg, args); + }; + } + /** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ + function unconstruct(Func) { + return function() { + for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) args[_key4] = arguments[_key4]; + return construct(Func, args); + }; + } + /** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ + function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) setPrototypeOf(set, null); + if (!arrayIsArray(array)) return set; + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === "string") { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + if (!isFrozen(array)) array[l] = lcElement; + element = lcElement; + } + } + set[element] = true; + } + return set; + } + /** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ + function cleanArray(array) { + for (let index = 0; index < array.length; index++) if (!objectHasOwnProperty(array, index)) array[index] = null; + return array; + } + /** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ + function clone(object) { + const newObject = create(null); + for (const _ref2 of entries(object)) { + var _ref3 = _slicedToArray(_ref2, 2); + const property = _ref3[0]; + const value = _ref3[1]; + if (objectHasOwnProperty(object, property)) { + if (arrayIsArray(value)) newObject[property] = cleanArray(value); + else if (value && typeof value === "object" && value.constructor === Object) newObject[property] = clone(value); + else newObject[property] = value; + } + } + return newObject; + } + /** + * Convert non-node values into strings without depending on direct property access. + * + * @param value - The value to stringify. + * @returns A string representation of the provided value. + */ + function stringifyValue(value) { + switch (typeof value) { + case "string": return value; + case "number": return numberToString(value); + case "boolean": return booleanToString(value); + case "bigint": return bigintToString ? bigintToString(value) : "0"; + case "symbol": return symbolToString ? symbolToString(value) : "Symbol()"; + case "undefined": return objectToString(value); + case "function": + case "object": { + if (value === null) return objectToString(value); + const valueAsRecord = value; + const valueToString = lookupGetter(valueAsRecord, "toString"); + if (typeof valueToString === "function") { + const stringified = valueToString(valueAsRecord); + return typeof stringified === "string" ? stringified : objectToString(stringified); + } + return objectToString(value); + } + default: return objectToString(value); + } + } + /** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ + function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) return unapply(desc.get); + if (typeof desc.value === "function") return unapply(desc.value); + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; + } + /** + * Like lookupGetter, but for accessor properties only, and returns null + * instead of a null-returning fallback when no getter exists on the chain. + * Lets a caller fall back to a direct property read in environments that + * lack the getter, where a fallback returning null would be indistinguishable + * from a legitimate null value (e.g. namespaceURI of a no-namespace element). + * + * @param object - The object (usually a prototype) to start the lookup at. + * @param prop - The accessor property name. + * @returns The unapplied getter, or null if none was found. + */ + function lookupAccessor(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) return desc.get ? unapply(desc.get) : null; + object = getPrototypeOf(object); + } + return null; + } + function isRegex(value) { + try { + regExpTest(value, ""); + return true; + } catch (_unused) { + return false; + } + } + const html$1 = freeze([ + "a", + "abbr", + "acronym", + "address", + "area", + "article", + "aside", + "audio", + "b", + "bdi", + "bdo", + "big", + "blink", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "center", + "cite", + "code", + "col", + "colgroup", + "content", + "data", + "datalist", + "dd", + "decorator", + "del", + "details", + "dfn", + "dialog", + "dir", + "div", + "dl", + "dt", + "element", + "em", + "fieldset", + "figcaption", + "figure", + "font", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "img", + "input", + "ins", + "kbd", + "label", + "legend", + "li", + "main", + "map", + "mark", + "marquee", + "menu", + "menuitem", + "meter", + "nav", + "nobr", + "ol", + "optgroup", + "option", + "output", + "p", + "picture", + "pre", + "progress", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "search", + "section", + "select", + "shadow", + "slot", + "small", + "source", + "spacer", + "span", + "strike", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "template", + "textarea", + "tfoot", + "th", + "thead", + "time", + "tr", + "track", + "tt", + "u", + "ul", + "var", + "video", + "wbr" + ]); + const svg$1 = freeze([ + "svg", + "a", + "altglyph", + "altglyphdef", + "altglyphitem", + "animatecolor", + "animatemotion", + "animatetransform", + "circle", + "clippath", + "defs", + "desc", + "ellipse", + "enterkeyhint", + "exportparts", + "filter", + "font", + "g", + "glyph", + "glyphref", + "hkern", + "image", + "inputmode", + "line", + "lineargradient", + "marker", + "mask", + "metadata", + "mpath", + "part", + "path", + "pattern", + "polygon", + "polyline", + "radialgradient", + "rect", + "stop", + "style", + "switch", + "symbol", + "text", + "textpath", + "title", + "tref", + "tspan", + "view", + "vkern" + ]); + const svgFilters = freeze([ + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence" + ]); + const svgDisallowed = freeze([ + "animate", + "color-profile", + "cursor", + "discard", + "font-face", + "font-face-format", + "font-face-name", + "font-face-src", + "font-face-uri", + "foreignobject", + "hatch", + "hatchpath", + "mesh", + "meshgradient", + "meshpatch", + "meshrow", + "missing-glyph", + "script", + "set", + "solidcolor", + "unknown", + "use" + ]); + const mathMl$1 = freeze([ + "math", + "menclose", + "merror", + "mfenced", + "mfrac", + "mglyph", + "mi", + "mlabeledtr", + "mmultiscripts", + "mn", + "mo", + "mover", + "mpadded", + "mphantom", + "mroot", + "mrow", + "ms", + "mspace", + "msqrt", + "mstyle", + "msub", + "msup", + "msubsup", + "mtable", + "mtd", + "mtext", + "mtr", + "munder", + "munderover", + "mprescripts" + ]); + const mathMlDisallowed = freeze([ + "maction", + "maligngroup", + "malignmark", + "mlongdiv", + "mscarries", + "mscarry", + "msgroup", + "mstack", + "msline", + "msrow", + "semantics", + "annotation", + "annotation-xml", + "mprescripts", + "none" + ]); + const text = freeze(["#text"]); + const html = freeze([ + "accept", + "action", + "align", + "alt", + "autocapitalize", + "autocomplete", + "autopictureinpicture", + "autoplay", + "background", + "bgcolor", + "border", + "capture", + "cellpadding", + "cellspacing", + "checked", + "cite", + "class", + "clear", + "color", + "cols", + "colspan", + "command", + "commandfor", + "controls", + "controlslist", + "coords", + "crossorigin", + "datetime", + "decoding", + "default", + "dir", + "disabled", + "disablepictureinpicture", + "disableremoteplayback", + "download", + "draggable", + "enctype", + "enterkeyhint", + "exportparts", + "face", + "for", + "headers", + "height", + "hidden", + "high", + "href", + "hreflang", + "id", + "inert", + "inputmode", + "integrity", + "ismap", + "kind", + "label", + "lang", + "list", + "loading", + "loop", + "low", + "max", + "maxlength", + "media", + "method", + "min", + "minlength", + "multiple", + "muted", + "name", + "nonce", + "noshade", + "novalidate", + "nowrap", + "open", + "optimum", + "part", + "pattern", + "placeholder", + "playsinline", + "popover", + "popovertarget", + "popovertargetaction", + "poster", + "preload", + "pubdate", + "radiogroup", + "readonly", + "rel", + "required", + "rev", + "reversed", + "role", + "rows", + "rowspan", + "spellcheck", + "scope", + "selected", + "shape", + "size", + "sizes", + "slot", + "span", + "srclang", + "start", + "src", + "srcset", + "step", + "style", + "summary", + "tabindex", + "title", + "translate", + "type", + "usemap", + "valign", + "value", + "width", + "wrap", + "xmlns" + ]); + const svg = freeze([ + "accent-height", + "accumulate", + "additive", + "alignment-baseline", + "amplitude", + "ascent", + "attributename", + "attributetype", + "azimuth", + "basefrequency", + "baseline-shift", + "begin", + "bias", + "by", + "class", + "clip", + "clippathunits", + "clip-path", + "clip-rule", + "color", + "color-interpolation", + "color-interpolation-filters", + "color-profile", + "color-rendering", + "cx", + "cy", + "d", + "dx", + "dy", + "diffuseconstant", + "direction", + "display", + "divisor", + "dominant-baseline", + "dur", + "edgemode", + "elevation", + "end", + "exponent", + "fill", + "fill-opacity", + "fill-rule", + "filter", + "filterunits", + "flood-color", + "flood-opacity", + "font-family", + "font-size", + "font-size-adjust", + "font-stretch", + "font-style", + "font-variant", + "font-weight", + "fx", + "fy", + "g1", + "g2", + "glyph-name", + "glyphref", + "gradientunits", + "gradienttransform", + "height", + "href", + "id", + "image-rendering", + "in", + "in2", + "intercept", + "k", + "k1", + "k2", + "k3", + "k4", + "kerning", + "keypoints", + "keysplines", + "keytimes", + "lang", + "lengthadjust", + "letter-spacing", + "kernelmatrix", + "kernelunitlength", + "lighting-color", + "local", + "marker-end", + "marker-mid", + "marker-start", + "markerheight", + "markerunits", + "markerwidth", + "maskcontentunits", + "maskunits", + "max", + "mask", + "mask-type", + "media", + "method", + "mode", + "min", + "name", + "numoctaves", + "offset", + "operator", + "opacity", + "order", + "orient", + "orientation", + "origin", + "overflow", + "paint-order", + "path", + "pathlength", + "patterncontentunits", + "patterntransform", + "patternunits", + "pointer-events", + "points", + "preservealpha", + "preserveaspectratio", + "primitiveunits", + "r", + "rx", + "ry", + "radius", + "refx", + "refy", + "repeatcount", + "repeatdur", + "restart", + "result", + "rotate", + "scale", + "seed", + "shape-rendering", + "slope", + "specularconstant", + "specularexponent", + "spreadmethod", + "startoffset", + "stddeviation", + "stitchtiles", + "stop-color", + "stop-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke", + "stroke-width", + "style", + "surfacescale", + "systemlanguage", + "tabindex", + "tablevalues", + "targetx", + "targety", + "transform", + "transform-origin", + "text-anchor", + "text-decoration", + "text-orientation", + "text-rendering", + "textlength", + "type", + "u1", + "u2", + "unicode", + "values", + "vector-effect", + "viewbox", + "visibility", + "version", + "vert-adv-y", + "vert-origin-x", + "vert-origin-y", + "width", + "word-spacing", + "wrap", + "writing-mode", + "xchannelselector", + "ychannelselector", + "x", + "x1", + "x2", + "xmlns", + "y", + "y1", + "y2", + "z", + "zoomandpan" + ]); + const mathMl = freeze([ + "accent", + "accentunder", + "align", + "bevelled", + "close", + "columnalign", + "columnlines", + "columnspacing", + "columnspan", + "denomalign", + "depth", + "dir", + "display", + "displaystyle", + "encoding", + "fence", + "frame", + "height", + "href", + "id", + "largeop", + "length", + "linethickness", + "lquote", + "lspace", + "mathbackground", + "mathcolor", + "mathsize", + "mathvariant", + "maxsize", + "minsize", + "movablelimits", + "notation", + "numalign", + "open", + "rowalign", + "rowlines", + "rowspacing", + "rowspan", + "rspace", + "rquote", + "scriptlevel", + "scriptminsize", + "scriptsizemultiplier", + "selection", + "separator", + "separators", + "stretchy", + "subscriptshift", + "supscriptshift", + "symmetric", + "voffset", + "width", + "xmlns" + ]); + const xml = freeze([ + "xlink:href", + "xml:id", + "xlink:title", + "xml:space", + "xmlns:xlink" + ]); + const MUSTACHE_EXPR = seal(/{{[\w\W]*|^[\w\W]*}}/g); + const ERB_EXPR = seal(/<%[\w\W]*|^[\w\W]*%>/g); + const TMPLIT_EXPR = seal(/\${[\w\W]*/g); + const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); + const ARIA_ATTR = seal(/^aria-[\-\w]+$/); + const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i); + const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); + const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g); + const DOCTYPE_NAME = seal(/^html$/i); + const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); + const ELEMENT_MARKUP_PROBE = seal(/<[/\w!]/g); + const COMMENT_MARKUP_PROBE = seal(/<[/\w]/g); + const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i); + const SELF_CLOSING_TAG = seal(/\/>/i); + const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + entityNode: 6, + processingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 + }; + const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, [ + "style", + "script", + "xmp", + "iframe", + "noembed", + "noframes", + "plaintext", + "noscript" + ])); + const getGlobal = function getGlobal() { + return typeof window === "undefined" ? null : window; + }; + /** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param trustedTypes The policy factory. + * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). + * @return The policy created (or null, if Trusted Types + * are not supported or creating the policy failed). + */ + const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + if (typeof trustedTypes !== "object" || typeof trustedTypes.createPolicy !== "function") return null; + let suffix = null; + const ATTR_NAME = "data-tt-policy-suffix"; + if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) suffix = purifyHostElement.getAttribute(ATTR_NAME); + const policyName = "dompurify" + (suffix ? "#" + suffix : ""); + try { + return trustedTypes.createPolicy(policyName, { + createHTML(html) { + return html; + }, + createScriptURL(scriptUrl) { + return scriptUrl; + } + }); + } catch (_) { + console.warn("TrustedTypes policy " + policyName + " could not be created."); + return null; + } + }; + const _createHooksMap = function _createHooksMap() { + return { + afterSanitizeAttributes: [], + afterSanitizeElements: [], + afterSanitizeShadowDOM: [], + beforeSanitizeAttributes: [], + beforeSanitizeElements: [], + beforeSanitizeShadowDOM: [], + uponSanitizeAttribute: [], + uponSanitizeElement: [], + uponSanitizeShadowNode: [] + }; + }; + /** + * Resolve a set-valued configuration option: a fresh set built from + * cfg[key] when it is an own array property (seeded with a clone of + * options.base when given, case-normalized via options.transform), + * the fallback set otherwise. + * + * @param cfg the cloned, prototype-free configuration object + * @param key the configuration property to read + * @param fallback the set to use when the option is absent or not an array + * @param options transform and optional base set to merge into + * @returns the resolved set + */ + const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options) { + return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key]) ? addToSet(options.base ? clone(options.base) : {}, cfg[key], options.transform) : fallback; + }; + /** + * Resolve an object-valued configuration option: a prototype-free clone + * of cfg[key] when it is an own, truthy object property, else a fresh + * fallback built by makeFallback (fresh on every parse, so a previous + * parse can never leak state into the next one). + * + * @param cfg the cloned, prototype-free configuration object + * @param key the configuration property to read + * @param makeFallback builds the fallback value when the option is absent + * @returns the resolved object + */ + const _resolveObjectOption = function _resolveObjectOption(cfg, key, makeFallback) { + const value = objectHasOwnProperty(cfg, key) ? cfg[key] : void 0; + return value && typeof value === "object" ? clone(value) : makeFallback(); + }; + function createDOMPurify() { + let window = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : getGlobal(); + const DOMPurify = (root) => createDOMPurify(root); + DOMPurify.version = "3.4.17"; + DOMPurify.removed = []; + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { + DOMPurify.isSupported = false; + return DOMPurify; + } + let document = window.document; + const originalDocument = document; + const currentScript = originalDocument.currentScript; + window.DocumentFragment; + const HTMLTemplateElement = window.HTMLTemplateElement, Node = window.Node, Element = window.Element, NodeFilter = window.NodeFilter; + window.NamedNodeMap === void 0 && (window.NamedNodeMap || window.MozNamedAttrMap); + window.HTMLFormElement; + const DOMParser = window.DOMParser, trustedTypes = window.trustedTypes; + const ElementPrototype = Element.prototype; + const cloneNode = lookupGetter(ElementPrototype, "cloneNode"); + const remove = lookupGetter(ElementPrototype, "remove"); + const removeAttributeNode = lookupGetter(ElementPrototype, "removeAttributeNode"); + const getNextSibling = lookupGetter(ElementPrototype, "nextSibling"); + const getChildNodes = lookupGetter(ElementPrototype, "childNodes"); + const getParentNode = lookupGetter(ElementPrototype, "parentNode"); + const getShadowRoot = lookupGetter(ElementPrototype, "shadowRoot"); + const getAttributes = lookupGetter(ElementPrototype, "attributes"); + const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, "nodeType") : null; + const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, "nodeName") : null; + const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, "ownerDocument") : null; + const _readNodeType = function _readNodeType(node) { + return getNodeType ? getNodeType(node) : node.nodeType; + }; + const _readNodeName = function _readNodeName(node) { + return getNodeName ? getNodeName(node) : node.nodeName; + }; + const getNamespaceURI = lookupAccessor(ElementPrototype, "namespaceURI"); + const _readNamespaceURI = function _readNamespaceURI(element) { + return getNamespaceURI ? getNamespaceURI(element) : element.namespaceURI; + }; + if (typeof HTMLTemplateElement === "function") { + const template = document.createElement("template"); + if (template.content && template.content.ownerDocument) document = template.content.ownerDocument; + } + let trustedTypesPolicy; + let emptyHTML = ""; + let defaultTrustedTypesPolicy; + let defaultTrustedTypesPolicyResolved = false; + let IN_TRUSTED_TYPES_POLICY = 0; + const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() { + if (IN_TRUSTED_TYPES_POLICY > 0) throw typeErrorCreate("A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the \"DOMPurify and Trusted Types\" section of the README."); + }; + const _createTrustedHTML = function _createTrustedHTML(html) { + _assertNotInTrustedTypesPolicy(); + IN_TRUSTED_TYPES_POLICY++; + try { + return trustedTypesPolicy.createHTML(html); + } finally { + IN_TRUSTED_TYPES_POLICY--; + } + }; + const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) { + _assertNotInTrustedTypesPolicy(); + IN_TRUSTED_TYPES_POLICY++; + try { + return trustedTypesPolicy.createScriptURL(scriptUrl); + } finally { + IN_TRUSTED_TYPES_POLICY--; + } + }; + const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() { + if (!defaultTrustedTypesPolicyResolved) { + defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + defaultTrustedTypesPolicyResolved = true; + } + return defaultTrustedTypesPolicy; + }; + const _document = document, implementation = _document.implementation, createNodeIterator = _document.createNodeIterator, createDocumentFragment = _document.createDocumentFragment, getElementsByTagName = _document.getElementsByTagName; + const importNode = originalDocument.importNode; + let hooks = _createHooksMap(); + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = typeof entries === "function" && typeof getParentNode === "function" && implementation && implementation.createHTMLDocument !== void 0; + const MUSTACHE_EXPR$1 = MUSTACHE_EXPR, ERB_EXPR$1 = ERB_EXPR, TMPLIT_EXPR$1 = TMPLIT_EXPR, DATA_ATTR$1 = DATA_ATTR, ARIA_ATTR$1 = ARIA_ATTR, IS_SCRIPT_OR_DATA$1 = IS_SCRIPT_OR_DATA, ATTR_WHITESPACE$1 = ATTR_WHITESPACE, CUSTOM_ELEMENT$1 = CUSTOM_ELEMENT; + let IS_ALLOWED_URI$1 = IS_ALLOWED_URI; + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + let ALLOWED_TAGS = null; + const DEFAULT_ALLOWED_TAGS = addToSet({}, [ + ...html$1, + ...svg$1, + ...svgFilters, + ...mathMl$1, + ...text + ]); + let ALLOWED_ATTR = null; + const DEFAULT_ALLOWED_ATTR = addToSet({}, [ + ...html, + ...svg, + ...mathMl, + ...xml + ]); + let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { + tagNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + allowCustomizedBuiltInElements: { + writable: true, + configurable: false, + enumerable: true, + value: false + } + })); + let FORBID_TAGS = null; + let FORBID_ATTR = null; + const EXTRA_ELEMENT_HANDLING = Object.seal(create(null, { + tagCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + } + })); + let ALLOW_ARIA_ATTR = true; + let ALLOW_DATA_ATTR = true; + let ALLOW_UNKNOWN_PROTOCOLS = false; + let ALLOW_SELF_CLOSE_IN_ATTR = true; + let SAFE_FOR_TEMPLATES = false; + let SAFE_FOR_XML = true; + let WHOLE_DOCUMENT = false; + let SET_CONFIG = false; + let SET_CONFIG_ALLOWED_TAGS = null; + let SET_CONFIG_ALLOWED_ATTR = null; + let FORCE_BODY = false; + let RETURN_DOM = false; + let RETURN_DOM_FRAGMENT = false; + let RETURN_TRUSTED_TYPE = false; + let SANITIZE_DOM = true; + let SANITIZE_NAMED_PROPS = false; + const SANITIZE_NAMED_PROPS_PREFIX = "user-content-"; + let KEEP_CONTENT = true; + let IN_PLACE = false; + let IN_PLACE_VISITED = null; + let USE_PROFILES = {}; + let FORBID_CONTENTS = null; + const DEFAULT_FORBID_CONTENTS = addToSet({}, [ + "annotation-xml", + "audio", + "colgroup", + "desc", + "foreignobject", + "head", + "iframe", + "math", + "mi", + "mn", + "mo", + "ms", + "mtext", + "noembed", + "noframes", + "noscript", + "plaintext", + "script", + "selectedcontent", + "style", + "svg", + "template", + "thead", + "title", + "video", + "xmp" + ]); + let DATA_URI_TAGS = null; + const DEFAULT_DATA_URI_TAGS = addToSet({}, [ + "audio", + "video", + "img", + "source", + "image", + "track" + ]); + let URI_SAFE_ATTRIBUTES = null; + const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, [ + "alt", + "class", + "for", + "id", + "label", + "name", + "pattern", + "placeholder", + "role", + "summary", + "title", + "value", + "style", + "xmlns" + ]); + const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML"; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + let NAMESPACE = HTML_NAMESPACE; + let IS_EMPTY_INPUT = false; + let ALLOWED_NAMESPACES = null; + const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [ + MATHML_NAMESPACE, + SVG_NAMESPACE, + HTML_NAMESPACE + ], stringToString); + const DEFAULT_MATHML_TEXT_INTEGRATION_POINTS = freeze([ + "mi", + "mo", + "mn", + "ms", + "mtext" + ]); + let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); + const DEFAULT_HTML_INTEGRATION_POINTS = freeze(["annotation-xml"]); + let HTML_INTEGRATION_POINTS = addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); + const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, [ + "title", + "style", + "font", + "a", + "script" + ]); + let PARSER_MEDIA_TYPE = null; + const SUPPORTED_PARSER_MEDIA_TYPES = ["application/xhtml+xml", "text/html"]; + const DEFAULT_PARSER_MEDIA_TYPE = "text/html"; + let transformCaseFunc = null; + let CONFIG = null; + const formElement = document.createElement("form"); + const isRegexOrFunction = function isRegexOrFunction(testValue) { + return testValue instanceof RegExp || testValue instanceof Function; + }; + /** + * _parseConfig + * + * @param cfg optional config literal + */ + const _parseConfig = function _parseConfig() { + let cfg = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : {}; + if (CONFIG && CONFIG === cfg) return; + if (!cfg || typeof cfg !== "object") cfg = {}; + cfg = clone(cfg); + PARSER_MEDIA_TYPE = SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; + transformCaseFunc = PARSER_MEDIA_TYPE === "application/xhtml+xml" ? stringToString : stringToLowerCase; + ALLOWED_TAGS = _resolveSetOption(cfg, "ALLOWED_TAGS", DEFAULT_ALLOWED_TAGS, { transform: transformCaseFunc }); + ALLOWED_ATTR = _resolveSetOption(cfg, "ALLOWED_ATTR", DEFAULT_ALLOWED_ATTR, { transform: transformCaseFunc }); + ALLOWED_NAMESPACES = _resolveSetOption(cfg, "ALLOWED_NAMESPACES", DEFAULT_ALLOWED_NAMESPACES, { transform: stringToString }); + URI_SAFE_ATTRIBUTES = _resolveSetOption(cfg, "ADD_URI_SAFE_ATTR", DEFAULT_URI_SAFE_ATTRIBUTES, { + transform: transformCaseFunc, + base: DEFAULT_URI_SAFE_ATTRIBUTES + }); + DATA_URI_TAGS = _resolveSetOption(cfg, "ADD_DATA_URI_TAGS", DEFAULT_DATA_URI_TAGS, { + transform: transformCaseFunc, + base: DEFAULT_DATA_URI_TAGS + }); + FORBID_CONTENTS = _resolveSetOption(cfg, "FORBID_CONTENTS", DEFAULT_FORBID_CONTENTS, { transform: transformCaseFunc }); + FORBID_TAGS = _resolveSetOption(cfg, "FORBID_TAGS", clone({}), { transform: transformCaseFunc }); + FORBID_ATTR = _resolveSetOption(cfg, "FORBID_ATTR", clone({}), { transform: transformCaseFunc }); + USE_PROFILES = objectHasOwnProperty(cfg, "USE_PROFILES") ? cfg.USE_PROFILES && typeof cfg.USE_PROFILES === "object" ? clone(cfg.USE_PROFILES) : cfg.USE_PROFILES : false; + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; + ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; + SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; + RETURN_DOM = cfg.RETURN_DOM || false; + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; + FORCE_BODY = cfg.FORCE_BODY || false; + SANITIZE_DOM = cfg.SANITIZE_DOM !== false; + SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; + KEEP_CONTENT = cfg.KEEP_CONTENT !== false; + IN_PLACE = cfg.IN_PLACE || false; + IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; + NAMESPACE = typeof cfg.NAMESPACE === "string" ? cfg.NAMESPACE : HTML_NAMESPACE; + MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(cfg, "MATHML_TEXT_INTEGRATION_POINTS", () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS)); + HTML_INTEGRATION_POINTS = _resolveObjectOption(cfg, "HTML_INTEGRATION_POINTS", () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS)); + const customElementHandling = _resolveObjectOption(cfg, "CUSTOM_ELEMENT_HANDLING", () => create(null)); + CUSTOM_ELEMENT_HANDLING = create(null); + if (objectHasOwnProperty(customElementHandling, "tagNameCheck") && isRegexOrFunction(customElementHandling.tagNameCheck)) CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; + if (objectHasOwnProperty(customElementHandling, "attributeNameCheck") && isRegexOrFunction(customElementHandling.attributeNameCheck)) CUSTOM_ELEMENT_HANDLING.attributeNameCheck = customElementHandling.attributeNameCheck; + if (objectHasOwnProperty(customElementHandling, "allowCustomizedBuiltInElements") && typeof customElementHandling.allowCustomizedBuiltInElements === "boolean") CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = customElementHandling.allowCustomizedBuiltInElements; + seal(CUSTOM_ELEMENT_HANDLING); + if (SAFE_FOR_TEMPLATES) ALLOW_DATA_ATTR = false; + if (RETURN_DOM_FRAGMENT) RETURN_DOM = true; + if (USE_PROFILES) { + ALLOWED_TAGS = addToSet({}, text); + ALLOWED_ATTR = create(null); + if (USE_PROFILES.html === true) { + addToSet(ALLOWED_TAGS, html$1); + addToSet(ALLOWED_ATTR, html); + } + if (USE_PROFILES.svg === true) { + addToSet(ALLOWED_TAGS, svg$1); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.svgFilters === true) { + addToSet(ALLOWED_TAGS, svgFilters); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.mathMl === true) { + addToSet(ALLOWED_TAGS, mathMl$1); + addToSet(ALLOWED_ATTR, mathMl); + addToSet(ALLOWED_ATTR, xml); + } + } + EXTRA_ELEMENT_HANDLING.tagCheck = null; + EXTRA_ELEMENT_HANDLING.attributeCheck = null; + if (objectHasOwnProperty(cfg, "ADD_TAGS")) { + if (typeof cfg.ADD_TAGS === "function") EXTRA_ELEMENT_HANDLING.tagCheck = cfg.ADD_TAGS; + else if (arrayIsArray(cfg.ADD_TAGS)) { + if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) ALLOWED_TAGS = clone(ALLOWED_TAGS); + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); + } + } + if (objectHasOwnProperty(cfg, "ADD_ATTR")) { + if (typeof cfg.ADD_ATTR === "function") EXTRA_ELEMENT_HANDLING.attributeCheck = cfg.ADD_ATTR; + else if (arrayIsArray(cfg.ADD_ATTR)) { + if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) ALLOWED_ATTR = clone(ALLOWED_ATTR); + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); + } + } + if (objectHasOwnProperty(cfg, "ADD_FORBID_CONTENTS") && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) { + if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) FORBID_CONTENTS = clone(FORBID_CONTENTS); + addToSet(FORBID_CONTENTS, cfg.ADD_FORBID_CONTENTS, transformCaseFunc); + } + if (KEEP_CONTENT) ALLOWED_TAGS["#text"] = true; + if (WHOLE_DOCUMENT) addToSet(ALLOWED_TAGS, [ + "html", + "head", + "body" + ]); + if (ALLOWED_TAGS.table) { + addToSet(ALLOWED_TAGS, ["tbody"]); + delete FORBID_TAGS.tbody; + } + if (cfg.TRUSTED_TYPES_POLICY) { + if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== "function") throw typeErrorCreate("TRUSTED_TYPES_POLICY configuration option must provide a \"createHTML\" hook."); + if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== "function") throw typeErrorCreate("TRUSTED_TYPES_POLICY configuration option must provide a \"createScriptURL\" hook."); + const previousTrustedTypesPolicy = trustedTypesPolicy; + trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; + try { + emptyHTML = _createTrustedHTML(""); + } catch (error) { + trustedTypesPolicy = previousTrustedTypesPolicy; + throw error; + } + } else if (cfg.TRUSTED_TYPES_POLICY === null) { + trustedTypesPolicy = void 0; + emptyHTML = ""; + } else { + if (trustedTypesPolicy === void 0) trustedTypesPolicy = _getDefaultTrustedTypesPolicy(); + if (trustedTypesPolicy && typeof emptyHTML === "string") emptyHTML = _createTrustedHTML(""); + } + if (freeze) freeze(cfg); + CONFIG = cfg; + }; + const ALL_SVG_TAGS = addToSet({}, [ + ...svg$1, + ...svgFilters, + ...svgDisallowed + ]); + const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); + /** + * Namespace rules for an element in the SVG namespace. + * + * @param tagName the element's lowercase tag name + * @param parent the (possibly simulated) parent node + * @param parentTagName the parent's lowercase tag name + * @returns true if a spec-compliant parser could produce this element + */ + const _checkSvgNamespace = function _checkSvgNamespace(tagName, parent, parentTagName) { + if (parent.namespaceURI === HTML_NAMESPACE) return tagName === "svg"; + if (parent.namespaceURI === MATHML_NAMESPACE) return tagName === "svg" && (parentTagName === "annotation-xml" || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + return Boolean(ALL_SVG_TAGS[tagName]); + }; + /** + * Namespace rules for an element in the MathML namespace. + * + * @param tagName the element's lowercase tag name + * @param parent the (possibly simulated) parent node + * @param parentTagName the parent's lowercase tag name + * @returns true if a spec-compliant parser could produce this element + */ + const _checkMathMlNamespace = function _checkMathMlNamespace(tagName, parent, parentTagName) { + if (parent.namespaceURI === HTML_NAMESPACE) return tagName === "math"; + if (parent.namespaceURI === SVG_NAMESPACE) return tagName === "math" && HTML_INTEGRATION_POINTS[parentTagName]; + return Boolean(ALL_MATHML_TAGS[tagName]); + }; + /** + * Namespace rules for an element in the HTML namespace. + * + * @param tagName the element's lowercase tag name + * @param parent the (possibly simulated) parent node + * @param parentTagName the parent's lowercase tag name + * @returns true if a spec-compliant parser could produce this element + */ + const _checkHtmlNamespace = function _checkHtmlNamespace(tagName, parent, parentTagName) { + if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) return false; + if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) return false; + return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); + }; + /** + * @param element a DOM element whose namespace is being checked + * @returns Return false if the element has a + * namespace that a spec-compliant parser would never + * return. Return true otherwise. + */ + const _checkValidNamespace = function _checkValidNamespace(element) { + const elementNamespace = _readNamespaceURI(element); + const tagName = stringToLowerCase(_readNodeName(element)); + const parentNode = getParentNode(element); + let parent; + let parentTagName; + if (parentNode && _readNodeType(parentNode) === NODE_TYPE.element) { + parent = { namespaceURI: _readNamespaceURI(parentNode) }; + parentTagName = stringToLowerCase(_readNodeName(parentNode)); + } else { + parent = { namespaceURI: NAMESPACE }; + parentTagName = "template"; + } + if (!ALLOWED_NAMESPACES[elementNamespace]) return false; + if (elementNamespace === SVG_NAMESPACE) return _checkSvgNamespace(tagName, parent, parentTagName); + if (elementNamespace === MATHML_NAMESPACE) return _checkMathMlNamespace(tagName, parent, parentTagName); + if (elementNamespace === HTML_NAMESPACE) return _checkHtmlNamespace(tagName, parent, parentTagName); + if (PARSER_MEDIA_TYPE === "application/xhtml+xml" && ALLOWED_NAMESPACES[elementNamespace]) return true; + return false; + }; + /** + * _forceRemove + * + * @param node a DOM node + */ + const _forceRemove = function _forceRemove(node) { + arrayPush(DOMPurify.removed, { element: node }); + try { + getParentNode(node).removeChild(node); + } catch (_) { + remove(node); + if (!getParentNode(node)) throw typeErrorCreate("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place"); + } + }; + /** + * _stripAttributeNode + * + * Remove a single Attr node case/namespace-exactly on an attribute-teardown + * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an + * HTML element in an HTML document and so silently misses a case-preserved + * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect + * _removeAttribute() was fixed for, which a name-based call would reintroduce + * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not + * record into DOMPurify.removed: the neutralize passes intentionally do not + * book-keep. A clobbered/detached node falls back to best-effort name-based + * removal. + * + * @param element the element to strip the attribute from + * @param attribute the Attr node to remove + * @param name the attribute's name, for the fallback path + */ + const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) { + try { + removeAttributeNode(element, attribute); + } catch (_) { + try { + element.removeAttribute(name); + } catch (_) {} + } + }; + /** + * Fail-closed teardown of an in-place root: strip every disallowed attribute + * in the subtree first, so nothing armed is left behind, then remove all + * children and the root's own attributes. + * + * @param root the in-place root to empty + */ + const _neutralizeRoot = function _neutralizeRoot(root) { + _neutralizeSubtree(root); + const childNodes = getChildNodes(root); + if (childNodes) arrayForEach(arraySlice(childNodes), (child) => { + try { + remove(child); + } catch (_) {} + }); + const attributes = _readNodeType(root) === NODE_TYPE.element ? getAttributes(root) : null; + if (attributes) for (let i = attributes.length - 1; i >= 0; --i) { + const attribute = attributes[i]; + const name = attribute && attribute.name; + if (typeof name === "string") _stripAttributeNode(root, attribute, name); + } + }; + /** + * Removes an attribute, preferably as its exact Attr node: name-based + * removal lowercases the name in HTML documents and misses case-preserved + * names, e.g. from imported XHTML. + * + * @param name an Attribute name + * @param element a DOM node + * @param attr the exact Attr node to remove, when the caller has it + */ + const _removeAttribute = function _removeAttribute(name, element, attr) { + if (!attr) try { + attr = element.getAttributeNode(name); + } catch (_) { + attr = null; + } + arrayPush(DOMPurify.removed, { + attribute: attr || null, + from: element + }); + try { + if (attr) removeAttributeNode(element, attr); + else element.removeAttribute(name); + } catch (_) { + try { + element.removeAttribute(name); + } catch (_) {} + } + if (name === "is") { + if (RETURN_DOM || RETURN_DOM_FRAGMENT) try { + _forceRemove(element); + } catch (_) {} + else try { + element.setAttribute(name, ""); + } catch (_) {} + } + }; + /** + * Removes every attribute the configuration does not allow. Used to + * neutralize nodes discarded from an in-place tree. + * + * @param element the element to strip + */ + const _stripDisallowedAttributes = function _stripDisallowedAttributes(element) { + const attributes = getAttributes(element); + if (!attributes) return; + for (let i = attributes.length - 1; i >= 0; --i) { + const attribute = attributes[i]; + const name = attribute && attribute.name; + if (typeof name !== "string" || ALLOWED_ATTR[transformCaseFunc(name)]) continue; + _stripAttributeNode(element, attribute, name); + } + }; + /** + * Iterative depth-first walk, so deep trees cannot overflow the call stack, + * reading through the cached getters, so clobbering cannot hide children. + * When `visit` returns false, the node's children are skipped. With + * `intoShadow`, open shadow roots are walked after their host's children. + * + * @param root where to start + * @param visit called for every node + * @param intoShadow whether to enter open shadow roots + */ + const _walkTree = function _walkTree(root, visit) { + let intoShadow = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : false; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (visit(node) === false) continue; + if (intoShadow && _readNodeType(node) === NODE_TYPE.element) { + const shadowRoot = getShadowRoot(node); + if (_isDocumentFragment(shadowRoot)) stack.push(shadowRoot); + } + const childNodes = getChildNodes(node); + if (childNodes) for (let i = childNodes.length - 1; i >= 0; --i) stack.push(childNodes[i]); + } + }; + /** + * Strips disallowed attributes from every element of a discarded subtree, + * so a handler that is already queued (a loading image) cannot fire. + * + * @param root the root of a removed subtree + */ + const _neutralizeSubtree = function _neutralizeSubtree(root) { + _walkTree(root, (node) => { + if (_readNodeType(node) === NODE_TYPE.element) _stripDisallowedAttributes(node); + }); + }; + /** + * All nodes under `root`, root first, including open shadow roots. Template + * content is not entered, it is inert. + * + * @param root the in-place root + * @return the nodes + */ + const _collectLiveNodes = function _collectLiveNodes(root) { + const nodes = []; + _walkTree(root, (node) => { + nodes.push(node); + }, true); + return nodes; + }; + /** + * Strips disallowed attributes from every element that was under the + * in-place root when the call started and no longer is, whatever removed + * or moved it. + * + * @param root the in-place root + * @param before the root's nodes, collected before the walk + */ + const _neutralizeEscapedElements = function _neutralizeEscapedElements(root, before) { + const reachable = setCreate(); + arrayForEach(_collectLiveNodes(root), (node) => { + if (_readNodeType(node) === NODE_TYPE.element) setAdd(reachable, node); + }); + arrayForEach(before, (node) => { + if (_readNodeType(node) === NODE_TYPE.element && !setHas(reachable, node)) _stripDisallowedAttributes(node); + }); + }; + /** + * Neutralizes everything that left the in-place tree during the call: what + * DOMPurify removed, and what hooks or custom elements removed or moved out. + * + * @param root the in-place root + * @param before the root's nodes, collected before the walk + * @return true if the root itself was removed + */ + const _neutralizeDiscarded = function _neutralizeDiscarded(root, before) { + let rootRemoved = false; + arrayForEach(DOMPurify.removed, (entry) => { + if (entry.element) { + rootRemoved = rootRemoved || entry.element === root; + _neutralizeSubtree(entry.element); + } + }); + _neutralizeEscapedElements(root, before); + return rootRemoved; + }; + const MAX_UNVISITED_ROUNDS = 8; + /** + * The walk only moves forward, so a node moved to a position it already + * passed is never visited. Give every original node that is still in the + * tree but unvisited the normal treatment, hooks included. Nodes created + * during the call are left alone. Fails closed if the tree keeps changing. + * + * @param root the in-place root + * @param before the root's nodes, collected before the walk + */ + const _sanitizeUnvisitedNodes = function _sanitizeUnvisitedNodes(root, before) { + const visited = IN_PLACE_VISITED; + let original = null; + const getOriginal = () => { + if (!original) { + original = setCreate(); + arrayForEach(before, (beforeNode) => { + setAdd(original, beforeNode); + }); + } + return original; + }; + for (let round = 0;; round++) { + const live = _collectLiveNodes(root); + const containers = [root]; + let pending = false; + arrayForEach(live, (node) => { + if (_readNodeType(node) === NODE_TYPE.documentFragment) arrayPush(containers, node); + else if (!pending && !setHas(visited, node)) pending = setHas(getOriginal(), node); + }); + if (!pending) return; + if (round >= MAX_UNVISITED_ROUNDS) throw typeErrorCreate("the tree kept changing during in-place sanitization; refusing to return it"); + arrayForEach(containers, (container, index) => { + _sanitizeUnvisitedIn(container, index > 0, root, getOriginal()); + }); + } + }; + /** + * One _sanitizeUnvisitedNodes pass over the root or an open shadow root. + * + * @param container the root or an open shadow root + * @param inShadow whether container is a shadow root + * @param root the in-place root + * @param original the root's nodes collected before the walk, as a set + */ + const _sanitizeUnvisitedIn = function _sanitizeUnvisitedIn(container, inShadow, root, original) { + const iterator = _createNodeIterator(container); + let node; + while (node = iterator.nextNode()) { + if (!setHas(original, node) || setHas(IN_PLACE_VISITED, node)) continue; + if (inShadow) _executeHooks(hooks.uponSanitizeShadowNode, node, null); + _sanitizeElements(node, root); + _sanitizeAttributes(node, root); + if (_isDocumentFragment(node.content)) _sanitizeShadowDOM2(node.content); + } + }; + /** + * Records that _sanitizeElements processed `node`. No-op outside IN_PLACE. + * + * @param node the node being processed + */ + const _markVisited = function _markVisited(node) { + if (IN_PLACE_VISITED) setAdd(IN_PLACE_VISITED, node); + }; + /** + * Patch-linkage attributes (declarative partial updates): `patchsrc` + * always, `for` everywhere except on