aphrodite
Version:
Framework-agnostic CSS-in-JS with support for server-side rendering, browser prefixing, and minimum CSS generation
372 lines (344 loc) • 13.9 kB
JavaScript
/* @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.<SelectorHandler>} 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.<string, function>} 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 "";
}
};