lexxy 0.9.33 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4827,24 +4827,65 @@ Prism$1.languages.webmanifest = Prism$1.languages.json;
4827
4827
  window.Prism ||= {};
4828
4828
  window.Prism.manual = true;
4829
4829
 
4830
- /*! @license DOMPurify 3.3.0 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.0/LICENSE */
4831
-
4832
- const {
4833
- entries,
4834
- setPrototypeOf,
4835
- isFrozen,
4836
- getPrototypeOf,
4837
- getOwnPropertyDescriptor
4838
- } = Object;
4839
- let {
4840
- freeze,
4841
- seal,
4842
- create
4843
- } = Object; // eslint-disable-line import/no-mutable-exports
4844
- let {
4845
- apply,
4846
- construct
4847
- } = typeof Reflect !== 'undefined' && Reflect;
4830
+ /*! @license DOMPurify 3.4.13 | (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.13/LICENSE */
4831
+
4832
+ function _arrayLikeToArray(r, a) {
4833
+ (null == a || a > r.length) && (a = r.length);
4834
+ for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
4835
+ return n;
4836
+ }
4837
+ function _arrayWithHoles(r) {
4838
+ if (Array.isArray(r)) return r;
4839
+ }
4840
+ function _iterableToArrayLimit(r, l) {
4841
+ var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
4842
+ if (null != t) {
4843
+ var e,
4844
+ n,
4845
+ i,
4846
+ u,
4847
+ a = [],
4848
+ f = true,
4849
+ o = false;
4850
+ try {
4851
+ if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);
4852
+ } catch (r) {
4853
+ o = true, n = r;
4854
+ } finally {
4855
+ try {
4856
+ if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return;
4857
+ } finally {
4858
+ if (o) throw n;
4859
+ }
4860
+ }
4861
+ return a;
4862
+ }
4863
+ }
4864
+ function _nonIterableRest() {
4865
+ throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
4866
+ }
4867
+ function _slicedToArray(r, e) {
4868
+ return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
4869
+ }
4870
+ function _unsupportedIterableToArray(r, a) {
4871
+ if (r) {
4872
+ if ("string" == typeof r) return _arrayLikeToArray(r, a);
4873
+ var t = {}.toString.call(r).slice(8, -1);
4874
+ 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;
4875
+ }
4876
+ }
4877
+
4878
+ const entries = Object.entries,
4879
+ setPrototypeOf = Object.setPrototypeOf,
4880
+ isFrozen = Object.isFrozen,
4881
+ getPrototypeOf = Object.getPrototypeOf,
4882
+ getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
4883
+ let freeze = Object.freeze,
4884
+ seal = Object.seal,
4885
+ create = Object.create; // eslint-disable-line import/no-mutable-exports
4886
+ let _ref = typeof Reflect !== 'undefined' && Reflect,
4887
+ apply = _ref.apply,
4888
+ construct = _ref.construct;
4848
4889
  if (!freeze) {
4849
4890
  freeze = function freeze(x) {
4850
4891
  return x;
@@ -4876,13 +4917,19 @@ const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf);
4876
4917
  const arrayPop = unapply(Array.prototype.pop);
4877
4918
  const arrayPush = unapply(Array.prototype.push);
4878
4919
  const arraySplice = unapply(Array.prototype.splice);
4920
+ const arrayIsArray = Array.isArray;
4879
4921
  const stringToLowerCase = unapply(String.prototype.toLowerCase);
4880
4922
  const stringToString = unapply(String.prototype.toString);
4881
4923
  const stringMatch = unapply(String.prototype.match);
4882
4924
  const stringReplace = unapply(String.prototype.replace);
4883
4925
  const stringIndexOf = unapply(String.prototype.indexOf);
4884
4926
  const stringTrim = unapply(String.prototype.trim);
4927
+ const numberToString = unapply(Number.prototype.toString);
4928
+ const booleanToString = unapply(Boolean.prototype.toString);
4929
+ const bigintToString = typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString);
4930
+ const symbolToString = typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString);
4885
4931
  const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);
4932
+ const objectToString = unapply(Object.prototype.toString);
4886
4933
  const regExpTest = unapply(RegExp.prototype.test);
4887
4934
  const typeErrorCreate = unconstruct(TypeError);
4888
4935
  /**
@@ -4932,6 +4979,9 @@ function addToSet(set, array) {
4932
4979
  // Prevent prototype setters from intercepting set as a this value.
4933
4980
  setPrototypeOf(set, null);
4934
4981
  }
4982
+ if (!arrayIsArray(array)) {
4983
+ return set;
4984
+ }
4935
4985
  let l = array.length;
4936
4986
  while (l--) {
4937
4987
  let element = array[l];
@@ -4972,10 +5022,13 @@ function cleanArray(array) {
4972
5022
  */
4973
5023
  function clone(object) {
4974
5024
  const newObject = create(null);
4975
- for (const [property, value] of entries(object)) {
5025
+ for (const _ref2 of entries(object)) {
5026
+ var _ref3 = _slicedToArray(_ref2, 2);
5027
+ const property = _ref3[0];
5028
+ const value = _ref3[1];
4976
5029
  const isPropertyExist = objectHasOwnProperty(object, property);
4977
5030
  if (isPropertyExist) {
4978
- if (Array.isArray(value)) {
5031
+ if (arrayIsArray(value)) {
4979
5032
  newObject[property] = cleanArray(value);
4980
5033
  } else if (value && typeof value === 'object' && value.constructor === Object) {
4981
5034
  newObject[property] = clone(value);
@@ -4986,6 +5039,58 @@ function clone(object) {
4986
5039
  }
4987
5040
  return newObject;
4988
5041
  }
5042
+ /**
5043
+ * Convert non-node values into strings without depending on direct property access.
5044
+ *
5045
+ * @param value - The value to stringify.
5046
+ * @returns A string representation of the provided value.
5047
+ */
5048
+ function stringifyValue(value) {
5049
+ switch (typeof value) {
5050
+ case 'string':
5051
+ {
5052
+ return value;
5053
+ }
5054
+ case 'number':
5055
+ {
5056
+ return numberToString(value);
5057
+ }
5058
+ case 'boolean':
5059
+ {
5060
+ return booleanToString(value);
5061
+ }
5062
+ case 'bigint':
5063
+ {
5064
+ return bigintToString ? bigintToString(value) : '0';
5065
+ }
5066
+ case 'symbol':
5067
+ {
5068
+ return symbolToString ? symbolToString(value) : 'Symbol()';
5069
+ }
5070
+ case 'undefined':
5071
+ {
5072
+ return objectToString(value);
5073
+ }
5074
+ case 'function':
5075
+ case 'object':
5076
+ {
5077
+ if (value === null) {
5078
+ return objectToString(value);
5079
+ }
5080
+ const valueAsRecord = value;
5081
+ const valueToString = lookupGetter(valueAsRecord, 'toString');
5082
+ if (typeof valueToString === 'function') {
5083
+ const stringified = valueToString(valueAsRecord);
5084
+ return typeof stringified === 'string' ? stringified : objectToString(stringified);
5085
+ }
5086
+ return objectToString(value);
5087
+ }
5088
+ default:
5089
+ {
5090
+ return objectToString(value);
5091
+ }
5092
+ }
5093
+ }
4989
5094
  /**
4990
5095
  * This method automatically checks if the prop is function or getter and behaves accordingly.
4991
5096
  *
@@ -5011,6 +5116,14 @@ function lookupGetter(object, prop) {
5011
5116
  }
5012
5117
  return fallbackValue;
5013
5118
  }
5119
+ function isRegex(value) {
5120
+ try {
5121
+ regExpTest(value, '');
5122
+ return true;
5123
+ } catch (_unused) {
5124
+ return false;
5125
+ }
5126
+ }
5014
5127
 
5015
5128
  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']);
5016
5129
  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']);
@@ -5026,15 +5139,14 @@ const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mgly
5026
5139
  const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']);
5027
5140
  const text = freeze(['#text']);
5028
5141
 
5029
- const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', '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', 'slot']);
5030
- 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', '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', '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-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', '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']);
5031
- const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', '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']);
5142
+ 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']);
5143
+ 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', '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', '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']);
5144
+ 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']);
5032
5145
  const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
5033
5146
 
5034
- // eslint-disable-next-line unicorn/better-regex
5035
- const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode
5036
- const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm);
5037
- const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex
5147
+ const MUSTACHE_EXPR = seal(/{{[\w\W]*|^[\w\W]*}}/g);
5148
+ const ERB_EXPR = seal(/<%[\w\W]*|^[\w\W]*%>/g);
5149
+ const TMPLIT_EXPR = seal(/\${[\w\W]*/g);
5038
5150
  const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape
5039
5151
  const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape
5040
5152
  const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape
@@ -5044,30 +5156,31 @@ const ATTR_WHITESPACE$1 = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u2
5044
5156
  );
5045
5157
  const DOCTYPE_NAME = seal(/^html$/i);
5046
5158
  const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i);
5159
+ // Markup-significant character probes used by _sanitizeElements.
5160
+ // Shared module-level instances are safe despite the sticky /g flags:
5161
+ // unapply() resets lastIndex for RegExp receivers before every call.
5162
+ const ELEMENT_MARKUP_PROBE = seal(/<[/\w!]/g);
5163
+ const COMMENT_MARKUP_PROBE = seal(/<[/\w]/g);
5164
+ const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i);
5165
+ const SELF_CLOSING_TAG = seal(/\/>/i);
5047
5166
 
5048
- var EXPRESSIONS = /*#__PURE__*/Object.freeze({
5049
- __proto__: null,
5050
- ARIA_ATTR: ARIA_ATTR,
5051
- ATTR_WHITESPACE: ATTR_WHITESPACE$1,
5052
- CUSTOM_ELEMENT: CUSTOM_ELEMENT,
5053
- DATA_ATTR: DATA_ATTR,
5054
- DOCTYPE_NAME: DOCTYPE_NAME,
5055
- ERB_EXPR: ERB_EXPR,
5056
- IS_ALLOWED_URI: IS_ALLOWED_URI,
5057
- IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA,
5058
- MUSTACHE_EXPR: MUSTACHE_EXPR,
5059
- TMPLIT_EXPR: TMPLIT_EXPR
5060
- });
5061
-
5062
- /* eslint-disable @typescript-eslint/indent */
5063
5167
  // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
5064
5168
  const NODE_TYPE = {
5065
5169
  element: 1,
5170
+ attribute: 2,
5066
5171
  text: 3,
5172
+ cdataSection: 4,
5173
+ entityReference: 5,
5174
+ // Deprecated
5175
+ entityNode: 6,
5067
5176
  // Deprecated
5068
- progressingInstruction: 7,
5177
+ processingInstruction: 7,
5069
5178
  comment: 8,
5070
- document: 9};
5179
+ document: 9,
5180
+ documentType: 10,
5181
+ documentFragment: 11,
5182
+ notation: 12 // Deprecated
5183
+ };
5071
5184
  const getGlobal = function getGlobal() {
5072
5185
  return typeof window === 'undefined' ? null : window;
5073
5186
  };
@@ -5122,10 +5235,25 @@ const _createHooksMap = function _createHooksMap() {
5122
5235
  uponSanitizeShadowNode: []
5123
5236
  };
5124
5237
  };
5238
+ /**
5239
+ * Resolve a set-valued configuration option: a fresh set built from
5240
+ * cfg[key] when it is an own array property (seeded with a clone of
5241
+ * options.base when given, case-normalized via options.transform),
5242
+ * the fallback set otherwise.
5243
+ *
5244
+ * @param cfg the cloned, prototype-free configuration object
5245
+ * @param key the configuration property to read
5246
+ * @param fallback the set to use when the option is absent or not an array
5247
+ * @param options transform and optional base set to merge into
5248
+ * @returns the resolved set
5249
+ */
5250
+ const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options) {
5251
+ return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key]) ? addToSet(options.base ? clone(options.base) : {}, cfg[key], options.transform) : fallback;
5252
+ };
5125
5253
  function createDOMPurify() {
5126
5254
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
5127
5255
  const DOMPurify = root => createDOMPurify(root);
5128
- DOMPurify.version = '3.3.0';
5256
+ DOMPurify.version = '3.4.13';
5129
5257
  DOMPurify.removed = [];
5130
5258
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
5131
5259
  // Not running in a browser, provide a factory function
@@ -5133,28 +5261,30 @@ function createDOMPurify() {
5133
5261
  DOMPurify.isSupported = false;
5134
5262
  return DOMPurify;
5135
5263
  }
5136
- let {
5137
- document
5138
- } = window;
5264
+ let document = window.document;
5139
5265
  const originalDocument = document;
5140
5266
  const currentScript = originalDocument.currentScript;
5141
- const {
5142
- DocumentFragment,
5143
- HTMLTemplateElement,
5144
- Node,
5145
- Element,
5146
- NodeFilter,
5147
- NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap,
5148
- HTMLFormElement,
5149
- DOMParser,
5150
- trustedTypes
5151
- } = window;
5267
+ window.DocumentFragment;
5268
+ const HTMLTemplateElement = window.HTMLTemplateElement,
5269
+ Node = window.Node,
5270
+ Element = window.Element,
5271
+ NodeFilter = window.NodeFilter,
5272
+ _window$NamedNodeMap = window.NamedNodeMap;
5273
+ _window$NamedNodeMap === void 0 ? window.NamedNodeMap || window.MozNamedAttrMap : _window$NamedNodeMap;
5274
+ window.HTMLFormElement;
5275
+ const DOMParser = window.DOMParser,
5276
+ trustedTypes = window.trustedTypes;
5152
5277
  const ElementPrototype = Element.prototype;
5153
5278
  const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
5154
5279
  const remove = lookupGetter(ElementPrototype, 'remove');
5155
5280
  const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
5156
5281
  const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
5157
5282
  const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
5283
+ const getShadowRoot = lookupGetter(ElementPrototype, 'shadowRoot');
5284
+ const getAttributes = lookupGetter(ElementPrototype, 'attributes');
5285
+ const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
5286
+ const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
5287
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
5158
5288
  // As per issue #47, the web-components registry is inherited by a
5159
5289
  // new document created via createHTMLDocument. As per the spec
5160
5290
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -5169,33 +5299,74 @@ function createDOMPurify() {
5169
5299
  }
5170
5300
  let trustedTypesPolicy;
5171
5301
  let emptyHTML = '';
5172
- const {
5173
- implementation,
5174
- createNodeIterator,
5175
- createDocumentFragment,
5176
- getElementsByTagName
5177
- } = document;
5178
- const {
5179
- importNode
5180
- } = originalDocument;
5302
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
5303
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
5304
+ // on duplicate policy names — and is the only policy allowed to persist
5305
+ // across configurations and survive `clearConfig()`.
5306
+ let defaultTrustedTypesPolicy;
5307
+ let defaultTrustedTypesPolicyResolved = false;
5308
+ // Tracks whether we are already inside a call to the configured Trusted Types
5309
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
5310
+ // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
5311
+ // re-enter the policy and recurse until the stack overflows. We detect that
5312
+ // re-entry and throw a clear, actionable error instead. The guard is shared
5313
+ // across both callbacks, because either one re-entering `sanitize` triggers
5314
+ // the same unbounded recursion.
5315
+ let IN_TRUSTED_TYPES_POLICY = 0;
5316
+ const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() {
5317
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
5318
+ 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.');
5319
+ }
5320
+ };
5321
+ const _createTrustedHTML = function _createTrustedHTML(html) {
5322
+ _assertNotInTrustedTypesPolicy();
5323
+ IN_TRUSTED_TYPES_POLICY++;
5324
+ try {
5325
+ return trustedTypesPolicy.createHTML(html);
5326
+ } finally {
5327
+ IN_TRUSTED_TYPES_POLICY--;
5328
+ }
5329
+ };
5330
+ const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) {
5331
+ _assertNotInTrustedTypesPolicy();
5332
+ IN_TRUSTED_TYPES_POLICY++;
5333
+ try {
5334
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
5335
+ } finally {
5336
+ IN_TRUSTED_TYPES_POLICY--;
5337
+ }
5338
+ };
5339
+ // Lazily resolve (and cache) the instance's internal default policy.
5340
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
5341
+ // repeated (Trusted Types throws on duplicate names), and a failed or
5342
+ // unsupported attempt must not be retried on every parse.
5343
+ const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() {
5344
+ if (!defaultTrustedTypesPolicyResolved) {
5345
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
5346
+ defaultTrustedTypesPolicyResolved = true;
5347
+ }
5348
+ return defaultTrustedTypesPolicy;
5349
+ };
5350
+ const _document = document,
5351
+ implementation = _document.implementation,
5352
+ createNodeIterator = _document.createNodeIterator,
5353
+ createDocumentFragment = _document.createDocumentFragment,
5354
+ getElementsByTagName = _document.getElementsByTagName;
5355
+ const importNode = originalDocument.importNode;
5181
5356
  let hooks = _createHooksMap();
