mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-12-11 16:32:59 +00:00
1183 lines
42 KiB
JavaScript
1183 lines
42 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set ts=2 et sw=2 tw=80: */
|
|
/* 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 {CSS_ANGLEUNIT} = require("devtools/shared/css/properties-db");
|
|
|
|
const promise = require("promise");
|
|
const {getCSSLexer} = require("devtools/shared/css/lexer");
|
|
const {Task} = require("devtools/shared/task");
|
|
|
|
const SELECTOR_ATTRIBUTE = exports.SELECTOR_ATTRIBUTE = 1;
|
|
const SELECTOR_ELEMENT = exports.SELECTOR_ELEMENT = 2;
|
|
const SELECTOR_PSEUDO_CLASS = exports.SELECTOR_PSEUDO_CLASS = 3;
|
|
|
|
// 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|$)/;
|
|
|
|
// When commenting out a declaration, we put this character into the
|
|
// comment opener so that future parses of the commented text know to
|
|
// bypass the property name validity heuristic.
|
|
const COMMENT_PARSING_HEURISTIC_BYPASS_CHAR = "!";
|
|
|
|
/**
|
|
* A generator function that lexes a CSS source string, yielding the
|
|
* CSS tokens. Comment tokens are dropped.
|
|
*
|
|
* @param {String} CSS source string
|
|
* @yield {CSSToken} The next CSSToken that is lexed
|
|
* @see CSSToken for details about the returned tokens
|
|
*/
|
|
function* cssTokenizer(string) {
|
|
let lexer = getCSSLexer(string);
|
|
while (true) {
|
|
let token = lexer.nextToken();
|
|
if (!token) {
|
|
break;
|
|
}
|
|
// None of the existing consumers want comments.
|
|
if (token.tokenType !== "comment") {
|
|
yield token;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pass |string| to the CSS lexer and return an array of all the
|
|
* returned tokens. Comment tokens are not included. In addition to
|
|
* the usual information, each token will have starting and ending
|
|
* line and column information attached. Specifically, each token
|
|
* has an additional "loc" attribute. This attribute is an object
|
|
* of the form {line: L, column: C}. Lines and columns are both zero
|
|
* based.
|
|
*
|
|
* It's best not to add new uses of this function. In general it is
|
|
* simpler and better to use the CSSToken offsets, rather than line
|
|
* and column. Also, this function lexes the entire input string at
|
|
* once, rather than lazily yielding a token stream. Use
|
|
* |cssTokenizer| or |getCSSLexer| instead.
|
|
*
|
|
* @param{String} string The input string.
|
|
* @return {Array} An array of tokens (@see CSSToken) that have
|
|
* line and column information.
|
|
*/
|
|
function cssTokenizerWithLineColumn(string) {
|
|
let lexer = getCSSLexer(string);
|
|
let result = [];
|
|
let prevToken = undefined;
|
|
while (true) {
|
|
let token = lexer.nextToken();
|
|
let lineNumber = lexer.lineNumber;
|
|
let columnNumber = lexer.columnNumber;
|
|
|
|
if (prevToken) {
|
|
prevToken.loc.end = {
|
|
line: lineNumber,
|
|
column: columnNumber
|
|
};
|
|
}
|
|
|
|
if (!token) {
|
|
break;
|
|
}
|
|
|
|
if (token.tokenType === "comment") {
|
|
// We've already dealt with the previous token's location.
|
|
prevToken = undefined;
|
|
} else {
|
|
let startLoc = {
|
|
line: lineNumber,
|
|
column: columnNumber
|
|
};
|
|
token.loc = {start: startLoc};
|
|
|
|
result.push(token);
|
|
prevToken = token;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Escape a comment body. Find the comment start and end strings in a
|
|
* string and inserts backslashes so that the resulting text can
|
|
* itself be put inside a comment.
|
|
*
|
|
* @param {String} inputString
|
|
* input string
|
|
* @return {String} the escaped result
|
|
*/
|
|
function escapeCSSComment(inputString) {
|
|
let result = inputString.replace(/\/(\\*)\*/g, "/\\$1*");
|
|
return result.replace(/\*(\\*)\//g, "*\\$1/");
|
|
}
|
|
|
|
/**
|
|
* Un-escape a comment body. This undoes any comment escaping that
|
|
* was done by escapeCSSComment. That is, given input like "/\*
|
|
* comment *\/", it will strip the backslashes.
|
|
*
|
|
* @param {String} inputString
|
|
* input string
|
|
* @return {String} the un-escaped result
|
|
*/
|
|
function unescapeCSSComment(inputString) {
|
|
let result = inputString.replace(/\/\\(\\*)\*/g, "/$1*");
|
|
return result.replace(/\*\\(\\*)\//g, "*$1/");
|
|
}
|
|
|
|
/**
|
|
* A helper function for @see parseDeclarations that handles parsing
|
|
* of comment text. This wraps a recursive call to parseDeclarations
|
|
* with the processing needed to ensure that offsets in the result
|
|
* refer back to the original, unescaped, input string.
|
|
*
|
|
* @param {Function} isCssPropertyKnown
|
|
* A function to check if the CSS property is known. This is either an
|
|
* internal server function or from the CssPropertiesFront.
|
|
* @param {String} commentText The text of the comment, without the
|
|
* delimiters.
|
|
* @param {Number} startOffset The offset of the comment opener
|
|
* in the original text.
|
|
* @param {Number} endOffset The offset of the comment closer
|
|
* in the original text.
|
|
* @return {array} Array of declarations of the same form as returned
|
|
* by parseDeclarations.
|
|
*/
|
|
function parseCommentDeclarations(isCssPropertyKnown, commentText, startOffset,
|
|
endOffset) {
|
|
let commentOverride = false;
|
|
if (commentText === "") {
|
|
return [];
|
|
} else if (commentText[0] === COMMENT_PARSING_HEURISTIC_BYPASS_CHAR) {
|
|
// This is the special sign that the comment was written by
|
|
// rewriteDeclarations and so we should bypass the usual
|
|
// heuristic.
|
|
commentOverride = true;
|
|
commentText = commentText.substring(1);
|
|
}
|
|
|
|
let rewrittenText = unescapeCSSComment(commentText);
|
|
|
|
// We might have rewritten an embedded comment. For example
|
|
// /\* ... *\/ would turn into /* ... */.
|
|
// This rewriting is necessary for proper lexing, but it means
|
|
// that the offsets we get back can be off. So now we compute
|
|
// a map so that we can rewrite offsets later. The map is the same
|
|
// length as |rewrittenText| and tells us how to map an index
|
|
// into |rewrittenText| to an index into |commentText|.
|
|
//
|
|
// First, we find the location of each comment starter or closer in
|
|
// |rewrittenText|. At these spots we put a 1 into |rewrites|.
|
|
// Then we walk the array again, using the elements to compute a
|
|
// delta, which we use to make the final mapping.
|
|
//
|
|
// Note we allocate one extra entry because we can see an ending
|
|
// offset that is equal to the length.
|
|
let rewrites = new Array(rewrittenText.length + 1).fill(0);
|
|
|
|
let commentRe = /\/\\*\*|\*\\*\//g;
|
|
while (true) {
|
|
let matchData = commentRe.exec(rewrittenText);
|
|
if (!matchData) {
|
|
break;
|
|
}
|
|
rewrites[matchData.index] = 1;
|
|
}
|
|
|
|
let delta = 0;
|
|
for (let i = 0; i <= rewrittenText.length; ++i) {
|
|
delta += rewrites[i];
|
|
// |startOffset| to add the offset from the comment starter, |+2|
|
|
// for the length of the "/*", then |i| and |delta| as described
|
|
// above.
|
|
rewrites[i] = startOffset + 2 + i + delta;
|
|
if (commentOverride) {
|
|
++rewrites[i];
|
|
}
|
|
}
|
|
|
|
// Note that we pass "false" for parseComments here. It doesn't
|
|
// seem worthwhile to support declarations in comments-in-comments
|
|
// here, as there's no way to generate those using the tools, and
|
|
// users would be crazy to write such things.
|
|
let newDecls = parseDeclarationsInternal(isCssPropertyKnown, rewrittenText,
|
|
false, true, commentOverride);
|
|
for (let decl of newDecls) {
|
|
decl.offsets[0] = rewrites[decl.offsets[0]];
|
|
decl.offsets[1] = rewrites[decl.offsets[1]];
|
|
decl.colonOffsets[0] = rewrites[decl.colonOffsets[0]];
|
|
decl.colonOffsets[1] = rewrites[decl.colonOffsets[1]];
|
|
decl.commentOffsets = [startOffset, endOffset];
|
|
}
|
|
return newDecls;
|
|
}
|
|
|
|
/**
|
|
* A helper function for parseDeclarationsInternal that creates a new
|
|
* empty declaration.
|
|
*
|
|
* @return {object} an empty declaration of the form returned by
|
|
* parseDeclarations
|
|
*/
|
|
function getEmptyDeclaration() {
|
|
return {name: "", value: "", priority: "",
|
|
terminator: "",
|
|
offsets: [undefined, undefined],
|
|
colonOffsets: false};
|
|
}
|
|
|
|
/**
|
|
* A helper function that does all the parsing work for
|
|
* parseDeclarations. This is separate because it has some arguments
|
|
* that don't make sense in isolation.
|
|
*
|
|
* The return value and arguments are like parseDeclarations, with
|
|
* these additional arguments.
|
|
*
|
|
* @param {Function} isCssPropertyKnown
|
|
* Function to check if the CSS property is known.
|
|
* @param {Boolean} inComment
|
|
* If true, assume that this call is parsing some text
|
|
* which came from a comment in another declaration.
|
|
* In this case some heuristics are used to avoid parsing
|
|
* text which isn't obviously a series of declarations.
|
|
* @param {Boolean} commentOverride
|
|
* This only makes sense when inComment=true.
|
|
* When true, assume that the comment was generated by
|
|
* rewriteDeclarations, and skip the usual name-checking
|
|
* heuristic.
|
|
*/
|
|
function parseDeclarationsInternal(isCssPropertyKnown, inputString,
|
|
parseComments, inComment, commentOverride) {
|
|
if (inputString === null || inputString === undefined) {
|
|
throw new Error("empty input string");
|
|
}
|
|
|
|
let lexer = getCSSLexer(inputString);
|
|
|
|
let declarations = [getEmptyDeclaration()];
|
|
let lastProp = declarations[0];
|
|
|
|
let current = "", hasBang = false;
|
|
while (true) {
|
|
let token = lexer.nextToken();
|
|
if (!token) {
|
|
break;
|
|
}
|
|
|
|
// Ignore HTML comment tokens (but parse anything they might
|
|
// happen to surround).
|
|
if (token.tokenType === "htmlcomment") {
|
|
continue;
|
|
}
|
|
|
|
// Update the start and end offsets of the declaration, but only
|
|
// when we see a significant token.
|
|
if (token.tokenType !== "whitespace" && token.tokenType !== "comment") {
|
|
if (lastProp.offsets[0] === undefined) {
|
|
lastProp.offsets[0] = token.startOffset;
|
|
}
|
|
lastProp.offsets[1] = token.endOffset;
|
|
} else if (lastProp.name && !current && !hasBang &&
|
|
!lastProp.priority && lastProp.colonOffsets[1]) {
|
|
// Whitespace appearing after the ":" is attributed to it.
|
|
lastProp.colonOffsets[1] = token.endOffset;
|
|
}
|
|
|
|
if (token.tokenType === "symbol" && token.text === ":") {
|
|
if (!lastProp.name) {
|
|
// Set the current declaration name if there's no name yet
|
|
lastProp.name = current.trim();
|
|
lastProp.colonOffsets = [token.startOffset, token.endOffset];
|
|
current = "";
|
|
hasBang = false;
|
|
|
|
// When parsing a comment body, if the left-hand-side is not a
|
|
// valid property name, then drop it and stop parsing.
|
|
if (inComment && !commentOverride &&
|
|
!isCssPropertyKnown(lastProp.name)) {
|
|
lastProp.name = null;
|
|
break;
|
|
}
|
|
} else {
|
|
// Otherwise, just append ':' to the current value (declaration value
|
|
// with colons)
|
|
current += ":";
|
|
}
|
|
} else if (token.tokenType === "symbol" && token.text === ";") {
|
|
lastProp.terminator = "";
|
|
// When parsing a comment, if the name hasn't been set, then we
|
|
// have probably just seen an ordinary semicolon used in text,
|
|
// so drop this and stop parsing.
|
|
if (inComment && !lastProp.name) {
|
|
current = "";
|
|
break;
|
|
}
|
|
lastProp.value = current.trim();
|
|
current = "";
|
|
hasBang = false;
|
|
declarations.push(getEmptyDeclaration());
|
|
lastProp = declarations[declarations.length - 1];
|
|
} else if (token.tokenType === "ident") {
|
|
if (token.text === "important" && hasBang) {
|
|
lastProp.priority = "important";
|
|
hasBang = false;
|
|
} else {
|
|
if (hasBang) {
|
|
current += "!";
|
|
}
|
|
// Re-escape the token to avoid dequoting problems.
|
|
// See bug 1287620.
|
|
current += CSS.escape(token.text);
|
|
}
|
|
} else if (token.tokenType === "symbol" && token.text === "!") {
|
|
hasBang = true;
|
|
} else if (token.tokenType === "whitespace") {
|
|
if (current !== "") {
|
|
current += " ";
|
|
}
|
|
} else if (token.tokenType === "comment") {
|
|
if (parseComments && !lastProp.name && !lastProp.value) {
|
|
let commentText = inputString.substring(token.startOffset + 2,
|
|
token.endOffset - 2);
|
|
let newDecls = parseCommentDeclarations(isCssPropertyKnown, commentText,
|
|
token.startOffset,
|
|
token.endOffset);
|
|
|
|
// Insert the new declarations just before the final element.
|
|
let lastDecl = declarations.pop();
|
|
declarations = [...declarations, ...newDecls, lastDecl];
|
|
} else {
|
|
current += " ";
|
|
}
|
|
} else {
|
|
current += inputString.substring(token.startOffset, token.endOffset);
|
|
}
|
|
}
|
|
|
|
// Handle whatever trailing properties or values might still be there
|
|
if (current) {
|
|
if (!lastProp.name) {
|
|
// Ignore this case in comments.
|
|
if (!inComment) {
|
|
// Trailing property found, e.g. p1:v1;p2:v2;p3
|
|
lastProp.name = current.trim();
|
|
}
|
|
} else {
|
|
// Trailing value found, i.e. value without an ending ;
|
|
lastProp.value = current.trim();
|
|
let terminator = lexer.performEOFFixup("", true);
|
|
lastProp.terminator = terminator + ";";
|
|
// If the input was unterminated, attribute the remainder to
|
|
// this property. This avoids some bad behavior when rewriting
|
|
// an unterminated comment.
|
|
if (terminator) {
|
|
lastProp.offsets[1] = inputString.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove declarations that have neither a name nor a value
|
|
declarations = declarations.filter(prop => prop.name || prop.value);
|
|
|
|
return declarations;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of CSS declarations given a string.
|
|
* For example, parseDeclarations(isCssPropertyKnown, "width: 1px; height: 1px")
|
|
* would return:
|
|
* [{name:"width", value: "1px"}, {name: "height", "value": "1px"}]
|
|
*
|
|
* The input string is assumed to only contain declarations so { and }
|
|
* characters will be treated as part of either the property or value,
|
|
* depending where it's found.
|
|
*
|
|
* @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.
|
|
* @param {String} inputString
|
|
* An input string of CSS
|
|
* @param {Boolean} parseComments
|
|
* If true, try to parse the contents of comments as well.
|
|
* A comment will only be parsed if it occurs outside of
|
|
* the body of some other declaration.
|
|
* @return {Array} an array of objects with the following signature:
|
|
* [{"name": string, "value": string, "priority": string,
|
|
* "terminator": string,
|
|
* "offsets": [start, end], "colonOffsets": [start, end]},
|
|
* ...]
|
|
* Here, "offsets" holds the offsets of the start and end
|
|
* of the declaration text, in a form suitable for use with
|
|
* String.substring.
|
|
* "terminator" is a string to use to terminate the declaration,
|
|
* usually "" to mean no additional termination is needed.
|
|
* "colonOffsets" holds the start and end locations of the
|
|
* ":" that separates the property name from the value.
|
|
* If the declaration appears in a comment, then there will
|
|
* be an additional {"commentOffsets": [start, end] property
|
|
* on the object, which will hold the offsets of the start
|
|
* and end of the enclosing comment.
|
|
*/
|
|
function parseDeclarations(isCssPropertyKnown, inputString,
|
|
parseComments = false) {
|
|
return parseDeclarationsInternal(isCssPropertyKnown, inputString,
|
|
parseComments, false, false);
|
|
}
|
|
|
|
/**
|
|
* Like @see parseDeclarations, but removes properties that do not
|
|
* have a name.
|
|
*/
|
|
function parseNamedDeclarations(isCssPropertyKnown, inputString,
|
|
parseComments = false) {
|
|
return parseDeclarations(isCssPropertyKnown, inputString, parseComments)
|
|
.filter(item => !!item.name);
|
|
}
|
|
|
|
/**
|
|
* 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(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|.
|
|
*
|
|
* @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.
|
|
* @return {Object} an object that can be used to rewrite the input text.
|
|
*/
|
|
function RuleRewriter(isCssPropertyKnown, rule, inputString) {
|
|
this.rule = rule;
|
|
this.isCssPropertyKnown = isCssPropertyKnown;
|
|
|
|
// 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);
|
|
}
|
|
|
|
RuleRewriter.prototype = {
|
|
/**
|
|
* An internal function to initialize the rewriter with a given
|
|
* input string.
|
|
*
|
|
* @param {String} inputString the input to use
|
|
*/
|
|
startInitialization: function (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: function (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: function (string, offset) {
|
|
let originalOffset = offset;
|
|
for (--offset; offset >= 0; --offset) {
|
|
let 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: function (text) {
|
|
let lexer = getCSSLexer(text);
|
|
|
|
let result = "";
|
|
let previousOffset = 0;
|
|
let braceDepth = 0;
|
|
let anySanitized = false;
|
|
while (true) {
|
|
let token = lexer.nextToken();
|
|
if (!token) {
|
|
break;
|
|
}
|
|
|
|
if (token.tokenType === "symbol") {
|
|
switch (token.text) {
|
|
case ";":
|
|
// 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 "{":
|
|
++braceDepth;
|
|
break;
|
|
|
|
case "}":
|
|
--braceDepth;
|
|
if (braceDepth < 0) {
|
|
// Found an unmatched close bracket.
|
|
braceDepth = 0;
|
|
// Copy out text from |previousOffset|.
|
|
result += text.substring(previousOffset, token.startOffset);
|
|
// Quote the offending symbol.
|
|
result += "\\" + token.text;
|
|
previousOffset = token.endOffset;
|
|
anySanitized = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy out any remaining text, then any needed terminators.
|
|
result += text.substring(previousOffset, text.length);
|
|
let eofFixup = lexer.performEOFFixup("", true);
|
|
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: function (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: function (index) {
|
|
if (index < 0 || index >= this.declarations.length
|
|
// No need to rewrite declarations in comments.
|
|
|| ("commentOffsets" in this.declarations[index])) {
|
|
return;
|
|
}
|
|
|
|
let 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;
|
|
|
|
let 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: function (text, index) {
|
|
let [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: function (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]);
|
|
},
|
|
|
|
/**
|
|
* 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: function (index, name, isEnabled) {
|
|
this.completeInitialization(index);
|
|
const decl = this.decl;
|
|
let copyOffset = decl.offsets[1];
|
|
if (isEnabled) {
|
|
// Enable it. First see if the comment start can be deleted.
|
|
let 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.
|
|
let 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 = unescapeCSSComment(newText).trimRight();
|
|
this.result += this.sanitizeText(newText, index) + ";";
|
|
|
|
// See if the comment end can be deleted.
|
|
let 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.
|
|
let declText = this.inputString.substring(decl.offsets[0],
|
|
decl.offsets[1]);
|
|
this.result += "/*" + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
|
|
" " + escapeCSSComment(declText) + " */";
|
|
}
|
|
this.completeCopying(copyOffset);
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
getDefaultIndentation: function () {
|
|
return this.rule.parentStyleSheet.guessIndentation();
|
|
},
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
internalCreateProperty: Task.async(function* (index, name, value, priority, enabled) {
|
|
this.completeInitialization(index);
|
|
let newIndentation = "";
|
|
if (this.hasNewLine) {
|
|
if (this.declarations.length > 0) {
|
|
newIndentation = this.getIndentation(this.inputString,
|
|
this.declarations[0].offsets[0]);
|
|
} else if (this.defaultIndentation) {
|
|
newIndentation = this.defaultIndentation;
|
|
} else {
|
|
newIndentation = yield 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) {
|
|
let 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) + " */";
|
|
}
|
|
|
|
this.result += newIndentation + newText;
|
|
if (this.hasNewLine) {
|
|
this.result += "\n";
|
|
}
|
|
this.result += savedWhitespace;
|
|
|
|
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: function (index, name, value, priority, enabled) {
|
|
this.editPromise = this.internalCreateProperty(index, name, value,
|
|
priority, enabled);
|
|
},
|
|
|
|
/**
|
|
* 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: function (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]);
|
|
},
|
|
|
|
/**
|
|
* Remove a declaration.
|
|
*
|
|
* @param {Number} index index of the property in the rule.
|
|
* @param {String} name the name of the property to remove
|
|
*/
|
|
removeProperty: function (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) {
|
|
let nlOffset = this.skipWhitespaceBackward(this.result,
|
|
this.decl.offsets[0]);
|
|
if (nlOffset < 0 || this.result[nlOffset] === "\r" ||
|
|
this.result[nlOffset] === "\n") {
|
|
let trailingText = this.inputString.substring(copyOffset);
|
|
let match = BLANK_LINE_RX.exec(trailingText);
|
|
if (match) {
|
|
this.result = this.result.substring(0, nlOffset + 1);
|
|
copyOffset += match[0].length;
|
|
}
|
|
}
|
|
}
|
|
this.completeCopying(copyOffset);
|
|
},
|
|
|
|
/**
|
|
* 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: function (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: function () {
|
|
return promise.resolve(this.editPromise).then(() => {
|
|
return this.rule.setRuleText(this.result);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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: function () {
|
|
return {changed: this.changedDeclarations, text: this.result};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Returns an array of the parsed CSS selector value and type given a string.
|
|
*
|
|
* The components making up the CSS selector can be extracted into 3 different
|
|
* types: element, attribute and pseudoclass. The object that is appended to
|
|
* the returned array contains the value related to one of the 3 types described
|
|
* along with the actual type.
|
|
*
|
|
* The following are the 3 types that can be returned in the object signature:
|
|
* (1) SELECTOR_ATTRIBUTE
|
|
* (2) SELECTOR_ELEMENT
|
|
* (3) SELECTOR_PSEUDO_CLASS
|
|
*
|
|
* @param {String} value
|
|
* The CSS selector text.
|
|
* @return {Array} an array of objects with the following signature:
|
|
* [{ "value": string, "type": integer }, ...]
|
|
*/
|
|
function parsePseudoClassesAndAttributes(value) {
|
|
if (!value) {
|
|
throw new Error("empty input string");
|
|
}
|
|
|
|
let tokens = cssTokenizer(value);
|
|
let result = [];
|
|
let current = "";
|
|
let functionCount = 0;
|
|
let hasAttribute = false;
|
|
let hasColon = false;
|
|
|
|
for (let token of tokens) {
|
|
if (token.tokenType === "ident") {
|
|
current += value.substring(token.startOffset, token.endOffset);
|
|
|
|
if (hasColon && !functionCount) {
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_PSEUDO_CLASS });
|
|
}
|
|
|
|
current = "";
|
|
hasColon = false;
|
|
}
|
|
} else if (token.tokenType === "symbol" && token.text === ":") {
|
|
if (!hasColon) {
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_ELEMENT });
|
|
}
|
|
|
|
current = "";
|
|
hasColon = true;
|
|
}
|
|
|
|
current += token.text;
|
|
} else if (token.tokenType === "function") {
|
|
current += value.substring(token.startOffset, token.endOffset);
|
|
functionCount++;
|
|
} else if (token.tokenType === "symbol" && token.text === ")") {
|
|
current += token.text;
|
|
|
|
if (hasColon && functionCount == 1) {
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_PSEUDO_CLASS });
|
|
}
|
|
|
|
current = "";
|
|
functionCount--;
|
|
hasColon = false;
|
|
} else {
|
|
functionCount--;
|
|
}
|
|
} else if (token.tokenType === "symbol" && token.text === "[") {
|
|
if (!hasAttribute && !functionCount) {
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_ELEMENT });
|
|
}
|
|
|
|
current = "";
|
|
hasAttribute = true;
|
|
}
|
|
|
|
current += token.text;
|
|
} else if (token.tokenType === "symbol" && token.text === "]") {
|
|
current += token.text;
|
|
|
|
if (hasAttribute && !functionCount) {
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_ATTRIBUTE });
|
|
}
|
|
|
|
current = "";
|
|
hasAttribute = false;
|
|
}
|
|
} else {
|
|
current += value.substring(token.startOffset, token.endOffset);
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
result.push({ value: current, type: SELECTOR_ELEMENT });
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Expects a single CSS value to be passed as the input and parses the value
|
|
* and priority.
|
|
*
|
|
* @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.
|
|
* @param {String} value
|
|
* The value from the text editor.
|
|
* @return {Object} an object with 'value' and 'priority' properties.
|
|
*/
|
|
function parseSingleValue(isCssPropertyKnown, value) {
|
|
let declaration = parseDeclarations(isCssPropertyKnown,
|
|
"a: " + value + ";")[0];
|
|
return {
|
|
value: declaration ? declaration.value : "",
|
|
priority: declaration ? declaration.priority : ""
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert an angle value to degree.
|
|
*
|
|
* @param {Number} angleValue The angle value.
|
|
* @param {CSS_ANGLEUNIT} angleUnit The angleValue's angle unit.
|
|
* @return {Number} An angle value in degree.
|
|
*/
|
|
function getAngleValueInDegrees(angleValue, angleUnit) {
|
|
switch (angleUnit) {
|
|
case CSS_ANGLEUNIT.deg:
|
|
return angleValue;
|
|
case CSS_ANGLEUNIT.grad:
|
|
return angleValue * 0.9;
|
|
case CSS_ANGLEUNIT.rad:
|
|
return angleValue * 180 / Math.PI;
|
|
case CSS_ANGLEUNIT.turn:
|
|
return angleValue * 360;
|
|
default:
|
|
throw new Error("No matched angle unit.");
|
|
}
|
|
}
|
|
|
|
exports.cssTokenizer = cssTokenizer;
|
|
exports.cssTokenizerWithLineColumn = cssTokenizerWithLineColumn;
|
|
exports.escapeCSSComment = escapeCSSComment;
|
|
// unescapeCSSComment is exported for testing.
|
|
exports._unescapeCSSComment = unescapeCSSComment;
|
|
exports.parseDeclarations = parseDeclarations;
|
|
exports.parseNamedDeclarations = parseNamedDeclarations;
|
|
// parseCommentDeclarations is exported for testing.
|
|
exports._parseCommentDeclarations = parseCommentDeclarations;
|
|
exports.RuleRewriter = RuleRewriter;
|
|
exports.parsePseudoClassesAndAttributes = parsePseudoClassesAndAttributes;
|
|
exports.parseSingleValue = parseSingleValue;
|
|
exports.getAngleValueInDegrees = getAngleValueInDegrees;
|