Eine aufbereitete Darstellung der Quelle

 
     
 
 
Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 

Benutzer

Quelle  output-parser.js

  Sprache: JAVA
 

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */


"use strict";

const {
  angleUtils,
} = require("resource://devtools/client/shared/css-angle.js");
const { colorUtils } = require("resource://devtools/shared/css/color.js");
const {
  InspectorCSSParserWrapper,
} = require("resource://devtools/shared/css/lexer.js");

const STYLE_INSPECTOR_PROPERTIES =
  "devtools/shared/locales/styleinspector.properties";

loader.lazyGetter(this"STYLE_INSPECTOR_L10N"function () {
  const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
  return new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
});

loader.lazyGetter(this"VARIABLE_JUMP_DEFINITION_TITLE"function () {
  return STYLE_INSPECTOR_L10N.getStr("rule.variableJumpDefinition.title");
});

// All cubic-bezier CSS timing-function names.
const BEZIER_KEYWORDS = new Set([
  "linear",
  "ease-in-out",
  "ease-in",
  "ease-out",
  "ease",
]);
// Functions that accept a color argument.
const COLOR_TAKING_FUNCTIONS = new Set([
  "linear-gradient",
  "-moz-linear-gradient",
  "repeating-linear-gradient",
  "-moz-repeating-linear-gradient",
  "radial-gradient",
  "-moz-radial-gradient",
  "repeating-radial-gradient",
  "-moz-repeating-radial-gradient",
  "conic-gradient",
  "repeating-conic-gradient",
  "drop-shadow",
  "color-mix",
  "contrast-color",
  "light-dark",
  // color functions can take a relative color after `from`
  "color",
  "hsl",
  "hwb",
  "lab",
  "lch",
  "oklab",
  "oklch",
  "rgb",
  // image(<color>) is equivalent to linear-gradient(<color>)
  "image",
]);
// Functions that accept a shape argument.
const BASIC_SHAPE_FUNCTIONS = new Set([
  "polygon",
  "circle",
  "ellipse",
  "inset",
]);

const CSS_EXPLAINERS_SUPPORTED_FUNCTIONS = new Set(
  InspectorUtils.getComputationStepsSupportedCSSFunctions()
);

const BACKDROP_FILTER_ENABLED = Services.prefs.getBoolPref(
  "layout.css.backdrop-filter.enabled"
);
const HTML_NS = "http://www.w3.org/1999/xhtml";

