Repository: tailwindlabs/tailwindcss
Pull request #5383 (2021-09-03 15:45:38)

Add custom plugins tests
- add tests for the plugin API
- make plugin invocation optional

Ported these tests from the AOT engine.


Patch:
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
index 9c6b3325..26ba8e29 100644
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -499,7 +499,7 @@ function registerPlugins(plugins, context) {
         pluginItem(pluginApi)
       }
     } else {
-      plugin(pluginApi)
+      plugin?.(pluginApi)
     }
   }
 


Test patch:
diff --git a/tests/custom-plugins.test.js b/tests/custom-plugins.test.js
new file mode 100644
index 00000000..1256ff8b
--- /dev/null
+++ b/tests/custom-plugins.test.js
@@ -0,0 +1,1894 @@
+import createPlugin from './../plugin'
+import { run, html, css } from './util/run'
+
+test('plugins can create utilities with object syntax', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-object-fill custom-object-contain custom-object-cover"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities({
+          '.custom-object-fill': {
+            'object-fit': 'fill',
+          },
+          '.custom-object-contain': {
+            'object-fit': 'contain',
+          },
+          '.custom-object-cover': {
+            'object-fit': 'cover',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-object-fill {
+        object-fit: fill;
+      }
+      .custom-object-contain {
+        object-fit: contain;
+      }
+      .custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can create utilities with arrays of objects', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-object-fill custom-object-contain custom-object-cover"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities([
+          {
+            '.custom-object-fill': {
+              'object-fit': 'fill',
+            },
+            '.custom-object-contain': {
+              'object-fit': 'contain',
+            },
+            '.custom-object-cover': {
+              'object-fit': 'cover',
+            },
+          },
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-object-fill {
+        object-fit: fill;
+      }
+      .custom-object-contain {
+        object-fit: contain;
+      }
+      .custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can create utilities with raw PostCSS nodes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-object-fill custom-object-contain custom-object-cover"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities, postcss }) {
+        addUtilities([
+          postcss.rule({ selector: '.custom-object-fill' }).append([
+            postcss.decl({
+              prop: 'object-fit',
+              value: 'fill',
+            }),
+          ]),
+          postcss.rule({ selector: '.custom-object-contain' }).append([
+            postcss.decl({
+              prop: 'object-fit',
+              value: 'contain',
+            }),
+          ]),
+          postcss.rule({ selector: '.custom-object-cover' }).append([
+            postcss.decl({
+              prop: 'object-fit',
+              value: 'cover',
+            }),
+          ]),
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-object-fill {
+        object-fit: fill;
+      }
+      .custom-object-contain {
+        object-fit: contain;
+      }
+      .custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can create utilities with mixed object styles and PostCSS nodes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-object-fill custom-object-contain custom-object-cover"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities, postcss }) {
+        addUtilities([
+          {
+            '.custom-object-fill': {
+              objectFit: 'fill',
+            },
+          },
+          postcss.rule({ selector: '.custom-object-contain' }).append([
+            postcss.decl({
+              prop: 'object-fit',
+              value: 'contain',
+            }),
+          ]),
+          postcss.rule({ selector: '.custom-object-cover' }).append([
+            postcss.decl({
+              prop: 'object-fit',
+              value: 'cover',
+            }),
+          ]),
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-object-fill {
+        object-fit: fill;
+      }
+      .custom-object-contain {
+        object-fit: contain;
+      }
+      .custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can create components with object syntax', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<button class="btn-blue"></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents({
+          '.btn-blue': {
+            backgroundColor: 'blue',
+            color: 'white',
+            padding: '.5rem 1rem',
+            borderRadius: '.25rem',
+          },
+          '.btn-blue:hover': {
+            backgroundColor: 'darkblue',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+        color: white;
+        padding: 0.5rem 1rem;
+        border-radius: 0.25rem;
+      }
+      .btn-blue:hover {
+        background-color: darkblue;
+      }
+    `)
+  })
+})
+
+test('plugins can add base styles with object syntax', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<img /><button></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addBase }) {
+        addBase({
+          img: {
+            maxWidth: '100%',
+          },
+          button: {
+            fontFamily: 'inherit',
+          },
+        })
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  return run('@tailwind base', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      img {
+        max-width: 100%;
+      }
+      button {
+        font-family: inherit;
+      }
+    `)
+  })
+})
+
+test('plugins can add base styles with raw PostCSS nodes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<img /><button></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addBase, postcss }) {
+        addBase([
+          postcss.rule({ selector: 'img' }).append([
+            postcss.decl({
+              prop: 'max-width',
+              value: '100%',
+            }),
+          ]),
+          postcss.rule({ selector: 'button' }).append([
+            postcss.decl({
+              prop: 'font-family',
+              value: 'inherit',
+            }),
+          ]),
+        ])
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  return run('@tailwind base', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      img {
+        max-width: 100%;
+      }
+      button {
+        font-family: inherit;
+      }
+    `)
+  })
+})
+
+test('plugins can create components with raw PostCSS nodes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<button class="btn-blue"></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addComponents, postcss }) {
+        addComponents([
+          postcss.rule({ selector: '.btn-blue' }).append([
+            postcss.decl({
+              prop: 'background-color',
+              value: 'blue',
+            }),
+            postcss.decl({
+              prop: 'color',
+              value: 'white',
+            }),
+            postcss.decl({
+              prop: 'padding',
+              value: '.5rem 1rem',
+            }),
+            postcss.decl({
+              prop: 'border-radius',
+              value: '.25rem',
+            }),
+          ]),
+          postcss.rule({ selector: '.btn-blue:hover' }).append([
+            postcss.decl({
+              prop: 'background-color',
+              value: 'darkblue',
+            }),
+          ]),
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+        color: white;
+        padding: 0.5rem 1rem;
+        border-radius: 0.25rem;
+      }
+      .btn-blue:hover {
+        background-color: darkblue;
+      }
+    `)
+  })
+})
+
+test('plugins can create components with mixed object styles and raw PostCSS nodes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<button class="btn-blue"></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addComponents, postcss }) {
+        addComponents([
+          postcss.rule({ selector: '.btn-blue' }).append([
+            postcss.decl({
+              prop: 'background-color',
+              value: 'blue',
+            }),
+            postcss.decl({
+              prop: 'color',
+              value: 'white',
+            }),
+            postcss.decl({
+              prop: 'padding',
+              value: '.5rem 1rem',
+            }),
+            postcss.decl({
+              prop: 'border-radius',
+              value: '.25rem',
+            }),
+          ]),
+          {
+            '.btn-blue:hover': {
+              backgroundColor: 'darkblue',
+            },
+          },
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+        color: white;
+        padding: 0.5rem 1rem;
+        border-radius: 0.25rem;
+      }
+      .btn-blue:hover {
+        background-color: darkblue;
+      }
+    `)
+  })
+})
+
+test('plugins can create components with media queries with object syntax', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-container"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents({
+          '.custom-container': {
+            width: '100%',
+          },
+          '@media (min-width: 100px)': {
+            '.custom-container': {
+              maxWidth: '100px',
+            },
+          },
+          '@media (min-width: 200px)': {
+            '.custom-container': {
+              maxWidth: '200px',
+            },
+          },
+          '@media (min-width: 300px)': {
+            '.custom-container': {
+              maxWidth: '300px',
+            },
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-container {
+        width: 100%;
+      }
+      @media (min-width: 100px) {
+        .custom-container {
+          max-width: 100px;
+        }
+      }
+      @media (min-width: 200px) {
+        .custom-container {
+          max-width: 200px;
+        }
+      }
+      @media (min-width: 300px) {
+        .custom-container {
+          max-width: 300px;
+        }
+      }
+    `)
+  })
+})
+
+test('media queries can be defined multiple times using objects-in-array syntax', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-container"></div>
+          <button class="btn"></button>`,
+      },
+    ],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents([
+          {
+            '.custom-container': {
+              width: '100%',
+            },
+            '@media (min-width: 100px)': {
+              '.custom-container': {
+                maxWidth: '100px',
+              },
+            },
+          },
+          {
+            '.btn': {
+              padding: '1rem .5rem',
+              display: 'block',
+            },
+            '@media (min-width: 100px)': {
+              '.btn': {
+                display: 'inline-block',
+              },
+            },
+          },
+        ])
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-container {
+        width: 100%;
+      }
+      @media (min-width: 100px) {
+        .custom-container {
+          max-width: 100px;
+        }
+      }
+      .btn {
+        padding: 1rem 0.5rem;
+        display: block;
+      }
+      @media (min-width: 100px) {
+        .btn {
+          display: inline-block;
+        }
+      }
+    `)
+  })
+})
+
+test('plugins can create nested rules', () => {
+  let config = {
+    content: [{ raw: html`<button class="btn-blue"></button>` }],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents({
+          '.btn-blue': {
+            backgroundColor: 'blue',
+            color: 'white',
+            padding: '.5rem 1rem',
+            borderRadius: '.25rem',
+            '&:hover': {
+              backgroundColor: 'darkblue',
+            },
+            '@media (min-width: 500px)': {
+              '&:hover': {
+                backgroundColor: 'orange',
+              },
+            },
+            '> a': {
+              color: 'red',
+            },
+            'h1 &': {
+              color: 'purple',
+            },
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+        color: white;
+        padding: 0.5rem 1rem;
+        border-radius: 0.25rem;
+      }
+      .btn-blue:hover {
+        background-color: darkblue;
+      }
+      @media (min-width: 500px) {
+        .btn-blue:hover {
+          background-color: orange;
+        }
+      }
+      .btn-blue > a {
+        color: red;
+      }
+      h1 .btn-blue {
+        color: purple;
+      }
+    `)
+  })
+})
+
+test('plugins can create rules with escaped selectors', () => {
+  let config = {
+    content: [{ raw: html`<div class="custom-top-1/4"></div>` }],
+    plugins: [
+      function ({ e, addUtilities }) {
+        addUtilities({
+          [`.${e('custom-top-1/4')}`]: {
+            top: '25%',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-top-1\\/4 {
+        top: 25%;
+      }
+    `)
+  })
+})
+
+test('plugins can access the current config', () => {
+  let config = {
+    content: [{ raw: html`<div class="custom-container"></div>` }],
+    plugins: [
+      function ({ addComponents, config }) {
+        let containerClasses = [
+          {
+            '.custom-container': {
+              width: '100%',
+            },
+          },
+        ]
+
+        for (let maxWidth of Object.values(config('theme.customScreens'))) {
+          containerClasses.push({
+            [`@media (min-width: ${maxWidth})`]: {
+              '.custom-container': { maxWidth },
+            },
+          })
+        }
+
+        addComponents(containerClasses)
+      },
+    ],
+    theme: {
+      customScreens: {
+        sm: '576px',
+        md: '768px',
+        lg: '992px',
+        xl: '1200px',
+      },
+    },
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-container {
+        width: 100%;
+      }
+      @media (min-width: 576px) {
+        .custom-container {
+          max-width: 576px;
+        }
+      }
+      @media (min-width: 768px) {
+        .custom-container {
+          max-width: 768px;
+        }
+      }
+      @media (min-width: 992px) {
+        .custom-container {
+          max-width: 992px;
+        }
+      }
+      @media (min-width: 1200px) {
+        .custom-container {
+          max-width: 1200px;
+        }
+      }
+    `)
+  })
+})
+
+test('plugins can check if corePlugins are enabled', () => {
+  let config = {
+    content: [{ raw: html`<div class="test"></div>` }],
+    plugins: [
+      function ({ addUtilities, corePlugins }) {
+        addUtilities({
+          '.test': {
+            'text-color': corePlugins('textColor') ? 'true' : 'false',
+            opacity: corePlugins('opacity') ? 'true' : 'false',
+          },
+        })
+      },
+    ],
+    corePlugins: { textColor: false },
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .test {
+        text-color: false;
+        opacity: true;
+      }
+    `)
+  })
+})
+
+test('plugins can check if corePlugins are enabled when using array white-listing', () => {
+  let config = {
+    content: [{ raw: html`<div class="test"></div>` }],
+    plugins: [
+      function ({ addUtilities, corePlugins }) {
+        addUtilities({
+          '.test': {
+            'text-color': corePlugins('textColor') ? 'true' : 'false',
+            opacity: corePlugins('opacity') ? 'true' : 'false',
+          },
+        })
+      },
+    ],
+    corePlugins: ['textColor'],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .test {
+        text-color: true;
+        opacity: false;
+      }
+    `)
+  })
+})
+
+test('plugins can provide fallbacks to keys missing from the config', () => {
+  let config = {
+    content: [{ raw: html`<button class="btn"></button>` }],
+    plugins: [
+      function ({ addComponents, config }) {
+        addComponents({
+          '.btn': {
+            borderRadius: config('borderRadius.default', '.25rem'),
+          },
+        })
+      },
+    ],
+    theme: {
+      borderRadius: {
+        1: '1px',
+        2: '2px',
+        4: '4px',
+        8: '8px',
+      },
+    },
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn {
+        border-radius: 0.25rem;
+      }
+    `)
+  })
+})
+
+test('plugins can add multiple sets of utilities and components', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<button class="btn"></button>
+          <div class="card custom-skew-12deg custom-border-collapse"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities, addComponents }) {
+        addComponents({
+          '.card': {
+            padding: '1rem',
+            borderRadius: '.25rem',
+          },
+        })
+
+        addUtilities({
+          '.custom-skew-12deg': {
+            transform: 'skewY(-12deg)',
+          },
+        })
+
+        addComponents({
+          '.btn': {
+            padding: '1rem .5rem',
+            display: 'inline-block',
+          },
+        })
+
+        addUtilities({
+          '.custom-border-collapse': {
+            borderCollapse: 'collapse',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components; @tailwind utilities;', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .card {
+        padding: 1rem;
+        border-radius: 0.25rem;
+      }
+      .btn {
+        padding: 1rem 0.5rem;
+        display: inline-block;
+      }
+      .custom-skew-12deg {
+        transform: skewY(-12deg);
+      }
+      .custom-border-collapse {
+        border-collapse: collapse;
+      }
+    `)
+  })
+})
+
+test('plugins respect prefix and important options by default when adding utilities', () => {
+  let config = {
+    prefix: 'tw-',
+    important: true,
+    content: [{ raw: html`<div class="tw-custom-rotate-90"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities({
+          '.custom-rotate-90': {
+            transform: 'rotate(90deg)',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-custom-rotate-90 {
+        transform: rotate(90deg) !important;
+      }
+    `)
+  })
+})
+
+test('when important is a selector it is used to scope utilities instead of adding !important', () => {
+  let config = {
+    prefix: 'tw-',
+    important: '#app',
+    content: [{ raw: html`<div class="tw-custom-rotate-90"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities({
+          '.custom-rotate-90': {
+            transform: 'rotate(90deg)',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      #app .tw-custom-rotate-90 {
+        transform: rotate(90deg);
+      }
+    `)
+  })
+})
+
+test('when important is a selector it scopes all selectors in a rule, even though defining utilities like this is stupid', () => {
+  let config = {
+    important: '#app',
+    content: [{ raw: html`<div class="custom-rotate-90 custom-rotate-1/4"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities({
+          '.custom-rotate-90, .custom-rotate-1\\/4': {
+            transform: 'rotate(90deg)',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      #app .custom-rotate-90,
+      #app .custom-rotate-1\\/4 {
+        transform: rotate(90deg);
+        transform: rotate(90deg);
+      }
+    `)
+  })
+})
+
+test('important utilities are not made double important when important option is used', () => {
+  let config = {
+    important: true,
+    content: [{ raw: html`<div class="custom-rotate-90"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities({
+          '.custom-rotate-90': {
+            transform: 'rotate(90deg) !important',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-rotate-90 {
+        transform: rotate(90deg) !important;
+      }
+    `)
+  })
+})
+
+test("component declarations respect the 'prefix' option by default", () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<button class="tw-btn-blue"></button>` }],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents({
+          '.btn-blue': {
+            backgroundColor: 'blue',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-btn-blue {
+        background-color: blue;
+      }
+    `)
+  })
+})
+
+test('all selectors in a rule are prefixed', () => {
+  let config = {
+    prefix: 'tw-',
+    content: [
+      {
+        raw: html`<button class="tw-btn-blue tw-btn-red"></button>
+          <div class="tw-custom-rotate-90 tw-custom-rotate-1/4"></div>`,
+      },
+    ],
+    plugins: [
+      function ({ addUtilities, addComponents }) {
+        addUtilities({
+          '.custom-rotate-90, .custom-rotate-1\\/4': {
+            transform: 'rotate(90deg)',
+          },
+        })
+        addComponents({
+          '.btn-blue, .btn-red': {
+            padding: '10px',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-btn-blue,
+      .tw-btn-red {
+        padding: 10px;
+        padding: 10px;
+      }
+    `)
+  })
+})
+
+test("component declarations can optionally ignore 'prefix' option", () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<button class="btn-blue"></button>` }],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents(
+          {
+            '.btn-blue': {
+              backgroundColor: 'blue',
+            },
+          },
+          { respectPrefix: false }
+        )
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+      }
+    `)
+  })
+})
+
+test("component declarations are not affected by the 'important' option", () => {
+  let config = {
+    important: true,
+    content: [{ raw: html`<button class="btn-blue"></button>` }],
+    plugins: [
+      function ({ addComponents }) {
+        addComponents({
+          '.btn-blue': {
+            backgroundColor: 'blue',
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .btn-blue {
+        background-color: blue;
+      }
+    `)
+  })
+})
+
+test("plugins can apply the user's chosen prefix to components manually", () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<button class="tw-btn-blue"></button>` }],
+    plugins: [
+      function ({ addComponents, prefix }) {
+        addComponents(
+          {
+            [prefix('.btn-blue')]: {
+              backgroundColor: 'blue',
+            },
+          },
+          { respectPrefix: false }
+        )
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-btn-blue {
+        background-color: blue;
+      }
+    `)
+  })
+})
+
+test('prefix can optionally be ignored for utilities', () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="custom-rotate-90"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities(
+          {
+            '.custom-rotate-90': {
+              transform: 'rotate(90deg)',
+            },
+          },
+          { respectPrefix: false }
+        )
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-rotate-90 {
+        transform: rotate(90deg);
+      }
+    `)
+  })
+})
+
+test('important can optionally be ignored for utilities', () => {
+  let config = {
+    important: true,
+    content: [{ raw: html`<div class="custom-rotate-90"></div>` }],
+    plugins: [
+      function ({ addUtilities }) {
+        addUtilities(
+          {
+            '.custom-rotate-90': {
+              transform: 'rotate(90deg)',
+            },
+          },
+          { respectImportant: false }
+        )
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-rotate-90 {
+        transform: rotate(90deg);
+      }
+    `)
+  })
+})
+
+test('prefix will prefix all classes in a selector', () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="tw-btn-blue tw-w-1/4"></div>` }],
+    plugins: [
+      function ({ addComponents, prefix }) {
+        addComponents(
+          {
+            [prefix('.btn-blue .w-1\\/4 > h1.text-xl + a .bar')]: {
+              backgroundColor: 'blue',
+            },
+          },
+          { respectPrefix: false }
+        )
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-btn-blue .tw-w-1\\/4 > h1.tw-text-xl + a .tw-bar {
+        background-color: blue;
+      }
+    `)
+  })
+})
+
+test('plugins can be provided as an object with a handler function', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="custom-object-fill custom-object-contain custom-object-cover"></div>`,
+      },
+    ],
+    plugins: [
+      {
+        handler({ addUtilities }) {
+          addUtilities({
+            '.custom-object-fill': {
+              'object-fit': 'fill',
+            },
+            '.custom-object-contain': {
+              'object-fit': 'contain',
+            },
+            '.custom-object-cover': {
+              'object-fit': 'cover',
+            },
+          })
+        },
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .custom-object-fill {
+        object-fit: fill;
+      }
+      .custom-object-contain {
+        object-fit: contain;
+      }
+      .custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can provide a config but no handler', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div
+          class="tw-custom-object-fill tw-custom-object-contain tw-custom-object-cover"
+        ></div>`,
+      },
+    ],
+    plugins: [
+      {
+        config: {
+          prefix: 'tw-',
+        },
+      },
+      {
+        handler({ addUtilities }) {
+          addUtilities({
+            '.custom-object-fill': {
+              'object-fit': 'fill',
+            },
+            '.custom-object-contain': {
+              'object-fit': 'contain',
+            },
+            '.custom-object-cover': {
+              'object-fit': 'cover',
+            },
+          })
+        },
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .tw-custom-object-fill {
+        object-fit: fill;
+      }
+      .tw-custom-object-contain {
+        object-fit: contain;
+      }
+      .tw-custom-object-cover {
+        object-fit: cover;
+      }
+    `)
+  })
+})
+
+test('plugins can be created using the `createPlugin` function', () => {
+  let config = {
+    content: [{ raw: html`<div class="test-sm test-md test-lg hover:test-sm sm:test-sm"></div>` }],
+    corePlugins: [],
+    theme: {
+      screens: {
+        sm: '400px',
+      },
+    },
+    plugins: [
+      createPlugin(
+        function ({ addUtilities, theme, variants }) {
+          addUtilities(
+            Object.fromEntries(
+              Object.entries(theme('testPlugin')).map(([k, v]) => [
+                `.test-${k}`,
+                { testProperty: v },
+              ])
+            ),
+            variants('testPlugin')
+          )
+        },
+        {
+          theme: {
+            testPlugin: {
+              sm: '1rem',
+              md: '2rem',
+              lg: '3rem',
+            },
+          },
+        }
+      ),
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .test-sm {
+        test-property: 1rem;
+      }
+      .test-md {
+        test-property: 2rem;
+      }
+      .test-lg {
+        test-property: 3rem;
+      }
+      .hover\\:test-sm:hover {
+        test-property: 1rem;
+      }
+      @media (min-width: 400px) {
+        .sm\\:test-sm {
+          test-property: 1rem;
+        }
+      }
+    `)
+  })
+})
+
+test('plugins with extra options can be created using the `createPlugin.withOptions` function', () => {
+  let plugin = createPlugin.withOptions(
+    function ({ className }) {
+      return function ({ addUtilities, theme }) {
+        addUtilities(
+          Object.fromEntries(
+            Object.entries(theme('testPlugin')).map(([k, v]) => [
+              `.${className}-${k}`,
+              { testProperty: v },
+            ])
+          )
+        )
+      }
+    },
+    function () {
+      return {
+        theme: {
+          testPlugin: {
+            sm: '1rem',
+            md: '2rem',
+            lg: '3rem',
+          },
+        },
+      }
+    }
+  )
+
+  let config = {
+    content: [
+      { raw: html`<div class="banana-sm banana-md banana-lg hover:banana-sm sm:banana-sm"></div>` },
+    ],
+    corePlugins: [],
+    theme: {
+      screens: {
+        sm: '400px',
+      },
+    },
+    plugins: [plugin({ className: 'banana' })],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .banana-sm {
+        test-property: 1rem;
+      }
+      .banana-md {
+        test-property: 2rem;
+      }
+      .banana-lg {
+        test-property: 3rem;
+      }
+      .hover\\:banana-sm:hover {
+        test-property: 1rem;
+      }
+      @media (min-width: 400px) {
+        .sm\\:banana-sm {
+          test-property: 1rem;
+        }
+      }
+    `)
+  })
+})
+
+test('plugins should cache correctly', () => {
+  let plugin = createPlugin.withOptions(({ className = 'banana' } = {}) => ({ addComponents }) => {
+    addComponents({ [`.${className}`]: { position: 'absolute' } })
+  })
+
+  let config = {
+    content: [{ raw: html`<div class="banana sm:banana apple sm:apple"></div>` }],
+    corePlugins: [],
+    theme: {
+      screens: {
+        sm: '400px',
+      },
+    },
+  }
+
+  function internalRun(options = {}) {
+    return run('@tailwind components', {
+      ...config,
+      plugins: [plugin(options)],
+    })
+  }
+
+  return Promise.all([internalRun(), internalRun({ className: 'apple' })]).then(
+    ([result1, result2]) => {
+      let expected1 = css`
+        .banana {
+          position: absolute;
+        }
+
+        @media (min-width: 400px) {
+          .sm\\:banana {
+            position: absolute;
+          }
+        }
+      `
+
+      let expected2 = css`
+        .apple {
+          position: absolute;
+        }
+
+        @media (min-width: 400px) {
+          .sm\\:apple {
+            position: absolute;
+          }
+        }
+      `
+
+      expect(result1.css).toMatchCss(expected1)
+      expect(result2.css).toMatchCss(expected2)
+    }
+  )
+})
+
+test('plugins created using `createPlugin.withOptions` do not need to be invoked if the user wants to use the default options', () => {
+  let plugin = createPlugin.withOptions(
+    function ({ className } = { className: 'banana' }) {
+      return function ({ addUtilities, theme }) {
+        addUtilities(
+          Object.fromEntries(
+            Object.entries(theme('testPlugin')).map(([k, v]) => [
+              `.${className}-${k}`,
+              { testProperty: v },
+            ])
+          )
+        )
+      }
+    },
+    function () {
+      return {
+        theme: {
+          testPlugin: {
+            sm: '1rem',
+            md: '2rem',
+            lg: '3rem',
+          },
+        },
+        variants: {
+          testPlugin: ['responsive', 'hover'],
+        },
+      }
+    }
+  )
+
+  let config = {
+    content: [
+      { raw: html`<div class="banana-sm banana-md banana-lg hover:banana-sm sm:banana-sm"></div>` },
+    ],
+    corePlugins: [],
+    theme: {
+      screens: {
+        sm: '400px',
+      },
+    },
+    plugins: [plugin],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .banana-sm {
+        test-property: 1rem;
+      }
+      .banana-md {
+        test-property: 2rem;
+      }
+      .banana-lg {
+        test-property: 3rem;
+      }
+      .hover\\:banana-sm:hover {
+        test-property: 1rem;
+      }
+      @media (min-width: 400px) {
+        .sm\\:banana-sm {
+          test-property: 1rem;
+        }
+      }
+    `)
+  })
+})
+
+test('the configFunction parameter is optional when using the `createPlugin.withOptions` function', () => {
+  let plugin = createPlugin.withOptions(function ({ className }) {
+    return function ({ addUtilities, theme }) {
+      addUtilities(
+        Object.fromEntries(
+          Object.entries(theme('testPlugin')).map(([k, v]) => [
+            `.${className}-${k}`,
+            { testProperty: v },
+          ])
+        )
+      )
+    }
+  })
+
+  let config = {
+    content: [
+      { raw: html`<div class="banana-sm banana-md banana-lg hover:banana-sm sm:banana-sm"></div>` },
+    ],
+    corePlugins: [],
+    theme: {
+      screens: {
+        sm: '400px',
+      },
+      testPlugin: {
+        sm: '1px',
+        md: '2px',
+        lg: '3px',
+      },
+    },
+    plugins: [plugin({ className: 'banana' })],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .banana-sm {
+        test-property: 1px;
+      }
+      .banana-md {
+        test-property: 2px;
+      }
+      .banana-lg {
+        test-property: 3px;
+      }
+      .hover\\:banana-sm:hover {
+        test-property: 1px;
+      }
+      @media (min-width: 400px) {
+        .sm\\:banana-sm {
+          test-property: 1px;
+        }
+      }
+    `)
+  })
+})
+
+test('keyframes are not escaped', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo-[abc]"></div>` }],
+    plugins: [
+      function ({ matchUtilities }) {
+        matchUtilities({
+          foo: (value, { includeRules }) => {
+            includeRules(
+              [
+                {
+                  [`@keyframes ${value}`]: {
+                    '25.001%': {
+                      color: 'black',
+                    },
+                  },
+                },
+              ],
+              { respectImportant: false }
+            )
+
+            return {
+              animation: `${value} 1s infinite`,
+            }
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      @keyframes abc {
+        25.001% {
+          color: black;
+        }
+      }
+      .foo-\\[abc\\] {
+        animation: abc 1s infinite;
+      }
+    `)
+  })
+})
+
+test('font sizes are retrieved without default line-heights or letter-spacing using theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      fontSize: {
+        sm: ['14px', '20px'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            fontSize: theme('fontSize.sm'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        font-size: 14px;
+      }
+    `)
+  })
+})
+
+test('outlines are retrieved without outline-offset using theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      outline: {
+        black: ['2px dotted black', '4px'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            outline: theme('outline.black'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        outline: 2px dotted black;
+      }
+    `)
+  })
+})
+
+test('box-shadow values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      boxShadow: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            boxShadow: theme('boxShadow.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        box-shadow: width, height;
+      }
+    `)
+  })
+})
+
+test('transition-property values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      transitionProperty: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            transitionProperty: theme('transitionProperty.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        transition-property: width, height;
+      }
+    `)
+  })
+})
+
+test('transition-duration values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      transitionDuration: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            transitionDuration: theme('transitionDuration.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        transition-duration: width, height;
+      }
+    `)
+  })
+})
+
+test('transition-delay values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      transitionDuration: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            transitionDuration: theme('transitionDuration.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        transition-duration: width, height;
+      }
+    `)
+  })
+})
+
+test('transition-timing-function values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      transitionTimingFunction: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            transitionTimingFunction: theme('transitionTimingFunction.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        transition-timing-function: width, height;
+      }
+    `)
+  })
+})
+
+test('background-image values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      backgroundImage: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            backgroundImage: theme('backgroundImage.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        background-image: width, height;
+      }
+    `)
+  })
+})
+
+test('background-size values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      backgroundSize: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            backgroundSize: theme('backgroundSize.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        background-size: width, height;
+      }
+    `)
+  })
+})
+
+test('background-color values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      backgroundColor: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            backgroundColor: theme('backgroundColor.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        background-color: width, height;
+      }
+    `)
+  })
+})
+
+test('cursor values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      cursor: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            cursor: theme('cursor.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        cursor: width, height;
+      }
+    `)
+  })
+})
+
+test('animation values are joined when retrieved using the theme function', () => {
+  let config = {
+    content: [{ raw: html`<div class="foo"></div>` }],
+    corePlugins: [],
+    theme: {
+      animation: {
+        lol: ['width', 'height'],
+      },
+    },
+    plugins: [
+      function ({ addComponents, theme }) {
+        addComponents({
+          '.foo': {
+            animation: theme('animation.lol'),
+          },
+        })
+      },
+    ],
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .foo {
+        animation: width, height;
+      }
+    `)
+  })
+})
diff --git a/tests/layer-at-rules.test.js b/tests/layer-at-rules.test.js
index ac89d178..2cb7c11b 100644
--- a/tests/layer-at-rules.test.js
+++ b/tests/layer-at-rules.test.js
@@ -49,3 +49,134 @@ test('custom user-land utilities', () => {
     `)
   })
 })
