[6a3a178] | 1 | 'use strict';
|
---|
| 2 |
|
---|
[e29cc2e] | 3 | /**
|
---|
| 4 | * @typedef {import('../lib/types').Specificity} Specificity
|
---|
| 5 | * @typedef {import('../lib/types').XastElement} XastElement
|
---|
| 6 | * @typedef {import('../lib/types').XastParent} XastParent
|
---|
| 7 | */
|
---|
[6a3a178] | 8 |
|
---|
[e29cc2e] | 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';
|
---|
[6a3a178] | 20 | exports.name = 'inlineStyles';
|
---|
| 21 | exports.active = true;
|
---|
[e29cc2e] | 22 | exports.description = 'inline styles (additional options)';
|
---|
[6a3a178] | 23 |
|
---|
[e29cc2e] | 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;
|
---|
[6a3a178] | 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>
|
---|
[e29cc2e] | 61 | *
|
---|
| 62 | * @type {import('../lib/types').Plugin<{
|
---|
| 63 | * onlyMatchedOnce?: boolean,
|
---|
| 64 | * removeMatchedSelectors?: boolean,
|
---|
| 65 | * useMqs?: Array<string>,
|
---|
| 66 | * usePseudos?: Array<string>
|
---|
| 67 | * }>}
|
---|
[6a3a178] | 68 | */
|
---|
[e29cc2e] | 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;
|
---|
[6a3a178] | 97 | }
|
---|
[e29cc2e] | 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;
|
---|
[6a3a178] | 115 | }
|
---|
[e29cc2e] | 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 | }
|
---|
[6a3a178] | 132 |
|
---|
[e29cc2e] | 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 | },
|
---|
[6a3a178] | 196 |
|
---|
[e29cc2e] | 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 | }
|
---|
[6a3a178] | 229 |
|
---|
[e29cc2e] | 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 | }
|
---|
[6a3a178] | 235 |
|
---|
[e29cc2e] | 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 | }
|
---|
[6a3a178] | 290 |
|
---|
[e29cc2e] | 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;
|
---|
[6a3a178] | 300 | }
|
---|
| 301 |
|
---|
[e29cc2e] | 302 | // no further processing required
|
---|
| 303 | if (removeMatchedSelectors === false) {
|
---|
[6a3a178] | 304 | return;
|
---|
| 305 | }
|
---|
| 306 |
|
---|
[e29cc2e] | 307 | // clean up matched class + ID attribute values
|
---|
| 308 | for (const selector of sortedSelectors) {
|
---|
| 309 | if (selector.matchedElements == null) {
|
---|
| 310 | continue;
|
---|
| 311 | }
|
---|
[6a3a178] | 312 |
|
---|
[e29cc2e] | 313 | if (onlyMatchedOnce && selector.matchedElements.length > 1) {
|
---|
| 314 | // skip selectors that match more than once if option onlyMatchedOnce is enabled
|
---|
| 315 | continue;
|
---|
| 316 | }
|
---|
[6a3a178] | 317 |
|
---|
[e29cc2e] | 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 | }
|
---|
[6a3a178] | 349 |
|
---|
[e29cc2e] | 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 | };
|
---|
[6a3a178] | 379 | };
|
---|