/* 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 MAX_DATA_URL_LENGTH = 40; /** * Provide access to the style information in a page. * CssLogic uses the standard DOM API, and the Gecko InspectorUtils API to * access styling information in the page, and present this to the user in a way * that helps them understand: * - why their expectations may not have been fulfilled * - how browsers process CSS * @constructor */ loader.lazyRequireGetter( this, "getCSSLexer", "resource://devtools/shared/css/lexer.js", true ); loader.lazyRequireGetter( this, "getTabPrefs", "resource://devtools/shared/indentation.js", true ); const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); const styleInspectorL10N = new LocalizationHelper( "devtools/shared/locales/styleinspector.properties" ); /** * Special values for filter, in addition to an href these values can be used */ exports.FILTER = { // show properties for all user style sheets. USER: "user", // USER, plus user-agent (i.e. browser) style sheets UA: "ua", }; /** * Each rule has a status, the bigger the number, the better placed it is to * provide styling information. * * These statuses are localized inside the styleinspector.properties * string bundle. * @see csshtmltree.js RuleView._cacheStatusNames() */ exports.STATUS = { BEST: 3, MATCHED: 2, PARENT_MATCH: 1, UNMATCHED: 0, UNKNOWN: -1, }; /** * Mapping of CSS at-Rule className to CSSRule type name. */ exports.CSSAtRuleClassNameType = { CSSContainerRule: "container", CSSCounterStyleRule: "counter-style", CSSDocumentRule: "document", CSSFontFaceRule: "font-face", CSSFontFeatureValuesRule: "font-feature-values", CSSImportRule: "import", CSSKeyframeRule: "keyframe", CSSKeyframesRule: "keyframes", CSSLayerBlockRule: "layer", CSSMediaRule: "media", CSSNamespaceRule: "namespace", CSSPageRule: "page", CSSSupportsRule: "supports", }; /** * Get Rule type as human-readable string (ex: "@media", "@container", …) * * @param {CSSRule} cssRule * @returns {String} */ exports.getCSSAtRuleTypeName = function (cssRule) { const ruleClassName = ChromeUtils.getClassName(cssRule); const atRuleTypeName = exports.CSSAtRuleClassNameType[ruleClassName]; if (atRuleTypeName) { return "@" + atRuleTypeName; } return ""; }; /** * Lookup a l10n string in the shared styleinspector string bundle. * * @param {String} name * The key to lookup. * @returns {String} A localized version of the given key. */ exports.l10n = name => styleInspectorL10N.getStr(name); exports.l10nFormatStr = (name, ...args) => styleInspectorL10N.getFormatStr(name, ...args); /** * Is the given property sheet an author stylesheet? * * @param {CSSStyleSheet} sheet a stylesheet * @return {boolean} true if the given stylesheet is an author stylesheet, * false otherwise. */ exports.isAuthorStylesheet = function (sheet) { return sheet.parsingMode === "author"; }; /** * Is the given property sheet a user stylesheet? * * @param {CSSStyleSheet} sheet a stylesheet * @return {boolean} true if the given stylesheet is a user stylesheet, * false otherwise. */ exports.isUserStylesheet = function (sheet) { return sheet.parsingMode === "user"; }; /** * Is the given property sheet a agent stylesheet? * * @param {CSSStyleSheet} sheet a stylesheet * @return {boolean} true if the given stylesheet is a agent stylesheet, * false otherwise. */ exports.isAgentStylesheet = function (sheet) { return sheet.parsingMode === "agent"; }; /** * Return a shortened version of a style sheet's source. * * @param {CSSStyleSheet} sheet the DOM object for the style sheet. */ exports.shortSource = function (sheet) { if (!sheet) { return exports.l10n("rule.sourceInline"); } if (!sheet.href) { return exports.l10n( sheet.constructed ? "rule.sourceConstructed" : "rule.sourceInline" ); } let name = sheet.href; // If the sheet is a data URL, return a trimmed version of it. const dataUrl = sheet.href.trim().match(/^data:.*?,((?:.|\r|\n)*)$/); if (dataUrl) { name = dataUrl[1].length > MAX_DATA_URL_LENGTH ? `${dataUrl[1].substr(0, MAX_DATA_URL_LENGTH - 1)}…` : dataUrl[1]; } else { // We try, in turn, the filename, filePath, query string, whole thing let url = {}; try { url = new URL(sheet.href); } catch (ex) { // Some UA-provided stylesheets are not valid URLs. } if (url.pathname) { const index = url.pathname.lastIndexOf("/"); if (index !== -1 && index < url.pathname.length) { name = url.pathname.slice(index + 1); } else { name = url.pathname; } } else if (url.query) { name = url.query; } } try { name = decodeURIComponent(name); } catch (e) { // This may still fail if the URL contains invalid % numbers (for ex) } return name; }; /** * Return the style sheet's source, handling element, inline and constructed stylesheets. * * @param {CSSStyleSheet} sheet the DOM object for the style sheet. */ exports.longSource = function (sheet) { if (!sheet) { return exports.l10n("rule.sourceInline"); } if (!sheet.href) { return exports.l10n( sheet.constructed ? "rule.sourceConstructed" : "rule.sourceInline" ); } return sheet.href; }; const TAB_CHARS = "\t"; const SPACE_CHARS = " "; function getLineCountInComments(text) { let count = 0; for (const comment of text.match(/\/\*(?:.|\n)*?\*\//gm) || []) { count += comment.split("\n").length + 1; } return count; } /** * Prettify minified CSS text. * This prettifies CSS code where there is no indentation in usual places while * keeping original indentation as-is elsewhere. * * Returns an object with the resulting prettified source and a list of mappings of * token positions between the original and the prettified source. Each single mapping * is an object that looks like this: * * { * original: {line: {Number}, column: {Number}}, * generated: {line: {Number}, column: {Number}}, * } * * @param {String} text * The CSS source to prettify. * @param {Number} ruleCount * The number of CSS rules expected in the CSS source. * Set to null to force the text to be pretty-printed. * * @return {Object} * Object with the prettified source and source mappings. * { * result: {String} // Prettified source * mappings: {Array} // List of objects with mappings for lines and columns * // between the original source and prettified source * } */ // eslint-disable-next-line complexity function prettifyCSS(text, ruleCount) { if (prettifyCSS.LINE_SEPARATOR == null) { const os = Services.appinfo.OS; prettifyCSS.LINE_SEPARATOR = os === "WINNT" ? "\r\n" : "\n"; } // Stylesheets may start and end with HTML comment tags (possibly with whitespaces // before and after). Remove those first. Don't do anything there aren't any. const trimmed = text.trim(); if (trimmed.startsWith("$/, "").trim(); } const originalText = text; text = text.trim(); // don't attempt to prettify if there's more than one line per rule, excluding comments. const lineCount = text.split("\n").length - 1 - getLineCountInComments(text); if (ruleCount !== null && lineCount >= ruleCount) { return { result: originalText, mappings: [] }; } // We reformat the text using a simple state machine. The // reformatting preserves most of the input text, changing only // whitespace. The rules are: // // * After a "{" or ";" symbol, ensure there is a newline and // indentation before the next non-comment, non-whitespace token. // * Additionally after a "{" symbol, increase the indentation. // * A "}" symbol ensures there is a preceding newline, and // decreases the indentation level. // * Ensure there is whitespace before a "{". // // This approach can be confused sometimes, but should do ok on a // minified file. let indent = ""; let indentLevel = 0; const tokens = getCSSLexer(text); // List of mappings of token positions from original source to prettified source. const mappings = []; // Line and column offsets used to shift the token positions after prettyfication. let lineOffset = 0; let columnOffset = 0; let indentOffset = 0; let result = ""; let pushbackToken = undefined; // A helper function that reads tokens, looking for the next // non-comment, non-whitespace token. Comment and whitespace tokens // are appended to |result|. If this encounters EOF, it returns // null. Otherwise it returns the last whitespace token that was // seen. This function also updates |pushbackToken|. const readUntilSignificantToken = () => { while (true) { const token = tokens.nextToken(); if (!token || token.tokenType !== "whitespace") { pushbackToken = token; return token; } // Saw whitespace. Before committing to it, check the next // token. const nextToken = tokens.nextToken(); if (!nextToken || nextToken.tokenType !== "comment") { pushbackToken = nextToken; return token; } // Saw whitespace + comment. Update the result and continue. result = result + text.substring(token.startOffset, nextToken.endOffset); } }; // State variables for readUntilNewlineNeeded. // // Starting index of the accumulated tokens. let startIndex; // Ending index of the accumulated tokens. let endIndex; // True if any non-whitespace token was seen. let anyNonWS; // True if the terminating token is "}". let isCloseBrace; // True if the token just before the terminating token was // whitespace. let lastWasWS; // True if the current token is inside a CSS selector. let isInSelector = true; // True if the current token is inside an at-rule definition. let isInAtRuleDefinition = false; // A helper function that reads tokens until there is a reason to // insert a newline. This updates the state variables as needed. // If this encounters EOF, it returns null. Otherwise it returns // the final token read. Note that if the returned token is "{", // then it will not be included in the computed start/end token // range. This is used to handle whitespace insertion before a "{". const readUntilNewlineNeeded = () => { let token; while (true) { if (pushbackToken) { token = pushbackToken; pushbackToken = undefined; } else { token = tokens.nextToken(); } if (!token) { endIndex = text.length; break; } const line = tokens.lineNumber; const column = tokens.columnNumber; mappings.push({ original: { line, column, }, generated: { line: lineOffset + line, column: columnOffset, }, }); // Shift the column offset for the next token by the current token's length. columnOffset += token.endOffset - token.startOffset; if (token.tokenType === "at") { isInAtRuleDefinition = true; } // A "}" symbol must be inserted later, to deal with indentation // and newline. if (token.tokenType === "symbol" && token.text === "}") { isInSelector = true; isCloseBrace = true; break; } else if (token.tokenType === "symbol" && token.text === "{") { if (isInAtRuleDefinition) { isInAtRuleDefinition = false; } else { isInSelector = false; } break; } if (token.tokenType !== "whitespace") { anyNonWS = true; } if (startIndex === undefined) { startIndex = token.startOffset; } endIndex = token.endOffset; if (token.tokenType === "symbol" && token.text === ";") { break; } if ( token.tokenType === "symbol" && token.text === "," && isInSelector && !isInAtRuleDefinition ) { break; } lastWasWS = token.tokenType === "whitespace"; } return token; }; // Get preference of the user regarding what to use for indentation, // spaces or tabs. const tabPrefs = getTabPrefs(); const baseIndentString = tabPrefs.indentWithTabs ? TAB_CHARS : SPACE_CHARS.repeat(tabPrefs.indentUnit); while (true) { // Set the initial state. startIndex = undefined; endIndex = undefined; anyNonWS = false; isCloseBrace = false; lastWasWS = false; // Read tokens until we see a reason to insert a newline. let token = readUntilNewlineNeeded(); // Append any saved up text to the result, applying indentation. if (startIndex !== undefined) { if (isCloseBrace && !anyNonWS) { // If we saw only whitespace followed by a "}", then we don't // need anything here. } else { result = result + indent + text.substring(startIndex, endIndex); if (isCloseBrace) { result += prettifyCSS.LINE_SEPARATOR; lineOffset = lineOffset + 1; } } } if (isCloseBrace) { // Even if the stylesheet contains extra closing braces, the indent level should // remain > 0. indentLevel = Math.max(0, indentLevel - 1); indent = baseIndentString.repeat(indentLevel); // FIXME: This is incorrect and should be fixed in Bug 1839297 if (tabPrefs.indentWithTabs) { indentOffset = 4 * indentLevel; } else { indentOffset = 1 * indentLevel; } result = result + indent + "}"; } if (!token) { break; } if (token.tokenType === "symbol" && token.text === "{") { if (!lastWasWS) { result += " "; columnOffset++; } result += "{"; indentLevel++; indent = baseIndentString.repeat(indentLevel); indentOffset = indent.length; // FIXME: This is incorrect and should be fixed in Bug 1839297 if (tabPrefs.indentWithTabs) { indentOffset = 4 * indentLevel; } else { indentOffset = 1 * indentLevel; } } // Now it is time to insert a newline. However first we want to // deal with any trailing comments. token = readUntilSignificantToken(); // "Early" bail-out if the text does not appear to be minified. // Here we ignore the case where whitespace appears at the end of // the text. if ( ruleCount !== null && pushbackToken && token && token.tokenType === "whitespace" && /\n/g.test(text.substring(token.startOffset, token.endOffset)) ) { return { result: originalText, mappings: [] }; } // Finally time for that newline. result = result + prettifyCSS.LINE_SEPARATOR; // Update line and column offsets for the new line. lineOffset = lineOffset + 1; columnOffset = 0 + indentOffset; // Maybe we hit EOF. if (!pushbackToken) { break; } } return { result, mappings }; } exports.prettifyCSS = prettifyCSS; /** * Given a node, check to see if it is a ::marker, ::before, or ::after element. * If so, return the node that is accessible from within the document * (the parent of the anonymous node), along with which pseudo element * it was. Otherwise, return the node itself. * * @returns {Object} * - {DOMNode} node The non-anonymous node * - {string} pseudo One of '::marker', '::before', '::after', or null. */ function getBindingElementAndPseudo(node) { let bindingElement = node; let pseudo = null; if (node.nodeName == "_moz_generated_content_marker") { bindingElement = node.parentNode; pseudo = "::marker"; } else if (node.nodeName == "_moz_generated_content_before") { bindingElement = node.parentNode; pseudo = "::before"; } else if (node.nodeName == "_moz_generated_content_after") { bindingElement = node.parentNode; pseudo = "::after"; } return { bindingElement, pseudo, }; } exports.getBindingElementAndPseudo = getBindingElementAndPseudo; /** * Returns css style rules for a given a node. * This function can handle ::before or ::after pseudo element as well as * normal element. */ function getCSSStyleRules(node) { const { bindingElement, pseudo } = getBindingElementAndPseudo(node); const rules = InspectorUtils.getCSSStyleRules(bindingElement, pseudo); return rules; } exports.getCSSStyleRules = getCSSStyleRules; /** * Returns true if the given node has visited state. */ function hasVisitedState(node) { if (!node) { return false; } // ElementState::VISITED const ELEMENT_STATE_VISITED = 1 << 18; return ( !!(InspectorUtils.getContentState(node) & ELEMENT_STATE_VISITED) || InspectorUtils.hasPseudoClassLock(node, ":visited") ); } exports.hasVisitedState = hasVisitedState; /** * Find the position of [element] in [nodeList]. * @returns an index of the match, or -1 if there is no match */ function positionInNodeList(element, nodeList) { for (let i = 0; i < nodeList.length; i++) { if (element === nodeList[i]) { return i; } } return -1; } /** * For a provided node, find the appropriate container/node couple so that * container.contains(node) and a CSS selector can be created from the * container to the node. */ function findNodeAndContainer(node) { const shadowRoot = node.containingShadowRoot; while (node?.isNativeAnonymous) { node = node.parentNode; } if (shadowRoot) { // If the node is under a shadow root, the shadowRoot contains the node and // we can find the node via shadowRoot.querySelector(path). return { containingDocOrShadow: shadowRoot, node, }; } // Otherwise, get the root binding parent to get a non anonymous element that // will be accessible from the ownerDocument. return { containingDocOrShadow: node.ownerDocument, node, }; } /** * Find a unique CSS selector for a given element * @returns a string such that: * - ele.containingDocOrShadow.querySelector(reply) === ele * - ele.containingDocOrShadow.querySelectorAll(reply).length === 1 */ const findCssSelector = function (ele) { const { node, containingDocOrShadow } = findNodeAndContainer(ele); ele = node; if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { // findCssSelector received element not inside container. return ""; } const cssEscape = ele.ownerGlobal.CSS.escape; // document.querySelectorAll("#id") returns multiple if elements share an ID if ( ele.id && containingDocOrShadow.querySelectorAll("#" + cssEscape(ele.id)).length === 1 ) { return "#" + cssEscape(ele.id); } // Inherently unique by tag name const tagName = ele.localName; if (tagName === "html") { return "html"; } if (tagName === "head") { return "head"; } if (tagName === "body") { return "body"; } // We might be able to find a unique class name let selector, index, matches; for (let i = 0; i < ele.classList.length; i++) { // Is this className unique by itself? selector = "." + cssEscape(ele.classList.item(i)); matches = containingDocOrShadow.querySelectorAll(selector); if (matches.length === 1) { return selector; } // Maybe it's unique with a tag name? selector = cssEscape(tagName) + selector; matches = containingDocOrShadow.querySelectorAll(selector); if (matches.length === 1) { return selector; } // Maybe it's unique using a tag name and nth-child index = positionInNodeList(ele, ele.parentNode.children) + 1; selector = selector + ":nth-child(" + index + ")"; matches = containingDocOrShadow.querySelectorAll(selector); if (matches.length === 1) { return selector; } } // Not unique enough yet. index = positionInNodeList(ele, ele.parentNode.children) + 1; selector = cssEscape(tagName) + ":nth-child(" + index + ")"; if (ele.parentNode !== containingDocOrShadow) { selector = findCssSelector(ele.parentNode) + " > " + selector; } return selector; }; exports.findCssSelector = findCssSelector; /** * Get the full CSS path for a given element. * * @returns a string that can be used as a CSS selector for the element. It might not * match the element uniquely. It does however, represent the full path from the root * node to the element. */ function getCssPath(ele) { const { node, containingDocOrShadow } = findNodeAndContainer(ele); ele = node; if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { // getCssPath received element not inside container. return ""; } const nodeGlobal = ele.ownerGlobal.Node; const getElementSelector = element => { if (!element.localName) { return ""; } let label = element.nodeName == element.nodeName.toUpperCase() ? element.localName.toLowerCase() : element.localName; if (element.id) { label += "#" + element.id; } if (element.classList) { for (const cl of element.classList) { label += "." + cl; } } return label; }; const paths = []; while (ele) { if (!ele || ele.nodeType !== nodeGlobal.ELEMENT_NODE) { break; } paths.splice(0, 0, getElementSelector(ele)); ele = ele.parentNode; } return paths.length ? paths.join(" ") : ""; } exports.getCssPath = getCssPath; /** * Get the xpath for a given element. * * @param {DomNode} ele * @returns a string that can be used as an XPath to find the element uniquely. */ function getXPath(ele) { const { node, containingDocOrShadow } = findNodeAndContainer(ele); ele = node; if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { // getXPath received element not inside container. return ""; } // Create a short XPath for elements with IDs. if (ele.id) { return `//*[@id="${ele.id}"]`; } // Otherwise walk the DOM up and create a part for each ancestor. const parts = []; const nodeGlobal = ele.ownerGlobal.Node; // Use nodeName (instead of localName) so namespace prefix is included (if any). while (ele && ele.nodeType === nodeGlobal.ELEMENT_NODE) { let nbOfPreviousSiblings = 0; let hasNextSiblings = false; // Count how many previous same-name siblings the element has. let sibling = ele.previousSibling; while (sibling) { // Ignore document type declaration. if ( sibling.nodeType !== nodeGlobal.DOCUMENT_TYPE_NODE && sibling.nodeName == ele.nodeName ) { nbOfPreviousSiblings++; } sibling = sibling.previousSibling; } // Check if the element has at least 1 next same-name sibling. sibling = ele.nextSibling; while (sibling) { if (sibling.nodeName == ele.nodeName) { hasNextSiblings = true; break; } sibling = sibling.nextSibling; } const prefix = ele.prefix ? ele.prefix + ":" : ""; const nth = nbOfPreviousSiblings || hasNextSiblings ? `[${nbOfPreviousSiblings + 1}]` : ""; parts.push(prefix + ele.localName + nth); ele = ele.parentNode; } return parts.length ? "/" + parts.reverse().join("/") : ""; } exports.getXPath = getXPath; /** * Build up a regular expression that matches a CSS variable token. This is an * ident token that starts with two dashes "--". * * https://www.w3.org/TR/css-syntax-3/#ident-token-diagram */ var NON_ASCII = "[^\\x00-\\x7F]"; var ESCAPE = "\\\\[^\n\r]"; var VALID_CHAR = ["[_a-z0-9-]", NON_ASCII, ESCAPE].join("|"); var IS_VARIABLE_TOKEN = new RegExp(`^--(${VALID_CHAR})*$`, "i"); /** * Check that this is a CSS variable. * * @param {String} input * @return {Boolean} */ function isCssVariable(input) { return !!input.match(IS_VARIABLE_TOKEN); } exports.isCssVariable = isCssVariable;