5182
5357
  /**
5183
5358
  * Expose whether this browser supports running the full DOMPurify.
5184
5359
  */
5185
5360
  DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined;
5186
- const {
5187
- MUSTACHE_EXPR,
5188
- ERB_EXPR,
5189
- TMPLIT_EXPR,
5190
- DATA_ATTR,
5191
- ARIA_ATTR,
5192
- IS_SCRIPT_OR_DATA,
5193
- ATTR_WHITESPACE,
5194
- CUSTOM_ELEMENT
5195
- } = EXPRESSIONS;
5196
- let {
5197
- IS_ALLOWED_URI: IS_ALLOWED_URI$1
5198
- } = EXPRESSIONS;
5361
+ const MUSTACHE_EXPR$1 = MUSTACHE_EXPR,
5362
+ ERB_EXPR$1 = ERB_EXPR,
5363
+ TMPLIT_EXPR$1 = TMPLIT_EXPR,
5364
+ DATA_ATTR$1 = DATA_ATTR,
5365
+ ARIA_ATTR$1 = ARIA_ATTR,
5366
+ IS_SCRIPT_OR_DATA$1 = IS_SCRIPT_OR_DATA,
5367
+ ATTR_WHITESPACE$1$1 = ATTR_WHITESPACE$1,
5368
+ CUSTOM_ELEMENT$1 = CUSTOM_ELEMENT;
5369
+ let IS_ALLOWED_URI$1 = IS_ALLOWED_URI;
5199
5370
  /**
5200
5371
  * We consider the elements and attributes below to be safe. Ideally
5201
5372
  * don't add any new ones but feel free to remove unwanted ones.
@@ -5272,6 +5443,13 @@ function createDOMPurify() {
5272
5443
  let WHOLE_DOCUMENT = false;
5273
5444
  /* Track whether config is already set on this instance of DOMPurify. */
5274
5445
  let SET_CONFIG = false;
5446
+ /* Pristine allowlist bindings captured at setConfig() time. On the
5447
+ * persistent-config path sanitize() restores the sets from these before
5448
+ * the per-walk hook clone-guard, so a hook's in-call widening cannot
5449
+ * carry across calls. Null until setConfig() is called; reset by
5450
+ * clearConfig(). */
5451
+ let SET_CONFIG_ALLOWED_TAGS = null;
5452
+ let SET_CONFIG_ALLOWED_ATTR = null;
5275
5453
  /* Decide if all elements (e.g. style, script) must be children of
5276
5454
  * document.body. By default, browsers might move them to document.head */
5277
5455
  let FORCE_BODY = false;
@@ -5314,7 +5492,17 @@ function createDOMPurify() {
5314
5492
  let USE_PROFILES = {};
5315
5493
  /* Tags to ignore content of when KEEP_CONTENT is true */
5316
5494
  let FORBID_CONTENTS = null;
5317
- const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
5495
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script',
5496
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
5497
+ // the UA (customizable <select>) — including any on* handlers — and the
5498
+ // engine re-mirrors synchronously whenever a removal changes which
5499
+ // option/selectedcontent is current, even inside DOMPurify's inert
5500
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
5501
+ // mirror target ahead of the walk, which the engine refills, looping
5502
+ // forever (DoS) and amplifying output. Dropping its content on removal
5503
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
5504
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
5505
+ 'selectedcontent', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
5318
5506
  /* Tags that are safe for data: URIs */
5319
5507
  let DATA_URI_TAGS = null;
5320
5508
  const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
@@ -5330,8 +5518,10 @@ function createDOMPurify() {
5330
5518
  /* Allowed XHTML+XML namespaces */
5331
5519
  let ALLOWED_NAMESPACES = null;
5332
5520
  const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString);
5333
- let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']);
5334
- let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']);
5521
+ const DEFAULT_MATHML_TEXT_INTEGRATION_POINTS = freeze(['mi', 'mo', 'mn', 'ms', 'mtext']);
5522
+ let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS);
5523
+ const DEFAULT_HTML_INTEGRATION_POINTS = freeze(['annotation-xml']);
5524
+ let HTML_INTEGRATION_POINTS = addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS);
5335
5525
  // Certain elements are allowed in both SVG and HTML
5336
5526
  // namespace. We need to specify them explicitly
5337
5527
  // so that they don't get erroneously deleted from
@@ -5373,15 +5563,33 @@ function createDOMPurify() {
5373
5563
  // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is.
5374
5564
  transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase;
5375
5565
  /* Set configuration parameters */
5376
- ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS;
5377
- ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR;
5378
- ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES;
5379
- URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES;
5380
- DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS;
5381
- FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS;
5382
- FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : clone({});
5383
- FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : clone({});
5384
- USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false;
5566
+ ALLOWED_TAGS = _resolveSetOption(cfg, 'ALLOWED_TAGS', DEFAULT_ALLOWED_TAGS, {
5567
+ transform: transformCaseFunc
5568
+ });
5569
+ ALLOWED_ATTR = _resolveSetOption(cfg, 'ALLOWED_ATTR', DEFAULT_ALLOWED_ATTR, {
5570
+ transform: transformCaseFunc
5571
+ });
5572
+ ALLOWED_NAMESPACES = _resolveSetOption(cfg, 'ALLOWED_NAMESPACES', DEFAULT_ALLOWED_NAMESPACES, {
5573
+ transform: stringToString
5574
+ });
5575
+ URI_SAFE_ATTRIBUTES = _resolveSetOption(cfg, 'ADD_URI_SAFE_ATTR', DEFAULT_URI_SAFE_ATTRIBUTES, {
5576
+ transform: transformCaseFunc,
5577
+ base: DEFAULT_URI_SAFE_ATTRIBUTES
5578
+ });
5579
+ DATA_URI_TAGS = _resolveSetOption(cfg, 'ADD_DATA_URI_TAGS', DEFAULT_DATA_URI_TAGS, {
5580
+ transform: transformCaseFunc,
5581
+ base: DEFAULT_DATA_URI_TAGS
5582
+ });
5583
+ FORBID_CONTENTS = _resolveSetOption(cfg, 'FORBID_CONTENTS', DEFAULT_FORBID_CONTENTS, {
5584
+ transform: transformCaseFunc
5585
+ });
5586
+ FORBID_TAGS = _resolveSetOption(cfg, 'FORBID_TAGS', clone({}), {
5587
+ transform: transformCaseFunc
5588
+ });
5589
+ FORBID_ATTR = _resolveSetOption(cfg, 'FORBID_ATTR', clone({}), {
5590
+ transform: transformCaseFunc
5591
+ });
5592
+ USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES && typeof cfg.USE_PROFILES === 'object' ? clone(cfg.USE_PROFILES) : cfg.USE_PROFILES : false;
5385
5593
  ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true
5386
5594
  ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true
5387
5595
  ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false
@@ -5397,20 +5605,22 @@ function createDOMPurify() {
5397
5605
  SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false
5398
5606
  KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true
5399
5607
  IN_PLACE = cfg.IN_PLACE || false; // Default false
5400
- IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI;
5401
- NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE;
5402
- MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS;
5403
- HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS;
5404
- CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};
5405
- if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) {
5406
- CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck;
5407
- }
5408
- if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) {
5409
- CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck;
5410
- }
5411
- if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') {
5412
- CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements;
5413
- }
5608
+ IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; // Default regexp
5609
+ NAMESPACE = typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
5610
+ MATHML_TEXT_INTEGRATION_POINTS = objectHasOwnProperty(cfg, 'MATHML_TEXT_INTEGRATION_POINTS') && cfg.MATHML_TEXT_INTEGRATION_POINTS && typeof cfg.MATHML_TEXT_INTEGRATION_POINTS === 'object' ? clone(cfg.MATHML_TEXT_INTEGRATION_POINTS) : addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); // Default built-in map
5611
+ HTML_INTEGRATION_POINTS = objectHasOwnProperty(cfg, 'HTML_INTEGRATION_POINTS') && cfg.HTML_INTEGRATION_POINTS && typeof cfg.HTML_INTEGRATION_POINTS === 'object' ? clone(cfg.HTML_INTEGRATION_POINTS) : addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); // Default built-in map
5612
+ const customElementHandling = objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') && cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object' ? clone(cfg.CUSTOM_ELEMENT_HANDLING) : create(null);
5613
+ CUSTOM_ELEMENT_HANDLING = create(null);
5614
+ if (objectHasOwnProperty(customElementHandling, 'tagNameCheck') && isRegexOrFunction(customElementHandling.tagNameCheck)) {
5615
+ CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; // Default undefined
5616
+ }
5617
+ if (objectHasOwnProperty(customElementHandling, 'attributeNameCheck') && isRegexOrFunction(customElementHandling.attributeNameCheck)) {
5618
+ CUSTOM_ELEMENT_HANDLING.attributeNameCheck = customElementHandling.attributeNameCheck; // Default undefined
5619
+ }
5620
+ if (objectHasOwnProperty(customElementHandling, 'allowCustomizedBuiltInElements') && typeof customElementHandling.allowCustomizedBuiltInElements === 'boolean') {
5621
+ CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = customElementHandling.allowCustomizedBuiltInElements; // Default undefined
5622
+ }
5623
+ seal(CUSTOM_ELEMENT_HANDLING);
5414
5624
  if (SAFE_FOR_TEMPLATES) {
5415
5625
  ALLOW_DATA_ATTR = false;
5416
5626
  }