// This regexp matches a URL token.  It puts the "url(", any
// leading whitespace, and any opening quote into |leader|; the
// URL text itself into |body|, and any trailing quote, trailing
// whitespace, and the ")" into |trailer|.
const URL_REGEX =
  /^(?<leader>url\([ \t\r\n\f]*(["']?))(?<body>.*?)(?<trailer>\2[ \t\r\n\f]*\))$/i;

// Very long text properties should be truncated using CSS to avoid creating
// extremely tall propertyvalue containers. 5000 characters is an arbitrary
// limit. Assuming an average ruleview can hold 50 characters per line, this
// should start truncating properties which would otherwise be 100 lines long.
const TRUNCATE_LENGTH_THRESHOLD = 5000;
const TRUNCATE_NODE_CLASSNAME = "propertyvalue-long-text";

// This symbol is used in stack entries for the `tokenType` property of the object we set
// as a key in `tokensByPart`, for token/part pairs that were already processed in child
// stack entries and which shouldn't be processed as individual entries.
const AGGREGATED_TOKEN_TYPE = Symbol("AGGREGATED_TOKEN_TYPE");

/**
 * This module is used to process CSS text declarations and output DOM fragments (to be
 * appended to panels in DevTools) for CSS values decorated with additional UI and
 * functionality.
 *
 * For example:
 * - attaching swatches for values instrumented with specialized tools: colors, timing
 * functions (cubic-bezier), filters, shapes, display values (flex/grid), etc.
 * - adding previews where possible (images, fonts, CSS transforms).
 * - converting between color types on Shift+click on their swatches.
 *
 * Usage:
 *   const OutputParser = require("devtools/client/shared/output-parser");
 *   const parser = new OutputParser(document, cssProperties);
 *   parser.parseCssProperty("color", "red"); // Returns document fragment.
 *
 */

class OutputParser {
  /**
   * @param {Document} document
   *        Used to create DOM nodes.
   * @param {CssProperties} cssProperties
   *        Instance of CssProperties, an object which provides an interface for
   *        working with the database of supported CSS properties and values.
   */

  constructor(document, cssProperties) {
    this.#doc = document;
    this.#cssProperties = cssProperties;
  }

  #angleSwatches = new WeakMap();
  #colorSwatches = new WeakMap();
  #cssProperties;
  #doc;
  #parsed = [];
  #stack = [];

  /**
   * Parse a CSS property value given a property name.
   *
   * @param  {string} name
   *         CSS Property Name
   * @param  {string} value
   *         CSS Property value
   * @param  {object} [options]
   *         Options object. For valid options and default values see
   *         #mergeOptions().
   * @return {DocumentFragment}
   *         A document fragment containing color swatches etc.
   */

  parseCssProperty(name, value, options = {}) {
    options = this.#mergeOptions(options);

    options.expectTimingFunction = this.#cssProperties.supportsType(
      name,
      "timing-function"
    );
    this.parsedPropertyName = name;
    options.expectDisplay = name === "display";
    options.expectFilter =
      name === "filter" ||
      (BACKDROP_FILTER_ENABLED && name === "backdrop-filter");
    options.expectShape =
      name === "clip-path" ||
      name === "shape-outside" ||
      name === "offset-path";
    options.expectFont = name === "font-family";
    options.isVariable = name.startsWith("--");
    options.supportsColor =
      this.#cssProperties.supportsType(name, "color") ||
      this.#cssProperties.supportsType(name, "gradient") ||
      // Parse colors for CSS variables declaration if the declaration value or the computed
      // value are valid colors.
      (options.isVariable &&
        (InspectorUtils.isValidCSSColor(value) ||
          InspectorUtils.isValidCSSColor(
            options.getVariableData?.(name).computedValue
          )));

    if (this.#cssPropertySupportsValue(name, value, options)) {
      return this.#parse(value, options);
    }
    this.#appendTextNode(value);

    return this.#toDOM();
  }

  /**
   * The workhorse for @see #parse. This parses some CSS text, stopping at EOF
   *
   * @param  {string} text
   *         The original input text.
   * @param  {object} options
   *         The options object in use; @see #mergeOptions.
   * @param  {CSSLexer} tokenStream
   *         The token stream from which to read
   * @return {DocumentFragment}
   *         A document fragment.
   */

  // eslint-disable-next-line complexity
  #doParse(text, options, tokenStream) {
    let fontFamilyNameIndex = null;
    let previousWasBang = false;

    const colorOK = () => {
      return (
        options.supportsColor ||
        ((options.expectFilter || options.isVariable) &&
          this.#stack.length !== 0 &&
          this.#stack.at(-1).isColorTakingFunction)
      );
    };

    const angleOK = function (angle) {
      return new angleUtils.CssAngle(angle).valid;
    };

    let spaceNeeded = false;

    let token;
    while ((token = tokenStream.nextToken())) {
      const tokenType = token.tokenType;
      if (tokenType === "Comment") {
        // This doesn't change spaceNeeded, because we didn't emit
        // anything to the output.
        continue;
      }

      const tokenText = text.substring(token.startOffset, token.endOffset);
      const lowerCaseTokenText = tokenText.toLowerCase();

      if (
        this.#stack.length &&
        // Don't add the token text to the current stack if we have a function or an
        // opening parenthesis, as we're going to create a new stack entry for those (with
        // the tokenText being the initial text value in it)
        tokenType !== "Function" &&
        tokenType !== "ParenthesisBlock"
      ) {
        const stackEntry = this.#stack.at(-1);
        stackEntry.text += tokenText;
        // We only want to add the token text to substituted text when there was one
        // deeper subtitution function (see #onCloseParenthesis)
        if (stackEntry.substitutedText !== null) {
          stackEntry.substitutedText += tokenText;
        }
      }

      switch (tokenType) {
        case "Function": {
          const functionName = token.value;
          const lowerCaseFunctionName = functionName.toLowerCase();
          const isColorTakingFunction = COLOR_TAKING_FUNCTIONS.has(
            lowerCaseFunctionName
          );

          this.#createStackEntry({
            lowerCaseFunctionName,
            functionName,
            isColorTakingFunction,
            text: tokenText,
          });

          if (
            options.cssExplainersEnabled &&
            CSS_EXPLAINERS_SUPPORTED_FUNCTIONS.has(lowerCaseFunctionName)
          ) {
            this.#appendNode(
              "span",
              { class"css-explainers-function-name" },
              functionName,
              token
            );
            this.#appendTextNode("(", token);
          } else {
            this.#appendTextNode(tokenText, token);
          }

          break;
        }

        case "Ident":
          if (
            options.expectTimingFunction &&
            BEZIER_KEYWORDS.has(lowerCaseTokenText)
          ) {
            this.#append(
              this.#createCubicBezierContainer({
                children: [token.text],
                parseOptions: options,
              }) || token.text,
              token
            );
          } else if (this.#isDisplayFlex(text, token, options)) {
            this.#appendDisplayWithHighlighterToggle(
              token.text,
              options.flexClass
            );
          } else if (this.#isDisplayGrid(text, token, options)) {
            this.#appendDisplayWithHighlighterToggle(
              token.text,
              options.gridClass
            );
          } else if (colorOK() && InspectorUtils.isValidCSSColor(token.text)) {
            const colorFunctionEntry = this.#stack.findLast(
              entry => entry.isColorTakingFunction
            );
            this.#appendColor(
              token.text,
              {
                ...options,
                colorFunction: colorFunctionEntry?.functionName,
              },
              token
            );
          } else if (angleOK(token.text)) {
            this.#appendAngle(token.text, options, token);
          } else {
            const idx = this.#appendTextNode(tokenText, token);
            if (
              options.expectFont &&
              // We don't append the identifier if the previous token
              // was equal to '!', since in that case we expect the
              // identifier to be equal to 'important'.
              !previousWasBang &&
              fontFamilyNameIndex == null &&
              // And if we're in a stack, we only expect a font-family after a comma (e.g.
              // in the fallback params for `var()`/`attr()`
              (!this.#stack.length || this.#stack.at(-1).sawComma)
            ) {
              fontFamilyNameIndex = idx;
            }
          }
          break;

        case "IDHash":
        case "Hash": {
          const original = tokenText;
          if (colorOK() && InspectorUtils.isValidCSSColor(original)) {
            if (spaceNeeded) {
              // Insert a space to prevent token pasting when a #xxx
              // color is changed to something like rgb(...).
              this.#appendTextNode(" ", token);
            }
            const colorFunctionEntry = this.#stack.findLast(
              entry => entry.isColorTakingFunction
            );
            this.#appendColor(
              original,
              {
                ...options,
                colorFunction: colorFunctionEntry?.functionName,
              },
              token
            );
          } else {
            this.#appendTextNode(original, token);
          }
          break;
        }
        case "Dimension": {
          if (angleOK(tokenText)) {
            this.#appendAngle(tokenText, options, token);
          } else {
            this.#appendTextNode(tokenText, token);
          }
          break;
        }
        case "UnquotedUrl":
        case "BadUrl":
          for (const part of this.#createURLElements(
            tokenText,
            token.value,
            options
          )) {
            this.#append(part, token);
          }
          break;

        case "QuotedString":
          {
            const idx = this.#appendTextNode(tokenText, token);
            if (options.expectFont && fontFamilyNameIndex == null) {
              fontFamilyNameIndex = idx;
            }
          }
          break;

        case "WhiteSpace":
          this.#appendTextNode(tokenText, token);
          break;

        case "ParenthesisBlock":
          this.#createStackEntry({ text: tokenText });
          this.#appendTextNode(tokenText, token);
          break;

        case "CloseParenthesis": {
          if (options.expectFont && fontFamilyNameIndex !== null) {
            this.#wrapFontFamilyName(fontFamilyNameIndex, options);
            // reset the variable so we can handle following names
            fontFamilyNameIndex = null;
          }
          this.#appendTextNode(")", token);
          this.#onCloseParenthesis(options);
          break;
        }

        case "Comma":
        case "Delim":
          if (
            (token.tokenType === "Comma" || token.text === "!") &&
            options.expectFont &&
            fontFamilyNameIndex !== null
          ) {
            this.#wrapFontFamilyName(fontFamilyNameIndex, options);
            // reset the variable so we can handle following names
            fontFamilyNameIndex = null;
          }

          if (tokenType === "Comma" && this.#stack.length) {
            this.#stack.at(-1).sawComma = true;
          }

          this.#appendTextNode(tokenText, token);
          break;

        // falls through
        default:
          this.#appendTextNode(tokenText, token);
          break;
      }

      // If this token might possibly introduce token pasting when
      // color-cycling, require a space.
      spaceNeeded =
        token.tokenType === "Ident" ||
        token.tokenType === "AtKeyword" ||
        token.tokenType === "IDHash" ||
        token.tokenType === "Hash" ||
        token.tokenType === "Number" ||
        token.tokenType === "Dimension" ||
        token.tokenType === "Percentage" ||
        token.tokenType === "Dimension";
      previousWasBang = token.tokenType === "Delim" && token.text === "!";
    }

    if (options.expectFont && fontFamilyNameIndex !== null) {
      this.#wrapFontFamilyName(fontFamilyNameIndex, options);
    }

    // We might never encounter a matching closing parenthesis for a function and still
    // have a "valid" value (e.g. `background: linear-gradient(90deg, red, blue"`)
    // In such case, go through the stack and handle each items until we have nothing left.
    if (this.#stack.length) {
      while (this.#stack.length !== 0) {
        this.#onCloseParenthesis(options);
      }
    }

    let result = this.#toDOM();

    if (options.expectFilter && !options.filterSwatch) {
      result = this.#wrapFilter(text, options, result);
    }

    return result;
  }

  /**
   * Add a stack entry in this.#stack
   *
   * @param {object} entryData: An object that will be spread into the stack entry.
   */

  #createStackEntry(entryData) {
    const stackEntry = {
      // The parsed parts of the function that will be rendered on screen.
      // This can hold Element or Text instances
      parts: [],
      // A <(Element|Text),object> Map, whose keys are element in `parts`,
      // and values are usually the token they represents (multiple part can represent
      // a single token).
      // When a set of tokens (e.g. a stack entry, a font family name, …) was already
      // handled (e.g. in #onCloseParenthesis or in #wrapFontFamilyName) the value will,
      // be an object with an AGGREGATED_TOKEN_TYPE tokenType and a `data` property that will
      // hold all or a subset of the properties that can be found in a stack entry
      tokensByPart: new WeakMap(),
      // Function name if token is a function, null otherwise.
      functionName: null,
      // Lowercase function name if token is a function, null otherwise.
      // Precomputed because this can be a hot path.
      lowerCaseFunctionName: null,
      // Will hold the names of the functions that are used inside the current one
      nestedFunctions: [],
      // Boolean indicating if the function accepts color parameters
      // if token is a function, null otherwise.
      isColorTakingFunction: null,
      // Will hold the text for the stack entry, i.e. the whole function call
      // (e.g. `min(10px, max(1em, var(--w, 20w)))`),
      text: "",
      // Will hold the substituted text for the stack entry, i.e. the whole function call
      // with subtitution functions (for now `var()`, but later `attr()` and `env()`)
      // being replaced by their returned value
      // (e.g. for `min(10px, max(1em, var(--w, 20w)))` with `--w:30%`, this will be
      // `min(10px, max(1em, 30%))`),
      // Initial value is null so it can properly be replaced by `text` when there
      // isn't any subtitution functions in the stack (we can't just use an empty string
      // as some function can subtitute to an empty string)
      substitutedText: null,
      // Used to know if a comma was found in the entry. Useful for functions like `var()`
      // or `attr()` to know if they have a fallback.
      sawComma: false,
      ...entryData,
    };
    this.#stack.push(stackEntry);
  }

  // eslint-disable-next-line complexity
  #onCloseParenthesis(options) {
    if (!this.#stack.length) {
      return;
    }

    const stackEntry = this.#stack.pop();
    let { lowerCaseFunctionName, parts, text } = stackEntry;
    if (lowerCaseFunctionName === "attr") {
      parts = this.#onCloseParenthesisForAttr(stackEntry, options);
    } else if (lowerCaseFunctionName === "cubic-bezier") {
      parts = this.#onCloseParenthesisForCubicBezier(stackEntry, options);
    } else if (lowerCaseFunctionName === "light-dark") {
      parts = this.#onCloseParenthesisForLightDark(stackEntry, options);
    } else if (lowerCaseFunctionName === "linear") {
      parts = this.#onCloseParenthesisForLinear(stackEntry, options);
    } else if (lowerCaseFunctionName === "url") {
      parts = this.#onCloseParenthesisForUrl(stackEntry, options);
    } else if (lowerCaseFunctionName === "var") {
      parts = this.#onCloseParenthesisForVar(stackEntry, options);
    } else if (BASIC_SHAPE_FUNCTIONS.has(lowerCaseFunctionName)) {
      parts = this.#onCloseParenthesisForBasicShape(stackEntry, options);
    } else if (
      (options.supportsColor ||
        ((options.expectFilter || options.isVariable) &&
          this.#stack.length !== 0 &&
          this.#stack.at(-1).isColorTakingFunction)) &&
      InspectorUtils.isValidCSSColor(
        // use the substituted text when we have one, as it allows us to still get the
        // color swatch when we have CSS variables parameters
        stackEntry.substitutedText ?? stackEntry.text
      )
    ) {
      const colorFunctionEntry = this.#stack.findLast(
        entry => entry.isColorTakingFunction
      );
      const colorObj =
        options.colorObj ||
        new colorUtils.CssColor(stackEntry.substitutedText ?? stackEntry.text);
      const colorContainerEl = this.#createColorContainerElement(
        colorObj,
        {
          ...options,
          colorFunction: colorFunctionEntry?.functionName,
        },
        stackEntry.parts
      );
      parts = [colorContainerEl];
    }

    if (
      options.cssExplainersEnabled &&
      CSS_EXPLAINERS_SUPPORTED_FUNCTIONS.has(lowerCaseFunctionName) &&
      stackEntry.nestedFunctions.every(fn =>
        CSS_EXPLAINERS_SUPPORTED_FUNCTIONS.has(fn)
      )
    ) {
      const functionNode = this.#createNode("span", {
        class: options.functionClass,
        "data-function-expression": stackEntry.text,
      });
      functionNode.append(...parts);
      parts = [functionNode];
    }

    // Put all the parts in the "new" last stack, or the main parsed array if there
    // is no more entry in the stack
    this.#getCurrentStackParts().push(...parts);

    if (this.#stack.length) {
      const lastStackEntry = this.#stack.at(-1);
      // This needs to be done before we update lastStackEntry.text
      if (
        // Only compute the substituted text if a stack entry has substituted text…
        stackEntry.substitutedText ||
        // …or if substituted text was already consumed in a "child" stack entry
        lastStackEntry.substitutedText
      ) {
        // if that's the first substituted function we encounter, we need to initialize
        // the value
        if (lastStackEntry.substitutedText === null) {
          lastStackEntry.substitutedText = lastStackEntry.text;
        }

        // substitutedText is only computed for some functions, so fall back to text when
        // it doesn't exist
        const textToAdd = stackEntry.substitutedText ?? text;
        lastStackEntry.substitutedText += textToAdd;
      }
      // Then update the authored text
      lastStackEntry.text += text;

      if (stackEntry.lowerCaseFunctionName) {
        // Set the nested functions by adding the one for the stack entry we just handled
        lastStackEntry.nestedFunctions = [
          stackEntry.lowerCaseFunctionName,
          ...stackEntry.nestedFunctions,
        ];
      } else {
        // If we closed a parenthesis block, just copy the nested functions we had
        lastStackEntry.nestedFunctions = Array.from(stackEntry.nestedFunctions);
      }

      const compoundEntryToken = {
        // Associate AGGREGATED_TOKEN_TYPE to the part so consumers can know the part was for
        // a previous stack entry and shouldn't be considered.
        tokenType: AGGREGATED_TOKEN_TYPE,
        data: stackEntry,
      };
      for (const part of parts) {
        lastStackEntry.tokensByPart.set(part, compoundEntryToken);
      }
    }
  }

  /**
   * Called when we got the closing bracket for `light-dark()`
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  #onCloseParenthesisForLightDark(stackEntry, options) {
    const stackEntryParts = stackEntry.parts;
    if (typeof options.isDarkColorScheme !== "boolean") {
      return stackEntryParts;
    }

    let separatorIndex = null;
    for (let i = 0; i < stackEntryParts.length; i++) {
      const token = stackEntry.tokensByPart.get(stackEntryParts[i]);
      if (token?.tokenType === "Comma") {
        if (separatorIndex === null) {
          separatorIndex = i;
        } else {
          // light-dark takes exactly two parameters, so if we don't get exactly 1 separator
          // at this point, that means that the value is valid at parse time, but is invalid
          // at computed value time.
          // TODO: We might want to add a class to indicate that this is invalid at computed
          // value time (See Bug 1910845)
          return stackEntryParts;
        }
      }
    }

    if (separatorIndex === null) {
      return stackEntryParts;
    }

    let startIndex;
    let endIndex;
    if (options.isDarkColorScheme) {
      // If we're using a dark color scheme, we want to mark the first param as
      // not used.

      // The first "part" is `light-dark(`, so we can start after that.
      // We want to filter out white space character before the first parameter
      for (let i = 1; i < separatorIndex; i++) {
        const token = stackEntry.tokensByPart.get(stackEntryParts[i]);
        if (token?.tokenType !== "WhiteSpace") {
          startIndex = i;
          break;
        }
      }

      // same for the end of the parameter, we want to filter out whitespaces
      // after the parameter and before the comma
      endIndex = separatorIndex - 1;
      for (let i = endIndex; i >= startIndex; i--) {
        const token = stackEntry.tokensByPart.get(stackEntryParts[i]);
        if (token?.tokenType !== "WhiteSpace") {
          // We found a non-whitespace part, we need to include it, so increment the endIndex
          endIndex = i + 1;
          break;
        }
      }
    } else {
      // If we're not using a dark color scheme, we want to mark the second param as
      // not used.

      // We want to filter out white space character after the comma and before the
      // second parameter
      for (let i = separatorIndex + 1; i < stackEntryParts.length; i++) {
        const token = stackEntry.tokensByPart.get(stackEntryParts[i]);
        if (token?.tokenType !== "WhiteSpace") {
          startIndex = i;
          break;
        }
      }

      // same for the end of the parameter, we want to filter out whitespaces
      // after the parameter and before the closing parenthesis (which is not yet
      // included in stackEntryParts)
      for (
        // we don't start at the last part, but the one before that, as the last part will
        // always be the closing parenthesis for the function, and it shouldn't be included
        // in the unmatched span.
        let i = stackEntryParts.length - 2;
        i > separatorIndex;
        i--
      ) {
        const token = stackEntry.tokensByPart.get(stackEntryParts[i]);
        if (token?.tokenType !== "WhiteSpace") {
          // We found a non-whitespace part, we need to include it, so increment the endIndex
          endIndex = i + 1;
          break;
        }
      }
    }

    const parts = stackEntryParts.slice(startIndex, endIndex);

    // If the item we need to mark is already an element (e.g. a parsed color),
    // just add a class to it.
    if (parts.length === 1 && Element.isInstance(parts[0])) {
      parts[0].classList.add(options.unmatchedClass);
    } else {
      // Otherwise, we need to wrap our parts into a specific element so we can
      // style them
      const node = this.#createNode("span", {
        class: options.unmatchedClass,
      });
      node.append(...parts);
      stackEntryParts.splice(startIndex, parts.length, node);
    }

    return stackEntryParts;
  }

  /**
   * Called when we got the closing bracket for `cubic-bezier()`
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  #onCloseParenthesisForCubicBezier(stackEntry, options) {
    if (!options.expectTimingFunction) {
      return stackEntry.parts;
    }

    const container = this.#createCubicBezierContainer({
      children: stackEntry.parts,
      parseOptions: options,
    });

    return container ? [container] : stackEntry.parts;
  }

  /**
   * Called when we got the closing bracket for `linear()`
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  #onCloseParenthesisForLinear(stackEntry, options) {
    if (!options.expectTimingFunction) {
      return stackEntry.parts;
    }

    const linear = stackEntry.text;

    if (linear.includes("var(")) {
      // For now, we don't support cubic-bezier with CSS variables (see Bug 2031696)
      return stackEntry.parts;
    }

    const container = this.#createNode("span", {
      "data-linear": linear,
    });

    if (options.linearEasingSwatchClass) {
      const swatch = this.#createNode("span", {
        class: options.linearEasingSwatchClass,
        tabindex: "0",
        role: "button",
        "data-linear": linear,
      });
      container.appendChild(swatch);
    }

    const valueEl = this.#createNode("span", {
      class: options.linearEasingClass,
    });
    valueEl.append(...stackEntry.parts);
    container.appendChild(valueEl);
    return [container];
  }

  /**
   * Called when we got the closing bracket for `attr()`
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  // eslint-disable-next-line complexity
  #onCloseParenthesisForAttr(stackEntry, options) {
    if (typeof options.getAttributeValue !== "function") {
      return stackEntry.parts;
    }

    let attrNameIndex = null;
    let attrTypeIndex = null;
    let commaIndex = null;
    for (let i = 0; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      if (!stackEntry.tokensByPart.has(part)) {
        continue;
      }
      const token = stackEntry.tokensByPart.get(part);

      // The attribute name is the first Ident
      if (token.tokenType === "Ident" && attrNameIndex === null) {
        attrNameIndex = i;
      } else if (
        // If we have another Ident or a closed stack entry before the comma, then that's
        // the attr type.
        attrNameIndex !== null &&
        attrTypeIndex === null &&
        // Here we're looking for <attr-type> which might be an Ident (raw-string,
        // number, px, …), a % (Delim) or the `type()` function (which will be represented
        // as an aggregated token at this point)
        (token.tokenType === "Ident" ||
          (token.tokenType === "Delim" && token.text === "%") ||
          token.tokenType === AGGREGATED_TOKEN_TYPE)
      ) {
        attrTypeIndex = i;
      } else if (token.tokenType === "Comma") {
        commaIndex = i;
        break;
      }
    }

    // This shouldn't happen, but let's be safe
    if (attrNameIndex === null) {
      return stackEntry.parts;
    }

    // Get the attribute name part, which should be the first Ident
    const attrNamePart = stackEntry.parts[attrNameIndex];
    const attrName = attrNamePart.textContent;
    // and its value
    const attrValue = options.getAttributeValue(attrName);

    // as well as the first attribute (might contain attribute name + typing information),
    // with specific style if the attribute isn't set
    const attrFirstParamNode = this.#createNode("span", {
      class"inspector-attr-param",
    });

    // > When an <attr-type> is set, attr() will try to parse the attribute into that
    // > specified <attr-type> and return it.
    // > If the attribute cannot be parsed into the given <attr-type>, the <fallback-value>
    // > will be returned instead.
    // > When no <attr-type> is set, the attribute will be parsed into a CSS string.
    // > If no <fallback-value> is set, the return value will default to an empty string
    // > when no <attr-type> is set or the guaranteed-invalid value when an <attr-type> is set.
    let fallbackValueIsUsed = attrValue === null;
    let attrTypeMismatchText;
    if (attrTypeIndex !== null && attrValue !== null) {
      const part = stackEntry.parts[attrTypeIndex];
      const token = stackEntry.tokensByPart.get(part);
      // First, we want to handle <attr-type> other than `type()`, i.e. Idents (`raw-string`,
      // `number`, `px`, …) and `%`
      if (
        token.tokenType === "Ident" ||
        (token.tokenType === "Delim" && token.text === "%")
      ) {
        // For `number` and units, the spec says:
        // > If given as the number keyword, it causes the attribute’s literal value […]
        // to be parsed as a <number-token>.
        // > Values that fail to parse trigger fallback.
        // […]
        // > If given as an <attr-unit> value, the value is first parsed as if number
        // > keyword was specified, then the resulting numeric value is turned into a
        // > dimension with the corresponding unit, or a percentage if % was given.
        // > Same as for number <attr-type>, values that do not correspond to the
        // > <number-token> production trigger fallback.

        // So we need to check that the attribute value is actually a number. And that's
        // pretty much it: for <attr-unit>, if the given unit is not known, the declaration
        // is invalid and won't be parsed anyway

        if (
          token.text !== "raw-string" &&
          !InspectorUtils.valueMatchesSyntax(this.#doc, attrValue, "<number>")
        ) {
          fallbackValueIsUsed = true;
          attrTypeMismatchText = STYLE_INSPECTOR_L10N.getFormatStr(
            "rule.attributeNotNumber",
            `"${attrValue}"`
          );
        }
      } else if (
        token.tokenType === AGGREGATED_TOKEN_TYPE &&
        token.data.lowerCaseFunctionName === "type"
      ) {
        // Here we have a type() function. We need to extract its content to see if
        // the attribute value can be parsed with this type.
        // We can take a small shortcut here: we have the text of the type() function so…
        const syntax = token.data.text
          .slice(
            // …we can just remove the leading "type("
            5,
            // …as well as the  trailing ")"
            -1
          )
          .trim();
        if (!InspectorUtils.valueMatchesSyntax(this.#doc, attrValue, syntax)) {
          fallbackValueIsUsed = true;
          attrTypeMismatchText = STYLE_INSPECTOR_L10N.getFormatStr(
            "rule.attributeUnmatchedType",
            `"${attrValue}"`,
            `"${syntax}"`
          );
        }
      }
    }

    // First, we want to render the attribute name on its own element
    const attrNameNode = this.#createNode(
      "span",
      {
        class"inspector-attr-name",
      },
      attrName
    );
    stackEntry.parts[attrNameIndex] = attrNameNode;

    if (fallbackValueIsUsed) {
      attrFirstParamNode.classList.add(options.unmatchedClass);
    }

    if (attrValue === null) {
      attrFirstParamNode.setAttribute(
        "data-attribute",
        STYLE_INSPECTOR_L10N.getFormatStr("rule.attributeUnset", attrName)
      );
    } else if (attrTypeMismatchText) {
      attrFirstParamNode.setAttribute("data-attribute", attrTypeMismatchText);
    } else {
      // Otherwise we set it on the attribute name only
      attrNameNode.setAttribute("data-attribute", `"${attrValue}"`);
    }

    // Let's put all the parts starting with the attribute name until the comma
    let attrFirstParamChildCount = 0;
    let attrFirstParamEndIndex;
    if (commaIndex === null) {
      // if we didn't found a comma, we want to get all the items until the closing
      // parenthesis, which is the last item in parts
      attrFirstParamEndIndex = stackEntry.parts.length - 1;
    } else if (
      // if the token before the comma is a whitespace, don't include it in the first param node
      stackEntry.tokensByPart.get(stackEntry.parts[commaIndex - 1])
        ?.tokenType === "WhiteSpace"
    ) {
      attrFirstParamEndIndex = commaIndex - 1;
    } else {
      attrFirstParamEndIndex = commaIndex;
    }

    for (let i = attrNameIndex; i < attrFirstParamEndIndex; i++) {
      attrFirstParamNode.append(stackEntry.parts[i]);
      attrFirstParamChildCount++;
    }

    stackEntry.parts.splice(
      attrNameIndex,
      attrFirstParamChildCount,
      attrFirstParamNode
    );

    // We don't have to do anything more when there's no fallback value, i.e. if we didn't
    // found a comma
    if (commaIndex === null) {
      return stackEntry.parts;
    }

    // we need to update the comma index, as we added attrFirstParamNode in parts and
    // removed all the elements we put in it.
    commaIndex = commaIndex + 1 - attrFirstParamChildCount;
    let fallbackStartIndex = null;
    // Then we want to find the part that correspond to the first non whitespace token,
    // which will be the start of the fallback param
    for (let i = commaIndex + 1; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      if (!stackEntry.tokensByPart.has(part)) {
        continue;
      }
      const token = stackEntry.tokensByPart.get(part);
      if (
        // we might get into a part that was already handled, for example a nested function,
        // and in such case, it should be part of the fallback element
        token.tokenType === AGGREGATED_TOKEN_TYPE ||
        token.tokenType !== "WhiteSpace"
      ) {
        fallbackStartIndex = i;
        break;
      }
    }

    // This shouldn't happen, but let's be safe an bail if we didn't find the fallback part
    if (fallbackStartIndex === null) {
      return stackEntry.parts;
    }

    // The last part is the closing bracket, so let's put the index before it.
    let fallbackEndTokenIndex = stackEntry.parts.length - 2;
    for (let i = fallbackEndTokenIndex; i >= fallbackStartIndex; i--) {
      const part = stackEntry.parts[i];
      if (!stackEntry.tokensByPart.has(part)) {
        continue;
      }
      const token = stackEntry.tokensByPart.get(part);
      if (
        // we might get into a part that was already handled, for example a nested function,
        // and in such case, it should be part of the fallback element
        token.tokenType === AGGREGATED_TOKEN_TYPE ||
        token.tokenType !== "WhiteSpace"
      ) {
        fallbackEndTokenIndex = i;
        break;
      }
    }

    // So, at this point, we have the fallback parts that we want to put in their own elements
    const partsToWrap = stackEntry.parts.splice(
      fallbackStartIndex,
      fallbackEndTokenIndex - fallbackStartIndex + 1
    );

    const fallbackEl = this.#createNode("span", {
      class: `inspector-attr-fallback${fallbackValueIsUsed ? "" : " " + options.unmatchedClass}`,
    });
    fallbackEl.append(...partsToWrap);
    stackEntry.parts.splice(fallbackStartIndex, 0, fallbackEl);
    return stackEntry.parts;
  }

  /**
   * Called when we got the closing bracket for any function in BASIC_SHAPE_FUNCTIONS.
   * It will append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  #onCloseParenthesisForBasicShape(stackEntry, options) {
    if (!options.expectShape) {
      return stackEntry.parts;
    }

    const container = this.#createNode("span", {});
    const valContainer = this.#createNode("span", {
      class: options.shapeClass,
    });

    // Let's retrieve the index in `parts` where the coordinates start
    let coordStartIdx = null;
    let previousToken;
    for (let i = 0; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);
      // Multiple consecutive parts can reference the same token, so let's find the first
      // part that refers to a token that is not the initial function.
      if (
        token.tokenType === "Function" &&
        (!previousToken || token === previousToken)
      ) {
        coordStartIdx = i + 1;
        previousToken = token;
        valContainer.append(part);
      } else if (coordStartIdx !== null) {
        // we already found the coordinate, and the token does not represent the initial
        // function, so we can stop looping
        break;
      }
    }

    // That shouldn't happen, but let's be safe
    if (coordStartIdx === null) {
      return stackEntry.parts;
    }

    if (stackEntry.lowerCaseFunctionName === "polygon") {
      valContainer.append(
        ...this.#onCloseParenthesisForPolygonShape(stackEntry, coordStartIdx)
      );
    } else if (stackEntry.lowerCaseFunctionName === "circle") {
      valContainer.append(
        ...this.#onCloseParenthesisForCircleShape(stackEntry, coordStartIdx)
      );
    } else if (stackEntry.lowerCaseFunctionName === "ellipse") {
      valContainer.append(
        ...this.#onCloseParenthesisForEllipseShape(stackEntry, coordStartIdx)
      );
    } else if (stackEntry.lowerCaseFunctionName === "inset") {
      valContainer.append(
        ...this.#onCloseParenthesisForInsetShape(stackEntry, coordStartIdx)
      );
    }

    if (options.shapeSwatchClass) {
      const toggleButton = this.#createNode("button", {
        class: options.shapeSwatchClass,
      });
      container.appendChild(toggleButton);
    }

    container.appendChild(valContainer);
    return [container];
  }

  /**
   * Called when we got the closing bracket for the `polygon()` function.
   * It will append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {number} coordsStartIdx
   *        The index in stackEntry.parts at which the coordinates for the polygon start
   * @returns {Array<Element|Text>} The parts that were handled
   */

  // eslint-disable-next-line complexity
  #onCloseParenthesisForPolygonShape(stackEntry, coordsStartIdx) {
    const points = [];
    let previousToken;
    for (let i = coordsStartIdx; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);

      if (
        token.tokenType !== "Number" &&
        token.tokenType !== "Dimension" &&
        token.tokenType !== "Percentage" &&
        // a collapsed function call (e.g. `var(…)`) counts as a single argument
        token.tokenType !== AGGREGATED_TOKEN_TYPE
      ) {
        continue;
      }

      const lastPoint = points.at(-1);
      if (previousToken !== token) {
        if (!lastPoint || lastPoint.y) {
          points.push({
            x: [i],
          });
        } else {
          lastPoint.y = [i];
        }
      } else if (lastPoint.y) {
        lastPoint.y.push(i);
      } else {
        lastPoint.x.push(i);
      }

      previousToken = token;
    }

    // Let's iterate through points in reverse as we're going to mutate stackEntry.parts
    // and the indexes in `points` refer to the original indexes
    for (let i = points.length - 1; i >= 0; i--) {
      const point = points[i];
      const xNode = this.#createNode("span", {
        class"inspector-shape-point",
        "data-point": i,
        "data-pair""x",
      });
      for (const idx of point.x) {
        xNode.append(stackEntry.parts[idx]);
      }
      const yNode = this.#createNode("span", {
        class"inspector-shape-point",
        "data-point": i,
        "data-pair""y",
      });
      for (const idx of point.y) {
        yNode.append(stackEntry.parts[idx]);
      }
      const coordNode = this.#createNode("span", {
        class"inspector-shape-point",
        "data-point": i,
      });
      coordNode.append(xNode);
      // Put the parts between the x and y points
      for (let j = point.x.at(-1) + 1; j < point.y[0]; j++) {
        coordNode.append(stackEntry.parts[j]);
      }
      coordNode.append(yNode);
      stackEntry.parts.splice(
        point.x[0],
        point.y.at(-1) - point.x[0] + 1,
        coordNode
      );
    }

    return stackEntry.parts;
  }

  /**
   * Called when we got the closing bracket for the `circle()` function.
   * It will append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {number} coordsStartIdx
   *        The index in stackEntry.parts at which the coordinates for the circle start
   * @returns {Array<Element|Text>} The parts that were handled
   */
  // eslint-disable-next-line complexity
  #onCloseParenthesisForCircleShape(stackEntry, coordsStartIdx) {
    const radiusPartsIndexes = [];
    const positionsPartsIndexes = [];
    let seenAtKeyword = false;
    let previousToken;
    for (let i = coordsStartIdx; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);

      if (token.tokenType === "Ident" && token.text === "at") {
        seenAtKeyword = true;
        continue;
      }

      // circle() can take a radius which is before `at`, which can be a length, percentage,
      // or a keyword (closest-corner, closest-side, farthest-corner, farthest-side)
      if (
        !seenAtKeyword &&
        (token.tokenType === "Number" ||
          token.tokenType === "Dimension" ||
          token.tokenType === "Percentage" ||
          token.tokenType === "Ident" ||
          // a collapsed function call (e.g. `var(…)`) counts as a single argument
          token.tokenType === AGGREGATED_TOKEN_TYPE)
      ) {
        // we have a single radius, the array will contain all the indexes of parts that
        // refer to it.
        radiusPartsIndexes.push(i);
      }

      // after that `at` keyword, the position of the circle is defined. It can be represented
      // by 1, 2 or 4 length, percentage or keyword (e.g. start, center, …)
      // So let's collect all those here
      if (
        seenAtKeyword &&
        (token.tokenType === "Number" ||
          token.tokenType === "Dimension" ||
          token.tokenType === "Percentage" ||
          token.tokenType === "Ident" ||
          // a collapsed function call (e.g. `var(…)`) counts as a single argument
          token.tokenType === AGGREGATED_TOKEN_TYPE)
      ) {
        if (token !== previousToken) {
          positionsPartsIndexes.push([i]);
        } else {
          // if the token for the current part is the same one as the previous part, then
          // it represent the same position, so we add the part index to the last position
          // item we added.
          positionsPartsIndexes.at(-1).push(i);
        }
      }

      previousToken = token;
    }

    // We're going to mutate stackEntry.parts, so let's go through the parts in reverse
    // as the indexes in radiusIndexes and positionIndexes refer to the original indexes
    // So first, let's handle positions if there are some
    if (positionsPartsIndexes.length) {
      const centerEl = this.#createNode("span", {
        class: "inspector-shape-point",
        "data-point": "center",
      });
      for (let i = positionsPartsIndexes.length - 1; i >= 0; i--) {
        const pointEl = this.#createNode("span", {
          class: "inspector-shape-point",
          "data-point": "center",
        });
        if (i === 0) {
          pointEl.setAttribute("data-pair", "x");
        } else if (positionsPartsIndexes.length === 2) {
          // Here we're not handling the first item, and there's only 2 items, so we know
          // we have the y coord
          pointEl.setAttribute("data-pair", "y");
        } else if (i === 2) {
          // If there's more than 2 position, that means we have a <position-four> type,
          // where there's both x,y positions + offsets (e.g. `left 10px top 15px`)
          // In such case, the first item is x (already handled in the first if block),
          // and the third item is y
          pointEl.setAttribute("data-pair", "y");
        }

        const indexes = positionsPartsIndexes[i];
        for (const idx of indexes) {
          pointEl.append(stackEntry.parts[idx]);
        }

        centerEl.prepend(pointEl);
        stackEntry.parts.splice(indexes[0], indexes.length);

        // append any parts between this point and the previous one into centerEl
        const previousIndexes = positionsPartsIndexes[i - 1];
        if (previousIndexes) {
          for (let j = indexes[0] - 1; j > previousIndexes.at(-1); j--) {
            centerEl.prepend(stackEntry.parts[j]);
            stackEntry.parts.splice(j, 1);
          }
        }
      }
      stackEntry.parts.splice(positionsPartsIndexes[0][0], 0, centerEl);
    }

    // Handle radius size if there's one
    if (radiusPartsIndexes.length) {
      const radiusEl = this.#createNode("span", {
        class: "inspector-shape-point",
        "data-point": "radius",
      });
      for (let i = radiusPartsIndexes.length - 1; i >= 0; i--) {
        const idx = radiusPartsIndexes[i];
        radiusEl.prepend(stackEntry.parts[idx]);
        stackEntry.parts.splice(idx, 1);
      }
      stackEntry.parts.splice(radiusPartsIndexes[0], 0, radiusEl);
    }

    return stackEntry.parts;
  }

  /**
   * Called when we got the closing bracket for the `ellipse()` function.
   * It will append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {number} coordsStartIdx
   *        The index in stackEntry.parts at which the coordinates for the ellipse start
   * @returns {Array<Element|Text>} The parts that were handled
   */

  // eslint-disable-next-line complexity
  #onCloseParenthesisForEllipseShape(stackEntry, coordsStartIdx) {
    const radiiPartsIndexes = [];
    const positionsPartsIndexes = [];
    let seenAtKeyword = false;
    let previousToken;
    for (let i = coordsStartIdx; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);

      if (token.tokenType === "Ident" && token.text === "at") {
        seenAtKeyword = true;
        continue;
      }

      // ellipse() can take two radii before `at`, which can be a lengths, percentages,
      // or a keywords (closest-corner, closest-side, farthest-corner, farthest-side)
      if (
        !seenAtKeyword &&
        (token.tokenType === "Number" ||
          token.tokenType === "Dimension" ||
          token.tokenType === "Percentage" ||
          token.tokenType === "Ident" ||
          // a collapsed function call (e.g. `var(…)`) counts as a single argument
          token.tokenType === AGGREGATED_TOKEN_TYPE)
      ) {
        if (token !== previousToken) {
          radiiPartsIndexes.push([i]);
        } else {
          // if the token for the current part is the same one as the previous part, then
          // it represent the same radius, so we add the part index to the last radius
          // item we added.
          radiiPartsIndexes.at(-1).push(i);
        }
      }

      // after that `at` keyword, the position of the ellipse is defined. It can be represented
      // by 1, 2 or 4 length, percentage or keyword (e.g. start, center, …)
      // So let's collect all those here
      if (
        seenAtKeyword &&
        (token.tokenType === "Number" ||
          token.tokenType === "Dimension" ||
          token.tokenType === "Percentage" ||
          token.tokenType === "Ident" ||
          // a collapsed function call (e.g. `var(…)`) counts as a single argument
          token.tokenType === AGGREGATED_TOKEN_TYPE)
      ) {
        if (token !== previousToken) {
          positionsPartsIndexes.push([i]);
        } else {
          // if the token for the current part is the same one as the previous part, then
          // it represent the same position, so we add the part index to the last position
          // item we added.
          positionsPartsIndexes.at(-1).push(i);
        }
      }

      previousToken = token;
    }

    // We're going to mutate stackEntry.parts, so let's go through the parts in reverse
    // as the indexes in radiusIndexes and positionIndexes refer to the original indexes
    // So first, let's handle positions if there are some
    if (positionsPartsIndexes.length) {
      const centerEl = this.#createNode("span", {
        class"inspector-shape-point",
        "data-point""center",
      });
      for (let i = positionsPartsIndexes.length - 1; i >= 0; i--) {
        const pointEl = this.#createNode("span", {
          class"inspector-shape-point",
          "data-point""center",
        });
        if (i === 0) {
          pointEl.setAttribute("data-pair""x");
        } else if (positionsPartsIndexes.length === 2) {
          // Here we're not handling the first item, and there's only 2 items, so we know
          // we have the y coord
          pointEl.setAttribute("data-pair""y");
        } else if (i === 2) {
          // If there's more than 2 position, that means we have a <position-four> type,
          // where there's both x,y positions + offsets (e.g. `left 10px top 15px`)
          // In such case, the first item is x (already handled in the first if block),
          // and the third item is y
          pointEl.setAttribute("data-pair""y");
        }

        const indexes = positionsPartsIndexes[i];
        for (const idx of indexes) {
          pointEl.append(stackEntry.parts[idx]);
        }
        // we're iterating the parts in reverse, so we need to prepend in centerEl
        centerEl.prepend(pointEl);
        // We can remove as many items as we have indexes here, because if we have
        // multiple parts refering to the same position, their indexes should be consecutive.
        stackEntry.parts.splice(indexes[0], indexes.length);

        // prepend any parts (e.g. whitespaces) between this point and the previous one
        // into centerEl
        const previousIndexes = positionsPartsIndexes[i - 1];
        if (previousIndexes) {
          for (let j = indexes[0] - 1; j > previousIndexes.at(-1); j--) {
            centerEl.prepend(stackEntry.parts[j]);
            stackEntry.parts.splice(j, 1);
          }
        }
      }
      stackEntry.parts.splice(positionsPartsIndexes[0][0], 0, centerEl);
    }

    // Handle radius size if there are some
    if (radiiPartsIndexes.length) {
      for (let i = radiiPartsIndexes.length - 1; i >= 0; i--) {
        const radiusEl = this.#createNode("span", {
          class"inspector-shape-point",
          // we should only have 2 radii, the first one being rx and the second one ry
          "data-point": i === 0 ? "rx" : "ry",
        });

        const indexes = radiiPartsIndexes[i];
        for (const idx of indexes) {
          radiusEl.append(stackEntry.parts[idx]);
        }
        // We can remove as many items as we have indexes here, because if we have
        // multiple parts refering to the same radius, their indexes should be consecutive.
        stackEntry.parts.splice(indexes[0], indexes.length, radiusEl);
      }
    }

    return stackEntry.parts;
  }

  /**
   * Called when we got the closing bracket for the `inset()` function.
   * It will append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {number} coordsStartIdx
   *        The index in stackEntry.parts at which the coordinates for the inset start
   * @returns {Array<Element|Text>} The parts that were handled
   */

  #onCloseParenthesisForInsetShape(stackEntry, coordsStartIdx) {
    const insetPointsPartsIndexes = [];
    let previousToken;
    for (let i = coordsStartIdx; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);

      if (token.tokenType === "Ident" && token.text === "round") {
        // Once we see the `round` keyword, we can stop looping, we have all the coordinates
        // we need
        break;
      }

      if (
        token.tokenType !== "Number" &&
        token.tokenType !== "Dimension" &&
        token.tokenType !== "Percentage" &&
        // a collapsed function call (e.g. `var(…)`) counts as a single argument
        token.tokenType !== AGGREGATED_TOKEN_TYPE
      ) {
        continue;
      }

      const lastPoint = insetPointsPartsIndexes.at(-1);
      if (!lastPoint || previousToken !== token) {
        insetPointsPartsIndexes.push([i]);
      } else if (lastPoint) {
        lastPoint.push(i);
      }

      previousToken = token;
    }

    const insetPoints = ["top""right""bottom""left"];

    // Let's iterate through points in reverse as we're going to mutate stackEntry.parts
    // and the indexes in `points` refer to the original indexes
    for (let i = insetPointsPartsIndexes.length - 1; i >= 0; i--) {
      const pointPartsIndexes = insetPointsPartsIndexes[i];
      const shapePointNode = this.#createNode("span", {
        class"inspector-shape-point",
      });

      // insetPoints contains the 4 different possible inset points in the order they are
      // defined. By taking the modulo of the index in insetPoints with the number of nodes,
      // we can get which node represents each point (e.g. if there is only 1 node, it
      // represents all 4 points). The exception is "left" when there are 3 nodes. In that
      // case, it is nodes[1] that represents the left point rather than nodes[0].
      if (insetPointsPartsIndexes.length === 1) {
        shapePointNode.classList.add(...insetPoints);
      } else if (insetPointsPartsIndexes.length === 2) {
        if (i === 0) {
          shapePointNode.classList.add(insetPoints[0], insetPoints[2]);
        } else {
          shapePointNode.classList.add(insetPoints[1], insetPoints[3]);
        }
      } else if (insetPointsPartsIndexes.length === 3) {
        if (i === 1) {
          shapePointNode.classList.add(insetPoints[1], insetPoints[3]);
        } else {
          shapePointNode.classList.add(insetPoints[i]);
        }
      } else if (insetPointsPartsIndexes.length === 4) {
        shapePointNode.classList.add(insetPoints[i]);
      }

      for (const idx of pointPartsIndexes) {
        shapePointNode.append(stackEntry.parts[idx]);
      }

      stackEntry.parts.splice(
        pointPartsIndexes[0],
        pointPartsIndexes.at(-1) - pointPartsIndexes[0] + 1,
        shapePointNode
      );
    }

    return stackEntry.parts;
  }

  /**
   * Called when we got the closing parenthesis for `url()`.
   * It will wrap the URL into a proper <a> element.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  #onCloseParenthesisForUrl(stackEntry, options) {
    if (!options.urlClass) {
      return stackEntry.parts;
    }

    // url() with quoted strings are not mapped as UnquotedUrl, instead, we get a "Function"
    // token with "url" (the one we're closing here), and later, a "QuotedString" token
    // which contains the actual URL.
    // So here, we only need to loop through the parts to find the one which holds the
    // QuotedString token and wrap it in an anchor.
    let url;
    for (let i = 0; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);
      if (token?.tokenType !== "QuotedString") {
        continue;
      }

      // url() only takes a string, so we'll only have a single part refering to the url token
      url = token.value;
      break;
    }

    if (!url) {
      return stackEntry.parts;
    }

    return this.#createURLElements(stackEntry.text, url, options);
  }

  /**
   * Called when we got the closing parenthesis for `var()`.
   *
   * @param {object} stackEntry
   *        The last item in this.#stack
   * @param {object} options
   *        options passed to the parse function. @see #mergeOptions for valid options
   *        and default values
   * @returns {Array<string|Element>} The updated parts for the stack entry that is being closed.
   */

  // eslint-disable-next-line complexity
  #onCloseParenthesisForVar(stackEntry, options) {
    if (!options.getVariableData) {
      return stackEntry.parts;
    }

    let varNameIndex = null;
    let varName = null;
    let fallbackStartIndex = null;
    for (let i = 0; i < stackEntry.parts.length; i++) {
      const part = stackEntry.parts[i];
      const token = stackEntry.tokensByPart.get(part);

      // The variable name is the first Ident we find
      if (varNameIndex === null && token.tokenType === "Ident") {
        varNameIndex = i;
        varName = token.text;
      } else if (token.tokenType === "Comma") {
        // Anything between the first comma and the end of the function is considered a
        // fallback value.
        fallbackStartIndex = i + 1;
        break;
      }
    }

    // Shouldn't happen, but let's be safe
    if (varNameIndex === null) {
      return stackEntry.parts;
    }

    const varData = options.getVariableData(varName);
    const varValue =
      typeof varData.value === "string"
        ? varData.value
        : varData.registeredProperty?.initialValue;
    let varStartingStyleValue;
    if (options.inStartingStyleRule) {
      varStartingStyleValue =
        typeof varData.startingStyle === "string"
          ? varData.startingStyle
          : // If the variable is not set in starting style, then it will default to either:
            // - a declaration in a "regular" rule
            // - or if there's no declaration in regular rule, to the registered property initial-value.
            varValue;
    }

    let varSubstitutedValue = options.inStartingStyleRule
      ? varStartingStyleValue
      : varValue;
    const variableExists = typeof varSubstitutedValue === "string";
    // TODO: we should also check if the variable is not guaranteed invalid (see Bug 1904013)
    const shouldUseFallback = !variableExists;
    const varComputedValue = varData.computedValue;
    const varNameNodeOptions = {};
    const varFallbackNodeOptions = {};

    if (variableExists) {
      // The variable value is valid, store the substituted value in a data attribute to
      // be reused by the variable tooltip.
      varNameNodeOptions["data-variable"] = varSubstitutedValue;
      varNameNodeOptions.class = options.matchedVariableClass;
      varFallbackNodeOptions.class = options.unmatchedClass;

      // Display computed value when it exists, is different from the substituted value
      // we computed, and we're not inside a starting-style rule
      if (
        !options.inStartingStyleRule &&
        typeof varComputedValue === "string" &&
        varComputedValue !== varSubstitutedValue
      ) {
        varNameNodeOptions["data-variable-computed"] = varComputedValue;
      }

      // Display starting-style value when not in a starting style rule
      if (
        !options.inStartingStyleRule &&
        typeof varData.startingStyle === "string"
      ) {
        varNameNodeOptions["data-starting-style-variable"] =
          varData.startingStyle;
      }

      if (varData.registeredProperty) {
        const { initialValue, syntax, inherits } = varData.registeredProperty;
        varNameNodeOptions["data-registered-property-initial-value"] =
          initialValue;
        varNameNodeOptions["data-registered-property-syntax"] = syntax;
        // createNode does not handle `false`, let's stringify the boolean.
        varNameNodeOptions["data-registered-property-inherits"] = `${inherits}`;
      }
    } else {
      // The variable is not set and does not have an initial value, mark it unmatched.
      varNameNodeOptions.class = options.unmatchedClass;
      varNameNodeOptions["data-variable"] = STYLE_INSPECTOR_L10N.getFormatStr(
        "rule.variableUnset",
        varName
      );
    }

    const varNameNode = this.#createNode("span", varNameNodeOptions);
    varNameNode.append(stackEntry.parts[varNameIndex]);
    stackEntry.parts.splice(varNameIndex, 1, varNameNode);

    if (variableExists && options.showJumpToVariableButton) {
      varNameNode.append(
        this.#createNode("button", {
          class"ruleview-variable-link jump-definition",
          "data-variable-name": varName,
          title: VARIABLE_JUMP_DEFINITION_TITLE,
        })
      );
    }

    // From https://drafts.csswg.org/css-variables/#using-variables:
    // > var(--a,) is a valid function, specifying that if the --a custom property is
    // > invalid or missing, the var() should be replaced with nothing.
    //
    // So if we saw a comma, initialize the value with an empty string
    let fallbackSubstitutedValue = fallbackStartIndex !== null ? "" : null;

    if (fallbackStartIndex !== null) {
      // We want to wrap the fallback into a span, so let's find the last non whitespace
      // token before the closing parenthesis now
      let fallbackEndIndex = null;
      for (
        // we can start at the part before the last one, as the last one will always be
        // the closing parenthesis
        let i = stackEntry.parts.length - 2;
        i >= fallbackStartIndex;
        i--
      ) {
        const part = stackEntry.parts[i];
        const token = stackEntry.tokensByPart.get(part);
        if (token.tokenType !== "WhiteSpace") {
          fallbackEndIndex = i;
          break;
        }
      }

      const fallbackNode = this.#createNode("span", varFallbackNodeOptions);
      let previousToken;
      for (let i = fallbackStartIndex; i <= fallbackEndIndex; i++) {
        const part = stackEntry.parts[i];
        const token = stackEntry.tokensByPart.get(part);
        fallbackNode.append(part);
        if (previousToken === token) {
          continue;
        }
        if (token?.tokenType === AGGREGATED_TOKEN_TYPE) {
          fallbackSubstitutedValue +=
            token.data.substitutedText ?? token.data.text;
        } else {
          fallbackSubstitutedValue += part.textContent;
        }
        previousToken = token;
      }
      stackEntry.parts.splice(
        fallbackStartIndex,
        fallbackEndIndex - fallbackStartIndex + 1,
        fallbackNode
      );
    }

    // Now that we went through the fallback, we can re-compute varSubstitutedValue
    // to potentially include the fallback value.
    if (shouldUseFallback) {
      // If the fallback should be used (i.e. the variable value is guaranteed invalid)
      // but none was found, then the substituted value should be an empty string, as
      // defined in https://drafts.csswg.org/css-variables/#guaranteed-invalid:
      // > The guaranteed-invalid value serializes as the empty string
      if (fallbackSubstitutedValue === null) {
        varSubstitutedValue = "";
      } else {
        varSubstitutedValue = fallbackSubstitutedValue;
      }
    }

    // TODO: We should handle the following case (see Bug 2006565)
    // From https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/var#invalid_values:
    // > var() functions can resolve to invalid values if:
    // > - […]
    // > - The custom property is defined but its value is an invalid value for the
    //     property it is used in.
    // > When this happens, the property is treated as if it has value unset

    const varComputedOrSubstitutedValue = options.inStartingStyleRule
      ? varSubstitutedValue
      : (varComputedValue ?? varSubstitutedValue);

    // Put the substitutedText in the entry so it can then be consumed in onCloseParenthesis
    stackEntry.substitutedText = varComputedOrSubstitutedValue;

    if (
      options.supportsColor ||
      ((options.expectFilter || options.isVariable) &&
        this.#stack.length !== 0 &&
        this.#stack.at(-1).isColorTakingFunction)
    ) {
      // InspectorUtils.isValidCSSColor returns true for `light-dark()` function,
      // but `#isValidColor` returns false. As the latter is used in #appendColor,
      // we need to check that both functions return true.
      const colorObj =
        varSubstitutedValue &&
        InspectorUtils.isValidCSSColor(varComputedOrSubstitutedValue)
          ? new colorUtils.CssColor(varComputedOrSubstitutedValue)
          : null;
      if (colorObj && this.#isValidColor(colorObj)) {
        const colorFunctionEntry = this.#stack.findLast(
          entry => entry.isColorTakingFunction
        );
        const colorContainerEl = this.#createColorContainerElement(
          colorObj,
          {
            ...options,
            colorFunction: colorFunctionEntry?.functionName,
          },
          stackEntry.parts
        );
        return [colorContainerEl];
      }
    }

    const variableNode = this.#createNode("span", {});
    variableNode.append(...stackEntry.parts);
    return [variableNode];
  }

  /**
   * Parse a string.
   *
   * @param  {string} text
   *         Text to parse.
   * @param  {object} [options]
   *         Options object. For valid options and default values see
   *         #mergeOptions().
   * @return {DocumentFragment}
   *         A document fragment.
   */

  #parse(text, options = {}) {
    text = text.trim();
    this.#parsed.length = 0;
    this.#stack.length = 0;

    const tokenStream = new InspectorCSSParserWrapper(text);
    return this.#doParse(text, options, tokenStream);
  }

  /**
   * Returns true if it's a "display: [inline-]flex" token.
   *
   * @param  {string} text
   *         The parsed text.
   * @param  {object} token
   *         The parsed token.
   * @param  {object} options
   *         The options given to #parse.
   */

  #isDisplayFlex(text, token, options) {
    return (
      options.expectDisplay &&
      (token.text === "flex" || token.text === "inline-flex")
    );
  }

  /**
   * Returns true if it's a "display: [inline-]grid" token.
   *
   * @param  {string} text
   *         The parsed text.
   * @param  {object} token
   *         The parsed token.
   * @param  {object} options
   *         The options given to #parse.
   */

  #isDisplayGrid(text, token, options) {
    return (
      options.expectDisplay &&
      (token.text === "grid" || token.text === "inline-grid")
    );
  }

  /**
   * Create an element for a cubic-bezier timing function.
   * Returns null if the element couldn't be created
   *
   * @param {object} options
   * @param {Array<string|Node>} options.children
   *        Children (strings or node) of the container that will be created.
   * @param {object} options.parseOptions
   *        Options object. For valid options and default values see
   *        #mergeOptions()
   * @return {Node|null}
   */

  #createCubicBezierContainer({ children, parseOptions }) {
    let bezier = "";
    for (const child of children) {
      bezier += child.textContent ?? child;
    }

    if (bezier.includes("var(")) {
      // For now, we don't support cubic-bezier with CSS variables (see Bug 2031695)
      return null;
    }

    const container = this.#createNode("span", {
      "data-bezier": bezier,
    });

    if (parseOptions.bezierSwatchClass) {
      const swatch = this.#createNode("span", {
        class: parseOptions.bezierSwatchClass,
        tabindex: "0",
        role: "button",
      });
      container.appendChild(swatch);
    }

    const valueEl = this.#createNode("span", {
      class: parseOptions.bezierClass,
    });
    valueEl.append(...children);

    container.appendChild(valueEl);
    return container;
  }

  /**
   * Append a Flexbox|Grid highlighter toggle icon next to the value in a
   * "display: [inline-]flex" or "display: [inline-]grid" declaration.
   *
   * @param {string} text
   *        The text value to append
   * @param {string} toggleButtonClassName
   *        The class name for the toggle button.
   *        If not passed/empty, the toggle button won't be created.
   */

  #appendDisplayWithHighlighterToggle(text, toggleButtonClassName) {
    const container = this.#createNode("span", {});

    if (toggleButtonClassName) {
      const toggleButton = this.#createNode("button", {
        class: toggleButtonClassName,
      });
      container.append(toggleButton);
    }

    const value = this.#createNode("span", {}, text);
    container.append(value);
    this.#append(container);
  }

  /**
   * Append a angle value to the output
   *
   * @param {string} angle
   *        angle to append
   * @param {object} options
   *        Options object. For valid options and default values see
   *        #mergeOptions()
   * @param {object} token
   */

  #appendAngle(angle, options, token) {
    const angleObj = new angleUtils.CssAngle(angle);
    const container = this.#createNode("span", {
      "data-angle": angle,
    });

    if (options.angleSwatchClass) {
      const swatch = this.#createNode("span", {
        class: options.angleSwatchClass,
        tabindex: "0",
        role: "button",
      });
      this.#angleSwatches.set(swatch, angleObj);
      swatch.addEventListener("mousedown"this.#onAngleSwatchMouseDown);

      // Add click listener to stop event propagation when shift key is pressed
      // in order to prevent the value input to be focused.
      // Bug 711942 will add a tooltip to edit angle values and we should
      // be able to move this listener to Tooltip.js when it'll be implemented.
      swatch.addEventListener("click"function (event) {
        if (event.shiftKey) {
          event.stopPropagation();
        }
      });
      container.appendChild(swatch);
    }

    const value = this.#createNode(
      "span",
      {
        class: options.angleClass,
      },
      angle
    );

    container.appendChild(value);
    this.#append(container, token);
  }

  /**
   * Check if a CSS property supports a specific value.
   *
   * @param  {string} name
   *         CSS Property name to check
   * @param  {string} value
   *         CSS Property value to check
   * @param  {object} options
   *         Options object. For valid options and default values see #mergeOptions().
   */

  #cssPropertySupportsValue(name, value, options = {}) {
    if (
      options.isValid ||
      // The filter property is special in that we want to show the swatch even if the
      // value is invalid, because this way the user can easily use the editor to fix it.
      options.expectFilter
    ) {
      return true;
    }

    // Checking pair as a CSS declaration string to account for "!important" in value.
    const declaration = `${name}:${value}`;
    return this.#doc.defaultView.CSS.supports(declaration);
  }

  /**
   * Tests if a given colorObject output by CssColor is valid for parsing.
   * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
   * except transparent
   */

  #isValidColor(colorObj) {
    return (
      colorObj.valid &&
      (!colorObj.specialValue || colorObj.specialValue === "transparent")
    );
  }

  /**
   * Append a color to the output.
   *
   * @param {string} color
   *         Color to append
   * @param {object} [options]
   * @param {CSSColor} options.colorObj: A css color for the passed color. Will be computed
   *         if not passed.
   * @param {string} options.colorFunction: The color function that is used to produce this color
   * @param {*} For all the other valid options and default values see #mergeOptions().
   * @param {object} token
   */

  #appendColor(color, options, token) {
    const colorObj = options.colorObj || new colorUtils.CssColor(color);

    if (this.#isValidColor(colorObj)) {
      const colorContainerEl = this.#createColorContainerElement(
        colorObj,
        options
      );

      this.#append(colorContainerEl, token);
    } else {
      this.#appendTextNode(color, token);
    }
  }

  #createColorContainerElement(colorObj, options, children) {
    let color = colorObj.authored;
    const containerEl = this.#createNode("span", {
      "data-color": color,
    });

    if (options.colorSwatchClass) {
      let attributes = {
        class: options.colorSwatchClass,
        style: "background-color:" + color,
      };

      // Color swatches next to values trigger the color editor everywhere aside from
      // the Computed panel where values are read-only.
      if (!options.colorSwatchReadOnly) {
        attributes = { ...attributes, tabindex: "0", role: "button" };
      }

      // The swatch is a <span> instead of a <button> intentionally. See Bug 1597125.
      // It is made keyboard accessible via `tabindex` and has keydown handlers
      // attached for pressing SPACE and RETURN in SwatchBasedEditorTooltip.js
      const swatch = this.#createNode("span", attributes);
      this.#colorSwatches.set(swatch, colorObj);
      if (options.colorFunction) {
        swatch.dataset.colorFunction = options.colorFunction;
      }
      swatch.addEventListener("mousedown"this.#onColorSwatchMouseDown);
      containerEl.appendChild(swatch);
      containerEl.classList.add("color-swatch-container");
    }

    let colorUnit = options.defaultColorUnit;
    if (!options.useDefaultColorUnit) {
      // If we're not being asked to convert the color to the default color type
      // specified by the user, then force the CssColor instance to be set to the type
      // of the current color.
      // Not having a type means that the default color type will be automatically used.
      colorUnit = colorUtils.classifyColor(color);
    }
    color = colorObj.toString(colorUnit);
    containerEl.dataset.color = color;

    const valueEl = this.#createNode("span", {
      class: options.colorClass,
    });
    if (children) {
      valueEl.append(...children);
    } else {
      valueEl.append(color);
    }
    containerEl.append(valueEl);
    return containerEl;
  }

  /**
   * Wrap some existing nodes in a filter editor.
   *
   * @param {string} filters
   *        The full text of the "filter" property.
   * @param {object} options
   *        The options object passed to parseCssProperty().
   * @param {object} nodes
   *        Nodes created by #toDOM().
   *
   * @returns {object}
   *        A new node that supplies a filter swatch and that wraps |nodes|.
   */

  #wrapFilter(filters, options, nodes) {
    const container = this.#createNode("span", {
      "data-filters": filters,
    });

    if (options.filterSwatchClass) {
      const swatch = this.#createNode("span", {
        class: options.filterSwatchClass,
        tabindex: "0",
        role: "button",
      });
      container.appendChild(swatch);
    }

    const value = this.#createNode("span", {
      class: options.filterClass,
    });
    value.appendChild(nodes);
    container.appendChild(value);

    return container;
  }

  #onColorSwatchMouseDown = event => {
    if (!event.shiftKey) {
      return;
    }

    // Prevent click event to be fired to not show the tooltip
    event.stopPropagation();
    // Prevent text selection but switch the focus
    event.preventDefault();
    event.target.focus({ focusVisible: false });

    const swatch = event.target;
    const color = this.#colorSwatches.get(swatch);
    const val = color.nextColorUnit();

    swatch.nextElementSibling.textContent = val;
    swatch.parentNode.dataset.color = val;

    const unitChangeEvent = new swatch.documentGlobal.CustomEvent(
      "unit-change"
    );
    swatch.dispatchEvent(unitChangeEvent);
  };

  #onAngleSwatchMouseDown = event => {
    if (!event.shiftKey) {
      return;
    }

    event.stopPropagation();

    const swatch = event.target;
    const angle = this.#angleSwatches.get(swatch);
    const val = angle.nextAngleUnit();

    swatch.nextElementSibling.textContent = val;

    const unitChangeEvent = new swatch.documentGlobal.CustomEvent(
      "unit-change"
    );
    swatch.dispatchEvent(unitChangeEvent);
  };

  /**
   * A helper function that sanitizes a possibly-unterminated URL.
   */

  #sanitizeURL(url) {
    // Re-lex the URL and add any needed termination characters.
    const urlTokenizer = new InspectorCSSParserWrapper(url, {
      trackEOFChars: true,
    });
    // Just read until EOF; there will only be a single token.
    while (urlTokenizer.nextToken()) {
      // Nothing.
    }

    return urlTokenizer.performEOFFixup(url);
  }

  /**
   * Returns the elements representing a URL.
   *
   * @param  {string} match
   *         Complete match that may include "url(xxx)"
   * @param  {string} url
   *         Actual URL
   * @param  {object} options
   *         Options object. For valid options and default values see #mergeOptions().
   * @returns {Array<Node>}
   */

  #createURLElements(match, url, options) {
    if (!options.urlClass) {
      return [this.#createTextElement(match)];
    }

    // Sanitize the URL. Note that if we modify the URL, we just
    // leave the termination characters. This isn't strictly
    // "as-authored", but it makes a bit more sense.
    match = this.#sanitizeURL(match);
    const urlParts = URL_REGEX.exec(match);

    // Bail out if that didn't match anything.
    if (!urlParts) {
      return [this.#doc.createTextNode(match)];
    }

    const { leader, body, trailer } = urlParts.groups;

    return [
      this.#doc.createTextNode(leader),
      this.#createNode(
        "a",
        {
          target: "_blank",
          class: options.urlClass,
          href: options.baseURI
            ? (URL.parse(url, options.baseURI)?.href ?? url)
            : url,
        },
        body
      ),
      this.#doc.createTextNode(trailer),
    ];
  }

  /**
   * Wrap a font family in a special element
   *
   * @param  {number} fontFamilyStartPartIndex
   *         The index in `parts` at which the font-family starts
   * @param  {object} options
   *         Options object. For valid options and default values see
   *         #mergeOptions().
   */

  #wrapFontFamilyName(fontFamilyStartPartIndex, options) {
    if (!options.expectFont) {
      return;
    }

    const parts = this.#getCurrentStackParts();
    // We have the beginning of the font family, we need to find the end.
    // Loop through the parts in reverse to find the first non whitespace character.
    // By default, let's consider the last part as the end of the font name
    let fontFamilyEndPartIndex = parts.length - 1;
    for (let i = parts.length - 1; i >= fontFamilyStartPartIndex; i--) {
      const part = parts[i];
      if (part.textContent.trim() !== "") {
        fontFamilyEndPartIndex = i;
        break;
      }
    }

    // Wrap the parts in a dedicated element
    const fontFamilyNode = this.#createNode("span", {
      class: options.fontFamilyClass,
    });

    // If the family name is quoted, we need to put the quotes outside of the family node
    // So first let's compute the family name (it might be made out of multiple parts at
    // the moment, e.g. if we have `Helvetica Black`)
    let familyName = "";
    for (let i = fontFamilyStartPartIndex; i <= fontFamilyEndPartIndex; i++) {
      familyName += parts[i].textContent;
    }

    // We'll associate the new part we create with this aggregated token, so other functions
    // know those shouldn't be processed.
    const aggregatedToken = this.#stack.length
      ? {
          tokenType: AGGREGATED_TOKEN_TYPE,
          data: {
            text: familyName,
          },
        }
      : null;
    const stackEntry = this.#stack.length ? this.#stack.at(-1) : null;
    if (stackEntry) {
      stackEntry.tokensByPart.set(fontFamilyNode, aggregatedToken);
    }

    // Extracting opening and closing quotes, as well as the font name
    const quoteRegex = /^(?<open>['"])(?<name>[^'"]*)(?<close>['"])$/g;
    const regexResult = quoteRegex.exec(familyName);
    // If it's actually wrapped in quote
    if (regexResult !== null) {
      // Then, first append the closing quote, as we're going to modify parts and we rely
      // on the indexes to insert the name at the right spot
      const part = this.#doc.createTextNode(regexResult.groups.close);
      parts.splice(fontFamilyEndPartIndex + 10, part);

      if (stackEntry) {
        stackEntry.tokensByPart.set(part, aggregatedToken);
      }
      // Update the family name with the non-quoted one
      familyName = regexResult.groups.name;
    }

    fontFamilyNode.append(familyName);

    // Then we want to insert our container, and remove the parts that are representing it
    const fontFamilyNodeChildCount =
      fontFamilyEndPartIndex - fontFamilyStartPartIndex + 1;
    parts.splice(
      fontFamilyStartPartIndex,
      fontFamilyNodeChildCount,
      fontFamilyNode
    );

    // Finally we insert the opening quote
    if (regexResult !== null) {
      const part = this.#doc.createTextNode(regexResult.groups.open);
      parts.splice(fontFamilyStartPartIndex, 0, part);

      if (stackEntry) {
        stackEntry.tokensByPart.set(part, aggregatedToken);
      }
    }
  }

  /**
   * Create a node.
   *
   * @param  {string} tagName
   *         Tag type e.g. "div"
   * @param  {object} attributes
   *         e.g. {class: "someClass", style: "cursor:pointer"};
   * @param  {string} [value]
   *         If a value is included it will be appended as a text node inside
   *         the tag. This is useful e.g. for span tags.
   * @return {Node} Newly created Node.
   */

  #createNode(tagName, attributes, value = "") {
    const node = this.#doc.createElementNS(HTML_NS, tagName);
    const attrs = Object.getOwnPropertyNames(attributes);

    for (const attr of attrs) {
      const attrValue = attributes[attr];
      if (attrValue !== null && attrValue !== undefined) {
        node.setAttribute(attr, attributes[attr]);
      }
    }

    if (value) {
      const textNode = this.#doc.createTextNode(value);
      node.appendChild(textNode);
      const truncated = value.length > TRUNCATE_LENGTH_THRESHOLD;
      node.classList.toggle(TRUNCATE_NODE_CLASSNAME, truncated);
    }

    return node;
  }

  /**
   * Create an element representing a simple text.
   *
   * @param  {string} text
   *         Text to append
   * @returns {Text|Element} Returns a Text, or, if the text is greater than the truncate
   *          threshold, a Node with a specific class to trigger CSS "truncation".
   */

  #createTextElement(text) {
    if (text.length > TRUNCATE_LENGTH_THRESHOLD) {
      // If the text is too long, force creating a node, which will add the
      // necessary classname to truncate the property correctly.
      return this.#createNode("span", {}, text);
    }

    return this.#doc.createTextNode(text);
  }

  /**
   * Create and append a node to the output.
   *
   * @param  {string} tagName
   *         Tag type e.g. "div"
   * @param  {object} attributes
   *         e.g. {class: "someClass", style: "cursor:pointer"};
   * @param  {string} [value]
   *         If a value is included it will be appended as a text node inside
   *         the tag. This is useful e.g. for span tags.
   * @param  {object} token
   * @return {number} The index of the new part in the parts array
   */

  #appendNode(tagName, attributes, value, token) {
    const node = this.#createNode(tagName, attributes, value);
    return this.#append(node, token);
  }

  /**
   * Append an element or a text node to the output.
   *
   * @param {Element|Text} item
   * @param {object} token
   * @return {number} The index of the new part in the parts array
   */

  #append(item, token = null) {
    const len = this.#getCurrentStackParts().push(item);

    if (token !== null && this.#stack.length) {
      const stackEntry = this.#stack.at(-1);
      stackEntry.tokensByPart.set(item, token);
    }

    return len - 1;
  }

  /**
   * Append a text node to the output. If the previously output item was a text
   * node then we append the text to that node.
   *
   * @param  {string} text
   *         Text to append
   * @param  {object} token
   * @return {number} The index of the new part in the parts array
   */

  #appendTextNode(text, token) {
    if (text.length > TRUNCATE_LENGTH_THRESHOLD) {
      // If the text is too long, force creating a node, which will add the
      // necessary classname to truncate the property correctly.
      return this.#appendNode("span", {}, text, token);
    }

    return this.#append(this.#doc.createTextNode(text), token);
  }

  #getCurrentStackParts() {
    return this.#stack.at(-1)?.parts || this.#parsed;
  }

  /**
   * Take all output and append it into a single DocumentFragment.
   *
   * @return {DocumentFragment}
   *         Document Fragment
   */

  #toDOM() {
    const frag = this.#doc.createDocumentFragment();

    for (const item of this.#parsed) {
      if (typeof item === "string") {
        frag.appendChild(this.#doc.createTextNode(item));
      } else {
        frag.appendChild(item);
      }
    }

    this.#parsed.length = 0;
    this.#stack.length = 0;
    return frag;
  }

  /**
   * Merges options objects. Default values are set here.
   *
   * @param  {object} overrides
   *         The option values to override e.g. #mergeOptions({colors: false})
   * @param {boolean} overrides.useDefaultColorUnit: Convert colors to the default type
   *                                                 selected in the options panel.
   * @param {string} overrides.angleClass: The class to use for the angle value that follows
   *                                       the swatch.
   * @param {string} overrides.angleSwatchClass: The class to use for angle swatches.
   * @param {string} overrides.bezierClass: The class to use for the bezier value that
   *        follows the swatch.
   * @param {string} overrides.bezierSwatchClass: The class to use for bezier swatches.
   * @param {string} overrides.colorClass: The class to use for the color value that
   *        follows the swatch.
   * @param {string} overrides.colorSwatchClass: The class to use for color swatches.
   * @param {boolean} overrides.colorSwatchReadOnly: Whether the resulting color swatch
   *        should be read-only or not. Defaults to false.
   * @param {boolean} overrides.filterSwatch: A special case for parsing a "filter" property,
   *        causing the parser to skip the call to #wrapFilter. Used only for previewing
   *        with the filter swatch.
   * @param {string} overrides.flexClass: The class to use for the flex icon.
   * @param {string} overrides.gridClass: The class to use for the grid icon.
   * @param {string} overrides.shapeClass: The class to use for the shape value that
   *         follows the swatch.
   * @param {string} overrides.shapeSwatchClass: The class to use for the shape swatch.
   * @param {string} overrides.urlClass: The class to be used for url() links.
   * @param {string} overrides.fontFamilyClass: The class to be used for font families.
   * @param {string} overrides.unmatchedClass: The class to use for a component of
   *        a `var()` or `attr()` that is not in use.
   * @param {boolean} overrides.supportsColor: Does the CSS property support colors?
   * @param {string} overrides.baseURI: A string used to resolve relative links.
   * @param {Function} overrides.getVariableData: A function taking a single argument,
   *        the name of a variable. This should return an object with the following properties:
   *          - {String|undefined} value: The variable's value. Undefined if variable is
   *            not set.
   *          - {RegisteredPropertyResource|undefined} registeredProperty: The registered
   *            property data (syntax, initial value, inherits). Undefined if the variable
   *            is not a registered property.
   * @param {Function} overrides.getAttributeValue: A function taking a single argument,
   *        the name of an attribute. This should return the value of the attribute, or
   *        null if the attribute doesn't exist.
   * @param {boolean} overrides.showJumpToVariableButton: Should we show a jump to
   *        definition for CSS variables. Defaults to true.
   * @param {boolean} overrides.isDarkColorScheme: Is the currently applied color scheme dark.
   * @param {boolean} overrides.isValid: Is the name+value valid.
   * @param {boolean} overrides.cssExplainersEnabled: Are CSS explainers enabled
   * @return {object} Overridden options object
   */

  #mergeOptions(overrides) {
    const defaults = {
      useDefaultColorUnit: true,
      defaultColorUnit: "authored",
      angleClass: null,
      angleSwatchClass: null,
      bezierClass: null,
      bezierSwatchClass: null,
      colorClass: null,
      colorSwatchClass: null,
      colorSwatchReadOnly: false,
      cssExplainersEnabled: false,
      filterSwatch: false,
      flexClass: null,
      gridClass: null,
      shapeClass: null,
      shapeSwatchClass: null,
      supportsColor: false,
      urlClass: null,
      fontFamilyClass: null,
      baseURI: undefined,
      getVariableData: null,
      getAttributeValue: null,
      showJumpToVariableButton: true,
      unmatchedClass: null,
      inStartingStyleRule: false,
      isDarkColorScheme: null,
    };

    for (const item in overrides) {
      defaults[item] = overrides[item];
    }
    return defaults;
  }
}

module.exports = OutputParser;

Messung V0.5 in Prozent
C=87 H=92 G=89

¤ Diese beiden folgenden Angebotsgruppen bietet das Unternehmen0.70Angebot  (Wie Sie bei der Firma Beratungs- und Dienstleistungen beauftragen können 2026-08-25) ¤

*Eine klare Vorstellung vom Zielzustand






Wurzel

Suchen

PVS Prover

Isabelle Prover

NIST Cobol Testsuite

Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Open Source Software

     Quellcodebibliothek
     Eigene Quellcodes
     Fremde Quellcodes
     Suchen

Jenseits des Üblichen ....

Besucherstatistik

Besucherstatistik

Statistik
#Sources=277311
#Domains=752002