diff --git a/src/get-css.ts b/src/get-css.ts index 7f397c7..f7c8443 100644 --- a/src/get-css.ts +++ b/src/get-css.ts @@ -2,6 +2,7 @@ import prefixer, {Rule} from './prefixer' import valueToString from './value-to-string' import getClassName, {PropertyInfo} from './get-class-name' import { EnhancedProp } from './types/enhancers' +import { apply as applyPlugins, RuleSet } from './plugins' /** * Generates the class name and styles. @@ -33,21 +34,30 @@ export default function getCss(propertyInfo: PropertyInfo, value: string | numbe rules = [{property: propertyInfo.cssName || '', value: valueString}] } - let styles: string + const ruleSet = applyPlugins({ + selector: `.${className}`, + rules + }) + + const styles = stringifyRuleSet(ruleSet) + + return {className, styles} +} + +function stringifyRuleSet({ selector, rules }: RuleSet): string { if (process.env.NODE_ENV === 'production') { const rulesString = rules .map(rule => `${rule.property}:${rule.value}`) .join(';') - styles = `.${className}{${rulesString}}` - } else { - const rulesString = rules - .map(rule => ` ${rule.property}: ${rule.value};`) - .join('\n') - styles = ` -.${className} { -${rulesString} -}` + return `${selector}{${rulesString}}` } - return {className, styles} + const rulesString = rules + .map(rule => ` ${rule.property}: ${rule.value};`) + .join('\n') + + return ` +${selector} { +${rulesString} +}` } diff --git a/src/index.tsx b/src/index.tsx index 6040406..dba9d04 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ export { default as splitBoxProps } from './utils/split-box-props' export { setClassNamePrefix } from './get-class-name' export { configureSafeHref } from './utils/safeHref' export { BoxProps, BoxOwnProps, EnhancerProps, PropsOf, PolymorphicBoxProps, BoxComponent } from './types/box-types' +export { use as usePlugin } from './plugins' export { background, diff --git a/src/plugins.ts b/src/plugins.ts new file mode 100644 index 0000000..12f0cd6 --- /dev/null +++ b/src/plugins.ts @@ -0,0 +1,64 @@ +import { Rule, isRule } from './prefixer' +import hasOwnProperty from './utils/has-own-property' + +export interface RuleSet { + selector: string, + rules: Rule[] +} + +type Plugin = (set: RuleSet) => RuleSet + +const plugins: Plugin[] = [] + +function isRuleSet (set: unknown): set is RuleSet { + if (typeof set !== 'object' || set === null) { + return false + } + if (!hasOwnProperty(set, 'selector') || + typeof set.selector !== 'string') { + return false + } + if (!hasOwnProperty(set, 'rules')) { + return false + } + + const rules = set.rules + + return Array.isArray(rules) && rules.every(isRule) +} + +/** + * Adds a plugin that ui-box will apply before emitting styles into a stylesheet. + * Plugins are applied in the *opposite* order of insertion (most recent `use` first). + */ +export function use(plugin: Plugin): void { + plugins.unshift(plugin) +} + +/** + * Applies the added plugins to a given set of CSS rules. + * Should be used only internally by ui-box, not by plugin authors or consumers. + */ +export function apply(set: RuleSet) { + let newSet = set + + for (const plugin of plugins) { + const updatedSet = plugin({...newSet}) + + // Skip broken plugins + if (isRuleSet(updatedSet)) { + newSet = updatedSet + } else if (process.env.NODE_ENV !== 'production') { + throw new Error(`📦 ui-box: Plugin "${plugin.name}" returned an invalid RuleSet.`) + } + } + + return newSet +} + +/** + * Removes all previously added plugins. + */ +export function clear(): void { + plugins.length = 0 +} diff --git a/src/prefixer.ts b/src/prefixer.ts index 999b655..e607b79 100644 --- a/src/prefixer.ts +++ b/src/prefixer.ts @@ -1,5 +1,6 @@ import {prefix} from 'inline-style-prefixer' import decamelize from './utils/decamelize' +import hasOwnProperty from './utils/has-own-property' const prefixRegex = /^(Webkit|ms|Moz|O)/ @@ -7,6 +8,17 @@ export interface Rule { property: string value: string } + +export function isRule(rule: unknown): rule is Rule { + if (typeof rule !== 'object' || rule === null) { + return false + } + return hasOwnProperty(rule, 'property') && + typeof rule.property === 'string' && + hasOwnProperty(rule, 'value') && + typeof rule.value === 'string' +} + /** * Adds vendor prefixes to properties and values. */ diff --git a/src/utils/has-own-property.ts b/src/utils/has-own-property.ts new file mode 100644 index 0000000..6d0e1d8 --- /dev/null +++ b/src/utils/has-own-property.ts @@ -0,0 +1,7 @@ +/** + * Test existence of a property on an object in a Typescript-friendly way + */ +export default function hasOwnProperty + (obj: UnknownObject, prop: Prop): obj is UnknownObject & Record { + return Object.prototype.hasOwnProperty.call(obj, prop) +} diff --git a/test/get-css.ts b/test/get-css.ts index e0b1e4a..f667dbb 100644 --- a/test/get-css.ts +++ b/test/get-css.ts @@ -1,9 +1,11 @@ import test from 'ava' import getCss from '../src/get-css' +import * as plugins from '../src/plugins' const originalNodeEnv = process.env.NODE_ENV test.afterEach.always(() => { process.env.NODE_ENV = originalNodeEnv + plugins.clear() }) test('supports basic prop + value', t => { @@ -59,6 +61,30 @@ test('adds prefixes', t => { ) }) +test('applies plugins', t => { + const propInfo = { + className: 'min-w', + cssName: 'min-width', + jsName: 'minWidth' + } + + plugins.use(({ selector, rules }) => { + return { + selector: `#my-div ${selector}`, + rules + } + }) + + const result = getCss(propInfo, '10px') + t.deepEqual(result, { + className: 'ub-min-w_10px', + styles: ` +#my-div .ub-min-w_10px { + min-width: 10px; +}` + }) +}) + test.serial('returns minified css in production', t => { process.env.NODE_ENV = 'production' const propInfo = { diff --git a/test/plugins.ts b/test/plugins.ts new file mode 100644 index 0000000..4d8976e --- /dev/null +++ b/test/plugins.ts @@ -0,0 +1,99 @@ +import test from 'ava' +import * as plugins from '../src/plugins' + +const originalNodeEnv = process.env.NODE_ENV +test.afterEach.always(() => { + process.env.NODE_ENV = originalNodeEnv + plugins.clear() +}) + +test('applies a selector prefix to the styles', t => { + const ruleset = { + selector: '.ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + } + plugins.use(({ selector, rules }) => { + return { + selector: `#my-div ${selector}`, + rules + } + }) + const result = plugins.apply(ruleset) + t.deepEqual(result, { + selector: '#my-div .ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + }) +}) + +test('removes plugins', t => { + const ruleset = { + selector: '.ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + } + plugins.use(({ selector, rules }) => { + return { + selector: `#my-div ${selector}`, + rules + } + }) + plugins.clear() + const result = plugins.apply(ruleset) + t.deepEqual(result, ruleset) +}) + +test('errors on plugins in dev that return invalid rules', t => { + const ruleset = { + selector: '.ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + } + plugins.use(({ selector, rules }) => { + return { + selector: `#my-div ${selector}`, + rules: { bad: 'rule' } + } + }) + t.throws(() => plugins.apply(ruleset), /invalid RuleSet/) +}) + +test('skips plugins in production that return invalid rules', t => { + process.env.NODE_ENV = 'production' + const ruleset = { + selector: '.ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + } + plugins.use(({ selector, rules }) => { + return { + selector: `#bad-div ${selector}`, + rules: { bad: 'rule' } + } + }) + plugins.use(({ selector, rules }) => { + return { + selector: `#my-div ${selector}`, + rules + } + }) + const result = plugins.apply(ruleset) + t.deepEqual(result, { + selector: '#my-div .ub-min-w_10px', + rules: [ + { property: 'min-width', value: '10px' }, + { property: 'user-select', value: 'none' } + ] + }) +})