@@ -5420,7 +5630,7 @@ function createDOMPurify() {
5420
5630
  /* Parse profile info */
5421
5631
  if (USE_PROFILES) {
5422
5632
  ALLOWED_TAGS = addToSet({}, text);
5423
- ALLOWED_ATTR = [];
5633
+ ALLOWED_ATTR = create(null);
5424
5634
  if (USE_PROFILES.html === true) {
5425
5635
  addToSet(ALLOWED_TAGS, html$1);
5426
5636
  addToSet(ALLOWED_ATTR, html);
@@ -5441,36 +5651,46 @@ function createDOMPurify() {
5441
5651
  addToSet(ALLOWED_ATTR, xml);
5442
5652
  }
5443
5653
  }
5654
+ /* Always reset function-based ADD_TAGS / ADD_ATTR checks to prevent
5655
+ * leaking across calls when switching from function to array config */
5656
+ EXTRA_ELEMENT_HANDLING.tagCheck = null;
5657
+ EXTRA_ELEMENT_HANDLING.attributeCheck = null;
5444
5658
  /* Merge configuration parameters */
5445
- if (cfg.ADD_TAGS) {
5659
+ if (objectHasOwnProperty(cfg, 'ADD_TAGS')) {
5446
5660
  if (typeof cfg.ADD_TAGS === 'function') {
5447
5661
  EXTRA_ELEMENT_HANDLING.tagCheck = cfg.ADD_TAGS;
5448
- } else {
5662
+ } else if (arrayIsArray(cfg.ADD_TAGS)) {
5449
5663
  if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
5450
5664
  ALLOWED_TAGS = clone(ALLOWED_TAGS);
5451
5665
  }
5452
5666
  addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);
5453
5667
  }
5454
5668
  }
5455
- if (cfg.ADD_ATTR) {
5669
+ if (objectHasOwnProperty(cfg, 'ADD_ATTR')) {
5456
5670
  if (typeof cfg.ADD_ATTR === 'function') {
5457
5671
  EXTRA_ELEMENT_HANDLING.attributeCheck = cfg.ADD_ATTR;
5458
- } else {
5672
+ } else if (arrayIsArray(cfg.ADD_ATTR)) {
5459
5673
  if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {
5460
5674
  ALLOWED_ATTR = clone(ALLOWED_ATTR);
5461
5675
  }
5462
5676
  addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
5463
5677
  }
5464
5678
  }
5465
- if (cfg.ADD_URI_SAFE_ATTR) {
5679
+ if (objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') && arrayIsArray(cfg.ADD_URI_SAFE_ATTR)) {
5466
5680
  addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
5467
5681
  }
5468
- if (cfg.FORBID_CONTENTS) {
5682
+ if (objectHasOwnProperty(cfg, 'FORBID_CONTENTS') && arrayIsArray(cfg.FORBID_CONTENTS)) {
5469
5683
  if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
5470
5684
  FORBID_CONTENTS = clone(FORBID_CONTENTS);
5471
5685
  }
5472
5686
  addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
5473
5687
  }
5688
+ if (objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) {
5689
+ if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
5690
+ FORBID_CONTENTS = clone(FORBID_CONTENTS);
5691
+ }
5692
+ addToSet(FORBID_CONTENTS, cfg.ADD_FORBID_CONTENTS, transformCaseFunc);
5693
+ }
5474
5694
  /* Add #text in case KEEP_CONTENT is set to true */
5475
5695
  if (KEEP_CONTENT) {
5476
5696
  ALLOWED_TAGS['#text'] = true;
@@ -5484,6 +5704,13 @@ function createDOMPurify() {
5484
5704
  addToSet(ALLOWED_TAGS, ['tbody']);
5485
5705
  delete FORBID_TAGS.tbody;
5486
5706
  }
5707
+ // Re-derive the active Trusted Types policy from this configuration on
5708
+ // every parse. The active policy must never be sticky closure state that
5709
+ // outlives the config that set it: a caller-supplied policy left in place
5710
+ // after `clearConfig()` — or after a later call that supplied none, or
5711
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
5712
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
5713
+ // See GHSA-vxr8-fq34-vvx9.
5487
5714
  if (cfg.TRUSTED_TYPES_POLICY) {
5488
5715
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
5489
5716
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');
@@ -5491,18 +5718,45 @@ function createDOMPurify() {
5491
5718
  if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {
5492
5719
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');
5493
5720
  }
5494
- // Overwrite existing TrustedTypes policy.
5721
+ // A caller-supplied policy applies to this configuration only.
5722
+ const previousTrustedTypesPolicy = trustedTypesPolicy;
5495
5723
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
5496
- // Sign local variables required by `sanitize`.
5497
- emptyHTML = trustedTypesPolicy.createHTML('');
5724
+ // Sign local variables required by `sanitize`. If the supplied policy's
5725
+ // `createHTML` is circular (i.e. it calls `DOMPurify.sanitize`), this
5726
+ // throws via the re-entrancy guard. Restore the previous policy first so
5727
+ // the instance is not left in a poisoned state. See #1422.
5728
+ try {
5729
+ emptyHTML = _createTrustedHTML('');
5730
+ } catch (error) {
5731
+ trustedTypesPolicy = previousTrustedTypesPolicy;
5732
+ throw error;
5733
+ }
5734
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
5735
+ // Explicit opt-out for this call: perform no Trusted Types signing and
5736
+ // create nothing (so a strict `trusted-types` CSP that disallows a
5737
+ // `dompurify` policy can still call `sanitize` from inside its own
5738
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
5739
+ // `null` also drops any previously retained caller policy, so it cannot
5740
+ // resurface on a later call, while still allowing the next config-less
5741
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
5742
+ trustedTypesPolicy = undefined;
5743
+ emptyHTML = '';
5498
5744
  } else {
5499
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
5745
+ // No policy supplied: keep the currently active policy if one is set — a
5746
+ // previously supplied policy is intentionally sticky across config-less
5747
+ // calls — otherwise fall back to the instance's own internal policy,
5748
+ // created at most once. (A policy supplied for a *single* call still
5749
+ // lingers by design; what must not linger is a policy whose configuration
5750
+ // has been torn down via `clearConfig()`, which restores the default.)
5500
5751
  if (trustedTypesPolicy === undefined) {
5501
- trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
5752
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
5502
5753
  }
5503
- // If creating the internal policy succeeded sign internal variables.
5504
- if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {
5505
- emptyHTML = trustedTypesPolicy.createHTML('');
5754
+ // Sign internal variables only when a policy is active. A falsy policy
5755
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
5756
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
5757
+ // a non-policy and throw. See #1422.
5758
+ if (trustedTypesPolicy && typeof emptyHTML === 'string') {
5759
+ emptyHTML = _createTrustedHTML('');
5506
5760
  }
5507
5761
  }
5508
5762
  // Prevent further manipulation of configuration.
@@ -5517,6 +5771,77 @@ function createDOMPurify() {
5517
5771
  * correctly. */
5518
5772
  const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]);
5519
5773
  const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]);
5774
+ /**
5775
+ * Namespace rules for an element in the SVG namespace.
5776
+ *
5777
+ * @param tagName the element's lowercase tag name
5778
+ * @param parent the (possibly simulated) parent node
5779
+ * @param parentTagName the parent's lowercase tag name
5780
+ * @returns true if a spec-compliant parser could produce this element
5781
+ */
5782
+ const _checkSvgNamespace = function _checkSvgNamespace(tagName, parent, parentTagName) {
5783
+ // The only way to switch from HTML namespace to SVG
5784
+ // is via <svg>. If it happens via any other tag, then
5785
+ // it should be killed.
5786
+ if (parent.namespaceURI === HTML_NAMESPACE) {
5787
+ return tagName === 'svg';
5788
+ }
5789
+ // The only way to switch from MathML to SVG is via <svg>
5790
+ // if the parent is either <annotation-xml> or a MathML
5791
+ // text integration point.
5792
+ if (parent.namespaceURI === MATHML_NAMESPACE) {
5793
+ return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]);
5794
+ }
5795
+ // We only allow elements that are defined in SVG
5796
+ // spec. All others are disallowed in SVG namespace.
5797
+ return Boolean(ALL_SVG_TAGS[tagName]);
5798
+ };
5799
+ /**
5800
+ * Namespace rules for an element in the MathML namespace.
5801
+ *
5802
+ * @param tagName the element's lowercase tag name
5803
+ * @param parent the (possibly simulated) parent node
5804
+ * @param parentTagName the parent's lowercase tag name
5805
+ * @returns true if a spec-compliant parser could produce this element
5806
+ */
5807
+ const _checkMathMlNamespace = function _checkMathMlNamespace(tagName, parent, parentTagName) {
5808
+ // The only way to switch from HTML namespace to MathML
5809
+ // is via <math>. If it happens via any other tag, then
5810
+ // it should be killed.
5811
+ if (parent.namespaceURI === HTML_NAMESPACE) {
5812
+ return tagName === 'math';
5813
+ }
5814
+ // The only way to switch from SVG to MathML is via
5815
+ // <math> and HTML integration points
5816
+ if (parent.namespaceURI === SVG_NAMESPACE) {
5817
+ return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];
5818
+ }
5819
+ // We only allow elements that are defined in MathML
5820
+ // spec. All others are disallowed in MathML namespace.
5821
+ return Boolean(ALL_MATHML_TAGS[tagName]);
5822
+ };
5823
+ /**
5824
+ * Namespace rules for an element in the HTML namespace.
5825
+ *
5826
+ * @param tagName the element's lowercase tag name
5827
+ * @param parent the (possibly simulated) parent node
5828
+ * @param parentTagName the parent's lowercase tag name
5829
+ * @returns true if a spec-compliant parser could produce this element
5830
+ */
5831
+ const _checkHtmlNamespace = function _checkHtmlNamespace(tagName, parent, parentTagName) {
5832
+ // The only way to switch from SVG to HTML is via
5833
+ // HTML integration points, and from MathML to HTML
5834
+ // is via MathML text integration points
5835
+ if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) {
5836
+ return false;
5837
+ }
5838
+ if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) {
5839
+ return false;
5840
+ }
5841
+ // We disallow tags that are specific for MathML
5842
+ // or SVG and should never appear in HTML namespace
5843
+ return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]);
5844
+ };
5520
5845
  /**
5521
5846
  * @param element a DOM element whose namespace is being checked
5522
5847
  * @returns Return false if the element has a
@@ -5539,51 +5864,13 @@ function createDOMPurify() {
5539
5864
  return false;
5540
5865
  }
5541
5866
  if (element.namespaceURI === SVG_NAMESPACE) {
5542
- // The only way to switch from HTML namespace to SVG
5543
- // is via <svg>. If it happens via any other tag, then
5544
- // it should be killed.
5545
- if (parent.namespaceURI === HTML_NAMESPACE) {
5546
- return tagName === 'svg';
5547
- }
5548
- // The only way to switch from MathML to SVG is via`
5549
- // svg if parent is either <annotation-xml> or MathML
5550
- // text integration points.
5551
- if (parent.namespaceURI === MATHML_NAMESPACE) {
5552
- return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]);
5553
- }
5554
- // We only allow elements that are defined in SVG
5555
- // spec. All others are disallowed in SVG namespace.
5556
- return Boolean(ALL_SVG_TAGS[tagName]);
5867
+ return _checkSvgNamespace(tagName, parent, parentTagName);
5557
5868
  }
5558
5869
  if (element.namespaceURI === MATHML_NAMESPACE) {
5559
- // The only way to switch from HTML namespace to MathML
5560
- // is via <math>. If it happens via any other tag, then
5561
- // it should be killed.
5562
- if (parent.namespaceURI === HTML_NAMESPACE) {
5563
- return tagName === 'math';
5564
- }
5565
- // The only way to switch from SVG to MathML is via
5566
- // <math> and HTML integration points
5567
- if (parent.namespaceURI === SVG_NAMESPACE) {
5568
- return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];
5569
- }
5570
- // We only allow elements that are defined in MathML
5571
- // spec. All others are disallowed in MathML namespace.
5572
- return Boolean(ALL_MATHML_TAGS[tagName]);
5870
+ return _checkMathMlNamespace(tagName, parent, parentTagName);
5573
5871
  }
5574
5872
  if (element.namespaceURI === HTML_NAMESPACE) {
5575
- // The only way to switch from SVG to HTML is via
5576
- // HTML integration points, and from MathML to HTML
5577
- // is via MathML text integration points
5578
- if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) {
5579
- return false;
5580
- }
5581
- if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) {
5582
- return false;
5583
- }
5584
- // We disallow tags that are specific for MathML
5585
- // or SVG and should never appear in HTML namespace
5586
- return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]);
5873
+ return _checkHtmlNamespace(tagName, parent, parentTagName);
5587
5874
  }
5588
5875
  // For XHTML and XML documents that support custom namespaces
5589
5876
  if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) {
@@ -5608,7 +5895,81 @@ function createDOMPurify() {
5608
5895
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
5609
5896
  getParentNode(node).removeChild(node);
5610
5897
  } catch (_) {
5898
+ /* The normal detach failed — this is reached for a parentless node
5899
+ (getParentNode() is null, so .removeChild throws). Element.prototype
5900
+ .remove() is itself a spec no-op on a parentless node, so a recorded
5901
+ "removal" would otherwise hand the caller back an intact,
5902
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
5903
+ the style-with-element-child rule decided to kill). Fail closed by
5904
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
5905
+ rather than trying to "neutralize" the node via its own methods.
5906
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
5907
+ on the node, both of which a <form> root can clobber via a named child
5908
+ (and _isClobbered does not even probe getAttributeNames), so the
5909
+ neutralize step could itself be silently defeated, leaving the payload
5910
+ intact. A throw touches only the cached, clobber-safe remove() and
5911
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
5912
+ to every root-kill reason. REPORT-3.
5913
+ This lives inside the catch, so it never fires for a normally-removed
5914
+ in-tree node: those have a parent, removeChild() succeeds, and the
5915
+ catch is not entered. Only a kept (parentless) root reaches here. */
5611
5916
  remove(node);
5917
+ if (!getParentNode(node)) {
5918
+ throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');
5919
+ }
5920
+ }
5921
+ };
5922
+ /**
5923
+ * _neutralizeRoot
5924
+ *
5925
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
5926
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
5927
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
5928
+ * parentless guard throws, or any other re-entrant engine mutation — would
5929
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
5930
+ * after the abort point still carrying its handlers. There is no safe way
5931
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
5932
+ * remove every child and every attribute, then let the caller's catch see
5933
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
5934
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
5935
+ *
5936
+ * @param root the in-place root to empty
5937
+ */
5938
+ const _neutralizeRoot = function _neutralizeRoot(root) {
5939
+ /* Strip every disallowed attribute (on* handlers included) off the whole
5940
+ subtree BEFORE detaching anything. Detaching first would hand back
5941
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
5942
+ whose queued resource event still fires in page scope after we throw.
5943
+ Clobber-safe reads; a doomed clobbered node's own attributes are
5944
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
5945
+ _neutralizeSubtree(root);
5946
+ const childNodes = getChildNodes(root);
5947
+ if (childNodes) {
5948
+ const snapshot = [];
5949
+ arrayForEach(childNodes, child => {
5950
+ arrayPush(snapshot, child);
5951
+ });
5952
+ arrayForEach(snapshot, child => {
5953
+ try {
5954
+ remove(child);
5955
+ } catch (_) {
5956
+ /* Best-effort teardown; a still-attached child is handled below */
5957
+ }
5958
+ });
5959
+ }
5960
+ const attributes = getAttributes(root);
5961
+ if (attributes) {
5962
+ for (let i = attributes.length - 1; i >= 0; --i) {
5963
+ const attribute = attributes[i];
5964
+ const name = attribute && attribute.name;
5965
+ if (typeof name === 'string') {
5966
+ try {
5967
+ root.removeAttribute(name);
5968
+ } catch (_) {
5969
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
5970
+ }
5971
+ }
5972
+ }
5612
5973
  }
5613
5974
  };
5614
5975
  /**
@@ -5643,6 +6004,148 @@ function createDOMPurify() {
5643
6004
  }
5644
6005
  }
5645
6006
  };
6007
+ /**
6008
+ * _stripDisallowedAttributes
6009
+ *
6010
+ * Removes every attribute the active configuration does not allow from a
6011
+ * single element, using the same allowlist as the main attribute pass (so
6012
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
6013
+ * neutralise nodes that are being discarded from an in-place tree.
6014
+ *
6015
+ * @param element the element to strip
6016
+ */
6017
+ const _stripDisallowedAttributes = function _stripDisallowedAttributes(element) {
6018
+ const attributes = getAttributes(element);
6019
+ if (!attributes) {
6020
+ return;
6021
+ }
6022
+ for (let i = attributes.length - 1; i >= 0; --i) {
6023
+ const attribute = attributes[i];
6024
+ const name = attribute && attribute.name;
6025
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
6026
+ continue;
6027
+ }
6028
+ try {
6029
+ element.removeAttribute(name);
6030
+ } catch (_) {
6031
+ /* Clobbered removeAttribute on a doomed node — ignore */
6032
+ }
6033
+ }
6034
+ };
6035
+ /**
6036
+ * _neutralizeSubtree
6037
+ *
6038
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
6039
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
6040
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
6041
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
6042
+ * those dropped nodes are detached from the caller's LIVE tree but a
6043
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
6044
+ * loading) keeps its queued resource event, which fires in page scope after
6045
+ * sanitize returns. This walks a removed subtree and strips every attribute
6046
+ * the active configuration does not allow — so `on*` handlers are cancelled
6047
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
6048
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
6049
+ * queued event can fire. Hook-free by design: these nodes leave the output,
6050
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
6051
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
6052
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
6053
+ * the `<img>`, are reached and scrubbed).
6054
+ *
6055
+ * @param root the root of a removed subtree to neutralise
6056
+ */
6057
+ const _neutralizeSubtree = function _neutralizeSubtree(root) {
6058
+ const stack = [root];
6059
+ while (stack.length > 0) {
6060
+ const node = stack.pop();
6061
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
6062
+ if (nodeType === NODE_TYPE.element) {
6063
+ _stripDisallowedAttributes(node);
6064
+ }
6065
+ const childNodes = getChildNodes(node);
6066
+ if (childNodes) {
6067
+ for (let i = childNodes.length - 1; i >= 0; --i) {
6068
+ stack.push(childNodes[i]);
6069
+ }
6070
+ }
6071
+ }
6072
+ };
6073
+ /**
6074
+ * _neutralizePatchLinkage
6075
+ *
6076
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
6077
+ * hardening, https://github.com/WICG/declarative-partial-updates).
6078
+ *
6079
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
6080
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
6081
+ * reaches each node. On a live in-place root that leaves a window: from the
6082
+ * moment the root is connected until the walk arrives at a given node, that
6083
+ * node's linkage is live. A patch applied on connection/stream can fire as
6084
+ * a microtask during the walk and inject or teleport an unsanitized DOM
6085
+ * range into a region the iterator has already passed and will not revisit,
6086
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
6087
+ * whole tree once up front and sever every linkage before the walk begins,
6088
+ * closing that window.
6089
+ *
6090
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
6091
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
6092
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
6093
+ * to group with the rest of the declarative-partial-updates handling and
6094
+ * stay overridable, consistent with the codebase.
6095
+ *
6096
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
6097
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
6098
+ *
6099
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
6100
+ * .html Q1): this mirrors the existing policy of keeping `for` on
6101
+ * <label>/<output>. If the shipping feature can drive a patch through a
6102
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
6103
+ * attribute check at _isBasicCustomElement's caller must additionally drop
6104
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
6105
+ *
6106
+ * @param root the in-place root to sweep
6107
+ */
6108
+ const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
6109
+ if (!SAFE_FOR_XML) {
6110
+ return;
6111
+ }
6112
+ const stack = [root];
6113
+ while (stack.length > 0) {
6114
+ const node = stack.pop();
6115
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
6116
+ /* Remove range markers (the target side of a patch linkage): every
6117
+ processing instruction, and any markup-bearing comment. */
6118
+ if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
6119
+ try {
6120
+ remove(node);
6121
+ } catch (_) {
6122
+ /* Best-effort */
6123
+ }
6124
+ continue;
6125
+ }
6126
+ /* Strip patch-source attributes (the source side) off elements. */
6127
+ if (nodeType === NODE_TYPE.element) {
6128
+ const element = node;
6129
+ const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
6130
+ try {
6131
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
6132
+ element.removeAttribute('patchsrc');
6133
+ }
6134
+ if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
6135
+ element.removeAttribute('for');
6136
+ }
6137
+ } catch (_) {
6138
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
6139
+ }
6140
+ }
6141
+ const childNodes = getChildNodes(node);
6142
+ if (childNodes) {
6143
+ for (let i = childNodes.length - 1; i >= 0; --i) {
6144
+ stack.push(childNodes[i]);
6145
+ }
6146
+ }
6147
+ }
6148
+ };
5646
6149
  /**
5647
6150
  * _initDocument
5648
6151
  *
@@ -5664,7 +6167,7 @@ function createDOMPurify() {
5664
6167
  // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict)
5665
6168
  dirty = '<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>' + dirty + '</body></html>';
5666
6169
  }
5667
- const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty;
6170
+ const dirtyPayload = trustedTypesPolicy ? _createTrustedHTML(dirty) : dirty;
5668
6171
  /*
5669
6172
  * Use the DOMParser API by default, fallback later if needs be
5670
6173
  * DOMParser not work for svg when has multiple root element.
@@ -5700,33 +6203,297 @@ function createDOMPurify() {
5700
6203
  * @return The created NodeIterator
5701
6204
  */
