diff --git a/Gemfile b/Gemfile index 18d54ed594..e8419bbc39 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,7 @@ end group :development do gem 'better_errors' gem 'pry-byebug' + gem 'fiddle', platforms: :windows # reline's Windows console needs it; not bundled since Ruby 4.0 gem 'sinatra-contrib' end diff --git a/Gemfile.lock b/Gemfile.lock index 16241f0bac..e6c3c3d383 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -64,9 +64,11 @@ GEM ffi (1.17.4-arm-linux-gnu) ffi (1.17.4-arm-linux-musl) ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x64-mingw-ucrt) ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) + fiddle (1.1.8) fspath (3.1.2) google-protobuf (4.36.2) bigdecimal @@ -80,6 +82,9 @@ GEM google-protobuf (4.36.2-arm64-darwin) bigdecimal rake (~> 13.3) + google-protobuf (4.36.2-x64-mingw-ucrt) + bigdecimal + rake (~> 13.3) google-protobuf (4.36.2-x86_64-darwin) bigdecimal rake (~> 13.3) @@ -162,6 +167,8 @@ GEM racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) @@ -241,6 +248,8 @@ GEM google-protobuf (~> 4.31) sass-embedded (1.106.1-arm64-darwin) google-protobuf (~> 4.31) + sass-embedded (1.106.1-x64-mingw-ucrt) + google-protobuf (~> 4.31) sass-embedded (1.106.1-x86_64-darwin) google-protobuf (~> 4.31) sass-embedded (1.106.1-x86_64-linux-gnu) @@ -303,6 +312,7 @@ PLATFORMS arm-linux-musl arm-linux-musleabihf arm64-darwin + x64-mingw-ucrt x86_64-darwin x86_64-linux-gnu x86_64-linux-musl @@ -313,6 +323,7 @@ DEPENDENCIES chunky_png dartsass-sprockets erubi + fiddle html-pipeline (~> 2.14) image_optim image_optim_pack @@ -369,14 +380,17 @@ CHECKSUMS ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x64-mingw-ucrt) sha256=f6ff9618cfccc494138bddade27aa06c74c6c7bc367a1ea1103d80c2fcb9ed35 ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fiddle (1.1.8) sha256=7fa8ee3627271497f3add5503acdbc3f40b32f610fc1cf49634f083ef3f32eee fspath (3.1.2) sha256=b5ac9bafb97e2c8f8f9e303cd98ebd484be76fe9aa588bc4d01c6d99e78c9d75 google-protobuf (4.36.2) sha256=2f8daff50ae14aad36ab0ccdcf284065fa71d726c444efe114e6db82ffe62494 google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169 google-protobuf (4.36.2-aarch64-linux-musl) sha256=0772c9dde1e6dce9a6b44d1d35c28c29adee99d9bd6aff5e21840daeb22161ce google-protobuf (4.36.2-arm64-darwin) sha256=f70d810e6bbab9d8ad9c1d5fd685a21de4c10ad3ad32efb9455e17e5b1286917 + google-protobuf (4.36.2-x64-mingw-ucrt) sha256=02b55d56c1c56a58a5e1cc3a7dda87e97343574ea5aa4f0e503379ba2647bbd1 google-protobuf (4.36.2-x86_64-darwin) sha256=8d1bd2a344f7f589703b202ad2ee4cca8ef731b4ced45782ef92d385967384ba google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db google-protobuf (4.36.2-x86_64-linux-musl) sha256=0fa17686c896da96a60a705e56ddc7d4ff56413f52787c655bd4c9fb33ffe14f @@ -412,6 +426,7 @@ CHECKSUMS nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x64-mingw-ucrt) sha256=051da97b8eccfdb5444fed40246a35e10d7298b9efe759b4cd25455ea04c587e nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b @@ -446,6 +461,7 @@ CHECKSUMS sass-embedded (1.106.1-arm-linux-gnueabihf) sha256=b1a68f22e0a04c897210435ea2153dfac9abc4cc57ded094054e985152b5944f sass-embedded (1.106.1-arm-linux-musleabihf) sha256=a77ad844ed78ef8b1eea427238110be7ad0312b0c93a6b8afc5fd1db2664877c sass-embedded (1.106.1-arm64-darwin) sha256=16a8481233712d3050741aae7e46cd35f2ae65493a197967af0a9efef7324f9f + sass-embedded (1.106.1-x64-mingw-ucrt) sha256=9426034480fc7153d8476936da84bb3e0bc830cce294d2db4ee71a9f03368af9 sass-embedded (1.106.1-x86_64-darwin) sha256=e993d6803ae98c80e6fa71e2ceea8a5699517ee6dd6b1162cff456e5a0ccb4a6 sass-embedded (1.106.1-x86_64-linux-gnu) sha256=2d0fb805f990a8328aa590cb201efbe21e26c98364107632e0ea5fc6b4a988c6 sass-embedded (1.106.1-x86_64-linux-musl) sha256=28e5a7a82abd5d68fe56df7d44e6706c66f4e27d05123dd36966dc441a7dee50 diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 7dcd06bdf1..73e0d925fc 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -2,6 +2,8 @@ import { config } from "./config.js"; import { DB } from "./db.js"; +import { DocPacks } from "./doc_packs.js"; +import { Resume } from "./resume.js"; import { Router } from "./router.js"; import { AppServiceWorker } from "./serviceworker.js"; import { Settings } from "./settings.js"; @@ -13,6 +15,7 @@ import { SettingsStore } from "../lib/settings_store.js"; import { Events } from "../lib/events.js"; import { LocalStorageStore } from "../lib/local_storage_store.js"; import { $ } from "../lib/util.js"; +/** @import { Context } from "../lib/page.js" */ import { Doc } from "../models/doc.js"; import { unsupportedBrowser } from "../templates/error_tmpl.js"; import { AppDocument } from "../views/layout/document.js"; @@ -81,6 +84,9 @@ export class App extends Events { this.serviceWorker = new AppServiceWorker(); } this.settings = new Settings(); + this.docPacks = new DocPacks(this.localStorage); + this.docPacks.showIcons(); + this.resume = new Resume(this.localStorage); this.db = new DB(); this.settings.initLayout(); @@ -181,10 +187,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; @@ -209,6 +215,7 @@ export class App extends Events { this.initDoc(doc); } this.trigger("ready"); + this.resumeReading(); this.router.start(); this.hideLoadingScreen(); setTimeout(() => { @@ -219,6 +226,51 @@ export class App extends Events { }, 50); } + /** + * Reopens the page read last time when DevDocs starts on its home page, at + * the same scroll position, and from then on remembers the page being read. + * Runs before the router dispatches the first page. + */ + resumeReading() { + if (this.isSingleDoc()) { + return; + } + const content = this.document.content; + + const last = this.settings.get("noResume") + ? null + : this.resume.target(location, (slug) => !!this.docs.findBySlug(slug)); + if (last) { + this.router.replacePath(last.path); + content.restoreScroll(last.scrollTop); + } + + let reading = false; + this.router.on("after", (name, /** @type {Context} */ context) => { + reading = name === "entry" || name === "type"; + if (reading) { + this.resume.remember(context.pathname + (context.hash ? `#${context.hash}` : "")); + } + }); + + // On phones the whole document scrolls, and its scroll events go to window. + const scroller = + content.scrollEl === document.scrollingElement || content.scrollEl === document.body + ? window + : content.scrollEl; + let timer; + $.on(scroller, "scroll", () => { + if (!reading) { + return; + } + clearTimeout(timer); + timer = setTimeout( + () => this.resume.rememberScroll(content.scrollEl.scrollTop), + Resume.SCROLL_DELAY, + ); + }); + } + /** * Adds a doc's types and entries to the search index. * @@ -400,6 +452,7 @@ export class App extends Events { } new News(); new Updates(); + this.docPacks.refresh(); return (this.updateChecker = new UpdateChecker()); } diff --git a/assets/javascripts/app/categories.js b/assets/javascripts/app/categories.js new file mode 100644 index 0000000000..6c4b12ed37 --- /dev/null +++ b/assets/javascripts/app/categories.js @@ -0,0 +1,140 @@ +// @ts-check + +/** + * The categories the docs are grouped in: in the sidebar, in the preferences' + * doc list, and as a search scope (type a category's name, then Tab). + * + * The built-in docs are sorted here, by slug without its version. A doc from + * a doc pack names its own category in its manifest. Anything else is "Other". + */ + +/** + * @typedef {object} Category + * @property {string} id What a pack's manifest gives as `category`. + * @property {string} name + * @property {string[]} aliases Other names that scope the search to it. + */ + +/** @type {Category[]} In the order they're shown. */ +export const CATEGORIES = [ + { id: "languages", name: "Languages", aliases: ["language", "lang", "langs"] }, + { id: "web", name: "Web platform", aliases: ["web"] }, + { id: "frameworks", name: "Frameworks & platforms", aliases: ["framework", "platform", "platforms"] }, + { id: "libraries", name: "Libraries", aliases: ["library", "lib", "libs"] }, + { id: "testing", name: "Testing", aliases: ["test", "tests"] }, + { id: "tools", name: "Tools", aliases: ["tool"] }, + { id: "databases", name: "Databases", aliases: ["database", "db", "dbs"] }, + { id: "ai", name: "AI & data", aliases: ["ml", "data"] }, + { id: "services", name: "Servers & services", aliases: ["service", "server", "servers"] }, + { id: "other", name: "Other", aliases: [] }, +]; + +export const DEFAULT_CATEGORY = "other"; + +/** The built-in docs of each category, by slug without the version. */ +const BUILT_IN = { + languages: + "c clojure coffeescript coldfusion cpp crystal d dart elisp elixir erlang gnu_cobol gnu_fortran go " + + "groovy haskell haxe javascript julia kotlin latex lua markdown nim ocaml octave odin openjdk perl " + + "php pony python r ruby rust scala tcl_tk tcllib typescript typst zig", + web: "browser_support_tables css dom html http less sass svg web_extensions xslt_xpath", + frameworks: + "angular angularjs astro backbone bootstrap bottle bun cakephp codeigniter cordova cyclejs deno " + + "django django_rest_framework dojo drupal electron ember express falcon fastapi flask godot gtk hapi " + + "jquerymobile koa laravel love marionette meteor nextjs node padrino phalcon phoenix qt rails " + + "react react_native spring_boot svelte symfony tailwindcss vertx vue wagtail wordpress yii", + libraries: + "async axios bluebird click d3 date_fns eigen3 es_toolkit fluture handlebars htmx immutable jinja " + + "joi jquery jqueryui knockout leaflet liquid lit lodash maplibre_gl modernizr moment moment_timezone " + + "nokogiri opengl openlayers phaser point_cloud_library pug pygame q rack ramda reactivex " + + "react_bootstrap react_router redux relay requests requirejs rxjs sanctuary sanctuary_def " + + "sanctuary_type_classes socketio tanstack_query tanstack_router threejs tokio trio twig underscore " + + "valibot vue_router vueuse vuex vulkan werkzeug celery", + testing: + "chai codeception codeceptjs cypress enzyme jasmine jest minitest mocha phpunit playwright " + + "puppeteer pytest qunit sinon vitest", + tools: + "ansible babel bash bazel bower chef cmake composer docker esbuild eslint fish flow gcc git " + + "gnu_make gnuplot graphviz grunt hammerspoon homebrew i3 jekyll jq jsdoc kubectl man nix npm " + + "nushell opentofu powershell prettier saltstack terraform vagrant vite webpack yarn zsh", + databases: + "couchdb duckdb graphite influxdata mariadb mongoose opentsdb postgresql redis rethinkdb sequelize sqlite", + ai: "apache_pig matplotlib numpy pandas polars pytorch scikit_image scikit_learn statsmodels tensorflow tensorflow_cpp", + services: "apache_http_server haproxy kubernetes nginx nginx_lua_module rabbitmq varnish", +}; + +/** @type {Map} Slug without version to category id. */ +const CATEGORY_OF_SLUG = new Map( + Object.entries(BUILT_IN).flatMap(([id, slugs]) => + slugs.split(" ").map((slug) => [slug, id]), + ), +); + +/** @type {Map} */ +const BY_ID = new Map(CATEGORIES.map((category) => [category.id, category])); + +/** + * @param {unknown} id + * @returns {id is string} Whether it names a category. + */ +export function isCategoryId(id) { + return typeof id === "string" && BY_ID.has(id); +} + +/** + * @param {string} id + * @returns {Category} + */ +export function getCategory(id) { + return BY_ID.get(id) || /** @type {Category} */ (BY_ID.get(DEFAULT_CATEGORY)); +} + +/** + * The category of a doc: its own for a pack doc, otherwise the built-in one. + * + * @param {{ slug: string, category?: unknown, pack?: unknown }} doc + * @returns {string} A category id. + */ +export function categoryOf(doc) { + if (isCategoryId(doc.category)) { + return doc.category; + } + if (doc.pack) { + return DEFAULT_CATEGORY; + } + return CATEGORY_OF_SLUG.get(doc.slug.split("~")[0]) || DEFAULT_CATEGORY; +} + +/** + * The category someone means by what they typed, e.g. "db" or "Databases". + * + * @param {string} query + * @returns {Category | undefined} + */ +export function findCategory(query) { + const text = query.trim().toLowerCase(); + if (!text) { + return undefined; + } + return CATEGORIES.find( + (category) => + category.id === text || + category.name.toLowerCase() === text || + category.aliases.includes(text), + ); +} + +/** + * Groups docs by category, in the categories' order, keeping the docs' order + * within each and leaving out empty categories. + * + * @template {{ category: string }} D + * @param {D[]} docs + * @returns {{ category: Category, docs: D[] }[]} + */ +export function groupByCategory(docs) { + return CATEGORIES.map((category) => ({ + category, + docs: docs.filter((doc) => doc.category === category.id), + })).filter((group) => group.docs.length > 0); +} diff --git a/assets/javascripts/app/doc_packs.js b/assets/javascripts/app/doc_packs.js new file mode 100644 index 0000000000..010424ac7d --- /dev/null +++ b/assets/javascripts/app/doc_packs.js @@ -0,0 +1,187 @@ +// @ts-check + +import { ajax } from "../lib/ajax.js"; +import { + DocPackError, + downloadIcons, + iconCss, + keepIcons, + 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) { + const insecure = /^\s*http:\/\//i.test(input); + callback( + insecure + ? new DocPackError( + "The address has to start with https://, so that nobody can change the pack on its way to you.", + "insecure", + ) + : new DocPackError( + "That isn't a web address. Paste a link such as github.com/someone/their-docs.", + "address", + ), + ); + return; + } + if (this.all().some((pack) => pack.url === url)) { + callback(new DocPackError("That pack is already added.", "duplicate")); + 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.", + "storage", + ), + ); + 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; + } + keepIcons(fresh, pack); + // 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(); + downloadIcons(pack).then((icons) => { + if (Object.keys(icons).length > 0) { + pack.icons = icons; + } + callback(null, pack); + }); + }, + error: (type, xhr) => { + if (type === "invalid") { + callback(new DocPackError(`${manifestUrl} isn't valid JSON.`, "invalid")); + } else if (xhr?.status === 404) { + callback(new DocPackError(`There's no ${MANIFEST_FILENAME} at ${url}.`, "missing")); + } else { + // Status 0: the site is down, or doesn't let other sites read it + // (most websites), which the browser doesn't tell apart. + callback(new DocPackError(`Couldn't download ${manifestUrl}.`, "unreachable")); + } + }, + }); + } + + /** + * Adds the CSS that shows the packs' own icons. Adding or removing a pack + * reloads the app, so once at boot is enough. + */ + showIcons() { + const css = iconCss(this.all()); + if (css) { + const style = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + } + } + + /** + * @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/app/resume.js b/assets/javascripts/app/resume.js new file mode 100644 index 0000000000..0527fcfe96 --- /dev/null +++ b/assets/javascripts/app/resume.js @@ -0,0 +1,82 @@ +// @ts-check + +/** @import { LocalStorageStore } from "../lib/local_storage_store.js" */ + +/** + * "Resume where I left off": the doc page being read is remembered, with how + * far down it's scrolled, and when DevDocs is next opened on its home page it + * goes back there. Opening a link to a particular page still opens that page. + * + * Kept in this browser's local storage, like the other per-person state. + */ +export class Resume { + static STORAGE_KEY = "lastPage"; + + /** How long scrolling has to stop before the position is stored. */ + static SCROLL_DELAY = 400; + + /** @param {LocalStorageStore} store */ + constructor(store) { + this.store = store; + } + + /** @returns {{ path: string, scrollTop: number } | null} The page last read. */ + last() { + const value = /** @type {{ path?: unknown, scrollTop?: unknown } | null | undefined} */ ( + this.store.get(Resume.STORAGE_KEY) + ); + if ( + !value || + typeof value.path !== "string" || + // Only paths within DevDocs. + !value.path.startsWith("/") || + value.path.startsWith("//") + ) { + return null; + } + return { path: value.path, scrollTop: Math.max(0, Number(value.scrollTop) || 0) }; + } + + /** + * Remembers the page being read, at its top. + * + * @param {string} path + */ + remember(path) { + if (this.last()?.path !== path) { + this.store.set(Resume.STORAGE_KEY, { path, scrollTop: 0 }); + } + } + + /** + * Remembers how far down the page being read is scrolled. + * + * @param {number} scrollTop + */ + rememberScroll(scrollTop) { + const last = this.last(); + if (last) { + this.store.set(Resume.STORAGE_KEY, { path: last.path, scrollTop: Math.round(scrollTop) }); + } + } + + /** + * The page to reopen when DevDocs starts at `location`: only when it starts + * on the home page with nothing in the address after it (a link to a page, + * or a search, goes where it says), and only a page of a doc that's still + * enabled. + * + * @param {{ pathname: string, search: string, hash: string }} location + * @param {(slug: string) => boolean} isEnabled Whether a doc's slug is enabled. + * @returns {{ path: string, scrollTop: number } | null} + */ + target(location, isEnabled) { + if (location.pathname !== "/" || location.search || location.hash) { + return null; + } + const last = this.last(); + // "/css/..." or a type page, "/css-properties/". + const slug = last?.path.split("/")[1]?.split("-")[0]; + return last && slug && isEnabled(slug) ? last : null; + } +} diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js index 68a2879641..211a523518 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -46,6 +46,16 @@ export class Router extends Events { page.show(path); } + /** + * Changes the address without showing the page; the first page shown is + * then that one. Used before `start`. + * + * @param {string} path + */ + replacePath(path) { + page.replace(path, null, true); + } + /** * Navigates without leaving a history entry behind. * diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 32a7372da3..ade6ba8fd9 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -36,6 +36,7 @@ import { $ } from "../lib/util.js"; * @property {string} baseFont A CSS font family list for the text; unset uses the default. * @property {string} monoFont A CSS font family list for code; unset uses the default. * @property {string} customCss A stylesheet applied on top of the app's. + * @property {StoredFlag} noResume Don't reopen the page read last time on startup. * @property {number} listPageSize How many entries a sidebar list shows before * "Show more…"; 0 shows them all. * @property {StoredFlag} autoLatestVersion @@ -81,6 +82,7 @@ export class Settings { "baseFont", "monoFont", "customCss", + "noResume", ]; static INTERNAL_KEYS = ["count", "schema", "version", "news"]; @@ -108,6 +110,7 @@ export class Settings { spaceTimeout: 0.5, noDocSpecificIcon: false, autoLatestVersion: false, + noResume: false, listPageSize: config.max_results, }; diff --git a/assets/javascripts/globals.d.ts b/assets/javascripts/globals.d.ts index eb5036f9e4..7d30617042 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,18 @@ 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; + /** For a doc from a doc pack: whether the pack gave it an icon. */ + pack_icon?: boolean; + + /** Derived: the category's id (see app/categories.js). */ + category: 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..48c84c380b --- /dev/null +++ b/assets/javascripts/lib/doc_packs.js @@ -0,0 +1,623 @@ +// @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. + */ + +import { isCategoryId } from "../app/categories.js"; + +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"'`<>\\]+$/; + +/** Where to send people who want to find or make a pack. */ +export const GUIDE_URL = + "https://github.com/buggypather/devdocs/blob/main-custom-docs/docs/doc-packs.md"; +export const FIND_PACKS_URL = "https://github.com/topics/devdocs-pack"; + +/** + * Why a pack couldn't be added, so the preferences can say what to do next. + * + * @typedef {"address" | "insecure" | "duplicate" | "missing" | "unreachable" | "invalid" | "storage"} DocPackErrorReason + */ + +/** A problem with a pack, worded for the person who added it. */ +export class DocPackError extends Error { + name = "DocPackError"; + + /** + * @param {string} message + * @param {DocPackErrorReason} reason + */ + constructor(message, reason) { + super(message); + this.reason = reason; + } +} + +/** + * @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. + * @property {Record} [icons] Docs' icons as `data:` URLs, by + * their slug in the pack, so that they show offline too. + */ + +/** + * 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 || /\s/.test(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; + } + // A site's name has a dot in it (example.com); only this computer doesn't. + if (!url.hostname.includes(".") && !isLocal(url)) { + 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.`, + "invalid", + ); + } + + 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".`, + "invalid", + ); + } + 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, + // One of app/categories.js' ids; anything else is "Other". + category: isCategoryId(raw.category) ? raw.category : undefined, + pack_icon: isIconDataUrl(pack.icons?.[sourceSlug]) || undefined, + }; +} + +/** + * A doc's icon, in its folder of the pack: DevDocs' own 32-pixel name, shown + * at 16 pixels (twice as sharp on high-resolution screens). + */ +export const ICON_FILENAME = "16@2x.png"; + +/** + * Icons are kept in local storage, so bigger ones are skipped. A 32-pixel PNG + * is a few kilobytes at most. + */ +export const MAX_ICON_BYTES = 16 * 1024; + +const MAX_ICONS = 100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const ICON_DATA_URL_RGX = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/; + +/** @param {unknown} value */ +function isIconDataUrl(value) { + return typeof value === "string" && ICON_DATA_URL_RGX.test(value); +} + +/** + * The slugs of the docs whose manifest entry says `"icon": true`. + * + * @param {StoredPack} pack + * @returns {string[]} + */ +function slugsWithIcons(pack) { + const slugs = (pack.docs || []) + .filter((raw) => raw.icon === true && typeof raw.slug === "string") + .map((raw) => /** @type {string} */ (raw.slug).trim()) + .filter((slug) => SLUG_RGX.test(slug)); + return [...new Set(slugs)].slice(0, MAX_ICONS); +} + +/** + * Turns a downloaded icon into a `data:` URL, if it's a PNG small enough to + * keep. + * + * @param {Uint8Array} bytes + * @returns {string | null} + */ +export function readIcon(bytes) { + if ( + bytes.length > MAX_ICON_BYTES || + bytes.length < PNG_SIGNATURE.length || + PNG_SIGNATURE.some((byte, i) => bytes[i] !== byte) + ) { + return null; + } + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return `data:image/png;base64,${btoa(binary)}`; +} + +/** + * Downloads the icons of the pack's docs that have one. An icon that's + * missing or unusable is left out, and its doc keeps the plain icon. + * + * @param {StoredPack} pack + * @param {(url: string) => Promise} [download] + * @returns {Promise>} The icons, by slug in the pack. + */ +export async function downloadIcons(pack, download = (url) => fetch(url)) { + /** @type {Record} */ + const icons = {}; + await Promise.all( + slugsWithIcons(pack).map(async (slug) => { + try { + const response = await download( + `${pack.url}${encodeURIComponent(slug)}/${ICON_FILENAME}`, + ); + if (!response.ok) return; + const icon = readIcon(new Uint8Array(await response.arrayBuffer())); + if (icon) icons[slug] = icon; + } catch { + // Offline, or the site refused: the doc keeps the plain icon. + } + }), + ); + return icons; +} + +/** + * When a manifest is downloaded again, keeps the old icons of the docs that + * still have one but whose icon couldn't be downloaded this time. + * + * @param {StoredPack} fresh + * @param {StoredPack} old + */ +export function keepIcons(fresh, old) { + for (const slug of slugsWithIcons(fresh)) { + const icon = old.icons?.[slug]; + if (!fresh.icons?.[slug] && isIconDataUrl(icon)) { + fresh.icons = { ...fresh.icons, [slug]: icon }; + } + } +} + +/** + * The icon class of a pack doc with an icon, `_icon-`. Built-in docs' + * names never contain a hyphen, so they can't clash; dots in slugs would + * end the class in a CSS selector, and slugs never contain a hyphen either. + * + * @param {string} slugWithoutVersion The doc's slug in DevDocs, with the pack's code. + */ +export function packIconName(slugWithoutVersion) { + return `pack-${slugWithoutVersion.replace(/\./g, "-")}`; +} + +/** + * CSS that shows the packs' icons, in place of the sprite that holds DevDocs' + * own icons. + * + * @param {StoredPack[]} packs + * @returns {string} + */ +export function iconCss(packs) { + const rules = new Map(); + for (const pack of packs) { + for (const doc of packDocs(pack)) { + if (!doc.pack_icon) continue; + const slug = /** @type {string} */ (doc.slug).split("~")[0]; + const icon = pack.icons[/** @type {string} */ (doc.source_slug)]; + rules.set( + slug, + `._icon-${packIconName(slug)}:before { background-image: url("${icon}") !important; ` + + "background-size: 1rem 1rem !important; background-position: 0 0 !important; }", + ); + } + } + return [...rules.values()].join("\n"); +} + +/** + * The built-in docs whose website or source code is at the address someone + * tried to add as a pack, e.g. `vuejs.org/guide` or `github.com/vuejs/docs` + * for Vue. Best match first. + * + * @param {unknown} input + * @param {{ name: string, links?: Record }[]} docs + * @returns {string[]} The docs' names, once each. + */ +export function findBuiltInDocs(input, docs) { + const target = parseLoosely(input); + if (!target) { + return []; + } + + /** @type {Map} */ + const scores = new Map(); + for (const doc of docs) { + for (const link of Object.values(doc.links || {})) { + const url = parseLoosely(link); + if (!url || url.host !== target.host) { + continue; + } + // Either address may be the other's parent: the site's root for a doc + // that lives in a folder, or a page deep inside the doc. + const shared = commonSegments(url.segments, target.segments); + if (shared === Math.min(url.segments.length, target.segments.length)) { + scores.set(doc.name, Math.max(scores.get(doc.name) ?? -1, shared)); + } + } + } + return [...scores].sort((a, b) => b[1] - a[1]).map(([name]) => name); +} + +/** + * @param {unknown} input + * @returns {{ host: string, segments: string[] } | null} + */ +function parseLoosely(input) { + let text = String(input ?? "").trim(); + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) { + text = `https://${text}`; + } + try { + const url = new URL(text); + return { + host: url.hostname.replace(/^www\./, ""), + segments: url.pathname + .toLowerCase() + .split("/") + .filter(Boolean) + .filter((segment) => !/^(index\.html?|docs\.json)$/.test(segment)), + }; + } catch (error) { + return null; + } +} + +/** + * @param {string[]} a + * @param {string[]} b + */ +function commonSegments(a, b) { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + return i; +} + +/** + * 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/lib/doc_search.js b/assets/javascripts/lib/doc_search.js new file mode 100644 index 0000000000..99ded0a19e --- /dev/null +++ b/assets/javascripts/lib/doc_search.js @@ -0,0 +1,41 @@ +// @ts-check + +/** + * Searching within one doc, for the opened doc's menu in the header. Kept + * free of the app so it can be tested under Node. + */ + +/** + * The entries whose names contain every word of the query, best first: names + * that are the query, then names that start with it, then the rest, shorter + * names first within each group. + * + * @template {{ name: string }} T + * @param {T[]} entries + * @param {string} query + * @param {number} [limit] + * @returns {T[]} + */ +export function searchEntries(entries, query, limit = 50) { + const needle = normalize(query); + if (!needle) { + return []; + } + const words = needle.split(" "); + const scored = []; + for (const entry of entries) { + const name = normalize(entry.name); + if (!words.every((word) => name.includes(word))) { + continue; + } + const rank = name === needle ? 0 : name.startsWith(needle) ? 1 : 2; + scored.push({ entry, rank, length: name.length }); + } + scored.sort((a, b) => a.rank - b.rank || a.length - b.length); + return scored.slice(0, limit).map(({ entry }) => entry); +} + +/** @param {string} text */ +function normalize(text) { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index b6579d731d..e9e2b83452 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -1,10 +1,12 @@ // @ts-check import { app } from "../app/app.js"; +import { categoryOf } from "../app/categories.js"; 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, packIconName } from "../lib/doc_packs.js"; import { Entry } from "./entry.js"; import { Model } from "./model.js"; @@ -42,11 +44,19 @@ 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; + // The sprite only holds DevDocs' own docs. A pack doc shows the icon from + // its pack (see DocPacks#showIcons), or else one plain icon shared by all + // (see _icons.scss.erb). Built-in slugs never contain a hyphen. + this.icon = this.pack + ? this.pack_icon + ? packIconName(this.slug_without_version) + : "doc-pack" + : this.slug_without_version; if (this.version) { this.short_version = this.version.split(" ")[0]; } this.text = this.toEntry().text; + this.category = categoryOf(this); } /** @@ -56,6 +66,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 +105,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/base.js b/assets/javascripts/templates/base.js index 4c2dcdff66..3d09fca210 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -1,5 +1,6 @@ // @ts-check +import * as docMenuTmpl from "./doc_menu_tmpl.js"; import * as errorTmpl from "./error_tmpl.js"; import * as noticeTmpl from "./notice_tmpl.js"; import * as notifTmpl from "./notif_tmpl.js"; @@ -25,6 +26,7 @@ import * as tipTmpl from "./tip_tmpl.js"; * @type {Record string) | string>} */ const templates = { + ...docMenuTmpl, ...errorTmpl, ...noticeTmpl, ...notifTmpl, diff --git a/assets/javascripts/templates/doc_menu_tmpl.js b/assets/javascripts/templates/doc_menu_tmpl.js new file mode 100644 index 0000000000..2c986fd3f2 --- /dev/null +++ b/assets/javascripts/templates/doc_menu_tmpl.js @@ -0,0 +1,69 @@ +// @ts-check + +import { $ } from "../lib/util.js"; +/** @import { Doc } from "../models/doc.js" */ +/** @import { Entry } from "../models/entry.js" */ +/** @import { Type } from "../models/type.js" */ + +const arrow = ''; + +/** + * The opened doc's contents menu, at the top left of the page: the button + * that opens it, and the panel. Filled in by views/layout/doc_menu.js. + */ +export const docMenu = `\ + +\ +`; + +/** + * The doc's contents: its types, each opening to list its entries. + * + * @param {Doc} doc + * @param {Type[]} types + * @returns {string} + */ +export const docMenuTypes = (doc, types) => { + if (types.length === 0) { + return `

