807 lines
28 KiB
JavaScript
807 lines
28 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
// This file holds various CSS parsing and rewriting utilities.
|
|
// Some entry points of note are:
|
|
// parseDeclarations - parse a CSS rule into declarations
|
|
// RuleRewriter - rewrite CSS rule text
|
|
// parsePseudoClassesAndAttributes - parse selector and extract
|
|
// pseudo-classes
|
|
// parseSingleValue - parse a single CSS property value
|
|
|
|
"use strict";
|
|
|
|
const {
|
|
InspectorCSSParserWrapper,
|
|
} = require("resource://devtools/shared/css/lexer.js");
|
|
const {
|
|
COMMENT_PARSING_HEURISTIC_BYPASS_CHAR,
|
|
escapeCSSComment,
|
|
parseNamedDeclarations,
|
|
unescapeCSSComment,
|
|
} = require("resource://devtools/shared/css/parsing-utils.js");
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["getIndentationFromPrefs", "getIndentationFromString"],
|
|
"resource://devtools/shared/indentation.js",
|
|
true
|
|
);
|
|
|
|
// Used to test whether a newline appears anywhere in some text.
|
|
const NEWLINE_RX = /[\r\n]/;
|
|
// Used to test whether a bit of text starts an empty comment, either
|
|
// an "ordinary" /* ... */ comment, or a "heuristic bypass" comment
|
|
// like /*! ... */.
|
|
const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/;
|
|
// Used to test whether a bit of text ends an empty comment.
|
|
const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//;
|
|
// Used to test whether a string starts with a blank line.
|
|
const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/;
|
|
|
|
/**
|
|
* Return an object that can be used to rewrite declarations in some
|
|
* source text. The source text and parsing are handled in the same
|
|
* way as @see parseNamedDeclarations, with |parseComments| being true.
|
|
* Rewriting is done by calling one of the modification functions like
|
|
* setPropertyEnabled. The returned object has the same interface
|
|
* as @see RuleModificationList.
|
|
*
|
|
* An example showing how to disable the 3rd property in a rule:
|
|
*
|
|
* let rewriter = new RuleRewriter(win, isCssPropertyKnown, ruleActor,
|
|
* ruleActor.authoredText);
|
|
* rewriter.setPropertyEnabled(3, "color", false);
|
|
* rewriter.apply().then(() => { ... the change is made ... });
|
|
*
|
|
* The exported rewriting methods are |renameProperty|, |setPropertyEnabled|,
|
|
* |createProperty|, |setProperty|, and |removeProperty|. The |apply|
|
|
* method can be used to send the edited text to the StyleRuleActor;
|
|
* |getDefaultIndentation| is useful for the methods requiring a
|
|
* default indentation value; and |getResult| is useful for testing.
|
|
*
|
|
* Additionally, editing will set the |changedDeclarations| property
|
|
* on this object. This property has the same form as the |changed|
|
|
* property of the object returned by |getResult|.
|
|
*/
|
|
class RuleRewriter {
|
|
/**
|
|
* @constructor
|
|
* @param {Window} win
|
|
* @param {Function} isCssPropertyKnown
|
|
* A function to check if the CSS property is known. This is either an
|
|
* internal server function or from the CssPropertiesFront.
|
|
* that are supported by the server. Note that if Bug 1222047
|
|
* is completed then isCssPropertyKnown will not need to be passed in.
|
|
* The CssProperty front will be able to obtained directly from the
|
|
* RuleRewriter.
|
|
* @param {StyleRuleFront} rule The style rule to use. Note that this
|
|
* is only needed by the |apply| and |getDefaultIndentation| methods;
|
|
* and in particular for testing it can be |null|.
|
|
* @param {String} inputString The CSS source text to parse and modify.
|
|
*/
|
|
constructor(win, isCssPropertyKnown, rule, inputString) {
|
|
this.win = win;
|
|
this.rule = rule;
|
|
this.isCssPropertyKnown = isCssPropertyKnown;
|
|
// The RuleRewriter sends CSS rules as text to the server, but with this modifications
|
|
// array, it also sends the list of changes so the server doesn't have to re-parse the
|
|
// rule if it needs to track what changed.
|
|
this.modifications = [];
|
|
|
|
// Keep track of which any declarations we had to rewrite while
|
|
// performing the requested action.
|
|
this.changedDeclarations = {};
|
|
|
|
// If not null, a promise that must be wait upon before |apply| can
|
|
// do its work.
|
|
this.editPromise = null;
|
|
|
|
// If the |defaultIndentation| property is set, then it is used;
|
|
// otherwise the RuleRewriter will try to compute the default
|
|
// indentation based on the style sheet's text. This override
|
|
// facility is for testing.
|
|
this.defaultIndentation = null;
|
|
|
|
this.startInitialization(inputString);
|
|
}
|
|
|
|
/**
|
|
* An internal function to initialize the rewriter with a given
|
|
* input string.
|
|
*
|
|
* @param {String} inputString the input to use
|
|
*/
|
|
startInitialization(inputString) {
|
|
this.inputString = inputString;
|
|
// Whether there are any newlines in the input text.
|
|
this.hasNewLine = /[\r\n]/.test(this.inputString);
|
|
// The declarations.
|
|
this.declarations = parseNamedDeclarations(
|
|
this.isCssPropertyKnown,
|
|
this.inputString,
|
|
true
|
|
);
|
|
this.decl = null;
|
|
this.result = null;
|
|
}
|
|
|
|
/**
|
|
* An internal function to complete initialization and set some
|
|
* properties for further processing.
|
|
*
|
|
* @param {Number} index The index of the property to modify
|
|
*/
|
|
completeInitialization(index) {
|
|
if (index < 0) {
|
|
throw new Error("Invalid index " + index + ". Expected positive integer");
|
|
}
|
|
// |decl| is the declaration to be rewritten, or null if there is no
|
|
// declaration corresponding to |index|.
|
|
// |result| is used to accumulate the result text.
|
|
if (index < this.declarations.length) {
|
|
this.decl = this.declarations[index];
|
|
this.result = this.inputString.substring(0, this.decl.offsets[0]);
|
|
} else {
|
|
this.decl = null;
|
|
this.result = this.inputString;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A helper function to compute the indentation of some text. This
|
|
* examines the rule's existing text to guess the indentation to use;
|
|
* unlike |getDefaultIndentation|, which examines the entire style
|
|
* sheet.
|
|
*
|
|
* @param {String} string the input text
|
|
* @param {Number} offset the offset at which to compute the indentation
|
|
* @return {String} the indentation at the indicated position
|
|
*/
|
|
getIndentation(string, offset) {
|
|
let originalOffset = offset;
|
|
for (--offset; offset >= 0; --offset) {
|
|
const c = string[offset];
|
|
if (c === "\r" || c === "\n" || c === "\f") {
|
|
return string.substring(offset + 1, originalOffset);
|
|
}
|
|
if (c !== " " && c !== "\t") {
|
|
// Found some non-whitespace character before we found a newline
|
|
// -- let's reset the starting point and keep going, as we saw
|
|
// something on the line before the declaration.
|
|
originalOffset = offset;
|
|
}
|
|
}
|
|
// Ran off the end.
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Modify a property value to ensure it is "lexically safe" for
|
|
* insertion into a style sheet. This function doesn't attempt to
|
|
* ensure that the resulting text is a valid value for the given
|
|
* property; but rather just that inserting the text into the style
|
|
* sheet will not cause unwanted changes to other rules or
|
|
* declarations.
|
|
*
|
|
* @param {String} text The input text. This should include the trailing ";".
|
|
* @return {Array} An array of the form [anySanitized, text], where
|
|
* |anySanitized| is a boolean that indicates
|
|
* whether anything substantive has changed; and
|
|
* where |text| is the text that has been rewritten
|
|
* to be "lexically safe".
|
|
*/
|
|
sanitizePropertyValue(text) {
|
|
// Start by stripping any trailing ";". This is done here to
|
|
// avoid the case where the user types "url(" (which is turned
|
|
// into "url(;" by the rule view before coming here), being turned
|
|
// into "url(;)" by this code -- due to the way "url(...)" is
|
|
// parsed as a single token.
|
|
text = text.replace(/;$/, "");
|
|
const lexer = new InspectorCSSParserWrapper(text, { trackEOFChars: true });
|
|
|
|
let result = "";
|
|
let previousOffset = 0;
|
|
const parenStack = [];
|
|
let anySanitized = false;
|
|
|
|
// Push a closing paren on the stack.
|
|
const pushParen = (token, closer) => {
|
|
result =
|
|
result +
|
|
text.substring(previousOffset, token.startOffset) +
|
|
text.substring(token.startOffset, token.endOffset);
|
|
// We set the location of the paren in a funny way, to handle
|
|
// the case where we've seen a function token, where the paren
|
|
// appears at the end.
|
|
parenStack.push({ closer, offset: result.length - 1, token });
|
|
previousOffset = token.endOffset;
|
|
};
|
|
|
|
// Pop a closing paren from the stack.
|
|
const popSomeParens = closer => {
|
|
while (parenStack.length) {
|
|
const paren = parenStack.pop();
|
|
|
|
if (paren.closer === closer) {
|
|
return true;
|
|
}
|
|
|
|
// We need to handle non-closed url function differently, as performEOFFixup will
|
|
// only automatically close missing parenthesis `url`.
|
|
// In such case, don't do anything here.
|
|
if (
|
|
paren.closer === ")" &&
|
|
closer == null &&
|
|
paren.token.tokenType === "Function" &&
|
|
paren.token.value === "url"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Found a non-matching closing paren, so quote it. Note that
|
|
// these are processed in reverse order.
|
|
result =
|
|
result.substring(0, paren.offset) +
|
|
"\\" +
|
|
result.substring(paren.offset);
|
|
anySanitized = true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let token;
|
|
while ((token = lexer.nextToken())) {
|
|
switch (token.tokenType) {
|
|
case "Semicolon":
|
|
// We simply drop the ";" here. This lets us cope with
|
|
// declarations that don't have a ";" and also other
|
|
// termination. The caller handles adding the ";" again.
|
|
result += text.substring(previousOffset, token.startOffset);
|
|
previousOffset = token.endOffset;
|
|
break;
|
|
|
|
case "CurlyBracketBlock":
|
|
pushParen(token, "}");
|
|
break;
|
|
|
|
case "ParenthesisBlock":
|
|
case "Function":
|
|
pushParen(token, ")");
|
|
break;
|
|
|
|
case "SquareBracketBlock":
|
|
pushParen(token, "]");
|
|
break;
|
|
|
|
case "CloseCurlyBracket":
|
|
case "CloseParenthesis":
|
|
case "CloseSquareBracket":
|
|
// Did we find an unmatched close bracket?
|
|
if (!popSomeParens(token.text)) {
|
|
// Copy out text from |previousOffset|.
|
|
result += text.substring(previousOffset, token.startOffset);
|
|
// Quote the offending symbol.
|
|
result += "\\" + token.text;
|
|
previousOffset = token.endOffset;
|
|
anySanitized = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fix up any unmatched parens.
|
|
popSomeParens(null);
|
|
|
|
// Copy out any remaining text, then any needed terminators.
|
|
result += text.substring(previousOffset, text.length);
|
|
|
|
const eofFixup = lexer.performEOFFixup("");
|
|
if (eofFixup) {
|
|
anySanitized = true;
|
|
result += eofFixup;
|
|
}
|
|
return [anySanitized, result];
|
|
}
|
|
|
|
/**
|
|
* Start at |index| and skip whitespace
|
|
* backward in |string|. Return the index of the first
|
|
* non-whitespace character, or -1 if the entire string was
|
|
* whitespace.
|
|
* @param {String} string the input string
|
|
* @param {Number} index the index at which to start
|
|
* @return {Number} index of the first non-whitespace character, or -1
|
|
*/
|
|
skipWhitespaceBackward(string, index) {
|
|
for (
|
|
--index;
|
|
index >= 0 && (string[index] === " " || string[index] === "\t");
|
|
--index
|
|
) {
|
|
// Nothing.
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Terminate a given declaration, if needed.
|
|
*
|
|
* @param {Number} index The index of the rule to possibly
|
|
* terminate. It might be invalid, so this
|
|
* function must check for that.
|
|
*/
|
|
maybeTerminateDecl(index) {
|
|
if (
|
|
index < 0 ||
|
|
index >= this.declarations.length ||
|
|
// No need to rewrite declarations in comments.
|
|
"commentOffsets" in this.declarations[index]
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const termDecl = this.declarations[index];
|
|
let endIndex = termDecl.offsets[1];
|
|
// Due to an oddity of the lexer, we might have gotten a bit of
|
|
// extra whitespace in a trailing bad_url token -- so be sure to
|
|
// skip that as well.
|
|
endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1;
|
|
|
|
const trailingText = this.result.substring(endIndex);
|
|
if (termDecl.terminator) {
|
|
// Insert the terminator just at the end of the declaration,
|
|
// before any trailing whitespace.
|
|
this.result =
|
|
this.result.substring(0, endIndex) + termDecl.terminator + trailingText;
|
|
// In a couple of cases, we may have had to add something to
|
|
// terminate the declaration, but the termination did not
|
|
// actually affect the property's value -- and at this spot, we
|
|
// only care about reporting value changes. In particular, we
|
|
// might have added a plain ";", or we might have terminated a
|
|
// comment with "*/;". Neither of these affect the value.
|
|
if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") {
|
|
this.changedDeclarations[index] =
|
|
termDecl.value + termDecl.terminator.slice(0, -1);
|
|
}
|
|
}
|
|
// If the rule generally has newlines, but this particular
|
|
// declaration doesn't have a trailing newline, insert one now.
|
|
// Maybe this style is too weird to bother with.
|
|
if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) {
|
|
this.result += "\n";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize the given property value and return the sanitized form.
|
|
* If the property is rewritten during sanitization, make a note in
|
|
* |changedDeclarations|.
|
|
*
|
|
* @param {String} text The property text.
|
|
* @param {Number} index The index of the property.
|
|
* @return {String} The sanitized text.
|
|
*/
|
|
sanitizeText(text, index) {
|
|
const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text);
|
|
if (anySanitized) {
|
|
this.changedDeclarations[index] = sanitizedText;
|
|
}
|
|
return sanitizedText;
|
|
}
|
|
|
|
/**
|
|
* Rename a declaration.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name current name of the property
|
|
* @param {String} newName new name of the property
|
|
*/
|
|
renameProperty(index, name, newName) {
|
|
this.completeInitialization(index);
|
|
this.result += CSS.escape(newName);
|
|
// We could conceivably compute the name offsets instead so we
|
|
// could preserve white space and comments on the LHS of the ":".
|
|
this.completeCopying(this.decl.colonOffsets[0]);
|
|
this.modifications.push({ type: "set", index, name, newName });
|
|
}
|
|
|
|
/**
|
|
* Enable or disable a declaration
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name current name of the property
|
|
* @param {Boolean} isEnabled true if the property should be enabled;
|
|
* false if it should be disabled
|
|
*/
|
|
setPropertyEnabled(index, name, isEnabled) {
|
|
this.completeInitialization(index);
|
|
const decl = this.decl;
|
|
const priority = decl.priority;
|
|
let copyOffset = decl.offsets[1];
|
|
if (isEnabled) {
|
|
// Enable it. First see if the comment start can be deleted.
|
|
const commentStart = decl.commentOffsets[0];
|
|
if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) {
|
|
this.result = this.result.substring(0, commentStart);
|
|
} else {
|
|
this.result += "*/ ";
|
|
}
|
|
|
|
// Insert the name and value separately, so we can report
|
|
// sanitization changes properly.
|
|
const commentNamePart = this.inputString.substring(
|
|
decl.offsets[0],
|
|
decl.colonOffsets[1]
|
|
);
|
|
this.result += unescapeCSSComment(commentNamePart);
|
|
|
|
// When uncommenting, we must be sure to sanitize the text, to
|
|
// avoid things like /* decl: }; */, which will be accepted as
|
|
// a property but which would break the entire style sheet.
|
|
let newText = this.inputString.substring(
|
|
decl.colonOffsets[1],
|
|
decl.offsets[1]
|
|
);
|
|
newText = cssTrimRight(unescapeCSSComment(newText));
|
|
this.result += this.sanitizeText(newText, index) + ";";
|
|
|
|
// See if the comment end can be deleted.
|
|
const trailingText = this.inputString.substring(decl.offsets[1]);
|
|
if (EMPTY_COMMENT_END_RX.test(trailingText)) {
|
|
copyOffset = decl.commentOffsets[1];
|
|
} else {
|
|
this.result += " /*";
|
|
}
|
|
} else {
|
|
// Disable it. Note that we use our special comment syntax
|
|
// here.
|
|
const declText = this.inputString.substring(
|
|
decl.offsets[0],
|
|
decl.offsets[1]
|
|
);
|
|
this.result +=
|
|
"/*" +
|
|
COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
|
|
" " +
|
|
escapeCSSComment(declText) +
|
|
" */";
|
|
}
|
|
this.completeCopying(copyOffset);
|
|
|
|
if (isEnabled) {
|
|
this.modifications.push({
|
|
type: "set",
|
|
index,
|
|
name,
|
|
value: decl.value,
|
|
priority,
|
|
});
|
|
} else {
|
|
this.modifications.push({ type: "disable", index, name });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a promise that will be resolved to the default indentation
|
|
* of the rule. This is a helper for internalCreateProperty.
|
|
*
|
|
* @return {Promise} a promise that will be resolved to a string
|
|
* that holds the default indentation that should be used
|
|
* for edits to the rule.
|
|
*/
|
|
async getDefaultIndentation() {
|
|
const prefIndent = getIndentationFromPrefs();
|
|
if (prefIndent) {
|
|
const { indentUnit, indentWithTabs } = prefIndent;
|
|
return indentWithTabs ? "\t" : " ".repeat(indentUnit);
|
|
}
|
|
|
|
const styleSheetsFront =
|
|
await this.rule.targetFront.getFront("stylesheets");
|
|
|
|
if (!this.rule.parentStyleSheet) {
|
|
// See Bug 1899341, due to resource throttling, the parentStyleSheet for
|
|
// the rule might not be received by the client yet. Fallback to a usable
|
|
// default value.
|
|
console.error(
|
|
"Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces"
|
|
);
|
|
return " ";
|
|
}
|
|
const { str: source } = await styleSheetsFront.getText(
|
|
this.rule.parentStyleSheet.resourceId
|
|
);
|
|
const { indentUnit, indentWithTabs } = getIndentationFromString(source);
|
|
return indentWithTabs ? "\t" : " ".repeat(indentUnit);
|
|
}
|
|
|
|
/**
|
|
* An internal function to create a new declaration. This does all
|
|
* the work of |createProperty|.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name name of the new property
|
|
* @param {String} value value of the new property
|
|
* @param {String} priority priority of the new property; either
|
|
* the empty string or "important"
|
|
* @param {Boolean} enabled True if the new property should be
|
|
* enabled, false if disabled
|
|
* @return {Promise} a promise that is resolved when the edit has
|
|
* completed
|
|
*/
|
|
async internalCreateProperty(index, name, value, priority, enabled) {
|
|
this.completeInitialization(index);
|
|
let newIndentation = "";
|
|
if (this.hasNewLine) {
|
|
if (this.declarations.length) {
|
|
newIndentation = this.getIndentation(
|
|
this.inputString,
|
|
this.declarations[0].offsets[0]
|
|
);
|
|
} else if (this.defaultIndentation) {
|
|
newIndentation = this.defaultIndentation;
|
|
} else {
|
|
newIndentation = await this.getDefaultIndentation();
|
|
}
|
|
}
|
|
|
|
this.maybeTerminateDecl(index - 1);
|
|
|
|
// If we generally have newlines, and if skipping whitespace
|
|
// backward stops at a newline, then insert our text before that
|
|
// whitespace. This ensures the indentation we computed is what
|
|
// is actually used.
|
|
let savedWhitespace = "";
|
|
if (this.hasNewLine) {
|
|
const wsOffset = this.skipWhitespaceBackward(
|
|
this.result,
|
|
this.result.length
|
|
);
|
|
if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") {
|
|
savedWhitespace = this.result.substring(wsOffset + 1);
|
|
this.result = this.result.substring(0, wsOffset + 1);
|
|
}
|
|
}
|
|
|
|
let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index);
|
|
if (priority === "important") {
|
|
newText += " !important";
|
|
}
|
|
newText += ";";
|
|
|
|
if (!enabled) {
|
|
newText =
|
|
"/*" +
|
|
COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
|
|
" " +
|
|
escapeCSSComment(newText) +
|
|
" */";
|
|
}
|
|
|
|
newText = `${newIndentation}${newText}${this.hasNewLine ? "\n" : ""}${savedWhitespace}`;
|
|
|
|
// If the rule has some nested declarations, we need to find the proper index where
|
|
// to put the new declaration at.
|
|
// e.g. if we have `body { color: red; &>span {}; }`, we want to put the new property
|
|
// after `color: red` but before `&>span`.
|
|
let insertIndex = -1;
|
|
// Don't try to find the index if we can already see there's no nested rules
|
|
if (this.result.includes("{")) {
|
|
// Create a rule with the initial rule text so we can check for children rules
|
|
const dummySheet = new this.win.CSSStyleSheet();
|
|
dummySheet.replaceSync(":root {\n" + this.result + "}");
|
|
const dummyRule = dummySheet.cssRules[0];
|
|
if (dummyRule.cssRules.length) {
|
|
const nestedRule = dummyRule.cssRules[0];
|
|
const nestedRuleLine = InspectorUtils.getRelativeRuleLine(nestedRule);
|
|
const nestedRuleColumn = InspectorUtils.getRuleColumn(nestedRule);
|
|
// We need to account for the new line we added for the parent rule,
|
|
// and then remove 1 again since the InspectorUtils method returns 1-based values
|
|
let actualLine = nestedRuleLine - 2;
|
|
const actualColumn = nestedRuleColumn - 1;
|
|
|
|
// First, we compute the index in the original rule text corresponding to the
|
|
// nested rule line number.
|
|
insertIndex = 0;
|
|
for (
|
|
;
|
|
insertIndex < this.result.length && actualLine > 0;
|
|
insertIndex++
|
|
) {
|
|
if (this.result[insertIndex] === "\n") {
|
|
actualLine--;
|
|
}
|
|
}
|
|
|
|
// If the property doesn't add a new line, we need to insert the declaration
|
|
// before the nested declaration. When the property does add a new line,
|
|
// insertIndex already has the correct position.
|
|
if (!this.hasNewLine) {
|
|
insertIndex += actualColumn;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (insertIndex == -1) {
|
|
this.result += newText;
|
|
} else {
|
|
this.result =
|
|
this.result.substring(0, insertIndex) +
|
|
newText +
|
|
this.result.substring(insertIndex);
|
|
}
|
|
|
|
if (this.decl) {
|
|
// Still want to copy in the declaration previously at this index.
|
|
this.completeCopying(this.decl.offsets[0]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new declaration.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name name of the new property
|
|
* @param {String} value value of the new property
|
|
* @param {String} priority priority of the new property; either
|
|
* the empty string or "important"
|
|
* @param {Boolean} enabled True if the new property should be
|
|
* enabled, false if disabled
|
|
*/
|
|
createProperty(index, name, value, priority, enabled) {
|
|
this.editPromise = this.internalCreateProperty(
|
|
index,
|
|
name,
|
|
value,
|
|
priority,
|
|
enabled
|
|
);
|
|
// Log the modification only if the created property is enabled.
|
|
if (enabled) {
|
|
this.modifications.push({ type: "set", index, name, value, priority });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a declaration's value.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* This can be -1 in the case where
|
|
* the rule does not support setRuleText;
|
|
* generally for setting properties
|
|
* on an element's style.
|
|
* @param {String} name the property's name
|
|
* @param {String} value the property's value
|
|
* @param {String} priority the property's priority, either the empty
|
|
* string or "important"
|
|
*/
|
|
setProperty(index, name, value, priority) {
|
|
this.completeInitialization(index);
|
|
// We might see a "set" on a previously non-existent property; in
|
|
// that case, act like "create".
|
|
if (!this.decl) {
|
|
this.createProperty(index, name, value, priority, true);
|
|
return;
|
|
}
|
|
|
|
// Note that this assumes that "set" never operates on disabled
|
|
// properties.
|
|
this.result +=
|
|
this.inputString.substring(
|
|
this.decl.offsets[0],
|
|
this.decl.colonOffsets[1]
|
|
) + this.sanitizeText(value, index);
|
|
|
|
if (priority === "important") {
|
|
this.result += " !important";
|
|
}
|
|
this.result += ";";
|
|
this.completeCopying(this.decl.offsets[1]);
|
|
this.modifications.push({ type: "set", index, name, value, priority });
|
|
}
|
|
|
|
/**
|
|
* Remove a declaration.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name the name of the property to remove
|
|
*/
|
|
removeProperty(index, name) {
|
|
this.completeInitialization(index);
|
|
|
|
// If asked to remove a property that does not exist, bail out.
|
|
if (!this.decl) {
|
|
return;
|
|
}
|
|
|
|
// If the property is disabled, then first enable it, and then
|
|
// delete it. We take this approach because we want to remove the
|
|
// entire comment if possible; but the logic for dealing with
|
|
// comments is hairy and already implemented in
|
|
// setPropertyEnabled.
|
|
if (this.decl.commentOffsets) {
|
|
this.setPropertyEnabled(index, name, true);
|
|
this.startInitialization(this.result);
|
|
this.completeInitialization(index);
|
|
}
|
|
|
|
let copyOffset = this.decl.offsets[1];
|
|
// Maybe removing this rule left us with a completely blank
|
|
// line. In this case, we'll delete the whole thing. We only
|
|
// bother with this if we're looking at sources that already
|
|
// have a newline somewhere.
|
|
if (this.hasNewLine) {
|
|
const nlOffset = this.skipWhitespaceBackward(
|
|
this.result,
|
|
this.decl.offsets[0]
|
|
);
|
|
if (
|
|
nlOffset < 0 ||
|
|
this.result[nlOffset] === "\r" ||
|
|
this.result[nlOffset] === "\n"
|
|
) {
|
|
const trailingText = this.inputString.substring(copyOffset);
|
|
const match = BLANK_LINE_RX.exec(trailingText);
|
|
if (match) {
|
|
this.result = this.result.substring(0, nlOffset + 1);
|
|
copyOffset += match[0].length;
|
|
}
|
|
}
|
|
}
|
|
this.completeCopying(copyOffset);
|
|
this.modifications.push({ type: "remove", index, name });
|
|
}
|
|
|
|
/**
|
|
* An internal function to copy any trailing text to the output
|
|
* string.
|
|
*
|
|
* @param {Number} copyOffset Offset into |inputString| of the
|
|
* final text to copy to the output string.
|
|
*/
|
|
completeCopying(copyOffset) {
|
|
// Add the trailing text.
|
|
this.result += this.inputString.substring(copyOffset);
|
|
}
|
|
|
|
/**
|
|
* Apply the modifications in this object to the associated rule.
|
|
*
|
|
* @return {Promise} A promise which will be resolved when the modifications
|
|
* are complete.
|
|
*/
|
|
apply() {
|
|
return Promise.resolve(this.editPromise).then(() => {
|
|
return this.rule.setRuleText(this.result, this.modifications);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the result of the rewriting. This is used for testing.
|
|
*
|
|
* @return {object} an object of the form {changed: object, text: string}
|
|
* |changed| is an object where each key is
|
|
* the index of a property whose value had to be
|
|
* rewritten during the sanitization process, and
|
|
* whose value is the new text of the property.
|
|
* |text| is the rewritten text of the rule.
|
|
*/
|
|
getResult() {
|
|
return { changed: this.changedDeclarations, text: this.result };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Like trimRight, but only trims CSS-allowed whitespace.
|
|
*/
|
|
function cssTrimRight(str) {
|
|
const match = /^(.*?)[ \t\r\n\f]*$/.exec(str);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
return str;
|
|
}
|
|
|
|
module.exports = RuleRewriter;
|