diff --git a/.gitignore b/.gitignore index 8e6db035b669..2bfdf621a35a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ index.html yarn.lock yarn-error.log +# "External" plugins +/nesting + # Perf related files isolate*.log diff --git a/package.json b/package.json index bb2737073261..227bc1161c88 100644 --- a/package.json +++ b/package.json @@ -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" ], diff --git a/plugins/nesting/README.md b/plugins/nesting/README.md new file mode 100644 index 000000000000..e983434cf073 --- /dev/null +++ b/plugins/nesting/README.md @@ -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. + diff --git a/plugins/nesting/index.js b/plugins/nesting/index.js new file mode 100644 index 000000000000..273814a75b2d --- /dev/null +++ b/plugins/nesting/index.js @@ -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 diff --git a/plugins/nesting/index.postcss7.js b/plugins/nesting/index.postcss7.js new file mode 100644 index 000000000000..54f3056890b6 --- /dev/null +++ b/plugins/nesting/index.postcss7.js @@ -0,0 +1,4 @@ +let postcss = require('postcss') +let nesting = require('./plugin') + +module.exports = postcss.plugin('tailwindcss/nesting', nesting) diff --git a/plugins/nesting/index.test.js b/plugins/nesting/index.test.js new file mode 100644 index 000000000000..1dbe38c6bda5 --- /dev/null +++ b/plugins/nesting/index.test.js @@ -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('') +} diff --git a/plugins/nesting/plugin.js b/plugins/nesting/plugin.js new file mode 100644 index 000000000000..6ba557594fc2 --- /dev/null +++ b/plugins/nesting/plugin.js @@ -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 + } +} diff --git a/scripts/build-plugins.js b/scripts/build-plugins.js new file mode 100644 index 000000000000..2ac5e281bfe0 --- /dev/null +++ b/scripts/build-plugins.js @@ -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) + } +} diff --git a/scripts/compat.js b/scripts/compat.js index db7efe34106e..aef701a07fc8 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -1,81 +1,85 @@ -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') -function fromRootPath(...paths) { - return path.resolve(process.cwd(), ...paths) -} - -function copy(fromPath, toPath) { - fs.copyFileSync(fromPath, toPath) -} +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 ( - fs.existsSync(fromRootPath('package.postcss8.json')) || - fs.existsSync(fromRootPath('src', 'index.postcss8.js')) - ) { + if (postcss8.length > 0) { 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') + let mainPackageJson = require('../package.json') + let compatPackageJson = require('../package.postcss7.json') - // 1. Backup original package.json file - copy(fromRootPath('package.json'), fromRootPath('package.postcss8.json')) + // Use postcss7 files + for (let file of postcss7) { + let bareFile = file.replace('.postcss7', '') + let postcss8File = file.replace('.postcss7', '.postcss8') - // 2. Backup src/index.js file - copy(fromRootPath('src', 'index.js'), fromRootPath('src', 'index.postcss8.js')) + // Backup + copy(fromRootPath(bareFile), fromRootPath(postcss8File)) - // 3. Use the PostCSS 7 compat file - copy(fromRootPath('src', 'index.postcss7.js'), fromRootPath('src', 'index.js')) + // Swap + copy(fromRootPath(file), fromRootPath(bareFile)) + } - // 4. Deep merge package.json contents - const packageJson = merge({}, mainPackageJson, compatPackageJson) + // Deep merge package.json contents + let packageJson = merge({}, mainPackageJson, compatPackageJson) - // 5. Remove peerDependencies + // Remove peerDependencies delete packageJson.peerDependencies - // 6. Cleanup devDependencies + // Cleanup devDependencies for (let key in packageJson.devDependencies) { if (key.includes('postcss')) delete packageJson.devDependencies[key] } - // 7. Use new name + // Use new name packageJson.name = '@tailwindcss/postcss7-compat' - // 8. Make sure you can publish + // Make sure you can publish packageJson.publishConfig = { access: 'public' } - // 9. Write package.json with the new contents + // 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 + // 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')) - ) { + if (postcss8.length === 0) { 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')) + // Use postcss8 files + for (let file of postcss8) { + let bareFile = file.replace('.postcss8', '') - // 2. Restore src/index.js file - copy(fromRootPath('src', 'index.postcss8.js'), fromRootPath('src', 'index.js')) + // Restore + copy(fromRootPath(file), fromRootPath(bareFile)) - // 3. Cleanup PostCSS 8 related files - fs.unlinkSync(fromRootPath('package.postcss8.json')) - fs.unlinkSync(fromRootPath('src', 'index.postcss8.js')) + // Remove + fs.unlinkSync(fromRootPath(file)) + } - // 4. Done + // 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) +} + +function copy(fromPath, toPath) { + fs.copyFileSync(fromPath, toPath) +}