diff --git a/README.md b/README.md index 9a994be..ad90872 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,95 @@ then we end up with the opposite effect, with `foo` overriding `bar`! The way to } ``` +## Advanced: Extensions + +Extra features can be added to Aphrodite using extensions. + +To add extensions to Aphrodite, call `StyleSheet.extend` with the extensions +you are adding. The result will be an object containing the usual exports of +Aphrodite (`css`, `StyleSheet`, etc.) which will have your extensions included. +For example: + +```js +// my-aphrodite.js +import {StyleSheet} from "aphrodite"; + +export default StyleSheet.extend([extension1, extension2]); + +// styled.js +import {StyleSheet, css} from "my-aphrodite.js"; + +const styles = StyleSheet.create({ + ... +}); +``` + +**Note**: Using extensions may cause Aphrodite's styles to not work properly. +Plain Aphrodite, when used properly, ensures that the correct styles will +always be applied to elements. Due to CSS specificity rules, extensions might +allow you to generate styles that conflict with each other, causing incorrect +styles to be shown. See the global extension below to see what could go wrong. + +### Creating extensions + +Currently, there is only one kind of extension available: selector handlers. +These kinds of extensions let you look at the selectors that someone specifies +and generate new selectors based on them. They are used to handle pseudo-styles +and media queries inside of Aphrodite. See the +[`defaultSelectorHandlers` docs](src/generate.js?L8) for information about how +to create a selector handler function. + +To use your extension, create an object containing a key of the kind of +extension that you created, and pass that into `StyleSheet.extend()`: + +```js +const mySelectorHandler = ...; + +const myExtension = {selectorHandler: mySelectorHandler}; + +StyleSheet.extend([myExtension]); +``` + +As an example, you could write an extension which generates global styles like + +```js +const globalSelectorHandler = (selector, _, generateSubtreeStyles) => { + if (selector[0] !== "*") { + return null; + } + + return generateSubtreeStyles(selector.slice(1)); +}; + +const globalExtension = {selectorHandler: globalSelectorHandler}; +``` + +This might cause problems when two places try to generate styles for the same +global selector however! For example, after + +``` +const styles = StyleSheet.create({ + globals: { + '*div': { + color: 'red', + }, + } +}); + +const styles2 = StyleSheet.create({ + globals: { + '*div': { + color: 'blue', + }, + }, +}); + +css(styles.globals); +css(styles2.globals); +``` + +It isn't determinate whether divs will be red or blue. + # Tools - [Aphrodite output tool](https://output.jsbin.com/qoseye) - Paste what you pass to `StyleSheet.create` and see the generated CSS diff --git a/src/exports.js b/src/exports.js new file mode 100644 index 0000000..25fc4b3 --- /dev/null +++ b/src/exports.js @@ -0,0 +1,125 @@ +import {mapObj, hashObject} from './util'; +import { + injectAndGetClassName, + reset, startBuffering, flushToString, + addRenderedClassNames, getRenderedClassNames, +} from './inject'; + +const StyleSheet = { + create(sheetDefinition) { + return mapObj(sheetDefinition, ([key, val]) => { + return [key, { + // TODO(emily): Make a 'production' mode which doesn't prepend + // the class name here, to make the generated CSS smaller. + _name: `${key}_${hashObject(val)}`, + _definition: val + }]; + }); + }, + + rehydrate(renderedClassNames=[]) { + addRenderedClassNames(renderedClassNames); + }, +}; + +/** + * Utilities for using Aphrodite server-side. + */ +const StyleSheetServer = { + renderStatic(renderFunc) { + reset(); + startBuffering(); + const html = renderFunc(); + const cssContent = flushToString(); + + return { + html: html, + css: { + content: cssContent, + renderedClassNames: getRenderedClassNames(), + }, + }; + }, +}; + +/** + * Utilities for using Aphrodite in tests. + * + * Not meant to be used in production. + */ +const StyleSheetTestUtils = { + /** + * Prevent styles from being injected into the DOM. + * + * This is useful in situations where you'd like to test rendering UI + * components which use Aphrodite without any of the side-effects of + * Aphrodite happening. Particularly useful for testing the output of + * components when you have no DOM, e.g. testing in Node without a fake DOM. + * + * Should be paired with a subsequent call to + * clearBufferAndResumeStyleInjection. + */ + suppressStyleInjection() { + reset(); + startBuffering(); + }, + + /** + * Opposite method of preventStyleInject. + */ + clearBufferAndResumeStyleInjection() { + reset(); + }, +}; + +/** + * Generate the Aphrodite API exports, with given `selectorHandlers` and + * `useImportant` state. + */ +const makeExports = (useImportant, selectorHandlers) => { + return { + StyleSheet: { + ...StyleSheet, + + /** + * Returns a version of the exports of Aphrodite (i.e. an object + * with `css` and `StyleSheet` properties) which have some + * extensions included. + * + * @param {Array.} extensions: An array of extensions to + * add to this instance of Aphrodite. Each object should have a + * single property on it, defining which kind of extension to + * add. + * @param {SelectorHandler} [extensions[].selectorHandler]: A + * selector handler extension. See `defaultSelectorHandlers` in + * generate.js. + * + * @returns {Object} An object containing the exports of the new + * instance of Aphrodite. + */ + extend(extensions) { + const extensionSelectorHandlers = extensions + // Pull out extensions with a selectorHandler property + .map(extension => extension.selectorHandler) + // Remove nulls (i.e. extensions without a selectorHandler + // property). + .filter(handler => handler); + + return makeExports( + useImportant, + selectorHandlers.concat(extensionSelectorHandlers) + ); + }, + }, + + StyleSheetServer, + StyleSheetTestUtils, + + css(...styleDefinitions) { + return injectAndGetClassName( + useImportant, styleDefinitions, selectorHandlers); + }, + }; +}; + +module.exports = makeExports; diff --git a/src/generate.js b/src/generate.js index 6f259cf..92f9446 100644 --- a/src/generate.js +++ b/src/generate.js @@ -4,6 +4,75 @@ import { objectToPairs, kebabifyStyleName, recursiveMerge, stringifyValue, importantify, flatten } from './util'; + +/** + * `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 CSS 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} The generated CSS for this selector, or null if we don't + * handle this selector. + */ +export const defaultSelectorHandlers = [ + // 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}}`; + }, +]; + /** * Generate CSS for a selector and some styles. * @@ -14,6 +83,9 @@ import { * 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` * @@ -22,7 +94,7 @@ import { * * For instance, a call to * - * generateCSSInner(".foo", { + * generateCSS(".foo", { * color: "red", * "@media screen": { * height: 20, @@ -39,7 +111,8 @@ import { * } * }); * - * will make 5 calls to `generateCSSRuleset`: + * with the default `selectorHandlers` will make 5 calls to + * `generateCSSRuleset`: * * generateCSSRuleset(".foo", { color: "red" }, ...) * generateCSSRuleset(".foo:active", { fontWeight: "bold" }, ...) @@ -48,37 +121,41 @@ import { * generateCSSRuleset(".foo", { height: 20 }, ...) * generateCSSRuleset(".foo:hover", { backgroundColor: "black" }, ...) */ -export const generateCSS = (selector, styleTypes, stringHandlers, - useImportant) => { +export const generateCSS = (selector, styleTypes, selectorHandlers=[], + stringHandlers={}, useImportant=true) => { const merged = styleTypes.reduce(recursiveMerge); - const declarations = {}; - const mediaQueries = {}; - const pseudoStyles = {}; + const plainDeclarations = {}; + let generatedStyles = ""; Object.keys(merged).forEach(key => { - if (key[0] === ':') { - pseudoStyles[key] = merged[key]; - } else if (key[0] === '@') { - mediaQueries[key] = merged[key]; - } else { - declarations[key] = merged[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, [merged[key]], selectorHandlers, + stringHandlers, useImportant); + }); + if (result != null) { + // If the handler returned something, add it to the generated + // CSS and stop looking for another handler. + generatedStyles += result; + return true; + } + }); + // If none of the handlers handled it, add it to the list of plain + // style declarations. + if (!foundHandler) { + plainDeclarations[key] = merged[key]; } }); return ( - generateCSSRuleset(selector, declarations, stringHandlers, - useImportant) + - Object.keys(pseudoStyles).map(pseudoSelector => { - return generateCSSRuleset(selector + pseudoSelector, - pseudoStyles[pseudoSelector], - stringHandlers, useImportant); - }).join("") + - Object.keys(mediaQueries).map(mediaQuery => { - const ruleset = generateCSS(selector, [mediaQueries[mediaQuery]], - stringHandlers, useImportant); - return `${mediaQuery}{${ruleset}}`; - }).join("") + generateCSSRuleset( + selector, plainDeclarations, stringHandlers, useImportant, + selectorHandlers) + + generatedStyles ); }; @@ -88,14 +165,20 @@ export const generateCSS = (selector, styleTypes, stringHandlers, * * See generateCSSRuleset for usage and documentation of paramater types. */ -const runStringHandlers = (declarations, stringHandlers) => { +const runStringHandlers = (declarations, stringHandlers, selectorHandlers) => { const result = {}; Object.keys(declarations).forEach(key => { // If a handler exists for this particular key, let it interpret // that value first before continuing if (stringHandlers && stringHandlers.hasOwnProperty(key)) { - result[key] = stringHandlers[key](declarations[key]); + // 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. + result[key] = stringHandlers[key]( + declarations[key], selectorHandlers); } else { result[key] = declarations[key]; } @@ -136,9 +219,9 @@ const runStringHandlers = (declarations, stringHandlers) => { * -> ".blah:hover{color: red}" */ export const generateCSSRuleset = (selector, declarations, stringHandlers, - useImportant) => { + useImportant, selectorHandlers) => { const handledDeclarations = runStringHandlers( - declarations, stringHandlers); + declarations, stringHandlers, selectorHandlers); const prefixedDeclarations = prefixAll(handledDeclarations); diff --git a/src/index.js b/src/index.js index 4bb0253..c3a1d18 100644 --- a/src/index.js +++ b/src/index.js @@ -1,85 +1,8 @@ -import {mapObj, hashObject} from './util'; -import { - injectAndGetClassName, - reset, startBuffering, flushToString, - addRenderedClassNames, getRenderedClassNames -} from './inject'; - -const StyleSheet = { - create(sheetDefinition) { - return mapObj(sheetDefinition, ([key, val]) => { - return [key, { - // TODO(emily): Make a 'production' mode which doesn't prepend - // the class name here, to make the generated CSS smaller. - _name: `${key}_${hashObject(val)}`, - _definition: val - }]; - }); - }, - - rehydrate(renderedClassNames=[]) { - addRenderedClassNames(renderedClassNames); - }, -}; - -/** - * Utilities for using Aphrodite server-side. - */ -const StyleSheetServer = { - renderStatic(renderFunc) { - reset(); - startBuffering(); - const html = renderFunc(); - const cssContent = flushToString(); - - return { - html: html, - css: { - content: cssContent, - renderedClassNames: getRenderedClassNames(), - }, - }; - }, -}; - -/** - * Utilities for using Aphrodite in tests. - * - * Not meant to be used in production. - */ -const StyleSheetTestUtils = { - /** - * Prevent styles from being injected into the DOM. - * - * This is useful in situations where you'd like to test rendering UI - * components which use Aphrodite without any of the side-effects of - * Aphrodite happening. Particularly useful for testing the output of - * components when you have no DOM, e.g. testing in Node without a fake DOM. - * - * Should be paired with a subsequent call to - * clearBufferAndResumeStyleInjection. - */ - suppressStyleInjection() { - reset(); - startBuffering(); - }, - - /** - * Opposite method of preventStyleInject. - */ - clearBufferAndResumeStyleInjection() { - reset(); - }, -}; - -const css = (...styleDefinitions) => { - const useImportant = true; // Append !important to all style definitions - return injectAndGetClassName(useImportant, styleDefinitions); -}; - -export default { - StyleSheet, - StyleSheetServer, - StyleSheetTestUtils, - css, -}; +import {defaultSelectorHandlers} from './generate'; +import makeExports from './exports'; + +const useImportant = true; // Add !important to all style definitions +export default makeExports( + useImportant, + defaultSelectorHandlers +); diff --git a/src/inject.js b/src/inject.js index f31d9d7..3eae8fe 100644 --- a/src/inject.js +++ b/src/inject.js @@ -77,7 +77,7 @@ const stringHandlers = { // TODO(emily): `stringHandlers` doesn't let us rename the key, so I have // to use `animationName` here. Improve that so we can call this // `animation` instead of `animationName`. - animationName: (val) => { + animationName: (val, selectorHandlers) => { if (typeof val !== "object") { return val; } @@ -92,7 +92,8 @@ const stringHandlers = { // build the inner layers and wrap it in `@keyframes` ourselves. let finalVal = `@keyframes ${name}{`; Object.keys(val).forEach(key => { - finalVal += generateCSS(key, [val[key]], stringHandlers, false); + finalVal += generateCSS( + key, [val[key]], selectorHandlers, stringHandlers, false); }); finalVal += '}'; @@ -135,10 +136,12 @@ const injectGeneratedCSSOnce = (key, generatedCSS) => { } } -export const injectStyleOnce = (key, selector, definitions, useImportant) => { +export const injectStyleOnce = (key, selector, definitions, useImportant, + selectorHandlers) => { if (!alreadyInjected[key]) { - const generated = generateCSS(selector, definitions, - stringHandlers, useImportant); + const generated = generateCSS( + selector, definitions, selectorHandlers, + stringHandlers, useImportant); injectGeneratedCSSOnce(key, generated); } @@ -193,7 +196,8 @@ export const addRenderedClassNames = (classNames) => { * arbitrarily nested arrays of them, as returned as properties of the * return value of StyleSheet.create(). */ -export const injectAndGetClassName = (useImportant, styleDefinitions) => { +export const injectAndGetClassName = (useImportant, styleDefinitions, + selectorHandlers) => { styleDefinitions = flattenDeep(styleDefinitions); // Filter out falsy values from the input, to allow for @@ -208,7 +212,7 @@ export const injectAndGetClassName = (useImportant, styleDefinitions) => { const className = validDefinitions.map(s => s._name).join("-o_O-"); injectStyleOnce(className, `.${className}`, validDefinitions.map(d => d._definition), - useImportant); + useImportant, selectorHandlers); return className; } diff --git a/src/no-important.js b/src/no-important.js index f1bbc15..563afca 100644 --- a/src/no-important.js +++ b/src/no-important.js @@ -1,23 +1,11 @@ // Module with the same interface as the core aphrodite module, // except that styles injected do not automatically have !important // appended to them. -// -import {injectAndGetClassName} from './inject'; +import {defaultSelectorHandlers} from './generate'; +import makeExports from './exports'; -import { - StyleSheet, - StyleSheetServer, - StyleSheetTestUtils, -} from './index.js' - -const css = (...styleDefinitions) => { - const useImportant = false; // Don't append !important to style definitions - return injectAndGetClassName(useImportant, styleDefinitions); -}; - -export { - StyleSheet, - StyleSheetServer, - StyleSheetTestUtils, - css -} +const useImportant = false; // Don't add !important to style definitions +export default makeExports( + useImportant, + defaultSelectorHandlers +); diff --git a/tests/generate_test.js b/tests/generate_test.js index 2e129b3..7f3920d 100644 --- a/tests/generate_test.js +++ b/tests/generate_test.js @@ -1,6 +1,8 @@ import {assert} from 'chai'; -import {generateCSSRuleset, generateCSS} from '../src/generate'; +import { + generateCSSRuleset, generateCSS, defaultSelectorHandlers +} from '../src/generate'; describe('generateCSSRuleset', () => { const assertCSSRuleset = (selector, declarations, expected) => { @@ -79,10 +81,10 @@ describe('generateCSSRuleset', () => { }); }); describe('generateCSS', () => { - const assertCSS = (className, styleTypes, expected, stringHandlers, - useImportant) => { - const actual = generateCSS(className, styleTypes, stringHandlers, - useImportant); + const assertCSS = (className, styleTypes, expected, selectorHandlers, + stringHandlers, useImportant) => { + const actual = generateCSS(className, styleTypes, selectorHandlers, + stringHandlers, useImportant); assert.equal(actual, expected.split('\n').map(x => x.trim()).join('')); }; @@ -105,7 +107,7 @@ describe('generateCSS', () => { ':hover': { color: 'red' } - }], '.foo:hover{color:red !important;}'); + }], '.foo:hover{color:red !important;}', defaultSelectorHandlers); }); it('supports media queries', () => { @@ -115,7 +117,7 @@ describe('generateCSS', () => { } }], `@media (max-width: 400px){ .foo{color:blue !important;} - }`); + }`, defaultSelectorHandlers); }); it('supports pseudo selectors inside media queries', () => { @@ -127,13 +129,13 @@ describe('generateCSS', () => { } }], `@media (max-width: 400px){ .foo:hover{color:blue !important;} - }`); + }`, defaultSelectorHandlers); }); it('supports custom string handlers', () => { assertCSS('.foo', [{ fontFamily: ["Helvetica", "sans-serif"] - }], '.foo{font-family:Helvetica, sans-serif !important;}', { + }], '.foo{font-family:Helvetica, sans-serif !important;}', [], { fontFamily: (val) => val.join(", "), }); }); @@ -142,7 +144,8 @@ describe('generateCSS', () => { assertCSS('@font-face', [{ fontFamily: ["FontAwesome"], fontStyle: "normal", - }], '@font-face{font-family:FontAwesome;font-style:normal;}', { + }], '@font-face{font-family:FontAwesome;font-style:normal;}', + defaultSelectorHandlers, { fontFamily: (val) => val.join(", "), }, false); }); @@ -150,7 +153,24 @@ describe('generateCSS', () => { it('adds browser prefixes', () => { assertCSS('.foo', [{ display: 'flex', - }], '.foo{display:-moz-box !important;display:-ms-flexbox !important;display:-webkit-box !important;display:-webkit-flex !important;display:flex !important;}'); + }], '.foo{display:-moz-box !important;display:-ms-flexbox !important;display:-webkit-box !important;display:-webkit-flex !important;display:flex !important;}', + defaultSelectorHandlers); + }); + + it('supports other selector handlers', () => { + const handler = (selector, baseSelector, callback) => { + if (selector[0] !== '^') { + return null; + } + return callback(`.${selector.slice(1)} ${baseSelector}`); + }; + + assertCSS('.foo', [{ + '^bar': { + color: 'red', + }, + color: 'blue', + }], '.foo{color:blue;}.bar .foo{color:red;}', [handler], {}, false); }); it('correctly prefixes border-color transition properties', () => { diff --git a/tests/index_test.js b/tests/index_test.js index 7785bc6..10cfbae 100644 --- a/tests/index_test.js +++ b/tests/index_test.js @@ -284,6 +284,74 @@ describe('rehydrate', () => { }); }); +describe('StyleSheet.extend', () => { + beforeEach(() => { + global.document = jsdom.jsdom(); + reset(); + }); + + afterEach(() => { + global.document.close(); + global.document = undefined; + }); + + it('accepts empty extensions', () => { + const newAphrodite = StyleSheet.extend([]); + + assert(newAphrodite.css); + assert(newAphrodite.StyleSheet); + }); + + it('uses a new selector handler', done => { + const descendantHandler = (selector, baseSelector, + generateSubtreeStyles) => { + if (selector[0] !== '^') { + return null; + } + return generateSubtreeStyles( + `.${selector.slice(1)} ${baseSelector}`); + }; + + const descendantHandlerExtension = { + selectorHandler: descendantHandler, + }; + + // Pull out the new StyleSheet/css functions to use for the rest of + // this test. + const {StyleSheet: newStyleSheet, css: newCss} = StyleSheet.extend([ + descendantHandlerExtension]); + + const sheet = newStyleSheet.create({ + foo: { + '^bar': { + '^baz': { + color: 'orange', + }, + color: 'red', + }, + color: 'blue', + }, + }); + + newCss(sheet.foo); + + asap(() => { + const styleTags = global.document.getElementsByTagName("style"); + assert.equal(styleTags.length, 1); + const styles = styleTags[0].textContent; + + assert.notInclude(styles, '^bar'); + assert.include(styles, '.bar .foo'); + assert.include(styles, '.baz .bar .foo'); + assert.include(styles, 'color:red'); + assert.include(styles, 'color:blue'); + assert.include(styles, 'color:orange'); + + done(); + }); + }); +}); + describe('StyleSheetServer.renderStatic', () => { const sheet = StyleSheet.create({ red: {