Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A Webpack plugin to remove media queries matching certain patterns from a generated CSS file.
/**
* This file defines the project-level Webpack production configuration. It
* combines configs from all relevant plugins and themes into one single
* array (a "multi-configuration" Webpack setup) and runs those builds in
* a single pass.
*/
const { basename, extname } = require( 'path' );
const postcss = require( 'postcss' );
const filtermq = require( 'postcss-filter-mq' );
const webpack = require( 'webpack' );
const { RawSource } = webpack.sources || require( 'webpack-sources' );
/**
* Peel apart media query-specific CSS into different sheets.
*/
class ExcludeMediaQueriesPlugin {
static name = 'ExcludeMediaQueriesPlugin';
constructor( options = {} ) {
this.options = options;
}
/**
* Get the value of a passed plugin option, or fall back to a default.
*
* @param {string} option Name of option to retrieve.
* @param {*} fallback Default value to return if no option was provided.
* @returns {*} Value of option, or fallback value.
*/
getOption( option, fallback ) {
if ( this.options[ option ] !== undefined ) {
return this.options[ option ];
}
return fallback;
}
/**
* Match whether to apply the plugin to a specific file.
*
* @param {Function|RegExp|string} fileName A function, RE, or string to use to test file names.
* @returns {boolean} Whether the provided file should be processed by this plugin.
*/
matchFile( fileName ) {
if ( typeof this.options.file === 'function' ) {
return this.options.file( fileName );
}
if ( this.options.file instanceof RegExp ) {
return this.options.file.test( fileName );
}
return this.options.file === fileName;
}
/**
* Convert an input file path to the desired output file path.
*
* @param {string} fileName A (hopefully absolute) file system path.
* @returns {string} The filename to which to output.
*/
getOutputFilePath( fileName ) {
if ( typeof this.options.outputFile === 'function' ) {
return this.options.outputFile( fileName );
}
if ( typeof this.options.outputFile === 'string' ) {
// Assume abs path.
return this.options.outputFile;
}
// Replace.
return fileName;
}
/**
* Given a string of CSS, return an array of numeric-value media query objects.
*
* @param {string} content Rendered CSS stylesheet content.
* @returns {object[]} Array of { query, value, unit } objects.
*/
getMediaQueriesFromStylesheet( content ) {
// This works best when the CSS is minified.
return content
.split( /{|}/ )
.filter( ( str ) => /@media/.test( str ) )
// Match anything of the format `@media (condition: {numeric value}{unit?})`.
// Does not currently support multi-condition media queries.
.map( ( css ) => css.match( /\(\s*(\S+)\s*:\s*(\d+)(\D*?)\s*\)/ ) )
.reduce(
( uniqueQueries, match ) => {
if ( match ) {
const [ , query, value, unit ] = match;
const matchingQuery = uniqueQueries.find( ( mq ) => (
mq.query === query && mq.value === +value && mq.unit === unit
) );
if ( ! matchingQuery ) {
return uniqueQueries.concat( {
query,
unit,
value: +value,
} );
}
}
return uniqueQueries;
},
[]
);
}
/**
* Given a string of CSS and, optionally, a `match` option which will filter
* the list of queries which get matched by the filtermq plugin, and return
* a regular expression we can pass to filtermq.
*
* @param {string} css Full CSS of output file to filter.
* @returns {RegExp} Regular expression for matching `@media` rules.
*/
getMatchingQueryPattern( css ) {
let mqs = this.getMediaQueriesFromStylesheet( css );
if ( typeof this.options.match === 'function' ) {
mqs = mqs.filter( this.options.match );
}
return new RegExp(
mqs.map( ( { query, value, unit } ) => `${ query }\\s*:\\s*${ value }${ unit }` ).join( '|' ),
'i'
);
}
/**
* Instantiate and return the filtermq plugin instance.
*
* @param {string} css Input CSS.
* @returns {postcss.Transformer} FilterMQ PostCSS plugin object (Transformer).
*/
getFilterPlugin( css ) {
return filtermq( {
regex: this.getMatchingQueryPattern( css ),
keepBaseRules: this.getOption( 'keepBaseRules', true ),
invert: this.getOption( 'invert', true ),
} );
}
/**
* Bind plugin logic to the Webpack compilation cycle.
*
* @param {webpack.Compiler} compiler Webpack Compiler to which to apply the plugin.
*/
apply( compiler ) {
compiler.hooks.emit.tapPromise(
ExcludeMediaQueriesPlugin.name,
( compilation ) => {
// Figure out which asset in the compilation we are reading in.
const fileName = Object.keys( compilation.assets )
.find( ( fileName ) => this.matchFile( fileName ) );
const outputFileName = this.getOutputFilePath( fileName );
const css = compilation.assets[ fileName ].source();
return postcss( [ this.getFilterPlugin( css ) ] )
.process( css, {
from: fileName,
to: outputFileName,
} )
.then( ( result ) => {
const source = new RawSource( result.css );
if ( outputFileName === fileName ) {
compilation.updateAsset( outputFileName, source );
} else {
compilation.emitAsset( outputFileName, source, {
name: basename( outputFileName ),
immutable: true,
} );
// Spoof a chunk to make the file get output with the correct
// "name" in any generated manifest.
const name = this.getOption( 'name', basename( outputFileName ).replace( extname( outputFileName ), '' ) );
const newFileChunk = compilation.addChunk( name );
newFileChunk.chunkReason = ExcludeMediaQueriesPlugin.name;
newFileChunk.files = [ outputFileName ];
newFileChunk.id = name;
newFileChunk.ids = [ name ];
}
} );
}
);
}
}
module.exports = ExcludeMediaQueriesPlugin;
// Add an instance of the plugin to your Webpack config's plugin array
// like so:
[
new ExcludeMediaQueriesPlugin( {
name: 'amp',
file: /main.css/,
match: ( { query, value, unit } ) => {
if ( query === 'min-width' && unit === 'px' ) {
return value > 640;
}
return false;
},
outputFile: ( name ) => name.replace( 'main.css', 'mobile-only.css' ),
} ),
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment