blob: e4bc9cdf11e349940c00484c4bdff2ea1595ac17 [file] [log] [blame]
/**
@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;