Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save froger-me/2d5b3c9190e731b35c633156cd470f9a to your computer and use it in GitHub Desktop.
Save froger-me/2d5b3c9190e731b35c633156cd470f9a to your computer and use it in GitHub Desktop.

Advanced Wordpress Webpack Configuration

Installation

Sample package.json file

Placed in the root of the project folder, this file is used to install the required packages and run the build process.

{
  "name": "webpack-project",
  "version": "1.0.0",
  "description": "A project using Webpack to bundle JS and SCSS files in WordPress.",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start",
    "server": "npx serve ."
  },
  "keywords": [ "webpack", "wordpress", "js", "scss" ],
  "author": "Author Name",
  "license": "MIT",
}

Output customization

Custom output folders:

"scripts": {
    "build": "CSS_PATH=styles JS_PATH=scripts wp-scripts build",
    "start": "CSS_PATH=styles JS_PATH=scripts wp-scripts start",
    "server": "npx serve ."
}

All-in-one output folder:

"scripts": {
    "build": "CSS_PATH=assets JS_PATH=assets wp-scripts build",
    "start": "CSS_PATH=assets JS_PATH=assets wp-scripts start",
    "server": "npx serve ."
}

Minimal generation, (but potentially huge files if including packages from WordPress):

"scripts": {
    "build": "INC_RTL=false INC_MIN=false INC_ASSET_PHP=false wp-scripts build",
    "start": "INC_RTL=false INC_MIN=false INC_ASSET_PHP=false wp-scripts start",
    "server": "npx serve ."
}

Available environment variables:

Variable Default value Description
CSS_PATH css Output folder for CSS files.
JS_PATH js Output folder for JS files.
INC_RTL true Include RTL CSS files.
INC_MIN true Include extra *.min.[js|css] minified CSS and JS files
if false, generated *.css and *.js files will be minified.
INC_ASSET_PHP true Include asset PHP files.

Modules

Command to install the required modules:

npm install --save-dev \
  @wordpress/scripts \
  clean-webpack-plugin \
  css-minimizer-webpack-plugin \
  glob \
  prettier \
  rtlcss-webpack-plugin \
  webpack-cli

Folder structure

Before running npm run [build|start]:

my-webpack-project/
├── src/
│   ├── first/
│   │   ├── admin/
│   │   │   ├── index.js
│   │   │   └── styles.scss
│   │   ├── index.js
│   │   └── styles.
│   ├── second/
│   │   ├── admin/
│   │   │   ├── index.js
│   │   │   └── styles.scss
│   │   ├── index.js
│   │   └── styles.scss
│   └── third/
│       ├── index-with-only-styles-import.js
│       ├── styles.scss
│       └── extra-styles.scss
├── package.json
└── webpack.config.js

Assuming package.json uses the default output folders js and css.
Assuming INC_RTL is set to false.

Expected result after running npm run build:

my-webpack-project/
├── src/
│   ├── first/
│   │   ├── admin/
│   │   │   ├── index.js
│   │   │   └── styles.scss
│   │   ├── index.js
│   │   └── styles.
│   ├── second/
│   │   ├── admin/
│   │   │   ├── index.js
│   │   │   └── styles.scss
│   │   ├── index.js
│   │   └── styles.scss
│   └── third/
│       ├── index-with-only-styles-import.js
│       ├── styles.scss
│       └── extra-styles.scss
├── js/
│   ├── admin/
│   │   ├── first.js
│   │   ├── first.min.js
│   │   ├── first.asset.php
│   │   ├── second.js
│   │   ├── second.min.js
│   │   └── second.asset.php
│   ├── first.js
│   ├── first.min.js
│   ├── first.asset.php
│   ├── second.js
│   ├── second.min.js
│   └── second.asset.php
├── css/
│   ├── admin/
│   │   ├── first.css
│   │   ├── first.min.css
│   │   ├── second.css
│   │   └── second.min.css
│   ├── first.css
│   ├── first.min.css
│   ├── second.css
│   ├── second.min.css
│   ├── third.css
│   └── third.min.css
├── package.json
└── webpack.config.js

Webpack configuration file

