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