Add tailwindcss/nesting plugin (#4673)

* add nesting plugin

* rename @tailwindcss/nesting to tailwindcss/nesting

* ignore the built `nesting` plugin

* add a postcss7 compat version

* include `nesting` plugin when publishing

* add `build-plugins` script

This will allow us to keep the plugins in their dedicated folders +
tests + postcss7 compatibility files. However, when we copy over the
plugins to the root. For example `plugins/nesting/` -> `nesting/` we
skip files like `.test.js` and `.postcss7.js`.

* build plugins when running `prepublishOnly`

* improve compat mode

We will use a glob so that we can move all *.postcss7.* files to just
*.* likewise we will also backup to *.* to *.postcss8.* for restoring
purposes.

Concrete example:

- Current state:
  - index.js            // PostCSS 8 implementation
  - index.postcss7.js   // PostCSS 7 implementation

- Run "compat"
  - index.js            // PostCSS 7 implementation
  - index.postcss7.js   // PostCSS 7 implementation
  - index.postcss8.js   // PostCSS 8 implementation (Backup of original)

- Run "compat:restore"
  - index.js            // PostCSS 8 implementation
  - index.postcss7.js   // PostCSS 7 implementation
  - X index.postcss8.js // PostCSS 8 implementation (Removed)

* Update README.md

* ensure we `npm install` before publishing

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Robin Malfait 2021-06-17 15:43:52 +02:00 • committed by GitHub
parent 243e8814d8
commit f63b4531f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 404 additions and 74 deletions

3
.gitignore vendored
View file

@ -10,5 +10,8 @@ index.html
yarn.lock
yarn-error.log
# "External" plugins
/nesting
# Perf related files
isolate*.log

View file

@ -22,7 +22,7 @@
"babelify": "babel src --out-dir lib --copy-files",
"postbabelify": "ncc build lib/cli-peer-dependencies.js -o peers",
"rebuild-fixtures": "npm run babelify && babel-node scripts/rebuildFixtures.js",
"prepublishOnly": "npm run babelify && babel-node scripts/build.js",
"prepublishOnly": "npm install --force && npm run babelify && babel-node scripts/build.js && node scripts/build-plugins.js",
"style": "eslint .",
"test": "cross-env TAILWIND_MODE=build jest",
"test:integrations": "npm run test --prefix ./integrations",
@ -38,6 +38,7 @@
"peers/*",
"scripts/*.js",
"stubs/*.stub.js",
"nesting/*",
"*.css",
"*.js"
],

42
plugins/nesting/README.md Normal file
View file

