rule-rewriter.js (28477B)
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 // This file holds various CSS parsing and rewriting utilities. 6 // Some entry points of note are: 7 // parseDeclarations - parse a CSS rule into declarations 8 // RuleRewriter - rewrite CSS rule text 9 // parsePseudoClassesAndAttributes - parse selector and extract 10 // pseudo-classes 11 // parseSingleValue - parse a single CSS property value 12 13 "use strict"; 14 15 const { 16 InspectorCSSParserWrapper, 17 } = require("resource://devtools/shared/css/lexer.js"); 18 const { 19 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR, 20 escapeCSSComment, 21 parseNamedDeclarations, 22 unescapeCSSComment, 23 } = require("resource://devtools/shared/css/parsing-utils.js"); 24 25 loader.lazyRequireGetter( 26 this, 27 "getIndentationFromPrefs", 28 "resource://devtools/shared/indentation.js", 29 true 30 ); 31 32 // Used to test whether a newline appears anywhere in some text. 33 const NEWLINE_RX = /[\r\n]/; 34 // Used to test whether a bit of text starts an empty comment, either 35 // an "ordinary" /* ... */ comment, or a "heuristic bypass" comment 36 // like /*! ... */. 37 const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/; 38 // Used to test whether a bit of text ends an empty comment. 39 const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//; 40 // Used to test whether a string starts with a blank line. 41 const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/; 42 43 /** 44 * Return an object that can be used to rewrite declarations in some 45 * source text. The source text and parsing are handled in the same 46 * way as @see parseNamedDeclarations, with |parseComments| being true. 47 * Rewriting is done by calling one of the modification functions like 48 * setPropertyEnabled. The returned object has the same interface 49 * as @see RuleModificationList. 50 * 51 * An example showing how to disable the 3rd property in a rule: 52 * 53 * let rewriter = new RuleRewriter(win, isCssPropertyKnown, ruleActor, 54 * ruleActor.authoredText); 55 * rewriter.setPropertyEnabled(3, "color", false); 56 * rewriter.apply().then(() => { ... the change is made ... }); 57 * 58 * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|, 59 * |createProperty|, |setProperty|, and |removeProperty|. The |apply| 60 * method can be used to send the edited text to the StyleRuleActor; 61 * |getDefaultIndentation| is useful for the methods requiring a 62 * default indentation value; and |getResult| is useful for testing. 63 * 64 * Additionally, editing will set the |changedDeclarations| property 65 * on this object. This property has the same form as the |changed| 66 * property of the object returned by |getResult|. 67 */ 68 class RuleRewriter { 69 /** 70 * @class 71 * @param {Window} win 72 * @param {Function} isCssPropertyKnown 73 * A function to check if the CSS property is known. This is either an 74 * internal server function or from the CssPropertiesFront. 75 * that are supported by the server. Note that if Bug 1222047 76 * is completed then isCssPropertyKnown will not need to be passed in. 77 * The CssProperty front will be able to obtained directly from the 78 * RuleRewriter. 79 * @param {StyleRuleFront} rule The style rule to use. Note that this 80 * is only needed by the |apply| and |getDefaultIndentation| methods; 81 * and in particular for testing it can be |null|. 82 * @param {string} inputString The CSS source text to parse and modify. 83 */ 84 constructor(win, isCssPropertyKnown, rule, inputString) { 85 this.win = win; 86 this.rule = rule; 87 this.isCssPropertyKnown = isCssPropertyKnown; 88 // The RuleRewriter sends CSS rules as text to the server, but with this modifications 89 // array, it also sends the list of changes so the server doesn't have to re-parse the 90 // rule if it needs to track what changed. 91 this.modifications = []; 92 93 // Keep track of which any declarations we had to rewrite while 94 // performing the requested action. 95 this.changedDeclarations = {}; 96 97 // If not null, a promise that must be wait upon before |apply| can 98 // do its work. 99 this.editPromise = null; 100 101 // If the |defaultIndentation| property is set, then it is used; 102 // otherwise the RuleRewriter will try to compute the default 103 // indentation based on the style sheet's text. This override 104 // facility is for testing. 105 this.defaultIndentation = null; 106 107 this.startInitialization(inputString); 108 } 109 110 /** 111 * An internal function to initialize the rewriter with a given 112 * input string. 113 * 114 * @param {string} inputString the input to use 115 */ 116 startInitialization(inputString) { 117 this.inputString = inputString; 118 // Whether there are any newlines in the input text. 119 this.hasNewLine = /[\r\n]/.test(this.inputString); 120 // The declarations. 121 this.declarations = parseNamedDeclarations( 122 this.isCssPropertyKnown, 123 this.inputString, 124 true 125 ); 126 this.decl = null; 127 this.result = null; 128 } 129 130 /** 131 * An internal function to complete initialization and set some 132 * properties for further processing. 133 * 134 * @param {number} index The index of the property to modify 135 */ 136 completeInitialization(index) { 137 if (index < 0) { 138 throw new Error("Invalid index " + index + ". Expected positive integer"); 139 } 140 // |decl| is the declaration to be rewritten, or null if there is no 141 // declaration corresponding to |index|. 142 // |result| is used to accumulate the result text. 143 if (index < this.declarations.length) { 144 this.decl = this.declarations[index]; 145 this.result = this.inputString.substring(0, this.decl.offsets[0]); 146 } else { 147 this.decl = null; 148 this.result = this.inputString; 149 } 150 } 151 152 /** 153 * A helper function to compute the indentation of some text. This 154 * examines the rule's existing text to guess the indentation to use; 155 * unlike |getDefaultIndentation|, which examines the entire style 156 * sheet. 157 * 158 * @param {string} string the input text 159 * @param {number} offset the offset at which to compute the indentation 160 * @return {string} the indentation at the indicated position 161 */ 162 getIndentation(string, offset) { 163 let originalOffset = offset; 164 for (--offset; offset >= 0; --offset) { 165 const c = string[offset]; 166 if (c === "\r" || c === "\n" || c === "\f") { 167 return string.substring(offset + 1, originalOffset); 168 } 169 if (c !== " " && c !== "\t") { 170 // Found some non-whitespace character before we found a newline 171 // -- let's reset the starting point and keep going, as we saw 172 // something on the line before the declaration. 173 originalOffset = offset; 174 } 175 } 176 // Ran off the end. 177 return ""; 178 } 179 180 /** 181 * Modify a property value to ensure it is "lexically safe" for 182 * insertion into a style sheet. This function doesn't attempt to 183 * ensure that the resulting text is a valid value for the given 184 * property; but rather just that inserting the text into the style 185 * sheet will not cause unwanted changes to other rules or 186 * declarations. 187 * 188 * @param {string} text The input text. This should include the trailing ";". 189 * @return {Array} An array of the form [anySanitized, text], where 190 * |anySanitized| is a boolean that indicates 191 * whether anything substantive has changed; and 192 * where |text| is the text that has been rewritten 193 * to be "lexically safe". 194 */ 195 sanitizePropertyValue(text) { 196 // Start by stripping any trailing ";". This is done here to 197 // avoid the case where the user types "url(" (which is turned 198 // into "url(;" by the rule view before coming here), being turned 199 // into "url(;)" by this code -- due to the way "url(...)" is 200 // parsed as a single token. 201 text = text.replace(/;$/, ""); 202 const lexer = new InspectorCSSParserWrapper(text, { trackEOFChars: true }); 203 204 let result = ""; 205 let previousOffset = 0; 206 const parenStack = []; 207 let anySanitized = false; 208 209 // Push a closing paren on the stack. 210 const pushParen = (token, closer) => { 211 result = 212 result + 213 text.substring(previousOffset, token.startOffset) + 214 text.substring(token.startOffset, token.endOffset); 215 // We set the location of the paren in a funny way, to handle 216 // the case where we've seen a function token, where the paren 217 // appears at the end. 218 parenStack.push({ closer, offset: result.length - 1, token }); 219 previousOffset = token.endOffset; 220 }; 221 222 // Pop a closing paren from the stack. 223 const popSomeParens = closer => { 224 while (parenStack.length) { 225 const paren = parenStack.pop(); 226 227 if (paren.closer === closer) { 228 return true; 229 } 230 231 // We need to handle non-closed url function differently, as performEOFFixup will 232 // only automatically close missing parenthesis `url`. 233 // In such case, don't do anything here. 234 if ( 235 paren.closer === ")" && 236 closer == null && 237 paren.token.tokenType === "Function" && 238 paren.token.value === "url" 239 ) { 240 return true; 241 } 242 243 // Found a non-matching closing paren, so quote it. Note that 244 // these are processed in reverse order. 245 result = 246 result.substring(0, paren.offset) + 247 "\\" + 248 result.substring(paren.offset); 249 anySanitized = true; 250 } 251 return false; 252 }; 253 254 let token; 255 while ((token = lexer.nextToken())) { 256 switch (token.tokenType) { 257 case "Semicolon": 258 // We simply drop the ";" here. This lets us cope with 259 // declarations that don't have a ";" and also other 260 // termination. The caller handles adding the ";" again. 261 result += text.substring(previousOffset, token.startOffset); 262 previousOffset = token.endOffset; 263 break; 264 265 case "CurlyBracketBlock": 266 pushParen(token, "}"); 267 break; 268 269 case "ParenthesisBlock": 270 case "Function": 271 pushParen(token, ")"); 272 break; 273 274 case "SquareBracketBlock": 275 pushParen(token, "]"); 276 break; 277 278 case "CloseCurlyBracket": 279 case "CloseParenthesis": 280 case "CloseSquareBracket": 281 // Did we find an unmatched close bracket? 282 if (!popSomeParens(token.text)) { 283 // Copy out text from |previousOffset|. 284 result += text.substring(previousOffset, token.startOffset); 285 // Quote the offending symbol. 286 result += "\\" + token.text; 287 previousOffset = token.endOffset; 288 anySanitized = true; 289 } 290 break; 291 } 292 } 293 294 // Fix up any unmatched parens. 295 popSomeParens(null); 296 297 // Copy out any remaining text, then any needed terminators. 298 result += text.substring(previousOffset, text.length); 299 300 const eofFixup = lexer.performEOFFixup(""); 301 if (eofFixup) { 302 anySanitized = true; 303 result += eofFixup; 304 } 305 return [anySanitized, result]; 306 } 307 308 /** 309 * Start at |index| and skip whitespace 310 * backward in |string|. Return the index of the first 311 * non-whitespace character, or -1 if the entire string was 312 * whitespace. 313 * 314 * @param {string} string the input string 315 * @param {number} index the index at which to start 316 * @return {number} index of the first non-whitespace character, or -1 317 */ 318 skipWhitespaceBackward(string, index) { 319 for ( 320 --index; 321 index >= 0 && (string[index] === " " || string[index] === "\t"); 322 --index 323 ) { 324 // Nothing. 325 } 326 return index; 327 } 328 329 /** 330 * Terminate a given declaration, if needed. 331 * 332 * @param {number} index The index of the rule to possibly 333 * terminate. It might be invalid, so this 334 * function must check for that. 335 */ 336 maybeTerminateDecl(index) { 337 if ( 338 index < 0 || 339 index >= this.declarations.length || 340 // No need to rewrite declarations in comments. 341 "commentOffsets" in this.declarations[index] 342 ) { 343 return; 344 } 345 346 const termDecl = this.declarations[index]; 347 let endIndex = termDecl.offsets[1]; 348 // Due to an oddity of the lexer, we might have gotten a bit of 349 // extra whitespace in a trailing bad_url token -- so be sure to 350 // skip that as well. 351 endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1; 352 353 const trailingText = this.result.substring(endIndex); 354 if (termDecl.terminator) { 355 // Insert the terminator just at the end of the declaration, 356 // before any trailing whitespace. 357 this.result = 358 this.result.substring(0, endIndex) + termDecl.terminator + trailingText; 359 // In a couple of cases, we may have had to add something to 360 // terminate the declaration, but the termination did not 361 // actually affect the property's value -- and at this spot, we 362 // only care about reporting value changes. In particular, we 363 // might have added a plain ";", or we might have terminated a 364 // comment with "*/;". Neither of these affect the value. 365 if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") { 366 this.changedDeclarations[index] = 367 termDecl.value + termDecl.terminator.slice(0, -1); 368 } 369 } 370 // If the rule generally has newlines, but this particular 371 // declaration doesn't have a trailing newline, insert one now. 372 // Maybe this style is too weird to bother with. 373 if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) { 374 this.result += "\n"; 375 } 376 } 377 378 /** 379 * Sanitize the given property value and return the sanitized form. 380 * If the property is rewritten during sanitization, make a note in 381 * |changedDeclarations|. 382 * 383 * @param {string} text The property text. 384 * @param {number} index The index of the property. 385 * @return {string} The sanitized text. 386 */ 387 sanitizeText(text, index) { 388 const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text); 389 if (anySanitized) { 390 this.changedDeclarations[index] = sanitizedText; 391 } 392 return sanitizedText; 393 } 394 395 /** 396 * Rename a declaration. 397 * 398 * @param {number} index index of the property in the rule. 399 * @param {string} name current name of the property 400 * @param {string} newName new name of the property 401 */ 402 renameProperty(index, name, newName) { 403 this.completeInitialization(index); 404 this.result += CSS.escape(newName); 405 // We could conceivably compute the name offsets instead so we 406 // could preserve white space and comments on the LHS of the ":". 407 this.completeCopying(this.decl.colonOffsets[0]); 408 this.modifications.push({ type: "set", index, name, newName }); 409 } 410 411 /** 412 * Enable or disable a declaration 413 * 414 * @param {number} index index of the property in the rule. 415 * @param {string} name current name of the property 416 * @param {boolean} isEnabled true if the property should be enabled; 417 * false if it should be disabled 418 */ 419 setPropertyEnabled(index, name, isEnabled) { 420 this.completeInitialization(index); 421 const decl = this.decl; 422 const priority = decl.priority; 423 let copyOffset = decl.offsets[1]; 424 if (isEnabled) { 425 // Enable it. First see if the comment start can be deleted. 426 const commentStart = decl.commentOffsets[0]; 427 if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) { 428 this.result = this.result.substring(0, commentStart); 429 } else { 430 this.result += "*/ "; 431 } 432 433 // Insert the name and value separately, so we can report 434 // sanitization changes properly. 435 const commentNamePart = this.inputString.substring( 436 decl.offsets[0], 437 decl.colonOffsets[1] 438 ); 439 this.result += unescapeCSSComment(commentNamePart); 440 441 // When uncommenting, we must be sure to sanitize the text, to 442 // avoid things like /* decl: }; */, which will be accepted as 443 // a property but which would break the entire style sheet. 444 let newText = this.inputString.substring( 445 decl.colonOffsets[1], 446 decl.offsets[1] 447 ); 448 newText = cssTrimRight(unescapeCSSComment(newText)); 449 this.result += this.sanitizeText(newText, index) + ";"; 450 451 // See if the comment end can be deleted. 452 const trailingText = this.inputString.substring(decl.offsets[1]); 453 if (EMPTY_COMMENT_END_RX.test(trailingText)) { 454 copyOffset = decl.commentOffsets[1]; 455 } else { 456 this.result += " /*"; 457 } 458 } else { 459 // Disable it. Note that we use our special comment syntax 460 // here. 461 const declText = this.inputString.substring( 462 decl.offsets[0], 463 decl.offsets[1] 464 ); 465 this.result += 466 "/*" + 467 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + 468 " " + 469 escapeCSSComment(declText) + 470 " */"; 471 } 472 this.completeCopying(copyOffset); 473 474 if (isEnabled) { 475 this.modifications.push({ 476 type: "set", 477 index, 478 name, 479 value: decl.value, 480 priority, 481 }); 482 } else { 483 this.modifications.push({ type: "disable", index, name }); 484 } 485 } 486 487 /** 488 * Return a promise that will be resolved to the default indentation 489 * of the rule. This is a helper for internalCreateProperty. 490 * 491 * @return {Promise} a promise that will be resolved to a string 492 * that holds the default indentation that should be used 493 * for edits to the rule. 494 */ 495 async getDefaultIndentation() { 496 const prefIndent = getIndentationFromPrefs(); 497 if (prefIndent) { 498 const { indentUnit, indentWithTabs } = prefIndent; 499 return indentWithTabs ? "\t" : " ".repeat(indentUnit); 500 } 501 502 const styleSheetsFront = 503 await this.rule.targetFront.getFront("stylesheets"); 504 505 if (!this.rule.parentStyleSheet) { 506 // See Bug 1899341, due to resource throttling, the parentStyleSheet for 507 // the rule might not be received by the client yet. Fallback to a usable 508 // default value. 509 console.error( 510 "Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces" 511 ); 512 return " "; 513 } 514 515 const styleSheetResourceId = this.rule.parentStyleSheet.resourceId; 516 return styleSheetsFront.getStyleSheetIndentation(styleSheetResourceId); 517 } 518 519 /** 520 * An internal function to create a new declaration. This does all 521 * the work of |createProperty|. 522 * 523 * @param {number} index index of the property in the rule. 524 * @param {string} name name of the new property 525 * @param {string} value value of the new property 526 * @param {string} priority priority of the new property; either 527 * the empty string or "important" 528 * @param {boolean} enabled True if the new property should be 529 * enabled, false if disabled 530 * @return {Promise} a promise that is resolved when the edit has 531 * completed 532 */ 533 async internalCreateProperty(index, name, value, priority, enabled) { 534 this.completeInitialization(index); 535 let newIndentation = ""; 536 if (this.hasNewLine) { 537 if (this.declarations.length) { 538 newIndentation = this.getIndentation( 539 this.inputString, 540 this.declarations[0].offsets[0] 541 ); 542 } else if (this.defaultIndentation) { 543 newIndentation = this.defaultIndentation; 544 } else { 545 newIndentation = await this.getDefaultIndentation(); 546 } 547 } 548 549 this.maybeTerminateDecl(index - 1); 550 551 // If we generally have newlines, and if skipping whitespace 552 // backward stops at a newline, then insert our text before that 553 // whitespace. This ensures the indentation we computed is what 554 // is actually used. 555 let savedWhitespace = ""; 556 if (this.hasNewLine) { 557 const wsOffset = this.skipWhitespaceBackward( 558 this.result, 559 this.result.length 560 ); 561 if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") { 562 savedWhitespace = this.result.substring(wsOffset + 1); 563 this.result = this.result.substring(0, wsOffset + 1); 564 } 565 } 566 567 let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index); 568 if (priority === "important") { 569 newText += " !important"; 570 } 571 newText += ";"; 572 573 if (!enabled) { 574 newText = 575 "/*" + 576 COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + 577 " " + 578 escapeCSSComment(newText) + 579 " */"; 580 } 581 582 newText = `${newIndentation}${newText}${this.hasNewLine ? "\n" : ""}${savedWhitespace}`; 583 584 // If the rule has some nested declarations, we need to find the proper index where 585 // to put the new declaration at. 586 // e.g. if we have `body { color: red; &>span {}; }`, we want to put the new property 587 // after `color: red` but before `&>span`. 588 let insertIndex = -1; 589 // Don't try to find the index if we can already see there's no nested rules 590 if (this.result.includes("{")) { 591 // Create a rule with the initial rule text so we can check for children rules 592 const dummySheet = new this.win.CSSStyleSheet(); 593 dummySheet.replaceSync(":root {\n" + this.result + "}"); 594 const dummyRule = dummySheet.cssRules[0]; 595 if (dummyRule.cssRules.length) { 596 const nestedRule = dummyRule.cssRules[0]; 597 const nestedRuleLine = InspectorUtils.getRelativeRuleLine(nestedRule); 598 const nestedRuleColumn = InspectorUtils.getRuleColumn(nestedRule); 599 // We need to account for the new line we added for the parent rule, 600 // and then remove 1 again since the InspectorUtils method returns 1-based values 601 let actualLine = nestedRuleLine - 2; 602 const actualColumn = nestedRuleColumn - 1; 603 604 // First, we compute the index in the original rule text corresponding to the 605 // nested rule line number. 606 insertIndex = 0; 607 for ( 608 ; 609 insertIndex < this.result.length && actualLine > 0; 610 insertIndex++ 611 ) { 612 if (this.result[insertIndex] === "\n") { 613 actualLine--; 614 } 615 } 616 617 // If the property doesn't add a new line, we need to insert the declaration 618 // before the nested declaration. When the property does add a new line, 619 // insertIndex already has the correct position. 620 if (!this.hasNewLine) { 621 insertIndex += actualColumn; 622 } 623 } 624 } 625 626 if (insertIndex == -1) { 627 this.result += newText; 628 } else { 629 this.result = 630 this.result.substring(0, insertIndex) + 631 newText + 632 this.result.substring(insertIndex); 633 } 634 635 if (this.decl) { 636 // Still want to copy in the declaration previously at this index. 637 this.completeCopying(this.decl.offsets[0]); 638 } 639 } 640 641 /** 642 * Create a new declaration. 643 * 644 * @param {number} index index of the property in the rule. 645 * @param {string} name name of the new property 646 * @param {string} value value of the new property 647 * @param {string} priority priority of the new property; either 648 * the empty string or "important" 649 * @param {boolean} enabled True if the new property should be 650 * enabled, false if disabled 651 */ 652 createProperty(index, name, value, priority, enabled) { 653 this.editPromise = this.internalCreateProperty( 654 index, 655 name, 656 value, 657 priority, 658 enabled 659 ); 660 // Log the modification only if the created property is enabled. 661 if (enabled) { 662 this.modifications.push({ type: "set", index, name, value, priority }); 663 } 664 } 665 666 /** 667 * Set a declaration's value. 668 * 669 * @param {number} index index of the property in the rule. 670 * This can be -1 in the case where 671 * the rule does not support setRuleText; 672 * generally for setting properties 673 * on an element's style. 674 * @param {string} name the property's name 675 * @param {string} value the property's value 676 * @param {string} priority the property's priority, either the empty 677 * string or "important" 678 */ 679 setProperty(index, name, value, priority) { 680 this.completeInitialization(index); 681 // We might see a "set" on a previously non-existent property; in 682 // that case, act like "create". 683 if (!this.decl) { 684 this.createProperty(index, name, value, priority, true); 685 return; 686 } 687 688 // Note that this assumes that "set" never operates on disabled 689 // properties. 690 this.result += 691 this.inputString.substring( 692 this.decl.offsets[0], 693 this.decl.colonOffsets[1] 694 ) + this.sanitizeText(value, index); 695 696 if (priority === "important") { 697 this.result += " !important"; 698 } 699 this.result += ";"; 700 this.completeCopying(this.decl.offsets[1]); 701 this.modifications.push({ type: "set", index, name, value, priority }); 702 } 703 704 /** 705 * Remove a declaration. 706 * 707 * @param {number} index index of the property in the rule. 708 * @param {string} name the name of the property to remove 709 */ 710 removeProperty(index, name) { 711 this.completeInitialization(index); 712 713 // If asked to remove a property that does not exist, bail out. 714 if (!this.decl) { 715 return; 716 } 717 718 // If the property is disabled, then first enable it, and then 719 // delete it. We take this approach because we want to remove the 720 // entire comment if possible; but the logic for dealing with 721 // comments is hairy and already implemented in 722 // setPropertyEnabled. 723 if (this.decl.commentOffsets) { 724 this.setPropertyEnabled(index, name, true); 725 this.startInitialization(this.result); 726 this.completeInitialization(index); 727 } 728 729 let copyOffset = this.decl.offsets[1]; 730 // Maybe removing this rule left us with a completely blank 731 // line. In this case, we'll delete the whole thing. We only 732 // bother with this if we're looking at sources that already 733 // have a newline somewhere. 734 if (this.hasNewLine) { 735 const nlOffset = this.skipWhitespaceBackward( 736 this.result, 737 this.decl.offsets[0] 738 ); 739 if ( 740 nlOffset < 0 || 741 this.result[nlOffset] === "\r" || 742 this.result[nlOffset] === "\n" 743 ) { 744 const trailingText = this.inputString.substring(copyOffset); 745 const match = BLANK_LINE_RX.exec(trailingText); 746 if (match) { 747 this.result = this.result.substring(0, nlOffset + 1); 748 copyOffset += match[0].length; 749 } 750 } 751 } 752 this.completeCopying(copyOffset); 753 this.modifications.push({ type: "remove", index, name }); 754 } 755 756 /** 757 * An internal function to copy any trailing text to the output 758 * string. 759 * 760 * @param {number} copyOffset Offset into |inputString| of the 761 * final text to copy to the output string. 762 */ 763 completeCopying(copyOffset) { 764 // Add the trailing text. 765 this.result += this.inputString.substring(copyOffset); 766 } 767 768 /** 769 * Apply the modifications in this object to the associated rule. 770 * 771 * @return {Promise} A promise which will be resolved when the modifications 772 * are complete. 773 */ 774 apply() { 775 return Promise.resolve(this.editPromise).then(() => { 776 return this.rule.setRuleText(this.result, this.modifications); 777 }); 778 } 779 780 /** 781 * Get the result of the rewriting. This is used for testing. 782 * 783 * @return {object} an object of the form {changed: object, text: string} 784 * |changed| is an object where each key is 785 * the index of a property whose value had to be 786 * rewritten during the sanitization process, and 787 * whose value is the new text of the property. 788 * |text| is the rewritten text of the rule. 789 */ 790 getResult() { 791 return { changed: this.changedDeclarations, text: this.result }; 792 } 793 } 794 795 /** 796 * Like trimRight, but only trims CSS-allowed whitespace. 797 */ 798 function cssTrimRight(str) { 799 const match = /^(.*?)[ \t\r\n\f]*$/.exec(str); 800 if (match) { 801 return match[1]; 802 } 803 return str; 804 } 805 806 module.exports = RuleRewriter;