diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/fronts/inspector | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/client/fronts/inspector.js | 282 | ||||
-rw-r--r-- | devtools/client/fronts/inspector/moz.build | 9 | ||||
-rw-r--r-- | devtools/client/fronts/inspector/rule-rewriter.js | 745 |
3 files changed, 1036 insertions, 0 deletions
diff --git a/devtools/client/fronts/inspector.js b/devtools/client/fronts/inspector.js new file mode 100644 index 0000000000..116993078b --- /dev/null +++ b/devtools/client/fronts/inspector.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Telemetry = require("resource://devtools/client/shared/telemetry.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + inspectorSpec, +} = require("resource://devtools/shared/specs/inspector.js"); + +loader.lazyRequireGetter( + this, + "captureScreenshot", + "resource://devtools/client/shared/screenshot.js", + true +); + +const TELEMETRY_EYEDROPPER_OPENED = "DEVTOOLS_EYEDROPPER_OPENED_COUNT"; +const TELEMETRY_EYEDROPPER_OPENED_MENU = + "DEVTOOLS_MENU_EYEDROPPER_OPENED_COUNT"; +const SHOW_ALL_ANONYMOUS_CONTENT_PREF = + "devtools.inspector.showAllAnonymousContent"; + +const telemetry = new Telemetry(); + +/** + * Client side of the inspector actor, which is used to create + * inspector-related actors, including the walker. + */ +class InspectorFront extends FrontClassWithSpec(inspectorSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._client = client; + this._highlighters = new Map(); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "inspectorActor"; + + // Map of highlighter types to unsettled promises to create a highlighter of that type + this._pendingGetHighlighterMap = new Map(); + + this.noopStylesheetListener = () => {}; + } + + // async initialization + async initialize() { + if (this.initialized) { + return this.initialized; + } + + // Watch STYLESHEET resources to fill the ResourceCommand cache. + // StyleRule front's `get parentStyleSheet()` will query the cache to + // retrieve the resource corresponding to the parent stylesheet of a rule. + const { resourceCommand } = this.targetFront.commands; + // Backup resourceCommand, targetFront.commands might be null in `destroy`. + this.resourceCommand = resourceCommand; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: this.noopStylesheetListener, + }); + + // Bail out if the inspector is closed while watchResources was pending + if (this.isDestroyed()) { + return null; + } + + this.initialized = await Promise.all([ + this._getWalker(), + this._getPageStyle(), + ]); + + return this.initialized; + } + + async _getWalker() { + const showAllAnonymousContent = Services.prefs.getBoolPref( + SHOW_ALL_ANONYMOUS_CONTENT_PREF + ); + this.walker = await this.getWalker({ + showAllAnonymousContent, + }); + + // We need to reparent the RootNode of remote iframe Walkers + // so that their parent is the NodeFront of the <iframe> + // element, coming from another process/target/WalkerFront. + await this.walker.reparentRemoteFrame(); + } + + hasHighlighter(type) { + return this._highlighters.has(type); + } + + async _getPageStyle() { + this.pageStyle = await super.getPageStyle(); + } + + async getCompatibilityFront() { + if (!this._compatibility) { + this._compatibility = await super.getCompatibility(); + } + + return this._compatibility; + } + + destroy() { + if (this.isDestroyed()) { + return; + } + this._compatibility = null; + + const { resourceCommand } = this; + resourceCommand.unwatchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: this.noopStylesheetListener, + }); + this.resourceCommand = null; + + this.walker = null; + + // CustomHighlighter fronts are managed by InspectorFront and so will be + // automatically destroyed. But we have to clear the `_highlighters` + // Map as well as explicitly call `finalize` request on all of them. + this.destroyHighlighters(); + super.destroy(); + } + + destroyHighlighters() { + for (const type of this._highlighters.keys()) { + if (this._highlighters.has(type)) { + const highlighter = this._highlighters.get(type); + if (!highlighter.isDestroyed()) { + highlighter.finalize(); + } + this._highlighters.delete(type); + } + } + } + + async getHighlighterByType(typeName) { + let highlighter = null; + try { + highlighter = await super.getHighlighterByType(typeName); + } catch (_) { + throw new Error( + "The target doesn't support " + + `creating highlighters by types or ${typeName} is unknown` + ); + } + return highlighter; + } + + getKnownHighlighter(type) { + return this._highlighters.get(type); + } + + /** + * Return a highlighter instance of the given type. + * If an instance was previously created, return it. Else, create and return a new one. + * + * Store a promise for the request to create a new highlighter. If another request + * comes in before that promise is resolved, wait for it to resolve and return the + * highlighter instance it resolved with instead of creating a new request. + * + * @param {String} type + * Highlighter type + * @return {Promise} + * Promise which resolves with a highlighter instance of the given type + */ + async getOrCreateHighlighterByType(type) { + let front = this._highlighters.get(type); + let pendingGetHighlighter = this._pendingGetHighlighterMap.get(type); + + if (!front && !pendingGetHighlighter) { + pendingGetHighlighter = (async () => { + const highlighter = await this.getHighlighterByType(type); + this._highlighters.set(type, highlighter); + this._pendingGetHighlighterMap.delete(type); + return highlighter; + })(); + + this._pendingGetHighlighterMap.set(type, pendingGetHighlighter); + } + + if (pendingGetHighlighter) { + front = await pendingGetHighlighter; + } + + return front; + } + + async pickColorFromPage(options) { + let screenshot = null; + + // @backward-compat { version 87 } ScreenshotContentActor was only added in 87. + // When connecting to older server, the eyedropper will use drawWindow + // to retrieve the screenshot of the page (that's a decent fallback, + // even if it doesn't handle remote frames). + if (this.targetFront.hasActor("screenshotContent")) { + try { + // We use the screenshot actors as it can retrieve an image of the current viewport, + // handling remote frame if need be. + const { data } = await captureScreenshot(this.targetFront, { + browsingContextID: this.targetFront.browsingContextID, + disableFlash: true, + ignoreDprForFileScale: true, + }); + screenshot = data; + } catch (e) { + // We simply log the error and still call pickColorFromPage as it will default to + // use drawWindow in order to get the screenshot of the page (that's a decent + // fallback, even if it doesn't handle remote frames). + console.error( + "Error occured when taking a screenshot for the eyedropper", + e + ); + } + } + + await super.pickColorFromPage({ + ...options, + screenshot, + }); + + if (options?.fromMenu) { + telemetry.getHistogramById(TELEMETRY_EYEDROPPER_OPENED_MENU).add(true); + } else { + telemetry.getHistogramById(TELEMETRY_EYEDROPPER_OPENED).add(true); + } + } + + /** + * Given a node grip, return a NodeFront on the right context. + * + * @param {Object} grip: The node grip. + * @returns {Promise<NodeFront|null>} A promise that resolves with a NodeFront or null + * if the NodeFront couldn't be created/retrieved. + */ + async getNodeFrontFromNodeGrip(grip) { + return this.getNodeActorFromContentDomReference(grip.contentDomReference); + } + + async getNodeActorFromContentDomReference(contentDomReference) { + const { browsingContextId } = contentDomReference; + // If the contentDomReference lives in the same browsing context id than the + // current one, we can directly use the current walker. + if (this.targetFront.browsingContextID === browsingContextId) { + return this.walker.getNodeActorFromContentDomReference( + contentDomReference + ); + } + + // If the contentDomReference has a different browsing context than the current one, + // we are either in Fission or in the Multiprocess Browser Toolbox, so we need to + // retrieve the walker of the WindowGlobalTarget. + // Get the target for this remote frame element + + // Tab and Process Descriptors expose a Watcher, which should be used to + // fetch the node's target. + let target; + const { watcherFront } = this.targetFront.commands; + if (watcherFront) { + target = await watcherFront.getWindowGlobalTarget(browsingContextId); + } else { + // For descriptors which don't expose a watcher (e.g. WebExtension) + // we used to call RootActor::getBrowsingContextDescriptor, but it was + // removed in FF77. + // Support for watcher in WebExtension descriptors is Bug 1644341. + throw new Error( + `Unable to call getNodeActorFromContentDomReference for ${this.targetFront.actorID}` + ); + } + const { walker } = await target.getFront("inspector"); + return walker.getNodeActorFromContentDomReference(contentDomReference); + } +} + +exports.InspectorFront = InspectorFront; +registerFront(InspectorFront); diff --git a/devtools/client/fronts/inspector/moz.build b/devtools/client/fronts/inspector/moz.build new file mode 100644 index 0000000000..de635f5947 --- /dev/null +++ b/devtools/client/fronts/inspector/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "rule-rewriter.js", +) diff --git a/devtools/client/fronts/inspector/rule-rewriter.js b/devtools/client/fronts/inspector/rule-rewriter.js new file mode 100644 index 0000000000..30d1cf88d2 --- /dev/null +++ b/devtools/client/fronts/inspector/rule-rewriter.js @@ -0,0 +1,745 @@ +/* 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 { getCSSLexer } = 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(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; + // 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); +} + +RuleRewriter.prototype = { + /** + * 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 = getCSSLexer(text); + + 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 }); + 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; + } + + // 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; + }; + + while (true) { + const 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 "{": + pushParen(token, "}"); + break; + + case "(": + pushParen(token, ")"); + break; + + case "[": + pushParen(token, "]"); + break; + + case "}": + case ")": + case "]": + // 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; + } + } else if (token.tokenType === "function") { + pushParen(token, ")"); + } + } + + // 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("", 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(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() { + if (!this.rule.parentStyleSheet) { + return null; + } + + const prefIndent = getIndentationFromPrefs(); + if (prefIndent) { + const { indentUnit, indentWithTabs } = prefIndent; + return indentWithTabs ? "\t" : " ".repeat(indentUnit); + } + + const styleSheetsFront = await this.rule.targetFront.getFront( + "stylesheets" + ); + 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) + + " */"; + } + + 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(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; |