5702
6205
  const _createNodeIterator = function _createNodeIterator(root) {
5703
- return createNodeIterator.call(root.ownerDocument || root, root,
6206
+ /* Read ownerDocument through the cached Node.prototype getter, never the
6207
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
6208
+ clobbering child (<input name="ownerDocument"> or a form-associated
6209
+ external input) shadows the prototype getter and makes a direct read
6210
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
6211
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
6212
+ the walk's fail-closed barrier - leaving the caller's live tree, with
6213
+ any already-armed handler in it, un-neutralized. The cached getter
6214
+ returns the real Document regardless of the clobber. */
6215
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
6216
+ return createNodeIterator.call(doc || root, root,
5704
6217
  // eslint-disable-next-line no-bitwise
5705
6218
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);
5706
6219
  };
6220
+ /**
6221
+ * Replace template expression syntax (mustache, ERB, template
6222
+ * literal) with a space; shared by all SAFE_FOR_TEMPLATES scrub
6223
+ * sites. Order matters: mustache, then ERB, then template literal.
6224
+ *
6225
+ * @param value the string to scrub
6226
+ * @returns the scrubbed string
6227
+ */
6228
+ const _stripTemplateExpressions = function _stripTemplateExpressions(value) {
6229
+ value = stringReplace(value, MUSTACHE_EXPR$1, ' ');
6230
+ value = stringReplace(value, ERB_EXPR$1, ' ');
6231
+ value = stringReplace(value, TMPLIT_EXPR$1, ' ');
6232
+ return value;
6233
+ };
6234
+ /**
6235
+ * Strip template-engine expressions ({{...}}, ${...}, <%...%>) from the
6236
+ * character data of an element subtree. Used as the final safety net for
6237
+ * SAFE_FOR_TEMPLATES on every DOM-returning code path so that expressions
6238
+ * which only form after text-node normalization (e.g. fragments split across
6239
+ * stripped elements) cannot survive into a template-evaluating framework.
6240
+ *
6241
+ * Walks text/comment/CDATA/processing-instruction nodes and mutates `.data`
6242
+ * in place rather than round-tripping through innerHTML. This preserves
6243
+ * descendant node references (important for IN_PLACE callers), avoids a
6244
+ * serialize/reparse cycle, and reads literal character data — which means
6245
+ * `<%...%>` in text content matches the ERB regex against its real bytes
6246
+ * instead of the HTML-entity-escaped form innerHTML would produce.
6247
+ *
6248
+ * Attribute values are not visited here; SAFE_FOR_TEMPLATES handling for
6249
+ * attributes is performed during the per-node `_sanitizeAttributes` pass.
6250
+ *
6251
+ * @param node The root element whose character data should be scrubbed.
6252
+ */
6253
+ const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
6254
+ var _node$querySelectorAl;
6255
+ node.normalize();
6256
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
6257
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
6258
+ carry a form-named-getter override of ownerDocument. */
6259
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
6260
+ const walker = createNodeIterator.call(doc || node, node,
6261
+ // eslint-disable-next-line no-bitwise
6262
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
6263
+ let currentNode = walker.nextNode();
6264
+ while (currentNode) {
6265
+ currentNode.data = _stripTemplateExpressions(currentNode.data);
6266
+ currentNode = walker.nextNode();
6267
+ }
6268
+ // NodeIterator does not descend into <template>.content per the DOM spec,
6269
+ // so we must explicitly recurse into each template's content fragment,
6270
+ // mirroring the approach used by _sanitizeShadowDOM.
6271
+ const templates = (_node$querySelectorAl = node.querySelectorAll) === null || _node$querySelectorAl === void 0 ? void 0 : _node$querySelectorAl.call(node, 'template');
6272
+ if (templates) {
6273
+ arrayForEach(templates, tmpl => {
6274
+ if (_isDocumentFragment(tmpl.content)) {
6275
+ _scrubTemplateExpressions2(tmpl.content);
6276
+ }
6277
+ });
6278
+ }
6279
+ };
5707
6280
  /**
5708
6281
  * _isClobbered
5709
6282
  *
6283
+ * Detect DOM-clobbering on HTMLFormElement nodes. Form is the only HTML
6284
+ * interface with [LegacyOverrideBuiltIns]; a descendant element with a
6285
+ * `name` attribute matching a prototype property shadows that property
6286
+ * on direct reads. We use this check at the IN_PLACE entry-point and
6287
+ * during attribute sanitization to refuse clobbered forms.
6288
+ *
5710
6289
  * @param element element to check for clobbering attacks
5711
6290
  * @return true if clobbered, false if safe
5712
6291
  */
5713
6292
  const _isClobbered = function _isClobbered(element) {
5714
- return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function');
6293
+ // Realm-independent tag-name probe. If we can't determine the tag
6294
+ // name at all, we can't reason about clobbering — return false
6295
+ // (the caller's other defences still apply).
6296
+ const realTagName = getNodeName ? getNodeName(element) : null;
6297
+ if (typeof realTagName !== 'string') {
6298
+ return false;
6299
+ }
6300
+ if (transformCaseFunc(realTagName) !== 'form') {
6301
+ return false;
6302
+ }
6303
+ return typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' ||
6304
+ // Realm-safe NamedNodeMap detection: equality against the cached
6305
+ // prototype getter. Clobbered .attributes (e.g. <input name="attributes">)
6306
+ // makes the direct read diverge from the cached read; a clean form
6307
+ // (same-realm OR foreign-realm) has both reads pointing at the same
6308
+ // canonical NamedNodeMap.
6309
+ element.attributes !== getAttributes(element) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' ||
6310
+ // NodeType clobbering probe. Cached Node.prototype.nodeType getter
6311
+ // returns the integer 1 for any Element regardless of realm; direct
6312
+ // read on a clobbered form (e.g. <input name="nodeType">) returns
6313
+ // the named child element. Cheap addition — nodeType is read from
6314
+ // an internal slot, no serialization cost — and removes a residual
6315
+ // clobbering surface used by several mXSS / PI / comment branches
6316
+ // in _sanitizeElements that compare currentNode.nodeType directly.
6317
+ element.nodeType !== getNodeType(element) ||
6318
+ // HTMLFormElement has [LegacyOverrideBuiltIns]: a descendant named
6319
+ // "childNodes" shadows the prototype getter. Direct reads of
6320
+ // form.childNodes from a clobbered form return the named child
6321
+ // instead of the real NodeList, so any walk that reads it directly
6322
+ // skips the form's real children. Compare the direct read to the
6323
+ // cached Node.prototype getter — when the form's named-property
6324
+ // getter intercepts the read, the two values differ and we flag
6325
+ // the form. This catches every clobbering child type (input,
6326
+ // select, etc.) regardless of whether the named child happens to
6327
+ // carry a numeric .length, which a typeof-based probe would miss
6328
+ // (e.g. HTMLSelectElement.length is a defined unsigned-long).
6329
+ element.childNodes !== getChildNodes(element);
5715
6330
  };
5716
6331
  /**
5717
- * Checks whether the given object is a DOM node.
6332
+ * Checks whether the given value is a DocumentFragment from any realm.
6333
+ *
6334
+ * The realm-independent replacement reads `nodeType` through the cached
6335
+ * Node.prototype getter and compares to the DOCUMENT_FRAGMENT_NODE
6336
+ * constant (11). nodeType is a numeric value resolved from the node's
6337
+ * internal slot, identical across realms for the same kind of node.
6338
+ *
6339
+ * @param value object to check
6340
+ * @return true if value is a DocumentFragment-shaped node from any realm
6341
+ */
6342
+ const _isDocumentFragment = function _isDocumentFragment(value) {
6343
+ if (!getNodeType || typeof value !== 'object' || value === null) {
6344
+ return false;
6345
+ }
6346
+ try {
6347
+ return getNodeType(value) === NODE_TYPE.documentFragment;
6348
+ } catch (_) {
6349
+ return false;
6350
+ }
6351
+ };
6352
+ /**
6353
+ * Checks whether the given object is a DOM node, including nodes that
6354
+ * originate from a different window/realm (e.g. an iframe's
6355
+ * contentDocument). The previous `value instanceof Node` check was
6356
+ * realm-bound: nodes from a different window failed it, causing
6357
+ * sanitize() to silently stringify them and reset IN_PLACE to false,
6358
+ * returning the original node unsanitized. See GHSA-4w3q-35jp-p934.
5718
6359
  *
5719
6360
  * @param value object to check whether it's a DOM node
5720
- * @return true is object is a DOM node
6361
+ * @return true if value is a DOM node from any realm
5721
6362
  */
5722
6363
  const _isNode = function _isNode(value) {
5723
- return typeof Node === 'function' && value instanceof Node;
6364
+ if (!getNodeType || typeof value !== 'object' || value === null) {
6365
+ return false;
6366
+ }
6367
+ try {
6368
+ return typeof getNodeType(value) === 'number';
6369
+ } catch (_) {
6370
+ return false;
6371
+ }
5724
6372
  };
5725
6373
  function _executeHooks(hooks, currentNode, data) {
6374
+ if (hooks.length === 0) {
6375
+ return;
6376
+ }
5726
6377
  arrayForEach(hooks, hook => {
5727
6378
  hook.call(DOMPurify, currentNode, data, CONFIG);
5728
6379
  });
5729
6380
  }
6381
+ /**
6382
+ * Structural-threat checks that condemn a node regardless of the
6383
+ * allowlists: mXSS via namespace confusion, risky CSS construction,
6384
+ * processing instructions, markup-bearing comments. Pure predicate;
6385
+ * the caller removes. Check order is load-bearing.
6386
+ *
6387
+ * @param currentNode the node to inspect
6388
+ * @param tagName the node's transformCaseFunc'd tag name
6389
+ * @return true if the node must be removed
6390
+ */
6391
+ const _isUnsafeNode = function _isUnsafeNode(currentNode, tagName) {
6392
+ /* Detect mXSS attempts abusing namespace confusion */
6393
+ if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) {
6394
+ return true;
6395
+ }
6396
+ /* Remove risky CSS construction leading to mXSS */
6397
+ if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && tagName === 'style' && _isNode(currentNode.firstElementChild)) {
6398
+ return true;
6399
+ }
6400
+ /* Remove any occurrence of processing instructions */
6401
+ if (currentNode.nodeType === NODE_TYPE.processingInstruction) {
6402
+ return true;
6403
+ }
6404
+ /* Remove any kind of possibly harmful comments */
6405
+ if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, currentNode.data)) {
6406
+ return true;
6407
+ }
6408
+ return false;
6409
+ };
6410
+ /**
6411
+ * Handle a node whose tag is forbidden or not allowlisted: keep
6412
+ * allowed custom elements (false return exits _sanitizeElements
6413
+ * early - the namespace and fallback-tag removal checks are
6414
+ * intentionally skipped for kept custom elements), else hoist
6415
+ * content per KEEP_CONTENT and remove.
6416
+ *
6417
+ * A kept custom element is the ONLY case in which this function
6418
+ * returns false, so the caller uses that return value to run the
6419
+ * afterSanitizeElements hook on the kept element and keep the
6420
+ * element-hook lifecycle consistent with normal allowlisted
6421
+ * elements (GHSA-c2j3-45gr-mqc4).
6422
+ *
6423
+ * @param currentNode the disallowed node
6424
+ * @param tagName the node's transformCaseFunc'd tag name
6425
+ * @return true if the node was removed, false if kept
6426
+ */
6427
+ const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
6428
+ /* Check if we have a custom element to handle */
6429
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
6430
+ if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
6431
+ return false;
6432
+ }
6433
+ if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
6434
+ return false;
6435
+ }
6436
+ }
6437
+ /* Keep content except for bad-listed elements.
6438
+ Use the cached prototype getters exclusively — the previous code
6439
+ had `|| currentNode.parentNode` / `|| currentNode.childNodes`
6440
+ fallbacks, but the cached getters always return the canonical
6441
+ value (or null for a real parent-less node), so the fallback
6442
+ path was dead in safe cases and a clobbering surface in unsafe
6443
+ ones. Falsy cached results stay falsy; the `if (childNodes &&
6444
+ parentNode)` check already gates correctly. */
6445
+ if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
6446
+ const parentNode = getParentNode(currentNode);
6447
+ const childNodes = getChildNodes(currentNode);
6448
+ if (childNodes && parentNode) {
6449
+ const childCount = childNodes.length;
6450
+ /* Hoist by moving each child up one level rather than deep-cloning
6451
+ it. Moving transfers every descendant exactly once, so a chain of
6452
+ nested disallowed elements costs O(n) instead of the O(n^2) that
6453
+ re-cloning the shrinking subtree at each level produced; it also
6454
+ empties the removed original, so `DOMPurify.removed` no longer
6455
+ pins whole subtrees. Moving preserves the in-place guarantee too:
6456
+ an original carrying already-queued resource events (`<img
6457
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
6458
+ relocated and sanitised rather than left detached but still armed.
6459
+ The sole case that must clone is removing the walk root itself.
6460
+ The result is serialised from the root's subtree, so a restrictive
6461
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
6462
+ leave the content inside it, which only cloning does. In IN_PLACE
6463
+ the root is pre-validated as an allowed tag and so is never removed
6464
+ here, so that path always takes the move branch.
6465
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
6466
+ valid whether we move (drops the trailing entry) or clone (leaves
6467
+ the list intact). */
6468
+ for (let i = childCount - 1; i >= 0; --i) {
6469
+ const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
6470
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
6471
+ }
6472
+ }
6473
+ }
6474
+ _forceRemove(currentNode);
6475
+ return true;
6476
+ };
6477
+ /**
6478
+ * Fork a hook-mutable allowlist off its shared binding the first time a
6479
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
6480
+ * hook cannot widen the per-instance default or the setConfig binding by
6481
+ * reference and leak past the call. Returns the set unchanged once it is
6482
+ * already call-local, so repeated calls across elements are idempotent.
6483
+ *
6484
+ * @param hookList the uponSanitize* hook array for this event
6485
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
6486
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
6487
+ * @param setConfigSet the captured setConfig() binding, or null
6488
+ * @return a call-local clone if a hook is present and set is still shared,
6489
+ * else set unchanged
6490
+ */
6491
+ const _forkSharedAllowlist = function _forkSharedAllowlist(hookList, set, defaultSet, setConfigSet) {
6492
+ if (hookList.length === 0) {
6493
+ return set;
6494
+ }
6495
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
6496
+ };
5730
6497
  /**
5731
6498
  * _sanitizeElements
5732
6499
  *
@@ -5736,81 +6503,112 @@ function createDOMPurify() {
5736
6503
  * @param currentNode to check for permission to exist
5737
6504
  * @return true if node was killed, false if left alive
5738
6505
  */
5739
- const _sanitizeElements = function _sanitizeElements(currentNode) {
5740
- let content = null;
6506
+ // eslint-disable-next-line complexity
6507
+ const _sanitizeElements = function _sanitizeElements(currentNode, root) {
5741
6508
  /* Execute a hook if present */
5742
6509
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
6510
+ /* A hook may have detached the node - treat it as removed (see the
6511
+ detached-node comment after the uponSanitizeElement hook below). On
6512
+ the IN_PLACE path, neutralize the detached subtree first so a queued
6513
+ resource handler on it cannot fire in page scope after we return. */
6514
+ if (currentNode !== root && getParentNode(currentNode) === null) {
6515
+ if (IN_PLACE) {
6516
+ _neutralizeSubtree(currentNode);
6517
+ }
6518
+ return true;
6519
+ }
5743
6520
  /* Check if element is clobbered or can clobber */
5744
6521
  if (_isClobbered(currentNode)) {
5745
6522
  _forceRemove(currentNode);
5746
6523
  return true;
5747
6524
  }
5748
6525
  /* Now let's check the element's type and name */
5749
- const tagName = transformCaseFunc(currentNode.nodeName);
6526
+ const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
6527
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
6528
+ hook may have been installed after that guard sampled the hook arrays
6529
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
6530
+ aliasing a shared binding that a widening hook would mutate by
6531
+ reference. Fork it before exposing it to the hook. */
6532
+ ALLOWED_TAGS = _forkSharedAllowlist(hooks.uponSanitizeElement, ALLOWED_TAGS, DEFAULT_ALLOWED_TAGS, SET_CONFIG_ALLOWED_TAGS);
5750
6533
  /* Execute a hook if present */
5751
6534
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
5752
6535
  tagName,
5753
6536
  allowedTags: ALLOWED_TAGS
5754
6537
  });
