Redesign PostCSS Configuration Loading (#9704)

* Redesign PostCSS Configuration Loading

* Test array configuration

* Test new configuration handling

* Remove unnecessary async
This commit is contained in:
Joe Haddad 2019-12-11 11:51:10 -05:00 committed by GitHub
parent a6dc0e1603
commit 179e627a36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 651 additions and 67 deletions

View file

@ -75,6 +75,8 @@
"node-notifier": "5.4.0",
"node-sass": "4.12.0",
"npm-run-all": "4.1.5",
"pixrem": "5.0.0",
"postcss-pseudoelements": "5.0.0",
"postcss-short-size": "4.0.0",
"postcss-trolling": "0.1.7",
"pre-commit": "1.2.2",

View file

@ -2,36 +2,79 @@ import chalk from 'chalk'
import { findConfig } from '../../../../../lib/find-config'
import { resolveRequest } from '../../../../../lib/resolve-request'
export async function getPostCssPlugins(dir: string): Promise<unknown[]> {
function load(plugins: { [key: string]: object | false }): unknown[] {
return Object.keys(plugins)
.map(pkg => {
const options = plugins[pkg]
if (options === false) {
return false
}
type CssPluginCollection_Array = (string | [string, boolean | object])[]
const pluginPath = resolveRequest(pkg, `${dir}/`)
type CssPluginCollection_Object = { [key: string]: object | boolean }
if (options == null || Object.keys(options).length === 0) {
return require(pluginPath)
}
return require(pluginPath)(options)
})
.filter(Boolean)
type CssPluginCollection =
| CssPluginCollection_Array
| CssPluginCollection_Object
type CssPluginShape = [string, object | boolean]
const genericErrorText = 'Malformed PostCSS Configuration'
function getError_NullConfig(pluginName: string) {
return `${chalk.red.bold(
'Error'
)}: Your PostCSS configuration for '${pluginName}' cannot have ${chalk.bold(
'null'
)} configuration.\nTo disable '${pluginName}', pass ${chalk.bold(
'false'
)}, otherwise, pass ${chalk.bold('true')} or a configuration object.`
}
function isIgnoredPlugin(pluginPath: string): boolean {
const ignoredRegex = /(?:^|[\\/])(postcss-modules-values|postcss-modules-scope|postcss-modules-extract-imports|postcss-modules-local-by-default|postcss-modules)(?:[\\/]|$)/i
const match = ignoredRegex.exec(pluginPath)
if (match == null) {
return false
}
const config = await findConfig<{ plugins: { [key: string]: object } }>(
dir,
'postcss'
const plugin = match.pop()!
console.warn(
`${chalk.yellow.bold('Warning')}: Please remove the ${chalk.underline(
plugin
)} plugin from your PostCSS configuration. ` +
`This plugin is automatically configured by Next.js.`
)
return true
}
let target: unknown[]
async function loadPlugin(
dir: string,
pluginName: string,
options: boolean | object
): Promise<import('postcss').AcceptedPlugin | false> {
if (options === false || isIgnoredPlugin(pluginName)) {
return false
}
if (!config) {
target = load({
[require.resolve('postcss-flexbugs-fixes')]: {},
[require.resolve('postcss-preset-env')]: {
if (options == null) {
console.error(getError_NullConfig(pluginName))
throw new Error(genericErrorText)
}
const pluginPath = resolveRequest(pluginName, `${dir}/`)
if (isIgnoredPlugin(pluginPath)) {
return false
} else if (options === true) {
return require(pluginPath)
} else {
const keys = Object.keys(options)
if (keys.length === 0) {
return require(pluginPath)
}
return require(pluginPath)(options)
}
}
function getDefaultPlugins(): CssPluginCollection {
return [
require.resolve('postcss-flexbugs-fixes'),
[
require.resolve('postcss-preset-env'),
{
autoprefixer: {
// Disable legacy flexbox support
flexbox: 'no-2009',
@ -40,49 +83,109 @@ export async function getPostCssPlugins(dir: string): Promise<unknown[]> {
// web platform, i.e. in 2+ browsers unflagged.
stage: 3,
},
})
} else {
const plugins = config.plugins
if (plugins == null || typeof plugins !== 'object') {
throw new Error(
`Your custom PostCSS configuration must export a \`plugins\` key.`
)
}
],
]
}
const invalidKey = Object.keys(config).find(key => key !== 'plugins')
if (invalidKey) {
console.warn(
`${chalk.yellow.bold(
'Warning'
)}: Your PostCSS configuration defines a field which is not supported (\`${invalidKey}\`). ` +
`Please remove this configuration value.`
)
}
export async function getPostCssPlugins(
dir: string
): Promise<import('postcss').AcceptedPlugin[]> {
let config = await findConfig<{ plugins: CssPluginCollection }>(
dir,
'postcss'
)
// These plugins cannot be enabled by the user because they'll conflict with
// `css-loader`'s behavior to make us compatible with webpack.
;[
'postcss-modules-values',
'postcss-modules-scope',
'postcss-modules-extract-imports',
'postcss-modules-local-by-default',
'postcss-modules',
].forEach(plugin => {
if (!plugins.hasOwnProperty(plugin)) {
return
}
console.warn(
`${chalk.yellow.bold('Warning')}: Please remove the ${chalk.underline(
plugin
)} plugin from your PostCSS configuration. ` +
`This plugin is automatically configured by Next.js.`
)
delete plugins[plugin]
})
target = load(plugins as { [key: string]: object })
if (config == null) {
config = { plugins: getDefaultPlugins() }
}
return target
// Warn user about configuration keys which are not respected
const invalidKey = Object.keys(config).find(key => key !== 'plugins')
if (invalidKey) {
console.warn(
`${chalk.yellow.bold(
'Warning'
)}: Your PostCSS configuration defines a field which is not supported (\`${invalidKey}\`). ` +
`Please remove this configuration value.`
)
}
// Enforce the user provided plugins if the configuration file is present
let plugins = config.plugins
if (plugins == null || typeof plugins !== 'object') {
throw new Error(
`Your custom PostCSS configuration must export a \`plugins\` key.`
)
}
if (!Array.isArray(plugins)) {
// Capture variable so TypeScript is happy
const pc = plugins
plugins = Object.keys(plugins).reduce((acc, curr) => {
const p = pc[curr]
if (typeof p === 'undefined') {
console.error(getError_NullConfig(curr))
throw new Error(genericErrorText)
}
acc.push([curr, p])
return acc
}, [] as CssPluginCollection_Array)
}
const parsed: CssPluginShape[] = []
plugins.forEach(plugin => {
if (plugin == null) {
console.warn(
`${chalk.yellow.bold('Warning')}: A ${chalk.bold(
'null'
)} PostCSS plugin was provided. This entry will be ignored.`
)
} else if (typeof plugin === 'string') {
parsed.push([plugin, true])
} else if (Array.isArray(plugin)) {
const pluginName = plugin[0]
const pluginConfig = plugin[1]
if (
typeof pluginName === 'string' &&
(typeof pluginConfig === 'boolean' || typeof pluginConfig === 'object')
) {
parsed.push([pluginName, pluginConfig])
} else {
if (typeof pluginName !== 'string') {
console.error(
`${chalk.red.bold(
'Error'
)}: A PostCSS Plugin must be provided as a ${chalk.bold(
'string'
)}. Instead, we got: '${pluginName}'.`
)
} else {
console.error(
`${chalk.red.bold(
'Error'
)}: A PostCSS Plugin was passed as an array but did not provide its configuration ('${pluginName}').`
)
}
throw new Error(genericErrorText)
}
} else {
console.error(
`${chalk.red.bold(
'Error'
)}: An unknown PostCSS plugin was provided (${plugin}).`
)
throw new Error(genericErrorText)
}
})
const resolved = await Promise.all(
parsed.map(p => loadPlugin(dir, p[0], p[1]))
)
const filtered: import('postcss').AcceptedPlugin[] = resolved.filter(
Boolean
) as import('postcss').AcceptedPlugin[]
return filtered
}

View file

@ -0,0 +1,3 @@
{
"plugins": [["postcss-trolling"]]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,3 @@
{
"plugins": [["postcss-trolling", null]]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,3 @@
{
"plugins": [[5, null]]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,3 @@
{
"plugins": [5]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,3 @@
{
"plugins": null
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,3 @@
{
"plugins": [["postcss-trolling", 5]]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -0,0 +1,16 @@
{
// Use comments to test JSON5 support
"plugins": [
"pixrem",
["postcss-pseudoelements", true],
// Test a non-standard feature that wouldn't be normally enabled
[
"postcss-short-size",
{
// Add a prefix to test that configuration is passed
"prefix": "xyz"
}
],
["postcss-trolling", false]
]
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import App from 'next/app'
import '../styles/global.css'
class MyApp extends App {
render() {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
}
}
export default MyApp

View file

@ -0,0 +1,3 @@
export default function Home() {
return <div />
}

View file

@ -0,0 +1,14 @@
/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}

View file

@ -283,6 +283,73 @@ describe('CSS Support', () => {
})
})
describe('CSS Customization Array', () => {
const appDir = join(fixturesDir, 'custom-configuration-arr')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should build successfully', async () => {
await nextBuild(appDir)
})
it(`should've compiled and prefixed`, async () => {
const cssFolder = join(appDir, '.next/static/css')
const files = await readdir(cssFolder)
const cssFiles = files.filter(f => /\.css$/.test(f))
expect(cssFiles.length).toBe(1)
const cssContent = await readFile(join(cssFolder, cssFiles[0]), 'utf8')
expect(
cssContent.replace(/\/\*.*?\*\//g, '').trim()
).toMatchInlineSnapshot(
`"@media (480px <= width < 768px){a:before{content:\\"\\"}::placeholder{color:green}}.video{max-width:6400px;max-height:4800px;max-width:400rem;max-height:300rem}"`
)
// Contains a source map
expect(cssContent).toMatch(/\/\*#\s*sourceMappingURL=(.+\.map)\s*\*\//)
})
it(`should've emitted a source map`, async () => {
const cssFolder = join(appDir, '.next/static/css')
const files = await readdir(cssFolder)
const cssMapFiles = files.filter(f => /\.css\.map$/.test(f))
expect(cssMapFiles.length).toBe(1)
const cssMapContent = (
await readFile(join(cssFolder, cssMapFiles[0]), 'utf8')
).trim()
const { version, mappings, sourcesContent } = JSON.parse(cssMapContent)
expect({ version, mappings, sourcesContent }).toMatchInlineSnapshot(`
Object {
"mappings": "AACA,gCACE,SACE,UACF,CACA,cACE,WACF,CACF,CAGA,OACE,gBAA4B,CAA5B,iBAA4B,CAA5B,gBAA4B,CAA5B,iBACF",
"sourcesContent": Array [
"/* this should pass through untransformed */
@media (480px <= width < 768px) {
a::before {
content: '';
}
::placeholder {
color: green;
}
}
/* this should be transformed to width/height */
.video {
-xyz-max-size: 400rem 300rem;
}
",
],
"version": 3,
}
`)
})
})
describe('Bad CSS Customization', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration')
@ -333,6 +400,126 @@ describe('CSS Support', () => {
})
})
describe('Bad CSS Customization Array (1)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-1')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/A PostCSS Plugin was passed as an array but did not provide its configuration \('postcss-trolling'\)/
)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (2)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-2')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/Error: Your PostCSS configuration for 'postcss-trolling' cannot have null configuration./
)
expect(stderr).toMatch(
/To disable 'postcss-trolling', pass false, otherwise, pass true or a configuration object./
)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (3)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-3')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/A PostCSS Plugin must be provided as a string. Instead, we got: '5'/
)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (4)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-4')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(/An unknown PostCSS plugin was provided \(5\)/)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (5)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-5')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/Your custom PostCSS configuration must export a `plugins` key./
)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (6)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-6')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/Your custom PostCSS configuration must export a `plugins` key./
)
expect(stderr).toMatch(/Build error occurred/)
})
})
describe('Bad CSS Customization Array (7)', () => {
const appDir = join(fixturesDir, 'bad-custom-configuration-arr-7')
beforeAll(async () => {
await remove(join(appDir, '.next'))
})
it('should fail the build', async () => {
const { stderr } = await nextBuild(appDir, [], { stderr: true })
expect(stderr).toMatch(
/A PostCSS Plugin was passed as an array but did not provide its configuration \('postcss-trolling'\)/
)
expect(stderr).toMatch(/Build error occurred/)
})
})
// Tests css ordering
describe('Multi Global Support (reversed)', () => {
const appDir = join(fixturesDir, 'multi-global-reversed')

View file

@ -4164,7 +4164,7 @@ browserify-zlib@^0.2.0:
dependencies:
pako "~1.0.5"
browserslist@^4.0.0, browserslist@^4.6.0, browserslist@^4.6.3, browserslist@^4.6.4, browserslist@^4.7.1, browserslist@^4.7.3:
browserslist@^4.0.0, browserslist@^4.3.6, browserslist@^4.6.0, browserslist@^4.6.3, browserslist@^4.6.4, browserslist@^4.7.1, browserslist@^4.7.3:
version "4.8.2"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.8.2.tgz#b45720ad5fbc8713b7253c20766f701c9a694289"
integrity sha512-+M4oeaTplPm/f1pXDw84YohEv7B1i/2Aisei8s4s6k3QsoSHa7i5sz8u/cGQkkatCPxMASKxPualR4wwYgVboA==
@ -11792,6 +11792,15 @@ pirates@^4.0.1:
dependencies:
node-modules-regexp "^1.0.0"
pixrem@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pixrem/-/pixrem-5.0.0.tgz#460c534fbc19e4e9fbf39012ae26c7107cd40bca"
integrity sha512-ugJ4Imy92u55zeznaN/5d7iqOBIZjZ7q10/T+dcd0IuFtbLlsGDvAUabFu1cafER+G9f0T1WtTqvzm4KAdcDgQ==
dependencies:
browserslist "^4.3.6"
postcss "^7.0.7"
reduce-css-calc "^2.1.5"
pkg-dir@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
@ -12415,6 +12424,13 @@ postcss-pseudo-class-any-link@^6.0.0:
postcss "^7.0.2"
postcss-selector-parser "^5.0.0-rc.3"
postcss-pseudoelements@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/postcss-pseudoelements/-/postcss-pseudoelements-5.0.0.tgz#eef194e8d524645ca520a949e95e518e812402cb"
integrity sha1-7vGU6NUkZFylIKlJ6V5RjoEkAss=
dependencies:
postcss "^6.0.0"
postcss-reduce-initial@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df"
@ -12548,7 +12564,7 @@ postcss@^5.0.10:
source-map "^0.5.6"
supports-color "^3.2.3"
postcss@^6.0.1, postcss@^6.0.23, postcss@^6.0.9:
postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.23, postcss@^6.0.9:
version "6.0.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==
@ -12584,6 +12600,15 @@ postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0.18, postcss@^7.0
source-map "^0.6.1"
supports-color "^6.1.0"
postcss@^7.0.7:
version "7.0.24"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.24.tgz#972c3c5be431b32e40caefe6c81b5a19117704c2"
integrity sha512-Xl0XvdNWg+CblAXzNvbSOUvgJXwSjmbAKORqyw9V2AlHrm1js2gFw9y3jibBAhpKZi8b5JzJCVh/FyzPsTtgTA==
dependencies:
chalk "^2.4.2"
source-map "^0.6.1"
supports-color "^6.1.0"
pre-commit@1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/pre-commit/-/pre-commit-1.2.2.tgz#dbcee0ee9de7235e57f79c56d7ce94641a69eec6"
@ -13155,7 +13180,7 @@ redent@^2.0.0:
indent-string "^3.0.0"
strip-indent "^2.0.0"
reduce-css-calc@^2.1.6:
reduce-css-calc@^2.1.5, reduce-css-calc@^2.1.6:
version "2.1.7"
resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2"
integrity sha512-fDnlZ+AybAS3C7Q9xDq5y8A2z+lT63zLbynew/lur/IR24OQF5x98tfNwf79mzEdfywZ0a2wpM860FhFfMxZlA==