/**
 * WordPress Webpack Configuration
 *
 * Configuration file for Webpack (a tool that bundles code) that processes
 * JavaScript and CSS files for a WordPress plugin or theme.
 *
 * Main features:
 * - Automatically detects JavaScript files in the "src" folder
 * - Processes them (converts modern JavaScript to browser-compatible code)
 * - Creates both regular and minified versions (smaller files for production use)
 * - Handles CSS processing, including RTL (right-to-left language) versions
 * - Organizes output into a clean, customizable folder structure
 */

// ----- REQUIRED PACKAGES -----
// External tools and libraries needed for this configuration

// Core Node.js modules (built-in with Node.js)
const path = require('path');         // For working with file paths across different operating systems
const fs = require('fs');             // For reading and writing files on the system
const { execSync } = require('child_process'); // For running terminal commands from JavaScript

// External packages (must be installed via npm)
const defaultConfig = require('@wordpress/scripts/config/webpack.config'); // WordPress's default webpack settings
const { CleanWebpackPlugin } = require('clean-webpack-plugin');     // Deletes old files before building new ones
const RtlCssPlugin = require('rtlcss-webpack-plugin');             // Creates right-to-left versions of CSS files for languages like Arabic
const MiniCSSExtractPlugin = require('mini-css-extract-plugin');   // Extracts CSS into separate files
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');// Minifies CSS files by removing unnecessary characters
const TerserPlugin = require('terser-webpack-plugin');             // Minifies JavaScript files
const glob = require('glob');                                      // Finds files using pattern matching

/**
 * Safety Check: Validate File Paths
 *
 * Ensures paths are safe and valid, preventing security issues
 * like directory traversal attacks.
 *
 * @param {string} inputPath - The path to check for safety/validity
 * @param {string} defaultPath - Fallback path to use if the input is invalid
 * @return {string} - A cleaned-up, safe path without leading/trailing slashes
 */
const validatePath = (inputPath, defaultPath) => {
  // Define a regex for removing leading/trailing slashes
  const stripSlashesRegex = /^\/+|\/+$/g;
  
  // Check for potentially unsafe paths:
  // - Empty paths
  // - Root paths (/)
  // - Paths with parent directory references (..)
  // - Paths starting with .
  if (!inputPath ||
      inputPath === '/' ||
      inputPath.includes('..') ||
      inputPath.startsWith('.')) {
    console.warn(`Invalid path provided: "${inputPath}". Using default: "${defaultPath}"`);
    return defaultPath.replace(stripSlashesRegex, '');
  }

  // Clean up the path by removing any leading/trailing slashes
  return inputPath.replace(stripSlashesRegex, '');
};

// ----- CONFIGURATION OPTIONS -----
// Settings that control output locations and features

// Set paths for CSS and JavaScript output folders
// Can be overridden using environment variables (CSS_PATH, JS_PATH)
const rawCssPath = process.env.CSS_PATH || 'css';
const rawJsPath = process.env.JS_PATH || 'js';

// Make sure paths are safe and valid
const cssPath = validatePath(rawCssPath, 'css');
const jsPath = validatePath(rawJsPath, 'js');

// Feature flags - control what types of files to generate
// Set these to 'false' in environment variables to disable features
const includeRtl = process.env.INC_RTL !== 'false';        // Create right-to-left CSS versions?
const includeMin = process.env.INC_MIN !== 'false';        // Create minified (smaller) versions?
const includeAssetPhp = process.env.INC_ASSET_PHP !== 'false'; // Create PHP asset files for WordPress?

/**
 * Find JavaScript Entry Points
 *
 * An entry point is where Webpack starts looking for files to bundle.
 * Each entry point becomes a separate output file.
 *
 * This function scans for folders containing index.js files and adds them as entry points.
 * For example:
 * - src/index.js becomes "main.js"
 * - src/admin/index.js becomes "admin.js"
 * - src/admin/dashboard/index.js becomes "admin/dashboard.js"
 *
 * @param {string} dir - The directory to search in
 * @param {string} baseDir - Used for tracking nested directories
 * @param {Object} entries - Accumulator that collects entry points
 * @return {Object} - Object mapping entry names to file paths
 */