+
+test('layers are grouped and inserted at the matching @tailwind rule', () => {
+  let config = {
+    content: [
+      { raw: html`<div class="input btn card float-squirrel align-banana align-sandwich"></div>` },
+    ],
+    plugins: [
+      function ({ addBase, addComponents, addUtilities }) {
+        addBase({ body: { margin: 0 } })
+
+        addComponents({
+          '.input': { background: 'white' },
+        })
+
+        addUtilities({
+          '.float-squirrel': { float: 'squirrel' },
+        })
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @layer vanilla {
+      strong {
+        font-weight: medium;
+      }
+    }
+
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+
+    @layer components {
+      .btn {
+        background: blue;
+      }
+    }
+
+    @layer utilities {
+      .align-banana {
+        text-align: banana;
+      }
+    }
+
+    @layer base {
+      h1 {
+        font-weight: bold;
+      }
+    }
+
+    @layer components {
+      .card {
+        border-radius: 12px;
+      }
+    }
+
+    @layer base {
+      p {
+        font-weight: normal;
+      }
+    }
+
+    @layer utilities {
+      .align-sandwich {
+        text-align: sandwich;
+      }
+    }
+
+    @layer chocolate {
+      a {
+        text-decoration: underline;
+      }
+    }
+  `
+
+  expect.assertions(2)
+
+  return run(input, config).then((result) => {
+    expect(result.warnings().length).toBe(0)
+    expect(result.css).toMatchFormattedCss(css`
+      @layer vanilla {
+        strong {
+          font-weight: medium;
+        }
+      }
+
+      body {
+        margin: 0;
+      }
+
+      h1 {
+        font-weight: bold;
+      }
+
+      p {
+        font-weight: normal;
+      }
+
+      .input {
+        background: white;
+      }
+
+      .btn {
+        background: blue;
+      }
+
+      .card {
+        border-radius: 12px;
+      }
+
+      .float-squirrel {
+        float: squirrel;
+      }
+
+      .align-banana {
+        text-align: banana;
+      }
+
+      .align-sandwich {
+        text-align: sandwich;
+      }
+
+      @layer chocolate {
+        a {
+          text-decoration: underline;
+        }
+      }
+    `)
+  })
+})