| /** |
| @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 |
| */ |
| |
| 'use strict'; |
| |
| import {StyleNode} from './css-parse.js'; // eslint-disable-line no-unused-vars |
| import * as StyleUtil from './style-util.js'; |
| import {nativeShadow} from './style-settings.js'; |
| |
| /* Transforms ShadowDOM styling into ShadyDOM styling |
| |
| * scoping: |
| |
| * elements in scope get scoping selector class="x-foo-scope" |
| * selectors re-written as follows: |
| |
| div button -> div.x-foo-scope button.x-foo-scope |
| |
| * :host -> scopeName |
| |
| * :host(...) -> scopeName... |
| |
| * ::slotted(...) -> scopeName > ... |
| |
| * ...:dir(ltr|rtl) -> [dir="ltr|rtl"] ..., ...[dir="ltr|rtl"] |
| |
| * :host(:dir[rtl]) -> scopeName:dir(rtl) -> [dir="rtl"] scopeName, scopeName[dir="rtl"] |
| |
| */ |
| const SCOPE_NAME = 'style-scope'; |
| |
| class StyleTransformer { |
| get SCOPE_NAME() { |
| return SCOPE_NAME; |
| } |
| /** |
| * Given a node and scope name, add a scoping class to each node |
| * in the tree. This facilitates transforming css into scoped rules. |
| * @param {!Node} node |
| * @param {string} scope |
| * @param {boolean=} shouldRemoveScope |
| * @deprecated |
| */ |
| dom(node, scope, shouldRemoveScope) { |
| const fn = (node) => { |
| this.element(node, scope || '', shouldRemoveScope); |
| }; |
| this._transformDom(node, fn); |
| } |
| |
| /** |
| * Given a node and scope name, add a scoping class to each node in the tree. |
| * @param {!Node} node |
| * @param {string} scope |
| */ |
| domAddScope(node, scope) { |
| const fn = (node) => { |
| this.element(node, scope || ''); |
| }; |
| this._transformDom(node, fn); |
| } |
| |
| /** |
| * @param {!Node} startNode |
| * @param {!function(!Node)} transformer |
| */ |
| _transformDom(startNode, transformer) { |
| if (startNode.nodeType === Node.ELEMENT_NODE) { |
| transformer(startNode) |
| } |
| let c$; |
| if (startNode.localName === 'template') { |
| const template = /** @type {!HTMLTemplateElement} */ (startNode); |
| // In case the template is in svg context, fall back to the node |
| // since it won't be an HTMLTemplateElement with a .content property |
| c$ = (template.content || template._content || template).childNodes; |
| } else { |
| c$ = /** @type {!ParentNode} */ (startNode).children || |
| startNode.childNodes; |
| } |
| if (c$) { |
| for (let i = 0; i < c$.length; i++) { |
| this._transformDom(c$[i], transformer); |
| } |
| } |
| } |
| |
| /** |
| * @param {?} element |
| * @param {?} scope |
| * @param {?=} shouldRemoveScope |
| */ |
| element(element, scope, shouldRemoveScope) { |
| // note: if using classes, we add both the general 'style-scope' class |
| // as well as the specific scope. This enables easy filtering of all |
| // `style-scope` elements |
| if (scope) { |
| // note: svg on IE does not have classList so fallback to class |
| if (element.classList) { |
| if (shouldRemoveScope) { |
| element.classList.remove(SCOPE_NAME); |
| element.classList.remove(scope); |
| } else { |
| element.classList.add(SCOPE_NAME); |
| element.classList.add(scope); |
| } |
| } else if (element.getAttribute) { |
| let c = element.getAttribute(CLASS); |
| if (shouldRemoveScope) { |
| if (c) { |
| let newValue = c.replace(SCOPE_NAME, '').replace(scope, ''); |
| StyleUtil.setElementClassRaw(element, newValue); |
| } |
| } else { |
| let newValue = (c ? c + ' ' : '') + SCOPE_NAME + ' ' + scope; |
| StyleUtil.setElementClassRaw(element, newValue); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Given a node, replace the scoping class to each subnode in the tree. |
| * @param {!Node} node |
| * @param {string} oldScope |
| * @param {string} newScope |
| */ |
| domReplaceScope(node, oldScope, newScope) { |
| const fn = (node) => { |
| this.element(node, oldScope, true); |
| this.element(node, newScope); |
| }; |
| this._transformDom(node, fn); |
| } |
| /** |
| * Given a node, remove the scoping class to each subnode in the tree. |
| * @param {!Node} node |
| * @param {string} oldScope |
| */ |
| domRemoveScope(node, oldScope) { |
| const fn = (node) => { |
| this.element(node, oldScope || '', true); |
| }; |
| this._transformDom(node, fn); |
| } |
| |
| /** |
| * @param {?} element |
| * @param {?} styleRules |
| * @param {?=} callback |
| * @param {string=} cssBuild |
| * @param {string=} cssText |
| * @return {string} |
| */ |
| elementStyles(element, styleRules, callback, cssBuild = '', cssText = '') { |
| // no need to shim selectors if settings.useNativeShadow, also |
| // a shady css build will already have transformed selectors |
| // NOTE: This method may be called as part of static or property shimming. |
| // When there is a targeted build it will not be called for static shimming, |
| // but when the property shim is used it is called and should opt out of |
| // static shimming work when a proper build exists. |
| if (cssText === '') { |
| if (nativeShadow || cssBuild === 'shady') { |
| cssText = StyleUtil.toCssText(styleRules, callback); |
| } else { |
| let {is, typeExtension} = StyleUtil.getIsExtends(element); |
| cssText = this.css(styleRules, is, typeExtension, callback) + '\n\n'; |
| } |
| } |
| return cssText.trim(); |
| } |
| |
| // Given a string of cssText and a scoping string (scope), returns |
| // a string of scoped css where each selector is transformed to include |
| // a class created from the scope. ShadowDOM selectors are also transformed |
| // (e.g. :host) to use the scoping selector. |
| css(rules, scope, ext, callback) { |
| let hostScope = this._calcHostScope(scope, ext); |
| scope = this._calcElementScope(scope); |
| let self = this; |
| return StyleUtil.toCssText(rules, function(/** StyleNode */rule) { |
| if (!rule.isScoped) { |
| self.rule(rule, scope, hostScope); |
| rule.isScoped = true; |
| } |
| if (callback) { |
| callback(rule, scope, hostScope); |
| } |
| }); |
| } |
| |
| _calcElementScope(scope) { |
| if (scope) { |
| return CSS_CLASS_PREFIX + scope; |
| } else { |
| return ''; |
| } |
| } |
| |
| _calcHostScope(scope, ext) { |
| return ext ? `[is=${scope}]` : scope; |
| } |
| |
| rule(rule, scope, hostScope) { |
| this._transformRule(rule, this._transformComplexSelector, |
| scope, hostScope); |
| } |
| |
| /** |
| * transforms a css rule to a scoped rule. |
| * |
| * @param {StyleNode} rule |
| * @param {Function} transformer |
| * @param {string=} scope |
| * @param {string=} hostScope |
| */ |
| _transformRule(rule, transformer, scope, hostScope) { |
| // NOTE: save transformedSelector for subsequent matching of elements |
| // against selectors (e.g. when calculating style properties) |
| rule['selector'] = rule.transformedSelector = |
| this._transformRuleCss(rule, transformer, scope, hostScope); |
| } |
| |
| /** |
| * @param {StyleNode} rule |
| * @param {Function} transformer |
| * @param {string=} scope |
| * @param {string=} hostScope |
| */ |
| _transformRuleCss(rule, transformer, scope, hostScope) { |
| let p$ = StyleUtil.splitSelectorList(rule['selector']); |
| // we want to skip transformation of rules that appear in keyframes, |
| // because they are keyframe selectors, not element selectors. |
| if (!StyleUtil.isKeyframesSelector(rule)) { |
| for (let i=0, l=p$.length, p; (i<l) && (p=p$[i]); i++) { |
| p$[i] = transformer.call(this, p, scope, hostScope); |
| } |
| } |
| return p$.filter((part) => Boolean(part)).join(COMPLEX_SELECTOR_SEP); |
| } |
| |
| /** |
| * @param {string} selector |
| * @return {string} |
| */ |
| _twiddleNthPlus(selector) { |
| return selector.replace(NTH, (m, type, inside) => { |
| if (inside.indexOf('+') > -1) { |
| inside = inside.replace(/\+/g, '___'); |
| } else if (inside.indexOf('___') > -1) { |
| inside = inside.replace(/___/g, '+'); |
| } |
| return `:${type}(${inside})`; |
| }); |
| } |
| |
| /** |
| * Preserve `:matches()` selectors by replacing them with MATCHES_REPLACMENT |
| * and returning an array of `:matches()` selectors. |
| * Use `_replacesMatchesPseudo` to replace the `:matches()` parts |
| * |
| * @param {string} selector |
| * @return {{selector: string, matches: !Array<string>}} |
| */ |
| _preserveMatchesPseudo(selector) { |
| /** @type {!Array<string>} */ |
| const matches = []; |
| let match; |
| while ((match = selector.match(MATCHES))) { |
| const start = match.index; |
| const end = StyleUtil.findMatchingParen(selector, start); |
| if (end === -1) { |
| throw new Error(`${match.input} selector missing ')'`) |
| } |
| const part = selector.slice(start, end + 1); |
| selector = selector.replace(part, MATCHES_REPLACEMENT); |
| matches.push(part); |
| } |
| return {selector, matches}; |
| } |
| |
| /** |
| * Replace MATCHES_REPLACMENT character with the given set of `:matches()` |
| * selectors. |
| * |
| * @param {string} selector |
| * @param {!Array<string>} matches |
| * @return {string} |
| */ |
| _replaceMatchesPseudo(selector, matches) { |
| const parts = selector.split(MATCHES_REPLACEMENT); |
| return matches.reduce((acc, cur, idx) => acc + cur + parts[idx + 1], parts[0]); |
| } |
| |
| /** |
| * @param {string} selector |
| * @param {string} scope |
| * @param {string=} hostScope |
| */ |
| _transformComplexSelector(selector, scope, hostScope) { |
| let stop = false; |
| selector = selector.trim(); |
| // Remove spaces inside of selectors like `:nth-of-type` because it confuses SIMPLE_SELECTOR_SEP |
| let isNth = NTH.test(selector); |
| if (isNth) { |
| selector = selector.replace(NTH, (m, type, inner) => `:${type}(${inner.replace(/\s/g, '')})`) |
| selector = this._twiddleNthPlus(selector); |
| } |
| // Preserve selectors like `:-webkit-any` so that SIMPLE_SELECTOR_SEP does |
| // not get confused by spaces inside the pseudo selector |
| const isMatches = MATCHES.test(selector); |
| /** @type {!Array<string>} */ |
| let matches; |
| if (isMatches) { |
| ({selector, matches} = this._preserveMatchesPseudo(selector)); |
| } |
| selector = selector.replace(SLOTTED_START, `${HOST} $1`); |
| selector = selector.replace(SIMPLE_SELECTOR_SEP, (m, c, s) => { |
| if (!stop) { |
| let info = this._transformCompoundSelector(s, c, scope, hostScope); |
| stop = stop || info.stop; |
| c = info.combinator; |
| s = info.value; |
| } |
| return c + s; |
| }); |
| // replace `:matches()` selectors |
| if (isMatches) { |
| selector = this._replaceMatchesPseudo(selector, matches); |
| } |
| if (isNth) { |
| selector = this._twiddleNthPlus(selector); |
| } |
| return selector; |
| } |
| |
| _transformCompoundSelector(selector, combinator, scope, hostScope) { |
| // replace :host with host scoping class |
| let slottedIndex = selector.indexOf(SLOTTED); |
| if (selector.indexOf(HOST) >= 0) { |
| selector = this._transformHostSelector(selector, hostScope); |
| // replace other selectors with scoping class |
| } else if (slottedIndex !== 0) { |
| selector = scope ? this._transformSimpleSelector(selector, scope) : |
| selector; |
| } |
| // mark ::slotted() scope jump to replace with descendant selector + arg |
| // also ignore left-side combinator |
| let slotted = false; |
| if (slottedIndex >= 0) { |
| combinator = ''; |
| slotted = true; |
| } |
| // process scope jumping selectors up to the scope jump and then stop |
| let stop; |
| if (slotted) { |
| stop = true; |
| if (slotted) { |
| // .zonk ::slotted(.foo) -> .zonk.scope > .foo |
| selector = selector.replace(SLOTTED_PAREN, (m, paren) => ` > ${paren}`); |
| } |
| } |
| selector = selector.replace(DIR_PAREN, (m, before, dir) => |
| `[dir="${dir}"] ${before}, ${before}[dir="${dir}"]`); |
| return {value: selector, combinator, stop}; |
| } |
| |
| _transformSimpleSelector(selector, scope) { |
| const attributes = selector.split(/(\[.+?\])/); |
| |
| const output = []; |
| for (let i = 0; i < attributes.length; i++) { |
| // Do not attempt to transform any attribute selector content |
| if ((i % 2) === 1) { |
| output.push(attributes[i]); |
| } else { |
| const part = attributes[i]; |
| |
| if (!(part === '' && i === attributes.length - 1)) { |
| let p$ = part.split(PSEUDO_PREFIX); |
| p$[0] += scope; |
| output.push(p$.join(PSEUDO_PREFIX)); |
| } |
| } |
| } |
| |
| return output.join(''); |
| } |
| |
| // :host(...) -> scopeName... |
| _transformHostSelector(selector, hostScope) { |
| let m = selector.match(HOST_PAREN); |
| let paren = m && m[2].trim() || ''; |
| if (paren) { |
| if (!paren[0].match(SIMPLE_SELECTOR_PREFIX)) { |
| // paren starts with a type selector |
| let typeSelector = paren.split(SIMPLE_SELECTOR_PREFIX)[0]; |
| // if the type selector is our hostScope then avoid pre-pending it |
| if (typeSelector === hostScope) { |
| return paren; |
| // otherwise, this selector should not match in this scope so |
| // output a bogus selector. |
| } else { |
| return SELECTOR_NO_MATCH; |
| } |
| } else { |
| // make sure to do a replace here to catch selectors like: |
| // `:host(.foo)::before` |
| return selector.replace(HOST_PAREN, function(m, host, paren) { |
| return hostScope + paren; |
| }); |
| } |
| // if no paren, do a straight :host replacement. |
| // TODO(sorvell): this should not strictly be necessary but |
| // it's needed to maintain support for `:host[foo]` type selectors |
| // which have been improperly used under Shady DOM. This should be |
| // deprecated. |
| } else { |
| return selector.replace(HOST, hostScope); |
| } |
| } |
| |
| /** |
| * @param {StyleNode} rule |
| */ |
| documentRule(rule) { |
| // reset selector in case this is redone. |
| rule['selector'] = rule['parsedSelector']; |
| this.normalizeRootSelector(rule); |
| this._transformRule(rule, this._transformDocumentSelector); |
| } |
| |
| /** |
| * @param {StyleNode} rule |
| */ |
| normalizeRootSelector(rule) { |
| if (rule['selector'] === ROOT) { |
| rule['selector'] = 'html'; |
| } |
| } |
| |
| /** |
| * @param {string} selector |
| */ |
| _transformDocumentSelector(selector) { |
| if (selector.match(HOST)) { |
| // remove ':host' type selectors in document rules |
| return ''; |
| } else if (selector.match(SLOTTED)) { |
| return this._transformComplexSelector(selector, SCOPE_DOC_SELECTOR) |
| } else { |
| return this._transformSimpleSelector(selector.trim(), SCOPE_DOC_SELECTOR); |
| } |
| } |
| } |
| |
| const NTH = /:(nth[-\w]+)\(([^)]+)\)/; |
| const SCOPE_DOC_SELECTOR = `:not(.${SCOPE_NAME})`; |
| const COMPLEX_SELECTOR_SEP = ','; |
| const SIMPLE_SELECTOR_SEP = /(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=[])+)/g; |
| const SIMPLE_SELECTOR_PREFIX = /[[.:#*]/; |
| const HOST = ':host'; |
| const ROOT = ':root'; |
| const SLOTTED = '::slotted'; |
| const SLOTTED_START = new RegExp(`^(${SLOTTED})`); |
| // NOTE: this supports 1 nested () pair for things like |
| // :host(:not([selected]), more general support requires |
| // parsing which seems like overkill |
| const HOST_PAREN = /(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/; |
| // similar to HOST_PAREN |
| const SLOTTED_PAREN = /(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/; |
| const DIR_PAREN = /(.*):dir\((?:(ltr|rtl))\)/; |
| const CSS_CLASS_PREFIX = '.'; |
| const PSEUDO_PREFIX = ':'; |
| const CLASS = 'class'; |
| const SELECTOR_NO_MATCH = 'should_not_match'; |
| const MATCHES = /:(?:matches|any|-(?:webkit|moz)-any)/; |
| const MATCHES_REPLACEMENT = '\u{e000}'; |
| |
| export default new StyleTransformer() |