diff options
Diffstat (limited to 'catapult/third_party/polymer/components/shadycss/src/apply-shim.js')
-rw-r--r-- | catapult/third_party/polymer/components/shadycss/src/apply-shim.js | 525 |
1 files changed, 525 insertions, 0 deletions
diff --git a/catapult/third_party/polymer/components/shadycss/src/apply-shim.js b/catapult/third_party/polymer/components/shadycss/src/apply-shim.js new file mode 100644 index 00000000..e4bc9cdf --- /dev/null +++ b/catapult/third_party/polymer/components/shadycss/src/apply-shim.js @@ -0,0 +1,525 @@ +/** +@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; |