|
| 1 | +// Dependencies |
| 2 | +// ============================================================================= |
| 3 | +import getCssData from 'get-css-data'; |
| 4 | +import mergeDeep from './merge-deep'; |
| 5 | +import transformCss from './transform-css'; |
| 6 | +import { name as pkgName } from '../package.json'; |
| 7 | + |
| 8 | + |
| 9 | +// Constants & Variables |
| 10 | +// ============================================================================= |
| 11 | +const defaults = { |
| 12 | + // Sources |
| 13 | + include : 'style,link[rel=stylesheet]', |
| 14 | + exclude : '', |
| 15 | + // Options |
| 16 | + appendStyle: true, // cssVars |
| 17 | + onlyLegacy : true, // cssVars |
| 18 | + onlyVars : true, // cssVars, transformCss |
| 19 | + preserve : true, // transformCss |
| 20 | + silent : false, // cssVars |
| 21 | + variables : {}, // transformCss |
| 22 | + // Callbacks |
| 23 | + onSuccess() {}, // cssVars |
| 24 | + onError() {}, // cssVars |
| 25 | + onWarning() {}, // transformCss |
| 26 | + onComplete() {} // cssVars |
| 27 | +}; |
| 28 | +// Regex: CSS variable :root declarations and var() function values |
| 29 | +const reCssVars = /(?:(?::root\s*{\s*[^;]*;*\s*)|(?:var\(\s*))(--[^:)]+)(?:\s*[:)])/; |
| 30 | + |
| 31 | + |
| 32 | + |
| 33 | + |
| 34 | +// Functions |
| 35 | +// ============================================================================= |
| 36 | +/** |
| 37 | + * Description |
| 38 | + * |
| 39 | + * @preserve |
| 40 | + * @param {object} [options] Options object |
| 41 | + * @param {string} [options.include="style,link[rel=stylesheet]"] CSS selector |
| 42 | + * matching <link> and <style> nodes to include |
| 43 | + * @param {string} [options.exclude=""] CSS selector matching <link> and |
| 44 | + * <style> nodes to exclude |
| 45 | + * @param {boolean} [options.appendStyle=true] Append <style> node containing |
| 46 | + * updated CSS to DOM |
| 47 | + * @param {boolean} [options.onlyLegacy=true] Only process CSS variables in |
| 48 | + * browsers that lack native support |
| 49 | + * @param {boolean} [options.onlyVars=true] Remove declarations that do not |
| 50 | + * contain a CSS variable from the return value. Note that |
| 51 | + * font-face and keyframe rules require all declarations to be |
| 52 | + * returned if a CSS variable is used. |
| 53 | + * @param {boolean} [options.preserve=true] Preserve CSS variable definitions |
| 54 | + * and functions in the return value, allowing "live" variable |
| 55 | + * updates via JavaScript to continue working in browsers with |
| 56 | + * native CSS variable support. |
| 57 | + * @param {boolean} [options.silent=false] Prevent console warnign and error |
| 58 | + * messages |
| 59 | + * @param {object} [options.variables={}] CSS variable definitions to include |
| 60 | + * during transformation. Can be used to add new override |
| 61 | + * exisitng definitions. |
| 62 | + * @param {function} [options.onSuccess] Callback after all stylesheets have |
| 63 | + * been processed succesfully. Passes 1) a CSS string with CSS |
| 64 | + * variable values resolved as an argument. Modifying the CSS |
| 65 | + * appended when 'appendStyle' is 'true' can be done by |
| 66 | + * returning a string value from this funtion (or 'false' to |
| 67 | + * skip). |
| 68 | + * @param {function} [options.onError] Callback on each error. Passes 1) an |
| 69 | + * error message, and 2) source node reference as arguments. |
| 70 | + * @param {function} [options.onWarning] Callback on each warning. Passes 1) a |
| 71 | + * warning message as an argument. |
| 72 | + * @param {function} [options.onComplete] Callback after all stylesheets have |
| 73 | + * been processed succesfully and <style> node containing |
| 74 | + * updated CSS has been appended to the DOM (based on |
| 75 | + * 'appendStyle' setting. Passes 1) a CSS string with CSS |
| 76 | + * variable values resolved, and 2) a reference to the |
| 77 | + * appended <style> node. |
| 78 | + * |
| 79 | + * @example |
| 80 | + * |
| 81 | + * cssVars({ |
| 82 | + * include : 'style,link[rel="stylesheet"]', // default |
| 83 | + * exclude : '', |
| 84 | + * appendStyle: true, // default |
| 85 | + * onlyLegacy : true, // default |
| 86 | + * onlyVars : true, // default |
| 87 | + * preserve : true, // default |
| 88 | + * silent : false, // default |
| 89 | + * onError(message, node) { |
| 90 | + * // ... |
| 91 | + * }, |
| 92 | + * onWarning(message) { |
| 93 | + * // ... |
| 94 | + * }, |
| 95 | + * onSuccess(cssText) { |
| 96 | + * // ... |
| 97 | + * }, |
| 98 | + * onComplete(cssText, styleNode) { |
| 99 | + * // ... |
| 100 | + * } |
| 101 | + * }); |
| 102 | + */ |
| 103 | +function cssVars(options = {}) { |
| 104 | + const settings = mergeDeep(defaults, options); |
| 105 | + |
| 106 | + function handleError(message, sourceNode) { |
| 107 | + /* istanbul ignore next */ |
| 108 | + if (!settings.silent) { |
| 109 | + // eslint-disable-next-line |
| 110 | + console.error(`${message}\n`, sourceNode); |
| 111 | + } |
| 112 | + |
| 113 | + settings.onError(message, sourceNode); |
| 114 | + } |
| 115 | + |
| 116 | + function handleWarning(message) { |
| 117 | + /* istanbul ignore next */ |
| 118 | + if (!settings.silent) { |
| 119 | + // eslint-disable-next-line |
| 120 | + console.warn(message); |
| 121 | + } |
| 122 | + |
| 123 | + settings.onWarning(message); |
| 124 | + } |
| 125 | + |
| 126 | + // Verify readyState to ensure all <link> and <style> nodes are available |
| 127 | + if (document.readyState !== 'loading') { |
| 128 | + const hasNativeSupport = window.CSS && window.CSS.supports && window.CSS.supports('(--a: 0)'); |
| 129 | + |
| 130 | + // Lacks native support or onlyLegacy 'false' |
| 131 | + if (!hasNativeSupport || !settings.onlyLegacy) { |
| 132 | + const styleNodeId = pkgName; |
| 133 | + |
| 134 | + getCssData({ |
| 135 | + include: settings.include, |
| 136 | + // Always exclude styleNodeId element, which is the generated |
| 137 | + // <style> node containing previously transformed CSS. |
| 138 | + exclude: `#${styleNodeId}` + (settings.exclude ? `,${settings.exclude}` : ''), |
| 139 | + // This filter does a test on each block of CSS. An additional |
| 140 | + // filter is used in the parser to remove individual |
| 141 | + // declarations. |
| 142 | + filter : settings.onlyVars ? reCssVars : null, |
| 143 | + onComplete(cssText, cssArray, nodeArray) { |
| 144 | + let styleNode = null; |
| 145 | + |
| 146 | + try { |
| 147 | + cssText = transformCss(cssText, { |
| 148 | + onlyVars : settings.onlyVars, |
| 149 | + preserve : settings.preserve, |
| 150 | + variables: settings.variables, |
| 151 | + onWarning: handleWarning |
| 152 | + }); |
| 153 | + |
| 154 | + // Success if an error was not been throw during |
| 155 | + // transformation. Store the onSuccess return value, |
| 156 | + // which allows modifying cssText before passing to |
| 157 | + // onComplete and/or appending to new <style> node. |
| 158 | + const returnVal = settings.onSuccess(cssText); |
| 159 | + |
| 160 | + // Set cssText to return value (if provided) |
| 161 | + cssText = returnVal === false ? '' : returnVal || cssText; |
| 162 | + |
| 163 | + if (settings.appendStyle) { |
| 164 | + styleNode = document.querySelector(`#${styleNodeId}`) || document.createElement('style'); |
| 165 | + styleNode.setAttribute('id', styleNodeId); |
| 166 | + |
| 167 | + if (styleNode.textContent !== cssText) { |
| 168 | + styleNode.textContent = cssText; |
| 169 | + } |
| 170 | + |
| 171 | + // Append <style> element to either the <head> or |
| 172 | + // <body> based on the position of last stylesheet |
| 173 | + // node. |
| 174 | + const styleTargetNode = document.querySelector(`body link[rel=stylesheet], body style:not(#${styleNodeId})`) ? document.body : document.head; |
| 175 | + const isNewTarget = styleNode.parentNode !== styleTargetNode; |
| 176 | + const isLastStyleElm = matchesSelector(styleNode, 'style:last-of-type'); |
| 177 | + |
| 178 | + if (isNewTarget || !isLastStyleElm) { |
| 179 | + styleTargetNode.appendChild(styleNode); |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + catch(err) { |
| 184 | + let errorThrown = false; |
| 185 | + |
| 186 | + // Iterate cssArray to detect CSS text and node(s) |
| 187 | + // responsibile for error. |
| 188 | + cssArray.forEach((cssText, i) => { |
| 189 | + try { |
| 190 | + cssText = transformCss(cssText, settings); |
| 191 | + } |
| 192 | + catch(err) { |
| 193 | + const errorNode = nodeArray[i - 0]; |
| 194 | + |
| 195 | + errorThrown = true; |
| 196 | + handleError(err.message, errorNode); |
| 197 | + } |
| 198 | + }); |
| 199 | + |
| 200 | + // In the event the error thrown was not due to |
| 201 | + // transformCss, handle the original error. |
| 202 | + /* istanbul ignore next */ |
| 203 | + if (!errorThrown) { |
| 204 | + handleError(err.message || err); |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + settings.onComplete(cssText, styleNode); |
| 209 | + }, |
| 210 | + onError(xhr, node, url) { |
| 211 | + const errorMsg = `Unable to process ${url} (${xhr.status} - ${xhr.statusText}}`; |
| 212 | + |
| 213 | + handleError(errorMsg, node); |
| 214 | + } |
| 215 | + }); |
| 216 | + } |
| 217 | + } |
| 218 | + // Delay function until DOMContentLoaded event is fired |
| 219 | + /* istanbul ignore next */ |
| 220 | + else { |
| 221 | + document.addEventListener('DOMContentLoaded', function init(evt) { |
| 222 | + cssVars(options); |
| 223 | + |
| 224 | + document.removeEventListener('DOMContentLoaded', init); |
| 225 | + }); |
| 226 | + } |
| 227 | +} |
| 228 | + |
| 229 | + |
| 230 | +// Functions (Private) |
| 231 | +// ============================================================================= |
| 232 | +/** |
| 233 | + * Ponyfill for native Element.matches method |
| 234 | + * |
| 235 | + * @param {object} elm - The element to test |
| 236 | + * @param {string} selector - The CSS selector to test against |
| 237 | + * @returns {boolean} |
| 238 | + */ |
| 239 | +function matchesSelector(elm, selector) { |
| 240 | + /* istanbul ignore next */ |
| 241 | + const matches = elm.matches || elm.matchesSelector || elm.webkitMatchesSelector || elm.mozMatchesSelector || elm.msMatchesSelector || elm.oMatchesSelector; |
| 242 | + |
| 243 | + return matches.call(elm, selector); |
| 244 | +} |
| 245 | + |
| 246 | + |
| 247 | +// Export |
| 248 | +// ============================================================================= |
| 249 | +export default cssVars; |
0 commit comments