const findEntryPoints = (dir, baseDir = '', entries = {}) => {
  // Get a list of all files and folders in this directory
  const items = fs.readdirSync(dir);

  // If an index.js file exists, add it as an entry point
  if (items.includes('index.js')) {
    const relativePath = baseDir ? baseDir : path.basename(dir);
    entries[relativePath] = path.join(dir, 'index.js');
  }

  // Recursively look inside any subfolders for more entry points
  items.forEach(item => {
    const itemPath = path.join(dir, item);
    if (fs.statSync(itemPath).isDirectory()) {
      const nestedBaseDir = baseDir ? path.join(baseDir, item) : item;
      findEntryPoints(itemPath, nestedBaseDir, entries);
    }
  });

  return entries;
};

// Set the source directory where JavaScript files live
const srcFolder = path.resolve(__dirname, "src");

// Scan the source folder to find all JavaScript entry points
const entryPoints = findEntryPoints(srcFolder);

// ----- PREPARE ENTRY POINTS -----
// Create separate configurations for regular and minified versions

const normalEntries = {};   // For regular versions (e.g., main.js)
const minifiedEntries = {}; // For minified versions (e.g., main.min.js)

// Process each entry point for webpack
Object.keys(entryPoints).forEach(key => {
  // Add the regular version
  normalEntries[key] = entryPoints[key];

  // Add the minified version (if enabled)
  if (includeMin) {
    minifiedEntries[`${key}.min`] = entryPoints[key];
  }
});

/**
 * Helper function to handle nested paths in output filenames
 * 
 * @param {string} baseName - The base name of the entry point
 * @param {string} outputPath - The output path (js or css)
 * @param {boolean} isMin - Whether this is a minified version
 * @return {string} - The formatted output path
 */
const formatOutputPath = (baseName, outputPath, isMin) => {
  // Handle nested paths (like "admin/dashboard")
  if (baseName.includes('/')) {
    // For paths like 'admin/dashboard', output to 'outputPath/dashboard/admin.ext'
    const [parentFolder, subfolderName] = baseName.split('/');
    return `${outputPath}/${subfolderName}/${parentFolder}${isMin ? '.min' : ''}`;
  }

  // For top-level entries (like "main"), output to 'outputPath/main.ext'
  return `${outputPath}/${baseName}${isMin ? '.min' : ''}`;
};

/**
 * Create JavaScript Output Filenames
 *
 * Determines where each JavaScript file should be saved and its name.
 * Handles both regular and minified versions.
 *
 * For example:
 * - An entry point named "main" becomes "js/main.js"
 * - An entry point named "admin/dashboard" becomes "js/dashboard/admin.js"
 * - Minified versions get ".min" added to their names
 *
 * @param {Object} pathData - Information about the file being processed
 * @return {string} - The output filename with its path
 */
const getFilename = (pathData) => {
  const entryName = pathData.chunk.name;
  const isMin = entryName.endsWith('.min');  // Is this a minified version?
  const baseName = isMin ? entryName.replace('.min', '') : entryName;

  return `${formatOutputPath(baseName, jsPath, isMin)}.js`;
};

/**
 * Create CSS Output Filenames
 *
 * Similar to getFilename, but for CSS files.
 * Determines where CSS files should be saved.
 *
 * @param {Object} pathData - Information about the file being processed
 * @param {boolean} isMin - Whether this should be a minified version
 * @return {string} - The output CSS filename with its path
 */
const getCssFilename = (pathData, isMin) => {
  const entryName = pathData.chunk.name;
  // Remove .min from the entry name if it's already there
  const baseName = entryName.endsWith('.min') ? entryName.replace('.min', '') : entryName;

  return `${formatOutputPath(baseName, cssPath, isMin)}.css`;
};

/**
 * CSS Beautifier Plugin
 *
 * This custom plugin makes regular CSS files look nice and readable.
 * It runs after webpack finishes and formats CSS files using Prettier.
 */
class CssBeautifierPlugin {
  /**
   * Set up the plugin with options
   *
   * @param {Object} options - Configuration options
   */
  constructor(options = {}) {
    this.options = {
      pattern: `${cssPath}/**/*.css`,  // Which files to format (all CSS files)
      exclude: /\.min\.css$/,          // Don't format minified CSS files
      ...options
    };
  }

