changes.js (8807B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 loader.lazyRequireGetter( 7 this, 8 "getTabPrefs", 9 "resource://devtools/shared/indentation.js", 10 true 11 ); 12 13 const { 14 getSourceForDisplay, 15 } = require("resource://devtools/client/inspector/changes/utils/changes-utils.js"); 16 17 /** 18 * In the Redux state, changed CSS rules are grouped by source (stylesheet) and stored in 19 * a single level array, regardless of nesting. 20 * This method returns a nested tree structure of the changed CSS rules so the React 21 * consumer components can traverse it easier when rendering the nested CSS rules view. 22 * Keeping this interface updated allows the Redux state structure to change without 23 * affecting the consumer components. 24 * 25 * @param {object} state 26 * Redux slice for tracked changes. 27 * @param {object} filter 28 * Object with optional filters to use. Has the following properties: 29 * - sourceIds: {Array} 30 * Use only subtrees of sources matching source ids from this array. 31 * - ruleIds: {Array} 32 * Use only rules matching rule ids from this array. If the array includes ids 33 * of ancestor rules (@media, @supports), their nested rules will be included. 34 * @return {object} 35 */ 36 function getChangesTree(state, filter = {}) { 37 // Use or assign defaults of sourceId and ruleId arrays by which to filter the tree. 38 const { sourceIds: sourceIdsFilter = [], ruleIds: rulesIdsFilter = [] } = 39 filter; 40 /** 41 * Recursively replace a rule's array of child rule ids with the referenced child rules. 42 * Mark visited rules so as not to handle them (and their children) again. 43 * 44 * Returns the rule object with expanded children or null if previously visited. 45 * 46 * @param {string} ruleId 47 * @param {object} rule 48 * @param {Array} rules 49 * @param {Set} visitedRules 50 * @return {object | null} 51 */ 52 function expandRuleChildren(ruleId, rule, rules, visitedRules) { 53 if (visitedRules.has(ruleId)) { 54 return null; 55 } 56 57 visitedRules.add(ruleId); 58 59 return { 60 ...rule, 61 children: rule.children.map(childRuleId => 62 expandRuleChildren(childRuleId, rules[childRuleId], rules, visitedRules) 63 ), 64 }; 65 } 66 67 return Object.entries(state) 68 .filter(([sourceId]) => { 69 // Use only matching sources if an array to filter by was provided. 70 if (sourceIdsFilter.length) { 71 return sourceIdsFilter.includes(sourceId); 72 } 73 74 return true; 75 }) 76 .reduce((sourcesObj, [sourceId, source]) => { 77 const { rules } = source; 78 // Log of visited rules in this source. Helps avoid duplication when traversing the 79 // descendant rule tree. This Set is unique per source. It will be passed down to 80 // be populated with ids of rules once visited. This ensures that only visited rules 81 // unique to this source will be skipped and prevents skipping identical rules from 82 // other sources (ex: rules with the same selector and the same index). 83 const visitedRules = new Set(); 84 85 // Build a new collection of sources keyed by source id. 86 sourcesObj[sourceId] = { 87 ...source, 88 // Build a new collection of rules keyed by rule id. 89 rules: Object.entries(rules) 90 .filter(([ruleId]) => { 91 // Use only matching rules if an array to filter by was provided. 92 if (rulesIdsFilter.length) { 93 return rulesIdsFilter.includes(ruleId); 94 } 95 96 return true; 97 }) 98 .reduce((rulesObj, [ruleId, rule]) => { 99 // Expand the rule's array of child rule ids with the referenced child rules. 100 // Skip exposing null values which mean the rule was previously visited 101 // as part of an ancestor descendant tree. 102 const expandedRule = expandRuleChildren( 103 ruleId, 104 rule, 105 rules, 106 visitedRules 107 ); 108 if (expandedRule !== null) { 109 rulesObj[ruleId] = expandedRule; 110 } 111 112 return rulesObj; 113 }, {}), 114 }; 115 116 return sourcesObj; 117 }, {}); 118 } 119 120 /** 121 * Build the CSS text of a stylesheet with the changes aggregated in the Redux state. 122 * If filters for rule id or source id are provided, restrict the changes to the matching 123 * sources and rules. 124 * 125 * Code comments with the source origin are put above of the CSS rule (or group of 126 * rules). Removed CSS declarations are written commented out. Added CSS declarations are 127 * written as-is. 128 * 129 * @param {object} state 130 * Redux slice for tracked changes. 131 * @param {object} filter 132 * Object with optional source and rule filters. See getChangesTree() 133 * @return {string} 134 * CSS stylesheet text. 135 */ 136 137 // For stylesheet sources, the stylesheet filename and full path are used: 138 // 139 // /* styles.css | https://example.com/styles.css */ 140 // 141 // .selector { 142 // /* property: oldvalue; */ 143 // property: value; 144 // } 145 146 // For inline stylesheet sources, the stylesheet index and host document URL are used: 147 // 148 // /* Inline | https://example.com */ 149 // 150 // .selector { 151 // /* property: oldvalue; */ 152 // property: value; 153 // } 154 155 // For element style attribute sources, the unique selector generated for the element 156 // and the host document URL are used: 157 // 158 // /* Element (div) | https://example.com */ 159 // 160 // div:nth-child(1) { 161 // /* property: oldvalue; */ 162 // property: value; 163 // } 164 function getChangesStylesheet(state, filter) { 165 const changeTree = getChangesTree(state, filter); 166 // Get user prefs about indentation style. 167 const { indentUnit, indentWithTabs } = getTabPrefs(); 168 const indentChar = indentWithTabs 169 ? "\t".repeat(indentUnit) 170 : " ".repeat(indentUnit); 171 172 /** 173 * If the rule has just one item in its array of selector versions, return it as-is. 174 * If it has more than one, build a string using the first selector commented-out 175 * and the last selector as-is. This indicates that a rule's selector has changed. 176 * 177 * @param {Array} selectors 178 * History of selector versions if changed over time. 179 * Array with a single item (the original selector) if never changed. 180 * @param {number} level 181 * Level of nesting within a CSS rule tree. 182 * @return {string} 183 */ 184 function writeSelector(selectors = [], level) { 185 const indent = indentChar.repeat(level); 186 let selectorText; 187 switch (selectors.length) { 188 case 0: 189 selectorText = ""; 190 break; 191 case 1: 192 selectorText = `${indent}${selectors[0]}`; 193 break; 194 default: 195 selectorText = 196 `${indent}/* ${selectors[0]} { */\n` + 197 `${indent}${selectors[selectors.length - 1]}`; 198 } 199 200 return selectorText; 201 } 202 203 function writeRule(ruleId, rule, level) { 204 // Write nested rules, if any. 205 let ruleBody = rule.children.reduce((str, childRule) => { 206 str += writeRule(childRule.ruleId, childRule, level + 1); 207 return str; 208 }, ""); 209 210 // Write changed CSS declarations. 211 ruleBody += writeDeclarations(rule.remove, rule.add, level + 1); 212 213 const indent = indentChar.repeat(level); 214 const selectorText = writeSelector(rule.selectors, level); 215 return `\n${selectorText} {${ruleBody}\n${indent}}`; 216 } 217 218 function writeDeclarations(remove = [], add = [], level) { 219 const indent = indentChar.repeat(level); 220 const removals = remove 221 // Sort declarations in the order in which they exist in the original CSS rule. 222 .sort((a, b) => a.index > b.index) 223 .reduce((str, { property, value }) => { 224 str += `\n${indent}/* ${property}: ${value}; */`; 225 return str; 226 }, ""); 227 228 const additions = add 229 // Sort declarations in the order in which they exist in the original CSS rule. 230 .sort((a, b) => a.index > b.index) 231 .reduce((str, { property, value }) => { 232 str += `\n${indent}${property}: ${value};`; 233 return str; 234 }, ""); 235 236 return removals + additions; 237 } 238 239 // Iterate through all sources in the change tree and build a CSS stylesheet string. 240 return Object.values(changeTree).reduce((stylesheetText, source) => { 241 const { href, rules } = source; 242 // Write code comment with source origin 243 stylesheetText += `\n/* ${getSourceForDisplay(source)} | ${href} */\n`; 244 // Write CSS rules 245 stylesheetText += Object.entries(rules).reduce((str, [ruleId, rule]) => { 246 // Add a new like only after top-level rules (level == 0) 247 str += writeRule(ruleId, rule, 0) + "\n"; 248 return str; 249 }, ""); 250 251 return stylesheetText; 252 }, ""); 253 } 254 255 module.exports = { 256 getChangesTree, 257 getChangesStylesheet, 258 };