tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

output-parser.js (76208B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const {
      8  angleUtils,
      9 } = require("resource://devtools/client/shared/css-angle.js");
     10 const { colorUtils } = require("resource://devtools/shared/css/color.js");
     11 const {
     12  InspectorCSSParserWrapper,
     13 } = require("resource://devtools/shared/css/lexer.js");
     14 const {
     15  appendText,
     16 } = require("resource://devtools/client/inspector/shared/utils.js");
     17 
     18 const STYLE_INSPECTOR_PROPERTIES =
     19  "devtools/shared/locales/styleinspector.properties";
     20 
     21 loader.lazyGetter(this, "STYLE_INSPECTOR_L10N", function () {
     22  const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
     23  return new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
     24 });
     25 
     26 loader.lazyGetter(this, "VARIABLE_JUMP_DEFINITION_TITLE", function () {
     27  return STYLE_INSPECTOR_L10N.getStr("rule.variableJumpDefinition.title");
     28 });
     29 
     30 // Functions that accept an angle argument.
     31 const ANGLE_TAKING_FUNCTIONS = new Set([
     32  "linear-gradient",
     33  "-moz-linear-gradient",
     34  "repeating-linear-gradient",
     35  "-moz-repeating-linear-gradient",
     36  "conic-gradient",
     37  "repeating-conic-gradient",
     38  "rotate",
     39  "rotateX",
     40  "rotateY",
     41  "rotateZ",
     42  "rotate3d",
     43  "skew",
     44  "skewX",
     45  "skewY",
     46  "hue-rotate",
     47 ]);
     48 // All cubic-bezier CSS timing-function names.
     49 const BEZIER_KEYWORDS = new Set([
     50  "linear",
     51  "ease-in-out",
     52  "ease-in",
     53  "ease-out",
     54  "ease",
     55 ]);
     56 // Functions that accept a color argument.
     57 const COLOR_TAKING_FUNCTIONS = new Set([
     58  "linear-gradient",
     59  "-moz-linear-gradient",
     60  "repeating-linear-gradient",
     61  "-moz-repeating-linear-gradient",
     62  "radial-gradient",
     63  "-moz-radial-gradient",
     64  "repeating-radial-gradient",
     65  "-moz-repeating-radial-gradient",
     66  "conic-gradient",
     67  "repeating-conic-gradient",
     68  "drop-shadow",
     69  "color-mix",
     70  "contrast-color",
     71  "light-dark",
     72 ]);
     73 // Functions that accept a shape argument.
     74 const BASIC_SHAPE_FUNCTIONS = new Set([
     75  "polygon",
     76  "circle",
     77  "ellipse",
     78  "inset",
     79 ]);
     80 
     81 const BACKDROP_FILTER_ENABLED = Services.prefs.getBoolPref(
     82  "layout.css.backdrop-filter.enabled"
     83 );
     84 const HTML_NS = "http://www.w3.org/1999/xhtml";
     85 
     86 // This regexp matches a URL token.  It puts the "url(", any
     87 // leading whitespace, and any opening quote into |leader|; the
     88 // URL text itself into |body|, and any trailing quote, trailing
     89 // whitespace, and the ")" into |trailer|.
     90 const URL_REGEX =
     91  /^(?<leader>url\([ \t\r\n\f]*(["']?))(?<body>.*?)(?<trailer>\2[ \t\r\n\f]*\))$/i;
     92 
     93 // Very long text properties should be truncated using CSS to avoid creating
     94 // extremely tall propertyvalue containers. 5000 characters is an arbitrary
     95 // limit. Assuming an average ruleview can hold 50 characters per line, this
     96 // should start truncating properties which would otherwise be 100 lines long.
     97 const TRUNCATE_LENGTH_THRESHOLD = 5000;
     98 const TRUNCATE_NODE_CLASSNAME = "propertyvalue-long-text";
     99 
    100 /**
    101 * This module is used to process CSS text declarations and output DOM fragments (to be
    102 * appended to panels in DevTools) for CSS values decorated with additional UI and
    103 * functionality.
    104 *
    105 * For example:
    106 * - attaching swatches for values instrumented with specialized tools: colors, timing
    107 * functions (cubic-bezier), filters, shapes, display values (flex/grid), etc.
    108 * - adding previews where possible (images, fonts, CSS transforms).
    109 * - converting between color types on Shift+click on their swatches.
    110 *
    111 * Usage:
    112 *   const OutputParser = require("devtools/client/shared/output-parser");
    113 *   const parser = new OutputParser(document, cssProperties);
    114 *   parser.parseCssProperty("color", "red"); // Returns document fragment.
    115 *
    116 */
    117 class OutputParser {
    118  /**
    119   * @param {Document} document
    120   *        Used to create DOM nodes.
    121   * @param {CssProperties} cssProperties
    122   *        Instance of CssProperties, an object which provides an interface for
    123   *        working with the database of supported CSS properties and values.
    124   */
    125  constructor(document, cssProperties) {
    126    this.#doc = document;
    127    this.#cssProperties = cssProperties;
    128  }
    129 
    130  #angleSwatches = new WeakMap();
    131  #colorSwatches = new WeakMap();
    132  #cssProperties;
    133  #doc;
    134  #parsed = [];
    135  #stack = [];
    136 
    137  /**
    138   * Parse a CSS property value given a property name.
    139   *
    140   * @param  {string} name
    141   *         CSS Property Name
    142   * @param  {string} value
    143   *         CSS Property value
    144   * @param  {object} [options]
    145   *         Options object. For valid options and default values see
    146   *         #mergeOptions().
    147   * @return {DocumentFragment}
    148   *         A document fragment containing color swatches etc.
    149   */
    150  parseCssProperty(name, value, options = {}) {
    151    options = this.#mergeOptions(options);
    152 
    153    options.expectCubicBezier = this.#cssProperties.supportsType(
    154      name,
    155      "timing-function"
    156    );
    157    options.expectLinearEasing = this.#cssProperties.supportsType(
    158      name,
    159      "timing-function"
    160    );
    161    options.expectDisplay = name === "display";
    162    options.expectFilter =
    163      name === "filter" ||
    164      (BACKDROP_FILTER_ENABLED && name === "backdrop-filter");
    165    options.expectShape =
    166      name === "clip-path" ||
    167      name === "shape-outside" ||
    168      name === "offset-path";
    169    options.expectFont = name === "font-family";
    170    options.isVariable = name.startsWith("--");
    171    options.supportsColor =
    172      this.#cssProperties.supportsType(name, "color") ||
    173      this.#cssProperties.supportsType(name, "gradient") ||
    174      // Parse colors for CSS variables declaration if the declaration value or the computed
    175      // value are valid colors.
    176      (options.isVariable &&
    177        (InspectorUtils.isValidCSSColor(value) ||
    178          InspectorUtils.isValidCSSColor(
    179            options.getVariableData?.(name).computedValue
    180          )));
    181 
    182    if (this.#cssPropertySupportsValue(name, value, options)) {
    183      return this.#parse(value, options);
    184    }
    185    this.#appendTextNode(value);
    186 
    187    return this.#toDOM();
    188  }
    189 
    190  /**
    191   * Read tokens from |tokenStream| and collect all the (non-comment)
    192   * text. Return the collected texts and variable data (if any).
    193   * Stop when an unmatched closing paren is seen.
    194   * If |stopAtComma| is true, then also stop when a top-level
    195   * (unparenthesized) comma is seen.
    196   *
    197   * @param  {string} text
    198   *         The original source text.
    199   * @param  {CSSLexer} tokenStream
    200   *         The token stream from which to read.
    201   * @param  {object} options
    202   *         The options object in use; @see #mergeOptions.
    203   * @param  {boolean} stopAtComma
    204   *         If true, stop at a comma.
    205   * @return {object}
    206   *         An object of the form {tokens, functionData, sawComma, sawVariable, depth}.
    207   *         |tokens| is a list of the non-comment, non-whitespace tokens
    208   *         that were seen. The stopping token (paren or comma) will not
    209   *         be included.
    210   *         |functionData| is a list of parsed strings and nodes that contain the
    211   *         data between the matching parenthesis. The stopping token's text will
    212   *         not be included.
    213   *         |sawComma| is true if the stop was due to a comma, or false otherwise.
    214   *         |sawVariable| is true if a variable was seen while parsing the text.
    215   *         |depth| is the number of unclosed parenthesis remaining when we return.
    216   */
    217  #parseMatchingParens(text, tokenStream, options, stopAtComma) {
    218    let depth = 1;
    219    const functionData = [];
    220    const tokens = [];
    221    let sawVariable = false;
    222 
    223    while (depth > 0) {
    224      const token = tokenStream.nextToken();
    225      if (!token) {
    226        break;
    227      }
    228      if (token.tokenType === "Comment") {
    229        continue;
    230      }
    231 
    232      if (stopAtComma && depth === 1 && token.tokenType === "Comma") {
    233        return { tokens, functionData, sawComma: true, sawVariable, depth };
    234      } else if (token.tokenType === "ParenthesisBlock") {
    235        ++depth;
    236      } else if (token.tokenType === "CloseParenthesis") {
    237        this.#onCloseParenthesis(options);
    238        --depth;
    239        if (depth === 0) {
    240          break;
    241        }
    242      } else if (
    243        token.tokenType === "Function" &&
    244        token.value === "var" &&
    245        options.getVariableData
    246      ) {
    247        sawVariable = true;
    248        const { node, value, computedValue, fallbackValue } =
    249          this.#parseVariable(token, text, tokenStream, options);
    250        functionData.push({ node, value, computedValue, fallbackValue });
    251      } else if (token.tokenType === "Function") {
    252        ++depth;
    253      }
    254 
    255      if (
    256        token.tokenType !== "Function" ||
    257        token.value !== "var" ||
    258        !options.getVariableData
    259      ) {
    260        functionData.push(text.substring(token.startOffset, token.endOffset));
    261      }
    262 
    263      if (token.tokenType !== "WhiteSpace") {
    264        tokens.push(token);
    265      }
    266    }
    267 
    268    return { tokens, functionData, sawComma: false, sawVariable, depth };
    269  }
    270 
    271  /**
    272   * Parse var() use and return a variable node to be added to the output state.
    273   * This will read tokens up to and including the ")" that closes the "var("
    274   * invocation.
    275   *
    276   * @param  {CSSToken} initialToken
    277   *         The "var(" token that was already seen.
    278   * @param  {string} text
    279   *         The original input text.
    280   * @param  {CSSLexer} tokenStream
    281   *         The token stream from which to read.
    282   * @param  {object} options
    283   *         The options object in use; @see #mergeOptions.
    284   * @return {object}
    285   *         - node: A node for the variable, with the appropriate text and
    286   *           title. Eg. a span with "var(--var1)" as the textContent
    287   *           and a title for --var1 like "--var1 = 10" or
    288   *           "--var1 is not set".
    289   *         - value: The value for the variable.
    290   */
    291  #parseVariable(initialToken, text, tokenStream, options) {
    292    // Handle the "var(".
    293    const varText = text.substring(
    294      initialToken.startOffset,
    295      initialToken.endOffset
    296    );
    297    const variableNode = this.#createNode("span", {}, varText);
    298 
    299    // Parse the first variable name within the parens of var().
    300    const { tokens, functionData, sawComma, sawVariable } =
    301      this.#parseMatchingParens(text, tokenStream, options, true);
    302 
    303    const result = sawVariable ? "" : functionData.join("");
    304 
    305    // Display options for the first and second argument in the var().
    306    const firstOpts = {};
    307    const secondOpts = {};
    308 
    309    let varData;
    310    let varFallbackValue;
    311    let varSubstitutedValue;
    312    let varComputedValue;
    313    let varName;
    314 
    315    // Get the variable value if it is in use.
    316    if (tokens && tokens.length === 1) {
    317      varName = tokens[0].text;
    318      varData = options.getVariableData(varName);
    319      const varValue =
    320        typeof varData.value === "string"
    321          ? varData.value
    322          : varData.registeredProperty?.initialValue;
    323 
    324      const varStartingStyleValue =
    325        typeof varData.startingStyle === "string"
    326          ? varData.startingStyle
    327          : // If the variable is not set in starting style, then it will default to either:
    328            // - a declaration in a "regular" rule
    329            // - or if there's no declaration in regular rule, to the registered property initial-value.
    330            varValue;
    331 
    332      varSubstitutedValue = options.inStartingStyleRule
    333        ? varStartingStyleValue
    334        : varValue;
    335 
    336      varComputedValue = varData.computedValue;
    337    }
    338 
    339    if (typeof varSubstitutedValue === "string") {
    340      // The variable value is valid, store the substituted value in a data attribute to
    341      // be reused by the variable tooltip.
    342      firstOpts["data-variable"] = varSubstitutedValue;
    343      firstOpts.class = options.matchedVariableClass;
    344      secondOpts.class = options.unmatchedClass;
    345 
    346      // Display computed value when it exists, is different from the substituted value
    347      // we computed, and we're not inside a starting-style rule
    348      if (
    349        !options.inStartingStyleRule &&
    350        typeof varComputedValue === "string" &&
    351        varComputedValue !== varSubstitutedValue
    352      ) {
    353        firstOpts["data-variable-computed"] = varComputedValue;
    354      }
    355 
    356      // Display starting-style value when not in a starting style rule
    357      if (
    358        !options.inStartingStyleRule &&
    359        typeof varData.startingStyle === "string"
    360      ) {
    361        firstOpts["data-starting-style-variable"] = varData.startingStyle;
    362      }
    363 
    364      if (varData.registeredProperty) {
    365        const { initialValue, syntax, inherits } = varData.registeredProperty;
    366        firstOpts["data-registered-property-initial-value"] = initialValue;
    367        firstOpts["data-registered-property-syntax"] = syntax;
    368        // createNode does not handle `false`, let's stringify the boolean.
    369        firstOpts["data-registered-property-inherits"] = `${inherits}`;
    370      }
    371 
    372      const customPropNode = this.#createNode("span", firstOpts, result);
    373      if (options.showJumpToVariableButton) {
    374        customPropNode.append(
    375          this.#createNode("button", {
    376            class: "ruleview-variable-link jump-definition",
    377            "data-variable-name": varName,
    378            title: VARIABLE_JUMP_DEFINITION_TITLE,
    379          })
    380        );
    381      }
    382 
    383      variableNode.appendChild(customPropNode);
    384    } else if (varName) {
    385      // The variable is not set and does not have an initial value, mark it unmatched.
    386      firstOpts.class = options.unmatchedClass;
    387 
    388      firstOpts["data-variable"] = STYLE_INSPECTOR_L10N.getFormatStr(
    389        "rule.variableUnset",
    390        varName
    391      );
    392      variableNode.appendChild(this.#createNode("span", firstOpts, result));
    393    }
    394 
    395    // If we saw a ",", then append it and show the remainder using
    396    // the correct highlighting.
    397    if (sawComma) {
    398      variableNode.appendChild(this.#doc.createTextNode(","));
    399 
    400      // Parse the text up until the close paren, being sure to
    401      // disable the special case for filter.
    402      const subOptions = Object.assign({}, options);
    403      subOptions.expectFilter = false;
    404      const saveParsed = this.#parsed;
    405      const savedStack = this.#stack;
    406      this.#parsed = [];
    407      this.#stack = [];
    408      const rest = this.#doParse(text, subOptions, tokenStream, true);
    409      this.#parsed = saveParsed;
    410      this.#stack = savedStack;
    411 
    412      const span = this.#createNode("span", secondOpts);
    413      span.appendChild(rest);
    414      varFallbackValue = span.textContent;
    415      variableNode.appendChild(span);
    416    }
    417    variableNode.appendChild(this.#doc.createTextNode(")"));
    418 
    419    return {
    420      node: variableNode,
    421      value: varSubstitutedValue,
    422      computedValue: varComputedValue,
    423      fallbackValue: varFallbackValue,
    424    };
    425  }
    426 
    427  /**
    428   * The workhorse for @see #parse. This parses some CSS text,
    429   * stopping at EOF; or optionally when an umatched close paren is
    430   * seen.
    431   *
    432   * @param  {string} text
    433   *         The original input text.
    434   * @param  {object} options
    435   *         The options object in use; @see #mergeOptions.
    436   * @param  {CSSLexer} tokenStream
    437   *         The token stream from which to read
    438   * @param  {boolean} stopAtCloseParen
    439   *         If true, stop at an umatched close paren.
    440   * @return {DocumentFragment}
    441   *         A document fragment.
    442   */
    443  // eslint-disable-next-line complexity
    444  #doParse(text, options, tokenStream, stopAtCloseParen) {
    445    let fontFamilyNameParts = [];
    446    let previousWasBang = false;
    447 
    448    const colorOK = () => {
    449      return (
    450        options.supportsColor ||
    451        ((options.expectFilter || options.isVariable) &&
    452          this.#stack.length !== 0 &&
    453          this.#stack.at(-1).isColorTakingFunction)
    454      );
    455    };
    456 
    457    const angleOK = function (angle) {
    458      return new angleUtils.CssAngle(angle).valid;
    459    };
    460 
    461    let spaceNeeded = false;
    462    let done = false;
    463 
    464    while (!done) {
    465      const token = tokenStream.nextToken();
    466      if (!token) {
    467        break;
    468      }
    469      const lowerCaseTokenText = token.text?.toLowerCase();
    470 
    471      if (token.tokenType === "Comment") {
    472        // This doesn't change spaceNeeded, because we didn't emit
    473        // anything to the output.
    474        continue;
    475      }
    476 
    477      switch (token.tokenType) {
    478        case "Function": {
    479          const functionName = token.value;
    480          const lowerCaseFunctionName = functionName.toLowerCase();
    481 
    482          const isColorTakingFunction = COLOR_TAKING_FUNCTIONS.has(
    483            lowerCaseFunctionName
    484          );
    485 
    486          this.#stack.push({
    487            lowerCaseFunctionName,
    488            functionName,
    489            isColorTakingFunction,
    490            // The position of the function separators ("," or "/") in the `parts` property
    491            separatorIndexes: [],
    492            // The parsed parts of the function that will be rendered on screen.
    493            // This can hold both simple strings and DOMNodes.
    494            parts: [],
    495          });
    496 
    497          if (
    498            isColorTakingFunction ||
    499            ANGLE_TAKING_FUNCTIONS.has(lowerCaseFunctionName)
    500          ) {
    501            // The function can accept a color or an angle argument, and we know
    502            // it isn't special in some other way. So, we let it
    503            // through to the ordinary parsing loop so that the value
    504            // can be handled in a single place.
    505            this.#appendTextNode(
    506              text.substring(token.startOffset, token.endOffset)
    507            );
    508          } else if (
    509            lowerCaseFunctionName === "var" &&
    510            options.getVariableData
    511          ) {
    512            const {
    513              node: variableNode,
    514              value,
    515              computedValue,
    516            } = this.#parseVariable(token, text, tokenStream, options);
    517 
    518            const variableValue = computedValue ?? value;
    519            // InspectorUtils.isValidCSSColor returns true for `light-dark()` function,
    520            // but `#isValidColor` returns false. As the latter is used in #appendColor,
    521            // we need to check that both functions return true.
    522            const colorObj =
    523              value &&
    524              colorOK() &&
    525              InspectorUtils.isValidCSSColor(variableValue)
    526                ? new colorUtils.CssColor(variableValue)
    527                : null;
    528 
    529            if (colorObj && this.#isValidColor(colorObj)) {
    530              const colorFunctionEntry = this.#stack.findLast(
    531                entry => entry.isColorTakingFunction
    532              );
    533              this.#appendColor(variableValue, {
    534                ...options,
    535                colorObj,
    536                variableContainer: variableNode,
    537                colorFunction: colorFunctionEntry?.functionName,
    538              });
    539            } else {
    540              this.#append(variableNode);
    541            }
    542          } else {
    543            const {
    544              functionData,
    545              sawVariable,
    546              tokens: functionArgTokens,
    547              depth,
    548            } = this.#parseMatchingParens(text, tokenStream, options);
    549 
    550            if (sawVariable) {
    551              const computedFunctionText =
    552                functionName +
    553                "(" +
    554                functionData
    555                  .map(data => {
    556                    if (typeof data === "string") {
    557                      return data;
    558                    }
    559                    return (
    560                      data.computedValue ?? data.value ?? data.fallbackValue
    561                    );
    562                  })
    563                  .join("") +
    564                ")";
    565              if (
    566                colorOK() &&
    567                InspectorUtils.isValidCSSColor(computedFunctionText)
    568              ) {
    569                const colorFunctionEntry = this.#stack.findLast(
    570                  entry => entry.isColorTakingFunction
    571                );
    572 
    573                this.#appendColor(computedFunctionText, {
    574                  ...options,
    575                  colorFunction: colorFunctionEntry?.functionName,
    576                  valueParts: [
    577                    functionName,
    578                    "(",
    579                    ...functionData.map(data => data.node || data),
    580                    ")",
    581                  ],
    582                });
    583              } else {
    584                // If function contains variable, we need to add both strings
    585                // and nodes.
    586                this.#appendTextNode(functionName + "(");
    587                for (const data of functionData) {
    588                  if (typeof data === "string") {
    589                    this.#appendTextNode(data);
    590                  } else if (data) {
    591                    this.#append(data.node);
    592                  }
    593                }
    594                this.#appendTextNode(")");
    595              }
    596            } else {
    597              // If no variable in function, join the text together and add
    598              // to DOM accordingly.
    599              const functionText =
    600                functionName +
    601                "(" +
    602                functionData.join("") +
    603                // only append closing parenthesis if the authored text actually had it
    604                // In such case, we should probably indicate that there's a "syntax error"
    605                // See Bug 1891461.
    606                (depth == 0 ? ")" : "");
    607 
    608              if (lowerCaseFunctionName === "url" && options.urlClass) {
    609                // url() with quoted strings are not mapped as UnquotedUrl,
    610                // instead, we get a "Function" token with "url" function name,
    611                // and later, a "QuotedString" token, which contains the actual URL.
    612                let url;
    613                for (const argToken of functionArgTokens) {
    614                  if (argToken.tokenType === "QuotedString") {
    615                    url = argToken.value;
    616                    break;
    617                  }
    618                }
    619 
    620                if (url !== undefined) {
    621                  this.#appendURL(functionText, url, options);
    622                } else {
    623                  this.#appendTextNode(functionText);
    624                }
    625              } else if (
    626                options.expectCubicBezier &&
    627                lowerCaseFunctionName === "cubic-bezier"
    628              ) {
    629                this.#appendCubicBezier(functionText, options);
    630              } else if (
    631                options.expectLinearEasing &&
    632                lowerCaseFunctionName === "linear"
    633              ) {
    634                this.#appendLinear(functionText, options);
    635              } else if (
    636                colorOK() &&
    637                InspectorUtils.isValidCSSColor(functionText)
    638              ) {
    639                const colorFunctionEntry = this.#stack.findLast(
    640                  entry => entry.isColorTakingFunction
    641                );
    642                this.#appendColor(functionText, {
    643                  ...options,
    644                  colorFunction: colorFunctionEntry?.functionName,
    645                });
    646              } else if (
    647                options.expectShape &&
    648                BASIC_SHAPE_FUNCTIONS.has(lowerCaseFunctionName)
    649              ) {
    650                this.#appendShape(functionText, options);
    651              } else {
    652                this.#appendTextNode(functionText);
    653              }
    654            }
    655          }
    656          break;
    657        }
    658 
    659        case "Ident":
    660          if (
    661            options.expectCubicBezier &&
    662            BEZIER_KEYWORDS.has(lowerCaseTokenText)
    663          ) {
    664            this.#appendCubicBezier(token.text, options);
    665          } else if (
    666            options.expectLinearEasing &&
    667            lowerCaseTokenText == "linear"
    668          ) {
    669            this.#appendLinear(token.text, options);
    670          } else if (this.#isDisplayFlex(text, token, options)) {
    671            this.#appendDisplayWithHighlighterToggle(
    672              token.text,
    673              options.flexClass
    674            );
    675          } else if (this.#isDisplayGrid(text, token, options)) {
    676            this.#appendDisplayWithHighlighterToggle(
    677              token.text,
    678              options.gridClass
    679            );
    680          } else if (colorOK() && InspectorUtils.isValidCSSColor(token.text)) {
    681            const colorFunctionEntry = this.#stack.findLast(
    682              entry => entry.isColorTakingFunction
    683            );
    684            this.#appendColor(token.text, {
    685              ...options,
    686              colorFunction: colorFunctionEntry?.functionName,
    687            });
    688          } else if (angleOK(token.text)) {
    689            this.#appendAngle(token.text, options);
    690          } else if (options.expectFont && !previousWasBang) {
    691            // We don't append the identifier if the previous token
    692            // was equal to '!', since in that case we expect the
    693            // identifier to be equal to 'important'.
    694            fontFamilyNameParts.push(token.text);
    695          } else {
    696            this.#appendTextNode(
    697              text.substring(token.startOffset, token.endOffset)
    698            );
    699          }
    700          break;
    701 
    702        case "IDHash":
    703        case "Hash": {
    704          const original = text.substring(token.startOffset, token.endOffset);
    705          if (colorOK() && InspectorUtils.isValidCSSColor(original)) {
    706            if (spaceNeeded) {
    707              // Insert a space to prevent token pasting when a #xxx
    708              // color is changed to something like rgb(...).
    709              this.#appendTextNode(" ");
    710            }
    711            const colorFunctionEntry = this.#stack.findLast(
    712              entry => entry.isColorTakingFunction
    713            );
    714            this.#appendColor(original, {
    715              ...options,
    716              colorFunction: colorFunctionEntry?.functionName,
    717            });
    718          } else {
    719            this.#appendTextNode(original);
    720          }
    721          break;
    722        }
    723        case "Dimension": {
    724          const value = text.substring(token.startOffset, token.endOffset);
    725          if (angleOK(value)) {
    726            this.#appendAngle(value, options);
    727          } else {
    728            this.#appendTextNode(value);
    729          }
    730          break;
    731        }
    732        case "UnquotedUrl":
    733        case "BadUrl":
    734          this.#appendURL(
    735            text.substring(token.startOffset, token.endOffset),
    736            token.value,
    737            options
    738          );
    739          break;
    740 
    741        case "QuotedString":
    742          if (options.expectFont) {
    743            fontFamilyNameParts.push(
    744              text.substring(token.startOffset, token.endOffset)
    745            );
    746          } else {
    747            this.#appendTextNode(
    748              text.substring(token.startOffset, token.endOffset)
    749            );
    750          }
    751          break;
    752 
    753        case "WhiteSpace":
    754          if (options.expectFont) {
    755            fontFamilyNameParts.push(" ");
    756          } else {
    757            this.#appendTextNode(
    758              text.substring(token.startOffset, token.endOffset)
    759            );
    760          }
    761          break;
    762 
    763        case "ParenthesisBlock":
    764          this.#stack.push({
    765            isParenthesis: true,
    766            separatorIndexes: [],
    767            // The parsed parts of the function that will be rendered on screen.
    768            // This can hold both simple strings and DOMNodes.
    769            parts: [],
    770          });
    771          this.#appendTextNode(
    772            text.substring(token.startOffset, token.endOffset)
    773          );
    774          break;
    775 
    776        case "CloseParenthesis":
    777          this.#onCloseParenthesis(options);
    778 
    779          if (stopAtCloseParen && this.#stack.length === 0) {
    780            done = true;
    781            break;
    782          }
    783 
    784          this.#appendTextNode(
    785            text.substring(token.startOffset, token.endOffset)
    786          );
    787          break;
    788 
    789        case "Comma":
    790        case "Delim":
    791          if (
    792            (token.tokenType === "Comma" || token.text === "!") &&
    793            options.expectFont &&
    794            fontFamilyNameParts.length !== 0
    795          ) {
    796            this.#appendFontFamily(fontFamilyNameParts.join(""), options);
    797            fontFamilyNameParts = [];
    798          }
    799 
    800          // Add separator for the current function
    801          if (this.#stack.length) {
    802            this.#appendTextNode(token.text);
    803            const entry = this.#stack.at(-1);
    804            entry.separatorIndexes.push(entry.parts.length - 1);
    805            break;
    806          }
    807 
    808        // falls through
    809        default:
    810          this.#appendTextNode(
    811            text.substring(token.startOffset, token.endOffset)
    812          );
    813          break;
    814      }
    815 
    816      // If this token might possibly introduce token pasting when
    817      // color-cycling, require a space.
    818      spaceNeeded =
    819        token.tokenType === "Ident" ||
    820        token.tokenType === "AtKeyword" ||
    821        token.tokenType === "IDHash" ||
    822        token.tokenType === "Hash" ||
    823        token.tokenType === "Number" ||
    824        token.tokenType === "Dimension" ||
    825        token.tokenType === "Percentage" ||
    826        token.tokenType === "Dimension";
    827      previousWasBang = token.tokenType === "Delim" && token.text === "!";
    828    }
    829 
    830    if (options.expectFont && fontFamilyNameParts.length !== 0) {
    831      this.#appendFontFamily(fontFamilyNameParts.join(""), options);
    832    }
    833 
    834    // We might never encounter a matching closing parenthesis for a function and still
    835    // have a "valid" value (e.g. `background: linear-gradient(90deg, red, blue"`)
    836    // In such case, go through the stack and handle each items until we have nothing left.
    837    if (this.#stack.length) {
    838      while (this.#stack.length !== 0) {
    839        this.#onCloseParenthesis(options);
    840      }
    841    }
    842 
    843    let result = this.#toDOM();
    844 
    845    if (options.expectFilter && !options.filterSwatch) {
    846      result = this.#wrapFilter(text, options, result);
    847    }
    848 
    849    return result;
    850  }
    851 
    852  #onCloseParenthesis(options) {
    853    if (!this.#stack.length) {
    854      return;
    855    }
    856 
    857    const stackEntry = this.#stack.at(-1);
    858    if (
    859      stackEntry.lowerCaseFunctionName === "light-dark" &&
    860      typeof options.isDarkColorScheme === "boolean" &&
    861      // light-dark takes exactly two parameters, so if we don't get exactly 1 separator
    862      // at this point, that means that the value is valid at parse time, but is invalid
    863      // at computed value time.
    864      // TODO: We might want to add a class to indicate that this is invalid at computed
    865      // value time (See Bug 1910845)
    866      stackEntry.separatorIndexes.length === 1
    867    ) {
    868      const stackEntryParts = this.#getCurrentStackParts();
    869      const separatorIndex = stackEntry.separatorIndexes[0];
    870      let startIndex;
    871      let endIndex;
    872      if (options.isDarkColorScheme) {
    873        // If we're using a dark color scheme, we want to mark the first param as
    874        // not used.
    875 
    876        // The first "part" is `light-dark(`, so we can start after that.
    877        // We want to filter out white space character before the first parameter
    878        for (startIndex = 1; startIndex < separatorIndex; startIndex++) {
    879          const part = stackEntryParts[startIndex];
    880          if (typeof part !== "string" || part.trim() !== "") {
    881            break;
    882          }
    883        }
    884 
    885        // same for the end of the parameter, we want to filter out whitespaces
    886        // after the parameter and before the comma
    887        for (
    888          endIndex = separatorIndex - 1;
    889          endIndex >= startIndex;
    890          endIndex--
    891        ) {
    892          const part = stackEntryParts[endIndex];
    893          if (typeof part !== "string" || part.trim() !== "") {
    894            // We found a non-whitespace part, we need to include it, so increment the endIndex
    895            endIndex++;
    896            break;
    897          }
    898        }
    899      } else {
    900        // If we're not using a dark color scheme, we want to mark the second param as
    901        // not used.
    902 
    903        // We want to filter out white space character after the comma and before the
    904        // second parameter
    905        for (
    906          startIndex = separatorIndex + 1;
    907          startIndex < stackEntryParts.length;
    908          startIndex++
    909        ) {
    910          const part = stackEntryParts[startIndex];
    911          if (typeof part !== "string" || part.trim() !== "") {
    912            break;
    913          }
    914        }
    915 
    916        // same for the end of the parameter, we want to filter out whitespaces
    917        // after the parameter and before the closing parenthesis (which is not yet
    918        // included in stackEntryParts)
    919        for (
    920          endIndex = stackEntryParts.length - 1;
    921          endIndex > separatorIndex;
    922          endIndex--
    923        ) {
    924          const part = stackEntryParts[endIndex];
    925          if (typeof part !== "string" || part.trim() !== "") {
    926            // We found a non-whitespace part, we need to include it, so increment the endIndex
    927            endIndex++;
    928            break;
    929          }
    930        }
    931      }
    932 
    933      const parts = stackEntryParts.slice(startIndex, endIndex);
    934 
    935      // If the item we need to mark is already an element (e.g. a parsed color),
    936      // just add a class to it.
    937      if (parts.length === 1 && Element.isInstance(parts[0])) {
    938        parts[0].classList.add(options.unmatchedClass);
    939      } else {
    940        // Otherwise, we need to wrap our parts into a specific element so we can
    941        // style them
    942        const node = this.#createNode("span", {
    943          class: options.unmatchedClass,
    944        });
    945        node.append(...parts);
    946        stackEntryParts.splice(startIndex, parts.length, node);
    947      }
    948    }
    949 
    950    // Our job is done here, pop last stack entry
    951    const { parts } = this.#stack.pop();
    952    // Put all the parts in the "new" last stack, or the main parsed array if there
    953    // is no more entry in the stack
    954    this.#getCurrentStackParts().push(...parts);
    955  }
    956 
    957  /**
    958   * Parse a string.
    959   *
    960   * @param  {string} text
    961   *         Text to parse.
    962   * @param  {object} [options]
    963   *         Options object. For valid options and default values see
    964   *         #mergeOptions().
    965   * @return {DocumentFragment}
    966   *         A document fragment.
    967   */
    968  #parse(text, options = {}) {
    969    text = text.trim();
    970    this.#parsed.length = 0;
    971    this.#stack.length = 0;
    972 
    973    const tokenStream = new InspectorCSSParserWrapper(text);
    974    return this.#doParse(text, options, tokenStream, false);
    975  }
    976 
    977  /**
    978   * Returns true if it's a "display: [inline-]flex" token.
    979   *
    980   * @param  {string} text
    981   *         The parsed text.
    982   * @param  {object} token
    983   *         The parsed token.
    984   * @param  {object} options
    985   *         The options given to #parse.
    986   */
    987  #isDisplayFlex(text, token, options) {
    988    return (
    989      options.expectDisplay &&
    990      (token.text === "flex" || token.text === "inline-flex")
    991    );
    992  }
    993 
    994  /**
    995   * Returns true if it's a "display: [inline-]grid" token.
    996   *
    997   * @param  {string} text
    998   *         The parsed text.
    999   * @param  {object} token
   1000   *         The parsed token.
   1001   * @param  {object} options
   1002   *         The options given to #parse.
   1003   */
   1004  #isDisplayGrid(text, token, options) {
   1005    return (
   1006      options.expectDisplay &&
   1007      (token.text === "grid" || token.text === "inline-grid")
   1008    );
   1009  }
   1010 
   1011  /**
   1012   * Append a cubic-bezier timing function value to the output
   1013   *
   1014   * @param {string} bezier
   1015   *        The cubic-bezier timing function
   1016   * @param {object} options
   1017   *        Options object. For valid options and default values see
   1018   *        #mergeOptions()
   1019   */
   1020  #appendCubicBezier(bezier, options) {
   1021    const container = this.#createNode("span", {
   1022      "data-bezier": bezier,
   1023    });
   1024 
   1025    if (options.bezierSwatchClass) {
   1026      const swatch = this.#createNode("span", {
   1027        class: options.bezierSwatchClass,
   1028        tabindex: "0",
   1029        role: "button",
   1030      });
   1031      container.appendChild(swatch);
   1032    }
   1033 
   1034    const value = this.#createNode(
   1035      "span",
   1036      {
   1037        class: options.bezierClass,
   1038      },
   1039      bezier
   1040    );
   1041 
   1042    container.appendChild(value);
   1043    this.#append(container);
   1044  }
   1045 
   1046  #appendLinear(text, options) {
   1047    const container = this.#createNode("span", {
   1048      "data-linear": text,
   1049    });
   1050 
   1051    if (options.linearEasingSwatchClass) {
   1052      const swatch = this.#createNode("span", {
   1053        class: options.linearEasingSwatchClass,
   1054        tabindex: "0",
   1055        role: "button",
   1056        "data-linear": text,
   1057      });
   1058      container.appendChild(swatch);
   1059    }
   1060 
   1061    const value = this.#createNode(
   1062      "span",
   1063      {
   1064        class: options.linearEasingClass,
   1065      },
   1066      text
   1067    );
   1068 
   1069    container.appendChild(value);
   1070    this.#append(container);
   1071  }
   1072 
   1073  /**
   1074   * Append a Flexbox|Grid highlighter toggle icon next to the value in a
   1075   * "display: [inline-]flex" or "display: [inline-]grid" declaration.
   1076   *
   1077   * @param {string} text
   1078   *        The text value to append
   1079   * @param {string} toggleButtonClassName
   1080   *        The class name for the toggle button.
   1081   *        If not passed/empty, the toggle button won't be created.
   1082   */
   1083  #appendDisplayWithHighlighterToggle(text, toggleButtonClassName) {
   1084    const container = this.#createNode("span", {});
   1085 
   1086    if (toggleButtonClassName) {
   1087      const toggleButton = this.#createNode("button", {
   1088        class: toggleButtonClassName,
   1089      });
   1090      container.append(toggleButton);
   1091    }
   1092 
   1093    const value = this.#createNode("span", {}, text);
   1094    container.append(value);
   1095    this.#append(container);
   1096  }
   1097 
   1098  /**
   1099   * Append a CSS shapes highlighter toggle next to the value, and parse the value
   1100   * into spans, each containing a point that can be hovered over.
   1101   *
   1102   * @param {string} shape
   1103   *        The shape text value to append
   1104   * @param {object} options
   1105   *        Options object. For valid options and default values see
   1106   *        #mergeOptions()
   1107   */
   1108  #appendShape(shape, options) {
   1109    const shapeTypes = [
   1110      {
   1111        prefix: "polygon(",
   1112        coordParser: this.#addPolygonPointNodes.bind(this),
   1113      },
   1114      {
   1115        prefix: "circle(",
   1116        coordParser: this.#addCirclePointNodes.bind(this),
   1117      },
   1118      {
   1119        prefix: "ellipse(",
   1120        coordParser: this.#addEllipsePointNodes.bind(this),
   1121      },
   1122      {
   1123        prefix: "inset(",
   1124        coordParser: this.#addInsetPointNodes.bind(this),
   1125      },
   1126    ];
   1127 
   1128    const container = this.#createNode("span", {});
   1129 
   1130    const toggleButton = this.#createNode("button", {
   1131      class: options.shapeSwatchClass,
   1132    });
   1133 
   1134    const lowerCaseShape = shape.toLowerCase();
   1135    for (const { prefix, coordParser } of shapeTypes) {
   1136      if (lowerCaseShape.includes(prefix)) {
   1137        const coordsBegin = prefix.length;
   1138        const coordsEnd = shape.lastIndexOf(")");
   1139        let valContainer = this.#createNode("span", {
   1140          class: options.shapeClass,
   1141        });
   1142 
   1143        container.appendChild(toggleButton);
   1144 
   1145        appendText(valContainer, shape.substring(0, coordsBegin));
   1146 
   1147        const coordsString = shape.substring(coordsBegin, coordsEnd);
   1148        valContainer = coordParser(coordsString, valContainer);
   1149 
   1150        appendText(valContainer, shape.substring(coordsEnd));
   1151        container.appendChild(valContainer);
   1152      }
   1153    }
   1154 
   1155    this.#append(container);
   1156  }
   1157 
   1158  /**
   1159   * Parse the given polygon coordinates and create a span for each coordinate pair,
   1160   * adding it to the given container node.
   1161   *
   1162   * @param {string} coords
   1163   *        The string of coordinate pairs.
   1164   * @param {Node} container
   1165   *        The node to which spans containing points are added.
   1166   * @returns {Node} The container to which spans have been added.
   1167   */
   1168  // eslint-disable-next-line complexity
   1169  #addPolygonPointNodes(coords, container) {
   1170    const tokenStream = new InspectorCSSParserWrapper(coords);
   1171    let token = tokenStream.nextToken();
   1172    let coord = "";
   1173    let i = 0;
   1174    let depth = 0;
   1175    let isXCoord = true;
   1176    let fillRule = false;
   1177    let coordNode = this.#createNode("span", {
   1178      class: "inspector-shape-point",
   1179      "data-point": `${i}`,
   1180    });
   1181 
   1182    while (token) {
   1183      if (token.tokenType === "Comma") {
   1184        // Comma separating coordinate pairs; add coordNode to container and reset vars
   1185        if (!isXCoord) {
   1186          // Y coord not added to coordNode yet
   1187          const node = this.#createNode(
   1188            "span",
   1189            {
   1190              class: "inspector-shape-point",
   1191              "data-point": `${i}`,
   1192              "data-pair": isXCoord ? "x" : "y",
   1193            },
   1194            coord
   1195          );
   1196          coordNode.appendChild(node);
   1197          coord = "";
   1198          isXCoord = !isXCoord;
   1199        }
   1200 
   1201        if (fillRule) {
   1202          // If the last text added was a fill-rule, do not increment i.
   1203          fillRule = false;
   1204        } else {
   1205          container.appendChild(coordNode);
   1206          i++;
   1207        }
   1208        appendText(
   1209          container,
   1210          coords.substring(token.startOffset, token.endOffset)
   1211        );
   1212        coord = "";
   1213        depth = 0;
   1214        isXCoord = true;
   1215        coordNode = this.#createNode("span", {
   1216          class: "inspector-shape-point",
   1217          "data-point": `${i}`,
   1218        });
   1219      } else if (token.tokenType === "ParenthesisBlock") {
   1220        depth++;
   1221        coord += coords.substring(token.startOffset, token.endOffset);
   1222      } else if (token.tokenType === "CloseParenthesis") {
   1223        depth--;
   1224        coord += coords.substring(token.startOffset, token.endOffset);
   1225      } else if (token.tokenType === "WhiteSpace" && coord === "") {
   1226        // Whitespace at beginning of coord; add to container
   1227        appendText(
   1228          container,
   1229          coords.substring(token.startOffset, token.endOffset)
   1230        );
   1231      } else if (token.tokenType === "WhiteSpace" && depth === 0) {
   1232        // Whitespace signifying end of coord
   1233        const node = this.#createNode(
   1234          "span",
   1235          {
   1236            class: "inspector-shape-point",
   1237            "data-point": `${i}`,
   1238            "data-pair": isXCoord ? "x" : "y",
   1239          },
   1240          coord
   1241        );
   1242        coordNode.appendChild(node);
   1243        appendText(
   1244          coordNode,
   1245          coords.substring(token.startOffset, token.endOffset)
   1246        );
   1247        coord = "";
   1248        isXCoord = !isXCoord;
   1249      } else if (
   1250        token.tokenType === "Number" ||
   1251        token.tokenType === "Dimension" ||
   1252        token.tokenType === "Percentage" ||
   1253        token.tokenType === "Function"
   1254      ) {
   1255        if (isXCoord && coord && depth === 0) {
   1256          // Whitespace is not necessary between x/y coords.
   1257          const node = this.#createNode(
   1258            "span",
   1259            {
   1260              class: "inspector-shape-point",
   1261              "data-point": `${i}`,
   1262              "data-pair": "x",
   1263            },
   1264            coord
   1265          );
   1266          coordNode.appendChild(node);
   1267          isXCoord = false;
   1268          coord = "";
   1269        }
   1270 
   1271        coord += coords.substring(token.startOffset, token.endOffset);
   1272        if (token.tokenType === "Function") {
   1273          depth++;
   1274        }
   1275      } else if (
   1276        token.tokenType === "Ident" &&
   1277        (token.text === "nonzero" || token.text === "evenodd")
   1278      ) {
   1279        // A fill-rule (nonzero or evenodd).
   1280        appendText(
   1281          container,
   1282          coords.substring(token.startOffset, token.endOffset)
   1283        );
   1284        fillRule = true;
   1285      } else {
   1286        coord += coords.substring(token.startOffset, token.endOffset);
   1287      }
   1288      token = tokenStream.nextToken();
   1289    }
   1290 
   1291    // Add coords if any are left over
   1292    if (coord) {
   1293      const node = this.#createNode(
   1294        "span",
   1295        {
   1296          class: "inspector-shape-point",
   1297          "data-point": `${i}`,
   1298          "data-pair": isXCoord ? "x" : "y",
   1299        },
   1300        coord
   1301      );
   1302      coordNode.appendChild(node);
   1303      container.appendChild(coordNode);
   1304    }
   1305    return container;
   1306  }
   1307 
   1308  /**
   1309   * Parse the given circle coordinates and populate the given container appropriately
   1310   * with a separate span for the center point.
   1311   *
   1312   * @param {string} coords
   1313   *        The circle definition.
   1314   * @param {Node} container
   1315   *        The node to which the definition is added.
   1316   * @returns {Node} The container to which the definition has been added.
   1317   */
   1318  // eslint-disable-next-line complexity
   1319  #addCirclePointNodes(coords, container) {
   1320    const tokenStream = new InspectorCSSParserWrapper(coords);
   1321    let token = tokenStream.nextToken();
   1322    let depth = 0;
   1323    let coord = "";
   1324    let point = "radius";
   1325    const centerNode = this.#createNode("span", {
   1326      class: "inspector-shape-point",
   1327      "data-point": "center",
   1328    });
   1329    while (token) {
   1330      if (token.tokenType === "ParenthesisBlock") {
   1331        depth++;
   1332        coord += coords.substring(token.startOffset, token.endOffset);
   1333      } else if (token.tokenType === "CloseParenthesis") {
   1334        depth--;
   1335        coord += coords.substring(token.startOffset, token.endOffset);
   1336      } else if (token.tokenType === "WhiteSpace" && coord === "") {
   1337        // Whitespace at beginning of coord; add to container
   1338        appendText(
   1339          container,
   1340          coords.substring(token.startOffset, token.endOffset)
   1341        );
   1342      } else if (
   1343        token.tokenType === "WhiteSpace" &&
   1344        point === "radius" &&
   1345        depth === 0
   1346      ) {
   1347        // Whitespace signifying end of radius
   1348        const node = this.#createNode(
   1349          "span",
   1350          {
   1351            class: "inspector-shape-point",
   1352            "data-point": "radius",
   1353          },
   1354          coord
   1355        );
   1356        container.appendChild(node);
   1357        appendText(
   1358          container,
   1359          coords.substring(token.startOffset, token.endOffset)
   1360        );
   1361        point = "cx";
   1362        coord = "";
   1363        depth = 0;
   1364      } else if (token.tokenType === "WhiteSpace" && depth === 0) {
   1365        // Whitespace signifying end of cx/cy
   1366        const node = this.#createNode(
   1367          "span",
   1368          {
   1369            class: "inspector-shape-point",
   1370            "data-point": "center",
   1371            "data-pair": point === "cx" ? "x" : "y",
   1372          },
   1373          coord
   1374        );
   1375        centerNode.appendChild(node);
   1376        appendText(
   1377          centerNode,
   1378          coords.substring(token.startOffset, token.endOffset)
   1379        );
   1380        point = point === "cx" ? "cy" : "cx";
   1381        coord = "";
   1382        depth = 0;
   1383      } else if (token.tokenType === "Ident" && token.text === "at") {
   1384        // "at"; Add radius to container if not already done so
   1385        if (point === "radius" && coord) {
   1386          const node = this.#createNode(
   1387            "span",
   1388            {
   1389              class: "inspector-shape-point",
   1390              "data-point": "radius",
   1391            },
   1392            coord
   1393          );
   1394          container.appendChild(node);
   1395        }
   1396        appendText(
   1397          container,
   1398          coords.substring(token.startOffset, token.endOffset)
   1399        );
   1400        point = "cx";
   1401        coord = "";
   1402        depth = 0;
   1403      } else if (
   1404        token.tokenType === "Number" ||
   1405        token.tokenType === "Dimension" ||
   1406        token.tokenType === "Percentage" ||
   1407        token.tokenType === "Function"
   1408      ) {
   1409        if (point === "cx" && coord && depth === 0) {
   1410          // Center coords don't require whitespace between x/y. So if current point is
   1411          // cx, we have the cx coord, and depth is 0, then this token is actually cy.
   1412          // Add cx to centerNode and set point to cy.
   1413          const node = this.#createNode(
   1414            "span",
   1415            {
   1416              class: "inspector-shape-point",
   1417              "data-point": "center",
   1418              "data-pair": "x",
   1419            },
   1420            coord
   1421          );
   1422          centerNode.appendChild(node);
   1423          point = "cy";
   1424          coord = "";
   1425        }
   1426 
   1427        coord += coords.substring(token.startOffset, token.endOffset);
   1428        if (token.tokenType === "Function") {
   1429          depth++;
   1430        }
   1431      } else {
   1432        coord += coords.substring(token.startOffset, token.endOffset);
   1433      }
   1434      token = tokenStream.nextToken();
   1435    }
   1436 
   1437    // Add coords if any are left over.
   1438    if (coord) {
   1439      if (point === "radius") {
   1440        const node = this.#createNode(
   1441          "span",
   1442          {
   1443            class: "inspector-shape-point",
   1444            "data-point": "radius",
   1445          },
   1446          coord
   1447        );
   1448        container.appendChild(node);
   1449      } else {
   1450        const node = this.#createNode(
   1451          "span",
   1452          {
   1453            class: "inspector-shape-point",
   1454            "data-point": "center",
   1455            "data-pair": point === "cx" ? "x" : "y",
   1456          },
   1457          coord
   1458        );
   1459        centerNode.appendChild(node);
   1460      }
   1461    }
   1462 
   1463    if (centerNode.textContent) {
   1464      container.appendChild(centerNode);
   1465    }
   1466    return container;
   1467  }
   1468 
   1469  /**
   1470   * Parse the given ellipse coordinates and populate the given container appropriately
   1471   * with a separate span for each point
   1472   *
   1473   * @param {string} coords
   1474   *        The ellipse definition.
   1475   * @param {Node} container
   1476   *        The node to which the definition is added.
   1477   * @returns {Node} The container to which the definition has been added.
   1478   */
   1479  // eslint-disable-next-line complexity
   1480  #addEllipsePointNodes(coords, container) {
   1481    const tokenStream = new InspectorCSSParserWrapper(coords);
   1482    let token = tokenStream.nextToken();
   1483    let depth = 0;
   1484    let coord = "";
   1485    let point = "rx";
   1486    const centerNode = this.#createNode("span", {
   1487      class: "inspector-shape-point",
   1488      "data-point": "center",
   1489    });
   1490    while (token) {
   1491      if (token.tokenType === "ParenthesisBlock") {
   1492        depth++;
   1493        coord += coords.substring(token.startOffset, token.endOffset);
   1494      } else if (token.tokenType === "CloseParenthesis") {
   1495        depth--;
   1496        coord += coords.substring(token.startOffset, token.endOffset);
   1497      } else if (token.tokenType === "WhiteSpace" && coord === "") {
   1498        // Whitespace at beginning of coord; add to container
   1499        appendText(
   1500          container,
   1501          coords.substring(token.startOffset, token.endOffset)
   1502        );
   1503      } else if (token.tokenType === "WhiteSpace" && depth === 0) {
   1504        if (point === "rx" || point === "ry") {
   1505          // Whitespace signifying end of rx/ry
   1506          const node = this.#createNode(
   1507            "span",
   1508            {
   1509              class: "inspector-shape-point",
   1510              "data-point": point,
   1511            },
   1512            coord
   1513          );
   1514          container.appendChild(node);
   1515          appendText(
   1516            container,
   1517            coords.substring(token.startOffset, token.endOffset)
   1518          );
   1519          point = point === "rx" ? "ry" : "cx";
   1520          coord = "";
   1521          depth = 0;
   1522        } else {
   1523          // Whitespace signifying end of cx/cy
   1524          const node = this.#createNode(
   1525            "span",
   1526            {
   1527              class: "inspector-shape-point",
   1528              "data-point": "center",
   1529              "data-pair": point === "cx" ? "x" : "y",
   1530            },
   1531            coord
   1532          );
   1533          centerNode.appendChild(node);
   1534          appendText(
   1535            centerNode,
   1536            coords.substring(token.startOffset, token.endOffset)
   1537          );
   1538          point = point === "cx" ? "cy" : "cx";
   1539          coord = "";
   1540          depth = 0;
   1541        }
   1542      } else if (token.tokenType === "Ident" && token.text === "at") {
   1543        // "at"; Add radius to container if not already done so
   1544        if (point === "ry" && coord) {
   1545          const node = this.#createNode(
   1546            "span",
   1547            {
   1548              class: "inspector-shape-point",
   1549              "data-point": "ry",
   1550            },
   1551            coord
   1552          );
   1553          container.appendChild(node);
   1554        }
   1555        appendText(
   1556          container,
   1557          coords.substring(token.startOffset, token.endOffset)
   1558        );
   1559        point = "cx";
   1560        coord = "";
   1561        depth = 0;
   1562      } else if (
   1563        token.tokenType === "Number" ||
   1564        token.tokenType === "Dimension" ||
   1565        token.tokenType === "Percentage" ||
   1566        token.tokenType === "Function"
   1567      ) {
   1568        if (point === "rx" && coord && depth === 0) {
   1569          // Radius coords don't require whitespace between x/y.
   1570          const node = this.#createNode(
   1571            "span",
   1572            {
   1573              class: "inspector-shape-point",
   1574              "data-point": "rx",
   1575            },
   1576            coord
   1577          );
   1578          container.appendChild(node);
   1579          point = "ry";
   1580          coord = "";
   1581        }
   1582        if (point === "cx" && coord && depth === 0) {
   1583          // Center coords don't require whitespace between x/y.
   1584          const node = this.#createNode(
   1585            "span",
   1586            {
   1587              class: "inspector-shape-point",
   1588              "data-point": "center",
   1589              "data-pair": "x",
   1590            },
   1591            coord
   1592          );
   1593          centerNode.appendChild(node);
   1594          point = "cy";
   1595          coord = "";
   1596        }
   1597 
   1598        coord += coords.substring(token.startOffset, token.endOffset);
   1599        if (token.tokenType === "Function") {
   1600          depth++;
   1601        }
   1602      } else {
   1603        coord += coords.substring(token.startOffset, token.endOffset);
   1604      }
   1605      token = tokenStream.nextToken();
   1606    }
   1607 
   1608    // Add coords if any are left over.
   1609    if (coord) {
   1610      if (point === "rx" || point === "ry") {
   1611        const node = this.#createNode(
   1612          "span",
   1613          {
   1614            class: "inspector-shape-point",
   1615            "data-point": point,
   1616          },
   1617          coord
   1618        );
   1619        container.appendChild(node);
   1620      } else {
   1621        const node = this.#createNode(
   1622          "span",
   1623          {
   1624            class: "inspector-shape-point",
   1625            "data-point": "center",
   1626            "data-pair": point === "cx" ? "x" : "y",
   1627          },
   1628          coord
   1629        );
   1630        centerNode.appendChild(node);
   1631      }
   1632    }
   1633 
   1634    if (centerNode.textContent) {
   1635      container.appendChild(centerNode);
   1636    }
   1637    return container;
   1638  }
   1639 
   1640  /**
   1641   * Parse the given inset coordinates and populate the given container appropriately.
   1642   *
   1643   * @param {string} coords
   1644   *        The inset definition.
   1645   * @param {Node} container
   1646   *        The node to which the definition is added.
   1647   * @returns {Node} The container to which the definition has been added.
   1648   */
   1649  // eslint-disable-next-line complexity
   1650  #addInsetPointNodes(coords, container) {
   1651    const insetPoints = ["top", "right", "bottom", "left"];
   1652    const tokenStream = new InspectorCSSParserWrapper(coords);
   1653    let token = tokenStream.nextToken();
   1654    let depth = 0;
   1655    let coord = "";
   1656    let i = 0;
   1657    let round = false;
   1658    // nodes is an array containing all the coordinate spans. otherText is an array of
   1659    // arrays, each containing the text that should be inserted into container before
   1660    // the node with the same index. i.e. all elements of otherText[i] is inserted
   1661    // into container before nodes[i].
   1662    const nodes = [];
   1663    const otherText = [[]];
   1664 
   1665    while (token) {
   1666      if (round) {
   1667        // Everything that comes after "round" should just be plain text
   1668        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
   1669      } else if (token.tokenType === "ParenthesisBlock") {
   1670        depth++;
   1671        coord += coords.substring(token.startOffset, token.endOffset);
   1672      } else if (token.tokenType === "CloseParenthesis") {
   1673        depth--;
   1674        coord += coords.substring(token.startOffset, token.endOffset);
   1675      } else if (token.tokenType === "WhiteSpace" && coord === "") {
   1676        // Whitespace at beginning of coord; add to container
   1677        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
   1678      } else if (token.tokenType === "WhiteSpace" && depth === 0) {
   1679        // Whitespace signifying end of coord; create node and push to nodes
   1680        const node = this.#createNode(
   1681          "span",
   1682          {
   1683            class: "inspector-shape-point",
   1684          },
   1685          coord
   1686        );
   1687        nodes.push(node);
   1688        i++;
   1689        coord = "";
   1690        otherText[i] = [coords.substring(token.startOffset, token.endOffset)];
   1691        depth = 0;
   1692      } else if (
   1693        token.tokenType === "Number" ||
   1694        token.tokenType === "Dimension" ||
   1695        token.tokenType === "Percentage" ||
   1696        token.tokenType === "Function"
   1697      ) {
   1698        if (coord && depth === 0) {
   1699          // Inset coords don't require whitespace between each coord.
   1700          const node = this.#createNode(
   1701            "span",
   1702            {
   1703              class: "inspector-shape-point",
   1704            },
   1705            coord
   1706          );
   1707          nodes.push(node);
   1708          i++;
   1709          coord = "";
   1710          otherText[i] = [];
   1711        }
   1712 
   1713        coord += coords.substring(token.startOffset, token.endOffset);
   1714        if (token.tokenType === "Function") {
   1715          depth++;
   1716        }
   1717      } else if (token.tokenType === "Ident" && token.text === "round") {
   1718        if (coord && depth === 0) {
   1719          // Whitespace is not necessary before "round"; create a new node for the coord
   1720          const node = this.#createNode(
   1721            "span",
   1722            {
   1723              class: "inspector-shape-point",
   1724            },
   1725            coord
   1726          );
   1727          nodes.push(node);
   1728          i++;
   1729          coord = "";
   1730          otherText[i] = [];
   1731        }
   1732        round = true;
   1733        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
   1734      } else {
   1735        coord += coords.substring(token.startOffset, token.endOffset);
   1736      }
   1737      token = tokenStream.nextToken();
   1738    }
   1739 
   1740    // Take care of any leftover text
   1741    if (coord) {
   1742      if (round) {
   1743        otherText[i].push(coord);
   1744      } else {
   1745        const node = this.#createNode(
   1746          "span",
   1747          {
   1748            class: "inspector-shape-point",
   1749          },
   1750          coord
   1751        );
   1752        nodes.push(node);
   1753      }
   1754    }
   1755 
   1756    // insetPoints contains the 4 different possible inset points in the order they are
   1757    // defined. By taking the modulo of the index in insetPoints with the number of nodes,
   1758    // we can get which node represents each point (e.g. if there is only 1 node, it
   1759    // represents all 4 points). The exception is "left" when there are 3 nodes. In that
   1760    // case, it is nodes[1] that represents the left point rather than nodes[0].
   1761    for (let j = 0; j < 4; j++) {
   1762      const point = insetPoints[j];
   1763      const nodeIndex =
   1764        point === "left" && nodes.length === 3 ? 1 : j % nodes.length;
   1765      nodes[nodeIndex].classList.add(point);
   1766    }
   1767 
   1768    nodes.forEach((node, j) => {
   1769      for (const text of otherText[j]) {
   1770        appendText(container, text);
   1771      }
   1772      container.appendChild(node);
   1773    });
   1774 
   1775    // Add text that comes after the last node, if any exists
   1776    if (otherText[nodes.length]) {
   1777      for (const text of otherText[nodes.length]) {
   1778        appendText(container, text);
   1779      }
   1780    }
   1781 
   1782    return container;
   1783  }
   1784 
   1785  /**
   1786   * Append a angle value to the output
   1787   *
   1788   * @param {string} angle
   1789   *        angle to append
   1790   * @param {object} options
   1791   *        Options object. For valid options and default values see
   1792   *        #mergeOptions()
   1793   */
   1794  #appendAngle(angle, options) {
   1795    const angleObj = new angleUtils.CssAngle(angle);
   1796    const container = this.#createNode("span", {
   1797      "data-angle": angle,
   1798    });
   1799 
   1800    if (options.angleSwatchClass) {
   1801      const swatch = this.#createNode("span", {
   1802        class: options.angleSwatchClass,
   1803        tabindex: "0",
   1804        role: "button",
   1805      });
   1806      this.#angleSwatches.set(swatch, angleObj);
   1807      swatch.addEventListener("mousedown", this.#onAngleSwatchMouseDown);
   1808 
   1809      // Add click listener to stop event propagation when shift key is pressed
   1810      // in order to prevent the value input to be focused.
   1811      // Bug 711942 will add a tooltip to edit angle values and we should
   1812      // be able to move this listener to Tooltip.js when it'll be implemented.
   1813      swatch.addEventListener("click", function (event) {
   1814        if (event.shiftKey) {
   1815          event.stopPropagation();
   1816        }
   1817      });
   1818      container.appendChild(swatch);
   1819    }
   1820 
   1821    const value = this.#createNode(
   1822      "span",
   1823      {
   1824        class: options.angleClass,
   1825      },
   1826      angle
   1827    );
   1828 
   1829    container.appendChild(value);
   1830    this.#append(container);
   1831  }
   1832 
   1833  /**
   1834   * Check if a CSS property supports a specific value.
   1835   *
   1836   * @param  {string} name
   1837   *         CSS Property name to check
   1838   * @param  {string} value
   1839   *         CSS Property value to check
   1840   * @param  {object} options
   1841   *         Options object. For valid options and default values see #mergeOptions().
   1842   */
   1843  #cssPropertySupportsValue(name, value, options) {
   1844    if (
   1845      options.isValid ||
   1846      // The filter property is special in that we want to show the swatch even if the
   1847      // value is invalid, because this way the user can easily use the editor to fix it.
   1848      options.expectFilter
   1849    ) {
   1850      return true;
   1851    }
   1852 
   1853    // Checking pair as a CSS declaration string to account for "!important" in value.
   1854    const declaration = `${name}:${value}`;
   1855    return this.#doc.defaultView.CSS.supports(declaration);
   1856  }
   1857 
   1858  /**
   1859   * Tests if a given colorObject output by CssColor is valid for parsing.
   1860   * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
   1861   * except transparent
   1862   */
   1863  #isValidColor(colorObj) {
   1864    return (
   1865      colorObj.valid &&
   1866      (!colorObj.specialValue || colorObj.specialValue === "transparent")
   1867    );
   1868  }
   1869 
   1870  /**
   1871   * Append a color to the output.
   1872   *
   1873   * @param {string} color
   1874   *         Color to append
   1875   * @param {object} [options]
   1876   * @param {CSSColor} options.colorObj: A css color for the passed color. Will be computed
   1877   *         if not passed.
   1878   * @param {DOMNode} options.variableContainer: A DOM Node that is the result of parsing
   1879   *        a CSS variable
   1880   * @param {string} options.colorFunction: The color function that is used to produce this color
   1881   * @param {*} For all the other valid options and default values see #mergeOptions().
   1882   */
   1883  #appendColor(color, options = {}) {
   1884    const colorObj = options.colorObj || new colorUtils.CssColor(color);
   1885 
   1886    if (this.#isValidColor(colorObj)) {
   1887      const container = this.#createNode("span", {
   1888        "data-color": color,
   1889      });
   1890 
   1891      if (options.colorSwatchClass) {
   1892        let attributes = {
   1893          class: options.colorSwatchClass,
   1894          style: "background-color:" + color,
   1895        };
   1896 
   1897        // Color swatches next to values trigger the color editor everywhere aside from
   1898        // the Computed panel where values are read-only.
   1899        if (!options.colorSwatchReadOnly) {
   1900          attributes = { ...attributes, tabindex: "0", role: "button" };
   1901        }
   1902 
   1903        // The swatch is a <span> instead of a <button> intentionally. See Bug 1597125.
   1904        // It is made keyboard accessible via `tabindex` and has keydown handlers
   1905        // attached for pressing SPACE and RETURN in SwatchBasedEditorTooltip.js
   1906        const swatch = this.#createNode("span", attributes);
   1907        this.#colorSwatches.set(swatch, colorObj);
   1908        if (options.colorFunction) {
   1909          swatch.dataset.colorFunction = options.colorFunction;
   1910        }
   1911        swatch.addEventListener("mousedown", this.#onColorSwatchMouseDown);
   1912        container.appendChild(swatch);
   1913        container.classList.add("color-swatch-container");
   1914      }
   1915 
   1916      let colorUnit = options.defaultColorUnit;
   1917      if (!options.useDefaultColorUnit) {
   1918        // If we're not being asked to convert the color to the default color type
   1919        // specified by the user, then force the CssColor instance to be set to the type
   1920        // of the current color.
   1921        // Not having a type means that the default color type will be automatically used.
   1922        colorUnit = colorUtils.classifyColor(color);
   1923      }
   1924      color = colorObj.toString(colorUnit);
   1925      container.dataset.color = color;
   1926 
   1927      // Next we create the markup to show the value of the property.
   1928      if (options.variableContainer) {
   1929        // If we are creating a color swatch for a CSS variable we simply reuse
   1930        // the markup created for the variableContainer.
   1931        if (options.colorClass) {
   1932          options.variableContainer.classList.add(options.colorClass);
   1933        }
   1934        container.appendChild(options.variableContainer);
   1935      } else {
   1936        // Otherwise we create a new element with the `color` as textContent.
   1937        const value = this.#createNode("span", {
   1938          class: options.colorClass,
   1939        });
   1940        if (options.valueParts) {
   1941          value.append(...options.valueParts);
   1942        } else {
   1943          value.append(this.#doc.createTextNode(color));
   1944        }
   1945 
   1946        container.appendChild(value);
   1947      }
   1948 
   1949      this.#append(container);
   1950    } else {
   1951      this.#appendTextNode(color);
   1952    }
   1953  }
   1954 
   1955  /**
   1956   * Wrap some existing nodes in a filter editor.
   1957   *
   1958   * @param {string} filters
   1959   *        The full text of the "filter" property.
   1960   * @param {object} options
   1961   *        The options object passed to parseCssProperty().
   1962   * @param {object} nodes
   1963   *        Nodes created by #toDOM().
   1964   *
   1965   * @returns {object}
   1966   *        A new node that supplies a filter swatch and that wraps |nodes|.
   1967   */
   1968  #wrapFilter(filters, options, nodes) {
   1969    const container = this.#createNode("span", {
   1970      "data-filters": filters,
   1971    });
   1972 
   1973    if (options.filterSwatchClass) {
   1974      const swatch = this.#createNode("span", {
   1975        class: options.filterSwatchClass,
   1976        tabindex: "0",
   1977        role: "button",
   1978      });
   1979      container.appendChild(swatch);
   1980    }
   1981 
   1982    const value = this.#createNode("span", {
   1983      class: options.filterClass,
   1984    });
   1985    value.appendChild(nodes);
   1986    container.appendChild(value);
   1987 
   1988    return container;
   1989  }
   1990 
   1991  #onColorSwatchMouseDown = event => {
   1992    if (!event.shiftKey) {
   1993      return;
   1994    }
   1995 
   1996    // Prevent click event to be fired to not show the tooltip
   1997    event.stopPropagation();
   1998    // Prevent text selection but switch the focus
   1999    event.preventDefault();
   2000    event.target.focus({ focusVisible: false });
   2001 
   2002    const swatch = event.target;
   2003    const color = this.#colorSwatches.get(swatch);
   2004    const val = color.nextColorUnit();
   2005 
   2006    swatch.nextElementSibling.textContent = val;
   2007    swatch.parentNode.dataset.color = val;
   2008 
   2009    const unitChangeEvent = new swatch.ownerGlobal.CustomEvent("unit-change");
   2010    swatch.dispatchEvent(unitChangeEvent);
   2011  };
   2012 
   2013  #onAngleSwatchMouseDown = event => {
   2014    if (!event.shiftKey) {
   2015      return;
   2016    }
   2017 
   2018    event.stopPropagation();
   2019 
   2020    const swatch = event.target;
   2021    const angle = this.#angleSwatches.get(swatch);
   2022    const val = angle.nextAngleUnit();
   2023 
   2024    swatch.nextElementSibling.textContent = val;
   2025 
   2026    const unitChangeEvent = new swatch.ownerGlobal.CustomEvent("unit-change");
   2027    swatch.dispatchEvent(unitChangeEvent);
   2028  };
   2029 
   2030  /**
   2031   * A helper function that sanitizes a possibly-unterminated URL.
   2032   */
   2033  #sanitizeURL(url) {
   2034    // Re-lex the URL and add any needed termination characters.
   2035    const urlTokenizer = new InspectorCSSParserWrapper(url, {
   2036      trackEOFChars: true,
   2037    });
   2038    // Just read until EOF; there will only be a single token.
   2039    while (urlTokenizer.nextToken()) {
   2040      // Nothing.
   2041    }
   2042 
   2043    return urlTokenizer.performEOFFixup(url);
   2044  }
   2045 
   2046  /**
   2047   * Append a URL to the output.
   2048   *
   2049   * @param  {string} match
   2050   *         Complete match that may include "url(xxx)"
   2051   * @param  {string} url
   2052   *         Actual URL
   2053   * @param  {object} [options]
   2054   *         Options object. For valid options and default values see
   2055   *         #mergeOptions().
   2056   */
   2057  #appendURL(match, url, options) {
   2058    if (options.urlClass) {
   2059      // Sanitize the URL. Note that if we modify the URL, we just
   2060      // leave the termination characters. This isn't strictly
   2061      // "as-authored", but it makes a bit more sense.
   2062      match = this.#sanitizeURL(match);
   2063      const urlParts = URL_REGEX.exec(match);
   2064 
   2065      // Bail out if that didn't match anything.
   2066      if (!urlParts) {
   2067        this.#appendTextNode(match);
   2068        return;
   2069      }
   2070 
   2071      const { leader, body, trailer } = urlParts.groups;
   2072 
   2073      this.#appendTextNode(leader);
   2074 
   2075      this.#appendNode(
   2076        "a",
   2077        {
   2078          target: "_blank",
   2079          class: options.urlClass,
   2080          href: options.baseURI
   2081            ? (URL.parse(url, options.baseURI)?.href ?? url)
   2082            : url,
   2083        },
   2084        body
   2085      );
   2086 
   2087      this.#appendTextNode(trailer);
   2088    } else {
   2089      this.#appendTextNode(match);
   2090    }
   2091  }
   2092 
   2093  /**
   2094   * Append a font family to the output.
   2095   *
   2096   * @param  {string} fontFamily
   2097   *         Font family to append
   2098   * @param  {object} options
   2099   *         Options object. For valid options and default values see
   2100   *         #mergeOptions().
   2101   */
   2102  #appendFontFamily(fontFamily, options) {
   2103    let spanContents = fontFamily;
   2104    let quoteChar = null;
   2105    let trailingWhitespace = false;
   2106 
   2107    // Before appending the actual font-family span, we need to trim
   2108    // down the actual contents by removing any whitespace before and
   2109    // after, and any quotation characters in the passed string.  Any
   2110    // such characters are preserved in the actual output, but just
   2111    // not inside the span element.
   2112 
   2113    if (spanContents[0] === " ") {
   2114      this.#appendTextNode(" ");
   2115      spanContents = spanContents.slice(1);
   2116    }
   2117 
   2118    if (spanContents[spanContents.length - 1] === " ") {
   2119      spanContents = spanContents.slice(0, -1);
   2120      trailingWhitespace = true;
   2121    }
   2122 
   2123    if (spanContents[0] === "'" || spanContents[0] === '"') {
   2124      quoteChar = spanContents[0];
   2125    }
   2126 
   2127    if (quoteChar) {
   2128      this.#appendTextNode(quoteChar);
   2129      spanContents = spanContents.slice(1, -1);
   2130    }
   2131 
   2132    this.#appendNode(
   2133      "span",
   2134      {
   2135        class: options.fontFamilyClass,
   2136      },
   2137      spanContents
   2138    );
   2139 
   2140    if (quoteChar) {
   2141      this.#appendTextNode(quoteChar);
   2142    }
   2143 
   2144    if (trailingWhitespace) {
   2145      this.#appendTextNode(" ");
   2146    }
   2147  }
   2148 
   2149  /**
   2150   * Create a node.
   2151   *
   2152   * @param  {string} tagName
   2153   *         Tag type e.g. "div"
   2154   * @param  {object} attributes
   2155   *         e.g. {class: "someClass", style: "cursor:pointer"};
   2156   * @param  {string} [value]
   2157   *         If a value is included it will be appended as a text node inside
   2158   *         the tag. This is useful e.g. for span tags.
   2159   * @return {Node} Newly created Node.
   2160   */
   2161  #createNode(tagName, attributes, value = "") {
   2162    const node = this.#doc.createElementNS(HTML_NS, tagName);
   2163    const attrs = Object.getOwnPropertyNames(attributes);
   2164 
   2165    for (const attr of attrs) {
   2166      const attrValue = attributes[attr];
   2167      if (attrValue !== null && attrValue !== undefined) {
   2168        node.setAttribute(attr, attributes[attr]);
   2169      }
   2170    }
   2171 
   2172    if (value) {
   2173      const textNode = this.#doc.createTextNode(value);
   2174      node.appendChild(textNode);
   2175    }
   2176 
   2177    return node;
   2178  }
   2179 
   2180  /**
   2181   * Create and append a node to the output.
   2182   *
   2183   * @param  {string} tagName
   2184   *         Tag type e.g. "div"
   2185   * @param  {object} attributes
   2186   *         e.g. {class: "someClass", style: "cursor:pointer"};
   2187   * @param  {string} [value]
   2188   *         If a value is included it will be appended as a text node inside
   2189   *         the tag. This is useful e.g. for span tags.
   2190   */
   2191  #appendNode(tagName, attributes, value = "") {
   2192    const node = this.#createNode(tagName, attributes, value);
   2193    if (value.length > TRUNCATE_LENGTH_THRESHOLD) {
   2194      node.classList.add(TRUNCATE_NODE_CLASSNAME);
   2195    }
   2196 
   2197    this.#append(node);
   2198  }
   2199 
   2200  /**
   2201   * Append an element or a text node to the output.
   2202   *
   2203   * @param {DOMNode | string} item
   2204   */
   2205  #append(item) {
   2206    this.#getCurrentStackParts().push(item);
   2207  }
   2208 
   2209  /**
   2210   * Append a text node to the output. If the previously output item was a text
   2211   * node then we append the text to that node.
   2212   *
   2213   * @param  {string} text
   2214   *         Text to append
   2215   */
   2216  #appendTextNode(text) {
   2217    if (text.length > TRUNCATE_LENGTH_THRESHOLD) {
   2218      // If the text is too long, force creating a node, which will add the
   2219      // necessary classname to truncate the property correctly.
   2220      this.#appendNode("span", {}, text);
   2221    } else {
   2222      this.#append(text);
   2223    }
   2224  }
   2225 
   2226  #getCurrentStackParts() {
   2227    return this.#stack.at(-1)?.parts || this.#parsed;
   2228  }
   2229 
   2230  /**
   2231   * Take all output and append it into a single DocumentFragment.
   2232   *
   2233   * @return {DocumentFragment}
   2234   *         Document Fragment
   2235   */
   2236  #toDOM() {
   2237    const frag = this.#doc.createDocumentFragment();
   2238 
   2239    for (const item of this.#parsed) {
   2240      if (typeof item === "string") {
   2241        frag.appendChild(this.#doc.createTextNode(item));
   2242      } else {
   2243        frag.appendChild(item);
   2244      }
   2245    }
   2246 
   2247    this.#parsed.length = 0;
   2248    this.#stack.length = 0;
   2249    return frag;
   2250  }
   2251 
   2252  /**
   2253   * Merges options objects. Default values are set here.
   2254   *
   2255   * @param  {object} overrides
   2256   *         The option values to override e.g. #mergeOptions({colors: false})
   2257   * @param {boolean} overrides.useDefaultColorUnit: Convert colors to the default type
   2258   *                                                 selected in the options panel.
   2259   * @param {string} overrides.angleClass: The class to use for the angle value that follows
   2260   *                                       the swatch.
   2261   * @param {string} overrides.angleSwatchClass: The class to use for angle swatches.
   2262   * @param {string} overrides.bezierClass: The class to use for the bezier value that
   2263   *        follows the swatch.
   2264   * @param {string} overrides.bezierSwatchClass: The class to use for bezier swatches.
   2265   * @param {string} overrides.colorClass: The class to use for the color value that
   2266   *        follows the swatch.
   2267   * @param {string} overrides.colorSwatchClass: The class to use for color swatches.
   2268   * @param {boolean} overrides.colorSwatchReadOnly: Whether the resulting color swatch
   2269   *        should be read-only or not. Defaults to false.
   2270   * @param {boolean} overrides.filterSwatch: A special case for parsing a "filter" property,
   2271   *        causing the parser to skip the call to #wrapFilter. Used only for previewing
   2272   *        with the filter swatch.
   2273   * @param {string} overrides.flexClass: The class to use for the flex icon.
   2274   * @param {string} overrides.gridClass: The class to use for the grid icon.
   2275   * @param {string} overrides.shapeClass: The class to use for the shape value that
   2276   *         follows the swatch.
   2277   * @param {string} overrides.shapeSwatchClass: The class to use for the shape swatch.
   2278   * @param {string} overrides.urlClass: The class to be used for url() links.
   2279   * @param {string} overrides.fontFamilyClass: The class to be used for font families.
   2280   * @param {string} overrides.unmatchedClass: The class to use for a component of
   2281   *        a `var(…)` that is not in use.
   2282   * @param {boolean} overrides.supportsColor: Does the CSS property support colors?
   2283   * @param {string} overrides.baseURI: A string used to resolve relative links.
   2284   * @param {Function} overrides.getVariableData: A function taking a single argument,
   2285   *        the name of a variable. This should return an object with the following properties:
   2286   *          - {String|undefined} value: The variable's value. Undefined if variable is
   2287   *            not set.
   2288   *          - {RegisteredPropertyResource|undefined} registeredProperty: The registered
   2289   *            property data (syntax, initial value, inherits). Undefined if the variable
   2290   *            is not a registered property.
   2291   * @param {boolean} overrides.showJumpToVariableButton: Should we show a jump to
   2292   *        definition for CSS variables. Defaults to true.
   2293   * @param {boolean} overrides.isDarkColorScheme: Is the currently applied color scheme dark.
   2294   * @param {boolean} overrides.isValid: Is the name+value valid.
   2295   * @return {object} Overridden options object
   2296   */
   2297  #mergeOptions(overrides) {
   2298    const defaults = {
   2299      useDefaultColorUnit: true,
   2300      defaultColorUnit: "authored",
   2301      angleClass: null,
   2302      angleSwatchClass: null,
   2303      bezierClass: null,
   2304      bezierSwatchClass: null,
   2305      colorClass: null,
   2306      colorSwatchClass: null,
   2307      colorSwatchReadOnly: false,
   2308      filterSwatch: false,
   2309      flexClass: null,
   2310      gridClass: null,
   2311      shapeClass: null,
   2312      shapeSwatchClass: null,
   2313      supportsColor: false,
   2314      urlClass: null,
   2315      fontFamilyClass: null,
   2316      baseURI: undefined,
   2317      getVariableData: null,
   2318      showJumpToVariableButton: true,
   2319      unmatchedClass: null,
   2320      inStartingStyleRule: false,
   2321      isDarkColorScheme: null,
   2322    };
   2323 
   2324    for (const item in overrides) {
   2325      defaults[item] = overrides[item];
   2326    }
   2327    return defaults;
   2328  }
   2329 }
   2330 
   2331 module.exports = OutputParser;