  /**
   * Apply the plugin to webpack
   *
   * @param {Object} compiler - The webpack compiler instance
   */
  apply(compiler) {
    // Register this plugin to run after webpack generates all the files
    compiler.hooks.afterEmit.tapAsync('CssBeautifierPlugin', (compilation, callback) => {
      try {
        // Find all regular CSS files (excluding minified ones)
        const cssFiles = glob.sync(this.options.pattern, { cwd: compiler.outputPath })
          .filter(file => !this.options.exclude.test(file));

        // If no files to beautify, exit early
        if (cssFiles.length === 0) {
          console.log('No CSS files to beautify');
          callback();
          return;
        }

        console.log(`Beautifying ${cssFiles.length} CSS files...`);

        // Use the "prettier" tool to format all CSS files
        const filePaths = cssFiles.map(file => path.join(compiler.outputPath, file)).join(' ');
        execSync(`npx prettier --write ${filePaths}`, { stdio: 'inherit' });

        console.log('CSS beautification completed');
        callback();
      } catch (error) {
        console.error('CSS beautification error:', error);
        callback();
      }
    });
  }
}

/**
 * Empty File Cleanup Plugin
 *
 * Sometimes webpack generates empty JavaScript files when there's no actual JavaScript code.
 * This plugin removes those empty files and their associated files to keep things clean.
 */
class EmptyMinifiedFileCleanupPlugin {
  /**
   * Set up the plugin with options
   *
   * @param {Object} options - Configuration options
   */
  constructor(options = {}) {
    this.options = {
      jsPattern: `${jsPath}/**/*.js`,  // Look for all JavaScript files
      ...options
    };
  }

  /**
   * Apply the plugin to webpack
   *
   * @param {Object} compiler - The webpack compiler instance
   */
  apply(compiler) {
    // Run this plugin after webpack has generated all files
    compiler.hooks.afterEmit.tapAsync('EmptyMinifiedFileCleanupPlugin', (compilation, callback) => {
      try {
        // Find all JavaScript files
        const jsFiles = glob.sync(this.options.jsPattern, { cwd: compiler.outputPath });
        let removedCount = 0;

        // Check each file to see if it's empty (0 bytes)
        jsFiles.forEach(filePath => {
          const fullPath = path.join(compiler.outputPath, filePath);

          const fileStats = fs.statSync(fullPath);
          if (fileStats.size === 0) {
            // Get the file's name without extensions (e.g., "admin" from "admin.min.js")
            const fileName = path.basename(filePath.replace(/\.min$/, ''), '.js');
            const fileDir = path.dirname(filePath);

            console.log(`Found empty file: ${filePath}`);

            // Find all associated files with the same base name
            const jsPattern = path.join(fileDir, `${fileName}.*`);
            const relatedFiles = glob.sync(jsPattern, { cwd: compiler.outputPath });

            console.log(`Removing ${relatedFiles.length} associated files for ${fileName}`);

            // Delete all the found files
            relatedFiles.forEach(file => {
              const fileToRemove = path.join(compiler.outputPath, file);
              console.log(`Removing: ${file}`);
              fs.unlinkSync(fileToRemove);
              removedCount++;
            });
          }
        });

        if (removedCount > 0) {
          console.log(`Removed ${removedCount} files associated with empty JS files.`);
        } else {
          console.log('No empty JS files found.');
        }

        callback();
      } catch (error) {
        console.error('Error cleaning empty files:', error);
        callback();
      }
    });
  }
}

/**
 * PHP Asset File Cleaner Plugin
 *
 * WordPress uses .asset.php files to track dependencies, but duplicates
 * for minified files aren't necessary. This plugin removes those unnecessary .min.asset.php files.
 */
class RemoveMinAssetPhpPlugin {
  /**
   * Set up the plugin with options
   *
   * @param {Object} options - Configuration options
   */
  constructor(options = {}) {
    this.options = {
      pattern: '**/*.min.asset.php',  // Target all .min.asset.php files
      ...options
    };
  }

  /**
   * Apply the plugin to webpack
   *
   * @param {Object} compiler - The webpack compiler instance
   */
  apply(compiler) {
    // Run this plugin after webpack has generated all files
    compiler.hooks.afterEmit.tapAsync('RemoveMinAssetPhpPlugin', (compilation, callback) => {
      try {
        // Find all .min.asset.php files
        const files = glob.sync(this.options.pattern, { cwd: compiler.outputPath });
        let removedCount = 0;

        // Delete each found file
        files.forEach(file => {
          const filePath = path.join(compiler.outputPath, file);
          fs.unlinkSync(filePath);
          console.log(`Removed unwanted file: ${file}`);
          removedCount++;
        });

        if (removedCount > 0) {
          console.log(`Removed ${removedCount} *.min.asset.php files.`);
        }
        callback();
      } catch (error) {
        console.error('Error removing *.min.asset.php files:', error);
        callback();
      }
    });
  }
}

