Skip to content

Commit fd97096

Browse files
committed
added alias tagging to parse-preprocessing
1 parent 7f9c89d commit fd97096

File tree

2 files changed

+309
-200
lines changed

2 files changed

+309
-200
lines changed
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import postcss, { plugin } from 'postcss'
2+
import safeParser from 'postcss-safe-parser'
3+
import { parse as parseSelector, stringify as stringfySelector } from 'css-selector-tokenizer'
4+
import { unique } from 'shorthash'
5+
import { first, last, intersection, uniq } from 'lodash'
6+
7+
function toClass(s) {
8+
return `s${unique(s)}`
9+
}
10+
11+
function stringifySelectorNodes(nodes) {
12+
return stringfySelector({ type: 'selector', nodes })
13+
}
14+
15+
const complexRelationships = ['>', '~', '+']
16+
// not ":not()" because it can contain a dynamicPseudoSelector
17+
const staticPseudoSelectors = [ 'first-child', 'last-child', 'first-of-type', 'last-of-type', 'nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type', 'empty' ]
18+
const dynamicPseudoSelectors = [ 'hover', 'active', 'focus', 'link', 'visited', 'target', 'checked', 'in-range', 'out-of-range', 'invalid', 'scope' ]
19+
const pseudoElements = [ 'after', 'before', 'first-letter', 'first-line', 'selection', 'backdrop', 'placeholder', 'marker', 'spelling-error', 'grammar-error' ]
20+
21+
/**
22+
* process all the style tags
23+
* @param {Cheerio} $
24+
* @param {Array} elements
25+
*/
26+
function processStyles($, elements) {
27+
$.findNodes('style').forEach(($node) => {
28+
const css = processCSS($, elements, $node.html())
29+
30+
$node.html(css)
31+
})
32+
}
33+
34+
/**
35+
* process the given css
36+
* @param {Cheerio} $
37+
* @param {String} contents some css
38+
* @return {String} the modified css
39+
*/
40+
function processCSS($, elements, contents) {
41+
const { css } = postcss([
42+
safeSelectorize($),
43+
tagAllAliasSelectors($, elements),
44+
]).process(contents, { parser: safeParser })
45+
46+
return css
47+
}
48+
49+
const tagAllAliasSelectors = plugin('postcss-tag-aliases', ($, elements) => (root) => {
50+
/**
51+
* add the element tag to any css selectors that implicitly target an element
52+
* .i.e. #my-button that selects <button id="my-button">click me</button>
53+
*/
54+
const elementNames = elements.map(({ tagName }) => tagName)
55+
56+
root.walkRules((rule) => {
57+
let newSelectors = []
58+
59+
rule.selectors.forEach((selector) => {
60+
/** skip if we already target a tag (no need to alias) */
61+
if (targetsTag(selector)) return newSelectors.push(selector)
62+
63+
const selectedTags = uniq($.findNodes(queryableSelector(selector)).map(($node) => $node[0].name))
64+
const targetSingleTag = selectedTags.length === 1
65+
const selectedElements = intersection(selectedTags, elementNames)
66+
const targetsNonElements = selectedTags.length > selectedElements.length
67+
68+
/** skip if we are not targeting any elements (no need to alias) */
69+
if (selectedElements.length === 0) return newSelectors.push(selector)
70+
71+
/** if we target only one tag/element, just drop the tag onto the selector */
72+
if (targetSingleTag) {
73+
const elementName = first(selectedElements)
74+
return newSelectors.push(appendElementSelector(elementName, selector))
75+
}
76+
77+
/** if we target more than one tag/element, generate specific selector for each element */
78+
for (let elementName of selectedElements) {
79+
newSelectors.push(buildTheElementSpecificSelector(elementName, selector, $))
80+
}
81+
82+
/** if we target non-elements, we need to keep the original selector on */
83+
if (targetsNonElements) return newSelectors.push(selector)
84+
})
85+
86+
rule.selectors = newSelectors
87+
})
88+
})
89+
90+
91+
/**
92+
* Add the element tag to the end of the selector
93+
* @param {Object} element element definition
94+
* @param {String} selector the selector
95+
* @return {String} the modified selector
96+
*/
97+
function appendElementSelector (elementName, selector) {
98+
const nodes = first(parseSelector(selector).nodes).nodes
99+
100+
// default to the last node in case there is no combinator
101+
let lastCombinatorIndex = nodes.length - 1
102+
nodes.forEach((node, i) => {
103+
if (node.type === 'operator' || node.type === 'spacing') {
104+
lastCombinatorIndex = i + 1
105+
}
106+
})
107+
108+
nodes.splice(lastCombinatorIndex, 0, { name: elementName, type: 'element' })
109+
110+
return stringifySelectorNodes(nodes)
111+
}
112+
113+
114+
function buildTheElementSpecificSelector(elementName, selector, $) {
115+
const nodes = first(parseSelector(selector).nodes).nodes.reverse()
116+
const $elementNodes = $.findNodes(queryableSelector(appendElementSelector(elementName, selector)))
117+
118+
for (const node of nodes) {
119+
if (node.type === 'operator' || node.type === 'spacing') { break }
120+
121+
if (node.type === 'class') {
122+
const newClass = `${node.name}-${elementName}`
123+
$elementNodes.forEach(($node) => $node.removeClass(node.name).addClass(newClass))
124+
node.name = newClass
125+
}
126+
127+
if (node.type === 'id') {
128+
const newId = `${node.name}-${elementName}`
129+
$elementNodes.forEach(($node) => $node.attr('id', newId))
130+
node.name = newId
131+
}
132+
}
133+
134+
return appendElementSelector(elementName, stringifySelectorNodes(nodes.reverse()))
135+
}
136+
137+
function queryableSelector(s) {
138+
return s
139+
}
140+
141+
/**
142+
* checks if selector targets a tag
143+
* @param {String} selector the selector
144+
* @return {Boolean} if the selector targets a tag
145+
*/
146+
function targetsTag (selector) {
147+
const nodes = first(parseSelector(selector).nodes).nodes.reverse()
148+
149+
for (const node of nodes) {
150+
if (node.type === 'operator' || node.type === 'spacing') { return false }
151+
152+
if (node.type === 'tag') { return true }
153+
}
154+
}
155+
156+
157+
158+
159+
160+
161+
162+
163+
164+
165+
166+
167+
168+
169+
170+
171+
172+
173+
174+
175+
176+
177+
/**
178+
* This converts all complex selectors into classes via shorthash and applies them
179+
* to the elements that should be selected as to allow for the most cross client support
180+
* @param {Cheerio} $
181+
* @param {Array} elements
182+
*/
183+
const safeSelectorize = plugin('postcss-safe-selectorize', ($) => (root) => {
184+
root.walkRules((rule) => {
185+
rule.selectors = rule.selectors.map((selector) => {
186+
if (isComplexSelector(selector)) {
187+
const classesMap = generateClassesForSelector(selector)
188+
189+
for (const [ className, selectorPart ] of classesMap) {
190+
$(selectorPart).addClass(className)
191+
}
192+
193+
return generateReplacementSelector(selector)
194+
}
195+
196+
return selector
197+
})
198+
})
199+
})
200+
201+
/**
202+
* checks if the given selector contains any parts that have less then ideal support
203+
* @param {String]} selector
204+
* @return {Boolean} isComplex
205+
*/
206+
function isComplexSelector(selector) {
207+
const { nodes } = first(parseSelector(selector).nodes)
208+
209+
return nodes.filter(({ type, operator, name }) => {
210+
/** complex relationships */
211+
if (type === 'operator' && complexRelationships.includes(operator)) return true
212+
213+
/** attribute selector */
214+
if (type === 'attribute') return true
215+
216+
/** static pseudo selectors */
217+
if (type.startsWith('pseudo') && staticPseudoSelectors.includes(name)) return true
218+
219+
/** universal selector */
220+
if (type === 'universal') return true
221+
222+
return false
223+
}).length > 0
224+
}
225+
226+
/**
227+
* builds a map of selectors to be replaced with the corresponding class
228+
* @param {String} selector
229+
* @return {Map} selectorAndClassMap
230+
*/
231+
function generateClassesForSelector(selector) {
232+
const { nodes } = first(parseSelector(selector).nodes)
233+
const map = new Map()
234+
let selectorPartNodes = []
235+
236+
/**
237+
* 1. gather all the nodes until a pseudo element or dynamic pseudo selector
238+
* 2. create a class for the selector part and add selector part/class to the map
239+
*/
240+
nodes.forEach((node, index) => {
241+
/** we have a matched pseudo - drop the node, build the previous nodes to a string, and add it to the map entry */
242+
if (node.type.startsWith('pseudo') && (pseudoElements.includes(node.name) || dynamicPseudoSelectors.includes(node.name))) {
243+
const selectorPart = stringifySelectorNodes(selectorPartNodes)
244+
map.set(toClass(selectorPart), selectorPart)
245+
246+
/**
247+
* keep the previous last node on so that the selector continues to work
248+
* .i.e. a:hover > b will become these selectors ['a', 'a > b']
249+
*/
250+
selectorPartNodes = selectorPartNodes.length > 0 ? [ last(selectorPartNodes) ] : []
251+
}
252+
/** we are on the last element, push it on, and add the */
253+
else if (index === nodes.length - 1) {
254+
selectorPartNodes.push(node)
255+
const selectorPart = stringifySelectorNodes(selectorPartNodes)
256+
map.set(toClass(selectorPart), selectorPart)
257+
}
258+
/** push the node to the current selector part */
259+
else {
260+
selectorPartNodes.push(node)
261+
}
262+
})
263+
264+
return map
265+
}
266+
267+
/**
268+
* generate a selector that uses the same classes as what was generated in generateClassesForSelector, but leaves in all the pseudo pieces
269+
* @param {String} selector
270+
* @return {Map} selectorAndClassMap
271+
*/
272+
function generateReplacementSelector(selector) {
273+
const { nodes } = first(parseSelector(selector).nodes)
274+
const map = new Map()
275+
let selectorPartNodes = []
276+
let replacementSelector = ''
277+
278+
/**
279+
* 1. gather all the nodes until a pseudo element or dynamic pseudo selector
280+
* 2. create a class for the selector part and add selector part/class to the map
281+
*/
282+
nodes.forEach((node, index) => {
283+
/** we have a matched pseudo - build the previous nodes to a string, and add it to the replacement selector, append the pseudo */
284+
if (node.type.startsWith('pseudo') && (pseudoElements.includes(node.name) || dynamicPseudoSelectors.includes(node.name))) {
285+
const selectorPart = stringifySelectorNodes(selectorPartNodes)
286+
replacementSelector += `.${toClass(selectorPart)}${stringifySelectorNodes([node])} `
287+
288+
/**
289+
* keep the previous last node on so that the selector continues to work
290+
* .i.e. a:hover > b will become these selectors ['a', 'a > b']
291+
*/
292+
selectorPartNodes = selectorPartNodes.length > 0 ? [ last(selectorPartNodes) ] : []
293+
}
294+
/** we are on the last element, push it on, and add the class */
295+
else if (index === nodes.length - 1) {
296+
selectorPartNodes.push(node)
297+
const selectorPart = stringifySelectorNodes(selectorPartNodes)
298+
replacementSelector += `.${toClass(selectorPart)} `
299+
}
300+
/** push the node to the current selector part */
301+
else {
302+
selectorPartNodes.push(node)
303+
}
304+
})
305+
306+
return replacementSelector
307+
}
308+
309+
export default processStyles

0 commit comments

Comments
 (0)