-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from jonatanpedersen/upside-down
Adds support for selectors with nesting operators > + ~
- Loading branch information
Showing
4 changed files
with
437 additions
and
253 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,83 +1,182 @@ | ||
var css = require('css'); | ||
var debug = require('debug')('css-bingo'); | ||
var htmlparser = require('htmlparser2'); | ||
const css = require('css'); | ||
const htmlparser = require('htmlparser2'); | ||
const CssSelectorParser = require('css-selector-parser').CssSelectorParser; | ||
const debug = require('debug')('css-bingo'); | ||
|
||
module.exports = cssBingo; | ||
|
||
function cssBingo (cssCode, htmlCode) { | ||
const knownSelectors = new Set(); | ||
|
||
var parser = new htmlparser.Parser({ | ||
onopentag: (name, attribs) => { | ||
knownSelectors.add(name); | ||
|
||
const id = attribs.id; | ||
const classNames = [attribs['class'], attribs['data-class']].filter(Boolean).join(' ').split(' ').filter(Boolean).sort(); | ||
|
||
// .class.class | ||
const classNamesLength = classNames.length; | ||
|
||
for (var i = 0; i < classNamesLength; i++) { | ||
for (var j = i + 1; j < classNamesLength; j++) { | ||
classNames.push(classNames.slice(i, j + 1).join('.')); | ||
function cssBingo(cssCode, htmlCode) { | ||
const cssAst = css.parse(cssCode); | ||
const selectorRules = getSelectorRulesFromCssAst(cssAst); | ||
const unmatchedSelectors = matchSelectorsInHtml(selectorRules, htmlCode); | ||
const newCssAst = filterSelectorsFromCssAst(cssAst, unmatchedSelectors); | ||
|
||
return css.stringify(newCssAst, { compress: true }); | ||
} | ||
|
||
function getSelectorRulesFromCssAst(cssAst) { | ||
const cssSelectorParser = new CssSelectorParser(); | ||
cssSelectorParser.registerSelectorPseudos('has'); | ||
cssSelectorParser.registerNestingOperators('>', '+', '~'); | ||
cssSelectorParser.registerAttrEqualityMods('^', '$', '*', '~'); | ||
|
||
const selectors = new Set(); | ||
const selectorRules = []; | ||
|
||
walk(cssAst.stylesheet); | ||
|
||
return selectorRules; | ||
|
||
function walk(node) { | ||
for (var j = 0; j < node.rules.length; j++) { | ||
const rule = node.rules[j]; | ||
|
||
if (rule.type === 'rule' && rule.selectors) { | ||
for (var i = 0; i < rule.selectors.length; i++) { | ||
const selector = rule.selectors[i]; | ||
|
||
selectorRules.push({ | ||
selector: selector, | ||
rule: cssSelectorParser.parse(selector).rule | ||
}); | ||
|
||
selectors.add(selector); | ||
} | ||
} else if (rule.type === 'media') { | ||
walk(rule); | ||
} | ||
} | ||
} | ||
} | ||
|
||
function matchSelectorsInHtml(selectorRules, htmlCode) { | ||
var levelIdx = -1; | ||
var levels = []; | ||
|
||
const matchedSelectors = new Set(); | ||
const unmatchedSelectors = new Set(); | ||
const unmatchedSelectorRules = []; | ||
|
||
for (var x = 0; x < selectorRules.length; x++) { | ||
const selectorRule = selectorRules[x]; | ||
|
||
unmatchedSelectors.add(selectorRule.selector); | ||
unmatchedSelectorRules.push(selectorRule); | ||
} | ||
|
||
classNames.forEach(className => { | ||
knownSelectors.add(`.${className}`); | ||
knownSelectors.add(`${name}.${className}`); | ||
const parser = new htmlparser.Parser({ | ||
onopentag: (name, attrs) => { | ||
levelIdx++; | ||
|
||
if (id) { | ||
knownSelectors.add(`#${id}.${className}`); | ||
const levelSelectorRules = levels[levelIdx] = levels[levelIdx] || []; | ||
const nextLevelSelectorRules = levels[levelIdx + 1] = levels[levelIdx + 1] || []; | ||
|
||
const element = { | ||
name: name, | ||
id: attrs.id, | ||
attrs: attrs, | ||
classNames: new Set([attrs['class'], attrs['data-class']].join(' ').split(' ').filter(Boolean)) | ||
}; | ||
|
||
const tmpSelectorRules = []; | ||
|
||
for (var i = 0; i < unmatchedSelectorRules.length; i++) { | ||
tmpSelectorRules.push(unmatchedSelectorRules[i]); | ||
} | ||
|
||
for (var j = 0; j < levelSelectorRules.length; j++) { | ||
tmpSelectorRules.push(levelSelectorRules[j]); | ||
} | ||
|
||
for (var k = 0; k < tmpSelectorRules.length; k++) { | ||
processSelectorRules(tmpSelectorRules[k]); | ||
} | ||
|
||
function processSelectorRules(levelSelectorRule) { | ||
const matches = match(element, levelSelectorRule.rule); | ||
|
||
if (matches) { | ||
// If there is a match then we check whether the rule has any child rules, | ||
// If it does we send the child rule to the next level, | ||
// otherwise it means that the whole selector has been matched can remove it | ||
// from the unmatched selectors set and add it matched selectors set. | ||
if (levelSelectorRule.rule.rule) { | ||
const nestingOperator = levelSelectorRule.rule.rule.nestingOperator; | ||
const newLevelSelectorRule = { | ||
rule: levelSelectorRule.rule.rule, | ||
selector: levelSelectorRule.selector | ||
}; | ||
|
||
if (nestingOperator === null || nestingOperator === '>') { | ||
nextLevelSelectorRules.push(newLevelSelectorRule); | ||
} else { | ||
levelSelectorRules.push(newLevelSelectorRule); | ||
} | ||
} else { | ||
for (var y = 0; y < unmatchedSelectorRules.length; y++) { | ||
if (unmatchedSelectorRules[y].selector === levelSelectorRule.selector) { | ||
unmatchedSelectorRules.splice(y, 1); | ||
matchedSelectors.add(levelSelectorRule.selector); | ||
unmatchedSelectors.delete(levelSelectorRule.selector); | ||
break; | ||
} | ||
} | ||
} | ||
} else { | ||
// If we don't have a match then check the nesting operator. | ||
// If the nesting operator is null i.e. .class .class then we | ||
// can push the levelSelectorRule to the next level. | ||
if (levelSelectorRule.rule.nestingOperator === null) { | ||
nextLevelSelectorRules.push(levelSelectorRule); | ||
} | ||
} | ||
}); | ||
} | ||
|
||
function match(element, rule) { | ||
const id = (!rule.id || (rule.id === element.id)); | ||
const name = (!rule.tagName || rule.tagName === element.name || rule.tagName === '*'); | ||
const classNames = (!rule.classNames || (rule.classNames.filter(ruleClassName => element.classNames.has(ruleClassName)).length === rule.classNames.length)); | ||
|
||
if (id) { | ||
knownSelectors.add(`#${id}`); | ||
return id && name && classNames; | ||
} | ||
}, | ||
onclosetag: () => { | ||
delete levels[levelIdx + 1]; | ||
levelIdx--; | ||
} | ||
}); | ||
|
||
parser.write(htmlCode); | ||
parser.end(); | ||
|
||
const ast = css.parse(cssCode); | ||
|
||
walk(ast.stylesheet); | ||
return unmatchedSelectors; | ||
} | ||
|
||
function filterSelectorsFromCssAst(cssAst, selectors) { | ||
walk(cssAst.stylesheet); | ||
|
||
return css.stringify(ast, {compress:true}); | ||
return cssAst; | ||
|
||
function walk (node) { | ||
function walk(node) { | ||
node.rules = node.rules.reduce((rules, rule) => { | ||
switch (rule.type) { | ||
case 'rule': | ||
rule.selectors = rule.selectors.filter(selector => { | ||
if (/^[#\.]?[^:>\*\s+~]+$/.test(selector)) { | ||
const sortedSelector = selector.split('.').sort().join('.'); | ||
return knownSelectors.has(sortedSelector); | ||
} else { | ||
return true; | ||
} | ||
}); | ||
if (rule.type === 'rule') { | ||
rule.selectors = rule.selectors.filter(selector => !selectors.has(selector)); | ||
|
||
if (rule.selectors.length > 0) { | ||
rules.push(rule); | ||
} else { | ||
debug('removed rule: %o', rule); | ||
} | ||
break; | ||
case 'media': | ||
walk(rule); | ||
if (rule.rules.length > 0) { | ||
rules.push(rule); | ||
} | ||
break; | ||
default: | ||
if (rule.selectors.length > 0) { | ||
rules.push(rule); | ||
} else { | ||
debug('removed rule: %o', rule); | ||
} | ||
} else if (rule.type === 'media') { | ||
walk(rule); | ||
if (rule.rules.length > 0) { | ||
rules.push(rule); | ||
break; | ||
} | ||
} else { | ||
rules.push(rule); | ||
} | ||
|
||
return rules; | ||
}, []); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.