Repository: maizzle/framework
Pull request #676 (2022-05-04 19:22:59)

fix: preserve css in head
This PR: 

- fixes an issue with CSS being removed from `<style>` tags
- fixes an issue with attribute selectors being removed from `<style>` even when they were safelisted in

### [BREAKING] Dropped Node 12/13 support

We had to drop Node 12.x and 13.x support in order to introduce functionality for preserving CSS in the `<head>`.

Maizzle will now require at least Node 14.19.2.

This paves the way to the start of the migration to pure ESM.

### Fixed: CSS being removed

This happened because `email-comb` was removing CSS selectors that it considered to be unused. And rightfully so, because this was only the case with CSS email client targeting hacks like this:

```css
div > u + .body .gmail-android-block {
  display: block !important;
}
```

In that example, `div > u + .body` wouldn't actually exist in your HTML, so the entire rule was being removed.

Maizzle now safelists the most popular hacks with `email-comb`:

```js
const safelist = [
  '*body*', // Gmail
  '.outlook*', // Outlook.com
  '.bloop_container', // Airmail
  '.Singleton', // Apple Mail 10
  '.unused', // Notes 8
  '.moz-text-html', // Thunderbird
  '.mail-detail-content', // Comcast, Libero webmail
  '*edo*', // Edison (all)
  '#*', // Freenet uses #msgBody
  '.lang*' // Fenced code blocks
]
```

### Fixed: attribute selectors being removed

Similarly, attribute selectors can be used to target email clients.

For example:

```css
[data-ogsb] .ogsb-bg-blue-500 {
  background-color: #3b82f6
}
```

These were also removed, however a recent update to `email-comb` has fixed the issue.

### New: PostCSS generator

The `postcss` attribute transform now uses a PostCSS-only generator, so that when you do this:

```css
<style postcss>
/* some regular css */
</style>
```

... it only gets processed with the PostCSS plugins that you add in your environment config.

Previously this used the Tailwind CSS generator, just like `<style tailwindcss>` is, and was adding unwanted CSS in some cases.

### New: CSS generators programmatic API

You can now use the PostCSS and Tailwind CSS generators that Maizzle uses internally, simply by requiring them in your app:

**PostCSS**

`postcss.process(css, maizzleConfig).then(css => console.log(css))`

```js
const {postcss} = require('@maizzle/framework')

const maizzleConfig= {} // a Maizzle environment config object

postcss.process('@import ('src/css/example.css')', maizzleConfig).then(css => console.log(css))
// contents of example.css (thanks to postcss-import)
```

The following plugins are enabled in the PostCSS generator by default:

- [postcss-import](https://www.npmjs.com/package/postcss-import)
- [tailwindcss/nesting](https://tailwindcss.com/docs/using-with-preprocessors#nesting)
- [postcss-merge-longhand](https://github.com/cssnano/cssnano/tree/master/packages/postcss-merge-longhand) (when `process.env` is not `local`)

**TailwindCSS**

`tailwindcss.compile(css, html, tailwindConfig, maizzleConfig)`

```js
const {tailwindcss} = require('@maizzle/framework')

tailwindcss.compile('div { @apply hidden; }', '<div>text</div>', tailwindConfig, maizzleConfig).then(css => console.log(css))
// div { display: none }
```

Note: `html` is used as a [content source](https://tailwindcss.com/docs/content-configuration) to scan for selectors to preserve when compiling with Tailwind CSS.


Patch:
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 02b5d85..f4deea9 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -14,7 +14,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12, 14, 16]
+        node-version: [14, 16, 17]
 
     steps:
       - uses: actions/checkout@v2
diff --git a/package-lock.json b/package-lock.json
index d8406a2..edab731 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
         "autoprefixer": "^10.4.0",
         "browser-sync": "^2.26.13",
         "color-shorthand-hex-to-six-digit": "^3.0.2",
-        "email-comb": "^5.0.0",
+        "email-comb": "^5.2.0",
         "front-matter": "^4.0.0",
         "fs-extra": "^10.0.0",
         "glob-promise": "^4.1.0",
@@ -33,6 +33,7 @@
         "posthtml-extra-attributes": "^1.0.0",
         "posthtml-fetch": "^2.2.0",
         "posthtml-markdownit": "^1.3.0",
+        "posthtml-match-helper": "^1.0.3",
         "posthtml-modules": "^0.9.0",
         "posthtml-mso": "^1.0.4",
         "posthtml-postcss-merge-longhand": "^1.0.2",
@@ -3847,9 +3848,9 @@
       }
     },
     "node_modules/email-comb": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-5.1.0.tgz",
-      "integrity": "sha512-CL/+uq3WdT85J1HvQxATmDlMwPNkbmgvPe48SkbRFZUIMMTN8ChF4Bulfm52jeB3sqyAuVkxTRRvDkT148ELYA==",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-5.2.0.tgz",
+      "integrity": "sha512-VARpkVDuTr9j2vlpSGptMMxPfuTrm6tMNHpVgsAjl+cB2CqkVTKrWvGUPzDOw5GpQe6pCNEXFa7NIaqcwcVB3g==",
       "dependencies": {
         "@babel/runtime": "^7.14.0",
         "array-pull-all-with-glob": "^5.1.0",
@@ -18468,9 +18469,9 @@
       "dev": true
     },
     "email-comb": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-5.1.0.tgz",
-      "integrity": "sha512-CL/+uq3WdT85J1HvQxATmDlMwPNkbmgvPe48SkbRFZUIMMTN8ChF4Bulfm52jeB3sqyAuVkxTRRvDkT148ELYA==",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/email-comb/-/email-comb-5.2.0.tgz",
+      "integrity": "sha512-VARpkVDuTr9j2vlpSGptMMxPfuTrm6tMNHpVgsAjl+cB2CqkVTKrWvGUPzDOw5GpQe6pCNEXFa7NIaqcwcVB3g==",
       "requires": {
         "@babel/runtime": "^7.14.0",
         "array-pull-all-with-glob": "^5.1.0",
diff --git a/package.json b/package.json
index b84611c..254256c 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
     "autoprefixer": "^10.4.0",
     "browser-sync": "^2.26.13",
     "color-shorthand-hex-to-six-digit": "^3.0.2",
-    "email-comb": "^5.0.0",
+    "email-comb": "^5.2.0",
     "front-matter": "^4.0.0",
     "fs-extra": "^10.0.0",
     "glob-promise": "^4.1.0",
@@ -57,6 +57,7 @@
     "posthtml-extra-attributes": "^1.0.0",
     "posthtml-fetch": "^2.2.0",
     "posthtml-markdownit": "^1.3.0",
+    "posthtml-match-helper": "^1.0.3",
     "posthtml-modules": "^0.9.0",
     "posthtml-mso": "^1.0.4",
     "posthtml-postcss-merge-longhand": "^1.0.2",
@@ -76,7 +77,7 @@
     "xo": "0.39.1"
   },
   "engines": {
-    "node": ">=12.13.0"
+    "node": ">=14.19.1"
   },
   "ava": {
     "files": [
diff --git a/src/generators/postcss.js b/src/generators/postcss.js
new file mode 100644
index 0000000..ecb1354
--- /dev/null
+++ b/src/generators/postcss.js
@@ -0,0 +1,29 @@
+const path = require('path')
+const {get} = require('lodash')
+const postcss = require('postcss')
+const postcssImport = require('postcss-import')
+const postcssNested = require('tailwindcss/nesting')
+const mergeLonghand = require('postcss-merge-longhand')
+
+module.exports = {
+  process: async (css = '', maizzleConfig = {}, spinner = null) => {
+    const userFilePath = get(maizzleConfig, 'build.tailwind.css', path.join(process.cwd(), 'src/css/tailwind.css'))
+
+    return postcss([
+      postcssImport({path: path.dirname(userFilePath)}),
+      postcssNested(),
+      maizzleConfig.env === 'local' ? () => {} : mergeLonghand(),
+      ...get(maizzleConfig, 'build.postcss.plugins', [])
+    ])
+      .process(css, {from: undefined})
+      .then(result => result.css)
+      .catch(error => {
+        console.error(error)
+        if (spinner) {
+          spinner.stop()
+        }
+
+        throw new Error(`PostCSS processing failed`)
+      })
+  }
+}
diff --git a/src/generators/tailwindcss.js b/src/generators/tailwindcss.js
index b0fedcb..907c396 100644
--- a/src/generators/tailwindcss.js
+++ b/src/generators/tailwindcss.js
@@ -92,7 +92,7 @@ module.exports = {
     if (userFileExists) {
       css = await fs.readFile(path.resolve(userFilePath), 'utf8') + css
     } else {
-      css = '@import "tailwindcss/components"; @import "tailwindcss/utilities";' + css
+      css = `@import "tailwindcss/components"; @import "tailwindcss/utilities"; ${css}`
     }
 
     return postcss([
diff --git a/src/index.js b/src/index.js
index eca1c08..98b3372 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,12 +2,16 @@ const serve = require('./commands/serve')
 const toFile = require('./commands/build')
 const transformers = require('./transformers')
 const toString = require('./functions/render')
+const PostCSS = require('./generators/postcss')
 const toPlaintext = require('./functions/plaintext')
+const TailwindCSS = require('./generators/tailwindcss')
 
 module.exports = {
   serve,
   build: toFile,
   ...transformers,
   render: toString,
-  plaintext: toPlaintext
+  postcss: PostCSS,
+  plaintext: toPlaintext,
+  tailwindcss: TailwindCSS
 }
diff --git a/src/transformers/index.js b/src/transformers/index.js
index 9bb8c1a..964162e 100644
--- a/src/transformers/index.js
+++ b/src/transformers/index.js
@@ -14,6 +14,7 @@ const removeAttributes = require('./removeAttributes')
 const attributeToStyle = require('./attributeToStyle')
 const removeInlineSizes = require('./removeInlineSizes')
 const applyExtraAttributes = require('./extraAttributes')
+const removeInlinedClasses = require('./removeInlinedSelectors')
 const removeInlineBgColor = require('./removeInlineBackgroundColor')
 
 exports.process = async (html, config) => {
@@ -23,6 +24,7 @@ exports.process = async (html, config) => {
   html = await preventWidows(html, config)
   html = await attributeToStyle(html, config)
   html = await inline(html, config)
+  html = await removeInlinedClasses(html, config)
   html = await removeUnusedCSS(html, config)
   html = await removeInlineSizes(html, config)
   html = await removeInlineBgColor(html, config)
@@ -38,20 +40,21 @@ exports.process = async (html, config) => {
   return html
 }
 
-exports.inlineCSS = (html, config) => inline(html, config, true)
 exports.minify = (html, config) => minify(html, config, true)
+exports.inlineCSS = (html, config) => inline(html, config, true)
 exports.markdown = (html, config) => markdown(html, config, true)
 exports.prettify = (html, config) => prettify(html, config, true)
 exports.ensureSixHEX = (html, config) => ensureSixHEX(html, config)
 exports.addURLParams = (html, config) => addURLParams(html, config, true)
-exports.transformContents = (html, config) => transformContents(html, config, true)
 exports.preventWidows = (html, config) => preventWidows(html, config, true)
 exports.replaceStrings = (html, config) => replaceStrings(html, config, true)
 exports.safeClassNames = (html, config) => safeClassNames(html, config, true)
-exports.applyBaseImageUrl = (html, config) => applyBaseImageUrl(html, config, true)
 exports.removeUnusedCSS = (html, config) => removeUnusedCSS(html, config, true)
 exports.removeAttributes = (html, config) => removeAttributes(html, config, true)
+exports.attributeToStyle = (html, config) => attributeToStyle(html, config, true)
 exports.removeInlineSizes = (html, config) => removeInlineSizes(html, config, true)
-exports.applyExtraAttributes = (html, config) => applyExtraAttributes(html, config, true)
+exports.applyBaseImageUrl = (html, config) => applyBaseImageUrl(html, config, true)
+exports.transformContents = (html, config) => transformContents(html, config, true)
 exports.removeInlineBgColor = (html, config) => removeInlineBgColor(html, config, true)
-exports.attributeToStyle = (html, config) => attributeToStyle(html, config, true)
+exports.applyExtraAttributes = (html, config) => applyExtraAttributes(html, config, true)
+exports.removeInlinedClasses = (html, config) => removeInlinedClasses(html, config)
diff --git a/src/transformers/inlineCss.js b/src/transformers/inlineCss.js
index d82e8b9..b52440a 100644
--- a/src/transformers/inlineCss.js
+++ b/src/transformers/inlineCss.js
@@ -9,7 +9,7 @@ module.exports = async (html, config = {}, direct = false) => {
   }
 
   const options = direct ? config : get(config, 'inlineCSS', {})
-  const removeStyleTags = get(options, 'removeStyleTags', true)
+  const removeStyleTags = get(options, 'removeStyleTags', false)
   const css = get(config, 'customCSS', false)
 
   if (get(config, 'inlineCSS') === true || !isEmpty(options)) {
diff --git a/src/transformers/minify.js b/src/transformers/minify.js
index d22a72c..374f6f1 100644
--- a/src/transformers/minify.js
+++ b/src/transformers/minify.js
@@ -1,5 +1,5 @@
-const {get, isEmpty} = require('lodash')
 const {crush} = require('html-crush')
+const {get, isEmpty} = require('lodash')
 
 module.exports = async (html, config = {}, direct = false) => {
   if (get(config, 'minify') === false) {
diff --git a/src/transformers/prettify.js b/src/transformers/prettify.js
index 91792a9..de62906 100644
--- a/src/transformers/prettify.js
+++ b/src/transformers/prettify.js
@@ -1,5 +1,5 @@
-const {get, isEmpty} = require('lodash')
 const pretty = require('pretty')
+const {get, isEmpty} = require('lodash')
 
 module.exports = async (html, config = {}, direct = false) => {
   if (get(config, 'prettify') === false) {
diff --git a/src/transformers/removeInlineBackgroundColor.js b/src/transformers/removeInlineBackgroundColor.js
index be1108f..df38d48 100644
--- a/src/transformers/removeInlineBackgroundColor.js
+++ b/src/transformers/removeInlineBackgroundColor.js
@@ -1,5 +1,5 @@
-const {get, isEmpty} = require('lodash')
 const posthtml = require('posthtml')
+const {get, isEmpty} = require('lodash')
 const parseAttrs = require('posthtml-attrs-parser')
 
 module.exports = async (html, config = {}, direct = false) => {
diff --git a/src/transformers/removeInlinedSelectors.js b/src/transformers/removeInlinedSelectors.js
new file mode 100644
index 0000000..9dcb3ca
--- /dev/null
+++ b/src/transformers/removeInlinedSelectors.js
@@ -0,0 +1,71 @@
+const {get, has, remove} = require('lodash')
+const postcss = require('postcss')
+const posthtml = require('posthtml')
+const parseAttrs = require('posthtml-attrs-parser')
+const matchHelper = require('posthtml-match-helper')
+
+module.exports = async (html, config = {}) => {
+  if (get(config, 'removeInlinedClasses') === false) {
+    return html
+  }
+
+  const posthtmlOptions = get(config, 'build.posthtml.options', {})
+  return posthtml([plugin()]).process(html, posthtmlOptions).then(result => result.html)
+}
+
+const plugin = () => tree => {
+  const process = node => {
+    // For each style tag...
+    if (node.tag === 'style') {
+      const {root} = postcss().process(node.content)
+
+      root.walkRules(rule => {
+        // Skip media queries and such...
+        if (rule.parent.type === 'atrule') {
+          return
+        }
+
+        const {selector} = rule
+        const prop = get(rule.nodes[0], 'prop')
+
+        // If we find the selector in the HTML...
+        tree.match(matchHelper(selector), n => {
+          const parsedAttrs = parseAttrs(n.attrs)
+          const classAttr = get(parsedAttrs, 'class', [])
+          const styleAttr = get(parsedAttrs, 'style', {})
+
+          // If the class is in the style attribute (inlined), remove it
+          if (has(styleAttr, prop)) {
+            // Remove the class attribute
+            // console.log(styleAttr, prop, classAttr)
+            remove(classAttr, s => selector.includes(s))
+
+            // Remove the rule in the <style> tag
+            rule.remove()
+          }
+
+          /**
+           * Remove from <style> selectors that were used to create shorthand declarations
+           * like `margin: 0 0 0 16px` (transformed with mergeLonghand when inlining).
+           */
+          Object.keys(styleAttr).forEach(key => {
+            if (prop.includes(key)) {
+              rule.remove()
+              remove(classAttr, s => selector.includes(s))
+            }
+          })
+
+          n.attrs = parsedAttrs.compose()
+
+          return n
+        })
+      })
+
+      node.content = root.toString()
+    }
+
+    return node
+  }
+
+  return tree.walk(process)
+}
diff --git a/src/transformers/removeUnusedCss.js b/src/transformers/removeUnusedCss.js
index 72d9745..0da3964 100644
--- a/src/transformers/removeUnusedCss.js
+++ b/src/transformers/removeUnusedCss.js
@@ -1,5 +1,5 @@
-const {get, isEmpty} = require('lodash')
 const {comb} = require('email-comb')
+const {get, isEmpty} = require('lodash')
 
 module.exports = async (html, config = {}, direct = false) => {
   if (get(config, 'removeUnusedCSS') === false) {
@@ -8,11 +8,26 @@ module.exports = async (html, config = {}, direct = false) => {
 
   const options = direct ? config : get(config, 'removeUnusedCSS', {})
 
+  const safelist = [
+    '*body*', // Gmail
+    '.outlook*', // Outlook.com
+    '.bloop_container', // Airmail
+    '.Singleton', // Apple Mail 10
+    '.unused', // Notes 8
+    '.moz-text-html', // Thunderbird
+    '.mail-detail-content', // Comcast, Libero webmail
+    '*edo*', // Edison (all)
+    '#*', // Freenet uses #msgBody
+    '.lang*' // Fenced code blocks
+  ]
+
   if (typeof options === 'boolean' && options) {
-    return comb(html).result
+    return comb(html, {whitelist: safelist}).result
   }
 
   if (!isEmpty(options)) {
+    options.whitelist = [...get(options, 'whitelist', []), ...safelist]
+
     return comb(html, options).result
   }
 
diff --git a/src/transformers/transform.js b/src/transformers/transform.js
index 74fbef4..b498481 100644
--- a/src/transformers/transform.js
+++ b/src/transformers/transform.js
@@ -1,22 +1,51 @@
 const posthtml = require('posthtml')
+const {get, omit, has} = require('lodash')
+const PostCSS = require('../generators/postcss')
 const posthtmlContent = require('posthtml-content')
 const Tailwind = require('../generators/tailwindcss')
-const {get, omit} = require('lodash')
+const safeClassNames = require('posthtml-safe-class-names')
 
 module.exports = async (html, config = {}, direct = false) => {
   const replacements = direct ? config : get(config, 'transform', {})
   const posthtmlOptions = get(config, 'build.posthtml.options', {})
 
   /**
-   * Compile CSS in <style postcss> tags with PostCSS and Tailwind CSS
+   * Compile CSS in <style {post|tailwind}css> tags
    */
   const maizzleConfig = omit(config, ['build.tailwind.css', 'css'])
   const tailwindConfig = get(config, 'build.tailwind.config', 'tailwind.config.js')
 
-  const compileCss = css => Tailwind.compile(css, html, tailwindConfig, maizzleConfig)
+  replacements.postcss = css => PostCSS.process(css, maizzleConfig)
+  replacements.tailwindcss = css => Tailwind.compile(css, html, tailwindConfig, maizzleConfig)
 
-  replacements.tailwindcss = css => compileCss(css)
-  replacements.postcss = css => compileCss(css)
+  return posthtml([
+    styleDataEmbed(),
+    posthtmlContent(replacements),
+    safeClassNames()
+  ])
+    .process(html, posthtmlOptions)
+    .then(result => result.html)
+}
+
+/**
+ * Prevent CSS inlining
+ *
+ * Add a `data-embed` attribute to <style> tags that we want to preserve.
+ * Can be used for HTML email client targeting hacks.
+ */
+const styleDataEmbed = () => tree => {
+  const process = node => {
+    if (
+      node.tag === 'style'
+      && node.attrs
+      && (has(node.attrs, 'preserve') || has(node.attrs, 'embed'))) {
+      node.attrs = {...node.attrs, 'data-embed': true}
+      node.attrs.preserve = false
+      node.attrs.embed = false
+    }
+
+    return node
+  }
 
-  return posthtml([posthtmlContent(replacements)]).process(html, posthtmlOptions).then(result => result.html)
+  return tree.walk(process)
 }


Test patch:
diff --git a/test/expected/transformers/atimport-in-style.html b/test/expected/transformers/atimport-in-style.html
new file mode 100644
index 0000000..a448f93
--- /dev/null
+++ b/test/expected/transformers/atimport-in-style.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html>
+  <head>
+    <style>div {
+  margin-top: 1px;
+  margin-right: 2px;
+  margin-bottom: 3px;
+  margin-left: 4px;
+}</style>
+  </head>
+  <body>
+    <div>test</div>
+  </body>
+</html>
diff --git a/test/expected/transformers/preserve-transform-css.html b/test/expected/transformers/preserve-transform-css.html
new file mode 100644
index 0000000..e516b0a
--- /dev/null
+++ b/test/expected/transformers/preserve-transform-css.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html>
+  <head>
+    <style data-embed="">.block {
+        display: block !important;
+} .inline {
+        display: inline !important;
+} .table {
+        display: table !important;
+} .contents {
+        display: contents !important;
+} .transform {
+        transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important;
+} div {
+        text-transform: uppercase;
+} [data-ogsc] .inexistent {
+        color: #ef4444;
+      } div > u + .body .gmail-android-block {
+        display: block !important;
+      } u + #body a {
+        color: inherit;
+      }</style>
+  </head>
+  <body>
+    <div>test</div>
+  </body>
+</html>
diff --git a/test/expected/transformers/transform-postcss.html b/test/expected/transformers/transform-postcss.html
deleted file mode 100644
index dbefa10..0000000
--- a/test/expected/transformers/transform-postcss.html
+++ /dev/null
@@ -1,19 +0,0 @@
-<style>.inline { display: inline !important
-} .table { display: table !important
-} .contents { display: contents !important
-} .hidden { display: none !important
-} .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important
-} div { width: 100%
-} @media (min-width: 640px) { div { max-width: 640px
-    }
-} @media (min-width: 768px) { div { max-width: 768px
-    }
-} @media (min-width: 1024px) { div { max-width: 1024px
-    }
-} @media (min-width: 1280px) { div { max-width: 1280px
-    }
-} @media (min-width: 1536px) { div { max-width: 1536px
-    }
-} div { display: none
-}
-    </style>
diff --git a/test/fixtures/transformers/atimport-in-style.html b/test/fixtures/transformers/atimport-in-style.html
new file mode 100644
index 0000000..6ecb87e
--- /dev/null
+++ b/test/fixtures/transformers/atimport-in-style.html
@@ -0,0 +1,11 @@
+<!doctype html>
+<html>
+  <head>
+    <style postcss>
+      @import "test/stubs/post.css";
+    </style>
+  </head>
+  <body>
+    <div>test</div>
+  </body>
+</html>
diff --git a/test/fixtures/transformers/preserve-transform-css.html b/test/fixtures/transformers/preserve-transform-css.html
new file mode 100644
index 0000000..8c72f95
--- /dev/null
+++ b/test/fixtures/transformers/preserve-transform-css.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html>
+  <head>
+    <style tailwindcss preserve>
+      div {
+        @apply uppercase;
+      }
+
+      [data-ogsc] .inexistent {
+        color: #ef4444;
+      }
+
+      div > u + .body .gmail-android-block {
+        display: block !important;
+      }
+
+      u + #body a {
+        color: inherit;
+      }
+    </style>
+  </head>
+  <body>
+    <div>test</div>
+  </body>
+</html>
diff --git a/test/stubs/post.css b/test/stubs/post.css
new file mode 100644
index 0000000..1cfd42f
--- /dev/null
+++ b/test/stubs/post.css
@@ -0,0 +1,6 @@
+div {
+  margin-top: 1px;
+  margin-right: 2px;
+  margin-bottom: 3px;
+  margin-left: 4px;
+}
diff --git a/test/stubs/tailwind/preserve.html b/test/stubs/tailwind/content-source.html
similarity index 100%
rename from test/stubs/tailwind/preserve.html
rename to test/stubs/tailwind/content-source.html
diff --git a/test/stubs/tailwind/tailwind.css b/test/stubs/tailwind/tailwind.css
new file mode 100644
index 0000000..5a7d347
--- /dev/null
+++ b/test/stubs/tailwind/tailwind.css
@@ -0,0 +1,3 @@
+.example {
+  @apply hidden;
+}
diff --git a/test/test-postcss.js b/test/test-postcss.js
new file mode 100644
index 0000000..ad16673
--- /dev/null
+++ b/test/test-postcss.js
@@ -0,0 +1,8 @@
+const test = require('ava')
+const PostCSS = require('../src/generators/postcss')
+
+test('throws on processing error', async t => {
+  await t.throwsAsync(async () => {
+    await PostCSS.process(null, {})
+  }, {instanceOf: Error, message: 'PostCSS processing failed'})
+})
diff --git a/test/test-tailwind.js b/test/test-tailwindcss.js
similarity index 94%
rename from test/test-tailwind.js
rename to test/test-tailwindcss.js
index ea16a96..d462b62 100644
--- a/test/test-tailwind.js
+++ b/test/test-tailwindcss.js
@@ -9,7 +9,7 @@ test('throws on compile error', async t => {
   }, {instanceOf: Error, message: 'Tailwind CSS compilation failed'})
 })
 
-test('uses Tailwind defaults if no config specified', async t => {
+test('uses defaults if no config specified', async t => {
   const css = await Tailwind.compile(
     '@tailwind utilities;',
     '<p class="xl:z-0"></p>',
@@ -21,7 +21,7 @@ test('uses Tailwind defaults if no config specified', async t => {
   t.true(css.includes('.xl\\:z-0'))
 })
 
-test('uses CSS file provided in environment config', async t => {
+test('uses css file provided in environment config', async t => {
   const config = {
     env: 'production',
     build: {
@@ -89,7 +89,7 @@ test('uses maizzle template path as content source (single file)', async t => {
     {
       build: {
         templates: {
-          source: './test/stubs/tailwind/preserve.html'
+          source: './test/stubs/tailwind/content-source.html'
         }
       }
     }
diff --git a/test/test-tostring.js b/test/test-tostring.js
index 66fa432..4f79ca9 100644
--- a/test/test-tostring.js
+++ b/test/test-tostring.js
@@ -123,3 +123,20 @@ test('prevents overwriting page object', async t => {
 
   t.is(result, `1, undefined, undefined`)
 })
+
+test('preserves css in marked style tags (tailwindcss)', async t => {
+  const html = await renderString(fixture('transformers/preserve-transform-css'), {
+    // So that we don't compile twice
+    tailwind: {
+      compiled: ''
+    }
+  })
+
+  t.is(html, expected('transformers/preserve-transform-css'))
+})
+
+test('@import css files in marked style tags', async t => {
+  const html = await renderString(fixture('transformers/atimport-in-style'))
+
+  t.is(html, expected('transformers/atimport-in-style'))
+})
diff --git a/test/test-transformers.js b/test/test-transformers.js
index bad2a9e..9826b6c 100644
--- a/test/test-transformers.js
+++ b/test/test-transformers.js
@@ -6,7 +6,7 @@ const path = require('path')
 const {readFileSync} = require('fs')
 
 const fixture = file => readFileSync(path.join(__dirname, 'fixtures/transformers', `${file}.html`), 'utf8')
-const expected = file => readFileSync(path.join(__dirname, 'expected/transformers', `${file}.html`), 'utf8')
+const expect = file => readFileSync(path.join(__dirname, 'expected/transformers', `${file}.html`), 'utf8')
 
 test('remove inline sizes', async t => {
   const options = {
@@ -194,7 +194,7 @@ test('extra attributes (disabled)', async t => {
 test('base URL (string)', async t => {
   const html = await Maizzle.applyBaseImageUrl(fixture('base-image-url'), 'https://example.com/')
 
-  t.is(html, expected('base-image-url'))
+  t.is(html, expect('base-image-url'))
 })
 
 test('base URL (object)', async t => {
@@ -205,7 +205,7 @@ test('base URL (object)', async t => {
     inlineCss: true
   })
 
-  t.is(html, expected('base-image-url'))
+  t.is(html, expect('base-image-url'))
 })
 
 test('prettify', async t => {
@@ -288,16 +288,35 @@ test('transform contents (javascript)', async t => {
   t.is(html, '<div>TEST</div>')
 })
 
-test('transform contents (postcss)', async t => {
+test('transform contents (tailwindcss)', async t => {
   const html = await Maizzle.transformContents(
-    `<style postcss>
+    `<style tailwindcss>
       div {
-        @apply hidden container;
+        @apply hidden;
       }
     </style>`
   )
 
-  t.is(html, expected('transform-postcss').trim())
+  const expected = `<style>.inline { display: inline !important
+} .table { display: table !important
+} .contents { display: contents !important
+} .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important
+} div { display: none
+}</style>`
+
+  t.is(html, expected)
+})
+
+test('transform contents (postcss)', async t => {
+  const html = await Maizzle.transformContents(
+    `<style postcss>@import 'test/stubs/post.css';</style>`
+  )
+
+  const expected = `<style>div {
+  margin: 1px 2px 3px 4px;
+}</style>`
+
+  t.is(html, expected)
 })
 
 test('url parameters', async t => {
@@ -351,3 +370,110 @@ test('markdown (disabled)', async t => {
 
   t.is(html, '> a quote')
 })
+
+test('remove inlined selectors', async t => {
+  const html = `<!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        img {
+          border: 0;
+          vertical-align: middle
+        }
+
+        .hover-text-blue:hover {
+          color: #00a8ff;
+        }
+
+        .m-0 {margin: 0}
+
+        .mb-4 {margin-bottom: 16px}
+
+        .mt-0 {margin-top: 0}
+
+        .remove {color: red}
+
+        [data-ogsc] .hidden {display: none}
+
+        #keepId {float:none}
+
+        @media (max-width: 600px) {
+          .ignore {color: blue}
+        }
+      </style>
+      <style>
+        .keep {margin: 0}
+      </style>
+    </head>
+    <body>
+      <div id="keepId" class="remove keep ignore" style="color: red; display: inline">
+        <h1 class="m-0 mb-4 mt-0 hover-text-blue" style="margin: 0 0 16px;">Title</h1>
+        <img src="https://example.com/image.jpg" style="border: 0; vertical-align: middle">
+        <div id="keepId" class="remove keep ignore" style="color: red; display: inline">text</div>
+      </div>
+    </body>
+  </html>`
+
+  const expected = `<!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        .hover-text-blue:hover {
+          color: #00a8ff;
+        }
+
+        [data-ogsc] .hidden {display: none}
+
+        #keepId {float:none}
+
+        @media (max-width: 600px) {
+          .ignore {color: blue}
+        }
+      </style>
+      <style>
+        .keep {margin: 0}
+      </style>
+    </head>
+    <body>
+      <div id="keepId" class="keep ignore" style="color: red; display: inline">
+        <h1 class="hover-text-blue" style="margin: 0 0 16px">Title</h1>
+        <img src="https://example.com/image.jpg" style="border: 0; vertical-align: middle">
+        <div id="keepId" class="keep ignore" style="color: red; display: inline">text</div>
+      </div>
+    </body>
+  </html>`
+
+  const result = await Maizzle.removeInlinedClasses(html)
+
+  t.is(result, expected)
+})
+
+test('remove inlined selectors (disabled)', async t => {
+  const html = `<!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        .remove {color: red}
+      </style>
+    </head>
+    <body>
+      <div class="remove" style="color: red"></div>
+    </body>
+  </html>`
+
+  const expected = `<!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        .remove {color: red}
+      </style>
+    </head>
+    <body>
+      <div class="remove" style="color: red"></div>
+    </body>
+  </html>`
+
+  const result = await Maizzle.removeInlinedClasses(html, {removeInlinedClasses: false})
+
+  t.is(result, expected)
+})