5755
- /* Detect mXSS attempts abusing namespace confusion */
5756
- if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w!]/g, currentNode.innerHTML) && regExpTest(/<[/\w!]/g, currentNode.textContent)) {
5757
- _forceRemove(currentNode);
5758
- return true;
5759
- }
5760
- /* Remove any occurrence of processing instructions */
5761
- if (currentNode.nodeType === NODE_TYPE.progressingInstruction) {
5762
- _forceRemove(currentNode);
6538
+ /* A hook may have detached the node from the tree — a long-standing
6539
+ user pattern (issue #469; draw.io-style foreignObject filtering).
6540
+ Per the cached, unclobberable parentNode getter the node is
6541
+ genuinely out of the tree, so it can reach neither the serialized
6542
+ output nor an IN_PLACE live tree; treat it as removed and stop
6543
+ processing it. Without this guard, the unsafe-node / namespace
6544
+ checks below would call _forceRemove on a parentless node and hit
6545
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
6546
+ wants gone but *cannot* detach (clobbered / parentless roots), the
6547
+ opposite of a node that is already safely gone. The walk root is
6548
+ exempt: a detached IN_PLACE root is legitimate input and must still
6549
+ be fully sanitized, and a kill-decision on it must keep hitting the
6550
+ REPORT-3 throw. Nodes detached by hooks stay the hook's
6551
+ responsibility for placement: they are not recorded in
6552
+ DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
6553
+ DOMPurify.removed) does not reach them. But a hook-detached subtree
6554
+ can still hold a queued resource-event handler - e.g. an <img onload>
6555
+ that began loading when the caller built the live tree - which fires
6556
+ in page scope after sanitize returns even though the handler never
6557
+ reached the returned tree. That is the audit-5 F1 hazard, and the
6558
+ documented node.remove() hook pattern walks straight into it. So on
6559
+ the IN_PLACE path we neutralize the detached subtree inline here,
6560
+ stripping its non-allow-listed attributes before returning, exactly
6561
+ as the post-walk pass does for _forceRemove'd subtrees. */
6562
+ if (currentNode !== root && getParentNode(currentNode) === null) {
6563
+ if (IN_PLACE) {
6564
+ _neutralizeSubtree(currentNode);
6565
+ }
5763
6566
  return true;
5764
6567
  }
5765
- /* Remove any kind of possibly harmful comments */
5766
- if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) {
6568
+ /* Remove mXSS vectors, processing instructions and risky comments */
6569
+ if (_isUnsafeNode(currentNode, tagName)) {
5767
6570
  _forceRemove(currentNode);
5768
6571
  return true;
5769
6572
  }
5770
6573
  /* Remove element if anything forbids its presence */
5771
- if (!(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName])) {
5772
- /* Check if we have a custom element to handle */
5773
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
5774
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
5775
- return false;
5776
- }
5777
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
5778
- return false;
5779
- }
5780
- }
5781
- /* Keep content except for bad-listed elements */
5782
- if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
5783
- const parentNode = getParentNode(currentNode) || currentNode.parentNode;
5784
- const childNodes = getChildNodes(currentNode) || currentNode.childNodes;
5785
- if (childNodes && parentNode) {
5786
- const childCount = childNodes.length;
5787
- for (let i = childCount - 1; i >= 0; --i) {
5788
- const childClone = cloneNode(childNodes[i], true);
5789
- childClone.__removalCount = (currentNode.__removalCount || 0) + 1;
5790
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
5791
- }
5792
- }
6574
+ if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
6575
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
6576
+ /* A false return means the node is a custom element kept via
6577
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
6578
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
6579
+ element-hook lifecycle matches normal allowlisted elements: a
6580
+ security policy applied in this hook (e.g. stripping an attribute
6581
+ from every surviving element) must not silently skip kept custom
6582
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
6583
+ tail below - the hook runs, then the walker's subsequent
6584
+ _sanitizeAttributes pass sanitizes the element's attributes. The
6585
+ deliberately skipped namespace and fallback-tag removal checks stay
6586
+ skipped; they are removal decisions, not the hook contract. */
6587
+ if (removed === false) {
6588
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
5793
6589
  }
5794
- _forceRemove(currentNode);
5795
- return true;
5796
- }
5797
- /* Check whether element has a valid namespace */
5798
- if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) {
6590
+ return removed;
6591
+ }
6592
+ /* Check whether element has a valid namespace.
6593
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
6594
+ nodeType getter rather than `instanceof Element`, which is realm-
6595
+ bound and short-circuits to false for any node minted in a different
6596
+ realm — letting a foreign-realm element with a forbidden namespace
6597
+ slip past the namespace check entirely. */
6598
+ const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
6599
+ if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
5799
6600
  _forceRemove(currentNode);
5800
6601
  return true;
5801
6602
  }
5802
6603
  /* Make sure that older browsers don't get fallback-tag mXSS */
5803
- if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) {
6604
+ if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(FALLBACK_TAG_CLOSE, currentNode.innerHTML)) {
5804
6605
  _forceRemove(currentNode);
5805
6606
  return true;
5806
6607
  }
5807
6608
  /* Sanitize element content to be template-safe */
5808
6609
  if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {
5809
6610
  /* Get the element's text content */
5810
- content = currentNode.textContent;
5811
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {
5812
- content = stringReplace(content, expr, ' ');
5813
- });
6611
+ const content = _stripTemplateExpressions(currentNode.textContent);
5814
6612
  if (currentNode.textContent !== content) {
5815
6613
  arrayPush(DOMPurify.removed, {
5816
6614
  element: currentNode.cloneNode()
@@ -5832,15 +6630,47 @@ function createDOMPurify() {
5832
6630
  */
5833
6631
  // eslint-disable-next-line complexity
5834
6632
  const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) {
6633
+ /* FORBID_ATTR must always win, even if ADD_ATTR predicate would allow it */
6634
+ if (FORBID_ATTR[lcName]) {
6635
+ return false;
6636
+ }
6637
+ /* Reject declarative-partial-updates patch-linkage attributes
6638
+ (https://github.com/WICG/declarative-partial-updates).
6639
+ Empirical note (Chrome 150, verified — see
6640
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
6641
+ sanitization. For the string path it fires during sanitize()'s own
6642
+ parse, so the walk sees and sanitizes the fully materialized expanded
6643
+ tree — teleports into MathML/SVG integration points included; a
6644
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
6645
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
6646
+ Either way DOMPurify is NOT blind to the patch.
6647
+ This removal is therefore defense-in-depth rather than the sole barrier:
6648
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
6649
+ in the caller's context, and keeps behaviour deterministic if a future
6650
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
6651
+ anywhere else (notably <template for>) it links the element to a patch
6652
+ target and teleports or removes an arbitrary DOM range by id/marker name.
6653
+ `patchsrc` fetches remote markup and is treated as a script-loading
6654
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
6655
+ other structural-threat checks and stays overridable, consistent with
6656
+ the rest of the codebase. PI range markers are already removed by
6657
+ _isUnsafeNode. */
6658
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
6659
+ return false;
6660
+ }
6661
+ if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
6662
+ return false;
6663
+ }
5835
6664
  /* Make sure attribute cannot clobber */
5836
6665
  if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {
5837
6666
  return false;
5838
6667
  }
6668
+ const nameIsPermitted = ALLOWED_ATTR[lcName] || EXTRA_ELEMENT_HANDLING.attributeCheck instanceof Function && EXTRA_ELEMENT_HANDLING.attributeCheck(lcName, lcTag);
5839
6669
  /* Allow valid data-* attributes: At least one character after "-"
5840
6670
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
5841
6671
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
5842
6672
  We don't need to check the value; it's always URI safe. */
5843
- if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ; else if (EXTRA_ELEMENT_HANDLING.attributeCheck instanceof Function && EXTRA_ELEMENT_HANDLING.attributeCheck(lcName, lcTag)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) {
6673
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) ; else if (!nameIsPermitted) {
5844
6674
  if (
5845
6675
  // First condition does a very basic check if a) it's basically a valid custom element tagname AND
5846
6676
  // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
@@ -5852,11 +6682,15 @@ function createDOMPurify() {
5852
6682
  return false;
5853
6683
  }
5854
6684
  /* Check value is safe. First, is attr inert? If so, is safe */
5855
- } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) {
6685
+ } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1$1, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1$1, ''))) ; else if (value) {
5856
6686
  return false;
5857
6687
  } else ;
5858
6688
  return true;
5859
6689
  };
6690
+ /* Names the HTML spec reserves from valid-custom-element-name; these must
6691
+ * never be treated as basic custom elements even when a permissive
6692
+ * CUSTOM_ELEMENT_HANDLING.tagNameCheck is configured. */
6693
+ const RESERVED_CUSTOM_ELEMENT_NAMES = addToSet({}, ['annotation-xml', 'color-profile', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'missing-glyph']);
5860
6694
  /**
5861
6695
  * _isBasicCustomElement
5862
6696
  * checks if at least one dash is included in tagName, and it's not the first char
@@ -5866,7 +6700,64 @@ function createDOMPurify() {
5866
6700
  * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false.
5867
6701
  */
5868
6702
  const _isBasicCustomElement = function _isBasicCustomElement(tagName) {
5869
- return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT);
6703
+ return !RESERVED_CUSTOM_ELEMENT_NAMES[stringToLowerCase(tagName)] && regExpTest(CUSTOM_ELEMENT$1, tagName);
6704
+ };
6705
+ /**
6706
+ * Wrap an attribute value in the matching Trusted Types object when
6707
+ * the active policy requires it. Namespaced attributes pass through
6708
+ * unchanged (no TT support yet, see
6709
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=1305293).
6710
+ *
6711
+ * @param lcTag lowercase tag name of the containing element
6712
+ * @param lcName lowercase attribute name
6713
+ * @param namespaceURI the attribute's namespace, if any
6714
+ * @param value the attribute value to wrap
6715
+ * @return the value, wrapped when Trusted Types demand it
6716
+ */
6717
+ const _applyTrustedTypesToAttribute = function _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value) {
6718
+ if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function' && !namespaceURI) {
6719
+ switch (trustedTypes.getAttributeType(lcTag, lcName)) {
6720
+ case 'TrustedHTML':
6721
+ {
6722
+ return _createTrustedHTML(value);
6723
+ }
6724
+ case 'TrustedScriptURL':
6725
+ {
6726
+ return _createTrustedScriptURL(value);
6727
+ }
6728
+ }
6729
+ }
6730
+ return value;
6731
+ };
6732
+ /**
6733
+ * Write a modified attribute value back onto the element. On
6734
+ * success, re-probe for clobbering introduced by the new value and
6735
+ * remove the element when found; otherwise pop the removal entry
6736
+ * recorded by the earlier _removeAttribute (long-standing pairing
6737
+ * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
6738
+ * failure, remove the attribute instead.
6739
+ *
6740
+ * @param currentNode the element carrying the attribute
6741
+ * @param name the attribute name as present on the element
6742
+ * @param namespaceURI the attribute's namespace, if any
6743
+ * @param value the new attribute value
6744
+ */
6745
+ const _setAttributeValue = function _setAttributeValue(currentNode, name, namespaceURI, value) {
6746
+ try {
6747
+ if (namespaceURI) {
6748
+ currentNode.setAttributeNS(namespaceURI, name, value);
6749
+ } else {
6750
+ /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */
6751
+ currentNode.setAttribute(name, value);
6752
+ }
6753
+ if (_isClobbered(currentNode)) {
6754
+ _forceRemove(currentNode);
6755
+ } else {
6756
+ arrayPop(DOMPurify.removed);
6757
+ }
6758
+ } catch (_) {
6759
+ _removeAttribute(name, currentNode);
6760
+ }
5870
6761
  };
5871
6762
  /**
5872
6763
  * _sanitizeAttributes
@@ -5881,13 +6772,14 @@ function createDOMPurify() {
5881
6772
  const _sanitizeAttributes = function _sanitizeAttributes(currentNode) {
5882
6773
  /* Execute a hook if present */
5883
6774
  _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);
5884
- const {
5885
- attributes
5886
- } = currentNode;
6775
+ const attributes = currentNode.attributes;
5887
6776
  /* Check if we have attributes; if not we might have a text node */
5888
6777
  if (!attributes || _isClobbered(currentNode)) {
5889
6778
  return;
5890
6779
  }
6780
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
6781
+ attribute allowlist off its shared binding before a hook can see it. */
6782
+ ALLOWED_ATTR = _forkSharedAllowlist(hooks.uponSanitizeAttribute, ALLOWED_ATTR, DEFAULT_ALLOWED_ATTR, SET_CONFIG_ALLOWED_ATTR);
5891
6783
  const hookEvent = {
5892
6784
  attrName: '',
5893
6785
  attrValue: '',
@@ -5896,14 +6788,13 @@ function createDOMPurify() {
5896
6788
  forceKeepAttr: undefined
5897
6789
  };
5898
6790
  let l = attributes.length;
6791
+ const lcTag = transformCaseFunc(currentNode.nodeName);
5899
6792
  /* Go backwards over all attributes; safely remove bad ones */
5900
6793
  while (l--) {
5901
6794
  const attr = attributes[l];
5902
- const {
5903
- name,
5904
- namespaceURI,
5905
- value: attrValue
5906
- } = attr;
6795
+ const name = attr.name,
6796
+ namespaceURI = attr.namespaceURI,
6797
+ attrValue = attr.value;
5907
6798
  const lcName = transformCaseFunc(name);
5908
6799
  const initValue = attrValue;
5909
6800
  let value = name === 'value' ? initValue : stringTrim(initValue);
@@ -5917,14 +6808,16 @@ function createDOMPurify() {
5917
6808
  /* Full DOM Clobbering protection via namespace isolation,
5918
6809
  * Prefix id and name attributes with `user-content-`
5919
6810
  */
5920
- if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) {
6811
+ if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name') && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
5921
6812
  // Remove the attribute with this value
5922
6813
  _removeAttribute(name, currentNode);
5923
6814
  // Prefix the value and later re-create the attribute with the sanitized value
5924
6815
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
5925
6816
  }
6817
+ // Else: already prefixed, leave the attribute alone — the prefix is
6818
+ // itself the clobbering protection, and re-applying it is incorrect.
5926
6819
  /* Work around a security issue with comments inside attributes */
5927
- if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title|textarea)/i, value)) {
6820
+ if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
5928
6821
  _removeAttribute(name, currentNode);
5929
6822
  continue;
5930
6823
  }
@@ -5933,7 +6826,7 @@ function createDOMPurify() {
5933
6826
  _removeAttribute(name, currentNode);
5934
6827
  continue;
5935
6828
  }
5936
- /* Did the hooks approve of the attribute? */
6829
+ /* Did the hooks force-keep the attribute? */
5937
6830
  if (hookEvent.forceKeepAttr) {
5938
6831
  continue;
5939
6832
  }
@@ -5943,56 +6836,24 @@ function createDOMPurify() {
5943
6836
  continue;
5944
6837
  }
5945
6838
  /* Work around a security issue in jQuery 3.0 */
5946
- if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) {
6839
+ if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value)) {
5947
6840
  _removeAttribute(name, currentNode);
5948
6841
  continue;
5949
6842
  }
5950
6843
  /* Sanitize attribute content to be template-safe */
5951
6844
  if (SAFE_FOR_TEMPLATES) {
5952
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {
5953
- value = stringReplace(value, expr, ' ');
5954
- });
6845
+ value = _stripTemplateExpressions(value);
5955
6846
  }
5956
6847
  /* Is `value` valid for this attribute? */
5957
- const lcTag = transformCaseFunc(currentNode.nodeName);
5958
6848
  if (!_isValidAttribute(lcTag, lcName, value)) {
5959
6849
  _removeAttribute(name, currentNode);
5960
6850
  continue;
5961
6851
  }
5962
6852
  /* Handle attributes that require Trusted Types */
5963
- if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') {
5964
- if (namespaceURI) ; else {
5965
- switch (trustedTypes.getAttributeType(lcTag, lcName)) {
5966
- case 'TrustedHTML':
5967
- {
5968
- value = trustedTypesPolicy.createHTML(value);
5969
- break;
5970
- }
5971
- case 'TrustedScriptURL':
5972
- {
5973
- value = trustedTypesPolicy.createScriptURL(value);
5974
- break;
5975
- }
5976
- }
5977
- }
5978
- }
6853
+ value = _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value);
5979
6854
  /* Handle invalid data-* attribute set by try-catching it */
5980
6855
  if (value !== initValue) {
5981
- try {
5982
- if (namespaceURI) {
5983
- currentNode.setAttributeNS(namespaceURI, name, value);
5984
- } else {
5985
- /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */
5986
- currentNode.setAttribute(name, value);
5987
- }
5988
- if (_isClobbered(currentNode)) {
5989
- _forceRemove(currentNode);
5990
- } else {
5991
- arrayPop(DOMPurify.removed);
5992
- }
5993
- } catch (_) {
5994
- _removeAttribute(name, currentNode);
5995
- }
6856
+ _setAttributeValue(currentNode, name, namespaceURI, value);
5996
6857
  }