// ----- MAIN WEBPACK CONFIGURATION -----
// The complete configuration that tells webpack what to do

module.exports = {
  // Start with WordPress's default webpack configuration
  ...defaultConfig,

  // Tell webpack which JavaScript files to process (both regular and minified versions)
  entry: { ...normalEntries, ...minifiedEntries },

  // Configure where and how to save the processed files
  output: {
    ...defaultConfig.output,
    path: __dirname,      // Save files in the current directory
    filename: getFilename, // Use custom function to determine filenames
  },

  // Define dependencies not included in the bundle
  externals: {
    // Define jQuery as an external dependency
    jquery: 'jQuery',
  },

  // Configure optimization settings for minification
  optimization: {
    ...defaultConfig.optimization,
    minimize: true,  // Enable code minification
    minimizer: [
      // Configure JavaScript minification
      new TerserPlugin({
        include: (includeMin ? /\.min\.js$/ : /\.js$/), // Which files to minify
        terserOptions: {
          output: {
            comments: /translators:/i,  // Keep WordPress translation comments
          },
          compress: {
            passes: 2,  // Run compression twice for better results
          },
          mangle: {
            reserved: ['__', '_n', '_nx', '_x'],  // Don't change WordPress translation function names
          },
        },
        extractComments: false,  // Don't create separate comment files
      }),
    ],
  },

  // Add plugins that enhance the build process
  plugins: [
    // Clean up old files before building new ones
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: [
        `${jsPath}/**/*`,  // Delete all JavaScript files in the output folder
        `${cssPath}/**/*`  // Delete all CSS files in the output folder
      ],
      cleanStaleWebpackAssets: false,  // Don't remove files that weren't directly generated
      verbose: true  // Show what's being deleted
    }),

    // Include WordPress's default plugins, except the ones being replaced
    ...defaultConfig.plugins.filter(plugin => (
        plugin.constructor.name !== 'CleanWebpackPlugin' &&
        plugin.constructor.name !== 'MiniCssExtractPlugin' &&
        plugin.constructor.name !== 'RtlCssPlugin' &&
        // Remove asset PHP file generation plugin if disabled
        (includeAssetPhp || plugin.constructor.name !== 'DependencyExtractionWebpackPlugin')
    )),

    // Extract CSS into separate files (regular version)
    new MiniCSSExtractPlugin({
      filename: (pathData) => getCssFilename(pathData, false),
    }),

    // Add RTL CSS support if enabled
    ...(includeRtl ? [
      // Generate RTL versions of regular CSS
      new RtlCssPlugin({
        filename: (pathData) => getCssFilename(pathData, false).replace(/\.css$/, '-rtl.css')
      }),

      // Generate RTL versions of minified CSS (if minification is enabled)
      ...(includeMin ? [
        new RtlCssPlugin({
          filename: (pathData) => getCssFilename(pathData, true).replace(/\.min\.css$/, '-rtl.min.css')
        })
      ] : []),
    ] : []),

    // Add minification support if enabled
    ...(includeMin ? [
      // Extract CSS into separate minified files
      new MiniCSSExtractPlugin({
        filename: (pathData) => getCssFilename(pathData, true),
      }),

      // Minify CSS files
      new CssMinimizerPlugin({
        include: /\.min\.css$/,  // Only minify files with .min.css extension
        minimizerOptions: {
          preset: [
            'default',
            { discardComments: { removeAll: true } },  // Remove all CSS comments
          ],
        },
      }),

      // Make regular CSS files pretty and readable
      new CssBeautifierPlugin(),
    ] : []),

    // Clean up unwanted PHP asset files if PHP asset generation is enabled
    ...(includeAssetPhp ? [new RemoveMinAssetPhpPlugin()] : []),

    // Clean up empty JavaScript files and their associated files
    new EmptyMinifiedFileCleanupPlugin(),
  ],
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment