find-css-matches
Version:
Find the CSS selectors that match an HTML snippet.
369 lines (361 loc) • 11.2 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var puppeteer = _interopDefault(require('puppeteer'));
function stringifySelectors ({matches, children, html}, options) {
const result = {
matches: matches.map(match => {
const result = {
...match,
selector: match.selector.map(part => {
const [unmatched, matched] = options.formatSelector(...part);
return `${unmatched} ${matched}`.trim().replace(/\s+/g, ' ')
}).join(', ')
};
return result
})
};
if (html) {
result.html = html;
}
if (children) {
result.children = children.map(child => stringifySelectors(child, options));
}
return result
}
async function setPageContent (page, html, styles) {
await page.setContent(html);
for (const style of styles) {
await page.addStyleTag(style);
}
return page
}
function getOpeningTagName (html) {
const htmlWithNoComments = html.replace(/<!--[\s\S]*?-->/g, '');
const match = /^\s*<\s*([a-z]+)/i.exec(htmlWithNoComments);
if (match) {
return match[1].toLowerCase()
}
throw new Error('Input HTML does not contain a valid tag')
}
function findMatchingRules (options) {
function isCombinator(input) {
return input === '>' || input === '+' || input === '~' || input === ' '
}
function isHtmlSelector(selector) {
return /^html(?:$|[^a-z-])/i.test(selector)
}
function isBodySelector(selector) {
return /^body(?:$|[^a-z-])/i.test(selector)
}
function stringifyElement(element) {
const match = element.outerHTML.match(/[^>]*>/);
return match ? match[0] : ''
}
function getCssRules(styles) {
const CSS_RULE_TYPES = [
'UNKNOWN_RULE',
'STYLE_RULE',
'CHARSET_RULE',
'IMPORT_RULE',
'MEDIA_RULE',
'FONT_FACE_RULE',
'PAGE_RULE',
'KEYFRAMES_RULE',
'KEYFRAME_RULE',
null,
'NAMESPACE_RULE',
'COUNTER_STYLE_RULE',
'SUPPORTS_RULE',
'DOCUMENT_RULE',
'FONT_FEATURE_VALUES_RULE',
'VIEWPORT_RULE',
'REGION_STYLE_RULE'
];
const rules = [];
for (const {cssRules} of styles) {
for (const rule of cssRules) {
switch (CSS_RULE_TYPES[rule.type]) {
case 'STYLE_RULE':
rules.push(rule);
break
case 'MEDIA_RULE':
rules.push(...getCssRules([rule]));
break
}
}
}
return rules
}
function findRulesForElement(matches, rules, element, options, depth) {
const result = {};
if (options.includeHtml === true) {
result.html = stringifyElement(element);
}
result.matches = rules.reduce((acc, rule) => {
const selector = parseRuleForElement(matches, rule, element, options, depth);
selector && acc.push(formatRule(rule, selector, options));
return acc
}, []);
if (options.recursive === true) {
const depthOfChildren = depth + 1;
result.children = Array.prototype.map.call(element.children, child => {
return findRulesForElement(matches, rules, child, options, depthOfChildren)
});
}
return result
}
function parseRuleForElement(matches, rule, element, options, depth) {
let hasMatch = false;
const parts = rule.selectorText.split(/\s*,\s*/);
const result = parts.map(part => {
const selector = splitPartOfSelector(matches, element, part, depth, options);
if (options.includePartialMatches) {
if (selector[1]) {
hasMatch = true;
}
} else if (!selector[0]) {
hasMatch = true;
}
return selector
});
if (hasMatch) {
return result
}
return null
}
function splitPartOfSelector(matches, element, selector, depth, options) {
let result;
const parts = selectorStringToArray(selector);
if (isMatchable(parts)) {
if (isFullMatchable(parts, options) && matches(element, selector)) {
result = [[], parts];
} else if (options.includePartialMatches) {
const lastIndex = parts.length - 1;
const index = findMatchIndex(matches, element, depth, parts, lastIndex);
const unmatched = parts.slice(0, index);
const matched = parts.slice(index);
result = [unmatched, matched];
if (options.tagName === 'body') {
if (unmatched.length > 2 || !isHtmlSelector(unmatched[0])) {
result = null;
}
}
}
}
if (result) {
return selectorArrayToString(result)
}
return [selector, '']
}
function isMatchable(parts) {
if (parts.length === 0) {
return false
}
for (let i = 2; i < parts.length; i += 2) {
const part = parts[i];
if (isHtmlSelector(part)) {
return false
} else if (isBodySelector(part)) {
let prevPart = parts[i - 1];
if (prevPart === '+' || prevPart === '~') {
return false
}
if (prevPart === '>' || prevPart === ' ') {
if (!isHtmlSelector(parts[i - 2])) {
return false
}
}
}
}
return true
}
function isFullMatchable(parts, options) {
if (parts.length === 0) {
return false
}
if (options.isHtmlOrBodyTag) {
return true
}
const index = parts.findIndex(part => isBodySelector(part));
return index === -1 || parts[index + 1] === ' '
}
function selectorStringToArray(selector) {
let match;
const parts = [];
const REGEX = /\s[>+~]\s|\s+|[^\s]+/g;
while ((match = REGEX.exec(selector))) {
parts.push(match[0] === ' ' ? match[0] : match[0].trim());
}
return parts
}
function selectorArrayToString(selector) {
return selector.map(part => part.filter(p => p !== ' ').join(' '))
}
function findMatchIndex(matches, element, elementDepth, parts, index) {
if (index < 0) {
return 0
}
const part = parts[index];
const NO_MATCH = parts.length;
let combinator;
if (isCombinator(part)) {
combinator = part;
} else if (matches(element, part)) {
if (element.tagName === 'BODY') {
if (parts.slice(0, index).find(part => isBodySelector(part))) {
return NO_MATCH
}
}
return findMatchIndex(matches, element, elementDepth, parts, index - 1)
} else if (parts[index + 1] === ' ') {
return index + 2
} else {
return NO_MATCH
}
if (combinator === '>' && elementDepth <= 0) {
return index + 1
}
const {elements, depth} = combinatorQuery(element, combinator, elementDepth);
if (elements.length === 0) {
if (elementDepth > 0 && combinator !== ' ') {
return NO_MATCH
} else {
return index + 1
}
}
const indices = elements.map((element, i) => {
const _depth = combinator === ' ' ? depth - i : depth;
return findMatchIndex(matches, element, _depth, parts, index - 1)
});
return Math.min(...indices)
}
function combinatorQuery(element, combinator, depth) {
const elements = [];
let depthOfElements = depth;
if (combinator === '>') {
if (element.parentElement) {
elements.push(element.parentElement);
}
depthOfElements--;
} else if (combinator === '+') {
if (element.previousElementSibling) {
elements.push(element.previousElementSibling);
}
} else if (combinator === '~') {
let el = element;
while ((el = el.previousElementSibling)) {
elements.unshift(el);
}
} else if (combinator === ' ') {
let el = element;
while ((el = el.parentElement)) {
elements.push(el);
}
depthOfElements--;
}
return {elements, depth: depthOfElements}
}
function cssTextToArray(cssText) {
const match = cssText.match(/{([^}]*)}/);
const text = match ? match[1].trim() : '';
return text.split(/;\s*/).reduce((acc, str) => {
str && acc.push(`${str}`);
return acc
}, [])
}
function formatRule(rule, selector, options) {
const ruleObj = {selector};
const media = getMediaText(rule);
if (media) {
ruleObj.media = media;
}
if (options.includeCss === true) {
ruleObj.css = cssTextToArray(rule.cssText);
}
if (options.includePartialMatches) {
ruleObj.isPartialMatch = selector.every(([unmatched]) => unmatched);
}
return ruleObj
}
function getMediaText(rule) {
let media = '';
let current = rule;
while ((current = current.parentRule) && current.media) {
if (media) {
media = `${current.media.mediaText} AND ${media}`;
} else {
media = current.media.mediaText;
}
}
return media
}
const matches = Function.call.bind(window.Element.prototype.webkitMatchesSelector);
const rules = getCssRules(document.styleSheets);
let elements;
if (options.isHtmlOrBodyTag) {
elements = [document.querySelector(options.tagName)];
} else {
elements = [...document.body.children];
}
const result = elements.map(element => {
return findRulesForElement(matches, rules, element, options, 0)
});
return Promise.all(result)
}
async function findMatchesFromPage (page, html, styles, options) {
await setPageContent(page, html, styles);
let matches = await page.evaluate(findMatchingRules, options);
matches = matches.map(match => stringifySelectors(match, options));
if (matches.length === 1) {
matches = matches[0];
}
return matches
}
const DEFAULT_OPTIONS = {
recursive: true,
includeHtml: false,
includeCss: false,
includePartialMatches: true,
formatSelector: (a, b) => [a, b]
};
function normalizeStyles (styles) {
if (Array.isArray(styles)) {
return styles
} else if (typeof styles === 'string') {
return [{content: styles}]
}
return [styles]
}
function mergeOptions (options, html) {
const tagName = getOpeningTagName(html);
const isHtmlOrBodyTag = tagName === 'html' || tagName === 'body';
return Object.assign({}, DEFAULT_OPTIONS, options, {tagName, isHtmlOrBodyTag})
}
async function findMatchesFactory (styles, instanceOptions) {
const stylesArray = normalizeStyles(styles);
let browser = await puppeteer.launch();
let page = await browser.newPage();
page.on('console', msg => console.log(msg.text()));
async function findMatches (html, localOptions) {
if (!page) {
throw new Error('Unable to call findMatches(...) after findMatches.close()')
}
const userOptions = Object.assign({}, instanceOptions, localOptions);
return findMatchesFromPage(page, html, stylesArray, mergeOptions(userOptions, html))
}
findMatches.close = async () => {
await browser.close();
browser = null;
page = null;
};
return findMatches
}
async function findMatches (styles, html, options) {
const _findMatches = await findMatchesFactory(styles, options);
const selectors = await _findMatches(html);
_findMatches.close();
return selectors
}
exports.findMatchesFactory = findMatchesFactory;
exports.findMatches = findMatches;