Source code

Revision control

Copy as Markdown

Other Tools

/* 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";
loader.lazyRequireGetter(
this,
"getTabPrefs",
true
);
const {
getSourceForDisplay,
/**
* In the Redux state, changed CSS rules are grouped by source (stylesheet) and stored in
* a single level array, regardless of nesting.
* This method returns a nested tree structure of the changed CSS rules so the React
* consumer components can traverse it easier when rendering the nested CSS rules view.
* Keeping this interface updated allows the Redux state structure to change without
* affecting the consumer components.
*
* @param {Object} state
* Redux slice for tracked changes.
* @param {Object} filter
* Object with optional filters to use. Has the following properties:
* - sourceIds: {Array}
* Use only subtrees of sources matching source ids from this array.
* - ruleIds: {Array}
* Use only rules matching rule ids from this array. If the array includes ids
* of ancestor rules (@media, @supports), their nested rules will be included.
* @return {Object}
*/
function getChangesTree(state, filter = {}) {
// Use or assign defaults of sourceId and ruleId arrays by which to filter the tree.
const { sourceIds: sourceIdsFilter = [], ruleIds: rulesIdsFilter = [] } =
filter;
/**
* Recursively replace a rule's array of child rule ids with the referenced child rules.
* Mark visited rules so as not to handle them (and their children) again.
*
* Returns the rule object with expanded children or null if previously visited.
*
* @param {String} ruleId
* @param {Object} rule
* @param {Array} rules
* @param {Set} visitedRules
* @return {Object|null}
*/
function expandRuleChildren(ruleId, rule, rules, visitedRules) {
if (visitedRules.has(ruleId)) {
return null;
}
visitedRules.add(ruleId);
return {
...rule,
children: rule.children.map(childRuleId =>
expandRuleChildren(childRuleId, rules[childRuleId], rules, visitedRules)
),
};
}
return Object.entries(state)
.filter(([sourceId]) => {
// Use only matching sources if an array to filter by was provided.
if (sourceIdsFilter.length) {
return sourceIdsFilter.includes(sourceId);
}
return true;
})
.reduce((sourcesObj, [sourceId, source]) => {
const { rules } = source;
// Log of visited rules in this source. Helps avoid duplication when traversing the
// descendant rule tree. This Set is unique per source. It will be passed down to
// be populated with ids of rules once visited. This ensures that only visited rules
// unique to this source will be skipped and prevents skipping identical rules from
// other sources (ex: rules with the same selector and the same index).
const visitedRules = new Set();
// Build a new collection of sources keyed by source id.
sourcesObj[sourceId] = {
...source,
// Build a new collection of rules keyed by rule id.
rules: Object.entries(rules)
.filter(([ruleId]) => {
// Use only matching rules if an array to filter by was provided.
if (rulesIdsFilter.length) {
return rulesIdsFilter.includes(ruleId);
}
return true;
})
.reduce((rulesObj, [ruleId, rule]) => {
// Expand the rule's array of child rule ids with the referenced child rules.
// Skip exposing null values which mean the rule was previously visited
// as part of an ancestor descendant tree.
const expandedRule = expandRuleChildren(
ruleId,
rule,
rules,
visitedRules
);
if (expandedRule !== null) {
rulesObj[ruleId] = expandedRule;
}
return rulesObj;
}, {}),
};
return sourcesObj;
}, {});
}
/**
* Build the CSS text of a stylesheet with the changes aggregated in the Redux state.
* If filters for rule id or source id are provided, restrict the changes to the matching
* sources and rules.
*
* Code comments with the source origin are put above of the CSS rule (or group of
* rules). Removed CSS declarations are written commented out. Added CSS declarations are
* written as-is.
*
* @param {Object} state
* Redux slice for tracked changes.
* @param {Object} filter
* Object with optional source and rule filters. See getChangesTree()
* @return {String}
* CSS stylesheet text.
*/
// For stylesheet sources, the stylesheet filename and full path are used:
//
// /* styles.css | https://example.com/styles.css */
//
// .selector {
// /* property: oldvalue; */
// property: value;
// }
// For inline stylesheet sources, the stylesheet index and host document URL are used:
//
// /* Inline #1 | https://example.com */
//
// .selector {
// /* property: oldvalue; */
// property: value;
// }
// For element style attribute sources, the unique selector generated for the element
// and the host document URL are used:
//
// /* Element (div) | https://example.com */
//
// div:nth-child(1) {
// /* property: oldvalue; */
// property: value;
// }
function getChangesStylesheet(state, filter) {
const changeTree = getChangesTree(state, filter);
// Get user prefs about indentation style.
const { indentUnit, indentWithTabs } = getTabPrefs();
const indentChar = indentWithTabs
? "\t".repeat(indentUnit)
: " ".repeat(indentUnit);
/**
* If the rule has just one item in its array of selector versions, return it as-is.
* If it has more than one, build a string using the first selector commented-out
* and the last selector as-is. This indicates that a rule's selector has changed.
*
* @param {Array} selectors
* History of selector versions if changed over time.
* Array with a single item (the original selector) if never changed.
* @param {Number} level
* Level of nesting within a CSS rule tree.
* @return {String}
*/
function writeSelector(selectors = [], level) {
const indent = indentChar.repeat(level);
let selectorText;
switch (selectors.length) {
case 0:
selectorText = "";
break;
case 1:
selectorText = `${indent}${selectors[0]}`;
break;
default:
selectorText =
`${indent}/* ${selectors[0]} { */\n` +
`${indent}${selectors[selectors.length - 1]}`;
}
return selectorText;
}
function writeRule(ruleId, rule, level) {
// Write nested rules, if any.
let ruleBody = rule.children.reduce((str, childRule) => {
str += writeRule(childRule.ruleId, childRule, level + 1);
return str;
}, "");
// Write changed CSS declarations.
ruleBody += writeDeclarations(rule.remove, rule.add, level + 1);
const indent = indentChar.repeat(level);
const selectorText = writeSelector(rule.selectors, level);
return `\n${selectorText} {${ruleBody}\n${indent}}`;
}
function writeDeclarations(remove = [], add = [], level) {
const indent = indentChar.repeat(level);
const removals = remove
// Sort declarations in the order in which they exist in the original CSS rule.
.sort((a, b) => a.index > b.index)
.reduce((str, { property, value }) => {
str += `\n${indent}/* ${property}: ${value}; */`;
return str;
}, "");
const additions = add
// Sort declarations in the order in which they exist in the original CSS rule.
.sort((a, b) => a.index > b.index)
.reduce((str, { property, value }) => {
str += `\n${indent}${property}: ${value};`;
return str;
}, "");
return removals + additions;
}
// Iterate through all sources in the change tree and build a CSS stylesheet string.
return Object.values(changeTree).reduce((stylesheetText, source) => {
const { href, rules } = source;
// Write code comment with source origin
stylesheetText += `\n/* ${getSourceForDisplay(source)} | ${href} */\n`;
// Write CSS rules
stylesheetText += Object.entries(rules).reduce((str, [ruleId, rule]) => {
// Add a new like only after top-level rules (level == 0)
str += writeRule(ruleId, rule, 0) + "\n";
return str;
}, "");
return stylesheetText;
}, "");
}
module.exports = {
getChangesTree,
getChangesStylesheet,
};