5997
6858
  }
5998
6859
  /* Execute a hook if present */
@@ -6003,7 +6864,7 @@ function createDOMPurify() {
6003
6864
  *
6004
6865
  * @param fragment to iterate over recursively
6005
6866
  */
6006
- const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) {
6867
+ const _sanitizeShadowDOM2 = function _sanitizeShadowDOM(fragment) {
6007
6868
  let shadowNode = null;
6008
6869
  const shadowIterator = _createNodeIterator(fragment);
6009
6870
  /* Execute a hook if present */
@@ -6012,17 +6873,136 @@ function createDOMPurify() {
6012
6873
  /* Execute a hook if present */
6013
6874
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
6014
6875
  /* Sanitize tags and elements */
6015
- _sanitizeElements(shadowNode);
6876
+ _sanitizeElements(shadowNode, fragment);
6016
6877
  /* Check attributes next */
6017
6878
  _sanitizeAttributes(shadowNode);
6018
- /* Deep shadow DOM detected */
6019
- if (shadowNode.content instanceof DocumentFragment) {
6020
- _sanitizeShadowDOM(shadowNode.content);
6879
+ /* Deep shadow DOM detected.
6880
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType against the
6881
+ DOCUMENT_FRAGMENT_NODE constant rather than instanceof, so we
6882
+ recurse into <template>.content from foreign realms too. */
6883
+ if (_isDocumentFragment(shadowNode.content)) {
6884
+ _sanitizeShadowDOM2(shadowNode.content);
6885
+ }
6886
+ /* An element iterated here may itself host an attached
6887
+ shadow root. The default NodeIterator does not enter shadow
6888
+ trees, so a shadow root nested inside template.content was
6889
+ previously reached by no walk at all (the pre-pass at
6890
+ _sanitizeAttachedShadowRoots descends via childNodes, which
6891
+ doesn't enter template.content; the template-content recursion
6892
+ above iterates the content but never inspected shadowRoot).
6893
+ Walk it explicitly. The nodeType guard avoids reading
6894
+ shadowRoot off text / comment / CDATA / PI nodes that the
6895
+ iterator also surfaces. */
6896
+ const shadowNodeType = getNodeType ? getNodeType(shadowNode) : shadowNode.nodeType;
6897
+ if (shadowNodeType === NODE_TYPE.element) {
6898
+ const innerSr = getShadowRoot(shadowNode);
6899
+ if (_isDocumentFragment(innerSr)) {
6900
+ _sanitizeAttachedShadowRoots(innerSr);
6901
+ _sanitizeShadowDOM2(innerSr);
6902
+ }
6021
6903
  }
6022
6904
  }
6023
6905
  /* Execute a hook if present */
6024
6906
  _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
6025
6907
  };
6908
+ /**
6909
+ * _sanitizeAttachedShadowRoots
6910
+ *
6911
+ * Walks `root` and feeds every attached shadow root we encounter into
6912
+ * the existing _sanitizeShadowDOM pipeline. The default node iterator
6913
+ * does not descend into shadow trees, so nodes inside an attached
6914
+ * shadow root would otherwise be skipped entirely.
6915
+ *
6916
+ * Two real input paths put attached shadow roots in front of us:
6917
+ * 1. IN_PLACE on a DOM node that already has shadow roots attached.
6918
+ * 2. DOM-node input where importNode(dirty, true) deep-clones the
6919
+ * shadow root because it was created with `clonable: true`.
6920
+ *
6921
+ * This pass runs once, up front, so the main iteration loop (and the
6922
+ * existing _sanitizeShadowDOM template-content recursion) stay
6923
+ * untouched — string-input paths are not affected.
6924
+ *
6925
+ * @param root the subtree root to walk for attached shadow roots
6926
+ */
6927
+ const _sanitizeAttachedShadowRoots = function _sanitizeAttachedShadowRoots(root) {
6928
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
6929
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
6930
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
6931
+ call-stack budget would otherwise overflow native recursion here and
6932
+ throw at the IN_PLACE entry pre-pass, before a single node is
6933
+ sanitized, leaving the caller's live tree untouched (fail-open). See
6934
+ campaign-3 F4. A heap stack keeps depth off the call stack.
6935
+ Each work item is either a node to descend into, or a deferred
6936
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
6937
+ form preserves the original post-order discipline: a shadow root's
6938
+ nested shadow roots are discovered before the outer shadow is
6939
+ sanitized (which may remove hosts). Pushes are in reverse of the
6940
+ desired processing order (LIFO): template content, then children, then
6941
+ the shadow-sanitize, then the shadow walk — so the order matches the
6942
+ previous recursion exactly. */
6943
+ const stack = [{
6944
+ node: root,
6945
+ shadow: null
6946
+ }];
6947
+ while (stack.length > 0) {
6948
+ const item = stack.pop();
6949
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
6950
+ if (item.shadow) {
6951
+ _sanitizeShadowDOM2(item.shadow);
6952
+ continue;
6953
+ }
6954
+ const node = item.node;
6955
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
6956
+ const isElement = nodeType === NODE_TYPE.element;
6957
+ /* (pushed last → processed first) Children, snapshotted in reverse so
6958
+ the first child is processed first. Snapshotting matters because a
6959
+ hook may detach siblings mid-walk. */
6960
+ const childNodes = getChildNodes(node);
6961
+ if (childNodes) {
6962
+ for (let i = childNodes.length - 1; i >= 0; --i) {
6963
+ stack.push({
6964
+ node: childNodes[i],
6965
+ shadow: null
6966
+ });
6967
+ }
6968
+ }
6969
+ /* (pushed before children → processed after them, matching the old
6970
+ "template content last" order) When the node is a <template>,
6971
+ descend into its content. */
6972
+ if (isElement) {
6973
+ const rootName = getNodeName ? getNodeName(node) : null;
6974
+ if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
6975
+ const content = node.content;
6976
+ if (_isDocumentFragment(content)) {
6977
+ stack.push({
6978
+ node: content,
6979
+ shadow: null
6980
+ });
6981
+ }
6982
+ }
6983
+ }
6984
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
6985
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
6986
+ rather than `instanceof DocumentFragment`, which is realm-bound and
6987
+ silently skipped foreign-realm shadow roots (e.g.
6988
+ iframe.contentDocument attachShadow). */
6989
+ if (isElement) {
6990
+ const sr = getShadowRoot(node);
6991
+ if (_isDocumentFragment(sr)) {
6992
+ /* Push the deferred sanitise first so it pops after the shadow
6993
+ walk we push next, i.e. nested shadow roots are discovered
6994
+ before this one is sanitised. */
6995
+ stack.push({
6996
+ node: null,
6997
+ shadow: sr
6998
+ }, {
6999
+ node: sr,
7000
+ shadow: null
7001
+ });
7002
+ }
7003
+ }
7004
+ }
7005
+ };
6026
7006
  // eslint-disable-next-line complexity
6027
7007
  DOMPurify.sanitize = function (dirty) {
6028
7008
  let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
@@ -6039,13 +7019,9 @@ function createDOMPurify() {
6039
7019
  }
6040
7020
  /* Stringify, in case dirty is an object */
6041
7021
  if (typeof dirty !== 'string' && !_isNode(dirty)) {
6042
- if (typeof dirty.toString === 'function') {
6043
- dirty = dirty.toString();
6044
- if (typeof dirty !== 'string') {
6045
- throw typeErrorCreate('dirty is not a string, aborting');
6046
- }
6047
- } else {
6048
- throw typeErrorCreate('toString is not a function');
7022
+ dirty = stringifyValue(dirty);
7023
+ if (typeof dirty !== 'string') {
7024
+ throw typeErrorCreate('dirty is not a string, aborting');
6049
7025
  }
6050
7026
  }
6051
7027
  /* Return dirty HTML if DOMPurify cannot run */
@@ -6053,24 +7029,90 @@ function createDOMPurify() {
6053
7029
  return dirty;
6054
7030
  }
6055
7031
  /* Assign config vars */
6056
- if (!SET_CONFIG) {
7032
+ if (SET_CONFIG) {
7033
+ /* Persistent setConfig() path: _parseConfig is skipped, so the sets are
7034
+ * not re-derived per call. Restore them from the pristine bindings
7035
+ * captured at setConfig() time so a previous call's hook clone (mutated
7036
+ * below) does not carry over. */
7037
+ ALLOWED_TAGS = SET_CONFIG_ALLOWED_TAGS;
7038
+ ALLOWED_ATTR = SET_CONFIG_ALLOWED_ATTR;
7039
+ } else {
6057
7040
  _parseConfig(cfg);
6058
7041
  }
7042
+ /* Clone the hook-mutable allowlists before the walk whenever an
7043
+ * uponSanitize* hook is registered. The hook event exposes ALLOWED_TAGS
7044
+ * and ALLOWED_ATTR by reference (as allowedTags / allowedAttributes), so
7045
+ * a hook that widens them would otherwise mutate the shared set
7046
+ * permanently: across later calls and across every element. Cloning per
7047
+ * walk keeps documented in-call widening working while scoping it to the
7048
+ * call. A single guard for both config paths - the per-call path rebinds
7049
+ * the sets in _parseConfig each call, the persistent path restores them
7050
+ * from the captured bindings just above - so the two cannot diverge. */
7051
+ if (hooks.uponSanitizeElement.length > 0 || hooks.uponSanitizeAttribute.length > 0) {
7052
+ ALLOWED_TAGS = clone(ALLOWED_TAGS);
7053
+ }
7054
+ if (hooks.uponSanitizeAttribute.length > 0) {
7055
+ ALLOWED_ATTR = clone(ALLOWED_ATTR);
7056
+ }
6059
7057
  /* Clean up removed elements */
6060
7058
  DOMPurify.removed = [];
6061
- /* Check if dirty is correctly typed for IN_PLACE */
6062
- if (typeof dirty === 'string') {
6063
- IN_PLACE = false;
6064
- }
6065
- if (IN_PLACE) {
6066
- /* Do some early pre-sanitization to avoid unsafe root nodes */
6067
- if (dirty.nodeName) {
6068
- const tagName = transformCaseFunc(dirty.nodeName);
7059
+ /* Resolve IN_PLACE for this call without mutating persistent config.
7060
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
7061
+ where _parseConfig is skipped on later calls: a single string call would
7062
+ disable in-place mode for every subsequent node call, returning a
7063
+ sanitized copy while leaving the caller's node — which in-place callers
7064
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
7065
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
7066
+ if (inPlace) {
7067
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
7068
+ linkage across the live tree BEFORE the walk, so no patch can fire
7069
+ mid-walk and inject into an already-processed region. Runs first, so
7070
+ it also covers the forbidden/clobbered roots that throw below. */
7071
+ _neutralizePatchLinkage(dirty);
7072
+ /* Do some early pre-sanitization to avoid unsafe root nodes.
7073
+ Read nodeName through the cached prototype getter — a clobbering
7074
+ child named "nodeName" on the form root would otherwise shadow
7075
+ the property and let this check skip the root-allowlist
7076
+ validation entirely. */
7077
+ const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
7078
+ if (typeof nn === 'string') {
7079
+ const tagName = transformCaseFunc(nn);
6069
7080
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
7081
+ /* Fail closed on a live root: neutralize handlers/children before
7082
+ throwing, exactly as the mid-walk abort path does. */
7083
+ _neutralizeRoot(dirty);
6070
7084
  throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
6071
7085
  }
6072
7086
  }
6073
- } else if (dirty instanceof Node) {
7087
+ /* Pre-flight the root through _isClobbered. The iterator-driven
7088
+ removal path can not detach a parent-less root: _forceRemove
7089
+ falls through to Element.prototype.remove(), which per spec
7090
+ is a no-op on a node with no parent. A clobbered root would
7091
+ then survive the main loop with its attributes uninspected,
7092
+ because _sanitizeAttributes early-returns on _isClobbered. The
7093
+ result would be an attacker-controlled form, complete with any
7094
+ event-handler attributes the caller passed in, handed back to
7095
+ the application unsanitized. Refuse to sanitize such a root
7096
+ the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
7097
+ if (_isClobbered(dirty)) {
7098
+ /* Fail closed on a live clobbered root before throwing.
7099
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
7100
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
7101
+ _neutralizeRoot(dirty);
7102
+ throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
7103
+ }
7104
+ /* Sanitize attached shadow roots before the main iterator runs.
7105
+ The iterator does not descend into shadow trees. Same fail-closed
7106
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
7107
+ inside a shadow root could abort this pre-pass before the walk runs,
7108
+ which would otherwise leave the entire live tree unsanitized. */
7109
+ try {
7110
+ _sanitizeAttachedShadowRoots(dirty);
7111
+ } catch (error) {
7112
+ _neutralizeRoot(dirty);
7113
+ throw error;
7114
+ }
7115
+ } else if (_isNode(dirty)) {
6074
7116
  /* If dirty is a DOM element, append to an empty document to avoid
6075
7117
  elements being stripped by the parser */
6076
7118
  body = _initDocument('<!---->');
@@ -6084,12 +7126,18 @@ function createDOMPurify() {
6084
7126
  // eslint-disable-next-line unicorn/prefer-dom-node-append
6085
7127
  body.appendChild(importedNode);
6086
7128
  }
7129
+ /* Clonable shadow roots are deep-cloned by importNode(); sanitize
7130
+ them before the main iterator runs, since the iterator does not
7131
+ descend into shadow trees. The walk routes every read through a
7132
+ cached prototype getter so clobbering descendants on a form root
7133
+ cannot hide a shadow host from this pass. */
7134
+ _sanitizeAttachedShadowRoots(importedNode);
6087
7135
  } else {
6088
7136
  /* Exit directly if we have nothing to do */
6089
7137
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&
6090
7138
  // eslint-disable-next-line unicorn/prefer-includes
6091
7139
  dirty.indexOf('<') === -1) {
6092
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty;
7140
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(dirty) : dirty;
6093
7141
  }
6094
7142
  /* Initialize the document to work on */
6095
7143
  body = _initDocument(dirty);
@@ -6103,24 +7151,72 @@ function createDOMPurify() {
6103
7151
  _forceRemove(body.firstChild);
6104
7152
  }
6105
7153
  /* Get node iterator */
6106
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
6107
- /* Now start iterating over the created document */
6108
- while (currentNode = nodeIterator.nextNode()) {
6109
- /* Sanitize tags and elements */
6110
- _sanitizeElements(currentNode);
6111
- /* Check attributes next */
6112
- _sanitizeAttributes(currentNode);
6113
- /* Shadow DOM detected, sanitize it */
6114
- if (currentNode.content instanceof DocumentFragment) {
6115
- _sanitizeShadowDOM(currentNode.content);
7154
+ const walkRoot = inPlace ? dirty : body;
7155
+ /* Now start iterating over the created document.
7156
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
7157
+ engine/custom-element mutation can detach a node mid-walk so
7158
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
7159
+ barrier the caller's in-place tree would be left half-sanitized with the
7160
+ unvisited tail still armed. _createNodeIterator itself is inside the
7161
+ barrier too: constructing the iterator dereferences the root's document,
7162
+ and any failure there (e.g. an exotic/clobbered root) must still fail
7163
+ closed rather than skip the neutralize. On any throw we fail closed -
7164
+ strip the in-place root bare - then rethrow so the existing throw
7165
+ contract is preserved. (String/DOM-copy paths never return the partial
7166
+ body, so the propagating throw is already fail-closed there.) */
7167
+ try {
7168
+ const nodeIterator = _createNodeIterator(walkRoot);
7169
+ while (currentNode = nodeIterator.nextNode()) {
7170
+ /* Sanitize tags and elements */
7171
+ _sanitizeElements(currentNode, walkRoot);
7172
+ /* Check attributes next */
7173
+ _sanitizeAttributes(currentNode);
7174
+ /* Shadow DOM detected, sanitize it.
7175
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
7176
+ instead of instanceof, so foreign-realm <template>.content is
7177
+ walked correctly. */
7178
+ if (_isDocumentFragment(currentNode.content)) {
7179
+ _sanitizeShadowDOM2(currentNode.content);
7180
+ }
7181
+ }
7182
+ } catch (error) {
7183
+ if (inPlace) {
7184
+ _neutralizeRoot(dirty);
7185
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
7186
+ detached from the root, so _neutralizeRoot's subtree pass does not
7187
+ reach them. Defuse them too, mirroring the success-path loop below. */
7188
+ arrayForEach(DOMPurify.removed, entry => {
7189
+ if (entry.element) {
7190
+ _neutralizeSubtree(entry.element);
7191
+ }
7192
+ });
6116
7193
  }
7194
+ throw error;
6117
7195
  }
6118
7196
  /* If we sanitized `dirty` in-place, return it. */
6119
- if (IN_PLACE) {
7197
+ if (inPlace) {
7198
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
7199
+ the caller's live tree is detached but may still hold a queued
7200
+ resource-event handler that fires in page scope after we return. The
7201
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
7202
+ non-allow-listed attributes off every other removed subtree (clobber,
7203
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
7204
+ cancelled before any event can fire. Runs synchronously, pre-return. */
7205
+ arrayForEach(DOMPurify.removed, entry => {
7206
+ if (entry.element) {
7207
+ _neutralizeSubtree(entry.element);
7208
+ }
7209
+ });
7210
+ if (SAFE_FOR_TEMPLATES) {
7211
+ _scrubTemplateExpressions2(dirty);
7212
+ }
6120
7213
  return dirty;
6121
7214
  }
6122
7215
  /* Return sanitized string or DOM */
6123
7216
  if (RETURN_DOM) {
7217
+ if (SAFE_FOR_TEMPLATES) {
7218
+ _scrubTemplateExpressions2(body);
7219
+ }
6124
7220
  if (RETURN_DOM_FRAGMENT) {
6125
7221
  returnNode = createDocumentFragment.call(body.ownerDocument);
6126
7222
  while (body.firstChild) {
@@ -6149,20 +7245,28 @@ function createDOMPurify() {
6149
7245
  }
6150
7246
  /* Sanitize final string template-safe */
6151
7247
  if (SAFE_FOR_TEMPLATES) {
6152
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {
6153
- serializedHTML = stringReplace(serializedHTML, expr, ' ');
6154
- });
7248
+ serializedHTML = _stripTemplateExpressions(serializedHTML);
6155
7249
  }
6156
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML;
7250
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(serializedHTML) : serializedHTML;
6157
7251
  };
6158
7252
  DOMPurify.setConfig = function () {
6159
7253
  let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
6160
7254
  _parseConfig(cfg);
6161
7255
  SET_CONFIG = true;
7256
+ SET_CONFIG_ALLOWED_TAGS = ALLOWED_TAGS;
7257
+ SET_CONFIG_ALLOWED_ATTR = ALLOWED_ATTR;
6162
7258
  };
6163
7259
  DOMPurify.clearConfig = function () {
6164
7260
  CONFIG = null;
6165
7261
  SET_CONFIG = false;
7262
+ SET_CONFIG_ALLOWED_TAGS = null;
7263
+ SET_CONFIG_ALLOWED_ATTR = null;
7264
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
7265
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
7266
+ // never recreated — Trusted Types throws on duplicate names) is restored by
7267
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
7268
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
7269
+ emptyHTML = '';
6166
7270
  };
6167
7271
  DOMPurify.isValidAttribute = function (tag, attr, value) {
6168
7272
  /* Initialize shared config vars if necessary. */
@@ -6177,9 +7281,19 @@ function createDOMPurify() {
6177
7281
  if (typeof hookFunction !== 'function') {
6178
7282
  return;
6179
7283
  }
7284
+ /* Reject unknown entry points. Without this, a non-hook key (e.g.
7285
+ * '__proto__') indexes off the prototype chain rather than a real
7286
+ * hook array, and arrayPush then writes to Object.prototype. Guard
7287
+ * with an own-property check against the known hook names. */
7288
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
7289
+ return;
7290
+ }
6180
7291
  arrayPush(hooks[entryPoint], hookFunction);
6181
7292
  };
6182
7293
  DOMPurify.removeHook = function (entryPoint, hookFunction) {
7294
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
7295
+ return undefined;
7296
+ }
6183
7297
  if (hookFunction !== undefined) {
6184
7298
  const index = arrayLastIndexOf(hooks[entryPoint], hookFunction);
6185
7299
  return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0];
@@ -6187,6 +7301,9 @@ function createDOMPurify() {
6187
7301
  return arrayPop(hooks[entryPoint]);
6188
7302
  };
6189
7303
  DOMPurify.removeHooks = function (entryPoint) {
7304
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
7305
+ return;
7306
+ }
6190
7307
  hooks[entryPoint] = [];
6191
7308
  };
6192
7309
  DOMPurify.removeAllHooks = function () {
@@ -7001,6 +8118,35 @@ function isKeyboardActivation(event) {
7001
8118
  return event instanceof PointerEvent && event.pointerId === -1
7002
8119
  }
7003
8120
 
8121
+ function trapFocusAtTabBoundary(container, event) {
8122
+ if (event.key !== "Tab") return
8123
+
8124
+ const tabStops = Array.from(container.querySelectorAll("a[href], area[href], button, input:not([type='hidden']), select, textarea, iframe, object, embed, [contenteditable]:not([contenteditable='false']), [tabindex], summary, audio[controls], video[controls]"))
8125
+ .filter(element => element.tabIndex >= 0 && !element.matches(":disabled") && !element.closest("[inert]") && isActiveAndVisible(element))
8126
+ .sort((first, second) => {
8127
+ if (first.tabIndex > 0 && second.tabIndex > 0) {
8128
+ return first.tabIndex - second.tabIndex
8129
+ } else if (first.tabIndex > 0) {
8130
+ return -1
8131
+ } else if (second.tabIndex > 0) {
8132
+ return 1
8133
+ } else {
8134
+ return 0
8135
+ }
8136
+ });
8137
+
8138
+ const firstTabStop = tabStops[0];
8139
+ const lastTabStop = tabStops.at(-1);
8140
+
8141
+ if (event.shiftKey && document.activeElement === firstTabStop) {
8142
+ event.preventDefault();
8143
+ lastTabStop.focus();
8144
+ } else if (!event.shiftKey && document.activeElement === lastTabStop) {
8145
+ event.preventDefault();
8146
+ firstTabStop.focus();
8147
+ }
8148
+ }
8149
+
7004
8150
  function handleRollingTabIndex(elements, event, { orientation = "horizontal", wrap = false } = {}) {
7005
8151
  const previousActiveElement = document.activeElement;
7006
8152
 
@@ -7855,10 +9001,12 @@ class ToolbarDropdown extends HTMLElement {
7855
9001
  }
7856
9002
 
7857
9003
  #handleKeyDown = (event) => {
7858
- if (event.key === "Escape") {
9004
+ if (event.key === "Escape" && this.isOpen) {
7859
9005
  event.stopPropagation();
7860
9006
  this.close({ focusEditor: !this.#shouldReturnFocusToTrigger });
7861
9007
  if (this.#shouldReturnFocusToTrigger) this.trigger?.focus();
9008
+ } else if (this.isOpen && this.panel.role === "dialog") {
9009
+ trapFocusAtTabBoundary(this.panel, event);
7862
9010
  } else if (this.#isNavigatingMenu(event)) {
7863
9011
  event.stopPropagation();
7864
9012
  handleRollingTabIndex(this.#buttons, event, { orientation: "both", wrap: true });
@@ -7873,7 +9021,7 @@ class ToolbarDropdown extends HTMLElement {
7873
9021
  // Ask for the ring explicitly: opening the menu with the mouse otherwise leaves the
7874
9022
  // first item focused without a focus ring, since focus() inherits the mouse modality.
7875
9023
  this.#interactiveElements[0]?.focus({ focusVisible: true });
7876
- await this.#resetTabIndexValues();
9024
+ if (this.panel.role === "menu") await this.#resetTabIndexValues();
7877
9025
  }
7878
9026
 
7879
9027
  async #resetTabIndexValues() {
@@ -8735,12 +9883,33 @@ class CustomActionTextAttachmentNode extends Ji {
8735
9883
  const trimmedLabel = this.label.trim();
8736
9884
  const matches = [ ...figure.querySelectorAll("span") ].filter((span) => span.textContent.trim() === trimmedLabel);
8737
9885
  const deepest = matches.filter((span) => !matches.some((other) => other !== span && span.contains(other)));
9886
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
9887
+
8738
9888
  for (const span of deepest) {
8739
9889
  if (span.getAttribute("aria-hidden") !== "true") {
8740
- span.setAttribute("data-lexxy-label-mirror", "");
9890
+ if (span.contains(labelImage)) {
9891
+ this.#wrapLabelTextNodesIn(span);
9892
+ } else {
9893
+ span.setAttribute("data-lexxy-label-mirror", "");
9894
+ }
8741
9895
  }
8742
9896
  }
8743
9897
  }
9898
+
9899
+ #wrapLabelTextNodesIn(element) {
9900
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
9901
+ const textNodes = [];
9902
+
9903
+ while (walker.nextNode()) {
9904
+ if (walker.currentNode.textContent.trim()) textNodes.push(walker.currentNode);
9905
+ }
9906
+
9907
+ for (const textNode of textNodes) {
9908
+ const mirror = createElement("span", { "data-lexxy-label-mirror": "" });
9909
+ textNode.replaceWith(mirror);
9910
+ mirror.append(textNode);
9911
+ }
9912
+ }
8744
9913
  }
8745
9914
 
8746
9915
  function $isCustomActionTextAttachmentNode(node) {
@@ -9487,7 +10656,7 @@ class ActionTextAttachmentNode extends Ji {
9487
10656
  node: new ActionTextAttachmentNode({
9488
10657
  src: img.getAttribute("src"),
9489
10658
  fileName: fileName,
9490
- caption: img.getAttribute("alt") || "",
10659
+ altText: img.getAttribute("alt"),
9491
10660
  contentType: "image/*",
9492
10661
  width: img.getAttribute("width"),
9493
10662
  height: img.getAttribute("height")
@@ -9812,8 +10981,9 @@ class ActionTextAttachmentNode extends Ji {
9812
10981
  }
9813
10982
 
9814
10983
  #swapToPreviewDOM(figure, previewSrc) {
10984
+ const altText = this.editor.read(() => this.getLatest().altText);
9815
10985
  this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
9816
- const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
10986
+ const img = createElement("img", { src: previewSrc, draggable: false, alt: altText });
9817
10987
  img.onerror = () => this.#swapPreviewToFileDOM(img);
9818
10988
  const container = createElement("div", { className: "attachment__container" });
9819
10989
  container.appendChild(img);
@@ -12746,6 +13916,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12746
13916
  updateDOM(prevNode, dom) {
12747
13917
  if (this.uploadError !== prevNode.uploadError) return true
12748
13918
 
13919
+ const image = dom.querySelector("img");
13920
+ if (image && prevNode.altText !== this.altText) {
13921
+ image.alt = this.altText;
13922
+ }
13923
+
12749
13924
  if (prevNode.progress !== this.progress) {
12750
13925
  const progress = dom.querySelector("progress");
12751
13926
  progress.value = this.progress ?? 0;
@@ -12779,7 +13954,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12779
13954
  }
12780
13955
 
12781
13956
  #createDOMForImage() {
12782
- return createElement("img")
13957
+ return createElement("img", { alt: this.altText })
12783
13958
  }
12784
13959
 
12785
13960
  #createDOMForFile() {
@@ -12944,7 +14119,6 @@ class AttachmentNodeConversion {
12944
14119
  const { blob } = this;
12945
14120
  return {
12946
14121
  sgid: blob.attachable_sgid,
12947
- altText: blob.filename,
12948
14122
  contentType: blob.content_type,
12949
14123
  fileName: blob.filename,
12950
14124
  fileSize: blob.byte_size,
@@ -13822,7 +14996,7 @@ class Contents {
13822
14996
  this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
13823
14997
  }
13824
14998
 
13825
- uploadFiles(files, { selectLast } = {}) {
14999
+ uploadFiles(files, { selectLast, altText } = {}) {
13826
15000
  if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
13827
15001
 
13828
15002
  if (!this.editorElement.supportsAttachments) {
@@ -13835,6 +15009,10 @@ class Contents {
13835
15009
  const uploader = Uploader.for(this.editorElement, validFiles);
13836
15010
  uploader.$uploadFiles();
13837
15011
 
15012
+ if (altText && files.length === 1 && uploader.nodes.length === 1) {
15013
+ uploader.nodes[0].getWritable().altText = altText;
15014
+ }
15015
+
13838
15016
  if (selectLast && uploader.nodes?.length) {
13839
15017
  const lastNode = uploader.nodes.at(-1);
13840
15018
  lastNode.selectEnd();
@@ -14619,46 +15797,51 @@ class Clipboard {
14619
15797
  }
14620
15798
 
14621
15799
  #handlePastedFiles(clipboardData) {
14622
- if (!this.editorElement.supportsAttachments) return false
14623
-
14624
- const html = clipboardData.getData("text/html");
14625
- const files = clipboardData.files;
14626
-
14627
- if (files.length && this.#isCopiedImageHTML(html)) {
14628
- this.#uploadFilesPreservingScroll(files);
14629
- return true
14630
- }
14631
-
14632
- if (html && !this.#isLexicalClipboardData(clipboardData)) {
14633
- this.contents.insertHtml(html, { tag: jn });
14634
- return true
14635
- }
15800
+ if (this.editorElement.supportsAttachments) {
15801
+ const html = clipboardData.getData("text/html");
15802
+ const files = clipboardData.files;
15803
+ const copiedImage = files.length && this.#copiedImage(html);
14636
15804
 
14637
- if (files.length) {
14638
- this.#uploadFilesPreservingScroll(files);
14639
- return true
15805
+ if (copiedImage) {
15806
+ this.#uploadFilesPreservingScroll(files, copiedImage.getAttribute("alt"));
15807
+ return true
15808
+ } else if (html && !this.#isLexicalClipboardData(clipboardData)) {
15809
+ this.contents.insertHtml(html, { tag: jn });
15810
+ return true
15811
+ } else if (files.length) {
15812
+ this.#uploadFilesPreservingScroll(files);
15813
+ return true
15814
+ } else {
15815
+ return false
15816
+ }
15817
+ } else {
15818
+ return false
14640
15819
  }
14641
-
14642
- return false
14643
15820
  }
14644
15821
 
14645
15822
  #isLexicalClipboardData(clipboardData) {
14646
15823
  return Array.from(clipboardData.types).includes("application/x-lexical-editor")
14647
15824
  }
14648
15825
 
14649
- #isCopiedImageHTML(html) {
14650
- if (!html) return false
14651
-
14652
- const doc = parseHtml(html);
14653
- const elementChildren = Array.from(doc.body.children);
15826
+ #copiedImage(html) {
15827
+ if (html) {
15828
+ const doc = parseHtml(html);
15829
+ const elementChildren = Array.from(doc.body.children);
14654
15830
 
14655
- return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
15831
+ if (elementChildren.length === 1 && elementChildren[0].tagName === "IMG") {
15832
+ return elementChildren[0]
15833
+ } else {
15834
+ return null
15835
+ }
15836
+ } else {
15837
+ return null
15838
+ }
14656
15839
  }
14657
15840
 
14658
- #uploadFilesPreservingScroll(files) {
15841
+ #uploadFilesPreservingScroll(files, altText) {
14659
15842
  this.#preservingScrollPosition(() => {
14660
15843
  if (files.length) {
14661
- this.contents.uploadFiles(files, { selectLast: true });
15844
+ this.contents.uploadFiles(files, { selectLast: true, altText });
14662
15845
  }
14663
15846
  });
14664
15847
  }
@@ -16958,6 +18141,7 @@ class LexicalEditorElement extends HTMLElement {
16958
18141
  #historyState = { undo: false, redo: false }
16959
18142
 
16960
18143
  #validity = new Map()
18144
+ #validationAttempted = false
16961
18145
  #validationTextArea = document.createElement("textarea")
16962
18146
  #uploadRequests
16963
18147
  #liveRegion
@@ -17042,10 +18226,13 @@ class LexicalEditorElement extends HTMLElement {
17042
18226
  }
17043
18227
 
17044
18228
  requiredChangedCallback() {
18229
+ if (this.editorContentElement) this.#synchronizeAriaAttribute("aria-required");
17045
18230
  if (this.isConnected) this.#requestValidityRefresh();
17046
18231
  }
17047
18232
 
17048
18233
  formResetCallback() {
18234
+ this.#validationAttempted = false;
18235
+ this.#synchronizeAriaAttribute("aria-invalid");
17049
18236
  this.value = this.#initialValue;
17050
18237
  this.editor.dispatchCommand(Ye$1, undefined);
17051
18238
  }
@@ -17072,6 +18259,15 @@ class LexicalEditorElement extends HTMLElement {
17072
18259
  return this.internals.validity
17073
18260
  }
17074
18261
 
18262
+ get validationMessage() {
18263
+ return this.internals.validationMessage
18264
+ }
18265
+
18266
+ setCustomValidity(message) {
18267
+ this.#validationTextArea.setCustomValidity(message);
18268
+ this.#refreshValidity();
18269
+ }
18270
+
17075
18271
  checkValidity() {
17076
18272
  return this.internals.checkValidity()
17077
18273
  }
@@ -17309,6 +18505,7 @@ class LexicalEditorElement extends HTMLElement {
17309
18505
  this.#attachDebugHooks();
17310
18506
  this.#attachToolbar();
17311
18507
  this.#resetBeforeTurboCaches();
18508
+ this.#observeAriaAttributes();
17312
18509
 
17313
18510
  this.#setInternalFormValue(this.value, { suppressEvent: true });
17314
18511
  this.#synchronizeWithChanges();
@@ -17381,7 +18578,9 @@ class LexicalEditorElement extends HTMLElement {
17381
18578
  classList: "lexxy-editor__content",
17382
18579
  contenteditable: true,
17383
18580
  role: "textbox",
17384
- "aria-multiline": true,
18581
+ "aria-multiline": this.supportsMultiLine,
18582
+ "aria-required": this.required,
18583
+ "aria-invalid": false,
17385
18584
  "aria-label": this.#labelText,
17386
18585
  placeholder: this.getAttribute("placeholder")
17387
18586
  });
@@ -17414,6 +18613,46 @@ class LexicalEditorElement extends HTMLElement {
17414
18613
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
17415
18614
  }
17416
18615
 
18616
+ #observeAriaAttributes() {
18617
+ const observer = new MutationObserver(mutations => {
18618
+ for (const { attributeName } of mutations) {
18619
+ if (attributeName.startsWith("aria-")) {
18620
+ this.#synchronizeAriaAttribute(attributeName);
18621
+ } else if (attributeName === "single-line") {
18622
+ this.#synchronizeAriaAttribute("aria-multiline");
18623
+ }
18624
+ }
18625
+ });
18626
+ observer.observe(this, { attributes: true });
18627
+
18628
+ this.#listeners.track(
18629
+ () => observer.disconnect(),
18630
+ registerEventListener(this, "invalid", () => {
18631
+ this.#validationAttempted = true;
18632
+ this.#synchronizeAriaAttribute("aria-invalid");
18633
+ })
18634
+ );
18635
+ }
18636
+
18637
+ #synchronizeAriaAttribute(name) {
18638
+ const value = this.getAttribute(name) ?? this.#defaultAriaValue(name);
18639
+ if (value === null) {
18640
+ this.editorContentElement.removeAttribute(name);
18641
+ } else if (this.editorContentElement.getAttribute(name) !== String(value)) {
18642
+ this.editorContentElement.setAttribute(name, value);
18643
+ }
18644
+ }
18645
+
18646
+ #defaultAriaValue(name) {
18647
+ switch (name) {
18648
+ case "aria-label": return this.#labelText
18649
+ case "aria-required": return this.required
18650
+ case "aria-multiline": return this.supportsMultiLine
18651
+ case "aria-invalid": return this.#validationAttempted && !this.validity.valid
18652
+ default: return null
18653
+ }
18654
+ }
18655
+
17417
18656
  #setInternalFormValue(html, { suppressEvent = false } = {}) {
17418
18657
  const changed = html !== this.#previousInternalFormValue;
17419
18658
 
@@ -17470,11 +18709,16 @@ class LexicalEditorElement extends HTMLElement {
17470
18709
  #refreshValidity() {
17471
18710
  this.#refreshInternalValidity();
17472
18711
  const { validity, message } = this.#calculateValidity();
17473
- this.internals.setValidity(validity, message, this.editorContentElement);
18712
+ this.internals.setValidity(validity, message, this.editorContentElement ?? undefined);
18713
+ if (this.editorContentElement) {
18714
+ this.#synchronizeAriaAttribute("aria-invalid");
18715
+ }
17474
18716
  }
17475
18717
 
17476
18718
  #refreshInternalValidity() {
17477
- this.#validationTextArea.required = this.required && this.isBlank;
18719
+ if (this.editorContentElement) {
18720
+ this.#validationTextArea.required = this.required && this.isBlank;
18721
+ }
17478
18722
  const flags = this.#validationTextArea.validity;
17479
18723
  const message = this.#validationTextArea.validationMessage;
17480
18724
 
@@ -17489,12 +18733,14 @@ class LexicalEditorElement extends HTMLElement {
17489
18733
  // internal TextArea's ValidityState can contain `valid: true`
17490
18734
  if (flags.valid === true) continue
17491
18735
 
18736
+ let hasError = false;
17492
18737
  for (const flag in flags) {
17493
18738
  if (flags[flag]) {
17494
18739
  validity[flag] = true;
17495
- messages.push(message);
18740
+ hasError = true;
17496
18741
  }
17497
18742
  }
18743
+ if (hasError) messages.push(message);
17498
18744
  }
17499
18745
 
17500
18746
  return { validity, message: messages.join("\n") }
@@ -17812,6 +19058,7 @@ class LexicalEditorElement extends HTMLElement {
17812
19058
 
17813
19059
  #resetValidity() {
17814
19060
  this.#validity = new Map();
19061
+ this.#validationAttempted = false;
17815
19062
  }
17816
19063
  }
17817
19064
 
@@ -18757,6 +20004,111 @@ class CodeLanguagePicker extends HTMLElement {
18757
20004
  }
18758
20005
  }
18759
20006
 
20007
+ class AlternativeTextDialog {
20008
+ #editor
20009
+ #editorElement
20010
+ #dialog
20011
+ #input
20012
+ #nodeKey
20013
+ #elementToRefocus
20014
+ #listeners = new ListenerBin()
20015
+
20016
+ constructor(editorElement) {
20017
+ this.#editor = editorElement.editor;
20018
+ this.#editorElement = editorElement;
20019
+ this.#dialog = createElement("dialog", { className: "lexxy-alternative-text-dialog", ariaLabel: "Alternative text" });
20020
+ this.#input = createElement("textarea", { rows: 1, autofocus: true, ariaLabel: "Description", placeholder: "Keep it short, but enough to convey the context…" });
20021
+
20022
+ const title = createElement("h2", { textContent: "Describe the image for people who can't see it:" });
20023
+
20024
+ const actions = createElement("div", { className: "lexxy-alternative-text-dialog__actions" });
20025
+ const cancel = createElement("button", { type: "button", textContent: "Cancel" });
20026
+ const save = createElement("button", { type: "button", className: "lexxy-alternative-text-dialog__save", textContent: "Save" });
20027
+ actions.append(cancel, save);
20028
+
20029
+ const controls = createElement("div", { className: "lexxy-alternative-text-dialog__controls" });
20030
+ controls.append(this.#input, actions);
20031
+ this.#dialog.append(title, controls);
20032
+ editorElement.appendChild(this.#dialog);
20033
+
20034
+ this.#listeners.track(
20035
+ registerEventListener(cancel, "click", () => this.#close()),
20036
+ registerEventListener(save, "click", () => this.#save()),
20037
+ registerEventListener(this.#dialog, "keydown", this.#handleKeyDown),
20038
+ registerEventListener(document, "pointerdown", this.#closeOnClickOutside)
20039
+ );
20040
+ }
20041
+
20042
+ dispose() {
20043
+ this.#dialog.remove();
20044
+ this.#listeners.dispose();
20045
+ }
20046
+
20047
+ open(nodeKey) {
20048
+ const description = this.#editor.read(() => {
20049
+ const node = Yo(nodeKey);
20050
+ if ($isActionTextAttachmentNode(node)) {
20051
+ return node.altText
20052
+ }
20053
+ });
20054
+
20055
+ if (description !== undefined) {
20056
+ this.#nodeKey = nodeKey;
20057
+ this.#input.value = description;
20058
+ this.#anchorBelowAttachment(nodeKey);
20059
+ this.#elementToRefocus = document.activeElement;
20060
+ this.#editorElement.querySelector("lexxy-toolbar")?.closeDropdowns();
20061
+ this.#dialog.show();
20062
+ this.#input.focus();
20063
+ }
20064
+ }
20065
+
20066
+ #anchorBelowAttachment(nodeKey) {
20067
+ const figureElement = this.#editor.getElementByKey(nodeKey);
20068
+ if (figureElement) {
20069
+ const rect = figureElement.getBoundingClientRect();
20070
+ const editorRect = this.#editorElement.getBoundingClientRect();
20071
+ const bottom = rect.bottom - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop;
20072
+ this.#dialog.style.setProperty("--lexxy-anchor-bottom", `${bottom}px`);
20073
+ }
20074
+ }
20075
+
20076
+ #save() {
20077
+ const description = this.#input.value.trim();
20078
+ this.#editor.update(() => {
20079
+ const node = Yo(this.#nodeKey);
20080
+ if ($isActionTextAttachmentNode(node) && node.altText !== description) {
20081
+ node.getWritable().altText = description;
20082
+ }
20083
+ }, { tag: [ $n$1, qn ] });
20084
+ this.#close();
20085
+ }
20086
+
20087
+ #handleKeyDown = (event) => {
20088
+ event.stopPropagation();
20089
+ if (event.key === "Escape") {
20090
+ this.#close();
20091
+ } else {
20092
+ trapFocusAtTabBoundary(this.#dialog, event);
20093
+ }
20094
+ }
20095
+
20096
+ // Dismissing by clicking elsewhere must leave focus where the click landed.
20097
+ #closeOnClickOutside = (event) => {
20098
+ if (this.#dialog.open && !this.#dialog.contains(event.target)) {
20099
+ this.#close({ refocus: false });
20100
+ }
20101
+ }
20102
+
20103
+ #close({ refocus = true } = {}) {
20104
+ this.#dialog.close();
20105
+ if (refocus && this.#elementToRefocus?.isConnected) {
20106
+ this.#elementToRefocus.focus();
20107
+ }
20108
+ this.#elementToRefocus = null;
20109
+ }
20110
+ }
20111
+
18760
20112
  const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
18761
20113
  <path d="M11.2041 1.01074C12.2128 1.113 13 1.96435 13 3V4H15L15.1025 4.00488C15.6067 4.05621 16 4.48232 16 5C16 5.55228 15.5523 6 15 6H14.8457L14.1416 15.1533C14.0614 16.1953 13.1925 17 12.1475 17H5.85254L5.6582 16.9902C4.76514 16.9041 4.03607 16.2296 3.88184 15.3457L3.8584 15.1533L3.1543 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H5V3C5 1.89543 5.89543 1 7 1H11L11.2041 1.01074ZM5.85254 15H12.1475L12.8398 6H5.16016L5.85254 15ZM7 4H11V3H7V4Z"/>
18762
20114
  </svg>`;
@@ -18764,6 +20116,8 @@ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"
18764
20116
  class AttachmentToolbar extends HTMLElement {
18765
20117
  #listeners = new ListenerBin()
18766
20118
  #buttons = []
20119
+ #alternativeTextButton
20120
+ #alternativeTextDialog
18767
20121
  #currentNodeKey = null
18768
20122
  #reflowObserver = new ResizeObserver(() => this.#updatePosition())
18769
20123
  #presentationObserver = new MutationObserver(() => this.#refresh())
@@ -18790,6 +20144,7 @@ class AttachmentToolbar extends HTMLElement {
18790
20144
  this.#listeners.dispose();
18791
20145
  this.#reflowObserver.disconnect();
18792
20146
  this.#presentationObserver.disconnect();
20147
+ this.#alternativeTextDialog?.dispose();
18793
20148
  }
18794
20149
 
18795
20150
  get #editor() {
@@ -18804,7 +20159,9 @@ class AttachmentToolbar extends HTMLElement {
18804
20159
  this.innerHTML = "";
18805
20160
 
18806
20161
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
20162
+ container.appendChild(this.#createAlternativeTextButton());
18807
20163
  container.appendChild(this.#createRemoveButton());
20164
+ this.#alternativeTextDialog = new AlternativeTextDialog(this.#editorElement);
18808
20165
  this.appendChild(container);
18809
20166
 
18810
20167
  this.#buttons = Array.from(this.querySelectorAll("button"));
@@ -18837,6 +20194,22 @@ class AttachmentToolbar extends HTMLElement {
18837
20194
  return this.#currentNodeKey !== null
18838
20195
  }
18839
20196
 
20197
+ #createAlternativeTextButton() {
20198
+ this.#alternativeTextButton = createElement("button", {
20199
+ type: "button",
20200
+ className: "lexxy-attachment-toolbar__alternative-text",
20201
+ ariaLabel: "Alternative text",
20202
+ title: "Alternative text",
20203
+ ariaHasPopup: "dialog",
20204
+ tabIndex: -1
20205
+ });
20206
+ this.#alternativeTextButton.appendChild(createElement("span", null, "ALT"));
20207
+ this.#listeners.track(registerEventListener(this.#alternativeTextButton, "click", () => {
20208
+ this.#alternativeTextDialog.open(this.#currentNodeKey);
20209
+ }));
20210
+ return this.#alternativeTextButton
20211
+ }
20212
+
18840
20213
  #navigateOrExit = (event) => {
18841
20214
  if (event.key === "Escape") {
18842
20215
  event.preventDefault();
@@ -18909,6 +20282,7 @@ class AttachmentToolbar extends HTMLElement {
18909
20282
 
18910
20283
  this.#editor.getEditorState().read(() => {
18911
20284
  const node = Yo(nodeKey);
20285
+ this.#alternativeTextButton.hidden = !this.#canEditAlternativeText(node);
18912
20286
  this.dataset.nodeType = node.getType();
18913
20287
  this.dataset.presentation = this.#presentationOf(node, element);
18914
20288
 
@@ -18920,6 +20294,12 @@ class AttachmentToolbar extends HTMLElement {
18920
20294
  });
18921
20295
  }
18922
20296
 
20297
+ #canEditAlternativeText(node) {
20298
+ return this.#editorElement.dataset.actionTextSupportsAlt !== "false"
20299
+ && node.getType() === "action_text_attachment"
20300
+ && node.isPreviewableAttachment
20301
+ }
20302
+
18923
20303
  #presentationOf(node, element) {
18924
20304
  if ($isImageGalleryNode(node.getParent())) {
18925
20305
  return "gallery"
@@ -18941,8 +20321,11 @@ class AttachmentToolbar extends HTMLElement {
18941
20321
  if (figureElement) {
18942
20322
  const rect = figureElement.getBoundingClientRect();
18943
20323
  const editorRect = this.#editorElement.getBoundingClientRect();
18944
- this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top}px`);
18945
- this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left}px`);
20324
+ // We are positioned against the editor's padding box, but the rect measures its border box.
20325
+ const borderTop = this.#editorElement.clientTop;
20326
+ const borderLeft = this.#editorElement.clientLeft;
20327
+ this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top - borderTop}px`);
20328
+ this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left - borderLeft}px`);
18946
20329
  this.style.setProperty("--lexxy-anchor-width", `${rect.width}px`);
18947
20330
  this.style.setProperty("--lexxy-anchor-height", `${rect.height}px`);
18948
20331
  this.#flipWhenOverflowing(editorRect);
@@ -18982,7 +20365,7 @@ class AttachmentToolbar extends HTMLElement {
18982
20365
  #focusToolbarOnAltF10 = (event) => {
18983
20366
  if (this.#hasSelectedNode && event.altKey && event.key === "F10") {
18984
20367
  event.preventDefault();
18985
- this.#buttons[0]?.focus();
20368
+ this.#buttons.find(isActiveAndVisible)?.focus();
18986
20369
  return true
18987
20370
  } else {
18988
20371
  return false