• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2@license
3Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
4This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
5The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
6The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
7Code distributed by Google as part of the polymer project is also
8subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
9*/
10/*
11 * The apply shim simulates the behavior of `@apply` proposed at
12 * https://tabatkins.github.io/specs/css-apply-rule/.
13 * The approach is to convert a property like this:
14 *
15 *    --foo: {color: red; background: blue;}
16 *
17 * to this:
18 *
19 *    --foo_-_color: red;
20 *    --foo_-_background: blue;
21 *
22 * Then where `@apply --foo` is used, that is converted to:
23 *
24 *    color: var(--foo_-_color);
25 *    background: var(--foo_-_background);
26 *
27 * This approach generally works but there are some issues and limitations.
28 * Consider, for example, that somewhere *between* where `--foo` is set and used,
29 * another element sets it to:
30 *
31 *    --foo: { border: 2px solid red; }
32 *
33 * We must now ensure that the color and background from the previous setting
34 * do not apply. This is accomplished by changing the property set to this:
35 *
36 *    --foo_-_border: 2px solid red;
37 *    --foo_-_color: initial;
38 *    --foo_-_background: initial;
39 *
40 * This works but introduces one new issue.
41 * Consider this setup at the point where the `@apply` is used:
42 *
43 *    background: orange;
44 *    `@apply` --foo;
45 *
46 * In this case the background will be unset (initial) rather than the desired
47 * `orange`. We address this by altering the property set to use a fallback
48 * value like this:
49 *
50 *    color: var(--foo_-_color);
51 *    background: var(--foo_-_background, orange);
52 *    border: var(--foo_-_border);
53 *
54 * Note that the default is retained in the property set and the `background` is
55 * the desired `orange`. This leads us to a limitation.
56 *
57 * Limitation 1:
58
59 * Only properties in the rule where the `@apply`
60 * is used are considered as default values.
61 * If another rule matches the element and sets `background` with
62 * less specificity than the rule in which `@apply` appears,
63 * the `background` will not be set.
64 *
65 * Limitation 2:
66 *
67 * When using Polymer's `updateStyles` api, new properties may not be set for
68 * `@apply` properties.
69
70*/
71
72'use strict';
73
74import {forEachRule, processVariableAndFallback, rulesForStyle, toCssText, gatherStyleText} from './style-util.js';
75import {MIXIN_MATCH, VAR_ASSIGN} from './common-regex.js';
76import {detectMixin} from './common-utils.js';
77import {StyleNode} from './css-parse.js'; // eslint-disable-line no-unused-vars
78
79const APPLY_NAME_CLEAN = /;\s*/m;
80const INITIAL_INHERIT = /^\s*(initial)|(inherit)\s*$/;
81const IMPORTANT = /\s*!important/;
82
83// separator used between mixin-name and mixin-property-name when producing properties
84// NOTE: plain '-' may cause collisions in user styles
85const MIXIN_VAR_SEP = '_-_';
86
87/**
88 * @typedef {!Object<string, string>}
89 */
90let PropertyEntry; // eslint-disable-line no-unused-vars
91
92/**
93 * @typedef {!Object<string, boolean>}
94 */
95let DependantsEntry; // eslint-disable-line no-unused-vars
96
97/** @typedef {{
98 *    properties: PropertyEntry,
99 *    dependants: DependantsEntry
100 * }}
101 */
102let MixinMapEntry; // eslint-disable-line no-unused-vars
103
104// map of mixin to property names
105// --foo: {border: 2px} -> {properties: {(--foo, ['border'])}, dependants: {'element-name': proto}}
106class MixinMap {
107  constructor() {
108    /** @type {!Object<string, !MixinMapEntry>} */
109    this._map = {};
110  }
111  /**
112   * @param {string} name
113   * @param {!PropertyEntry} props
114   */
115  set(name, props) {
116    name = name.trim();
117    this._map[name] = {
118      properties: props,
119      dependants: {}
120    }
121  }
122  /**
123   * @param {string} name
124   * @return {MixinMapEntry}
125   */
126  get(name) {
127    name = name.trim();
128    return this._map[name] || null;
129  }
130}
131
132/**
133 * Callback for when an element is marked invalid
134 * @type {?function(string)}
135 */
136let invalidCallback = null;
137
138/** @unrestricted */
139class ApplyShim {
140  constructor() {
141    /** @type {?string} */
142    this._currentElement = null;
143    /** @type {HTMLMetaElement} */
144    this._measureElement = null;
145    this._map = new MixinMap();
146  }
147  /**
148   * return true if `cssText` contains a mixin definition or consumption
149   * @param {string} cssText
150   * @return {boolean}
151   */
152  detectMixin(cssText) {
153    return detectMixin(cssText);
154  }
155
156  /**
157   * Gather styles into one style for easier processing
158   * @param {!HTMLTemplateElement} template
159   * @return {HTMLStyleElement}
160   */
161  gatherStyles(template) {
162    const styleText = gatherStyleText(template.content);
163    if (styleText) {
164      const style = /** @type {!HTMLStyleElement} */(document.createElement('style'));
165      style.textContent = styleText;
166      template.content.insertBefore(style, template.content.firstChild);
167      return style;
168    }
169    return null;
170  }
171  /**
172   * @param {!HTMLTemplateElement} template
173   * @param {string} elementName
174   * @return {StyleNode}
175   */
176  transformTemplate(template, elementName) {
177    if (template._gatheredStyle === undefined) {
178      template._gatheredStyle = this.gatherStyles(template);
179    }
180    /** @type {HTMLStyleElement} */
181    const style = template._gatheredStyle;
182    return style ? this.transformStyle(style, elementName) : null;
183  }
184  /**
185   * @param {!HTMLStyleElement} style
186   * @param {string} elementName
187   * @return {StyleNode}
188   */
189  transformStyle(style, elementName = '') {
190    let ast = rulesForStyle(style);
191    this.transformRules(ast, elementName);
192    style.textContent = toCssText(ast);
193    return ast;
194  }
195  /**
196   * @param {!HTMLStyleElement} style
197   * @return {StyleNode}
198   */
199  transformCustomStyle(style) {
200    let ast = rulesForStyle(style);
201    forEachRule(ast, (rule) => {
202      if (rule['selector'] === ':root') {
203        rule['selector'] = 'html';
204      }
205      this.transformRule(rule);
206    })
207    style.textContent = toCssText(ast);
208    return ast;
209  }
210  /**
211   * @param {StyleNode} rules
212   * @param {string} elementName
213   */
214  transformRules(rules, elementName) {
215    this._currentElement = elementName;
216    forEachRule(rules, (r) => {
217      this.transformRule(r);
218    });
219    this._currentElement = null;
220  }
221  /**
222   * @param {!StyleNode} rule
223   */
224  transformRule(rule) {
225    rule['cssText'] = this.transformCssText(rule['parsedCssText'], rule);
226    // :root was only used for variable assignment in property shim,
227    // but generates invalid selectors with real properties.
228    // replace with `:host > *`, which serves the same effect
229    if (rule['selector'] === ':root') {
230      rule['selector'] = ':host > *';
231    }
232  }
233  /**
234   * @param {string} cssText
235   * @param {!StyleNode} rule
236   * @return {string}
237   */
238  transformCssText(cssText, rule) {
239    // produce variables
240    cssText = cssText.replace(VAR_ASSIGN, (matchText, propertyName, valueProperty, valueMixin) =>
241      this._produceCssProperties(matchText, propertyName, valueProperty, valueMixin, rule));
242    // consume mixins
243    return this._consumeCssProperties(cssText, rule);
244  }
245  /**
246   * @param {string} property
247   * @return {string}
248   */
249  _getInitialValueForProperty(property) {
250    if (!this._measureElement) {
251      this._measureElement = /** @type {HTMLMetaElement} */(document.createElement('meta'));
252      this._measureElement.setAttribute('apply-shim-measure', '');
253      this._measureElement.style.all = 'initial';
254      document.head.appendChild(this._measureElement);
255    }
256    return window.getComputedStyle(this._measureElement).getPropertyValue(property);
257  }
258  /**
259   * Walk over all rules before this rule to find fallbacks for mixins
260   *
261   * @param {!StyleNode} startRule
262   * @return {!Object}
263   */
264  _fallbacksFromPreviousRules(startRule) {
265    // find the "top" rule
266    let topRule = startRule;
267    while (topRule['parent']) {
268      topRule = topRule['parent'];
269    }
270    const fallbacks = {};
271    let seenStartRule = false;
272    forEachRule(topRule, (r) => {
273      // stop when we hit the input rule
274      seenStartRule = seenStartRule || r === startRule;
275      if (seenStartRule) {
276        return;
277      }
278      // NOTE: Only matching selectors are "safe" for this fallback processing
279      // It would be prohibitive to run `matchesSelector()` on each selector,
280      // so we cheat and only check if the same selector string is used, which
281      // guarantees things like specificity matching
282      if (r['selector'] === startRule['selector']) {
283        Object.assign(fallbacks, this._cssTextToMap(r['parsedCssText']));
284      }
285    });
286    return fallbacks;
287  }
288  /**
289   * replace mixin consumption with variable consumption
290   * @param {string} text
291   * @param {!StyleNode=} rule
292   * @return {string}
293   */
294  _consumeCssProperties(text, rule) {
295    /** @type {Array} */
296    let m = null;
297    // loop over text until all mixins with defintions have been applied
298    while((m = MIXIN_MATCH.exec(text))) {
299      let matchText = m[0];
300      let mixinName = m[1];
301      let idx = m.index;
302      // collect properties before apply to be "defaults" if mixin might override them
303      // match includes a "prefix", so find the start and end positions of @apply
304      let applyPos = idx + matchText.indexOf('@apply');
305      let afterApplyPos = idx + matchText.length;
306      // find props defined before this @apply
307      let textBeforeApply = text.slice(0, applyPos);
308      let textAfterApply = text.slice(afterApplyPos);
309      let defaults = rule ? this._fallbacksFromPreviousRules(rule) : {};
310      Object.assign(defaults, this._cssTextToMap(textBeforeApply));
311      let replacement = this._atApplyToCssProperties(mixinName, defaults);
312      // use regex match position to replace mixin, keep linear processing time
313      text = `${textBeforeApply}${replacement}${textAfterApply}`;
314      // move regex search to _after_ replacement
315      MIXIN_MATCH.lastIndex = idx + replacement.length;
316    }
317    return text;
318  }
319  /**
320   * produce variable consumption at the site of mixin consumption
321   * `@apply` --foo; -> for all props (${propname}: var(--foo_-_${propname}, ${fallback[propname]}}))
322   * Example:
323   *  border: var(--foo_-_border); padding: var(--foo_-_padding, 2px)
324   *
325   * @param {string} mixinName
326   * @param {Object} fallbacks
327   * @return {string}
328   */
329  _atApplyToCssProperties(mixinName, fallbacks) {
330    mixinName = mixinName.replace(APPLY_NAME_CLEAN, '');
331    let vars = [];
332    let mixinEntry = this._map.get(mixinName);
333    // if we depend on a mixin before it is created
334    // make a sentinel entry in the map to add this element as a dependency for when it is defined.
335    if (!mixinEntry) {
336      this._map.set(mixinName, {});
337      mixinEntry = this._map.get(mixinName);
338    }
339    if (mixinEntry) {
340      if (this._currentElement) {
341        mixinEntry.dependants[this._currentElement] = true;
342      }
343      let p, parts, f;
344      const properties = mixinEntry.properties;
345      for (p in properties) {
346        f = fallbacks && fallbacks[p];
347        parts = [p, ': var(', mixinName, MIXIN_VAR_SEP, p];
348        if (f) {
349          parts.push(',', f.replace(IMPORTANT, ''));
350        }
351        parts.push(')');
352        if (IMPORTANT.test(properties[p])) {
353          parts.push(' !important');
354        }
355        vars.push(parts.join(''));
356      }
357    }
358    return vars.join('; ');
359  }
360
361  /**
362   * @param {string} property
363   * @param {string} value
364   * @return {string}
365   */
366  _replaceInitialOrInherit(property, value) {
367    let match = INITIAL_INHERIT.exec(value);
368    if (match) {
369      if (match[1]) {
370        // initial
371        // replace `initial` with the concrete initial value for this property
372        value = this._getInitialValueForProperty(property);
373      } else {
374        // inherit
375        // with this purposfully illegal value, the variable will be invalid at
376        // compute time (https://www.w3.org/TR/css-variables/#invalid-at-computed-value-time)
377        // and for inheriting values, will behave similarly
378        // we cannot support the same behavior for non inheriting values like 'border'
379        value = 'apply-shim-inherit';
380      }
381    }
382    return value;
383  }
384
385  /**
386   * "parse" a mixin definition into a map of properties and values
387   * cssTextToMap('border: 2px solid black') -> ('border', '2px solid black')
388   * @param {string} text
389   * @param {boolean=} replaceInitialOrInherit
390   * @return {!Object<string, string>}
391   */
392  _cssTextToMap(text, replaceInitialOrInherit = false) {
393    let props = text.split(';');
394    let property, value;
395    let out = {};
396    for (let i = 0, p, sp; i < props.length; i++) {
397      p = props[i];
398      if (p) {
399        sp = p.split(':');
400        // ignore lines that aren't definitions like @media
401        if (sp.length > 1) {
402          property = sp[0].trim();
403          // some properties may have ':' in the value, like data urls
404          value = sp.slice(1).join(':');
405          if (replaceInitialOrInherit) {
406            value = this._replaceInitialOrInherit(property, value);
407          }
408          out[property] = value;
409        }
410      }
411    }
412    return out;
413  }
414
415  /**
416   * @param {MixinMapEntry} mixinEntry
417   */
418  _invalidateMixinEntry(mixinEntry) {
419    if (!invalidCallback) {
420      return;
421    }
422    for (let elementName in mixinEntry.dependants) {
423      if (elementName !== this._currentElement) {
424        invalidCallback(elementName);
425      }
426    }
427  }
428
429  /**
430   * @param {string} matchText
431   * @param {string} propertyName
432   * @param {?string} valueProperty
433   * @param {?string} valueMixin
434   * @param {!StyleNode} rule
435   * @return {string}
436   */
437  _produceCssProperties(matchText, propertyName, valueProperty, valueMixin, rule) {
438    // handle case where property value is a mixin
439    if (valueProperty) {
440      // form: --mixin2: var(--mixin1), where --mixin1 is in the map
441      processVariableAndFallback(valueProperty, (prefix, value) => {
442        if (value && this._map.get(value)) {
443          valueMixin = `@apply ${value};`
444        }
445      });
446    }
447    if (!valueMixin) {
448      return matchText;
449    }
450    let mixinAsProperties = this._consumeCssProperties('' + valueMixin, rule);
451    let prefix = matchText.slice(0, matchText.indexOf('--'));
452    // `initial` and `inherit` as properties in a map should be replaced because
453    // these keywords are eagerly evaluated when the mixin becomes CSS Custom Properties,
454    // and would set the variable value, rather than carry the keyword to the `var()` usage.
455    let mixinValues = this._cssTextToMap(mixinAsProperties, true);
456    let combinedProps = mixinValues;
457    let mixinEntry = this._map.get(propertyName);
458    let oldProps = mixinEntry && mixinEntry.properties;
459    if (oldProps) {
460      // NOTE: since we use mixin, the map of properties is updated here
461      // and this is what we want.
462      combinedProps = Object.assign(Object.create(oldProps), mixinValues);
463    } else {
464      this._map.set(propertyName, combinedProps);
465    }
466    let out = [];
467    let p, v;
468    // set variables defined by current mixin
469    let needToInvalidate = false;
470    for (p in combinedProps) {
471      v = mixinValues[p];
472      // if property not defined by current mixin, set initial
473      if (v === undefined) {
474        v = 'initial';
475      }
476      if (oldProps && !(p in oldProps)) {
477        needToInvalidate = true;
478      }
479      out.push(`${propertyName}${MIXIN_VAR_SEP}${p}: ${v}`);
480    }
481    if (needToInvalidate) {
482      this._invalidateMixinEntry(mixinEntry);
483    }
484    if (mixinEntry) {
485      mixinEntry.properties = combinedProps;
486    }
487    // because the mixinMap is global, the mixin might conflict with
488    // a different scope's simple variable definition:
489    // Example:
490    // some style somewhere:
491    // --mixin1:{ ... }
492    // --mixin2: var(--mixin1);
493    // some other element:
494    // --mixin1: 10px solid red;
495    // --foo: var(--mixin1);
496    // In this case, we leave the original variable definition in place.
497    if (valueProperty) {
498      prefix = `${matchText};${prefix}`;
499    }
500    return `${prefix}${out.join('; ')};`;
501  }
502}
503
504/* exports */
505/* eslint-disable no-self-assign */
506ApplyShim.prototype['detectMixin'] = ApplyShim.prototype.detectMixin;
507ApplyShim.prototype['transformStyle'] = ApplyShim.prototype.transformStyle;
508ApplyShim.prototype['transformCustomStyle'] = ApplyShim.prototype.transformCustomStyle;
509ApplyShim.prototype['transformRules'] = ApplyShim.prototype.transformRules;
510ApplyShim.prototype['transformRule'] = ApplyShim.prototype.transformRule;
511ApplyShim.prototype['transformTemplate'] = ApplyShim.prototype.transformTemplate;
512ApplyShim.prototype['_separator'] = MIXIN_VAR_SEP;
513/* eslint-enable no-self-assign */
514Object.defineProperty(ApplyShim.prototype, 'invalidCallback', {
515  /** @return {?function(string)} */
516  get() {
517    return invalidCallback;
518  },
519  /** @param {?function(string)} cb */
520  set(cb) {
521    invalidCallback = cb;
522  }
523});
524
525export default ApplyShim;
526