Два плагина к PostCSS для управления цветом

Владимир Кузнецов, Хантфлоу

Два плагина к PostCSS для управления цветом

Владимир Кузнецов, Хантфлоу

FrontTalks, 2018

PostCSS преобразует CSS в AST

PostCSS конвертирует CSS в AST и обратно

Плагины PostCSS изменяют AST

Плагины PostCSS меняют AST

Autoprefixer

Плагины PostCSS

  • cssnext
  • postcss-custom-properties
  • pixrem
  • postcss-unmq
  • postcss-hash-classname
  • postcss-namespace
  • postcss-assets-rebase
  • postcss-cachebuster
  • postcss-svg
  • postcss-at2x
  • postcss-bem-to-js
  • postcss-click

Плагины PostCSS

  • postcss-circle
  • postcss-triangle
  • postcss-clearfix
  • postcss-focus
  • postcss-aspect-ratio
  • postcss-zindex
  • postcss-conditionals
  • postcss-define-property
  • postcss-each
  • postcss-for
  • stylelint

Может оказаться, что существующий плагин решает задачу,
но не так как вам хотелось бы.
Что делать?

  1. Смириться.
  2. Сделать PR в плагин.
  3. Написать свой плагин.
Сомнения

Мой первый плагин

postcss-unwrap-at-media

/* Input */
.block { width: 100%; }
@media (min-width: 720px) {
  .block { width: 25%; }
}
/* Output */
.block { width: 100%; }
.block { width: 25%; }

Пример №1

postcss-alter-color