${$.escape(doc.fullName)} has no sections. Search it with the box above.

`; + } + return types + .map( + (type, index) => `\ +
+ + +
`, + ) + .join(""); +}; + +/** + * A link to one entry. Given a list, the template renderer draws one each. + * + * @param {Entry} entry + * @returns {string} + */ +export const docMenuEntry = (entry) => + `${$.escape(entry.name)}`; + +/** + * Entries matching the search in the doc. + * + * @param {Doc} doc + * @param {Entry[]} entries + * @returns {string} + */ +export const docMenuResults = (doc, entries) => + entries.length === 0 + ? `

Nothing in ${$.escape(doc.fullName)} matches.

` + : entries.map(docMenuEntry).join(""); diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 5e6d7c8dfb..f43d6d592a 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -16,6 +16,8 @@ import { $ } from "../../lib/util.js"; export const offlinePage = (docs, hasPersistence, isPersistent) => `\

Offline Documentation

+

Need documentation that isn't listed? Add documentation by link: paste a doc pack's address in Preferences, enable its docs, then install them here.

+
\ `; +/** + * 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)} + +
\ +`; + +/** + * The line that opens the doc packs section. + * + * @param {number} count How many packs are added. + * @returns {string} + */ +const docPacksSummary = (count) => + count === 0 + ? "Add documentation by link" + : `Show doc packs (${count} added)`; + +/** + * What to do when an address couldn't be added as a doc pack. + * + * @param {object} help + * @param {string} help.message What went wrong. + * @param {string} help.reason A `DocPackErrorReason`. + * @param {boolean} help.isGitHub Whether the address is a GitHub repository. + * @param {string[]} help.builtIn Built-in docs at that address. + * @param {string} help.guideUrl How to make a pack. + * @param {string} help.findUrl Where shared packs are listed. + * @returns {string} + */ +export const docPackHelp = ({ message, reason, isGitHub, builtIn, guideUrl, findUrl }) => { + let html = `

${$.escape(message)}

`; + + if (builtIn.length) { + html += `

DevDocs already has ${builtIn.map($.escape).join(", ")}: tick it in the list on the left, then save.

`; + } + + if (reason !== "missing" && reason !== "unreachable") { + return html; + } + + if (isGitHub) { + html += `

This repository isn't a doc pack: a pack has a docs.json at the top. If the pack is in a folder of the repository, open that folder on GitHub and paste its address.

`; + } else { + html += `

If this is a documentation website rather than a doc pack: DevDocs can't read websites directly, because browsers don't let one site read another's pages, and a website's pages need tidying before they can be searched. Ways to get these docs:

+
    +
  • Find a doc pack someone has already shared.
  • +
  • Look for the website's source on GitHub: many doc sites are written in a public repository, and some include a doc pack.
  • +
  • Make a doc pack of it: from pages you write, or from the website with DevDocs' scrapers.
  • +
`; + } + return html; +}; + /** * @param {Record} settings The user's current preferences. * @returns {string} @@ -86,6 +152,12 @@ export const settingsPage = (settings) => `\ settings.noAutofocus ? " checked" : "" }>Disable autofocus of search input +
+
+

Doc packs:

+ +
+ ${docPacksSummary( + /** @type {unknown[]} */ (settings.docPacks).length, + )} + ${/** @type {{ url: string, name: string, count: number }[]} */ ( + settings.docPacks + ) + .map(docPack) + .join("")} +
+ + + + + + Documentation shared by anyone as a doc pack: a Git repository or a website laid out for DevDocs. Its docs then appear in the list on the left, ready to enable. What's a doc pack? +
+
+
+

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/templates/sidebar_tmpl.js b/assets/javascripts/templates/sidebar_tmpl.js index bf35f38f79..26af6d14e0 100644 --- a/assets/javascripts/templates/sidebar_tmpl.js +++ b/assets/javascripts/templates/sidebar_tmpl.js @@ -164,6 +164,15 @@ export const sidebarVersionedDoc = function (doc, versions, options) { ); }; +/** + * The heading of a category of docs. + * + * @param {import("../app/categories.js").Category} category + * @returns {string} + */ +export const sidebarCategory = (category) => + `
    ${$.escape(category.name)}
    `; + /** * The heading above the disabled docs. * 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