Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/get-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
}`
}
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions src/plugins.ts
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions src/prefixer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import {prefix} from 'inline-style-prefixer'
import decamelize from './utils/decamelize'
import hasOwnProperty from './utils/has-own-property'

const prefixRegex = /^(Webkit|ms|Moz|O)/

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.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/utils/has-own-property.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Test existence of a property on an object in a Typescript-friendly way
*/
export default function hasOwnProperty<UnknownObject extends {}, Prop extends PropertyKey>
(obj: UnknownObject, prop: Prop): obj is UnknownObject & Record<Prop, unknown> {
return Object.prototype.hasOwnProperty.call(obj, prop)
}
26 changes: 26 additions & 0 deletions test/get-css.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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 = {
Expand Down
99 changes: 99 additions & 0 deletions test/plugins.ts
Original file line number Diff line number Diff line change
@@ -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' }
]
})
})