{ from: 'darkslategray', to: '#556832' }
p { color: darkslategray; }
a { color: #2F4F4F; }
.quote {
  border-left: 1px solid rgb(47, 79, 79);
  border-right: solid hsl(180, 25%, 25%) 1px;
}

postcss-alter-color

{ from: 'darkslategray', to: '#556832' }
p { color: darkslategray; }
a { color: #2F4F4F; }
.quote {
  border-left: 1px solid rgb(47, 79, 79);
  border-right: solid hsl(180, 25%, 25%) 1px;
}

postcss-alter-color

{ from: 'darkslategray', to: '#556832' }
p { color: #556832; }
a { color: #556832; }
.quote {
  border-left: 1px solid #556832;
  border-right: solid #556832 1px;
}

api.postcss.org

Обход дерева

Типы узлов

  • rule (div, .list)
  • declaration (color, background)
  • @rule (media, charset)
  • comment

Методы обхода

  • walkRules()
  • walkDecls()
  • walkAtRules()
  • walkComments()
  • walk()
        function plugin(options) {
          return function (root) {
            root.walkDecls(function (decl) {
              if (decl.value === options.from)
                decl.value = options.to;
            });
          };
        }
        module.exports = postcss.plugin('alter-color', plugin);
    

Варианты записи цвета

Сплошные цвета

  • white
  • #333
  • #556832
  • rgb(47, 79, 79)
  • hsl(180, 25%, 25%)

parse-color

{ rgb: [ 255, 165, 0 ],
  hsl: [ 39, 100, 50 ],
  keyword: 'orange',
  hex: '#ffa500',
  rgba: [ 255, 165, 0, 1 ],
  hsla: [ 39, 100, 50, 1 ] }

Разбор значения узла decl

#main { border: 1px solid #2F4F4F }
{
  type: "decl",
  prop: "border",
  value: "1px solid #2F4F4F"
}
Идея!

CSSTree

        root.walkDecls(decl => {
          const parsedValue = cssTree.parse(
            decl.value,
            { context: 'value' }
          );
          // тут можно модифицировать parsedValue
          decl.value = cssTree.generate(parsedValue);
        });
    
Волчок
"1px solid #2F4F4F"
        { type: "Value", children: [
          { type: "Dimension", value: 1, unit: "px" },
          { type: "WhiteSpace", value: " " },
          { type: "Identifier", name: "solid" },
          { type: "WhiteSpace", value: " " },
          { type: "HexColor", value: "2F4F4F" }
        ] }
    
"darkslategray"
        { type: "Value", children: [
          { type: "Identifier", name: "darkslategray" }
        ] }
    
rgb(47, 79, 79)
        { type: "Value", children: [
          { type: "Function", name: "rgb", "children": [
            { type: "Number", value: "47"},
            { type: "Operator", value: ","},
            { type: "Number", value: "79"},
            { type: "Operator", value: ","},
            { type: "Number", value: "79"}
          ] }
        ] }
    

Типы узлов CSSTree, которые могут содержать цвет

Документация по обходу AST CSSTree

        function identifierVisitor(node, item, list) {
          if (node.name === 'darkslategray') {
            // изменяем node
          }
        }
        cssTree.walk(parsedValue, {
          visit: 'Identifier',
          enter: identifierVisitor
        });
    
        function identifierVisitor(node, item, list) {
          if (node.name === 'darkslategray') {
            const data = cssTree.fromPlainObject({
              type: 'HexColor',
              value: '556832'
            });
            list.replace(item, list.createItem(data));
          }
        }
    
        cssTree.walk(parsedValue, {
          visit: 'HexColor',
          enter: hexColorVisitor
        });
        cssTree.walk(parsedValue, {
          visit: 'Function',
          enter: functionVisitor
        });
    

rgb(), rgba(), hsl() и hsla()

        { type: "Value", children: [
          { type: "Function", name: "rgb", "children": [
            { type: "Number", value: "47"},
            { type: "Operator", value: ","},
            { type: "Number", value: "79"},
            { type: "Operator", value: ","},
            { type: "Number", value: "79"}
          ] }
        ] }
    
        { type: "Value", children: [
          { type: "Function", name: "rgb", "children": [
            { type: "Percentage", value: "18.4%"},
            { type: "Operator", value: ","},
            { type: "Percentage", value: "30.98%"},
            { type: "Operator", value: ","},
            { type: "Percentage", value: "30.98%"}
          ] }
        ] }
    
Взрывающаяся голова

postcss-alter-color

  1. Познакомились с AST explorer.
  2. Разобрались с API PostCSS и CSSTree.
  3. Написали плагин, который делает больше, чем просто поиск и замена в текстовом редакторе.

Пример №2

postcss-themes-generator

  Website Portal Business International
Primary        
Secondary        
Accent        

Тема «Website»

Тема «Student Portal»

Тема «International»

Тема «Business»

CSS custom properties

        :root                { --primary-color:  #E54096; }
        .theme-portal        { --primary-color:  #0080C5; }
        .theme-business      { --primary-color:  #C1D730; }
        .theme-international { --primary-color:  #E5352D; }
        .tile_primary { background: var(--primary-color); }
    

Решения

Условия задачи

Конфигурация

[ { "name": "primary", "match": "#E54096",
  "themes": {
    "portal": "#0080C5",
    "business": "#C1D730",
    "international": "#E5352D"
  }
}, { name: "secondary", … } ]

Результат

        .tile_primary { color:  #E54096 }
        .theme-portal .tile_primary { color:  #0080C5 }
        .theme-business .tile_primary { color:  #C1D730 }
        .theme-international .tile_primary { color:  #E5352D }
    
        const plugin = options => (root, result) => {
		  root.walkRules(rule => {
            const config = getConfig(rule, options);
            const themed = Object.keys(config.themes)
              .map(name => makeThemedRule(rule, name, config));
            themed.unshift(rule.clone());
            rule.replaceWith(themed);
          });
        };
    
        function getConfig(rule, options) {
          return options.find(config => {
            const p = `_${config.name}(:[a-zA-Z]+)?(\\s.*)?$`;
            const rx = new RegExp(p);
            return rule.selectors.every(s => rx.test(s));
          });
        }
    
        {
          "name": "primary",
          "match": "#E54096",
          "themes": {
            "portal": "#0080C5",
            "business": "#C1D730",
            "international": "#E5352D"
          }
        }
    
        const plugin = options => (root, result) => {
		  root.walkRules(rule => {
            const config = getConfig(rule, options);
            const themed = Object.keys(config.themes)
              .map(name => makeThemedRule(rule, name, config));
            themed.unshift(rule.clone());
            rule.replaceWith(themed);
          });
        };
    
        function makeThemedRule(rule, themeName, config) {
          const themedRule = rule.clone();
          themedRule.selectors = themedRule.selectors
            .map(selector => `.theme-${themeName} ${selector}`);
          const processor = getDeclProcessor(themeName, config);
          themedRule.walkDecls(processor);
          return themedRule;
        }
    
        function getDeclProcessor(themeName, config) {
          return decl => {
            const ast = cssTree
              .parse(decl.value, { context: 'value' });
            cssTree.walk(ast, { visit: 'HexColor', … });
            cssTree.walk(ast, { visit: 'Function', … });
            decl.value = cssTree.generate(ast);
          };
        }
    

Подозрительное правило

Модификатор есть, а изменить ничего нельзя!

rule.warn(result, 'Suspicious rule');
Подозрения

Сложные случаи

        .tile_primary .text-tile__heading {
          color: #E54096;
        }
        .tile__badge_primary:hover {
          background-color: #E54096;
        }
    

Светлые и тёмные оттенки

        .button_primary[disabled] { 
          background: lighten( #E54096, 20%); /*  #F19AC7 */
        }
        .overlay_primary {
          background: darken( #E54096, 20%); /*  #A81763 */
        }
    

Светлые и тёмные оттенки

Тёмный Базовый Светлый
hsl(329, 76%, 37%) hsl(329, 76%, 57%) hsl(329, 76%, 77%)
hsl(201, 100%, 19%) hsl(201, 100%, 39%) hsl(201, 100%, 59%)
hsl(68, 68%, 32%) hsl(68, 68%, 52%) hsl(68, 68%, 72%)
hsl(3, 78%, 34%) hsl(3, 78%, 54%) hsl(3, 78%, 74%)

В цветовом пространстве HSL компоненты hue и saturation будут без изменений, а разницу значений компоненты lightness нужно добавить к цвету темы.

#F19AC7 hsl(329, 76%, 77%) hsl(329, 76%, 57%) → Δl = 20%

#0080C5 hsl(201, 100%, 39%) hsl(201, 100%, 59%) #2EB6FF

postcss-themes-generator

  1. Плагин позволил команде поддерживать чистый и компактный CSS.
  2. Плагин обрабатывает оттенки базовых цветов.
  3. Сгенерированные стили работают в старых браузерах.

Abstract Syntax Tree

James Steinbach

Writing Your First PostCSS Plugin

Владимир Кузнецов

@mistakster (English)
@mista_k (больше про жизнь)

noteskeeper.ru

Слайды презентации: bit.ly/ft18-cm