UNPKG

webdash-readme-preview

Version:
483 lines (463 loc) 15.3 kB
/** @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']); // :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 * @return {string} */ transformCssText(cssText) { // produce variables cssText = cssText.replace(VAR_ASSIGN, (matchText, propertyName, valueProperty, valueMixin) => this._produceCssProperties(matchText, propertyName, valueProperty, valueMixin)); // consume mixins return this._consumeCssProperties(cssText); } /** * @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); } /** * replace mixin consumption with variable consumption * @param {string} text * @return {string} */ _consumeCssProperties(text) { /** @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 = 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 * @return {!Object<string, string>} */ _cssTextToMap(text) { 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 = this._replaceInitialOrInherit(property, sp.slice(1).join(':')); 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 * @return {string} */ _produceCssProperties(matchText, propertyName, valueProperty, valueMixin) { // 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); let prefix = matchText.slice(0, matchText.indexOf('--')); let mixinValues = this._cssTextToMap(mixinAsProperties); 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 */ 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; Object.defineProperty(ApplyShim.prototype, 'invalidCallback', { /** @return {?function(string)} */ get() { return invalidCallback; }, /** @param {?function(string)} cb */ set(cb) { invalidCallback = cb; } }); export default ApplyShim;