1 | 'use strict';
|
---|
2 |
|
---|
3 | /**
|
---|
4 | * @typedef {import('../lib/types').Specificity} Specificity
|
---|
5 | * @typedef {import('../lib/types').XastElement} XastElement
|
---|
6 | * @typedef {import('../lib/types').XastParent} XastParent
|
---|
7 | */
|
---|
8 |
|
---|
9 | const csstree = require('css-tree');
|
---|
10 | // @ts-ignore not defined in @types/csso
|
---|
11 | const specificity = require('csso/lib/restructure/prepare/specificity');
|
---|
12 | const stable = require('stable');
|
---|
13 | const {
|
---|
14 | visitSkip,
|
---|
15 | querySelectorAll,
|
---|
16 | detachNodeFromParent,
|
---|
17 | } = require('../lib/xast.js');
|
---|
18 |
|
---|
19 | exports.type = 'visitor';
|
---|
20 | exports.name = 'inlineStyles';
|
---|
21 | exports.active = true;
|
---|
22 | exports.description = 'inline styles (additional options)';
|
---|
23 |
|
---|
24 | /**
|
---|
25 | * Compares two selector specificities.
|
---|
26 | * extracted from https://github.com/keeganstreet/specificity/blob/master/specificity.js#L211
|
---|
27 | *
|
---|
28 | * @type {(a: Specificity, b: Specificity) => number}
|
---|
29 | */
|
---|
30 | const compareSpecificity = (a, b) => {
|
---|
31 | for (var i = 0; i < 4; i += 1) {
|
---|
32 | if (a[i] < b[i]) {
|
---|
33 | return -1;
|
---|
34 | } else if (a[i] > b[i]) {
|
---|
35 | return 1;
|
---|
36 | }
|
---|
37 | }
|
---|
38 | return 0;
|
---|
39 | };
|
---|
40 |
|
---|
41 | /**
|
---|
42 | * Moves + merges styles from style elements to element styles
|
---|
43 | *
|
---|
44 | * Options
|
---|
45 | * onlyMatchedOnce (default: true)
|
---|
46 | * inline only selectors that match once
|
---|
47 | *
|
---|
48 | * removeMatchedSelectors (default: true)
|
---|
49 | * clean up matched selectors,
|
---|
50 | * leave selectors that hadn't matched
|
---|
51 | *
|
---|
52 | * useMqs (default: ['', 'screen'])
|
---|
53 | * what media queries to be used
|
---|
54 | * empty string element for styles outside media queries
|
---|
55 | *
|
---|
56 | * usePseudos (default: [''])
|
---|
57 | * what pseudo-classes/-elements to be used
|
---|
58 | * empty string element for all non-pseudo-classes and/or -elements
|
---|
59 | *
|
---|
60 | * @author strarsis <strarsis@gmail.com>
|
---|
61 | *
|
---|
62 | * @type {import('../lib/types').Plugin<{
|
---|
63 | * onlyMatchedOnce?: boolean,
|
---|
64 | * removeMatchedSelectors?: boolean,
|
---|
65 | * useMqs?: Array<string>,
|
---|
66 | * usePseudos?: Array<string>
|
---|
67 | * }>}
|
---|
68 | */
|
---|
69 | exports.fn = (root, params) => {
|
---|
70 | const {
|
---|
71 | onlyMatchedOnce = true,
|
---|
72 | removeMatchedSelectors = true,
|
---|
73 | useMqs = ['', 'screen'],
|
---|
74 | usePseudos = [''],
|
---|
75 | } = params;
|
---|
76 |
|
---|
77 | /**
|
---|
78 | * @type {Array<{ node: XastElement, parentNode: XastParent, cssAst: csstree.StyleSheet }>}
|
---|
79 | */
|
---|
80 | const styles = [];
|
---|
81 | /**
|
---|
82 | * @type {Array<{
|
---|
83 | * node: csstree.Selector,
|
---|
84 | * item: csstree.ListItem<csstree.CssNode>,
|
---|
85 | * rule: csstree.Rule,
|
---|
86 | * matchedElements?: Array<XastElement>
|
---|
87 | * }>}
|
---|
88 | */
|
---|
89 | let selectors = [];
|
---|
90 |
|
---|
91 | return {
|
---|
92 | element: {
|
---|
93 | enter: (node, parentNode) => {
|
---|
94 | // skip <foreignObject /> content
|
---|
95 | if (node.name === 'foreignObject') {
|
---|
96 | return visitSkip;
|
---|
97 | }
|
---|
98 | // collect only non-empty <style /> elements
|
---|
99 | if (node.name !== 'style' || node.children.length === 0) {
|
---|
100 | return;
|
---|
101 | }
|
---|
102 | // values other than the empty string or text/css are not used
|
---|
103 | if (
|
---|
104 | node.attributes.type != null &&
|
---|
105 | node.attributes.type !== '' &&
|
---|
106 | node.attributes.type !== 'text/css'
|
---|
107 | ) {
|
---|
108 | return;
|
---|
109 | }
|
---|
110 | // parse css in style element
|
---|
111 | let cssText = '';
|
---|
112 | for (const child of node.children) {
|
---|
113 | if (child.type === 'text' || child.type === 'cdata') {
|
---|
114 | cssText += child.value;
|
---|
115 | }
|
---|
116 | }
|
---|
117 | /**
|
---|
118 | * @type {null | csstree.CssNode}
|
---|
119 | */
|
---|
120 | let cssAst = null;
|
---|
121 | try {
|
---|
122 | cssAst = csstree.parse(cssText, {
|
---|
123 | parseValue: false,
|
---|
124 | parseCustomProperty: false,
|
---|
125 | });
|
---|
126 | } catch {
|
---|
127 | return;
|
---|
128 | }
|
---|
129 | if (cssAst.type === 'StyleSheet') {
|
---|
130 | styles.push({ node, parentNode, cssAst });
|
---|
131 | }
|
---|
132 |
|
---|
133 | // collect selectors
|
---|
134 | csstree.walk(cssAst, {
|
---|
135 | visit: 'Selector',
|
---|
136 | enter(node, item) {
|
---|
137 | const atrule = this.atrule;
|
---|
138 | const rule = this.rule;
|
---|
139 | if (rule == null) {
|
---|
140 | return;
|
---|
141 | }
|
---|
142 |
|
---|
143 | // skip media queries not included into useMqs param
|
---|
144 | let mq = '';
|
---|
145 | if (atrule != null) {
|
---|
146 | mq = atrule.name;
|
---|
147 | if (atrule.prelude != null) {
|
---|
148 | mq += ` ${csstree.generate(atrule.prelude)}`;
|
---|
149 | }
|
---|
150 | }
|
---|
151 | if (useMqs.includes(mq) === false) {
|
---|
152 | return;
|
---|
153 | }
|
---|
154 |
|
---|
155 | /**
|
---|
156 | * @type {Array<{
|
---|
157 | * item: csstree.ListItem<csstree.CssNode>,
|
---|
158 | * list: csstree.List<csstree.CssNode>
|
---|
159 | * }>}
|
---|
160 | */
|
---|
161 | const pseudos = [];
|
---|
162 | if (node.type === 'Selector') {
|
---|
163 | node.children.each((childNode, childItem, childList) => {
|
---|
164 | if (
|
---|
165 | childNode.type === 'PseudoClassSelector' ||
|
---|
166 | childNode.type === 'PseudoElementSelector'
|
---|
167 | ) {
|
---|
168 | pseudos.push({ item: childItem, list: childList });
|
---|
169 | }
|
---|
170 | });
|
---|
171 | }
|
---|
172 |
|
---|
173 | // skip pseudo classes and pseudo elements not includes into usePseudos param
|
---|
174 | const pseudoSelectors = csstree.generate({
|
---|
175 | type: 'Selector',
|
---|
176 | children: new csstree.List().fromArray(
|
---|
177 | pseudos.map((pseudo) => pseudo.item.data)
|
---|
178 | ),
|
---|
179 | });
|
---|
180 | if (usePseudos.includes(pseudoSelectors) === false) {
|
---|
181 | return;
|
---|
182 | }
|
---|
183 |
|
---|
184 | // remove pseudo classes and elements to allow querySelector match elements
|
---|
185 | // TODO this is not very accurate since some pseudo classes like first-child
|
---|
186 | // are used for selection
|
---|
187 | for (const pseudo of pseudos) {
|
---|
188 | pseudo.list.remove(pseudo.item);
|
---|
189 | }
|
---|
190 |
|
---|
191 | selectors.push({ node, item, rule });
|
---|
192 | },
|
---|
193 | });
|
---|
194 | },
|
---|
195 | },
|
---|
196 |
|
---|
197 | root: {
|
---|
198 | exit: () => {
|
---|
199 | if (styles.length === 0) {
|
---|
200 | return;
|
---|
201 | }
|
---|
202 | // stable sort selectors
|
---|
203 | const sortedSelectors = stable(selectors, (a, b) => {
|
---|
204 | const aSpecificity = specificity(a.item.data);
|
---|
205 | const bSpecificity = specificity(b.item.data);
|
---|
206 | return compareSpecificity(aSpecificity, bSpecificity);
|
---|
207 | }).reverse();
|
---|
208 |
|
---|
209 | for (const selector of sortedSelectors) {
|
---|
210 | // match selectors
|
---|
211 | const selectorText = csstree.generate(selector.item.data);
|
---|
212 | /**
|
---|
213 | * @type {Array<XastElement>}
|
---|
214 | */
|
---|
215 | const matchedElements = [];
|
---|
216 | try {
|
---|
217 | for (const node of querySelectorAll(root, selectorText)) {
|
---|
218 | if (node.type === 'element') {
|
---|
219 | matchedElements.push(node);
|
---|
220 | }
|
---|
221 | }
|
---|
222 | } catch (selectError) {
|
---|
223 | continue;
|
---|
224 | }
|
---|
225 | // nothing selected
|
---|
226 | if (matchedElements.length === 0) {
|
---|
227 | continue;
|
---|
228 | }
|
---|
229 |
|
---|
230 | // apply styles to matched elements
|
---|
231 | // skip selectors that match more than once if option onlyMatchedOnce is enabled
|
---|
232 | if (onlyMatchedOnce && matchedElements.length > 1) {
|
---|
233 | continue;
|
---|
234 | }
|
---|
235 |
|
---|
236 | // apply <style/> to matched elements
|
---|
237 | for (const selectedEl of matchedElements) {
|
---|
238 | const styleDeclarationList = csstree.parse(
|
---|
239 | selectedEl.attributes.style == null
|
---|
240 | ? ''
|
---|
241 | : selectedEl.attributes.style,
|
---|
242 | {
|
---|
243 | context: 'declarationList',
|
---|
244 | parseValue: false,
|
---|
245 | }
|
---|
246 | );
|
---|
247 | if (styleDeclarationList.type !== 'DeclarationList') {
|
---|
248 | continue;
|
---|
249 | }
|
---|
250 | const styleDeclarationItems = new Map();
|
---|
251 | csstree.walk(styleDeclarationList, {
|
---|
252 | visit: 'Declaration',
|
---|
253 | enter(node, item) {
|
---|
254 | styleDeclarationItems.set(node.property, item);
|
---|
255 | },
|
---|
256 | });
|
---|
257 | // merge declarations
|
---|
258 | csstree.walk(selector.rule, {
|
---|
259 | visit: 'Declaration',
|
---|
260 | enter(ruleDeclaration) {
|
---|
261 | // existing inline styles have higher priority
|
---|
262 | // no inline styles, external styles, external styles used
|
---|
263 | // inline styles, external styles same priority as inline styles, inline styles used
|
---|
264 | // inline styles, external styles higher priority than inline styles, external styles used
|
---|
265 | const matchedItem = styleDeclarationItems.get(
|
---|
266 | ruleDeclaration.property
|
---|
267 | );
|
---|
268 | const ruleDeclarationItem =
|
---|
269 | styleDeclarationList.children.createItem(ruleDeclaration);
|
---|
270 | if (matchedItem == null) {
|
---|
271 | styleDeclarationList.children.append(ruleDeclarationItem);
|
---|
272 | } else if (
|
---|
273 | matchedItem.data.important !== true &&
|
---|
274 | ruleDeclaration.important === true
|
---|
275 | ) {
|
---|
276 | styleDeclarationList.children.replace(
|
---|
277 | matchedItem,
|
---|
278 | ruleDeclarationItem
|
---|
279 | );
|
---|
280 | styleDeclarationItems.set(
|
---|
281 | ruleDeclaration.property,
|
---|
282 | ruleDeclarationItem
|
---|
283 | );
|
---|
284 | }
|
---|
285 | },
|
---|
286 | });
|
---|
287 | selectedEl.attributes.style =
|
---|
288 | csstree.generate(styleDeclarationList);
|
---|
289 | }
|
---|
290 |
|
---|
291 | if (
|
---|
292 | removeMatchedSelectors &&
|
---|
293 | matchedElements.length !== 0 &&
|
---|
294 | selector.rule.prelude.type === 'SelectorList'
|
---|
295 | ) {
|
---|
296 | // clean up matching simple selectors if option removeMatchedSelectors is enabled
|
---|
297 | selector.rule.prelude.children.remove(selector.item);
|
---|
298 | }
|
---|
299 | selector.matchedElements = matchedElements;
|
---|
300 | }
|
---|
301 |
|
---|
302 | // no further processing required
|
---|
303 | if (removeMatchedSelectors === false) {
|
---|
304 | return;
|
---|
305 | }
|
---|
306 |
|
---|
307 | // clean up matched class + ID attribute values
|
---|
308 | for (const selector of sortedSelectors) {
|
---|
309 | if (selector.matchedElements == null) {
|
---|
310 | continue;
|
---|
311 | }
|
---|
312 |
|
---|
313 | if (onlyMatchedOnce && selector.matchedElements.length > 1) {
|
---|
314 | // skip selectors that match more than once if option onlyMatchedOnce is enabled
|
---|
315 | continue;
|
---|
316 | }
|
---|
317 |
|
---|
318 | for (const selectedEl of selector.matchedElements) {
|
---|
319 | // class
|
---|
320 | const classList = new Set(
|
---|
321 | selectedEl.attributes.class == null
|
---|
322 | ? null
|
---|
323 | : selectedEl.attributes.class.split(' ')
|
---|
324 | );
|
---|
325 | const firstSubSelector = selector.node.children.first();
|
---|
326 | if (
|
---|
327 | firstSubSelector != null &&
|
---|
328 | firstSubSelector.type === 'ClassSelector'
|
---|
329 | ) {
|
---|
330 | classList.delete(firstSubSelector.name);
|
---|
331 | }
|
---|
332 | if (classList.size === 0) {
|
---|
333 | delete selectedEl.attributes.class;
|
---|
334 | } else {
|
---|
335 | selectedEl.attributes.class = Array.from(classList).join(' ');
|
---|
336 | }
|
---|
337 |
|
---|
338 | // ID
|
---|
339 | if (
|
---|
340 | firstSubSelector != null &&
|
---|
341 | firstSubSelector.type === 'IdSelector'
|
---|
342 | ) {
|
---|
343 | if (selectedEl.attributes.id === firstSubSelector.name) {
|
---|
344 | delete selectedEl.attributes.id;
|
---|
345 | }
|
---|
346 | }
|
---|
347 | }
|
---|
348 | }
|
---|
349 |
|
---|
350 | for (const style of styles) {
|
---|
351 | csstree.walk(style.cssAst, {
|
---|
352 | visit: 'Rule',
|
---|
353 | enter: function (node, item, list) {
|
---|
354 | // clean up <style/> rulesets without any css selectors left
|
---|
355 | if (
|
---|
356 | node.type === 'Rule' &&
|
---|
357 | node.prelude.type === 'SelectorList' &&
|
---|
358 | node.prelude.children.isEmpty()
|
---|
359 | ) {
|
---|
360 | list.remove(item);
|
---|
361 | }
|
---|
362 | },
|
---|
363 | });
|
---|
364 |
|
---|
365 | if (style.cssAst.children.isEmpty()) {
|
---|
366 | // remove emtpy style element
|
---|
367 | detachNodeFromParent(style.node, style.parentNode);
|
---|
368 | } else {
|
---|
369 | // update style element if any styles left
|
---|
370 | const firstChild = style.node.children[0];
|
---|
371 | if (firstChild.type === 'text' || firstChild.type === 'cdata') {
|
---|
372 | firstChild.value = csstree.generate(style.cssAst);
|
---|
373 | }
|
---|
374 | }
|
---|
375 | }
|
---|
376 | },
|
---|
377 | },
|
---|
378 | };
|
---|
379 | };
|
---|