One Gulp File to Rule Them All: Centralized CSS Compilation for Drupal Custom Themes and Modules
The Problem Every Drupal Developer Knows
On a typical Drupal project with a custom theme and several custom modules, the asset pipeline tends to sprawl. Each module that ships its own CSS gets its own gulpfile.js, its own package.json, and its own node_modules directory. A project with one theme and five custom modules ends up with six independent build systems — six places to update dependencies, six watch processes to juggle, and six points of failure.
This is waste in its most visible form.
The Solution: One gulpfile.js at the Project Root
The approach described here consolidates every SCSS compilation task — for the custom theme and every custom module — into a single gulpfile.js that lives at the repository root. No more per-directory build files. One npm install, one gulp watch, one place to update Autoprefixer.
The trick is a lightweight local configuration file (config.local.js) that each developer copies and edits once, then never thinks about again.
File Layout
project-root/
├── config.local.js ← your local settings (git-ignored)
├── config.local.js.example ← committed template
├── gulpfile.js ← the single build file
├── package.json
└── web/
├── themes/custom/
│ └── ahu/
│ ├── assets/scss/
│ └── dist/css/
└── modules/custom/
├── my_module_a/
│ ├── assets/scss/
│ └── dist/css/
└── my_module_b/
├── assets/scss/
└── dist/css/The gulpfile.js discovers every module under web/modules/custom/ that contains an assets/ directory and compiles it automatically. Adding a new module requires zero changes to the build system.
Step 1 — Install Dependencies Once
npm installThe package.json at the root declares everything that is needed:
package.json
{
"name": "makedrupaleasy",
"version": "2.0.1",
"author": "Ruslan Piskarov",
"homepage": "http://www.makedrupaleasy.com",
"private": true,
"scripts": {
"livereload": "livereload --host 127.0.0.1 --exclusions node_modules/ --exts 'css,js,twig,theme,apng,avif,gif,jpg,jpeg,jfif,pjpeg,pjp,png,svg,webp'"
},
"devDependencies": {
"autoprefixer": "^10.4.23",
"bootstrap": "^5.3.8",
"gulp": "^5.0.1",
"gulp-postcss": "^10.0.0",
"gulp-sass": "^6.0.1",
"livereload": "^0.10.3",
"postcss": "^8.5.6",
"sass": "^1.97.3"
},
"main": "gulpfile.js",
"browserslist": [
"last 2 version",
"> 1%",
"maintained node versions",
"not dead"
]
}| Package | Role |
|---|---|
gulp v5 | Task runner |
gulp-sass + sass | Dart Sass SCSS compilation |
gulp-postcss + autoprefixer | Vendor-prefix injection |
livereload | Browser live-reload during development |
bootstrap | Available as an SCSS import source |
Step 2 — Create Your Local Configuration
Copy the example file and edit it once:
cp config.local.js.example config.local.jsconfig.local.js.example is committed to the repository as a safe, generic template:
config.local.js.example
/**
* Local configuration for gulp tasks.
*
* Copy this file to config.local.js and modify as needed.
* The config.local.js file will be ignored by git.
*/
module.exports = {
// Local domain for the project.
localDomain: 'http://localhost',
// Docroot directory.
docroot: './web',
// Theme configuration.
theme: {
// Name of the custom theme.
name: 'my_theme',
},
};config.local.js is git-ignored and holds your actual environment values:
config.local.js
/**
* Local configuration for gulp tasks.
*
* Copy this file to config.local.js and modify as needed.
* Git will ignore the config.local.js file.
*/
module.exports = {
// Local domain for the project.
localDomain: 'https://ahu.ddev.site',
// Docroot directory.
docroot: './web',
// Theme configuration.
theme: {
// Name of the custom theme.
name: 'ahu',
},
};The file is intentionally minimal. The only values that vary between developers are the local domain and the theme name. Everything else — paths to modules, SCSS patterns, output structure — is derived automatically inside gulpfile.js.
Step 3 — The gulpfile.js in Detail
gulpfile.js
/**
* This gulpfile.js is for building custom theme and custom modules.
* From now, not necessary to have a similar gulpfile.js
* for a custom theme and each custom module.
* Before running this file, create a config.local.js.
* See config.local.js.example for an example.
*
* With best wishes, Ruslan Piskarov.
*/
'use strict';
const gulp = require('gulp'),
sass = require('gulp-sass')(require('sass')),
postcss = require('gulp-postcss'),
autoprefixer = require('autoprefixer'),
fs = require('fs'),
path = require('path');
// Load a local configuration if it exists.
let localConfig = {};
const localConfigPath = './config.local.js';
if (fs.existsSync(localConfigPath)) {
localConfig = require(localConfigPath);
}
// Define paths with defaults that can be overridden by local config.
const docroot = localConfig.docroot || './web';
const themePath = path.join(docroot, 'themes/custom');
const modulesPath = path.join(docroot, 'modules/custom');
// Get all custom modules with assets directory.
const getModulesWithAssets = () => {
const modules = [];
// Check if the modules directory exists.
if (fs.existsSync(modulesPath)) {
const modulesList = fs.readdirSync(modulesPath);
modulesList.forEach(moduleName => {
const assetsPath = path.join(modulesPath, moduleName, 'assets');
if (fs.existsSync(assetsPath)) {
modules.push({
name: moduleName,
path: path.join(modulesPath, moduleName),
assetsPath: assetsPath,
publicPath: path.join(modulesPath, moduleName, 'dist'),
});
}
});
}
return modules;
};
// Theme configuration.
const themeName = (localConfig.theme && localConfig.theme.name)
? localConfig.theme.name
: 'my_theme';
const theme = {
name: themeName,
path: path.join(themePath, themeName),
assetsPath: path.join(themePath, themeName, 'assets'),
publicPath: path.join(themePath, themeName, 'dist'),
};
// Get modules with assets.
const modules = getModulesWithAssets();
// Compile SCSS for theme.
async function compileThemeSass() {
return gulp.src([
path.join(theme.assetsPath, 'scss/*.scss'),
path.join(theme.assetsPath, 'scss/*/*.scss'),
], { sourcemaps: true })
.pipe(sass({
outputStyle: 'expanded',
precision: 10,
quietDeps: true,
silenceDeprecations: ['import', 'legacy-js-api'],
}).on('error', sass.logError))
.pipe(postcss([
autoprefixer({
overrideBrowserslist: ['last 2 versions'],
grid: true,
remove: false,
}),
]))
.pipe(gulp.dest(
path.join(theme.publicPath, 'css'),
{ sourcemaps: '../sourcemaps' }
));
}
// Compile SCSS for modules.
async function compileModulesSass() {
if (modules.length === 0) {
return Promise.resolve();
}
const tasks = modules.map(module => {
// Check if the scss directory exists.
const scssDir = path.join(module.assetsPath, 'scss');
if (!fs.existsSync(scssDir)) {
return Promise.resolve();
}
// Create a dist directory if it doesn't exist.
if (!fs.existsSync(module.publicPath)) {
fs.mkdirSync(module.publicPath, { recursive: true });
}
// Create a CSS directory if it doesn't.
const cssDir = path.join(module.publicPath, 'css');
if (!fs.existsSync(cssDir)) {
fs.mkdirSync(cssDir, { recursive: true });
}
return gulp.src([
path.join(module.assetsPath, 'scss/*.scss'),
path.join(module.assetsPath, 'scss/*/*.scss'),
], { sourcemaps: true })
.pipe(sass({
outputStyle: 'expanded',
precision: 10,
quietDeps: true,
silenceDeprecations: ['import', 'legacy-js-api'],
}).on('error', sass.logError))
.pipe(postcss([
autoprefixer({
overrideBrowserslist: ['last 2 versions'],
grid: true,
remove: false,
}),
]))
.pipe(gulp.dest(
path.join(module.publicPath, 'css'),
{ sourcemaps: '../sourcemaps' }
));
});
return Promise.all(tasks);
}
// Compile all SCSS.
async function compileSass() {
await compileThemeSass();
await compileModulesSass();
return Promise.resolve();
}
// Watch for changes.
async function watch() {
// Watch theme files.
gulp.watch([
path.join(theme.assetsPath, 'scss/*.scss'),
path.join(theme.assetsPath, 'scss/*/*.scss'),
], compileThemeSass);
// Watch module files.
modules.forEach(module => {
const scssDir = path.join(module.assetsPath, 'scss');
if (fs.existsSync(scssDir)) {
gulp.watch([
path.join(scssDir, '*.scss'),
], compileModulesSass);
}
});
}
// Define tasks.
gulp.task('sass', compileSass);
gulp.task('watch', watch);
gulp.task('default', gulp.series('watch'));How the Auto-Discovery Works
The getModulesWithAssets() function is the heart of the approach. At startup it reads the web/modules/custom/ directory and collects every subdirectory that contains an assets/ folder:
const getModulesWithAssets = () => {
const modules = [];
if (fs.existsSync(modulesPath)) {
fs.readdirSync(modulesPath).forEach(moduleName => {
const assetsPath = path.join(modulesPath, moduleName, 'assets');
if (fs.existsSync(assetsPath)) {
modules.push({
name: moduleName,
path: path.join(modulesPath, moduleName),
assetsPath,
publicPath: path.join(modulesPath, moduleName, 'dist'),
});
}
});
}
return modules;
};Each discovered module receives four derived paths:
| Property | Value |
|---|---|
name | Directory name, e.g. my_module |
path | web/modules/custom/my_module |
assetsPath | web/modules/custom/my_module/assets |
publicPath | web/modules/custom/my_module/dist |
SCSS is always expected at assets/scss/ and output always lands at dist/css/. This convention is enforced once, here, globally.
Graceful Fallbacks
The configuration loading uses fs.existsSync before require(), so the file works even when config.local.js is absent — for example, in CI:
let localConfig = {};
const localConfigPath = './config.local.js';
if (fs.existsSync(localConfigPath)) {
localConfig = require(localConfigPath);
}
const docroot = localConfig.docroot || './web';The theme name falls back to 'my_theme' when the config is missing:
const themeName = (localConfig.theme && localConfig.theme.name)
? localConfig.theme.name
: 'my_theme';SCSS Compilation Settings
Both the theme and module tasks share identical Sass settings:
.pipe(sass({
outputStyle: 'expanded', // human-readable; minify in a CI step
precision: 10, // decimal places for calculated values
quietDeps: true, // suppress deprecation noise from deps
silenceDeprecations: ['import', 'legacy-js-api'],
}).on('error', sass.logError))silenceDeprecations: ['import', 'legacy-js-api'] is significant. Dart Sass 1.x has deprecated the @import rule and the legacy JavaScript API. These flags keep the build output clean while a migration to @use / @forward is in progress.
Autoprefixer follows immediately via PostCSS:
.pipe(postcss([
autoprefixer({
overrideBrowserslist: ['last 2 versions'],
grid: true, // emit -ms-grid prefixes for IE 11 Grid support
remove: false, // never strip existing prefixes
}),
]))Source Maps
Both the theme and module tasks write source maps to a sibling sourcemaps/ directory:
.pipe(gulp.dest(
path.join(theme.publicPath, 'css'),
{ sourcemaps: '../sourcemaps' }
))The output tree for the theme looks like this:
web/themes/custom/ahu/dist/
├── css/
│ ├── style.css
│ └── components/
│ └── card.css
└── sourcemaps/
├── style.css.map
└── components/
└── card.css.mapThe watch Task
The watcher registers file system observers for every path it knows about at startup — theme SCSS and each discovered module's SCSS directory:
async function watch() {
gulp.watch([
path.join(theme.assetsPath, 'scss/*.scss'),
path.join(theme.assetsPath, 'scss/*/*.scss'),
], compileThemeSass);
modules.forEach(module => {
const scssDir = path.join(module.assetsPath, 'scss');
if (fs.existsSync(scssDir)) {
gulp.watch([path.join(scssDir, '*.scss')], compileModulesSass);
}
});
}gulp.task('default', gulp.series('watch')) means simply running gulp starts the watcher.
Available Commands
# Start watch mode (default)
npx gulp
# Watch and compile all SCSS
gulp watch
# Compile all SCSS once
npx gulp sass
# Run live-reload server (separate terminal)
npm run livereload
Adding a New Custom Module
Convention is the only requirement. Create the directory structure and the build system picks it up on the next gulp invocation:
web/modules/custom/my_new_module/
└── assets/
└── scss/
└── my_new_module.style.scssNothing else changes. No gulpfile.js in the module. No new package.json. The dist/css/ directory is created automatically if it does not exist.
Developer Onboarding Checklist
- Clone the repository.
- Run
npm installat the project root. - Copy
config.local.js.exampletoconfig.local.js. - Set
localDomainto your local URL (e.g.,https://project.ddev.site). - Set
theme.nameto the custom theme directory name. - Run
npx gulpto start the watcher.
That is the complete setup — for the theme and every current and future custom module.
Conclusion
The sprawl of per-directory build files is a solved problem. A single gulpfile.js at the project root, paired with a git-ignored config.local.js, gives every developer on the team one consistent place to compile SCSS — for the custom theme and for every custom module, present and future.
The key insights that make it work:
- Convention over configuration. Modules are discovered automatically by the presence of an
assets/directory. No registration, no manifest — just a folder. - Local config is ephemeral. The
config.local.jsfile is git-ignored by design. It holds only what is genuinely local — the domain and the theme name. Everything else is derived. - Graceful degradation in CI. When
config.local.jsis absent, the build falls back to safe defaults. The pipeline never breaks because a developer's personal config is missing. - One dependency tree. A single
node_modulesat the root means onenpm install, one version of Autoprefixer, one version of Dart Sass — across the entire project.
The next time a new custom module needs styles, the answer is simple: create assets/scss/, write SCSS, run npx gulp. The build system already knows what to do.