Let's scale that Gulpfile.js
Not a long time ago, I decided to switch and start my newly assigned project with Gulp, rather than Grunt.
It worked out well. I was able to do what my grunt configuration was doing, but this time via gulp. This was not possible without the help of all those community plugins, which I actually just glued together.
There was only one big problem left, which I had to solve again, but this time in a gulp way. Gulpfile.js looked like copy-pasted patchwork. There was plenty of repeatable code and most importantly no structure.
I had to find a new solution to the problem that I had with my grunt configuration. I was using load-grunt-config, but there was no such option for doing the same in gulp.
So this is what I did :
Separate configuration from build scripts
First things, first. I always hated to copy paste these "./.build/"
and "./src"
strings all-over my Gulp file. It doesn't feel good and it meant project search & replace
whenever I needed to change these.
That's why, you can usually find a ./configuration.yaml
file in most of my projects that
also contain the project structure. It looks like this :
#
# Configuration for MyAwesomeProject
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
# The `paths` section is used by Gulp to provide fine grain control and DRY
# principle in the configuration files
#
paths:
source:
root: '.'
fonts: '/fonts'
server: '/src'
images: '/images'
scripts: '/scripts'
templates: '/templates'
stylesheets: '/stylesheets'
build:
root: './.build'
server: '/'
templates: '/templates'
package: '/../.packages'
www:
root: '/www'
fonts: '/fonts'
images: '/images'
vendors: '/vendors'
scripts: '/scripts'
stylesheets: '/stylesheets'
# Other notable variables also can exist here
port: 8080
Doing this gives you a couple of benefits :
- There will be an easily-maintainable variable, instead of a hard-coded string for all directories in your project ;
- Everyone can see what is stored where, by opening this file ;
- You can reuse this configuration file in other parts of your application that needs them. ( e.g. ExpressJS middleware for static files, templates rendering, etc. )
Let's see how can we use this with Gulp.
Treat Gulpfile.js as nothing more than a NodeJS module
Well, this is one of the main benefits of using gulp. It's not a declarative configuration file, it's just a module that you can ( should ) consider as a separate application.
So let's do what we do with any application that we want to scale... Let's separate it.
My Gulpfile.js looks like this :
/**
*
* ### Overview
*
* We are using [gulp](http://gulpjs.com/) as a build system. Gulp in
* MyAwesomeProject is responsible for a couple of things :
*
* 1. Compiles the project ( written in TypeScript ) to Javascript ;
* 2. Helps during the development by watching for changes and
* compiles automatically.
*
* ### Structure
*
* Our gulp configuration starts in the root `./Gulpfile.js`, which
* loads all tasks in the directory `./gulp`.
*
* The gulp-task files itself are written according to JSDoc specs
* to make generating the future documentation flawless.
*
* There is another special directory, called `./gulp/lib`, which
* purpose is to store all non-gulptask files that have helpers
* for the tasks ( e.g. configuration options )
*
* ### External Configuration
*
* Gulp uses configuration variables stored in `./configuration.yaml`
*
* @name gulp
* @module
*
*/
require( "./gulp/development" );
require( "./gulp/build" );
// ... more, such as "./gulp/package", "./gulp/deploy", etc.
That's right. I'm including this long JSDoc comment ( and a lot more as you will see next ), because I want to make the life of my fellow team easier.
Also making nice JSDoc comments in our Gulp configuration will lay down the foundation of making a beautiful living documentation that explains how to build the project and what that means, without opening any files.
Separating Gulpfile.js, means that not everything will be stored in one single file, so documenting the build process becomes more and more important.
Create a scalable tasks structure
If you've read carefully that JSDoc in the root Gulpfile.js, you already know the structure and the purpose of the files in the whole gulp app.
Let's make it more visual :
./gulp/
./gulp/lib/helpers.js
./gulp/lib/paths.js
./gulp/build.js
./gulp/development.js
./Gulpfile.js
./configuration.yaml
So what's the reasoning behind this.
In the root of our ./gulp
directory we position our main tasks. Those are usually tasks
that are composite and define inside their building blocks. The task structure looks something like this
development - Watch files and executes "build-[task]" accordingly
build - It's a container for the whole build process
| build-clean - Cleans the build folder
| build-compile - Server-side files manipulation (e.g. TypeScript->JS)
| build-scripts - Client-side javascripts manipulation (e.g. Browserify)
| build-stylesheets - Client-side stylesheets ( e.g. autoprefixer )
| build-assets - Just copies fonts, images and other static files
package - Container for packaging task executes package-*
| package-assets - Copy other files that we want to add to our package
| package-tar-gz - Creates a zip file
documentation - Container for documentation task. Exec. documentation-*
| documentation-app - Generates documentation for our app
| documentation-gulp - Generates docs for the build system ( parsing JSDocs )
... more
Helpers and other shared code between the tasks that we can put inside /lib
. Actually
let's see what we have there so far.
Separate execution helpers separate from the task files
First, let's see what's inside gulp/lib/helpers.js
/**
* @module lib:helpers
* @private
*/
var gulp = require( "gulp" );
module.exports = {
/**
* Import tasks provided as an object into gulp
*
* @param tasks {object}
*/
importTasks : function( tasks ) {
Object
.keys( tasks )
.forEach( function( key ) { gulp.task( key, tasks[key] ); }
}
}
We export importTask
which basically replaces gulp.task('task', function() { ... })
with our own helper. We will use that in our gulp task files.
Our gulp/lib/paths.js
is a bit more complicated and looks like this :
/**
* @module lib:paths
* @private
*/
var yaml = require( "js-yaml" ),
fs = require( "fs" ),
_ = require( "lodash" );
/**
* Parse all paths and populate the directories with their roots if any.
*
* @private
*/
var normalizePaths = function( paths ) {
var result = {},
recurse = function( cursor, adder, prop ) {
if ( _.isString( cursor ) ) {
if ( prop !== "root" ) {
result[prop] = adder + cursor;
}
} else if ( _.isArray( cursor ) ) {
result[prop] = cursor.map( function( item ) {
return adder + item;
} );
} else {
var isEmpty = true;
if ( cursor["root"] ) {
adder += cursor["root"];
result[prop ? prop : "root"] = adder;
}
Object.keys( cursor ).forEach( function( key ) {
isEmpty = false;
recurse( cursor[key], adder, key );
} );
if ( isEmpty && prop ) {
result[prop] = {};
}
}
};
recurse( paths, "", "" );
return result;
};
/**
* Parse paths for all modules
*
* @private
*/
var paths = yaml.safeLoad(
fs.readFileSync( __dirname + "/../../configuration.yaml", "utf8" )
)["paths"],
source = normalizePaths( paths['source'] ),
build = normalizePaths( paths['build'] );
/**
* Export helpers
*/
module.exports = {
/**
* Exports all paths
*/
for : paths,
/**
* Returns path for source
*
* @param [path] {string}
* @returns {string}
*/
forSource : function( path ) { return source[path ? path : "root"]; },
/**
* Returns path for build
*
* @param [path] {string}
* @returns {string}
*/
forBuild : function( path ) { return build[path ? path : "root"]; }
};
So what we have as an outcome is friendly functions that can return the proper nested
path from our configuration.yaml
. Doing this will keep us out from any code duplications
that may occur for our directories.
Actually since we already know that Gulpfile.js
is an actual application, we should also
not worry about using popular libraries and speed. Most of the libraries out there use
lodash, so it's not an issue if we also use it every time we run gulp
.
Let's see how these two files can make our build system scalable.
Keep your task files readable
The infrastructure that we created gives huge benefits on how the task files can look.
A typical build file, using the principles above should look like :
/**
* @module build
*/
var gulp = require( 'gulp' ),
sequence = require( 'gulp-sequence' ),
gutil = require( 'gulp-util' ),
del = require( 'del' ),
es = require( 'event-stream' ),
sass = require( 'gulp-sass' ),
...
paths = require( './lib/paths' ),
helpers = require( './lib/helpers' );
/**
* ### Overview
*
* Tasks used for building the NodeJS application
*
* @namespace tasks
*/
var tasks = {
/**
* @task build
* @namespace tasks
*/
'build' : function( callback ) {
/**
* It is a composite task that runs the following tasks in sequence
*
* 1. `build-clean`
* 2. `build-compile`
* 3. `build-scripts`
* 4. `build-stylesheets`
* 5. `build-assets`
*
* The different tasks are found below :
*
* @namespace tasks:build
*/
sequence(
'build-clean',
'build-compile',
'build-scripts',
'build-stylesheets',
'build-assets',
callback );
},
/**
* #### Cleans the build target folder
*
* Cleans the folder, which is the root of the compiled app ( `./.build` )
*
* @task build-clean
* @namespace tasks
*/
'build-clean' : function() {
return del( [paths.forBuild() + '/**'] );
},
/**
* #### Compiles the app
*
* Compiles the source application directory to the build directory
*
* @task build-compile
* @namespace tasks
*/
'build-compile' : function() {
return gulp
.src( [paths.forSource( 'server' ) + '/**/*.*'] )
.pipe( /* ... */ )
.pipe( gulp.dest( paths.forBuild( 'server' ) ) );
},
/**
* #### Compiles front-end scripts
*
* Compiles all front-end scripts into bundles using ...
*
* @task build-scripts
* @namespace tasks
*/
'build-scripts' : function( callback ) {
return gulp
.src( [paths.forSource( 'scripts' ) + '/**/*.*'] )
.pipe( /* ... */ )
.pipe( gulp.dest( paths.forBuild( 'scripts' ) ) );
},
/**
* #### Compiles stylesheets
*
* Compiles source stylesheets via [Sass](http://sass-lang.com/) with
* SCSS syntax
*
* @task build-stylesheets
* @namespace tasks
*/
'build-stylesheets' : function() {
return gulp.src( paths.forSource( 'stylesheets' ) + '/**/*.scss' )
.pipe( sass().on( 'error', sass.logError ) )
.pipe( gulp.dest( paths.forBuild( 'stylesheets' ) ) )
.on( 'error', gutil.log );
},
/**
* #### Copy assets
*
* Copies the static assets that will be used by the server :
*
* - templates
* - images
* - fonts
*
* @task build-assets
* @namespace tasks
*/
'build-assets' : function() {
return es.merge(
gulp.src( paths.forSource( 'templates' ) + '/**/*' )
.pipe( gulp.dest( paths.forBuild( 'templates' ) ) ),
gulp.src( paths.forSource( 'images' ) + '/**/*' )
.pipe( gulp.dest( paths.forBuild( 'images' ) ) ),
gulp.src( paths.forSource( 'fonts' ) + '/**/*' )
.pipe( gulp.dest( paths.forBuild( 'fonts' ) ) )
);
}
};
//
// Registering Tasks
//
helpers.importTasks( tasks );
As you can see gulp/build.js
file looks totally different than most of the Gulpfile.js
that you can find. The main differences are :
- Using an object (
var tasks = { /* ... */ }
) that stores all the tasks as keys ; - Use of JSDocs to annotate and summarise what a task is actually doing ;
- Use a configuration file as a storage for source and destination paths.
The tasks
object separates what the local gulp
command can execute in a very prominent
way. Since it lives in a separate object any non gulp task functions that may exist as
helpers ( e.g. custom logging function ) lives outside the variable and is strictly
separated from the task itself.
With a little bit of help from gulp-jsdoc-to-markdown
you could generate a documentation out of the whole gulp application. I'm using some
custom annotation tags ( @task
) that I later filter and render with the proper
html markup. Teams benefit a lot from docs like this.
The way that the source and destination paths are specified, gives us the opportunity to reuse the same strings multiple times, without worrying when we need to refactor and change them. Imagine a commit message stating "Changing build folder" and 50 changed lines. Using this method the modifications will be only one row.
What do you think?