| /** |
| @license |
| Copyright (c) 2017 The Polymer Project Authors. All rights reserved. |
| This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt |
| The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt |
| The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt |
| Code distributed by Google as part of the polymer project is also |
| subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt |
| */ |
| /* |
| * The apply shim simulates the behavior of `@apply` proposed at |
| * https://tabatkins.github.io/specs/css-apply-rule/. |
| * The approach is to convert a property like this: |
| * |
| * --foo: {color: red; background: blue;} |
| * |
| * to this: |
| * |
| * --foo_-_color: red; |
| * --foo_-_background: blue; |
| * |
| * Then where `@apply --foo` is used, that is converted to: |
| * |
| * color: var(--foo_-_color); |
| * background: var(--foo_-_background); |
| * |
| * This approach generally works but there are some issues and limitations. |
| * Consider, for example, that somewhere *between* where `--foo` is set and used, |
| * another element sets it to: |
| * |
| * --foo: { border: 2px solid red; } |
| * |
| * We must now ensure that the color and background from the previous setting |
| * do not apply. This is accomplished by changing the property set to this: |
| * |
| * --foo_-_border: 2px solid red; |
| * --foo_-_color: initial; |
| * --foo_-_background: initial; |
| * |
| * This works but introduces one new issue. |
| * Consider this setup at the point where the `@apply` is used: |
| * |
| * background: orange; |
| * `@apply` --foo; |
| * |
| * In this case the background will be unset (initial) rather than the desired |
| * `orange`. We address this by altering the property set to use a fallback |
| * value like this: |
| * |
| * color: var(--foo_-_color); |
| * background: var(--foo_-_background, orange); |
| * border: var(--foo_-_border); |
| * |
| * Note that the default is retained in the property set and the `background` is |
| * the desired `orange`. This leads us to a limitation. |
| * |
| * Limitation 1: |
| |
| * Only properties in the rule where the `@apply` |
| * is used are considered as default values. |
| * If another rule matches the element and sets `background` with |
| * less specificity than the rule in which `@apply` appears, |
| * the `background` will not be set. |
| * |
| * Limitation 2: |
| * |
| * When using Polymer's `updateStyles` api, new properties may not be set for |
| * `@apply` properties. |
| |
| */ |
| |
| 'use strict'; |
| |
| import {forEachRule, processVariableAndFallback, rulesForStyle, toCssText, gatherStyleText} from './style-util.js'; |
| import {MIXIN_MATCH, VAR_ASSIGN} from './common-regex.js'; |
| import {detectMixin} from './common-utils.js'; |
| import {StyleNode} from './css-parse.js'; // eslint-disable-line no-unused-vars |
| |
| const APPLY_NAME_CLEAN = /;\s*/m; |
| const INITIAL_INHERIT = /^\s*(initial)|(inherit)\s*$/; |
| const IMPORTANT = /\s*!important/; |
| |
| // separator used between mixin-name and mixin-property-name when producing properties |
| // NOTE: plain '-' may cause collisions in user styles |
| const MIXIN_VAR_SEP = '_-_'; |
| |
| /** |
| * @typedef {!Object<string, string>} |
| */ |
| let PropertyEntry; // eslint-disable-line no-unused-vars |
| |
| /** |
| * @typedef {!Object<string, boolean>} |
| */ |
| let DependantsEntry; // eslint-disable-line no-unused-vars |
| |
| /** @typedef {{ |
| * properties: PropertyEntry, |
| * dependants: DependantsEntry |
| * }} |
| */ |
| let MixinMapEntry; // eslint-disable-line no-unused-vars |
| |
| // map of mixin to property names |
| // --foo: {border: 2px} -> {properties: {(--foo, ['border'])}, dependants: {'element-name': proto}} |
| class MixinMap { |
| constructor() { |
| /** @type {!Object<string, !MixinMapEntry>} */ |
| this._map = {}; |
| } |
| /** |
| * @param {string} name |
| * @param {!PropertyEntry} props |
| */ |
| set(name, props) { |
| name = name.trim(); |
| this._map[name] = { |
| properties: props, |
| dependants: {} |
| } |
| } |
| /** |
| * @param {string} name |
| * @return {MixinMapEntry} |
| */ |
| get(name) { |
| name = name.trim(); |
| return this._map[name] || null; |
| } |
| } |
| |
| /** |
| * Callback for when an element is marked invalid |
| * @type {?function(string)} |
| */ |
| let invalidCallback = null; |
| |
| /** @unrestricted */ |
| class ApplyShim { |
| constructor() { |
| /** @type {?string} */ |
| this._currentElement = null; |
| /** @type {HTMLMetaElement} */ |
| this._measureElement = null; |
| this._map = new MixinMap(); |
| } |
| /** |
| * return true if `cssText` contains a mixin definition or consumption |
| * @param {string} cssText |
| * @return {boolean} |
| */ |
| detectMixin(cssText) { |
| return detectMixin(cssText); |
| } |
| |
| /** |
| * Gather styles into one style for easier processing |
| * @param {!HTMLTemplateElement} template |
| * @return {HTMLStyleElement} |
| */ |
| gatherStyles(template) { |
| const styleText = gatherStyleText(template.content); |
| if (styleText) { |
| const style = /** @type {!HTMLStyleElement} */(document.createElement('style')); |
| style.textContent = styleText; |
| template.content.insertBefore(style, template.content.firstChild); |
| return style; |
| } |
| return null; |
| } |
| /** |
| * @param {!HTMLTemplateElement} template |
| * @param {string} elementName |
| * @return {StyleNode} |
| */ |
| transformTemplate(template, elementName) { |
| if (template._gatheredStyle === undefined) { |
| template._gatheredStyle = this.gatherStyles(template); |
| } |
| /** @type {HTMLStyleElement} */ |
| const style = template._gatheredStyle; |
| return style ? this.transformStyle(style, elementName) : null; |
| } |
| /** |
| * @param {!HTMLStyleElement} style |
| * @param {string} elementName |
| * @return {StyleNode} |
| */ |
| transformStyle(style, elementName = '') { |
| let ast = rulesForStyle(style); |
| this.transformRules(ast, elementName); |
| style.textContent = toCssText(ast); |
| return ast; |
| } |
| /** |
| * @param {!HTMLStyleElement} style |
| * @return {StyleNode} |
| */ |
| transformCustomStyle(style) { |
| let ast = rulesForStyle(style); |
| forEachRule(ast, (rule) => { |
| if (rule['selector'] === ':root') { |
| rule['selector'] = 'html'; |
| } |
| this.transformRule(rule); |
| }) |
| style.textContent = toCssText(ast); |
| return ast; |
| } |
| /** |
| * @param {StyleNode} rules |
| * @param {string} elementName |
| */ |
| transformRules(rules, elementName) { |
| this._currentElement = elementName; |
| forEachRule(rules, (r) => { |
| this.transformRule(r); |
| }); |
| this._currentElement = null; |
| } |
| /** |
| * @param {!StyleNode} rule |
| */ |
| transformRule(rule) { |
| rule['cssText'] = this.transformCssText(rule['parsedCssText'], rule); |
| // :root was only used for variable assignment in property shim, |
| // but generates invalid selectors with real properties. |
| // replace with `:host > *`, which serves the same effect |
| if (rule['selector'] === ':root') { |
| rule['selector'] = ':host > *'; |
| } |
| } |
| /** |
| * @param {string} cssText |
| * @param {!StyleNode} rule |
| * @return {string} |
| */ |
| transformCssText(cssText, rule) { |
| // produce variables |
| cssText = cssText.replace(VAR_ASSIGN, (matchText, propertyName, valueProperty, valueMixin) => |
| this._produceCssProperties(matchText, propertyName, valueProperty, valueMixin, rule)); |
| // consume mixins |
| return this._consumeCssProperties(cssText, rule); |
| } |
| /** |
| * @param {string} property |
| * @return {string} |
| */ |
| _getInitialValueForProperty(property) { |
| if (!this._measureElement) { |
| this._measureElement = /** @type {HTMLMetaElement} */(document.createElement('meta')); |
| this._measureElement.setAttribute('apply-shim-measure', ''); |
| this._measureElement.style.all = 'initial'; |
| document.head.appendChild(this._measureElement); |
| } |
| return window.getComputedStyle(this._measureElement).getPropertyValue(property); |
| } |
| /** |
| * Walk over all rules before this rule to find fallbacks for mixins |
| * |
| * @param {!StyleNode} startRule |
| * @return {!Object} |
| */ |
| _fallbacksFromPreviousRules(startRule) { |
| // find the "top" rule |
| let topRule = startRule; |
| while (topRule['parent']) { |
| topRule = topRule['parent']; |
| } |
| const fallbacks = {}; |
| let seenStartRule = false; |
| forEachRule(topRule, (r) => { |
| // stop when we hit the input rule |
| seenStartRule = seenStartRule || r === startRule; |
| if (seenStartRule) { |
| return; |
| } |
| // NOTE: Only matching selectors are "safe" for this fallback processing |
| // It would be prohibitive to run `matchesSelector()` on each selector, |
| // so we cheat and only check if the same selector string is used, which |
| // guarantees things like specificity matching |
| if (r['selector'] === startRule['selector']) { |
| Object.assign(fallbacks, this._cssTextToMap(r['parsedCssText'])); |
| } |
| }); |
| return fallbacks; |
| } |
| /** |
| * replace mixin consumption with variable consumption |
| * @param {string} text |
| * @param {!StyleNode=} rule |
| * @return {string} |
| */ |
| _consumeCssProperties(text, rule) { |
| /** @type {Array} */ |
| let m = null; |
| // loop over text until all mixins with defintions have been applied |
| while((m = MIXIN_MATCH.exec(text))) { |
| let matchText = m[0]; |
| let mixinName = m[1]; |
| let idx = m.index; |
| // collect properties before apply to be "defaults" if mixin might override them |
| // match includes a "prefix", so find the start and end positions of @apply |
| let applyPos = idx + matchText.indexOf('@apply'); |
| let afterApplyPos = idx + matchText.length; |
| // find props defined before this @apply |
| let textBeforeApply = text.slice(0, applyPos); |
| let textAfterApply = text.slice(afterApplyPos); |
| let defaults = rule ? this._fallbacksFromPreviousRules(rule) : {}; |
| Object.assign(defaults, this._cssTextToMap(textBeforeApply)); |
| let replacement = this._atApplyToCssProperties(mixinName, defaults); |
| // use regex match position to replace mixin, keep linear processing time |
| text = `${textBeforeApply}${replacement}${textAfterApply}`; |
| // move regex search to _after_ replacement |
| MIXIN_MATCH.lastIndex = idx + replacement.length; |
| } |
| return text; |
| } |
| /** |
| * produce variable consumption at the site of mixin consumption |
| * `@apply` --foo; -> for all props (${propname}: var(--foo_-_${propname}, ${fallback[propname]}})) |
| * Example: |
| * border: var(--foo_-_border); padding: var(--foo_-_padding, 2px) |
| * |
| * @param {string} mixinName |
| * @param {Object} fallbacks |
| * @return {string} |
| */ |
| _atApplyToCssProperties(mixinName, fallbacks) { |
| mixinName = mixinName.replace(APPLY_NAME_CLEAN, ''); |
| let vars = []; |
| let mixinEntry = this._map.get(mixinName); |
| // if we depend on a mixin before it is created |
| // make a sentinel entry in the map to add this element as a dependency for when it is defined. |
| if (!mixinEntry) { |
| this._map.set(mixinName, {}); |
| mixinEntry = this._map.get(mixinName); |
| } |
| if (mixinEntry) { |
| if (this._currentElement) { |
| mixinEntry.dependants[this._currentElement] = true; |
| } |
| let p, parts, f; |
| const properties = mixinEntry.properties; |
| for (p in properties) { |
| f = fallbacks && fallbacks[p]; |
| parts = [p, ': var(', mixinName, MIXIN_VAR_SEP, p]; |
| if (f) { |
| parts.push(',', f.replace(IMPORTANT, '')); |
| } |
| parts.push(')'); |
| if (IMPORTANT.test(properties[p])) { |
| parts.push(' !important'); |
| } |
| vars.push(parts.join('')); |
| } |
| } |
| return vars.join('; '); |
| } |
| |
| /** |
| * @param {string} property |
| * @param {string} value |
| * @return {string} |
| */ |
| _replaceInitialOrInherit(property, value) { |
| let match = INITIAL_INHERIT.exec(value); |
| if (match) { |
| if (match[1]) { |
| // initial |
| // replace `initial` with the concrete initial value for this property |
| value = this._getInitialValueForProperty(property); |
| } else { |
| // inherit |
| // with this purposfully illegal value, the variable will be invalid at |
| // compute time (https://www.w3.org/TR/css-variables/#invalid-at-computed-value-time) |
| // and for inheriting values, will behave similarly |
| // we cannot support the same behavior for non inheriting values like 'border' |
| value = 'apply-shim-inherit'; |
| } |
| } |
| return value; |
| } |
| |
| /** |
| * "parse" a mixin definition into a map of properties and values |
| * cssTextToMap('border: 2px solid black') -> ('border', '2px solid black') |
| * @param {string} text |
| * @param {boolean=} replaceInitialOrInherit |
| * @return {!Object<string, string>} |
| */ |
| _cssTextToMap(text, replaceInitialOrInherit = false) { |
| let props = text.split(';'); |
| let property, value; |
| let out = {}; |
| for (let i = 0, p, sp; i < props.length; i++) { |
| p = props[i]; |
| if (p) { |
| sp = p.split(':'); |
| // ignore lines that aren't definitions like @media |
| if (sp.length > 1) { |
| property = sp[0].trim(); |
| // some properties may have ':' in the value, like data urls |
| value = sp.slice(1).join(':'); |
| if (replaceInitialOrInherit) { |
| value = this._replaceInitialOrInherit(property, value); |
| } |
| out[property] = value; |
| } |
| } |
| } |
| return out; |
| } |
| |
| /** |
| * @param {MixinMapEntry} mixinEntry |
| */ |
| _invalidateMixinEntry(mixinEntry) { |
| if (!invalidCallback) { |
| return; |
| } |
| for (let elementName in mixinEntry.dependants) { |
| if (elementName !== this._currentElement) { |
| invalidCallback(elementName); |
| } |
| } |
| } |
| |
| /** |
| * @param {string} matchText |
| * @param {string} propertyName |
| * @param {?string} valueProperty |
| * @param {?string} valueMixin |
| * @param {!StyleNode} rule |
| * @return {string} |
| */ |
| _produceCssProperties(matchText, propertyName, valueProperty, valueMixin, rule) { |
| // handle case where property value is a mixin |
| if (valueProperty) { |
| // form: --mixin2: var(--mixin1), where --mixin1 is in the map |
| processVariableAndFallback(valueProperty, (prefix, value) => { |
| if (value && this._map.get(value)) { |
| valueMixin = `@apply ${value};` |
| } |
| }); |
| } |
| if (!valueMixin) { |
| return matchText; |
| } |
| let mixinAsProperties = this._consumeCssProperties('' + valueMixin, rule); |
| let prefix = matchText.slice(0, matchText.indexOf('--')); |
| // `initial` and `inherit` as properties in a map should be replaced because |
| // these keywords are eagerly evaluated when the mixin becomes CSS Custom Properties, |
| // and would set the variable value, rather than carry the keyword to the `var()` usage. |
| let mixinValues = this._cssTextToMap(mixinAsProperties, true); |
| let combinedProps = mixinValues; |
| let mixinEntry = this._map.get(propertyName); |
| let oldProps = mixinEntry && mixinEntry.properties; |
| if (oldProps) { |
| // NOTE: since we use mixin, the map of properties is updated here |
| // and this is what we want. |
| combinedProps = Object.assign(Object.create(oldProps), mixinValues); |
| } else { |
| this._map.set(propertyName, combinedProps); |
| } |
| let out = []; |
| let p, v; |
| // set variables defined by current mixin |
| let needToInvalidate = false; |
| for (p in combinedProps) { |
| v = mixinValues[p]; |
| // if property not defined by current mixin, set initial |
| if (v === undefined) { |
| v = 'initial'; |
| } |
| if (oldProps && !(p in oldProps)) { |
| needToInvalidate = true; |
| } |
| out.push(`${propertyName}${MIXIN_VAR_SEP}${p}: ${v}`); |
| } |
| if (needToInvalidate) { |
| this._invalidateMixinEntry(mixinEntry); |
| } |
| if (mixinEntry) { |
| mixinEntry.properties = combinedProps; |
| } |
| // because the mixinMap is global, the mixin might conflict with |
| // a different scope's simple variable definition: |
| // Example: |
| // some style somewhere: |
| // --mixin1:{ ... } |
| // --mixin2: var(--mixin1); |
| // some other element: |
| // --mixin1: 10px solid red; |
| // --foo: var(--mixin1); |
| // In this case, we leave the original variable definition in place. |
| if (valueProperty) { |
| prefix = `${matchText};${prefix}`; |
| } |
| return `${prefix}${out.join('; ')};`; |
| } |
| } |
| |
| /* exports */ |
| /* eslint-disable no-self-assign */ |
| ApplyShim.prototype['detectMixin'] = ApplyShim.prototype.detectMixin; |
| ApplyShim.prototype['transformStyle'] = ApplyShim.prototype.transformStyle; |
| ApplyShim.prototype['transformCustomStyle'] = ApplyShim.prototype.transformCustomStyle; |
| ApplyShim.prototype['transformRules'] = ApplyShim.prototype.transformRules; |
| ApplyShim.prototype['transformRule'] = ApplyShim.prototype.transformRule; |
| ApplyShim.prototype['transformTemplate'] = ApplyShim.prototype.transformTemplate; |
| ApplyShim.prototype['_separator'] = MIXIN_VAR_SEP; |
| /* eslint-enable no-self-assign */ |
| Object.defineProperty(ApplyShim.prototype, 'invalidCallback', { |
| /** @return {?function(string)} */ |
| get() { |
| return invalidCallback; |
| }, |
| /** @param {?function(string)} cb */ |
| set(cb) { |
| invalidCallback = cb; |
| } |
| }); |
| |
| export default ApplyShim; |