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