-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Add plugin system #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add plugin system #397
Conversation
Hey @adamwathan NICE!! I really love it. First (and very minor) is naming of the
|
Hey @psren thank you for chiming in!
I agree! I will rename this to
They get injected in different places in the CSS: components are added after
Yeah this is a potential problem although I'm not too worried about it; people will just not use that plugin or they will complain. The reason I want to defer this to the plugin author is that our plugin system doesn't enforce any restrictions about the types of rules you can create. Someone could make a rule like: rule('body > a.foo[type=bar]:last-child > span + .baz', { ... }) ...so it's not immediately obvious how to prefix that sort of thing or if it should matter. Same with I think the ultimate solution is to defer to the plugin author but also provide as much as we can helper-wise to make it very easy to respect the user's configuration. |
I might not be seeing it, but is there a way to create components using existing utilities? https://tailwindcss.com/docs/extracting-components The ability to create a package out of standard components would be amazing. |
A couple initial thoughts. First, why expose export default function ({ addUtilities, addComponents, config }) {
const radius = config('buttonRadius', '.25rem', 'borderRadius.default');
addUtilities({
'.object-fill': {
'object-fit': 'fill',
},
'.object-contain': {
'object-fit': 'contain',
},
'.object-cover': {
'object-fit': 'cover',
}
});
addComponents({
'.btn-blue': {
'background-color': 'blue',
'color': 'white',
'padding': '.5rem 1rem',
'border-radius': radius,
},
'.btn-blue:hover': {
'background-color': 'darkblue',
}
});
}
Second, while I completely understand the reasoning behind not exposing the tailwind config to the plugin, I think it limits many of the most promising use cases. If you don't have access to the tailwind config, what's the value in a tailwind plugin over just a PostCSS plugin? I think that something along the lines of my proposed The signature would be something like: config(configName, defaultValue[, defaultConfigPath]);
So, given the default tailwind config, you might call the plugin in two ways: // Override the default value
{ plugins: [ myPlugin({ buttonRadius: '.5rem' }) ] }
// Use the borderRadius.default value
{ plugins: [ myPlugin() ] }
// Fall back to default value
{
borderRadius: {
foo: '20rem'
}
plugins: [
myPlugin()
]
} While many people will customize their tailwind config heavily, I'm guessing that the overwhelming majority of people will keep many of the configuration names the same. Third, have you explored building tailwind's core functionality as plugins? I feel like generating a Tailwind documentation-quality style guide based on your specific tailwind config has tremendous promise, and if plugins automatically documented themselves in the same way by default, that would be amazing. |
const defaultConfig = require('tailwindcss/defaultConfig')();
const red = defaultConfig.colors.red;
const deleteButtonColor = config('buttonColor', red, ['colors.red', 'colors.danger']); When you think about the most likely use-cases here, there's maybe even an argument for just defaulting to the const deleteButtonColor = config('buttonColor', ['colors.red', 'colors.danger'], '#ff0000'); This would try (in this order):
I would venture a guess that 99% of the time, Thoughts? |
+1 for @milosdakic I agree withthe selector() rename to rule() Great job! |
This approach can't really be taken with plugins because there's no way to guarantee what utilities exist and how they're named. For example, the spacing scale is totally customizable, so someone might configure their build such that there is no Instead, you'll need to pass the values you want to use into the plugins themselves, so if you want a button plugin to use your customized spacing values, you'd just pass that information into the plugin as configuration. The plugin can totally have it's own defaults that mirror values from Tailwind's default config, but they won't actually be dependent on specific utility class names or values existing. |
@inxilpro Thanks for the thoughtful input! Thoughts below:
This is a valid point! My main argument for this is that I think ultimately I want to provide two ways to create rules.
For example: rule('.w-1/4', { ... }) ...would create this rule, where the .w-1/4 { ... } This is most useful when you are manually applying pseudo classes like I'd also like to add a function like utility('.w-1/4', { ... }) ...would generate: .w-1\/4 { ... } That function could also allow you to omit the leading Admittedly there's another way to solve this whole problem though which is to just expose a helper like addUtilities({
[`.${escape('w-1/4')`]: { ... }
}) Just a bit more verbose, although for this first pass of the feature I don't think I mind. I think it's better to start with providing raw/low-level access for maximum flexibility and layer on additional convenience features later vs. focusing on convenience out of the gate. I'm going to explore using the API you suggested + providing an
There's basically no benefit other than to try and make authoring plugins more accessible; you're totally right you could do all this with a regular PostCSS plugin!
This is a lot more complexity than I'd like to commit to right now, and has the unfortunate side effect of forcing a standard signature on the configuration of every plugin. I'd rather just pass in the raw config object and leave it to the plugin author to do any fancy crap with checking if keys are defined and falling back to defaults and stuff. It's very easy for a plugin author to rewrite this: config('radius', 'borderRadius.default') ...as: _.get(pluginConfig, 'radius', _.get(tailwindConfig, 'borderRadius.default', '.25rem')) ...without us having to figure out and commit to any fancy API for doing this across all plugins. Again, could totally explore more convenience features down the road but don't want to accidentally cripple anything or create unnecessary limitations by trying to be too clever or try to remove too much responsibility/control from the plugin author. I'll update things to pass the config through though; even if I think it can easily be abused you're right that there are ways to use it responsibly that can make configuration less burdensome 👍 |
I'm going to push back once on this, in that I think this is going to be a really important part of Tailwind plugins. I don't think it necessarily has to be complicated, and I think providing an API that doesn't solely rely on the keys from the default configuration would set everyone up for success. I also think there's a way to let plugins accept arbitrary config—anything from strings to callbacks—within the framework of what I proposed. In an attempt to not fill this thread with a ton of code, I created a gist with some ideas: https://gist.github.com/inxilpro/0f31c4ec164a1b56f76092ba98ed5f6e |
Just for now so that this feature can be introduced into the codebase without forcing a BC break. The container classes will eventually be moved to a built-in plugin that adds them as components.
…y escaped and respect prefix/important options
Leaving the original PR message untouched for historical reasons, but here's an updated version of the same message that includes the changes that have been made since then. Changes of note:
This PR introduces a new plugin system to Tailwind designed to allow end-users to add their own utilities, components, or even full blown themes. How plugins workPlugins are just functions that accept an object that can be destructured into a few useful helpers: function ({ rule, atRule, escape, prefix, config, utility, addUtilities, addComponents }) {
// Use `rule` to define new CSS rules, like utilities:
const utilities = [
rule('.object-fill', {
'object-fit': 'fill',
}),
rule('.object-contain', {
'object-fit': 'contain',
}),
rule('.object-cover', {
'object-fit': 'cover',
}),
]
// ...or components:
const components = [
rule('.btn-blue', {
'background-color': 'blue',
'color': 'white',
'padding': '.5rem 1rem',
'border-radius': '.25rem',
}),
rule('.btn-blue:hover', {
'background-color': 'darkblue',
}),
]
// Use `atRule` to wrap your own rules in at-rules, like media queries:
components.push([
rule('container', {
'width': '100%',
}),
atRule('@media (min-width: 500px)', [
rule('container', {
'max-width': '500px'
}),
]),
atRule('@media (min-width: 800px)', [
rule('container', {
'max-width': '800px'
}),
]),
])
// Use `escape` to escape anything that might contain non-standard
// characters but will be used as part of a class name:
utilities.push([
rule(`.skew-${escape(keyFromUser)}`, {
'transform': `skewY(${valueFromUser})`,
})
])
// Use `prefix` for applying the user's configured prefix to your plugins
// selectors, if it makes sense:
utilities.push([
rule(prefix('.border-collapse'), {
'border-collapse': 'collapse',
})
])
// Use `config` to access values from the user's Tailwind configuration.
//
// You should prefer explicit plugin configuration to this, but used
// responsibly it can simplify your plugin's API in certain cases.
components.push([
rule('.card', {
'border-radius': config('borderRadius.default', '.25rem'),
})
])
// Use `utility` for defining new utilities that are automatically escaped
// and automatically respect the user's `prefix` and `important` options:
utilities.push([
utility('.rotate-1/4', {
'transform': 'rotate(90deg)',
})
])
// Use `addUtilities` to register new utilities with Tailwind.
//
// These will be inserted *after* Tailwind's built-in utilities.
// You can also pass an array of variants to generate for plugin utilities.
addUtilities(utilities, ['responsive'])
// Use `addComponents` to register new component classes with Tailwind.
//
// These will be inserted at the new `@tailwind components` directive.
addComponents(components)
} Registering pluginsTo register a new plugin, add it to the {
// ...
plugins: [
function ({ rule, addUtilities }) {
addUtilities([
rule('.object-fill', {
'object-fit': 'fill',
}),
rule('.object-contain', {
'object-fit': 'contain',
}),
rule('.object-cover', {
'object-fit': 'cover',
})
], ['responsive', 'hover'])
},
]
} Importing plugins from external modulesPlugins can of course be place in separate modules, so in practice this will end up looking more like this: // tailwindcss-object-fit-utils:
export default function (variants) {
return function ({ rule, addUtilities }) {
addUtilities([
rule('.object-fill', {
'object-fit': 'fill',
}),
rule('.object-contain', {
'object-fit': 'contain',
}),
rule('.object-cover', {
'object-fit': 'cover',
}),
], variants)
}
}
// Your Tailwind config:
{
// ...
plugins: [
require('tailwindcss-object-fit-utils')(['responsive', 'hover', 'focus']),
]
} Configuring pluginsAs you might have gleaned from the above example, the recommended pattern for plugins that need configuration is to export a function that accepts your configuration options in whatever format you want, then returns the actual plugin function, which will then have access to the plugin configuration from the parent scope. That means a buttons plugin might look like this to use: // Your Tailwind config:
{
// ...
plugins: [
require('tailwindcss-simple-buttons')({
sizes: {
default: {
fontSize: '1rem',
padding: '.5rem 1rem',
},
sm: {
fontSize: '.875rem',
padding: '.25rem .5rem',
},
lg: {
fontSize: '1.25rem',
padding: '1rem 2rem',
},
},
colors: {
primary: {
bg: colors['blue'],
text: colors['white'],
},
secondary: {
bg: colors['grey-light'],
text: colors['black'],
}
}
}),
]
} ...and be implemented like this: import _ from 'lodash'
import Color from 'color'
export default function (options) {
return function ({ rule, addComponents }) {
const buttonSizes = _.map(options.sizes, ({ fontSize, padding }, size) => {
return rule(size === 'default' ? '.btn' : `.btn-${size}`, {
'font-size': fontSize,
'padding': padding,
})
})
const buttonColors = _.flatMap(options.colors, ({ bg, text }, name) => {
return [
rule(`.btn-${name}`, {
'background-color': bg,
'color': text,
}),
rule(`.btn-${name}:hover`, {
'background-color': Color(bg).darken(0.2).string(),
}),
]
})
addComponents([
...buttonSizes,
...buttonColors,
])
}
} If you're seeing this and saying to yourself, "wow, I might as well just write the CSS at this point," congratulations, now you understand why we didn't bother shipping configurable components with Tailwind in the first place :D How a plugin's configuration is structured is entirely up to the plugin author; Tailwind only cares about the actual plugin function you eventually provide it. Default configuration valuesThere's nothing stopping a plugin author from providing default configuration options of course, but Tailwind itself doesn't do anything for you in this regard; it's up to the plugin author to implement as she sees fit. For example, that button plugin might have some default sizes based on Tailwind's default font sizes and spacing sizes, and it might generate buttons for all of the default Tailwind colors, unless you override that with your own colors. Again the sky is the limit here, it's Just JavaScript™ so plugin authors can do whatever they want. Component statesYou might have noticed that unlike Instead, unlike utilities, many components need to apply different styles based on their state, like how a button might change color on hover. For now, plugin authors should treat the different states of their components as completely separate selectors, like demonstrated in the very first component example: function ({rule, addUtilities, addComponents}) {
const components = [
rule('.btn-blue', {
'background-color': 'blue',
'color': 'white',
'padding': '.5rem 1rem',
'border-radius': '.25rem',
})
rule('.btn-blue:hover', {
'background-color': 'darkblue',
})
]
addComponents(components)
} I might try to create a nicer API for this sort of thing in the future, but it would really just be sugar for this same code. Accessing existing config valuesPlugins receive a export default function (options) {
return function ({ rule, addComponents, config }) {
addComponents([
rule('.card', {
'border-radius': config('border-radius.default', '.25rem')
})
])
}
} Despite the fact that this function exists, be aware that this is not the recommended way for plugins to make themselves configurable. If anyone ever releases a plugin where the documentation says, "to specify the border radius of your cards, add a Instead, your plugin should accept its own options so the end user can configure the plugin directly, rather than having the plugin reaching into the user's main Tailwind config and blindly stumble around looking for keys that aren't guaranteed to exist. If you really wanted to create a plugin for a "card" component and wanted to use the user's // Plugin source
export default function (options) {
return function ({ rule, addComponents, config }) {
addComponents([
rule('.card', {
'border-radius': _.get(options, 'borderRadius', config('border-radius.default', '.25rem'))
})
])
}
}
// Scenario A: Plugin is configured explicitly
let radiusScale = {
default: '.25rem',
// ...
}
module.exports = {
// ...
borderRadius: radiusScale,
plugins: [
require('card-plugin')({ borderRadius: radiusScale.default }),
]
}
// Scenario B: Plugin falls back to `borderRadius.default`
module.exports = {
// ...
borderRadius: {
default: '.25rem',
// ...
},
plugins: [
require('card-plugin')(),
]
}
// Scenario C: Plugin falls back to hard-coded default because `borderRadius.default` does not exist
module.exports = {
// ...
borderRadius: {
sm: '.125rem',
md: '.25rem',
lg: '.5rem',
},
plugins: [
require('card-plugin')(),
]
}
|
Following up on this @inxilpro, I tried it out but ran into a few issues that made me decide to hold off on supporting that simpler API for now. The main ones:
I still want to support this syntax eventually, but trying to iron out all the little issues was a dark rabbit hole and I wanted to get a less fancy version out faster. |
I have to say I'm not too much a fan of this at all. function ({selector, addUtilities, addComponents}) {
const components = [
selector('.btn-blue', {
'background-color': 'blue',
'color': 'white',
'padding': '.5rem 1rem',
'border-radius': '.25rem',
})
selector('.btn-blue:hover', {
'background-color': 'darkblue',
})
]
addComponents(components)
} This completely goes against what Tailwind promotes which is to re-use a defined set of sizes, colours etc. The config is overly complicated when it's essentially just adding CSS! When I envisaged the component system I imagined it would operate much like the Components is definitely a very useful thing to add to Tailwind, but I just not a fan of this overly complicated implementation. Maybe documentation would clear that up though. Seems very webpack-y (which has never been easy or pleasant to configure!). |
@garygreen In practice, those values would be configurable and would fallback to sane defaults based on the Tailwind config file. The main benefit to this system is allowing people to Providing customizable third-party components via CSS can't really be done, so this system is designed to let you do it in JS, since at least JS components can be easily configured. It's definitely a non-trivial amount of work to build plugins in a robust, customizable, and also "nice-out-of-the-box" way but the goal here is making things nice for the plugin consumer. If a plugin is designed properly, a user using Tailwind's default configuration should be able to just do this:
...and have nice-looking button classes available instantly, that all follow Tailwind's default design system. If someone needs to customize, a plugin should expose all of that as well, but optionally:
I think it's all going to work out, but if you have any better suggestions I'd love to hear them. |
That looks more like it. Initially I was just overwhelmed by how complex this looks (on the side of component authors) but from the consumer side it looks simpler and makes a lot of sense when you get your head round it. Tailwind has been great for documentation so I'm sure all of this will be thoroughly explained for both author and consumer. One thing, what's the difference between |
That's actually all been changed and improved a lot since this PR though; check out the changes here: |
Update 2018-03-13: A lot of syntax has changed since this PR, be sure to see #412 for the changes until the plugin system has complete documentation.
This PR introduces a new plugin system to Tailwind designed to allow end-users to add their own utilities, components, or even full blown themes.
How plugins work
Plugins are just functions that accept an object that can be destructured into a few useful helpers:
Registering plugins
To register a new plugin, add it to the
plugins
section of your config file:Importing plugins from external modules
Plugins can of course be place in separate modules, so in practice this will end up looking more like this:
Configuring plugins
As you might have gleaned from the above example, the recommended pattern for plugins that need configuration is to export a function that accepts your configuration options in whatever format you want, then returns the actual plugin function, which will then have access to the plugin configuration from the parent scope.
That means a buttons plugin might look like this to use:
...and be implemented like this:
If you're seeing this and saying to yourself, "wow, I might as well just write the CSS at this point," congratulations, now you understand why we didn't bother shipping configurable components with Tailwind in the first place :D
How a plugin's configuration is structured is entirely up to the plugin author; Tailwind only cares about the actual plugin function you eventually provide it.
Default configuration values
There's nothing stopping a plugin author from providing default configuration options of course, but Tailwind itself doesn't do anything for you in this regard; it's up to the plugin author to implement as she sees fit.
For example, that button plugin might have some default sizes based on Tailwind's default font sizes and spacing sizes, and it might generate buttons for all of the default Tailwind colors, unless you override that with your own colors.
Again the sky is the limit here, it's Just JavaScript™ so plugin authors can do whatever they want.
Component states
You might have noticed that unlike
addUtilities
,addComponents
doesn't take the extra "state variants" parameter. That's because typically it doesn't make any sense for a true "component class" to only be "activated" by some interaction like hover or focus.Instead, unlike utilities, many components need to apply different styles based on their state, like how a button might change color on hover.
For now, plugin authors should treat the different states of their components as completely separate selectors, like demonstrated in the very first component example:
I might try to create a nicer API for this sort of thing in the future, but it would really just be sugar for this same code.
Accessing existing config values
I've explicitly decided not to pass the user's Tailwind config to the plugin function to discourage plugin authors from depending on certain keys existing that are not actually guaranteed to exist.
For example, I don't want someone writing a button plugin that tries to use
config.padding[4]
for horizontal padding, because users are encouraged to customize those scales to fit their needs.Instead, if a plugin wants to default to using values from Tailwind's default config, the plugin should import Tailwind's default config and reference that:
If you as the end user have customized your config and want to reuse those values to pass into plugins, use variables (like we already do for colors); your config file is Just JavaScript™ after all:
How would themes work?
A "theme" or full blown "component kit" would really just be a plugin that adds many components at once.
Since all of this stuff is Just JavaScript™, a theme could totally provide color palettes, type scales, spacing scales, etc. as well:
A truly "done for you" theme could even provide an entire Tailwind configuration:
Coming next
An
atRule
helper that will be useful for components that need to change at different breakpoints, for example our existingcontainer
component:Possibly a
component
helper that makes it easier to define hover/focus/etc states for a component class in a single function callPossibly a
escape
helper to make it easier to escape any user provided strings that are going to be used in class names, like we do already for things likew-1/4
Questions
How should the
prefix
option affect classes generated by plugins?My inclination is we do nothing by default, but perhaps pass the prefix to the plugin so the plugin can incorporate it however it needs to if it makes sense.
How should the
important
option affect classes generated by plugins?Again, I think we do nothing by default and just pass the information along to the plugin.