/* @flow */ import createPrefixer from 'inline-style-prefixer/lib/createPrefixer'; import staticData from '../lib/staticPrefixData'; import OrderedElements from './ordered-elements'; import { kebabifyStyleName, stringifyValue, stringifyAndImportantifyValue } from './util'; const prefixAll = createPrefixer(staticData); /* :: import type { SheetDefinition } from './index.js'; type StringHandlers = { [id:string]: Function }; type SelectorCallback = (selector: string) => string[]; export type SelectorHandler = ( selector: string, baseSelector: string, callback: SelectorCallback ) => string[] | string | null; */ /** * `selectorHandlers` are functions which handle special selectors which act * differently than normal style definitions. These functions look at the * current selector and can generate CSS for the styles in their subtree by * calling the callback with a new selector. * * For example, when generating styles with a base selector of '.foo' and the * following styles object: * * { * ':nth-child(2n)': { * ':hover': { * color: 'red' * } * } * } * * when we reach the ':hover' style, we would call our selector handlers like * * handler(':hover', '.foo:nth-child(2n)', callback) * * Since our `pseudoSelectors` handles ':hover' styles, that handler would call * the callback like * * callback('.foo:nth-child(2n):hover') * * to generate its subtree `{ color: 'red' }` styles with a * '.foo:nth-child(2n):hover' selector. The callback would return an array of CSS * rules like * * ['.foo:nth-child(2n):hover{color:red !important;}'] * * and the handler would then return that resulting CSS. * * `defaultSelectorHandlers` is the list of default handlers used in a call to * `generateCSS`. * * @name SelectorHandler * @function * @param {string} selector: The currently inspected selector. ':hover' in the * example above. * @param {string} baseSelector: The selector of the parent styles. * '.foo:nth-child(2n)' in the example above. * @param {function} generateSubtreeStyles: A function which can be called to * generate CSS for the subtree of styles corresponding to the selector. * Accepts a new baseSelector to use for generating those styles. * @returns {string[] | string | null} The generated CSS for this selector, or * null if we don't handle this selector. */ export const defaultSelectorHandlers /* : SelectorHandler[] */ = [ // Handle pseudo-selectors, like :hover and :nth-child(3n) function pseudoSelectors(selector, baseSelector, generateSubtreeStyles) { if (selector[0] !== ":") { return null; } return generateSubtreeStyles(baseSelector + selector); }, // Handle media queries (or font-faces) function mediaQueries(selector, baseSelector, generateSubtreeStyles) { if (selector[0] !== "@") { return null; } // Generate the styles normally, and then wrap them in the media query. const generated = generateSubtreeStyles(baseSelector); return [`${selector}{${generated.join('')}}`]; }, ]; /** * Generate CSS for a selector and some styles. * * This function handles the media queries and pseudo selectors that can be used * in aphrodite styles. * * @param {string} selector: A base CSS selector for the styles to be generated * with. * @param {Object} styleTypes: A list of properties of the return type of * StyleSheet.create, e.g. [styles.red, styles.blue]. * @param {Array.} selectorHandlers: A list of selector * handlers to use for handling special selectors. See * `defaultSelectorHandlers`. * @param stringHandlers: See `generateCSSRuleset` * @param useImportant: See `generateCSSRuleset` * * To actually generate the CSS special-construct-less styles are passed to * `generateCSSRuleset`. * * For instance, a call to * * generateCSS(".foo", [{ * color: "red", * "@media screen": { * height: 20, * ":hover": { * backgroundColor: "black" * } * }, * ":active": { * fontWeight: "bold" * } * }], defaultSelectorHandlers); * * with the default `selectorHandlers` will make 5 calls to * `generateCSSRuleset`: * * generateCSSRuleset(".foo", { color: "red" }, ...) * generateCSSRuleset(".foo:active", { fontWeight: "bold" }, ...) * // These 2 will be wrapped in @media screen {} * generateCSSRuleset(".foo", { height: 20 }, ...) * generateCSSRuleset(".foo:hover", { backgroundColor: "black" }, ...) */ export const generateCSS = ( selector /* : string */, styleTypes /* : SheetDefinition[] */, selectorHandlers /* : SelectorHandler[] */, stringHandlers /* : StringHandlers */, useImportant /* : boolean */ ) /* : string[] */ => { const merged = new OrderedElements(); for (let i = 0; i < styleTypes.length; i++) { merged.addStyleType(styleTypes[i]); } const plainDeclarations = new OrderedElements(); const generatedStyles = []; // TODO(emily): benchmark this to see if a plain for loop would be faster. merged.forEach((val, key) => { // For each key, see if one of the selector handlers will handle these // styles. const foundHandler = selectorHandlers.some(handler => { const result = handler(key, selector, (newSelector) => { return generateCSS( newSelector, [val], selectorHandlers, stringHandlers, useImportant); }); if (result != null) { // If the handler returned something, add it to the generated // CSS and stop looking for another handler. if (Array.isArray(result)) { generatedStyles.push(...result); } else { // eslint-disable-next-line console.warn( 'WARNING: Selector handlers should return an array of rules.' + 'Returning a string containing multiple rules is deprecated.', handler, ); generatedStyles.push(`@media all {${result}}`); } return true; } }); // If none of the handlers handled it, add it to the list of plain // style declarations. if (!foundHandler) { plainDeclarations.set(key, val, true); } }); const generatedRuleset = generateCSSRuleset( selector, plainDeclarations, stringHandlers, useImportant, selectorHandlers, ); if (generatedRuleset) { generatedStyles.unshift(generatedRuleset); } return generatedStyles; }; /** * Helper method of generateCSSRuleset to facilitate custom handling of certain * CSS properties. Used for e.g. font families. * * See generateCSSRuleset for usage and documentation of paramater types. */ const runStringHandlers = ( declarations /* : OrderedElements */, stringHandlers /* : StringHandlers */, selectorHandlers /* : SelectorHandler[] */ ) /* : void */ => { if (!stringHandlers) { return; } const stringHandlerKeys = Object.keys(stringHandlers); for (let i = 0; i < stringHandlerKeys.length; i++) { const key = stringHandlerKeys[i]; if (declarations.has(key)) { // A declaration exists for this particular string handler, so we // need to let the string handler interpret the declaration first // before proceeding. // // TODO(emily): Pass in a callback which generates CSS, similar to // how our selector handlers work, instead of passing in // `selectorHandlers` and have them make calls to `generateCSS` // themselves. Right now, this is impractical because our string // handlers are very specialized and do complex things. declarations.set( key, stringHandlers[key](declarations.get(key), selectorHandlers), // Preserve order here, since we are really replacing an // unprocessed style with a processed style, not overriding an // earlier style false ); } } }; const transformRule = ( key /* : string */, value /* : string */, transformValue /* : function */ ) /* : string */ => ( `${kebabifyStyleName(key)}:${transformValue(key, value)};` ); const arrayToObjectKeysReducer = (acc, val) => { acc[val] = true; return acc; }; /** * Generate a CSS ruleset with the selector and containing the declarations. * * This function assumes that the given declarations don't contain any special * children (such as media queries, pseudo-selectors, or descendant styles). * * Note that this method does not deal with nesting used for e.g. * psuedo-selectors or media queries. That responsibility is left to the * `generateCSS` function. * * @param {string} selector: the selector associated with the ruleset * @param {Object} declarations: a map from camelCased CSS property name to CSS * property value. * @param {Object.} stringHandlers: a map from camelCased CSS * property name to a function which will map the given value to the value * that is output. * @param {bool} useImportant: A boolean saying whether to append "!important" * to each of the CSS declarations. * @returns {string} A string of raw CSS. * * Examples: * * generateCSSRuleset(".blah", { color: "red" }) * -> ".blah{color: red !important;}" * generateCSSRuleset(".blah", { color: "red" }, {}, false) * -> ".blah{color: red}" * generateCSSRuleset(".blah", { color: "red" }, {color: c => c.toUpperCase}) * -> ".blah{color: RED}" * generateCSSRuleset(".blah:hover", { color: "red" }) * -> ".blah:hover{color: red}" */ export const generateCSSRuleset = ( selector /* : string */, declarations /* : OrderedElements */, stringHandlers /* : StringHandlers */, useImportant /* : boolean */, selectorHandlers /* : SelectorHandler[] */ ) /* : string */ => { // Mutates declarations runStringHandlers(declarations, stringHandlers, selectorHandlers); const originalElements = Object.keys(declarations.elements) .reduce(arrayToObjectKeysReducer, Object.create(null)); // NOTE(emily): This mutates handledDeclarations.elements. const prefixedElements = prefixAll(declarations.elements); const elementNames = Object.keys(prefixedElements); if (elementNames.length !== declarations.keyOrder.length) { // There are some prefixed values, so we need to figure out how to sort // them. // // Loop through prefixedElements, looking for anything that is not in // sortOrder, which means it was added by prefixAll. This means that we // need to figure out where it should appear in the sortOrder. for (let i = 0; i < elementNames.length; i++) { if (!originalElements[elementNames[i]]) { // This element is not in the sortOrder, which means it is a prefixed // value that was added by prefixAll. Let's try to figure out where it // goes. let originalStyle; if (elementNames[i][0] === 'W') { // This is a Webkit-prefixed style, like "WebkitTransition". Let's // find its original style's sort order. originalStyle = elementNames[i][6].toLowerCase() + elementNames[i].slice(7); } else if (elementNames[i][1] === 'o') { // This is a Moz-prefixed style, like "MozTransition". We check // the second character to avoid colliding with Ms-prefixed // styles. Let's find its original style's sort order. originalStyle = elementNames[i][3].toLowerCase() + elementNames[i].slice(4); } else { // if (elementNames[i][1] === 's') { // This is a Ms-prefixed style, like "MsTransition". originalStyle = elementNames[i][2].toLowerCase() + elementNames[i].slice(3); } if (originalStyle && originalElements[originalStyle]) { const originalIndex = declarations.keyOrder.indexOf(originalStyle); declarations.keyOrder.splice(originalIndex, 0, elementNames[i]); } else { // We don't know what the original style was, so sort it to // top. This can happen for styles that are added that don't // have the same base name as the original style. declarations.keyOrder.unshift(elementNames[i]); } } } } const transformValue = (useImportant === false) ? stringifyValue : stringifyAndImportantifyValue; const rules = []; for (let i = 0; i < declarations.keyOrder.length; i ++) { const key = declarations.keyOrder[i]; const value = prefixedElements[key]; if (Array.isArray(value)) { // inline-style-prefixer returns an array when there should be // multiple rules for the same key. Here we flatten to multiple // pairs with the same key. for (let j = 0; j < value.length; j++) { rules.push(transformRule(key, value[j], transformValue)); } } else { rules.push(transformRule(key, value, transformValue)); } } if (rules.length) { return `${selector}{${rules.join("")}}`; } else { return ""; } };