UNPKG

find-css-matches

Version:

Find the CSS selectors that match an HTML snippet.

369 lines (361 loc) 11.2 kB
'use strict'; 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;