@ -0,0 +1,42 @@
# tailwindcss/nesting
This is a PostCSS plugin that wraps [postcss-nested](https://github.com/postcss/postcss-nested) or [postcss-nesting](https://github.com/jonathantneal/postcss-nesting) and acts as a compatibility layer to make sure your nesting plugin of choice properly understands Tailwind's custom syntax like `@apply` and `@screen`.
Add it to your PostCSS configuration, somewhere before Tailwind itself:
```js
// postcss.config.js
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss/nesting'),
require('tailwindcss'),
require('autoprefixer'),
]
}
```
By default, it uses the [postcss-nested](https://github.com/postcss/postcss-nested) plugin under the hood, which uses a Sass-like syntax and is the plugin that powers nesting support in the [Tailwind CSS plugin API](https://tailwindcss.com/docs/plugins#css-in-js-syntax).
If you'd rather use [postcss-nesting](https://github.com/jonathantneal/postcss-nesting) (which is based on the work-in-progress [CSS Nesting](https://drafts.csswg.org/css-nesting-1/) specification), first install the plugin alongside:
```shell
npm install postcss-nesting
```
Then pass the plugin itself as an argument to `tailwindcss/nesting` in your PostCSS configuration:
```js
// postcss.config.js
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss/nesting')(require('postcss-nesting')),
require('tailwindcss'),
require('autoprefixer'),
]
}
```
This can also be helpful if for whatever reason you need to use a very specific version of `postcss-nested` and want to override the version we bundle with `tailwindcss/nesting` itself.

12
plugins/nesting/index.js Normal file
View file

@ -0,0 +1,12 @@
let nesting = require('./plugin')
module.exports = (opts) => {
return {
postcssPlugin: 'tailwindcss/nesting',
Once(root, { result }) {
return nesting(opts)(root, result)
},
}
}
module.exports.postcss = true

View file

@ -0,0 +1,4 @@
let postcss = require('postcss')
let nesting = require('./plugin')
module.exports = postcss.plugin('tailwindcss/nesting', nesting)

View file

@ -0,0 +1,176 @@
let postcss = require('postcss')
let postcssNested = require('postcss-nested')
let plugin = require('.')
it('should be possible to load a custom nesting plugin', async () => {
let input = css`
.foo {
color: black;
@screen md {
color: blue;
}
}
`
expect(
await run(input, function (root) {
root.walkRules((rule) => {
rule.selector += '-modified'
})
})
).toMatchCss(css`
.foo-modified {
color: black;
@media screen(md) {
color: blue;
}
}
`)
})
it('should be possible to load a custom nesting plugin by name (string) instead', async () => {
let input = css`
.foo {
color: black;
@screen md {
color: blue;
}
}
`
expect(await run(input, 'postcss-nested')).toMatchCss(css`
.foo {
color: black;
}
@media screen(md) {
.foo {
color: blue;
}
}
`)
})
it('should default to the bundled postcss-nested plugin (no options)', async () => {
let input = css`
.foo {
color: black;
@screen md {
color: blue;
}
}
`
expect(await run(input)).toMatchCss(css`
.foo {
color: black;
}
@media screen(md) {
.foo {
color: blue;
}
}
`)
})
it('should default to the bundled postcss-nested plugin (empty ooptions)', async () => {
let input = css`
.foo {
color: black;
@screen md {
color: blue;
}
}
`
expect(await run(input, {})).toMatchCss(css`
.foo {
color: black;
}
@media screen(md) {
.foo {
color: blue;
}
}
`)
})
test('@screen rules are replaced with media queries', async () => {
let input = css`
.foo {
color: black;
@screen md {
color: blue;
}
}
`
expect(await run(input, postcssNested)).toMatchCss(css`
.foo {
color: black;
}
@media screen(md) {
.foo {
color: blue;
}
}
`)
})
test('@screen rules can work with `@apply`', async () => {
let input = css`
.foo {
@apply bg-black;
@screen md {
@apply bg-blue-500;
}
}
`
expect(await run(input, postcssNested)).toMatchCss(css`
.foo {
@apply bg-black;
}
@media screen(md) {
.foo {
@apply bg-blue-500;
}
}
`)
})
// ---
function indentRecursive(node, indent = 0) {
node.each &&
node.each((child, i) => {
if (!child.raws.before || child.raws.before.includes('\n')) {
child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${' '.repeat(indent)}`
}
child.raws.after = `\n${' '.repeat(indent)}`
indentRecursive(child, indent + 1)
})
}
function formatNodes(root) {
indentRecursive(root)
if (root.first) {
root.first.raws.before = ''
}
}
async function run(input, options) {
return (
await postcss([options === undefined ? plugin : plugin(options), formatNodes]).process(input, {
from: undefined,
})
).toString()
}
function css(templates) {
return templates.join('')
}

41
plugins/nesting/plugin.js Normal file
View file

@ -0,0 +1,41 @@
let postcss = require('postcss')
let postcssNested = require('postcss-nested')
module.exports = function nesting(opts = postcssNested) {
return (root, result) => {
root.walkAtRules('screen', (rule) => {
rule.name = 'media'
rule.params = `screen(${rule.params})`
})
root.walkAtRules('apply', (rule) => {
rule.before(postcss.decl({ prop: '__apply', value: rule.params }))
rule.remove()
})
let plugin = (() => {
if (typeof opts === 'function') {
return opts
}
if (typeof opts === 'string') {
return require(opts)
}
if (Object.keys(opts).length <= 0) {
return postcssNested
}
throw new Error('tailwindcss/nesting should be loaded with a nesting plugin.')
})()
postcss([plugin]).process(root, result.opts).sync()
root.walkDecls('__apply', (decl) => {
decl.before(postcss.atRule({ name: 'apply', params: decl.value }))
decl.remove()
})
return root
}
}

47
scripts/build-plugins.js Normal file
View file

@ -0,0 +1,47 @@
let fs = require('fs')
let path = require('path')
let plugins = fs.readdirSync(fromRootPath('plugins'))
for (let plugin of plugins) {
// Cleanup
let pluginDest = fromRootPath(plugin)
if (fs.existsSync(pluginDest)) {
fs.rmdirSync(pluginDest, { recursive: true })
}
// Copy plugin over
copyFolder(fromRootPath('plugins', plugin), pluginDest, (file) => {
// Ignore test files
if (file.endsWith('.test.js')) return false
// Ignore postcss7 files
if (file.endsWith('.postcss7.js')) return false
// Ignore postcss8 files
if (file.endsWith('.postcss8.js')) return false
return true
})
}
// ---
function fromRootPath(...paths) {
return path.resolve(process.cwd(), ...paths)
}
function copy(fromPath, toPath) {
fs.mkdirSync(path.dirname(toPath), { recursive: true }) // Ensure folder exists
fs.copyFileSync(fromPath, toPath)
}
function copyFolder(fromPath, toPath, shouldCopy = () => true) {
let stats = fs.statSync(fromPath)
if (stats.isDirectory()) {
let filesAndFolders = fs.readdirSync(fromPath)
for (let file of filesAndFolders) {
copyFolder(path.resolve(fromPath, file), path.resolve(toPath, file), shouldCopy)
}
} else if (shouldCopy(fromPath)) {
copy(fromPath, toPath)
}
}

View file

@ -1,6 +1,80 @@
const fs = require('fs')
const path = require('path')
const merge = require('lodash/merge')
let fs = require('fs')
let path = require('path')
let merge = require('lodash/merge')
let fastGlob = require('fast-glob')
let postcss7 = fastGlob.sync(['./**/*.postcss7.*']).filter((file) => !file.startsWith('lib/'))
let postcss8 = fastGlob.sync(['./**/*.postcss8.*']).filter((file) => !file.startsWith('lib/'))
if (process.argv.includes('--prepare')) {
if (postcss8.length > 0) {
console.error('\n\n[ABORT] Already in PostCSS 7 compatibility mode!\n\n')
process.exit(1)
}
let mainPackageJson = require('../package.json')
let compatPackageJson = require('../package.postcss7.json')
// Use postcss7 files
for (let file of postcss7) {
let bareFile = file.replace('.postcss7', '')
let postcss8File = file.replace('.postcss7', '.postcss8')
// Backup
copy(fromRootPath(bareFile), fromRootPath(postcss8File))
// Swap
copy(fromRootPath(file), fromRootPath(bareFile))
}
// Deep merge package.json contents
let packageJson = merge({}, mainPackageJson, compatPackageJson)
// Remove peerDependencies
delete packageJson.peerDependencies
// Cleanup devDependencies
for (let key in packageJson.devDependencies) {
if (key.includes('postcss')) delete packageJson.devDependencies[key]
}
// Use new name
packageJson.name = '@tailwindcss/postcss7-compat'
// Make sure you can publish
packageJson.publishConfig = { access: 'public' }
// Write package.json with the new contents
fs.writeFileSync(fromRootPath('package.json'), JSON.stringify(packageJson, null, 2), 'utf8')
// Print some useful information to make publishing easy
console.log()
console.log('You can safely publish `tailwindcss` in PostCSS 7 compatibility mode:\n')
console.log()
} else if (process.argv.includes('--restore')) {
if (postcss8.length === 0) {
console.error('\n\n[ABORT] Already in latest PostCSS mode!\n\n')
process.exit(1)
}
// Use postcss8 files
for (let file of postcss8) {
let bareFile = file.replace('.postcss8', '')
// Restore
copy(fromRootPath(file), fromRootPath(bareFile))
// Remove
fs.unlinkSync(fromRootPath(file))
}
// Done
console.log()
console.log('Restored from PostCSS 7 mode to latest PostCSS mode!')
console.log()
}
// ---
function fromRootPath(...paths) {
return path.resolve(process.cwd(), ...paths)
@ -9,73 +83,3 @@ function fromRootPath(...paths) {
function copy(fromPath, toPath) {
fs.copyFileSync(fromPath, toPath)
}
if (process.argv.includes('--prepare')) {
if (
fs.existsSync(fromRootPath('package.postcss8.json')) ||
fs.existsSync(fromRootPath('src', 'index.postcss8.js'))
) {
console.error('\n\n[ABORT] Already in PostCSS 7 compatibility mode!\n\n')
process.exit(1)
}
const mainPackageJson = require('../package.json')
const compatPackageJson = require('../package.postcss7.json')
// 1. Backup original package.json file
copy(fromRootPath('package.json'), fromRootPath('package.postcss8.json'))
// 2. Backup src/index.js file
copy(fromRootPath('src', 'index.js'), fromRootPath('src', 'index.postcss8.js'))
// 3. Use the PostCSS 7 compat file
copy(fromRootPath('src', 'index.postcss7.js'), fromRootPath('src', 'index.js'))
// 4. Deep merge package.json contents
const packageJson = merge({}, mainPackageJson, compatPackageJson)
// 5. Remove peerDependencies
delete packageJson.peerDependencies
// 6. Cleanup devDependencies
for (let key in packageJson.devDependencies) {
if (key.includes('postcss')) delete packageJson.devDependencies[key]
}
// 7. Use new name
packageJson.name = '@tailwindcss/postcss7-compat'
// 8. Make sure you can publish
packageJson.publishConfig = { access: 'public' }
// 9. Write package.json with the new contents
fs.writeFileSync(fromRootPath('package.json'), JSON.stringify(packageJson, null, 2), 'utf8')
// 10. Print some useful information to make publishing easy
console.log()
console.log('You can safely publish `tailwindcss` in PostCSS 7 compatibility mode:\n')
console.log()
} else if (process.argv.includes('--restore')) {
if (
!fs.existsSync(fromRootPath('package.postcss8.json')) ||
!fs.existsSync(fromRootPath('src', 'index.postcss8.js'))
) {
console.error('\n\n[ABORT] Already in latest PostCSS mode!\n\n')
process.exit(1)
}
// 1. Restore original package.json file
copy(fromRootPath('package.postcss8.json'), fromRootPath('package.json'))
// 2. Restore src/index.js file
copy(fromRootPath('src', 'index.postcss8.js'), fromRootPath('src', 'index.js'))
// 3. Cleanup PostCSS 8 related files
fs.unlinkSync(fromRootPath('package.postcss8.json'))
fs.unlinkSync(fromRootPath('src', 'index.postcss8.js'))
// 4. Done
console.log()
console.log('Restored from PostCSS 7 mode to latest PostCSS mode!')
console.log()
}