UNPKG

css-sniff

Version:

Extracts matching CSS Rules (Selectors) that apply to elements

378 lines (318 loc) 12.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getCSSRules = getCSSRules; exports.deepMergeRules = deepMergeRules; exports.serializeCSSRules = serializeCSSRules; exports.splitSelectors = splitSelectors; /* CSS Sniff.js - Matthew Holloway (C) 2019 */ /* split-css-selector by Joakim Carlstein (C) 2015 for function 'splitSelectors'. Licenced under MIT */ /* @param children @param options @param matchedCSS @returns Promise<{ object }> 'matchedCSS' a variable to give to serializeCSSRules() */ async function getCSSRules(children, options, matchedCSS) { const matched = matchedCSS || {}; await Promise.all(children.map(async child => { await getCSSRulesByElement(child, options, matched); if (!options.ignoreChildren && child.childNodes) { await getCSSRules([...child.childNodes], options, matched); } })); return matched; } async function getCSSRulesByElement(el, options, matched) { if (!el.matches) { return; // presumed text node } const sheets = options.document.styleSheets || window.document.styleSheets; for (let i in sheets) { const sheet = sheets[i]; if (sheetIsAllowed(sheet, options)) { const matchedCSSRule = await _filterCSSRulesByElement(el, sheet.rules || sheet.cssRules, options, matched[i] || {}); if (matchedCSSRule) { matched[i] = matchedCSSRule; } } } } async function _filterCSSRulesByElement(el, rules, options, matched) { for (let i in rules) { const rule = rules[i]; if (rule.selectorText) { const sanitisedSelector = rule.selectorText.replace(/@charset.*?;/g, ""); if (ruleIsAllowed(sanitisedSelector, options)) { const selectors = splitSelectors(sanitisedSelector); await Promise.all(selectors.map(async selector => { let trimmedSelector, normalizedSelector; try { // Exceptions may be thrown about browser-specific // selectors such as // // input::-moz-something // input::-webkit-something // input::-ms-something // input:-moz-something // input:-webkit-something // input:-ms-something // // or potentially selectors without anything before // the ":", // // ::-moz-something // :not(input) // // and there are also escaped selectors like, // // .link.\:link // // (used like <input class="link :link">) // // and pseudo-elements like, // // span::before // // where the "::before" is irrelevant to whether the // selector matches the element so we should remove it. // // and // // input:first-child // p > :first-child // // where we should change to // input // p > * // respectively. // // So given all those scenarios we have the following logic, // // 1) If it starts with ":" without anything preceding we'll // consider it a match because it could be. // (maybe this should be configurable?) // // 2) If it has a ":" in it that's not preceded by "\" then // we remove to the end of the selector. ie, // input:-moz-something -> input // input\:-moz-something -> input\:-moz-something // input::before -> input::before // input\:\:moz-something -> input\:\:moz-something // PRE-NORMALISATON // Temporarily replace "\:" (escaped colon) to simplify // removing ":something" (real colon) which we restore later. normalizedSelector = selector.replace(/\\:/g, unique).trim(); // START OF NORMALIZATION // The goal here is to anticipate any possible node states that // might match this node (ie, "":checked" or adjacent sibling/hierarchical // selectors) normalizedSelector = normalizedSelector.replace(/^.*[\s]/, "") // regex 'greedy' selector anchored to string start, searching for whitespace to convert (eg) ".a + .b + .c" to ".c". Delete any conditions on hierarchical (adjacent sibling / descendent etc.) selectors because they could match and that's close enough to warrant including it. .replace(/:+.*$/gi, "") // regex 'greedy' selector anchored to string end, searching for ":" and deleting everything after .trim(); // END OF NORMALISATION // Restore escaped colons back to "\:". // See above comment about escaped colons. normalizedSelector = normalizedSelector.replace(new RegExp(unique, "g"), "\\:"); const isMatch = await Promise.resolve(el.matches(normalizedSelector)); // Why Promise.resolve? // // Well W3C DOM has .matches as a syncronous API // that returns booleans // // However to support some DOM implementations, // such as wrappers around Puppeteer, it would be // helpful to have an asyncronous API, so we also // resolve promises. // // As per https://stackoverflow.com/a/27746324 // we're using Promise.resolve to handle both types // of values. if (isMatch) { matched[i] = { selectors: matched[i] && matched[i].selectors || [], properties: rule.cssText.substring(rule.cssText.indexOf("{")) }; if (matched[i].selectors.indexOf(selector) === -1) { matched[i].selectors.push(selector); } } } catch (e) { if ("@charset".indexOf(rule.selectorText) !== -1) { console.error("ERROR", rule.type, `[${trimmedSelector}]`, `[[${normalizedSelector}]]`, `(((${rule.selectorText})))`, e); } } })); } } else if ((rule.rules || rule.cssRules) && (rule.conditionText || rule.media)) { const conditionText = rule.conditionText || rule.media[0]; if (mediaIsAllowed(conditionText, options)) { // a nested rule like @media { rule { ... } } // so we filter the rules inside individually const nestedRules = await _filterCSSRulesByElement(el, rule.rules || rule.cssRules, options, {}); if (nestedRules) { matched[i] = { before: "@media " + conditionText + " {", children: nestedRules, after: "}" }; } } } } return Object.keys(matched).length ? matched : undefined; } function sheetIsAllowed(sheet, options) { // Returns boolean of whether the sheet is allowed // due to whitelist/blacklist if (!sheet) return false; if (!sheet.ownerNode) return true; const checkStylesheet = (sheet, sheetMatch) => { switch (sheet.ownerNode.nodeName.toLowerCase()) { case "style": case "link": // matching on JSON.stringify(node.attrs) const nodeAttrs = sheet.ownerNode.attributes; const attrs = {}; for (let i = 0; i < nodeAttrs.length; i++) attrs[nodeAttrs[i].name] = nodeAttrs[i].value; const attributesJSON = JSON.stringify(attrs); return attributesJSON.indexOf(sheetMatch) !== -1; } throw new Error(`CSS Sniff: Unknown sheet nodeName of ${sheet.ownerNode && sheet.ownerNode.nodeName} `); }; let whitelisted = true; let blacklisted = false; const whitelistStylesheets = options.whitelist && options.whitelist.stylesheet; if (whitelistStylesheets) { const sheetMatches = Array.isArray(whitelistStylesheets) ? whitelistStylesheets : [whitelistStylesheets]; whitelisted = sheetMatches.some(sheetMatch => checkStylesheet(sheet, sheetMatches)); } const blacklistStylesheets = options.blacklist && options.blacklist.stylesheet; if (blacklistStylesheets) { const sheetMatches = Array.isArray(blacklistStylesheets) ? blacklistStylesheets : [blacklistStylesheets]; blacklisted = sheetMatches.some(sheetMatch => checkStylesheet(sheet, sheetMatch)); } return whitelisted !== false && blacklisted !== true; } function mediaIsAllowed(mediaString, options) { if (!options || !mediaString) return false; let whitelisted = true; let blacklisted = false; const whitelistMedia = options.whitelist && options.whitelist.media; if (whitelistMedia) { const mediaMatches = Array.isArray(whitelistMedia) ? whitelistMedia : [whitelistMedia]; whitelisted = mediaMatches.some(mediaMatch => mediaString.indexOf(mediaMatch) !== -1); } const blacklistMedia = options.blacklist && options.blacklist.media; if (blacklistMedia) { const mediaMatches = Array.isArray(blacklistMedia) ? blacklistMedia : [blacklistMedia]; blacklisted = mediaMatches.some(mediaMatch => mediaString.indexOf(mediaMatch) !== -1); } return whitelisted !== false && blacklisted !== true; } function ruleIsAllowed(ruleString, options) { if (!options || !ruleString) return false; let whitelisted = true; let blacklisted = false; const whitelistRules = options.whitelist && options.whitelist.rule; if (whitelistRules) { const ruleMatches = Array.isArray(whitelistRules) ? whitelistRules : [whitelistRules]; whitelisted = ruleMatches.some(ruleMatch => ruleString.indexOf(ruleMatch) !== -1); } const blacklistRules = options.blacklist && options.blacklist.rule; if (blacklistRules) { const ruleMatches = Array.isArray(blacklistRules) ? blacklistRules : [blacklistRules]; blacklisted = ruleMatches.some(ruleMatch => ruleString.indexOf(ruleMatch) !== -1); } return whitelisted !== false && blacklisted !== true; } function deepMergeRules(rulesArray) { // Via https://stackoverflow.com/a/34749873 /** * Simple object check. * @param item * @returns {boolean} */ function isObject(item) { return item && typeof item === "object" && !Array.isArray(item); } /** * Deep merge two objects. * @param target * @param ...sources */ function mergeDeep(target, ...sources) { if (!sources.length) return target; const source = sources.shift(); if (isObject(target) && isObject(source)) { for (const key in source) { if (isObject(source[key])) { if (!target[key]) Object.assign(target, { [key]: {} }); mergeDeep(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } } return mergeDeep(target, ...sources); } return mergeDeep({}, ...rulesArray); } function serializeCSSRules(rules) { if (!rules) return ""; return Object.keys(rules).map(key => { const rule = rules[key]; let css = ""; if (!rule) { throw Error(`css-sniff: serializeCSSRules() key: "${key}", value: ${JSON.stringify(rule)}`); } if (rule.selectors) { css += rule.selectors.join(","); css += rule.properties; } else if (rule.before) { css += rule.before; css += serializeCSSRules(rule.children); css += rule.after; } else if (rule instanceof Object) { css += serializeCSSRules(rule); } return css; }).join(""); } function splitSelectors(selectors) { function isAtRule(selector) { return selector.indexOf("@") === 0; } if (isAtRule(selectors)) { return [selectors]; } var splitted = []; var parens = 0; var angulars = 0; var soFar = ""; for (var i = 0, len = selectors.length; i < len; i++) { var char = selectors[i]; if (char === "(") { parens += 1; } else if (char === ")") { parens -= 1; } else if (char === "[") { angulars += 1; } else if (char === "]") { angulars -= 1; } else if (char === ",") { if (!parens && !angulars) { splitted.push(soFar.trim()); soFar = ""; continue; } } soFar += char; } splitted.push(soFar.trim()); return splitted; } const unique = `css-sniff-placeholder-${Math.random().toString(16).replace(/[^0-9A-Fa-f]/gi, "")}`;