Node.js version control in Drupal themes

Drupal and Node logos

It is important that for any given project, each developer uses the same version of Node.js and related Javascript (JS) packages.

Overview

In this article, we’ll discuss how to use “Node Version Manager” (NVM) to install multiple versions of Node.js, how to use “Automatic Version Switching for Node.js” (AVN) to automatically switch Node.js versions when changing to a project directory, and how to use “Node Package Manager” (NPM) to keep track of JS packages.

We will get into more detail, but for now, I’ll list some typical files that are found in Drupal themes along with brief descriptions of each file.

An example Drupal theme file structure

A Drupal theme that uses Node.js might include the following files:

  • .node-version
    • Used by AVN to keep track of which version of Node.js the project uses.
  • package.json
    • Used by NPM, this file is manually edited to declare which Javascript packages the project depends on.
  • npm-shrinkwrap.json
    • Used by NPM, this file is automatically generated and keeps track of exact versions of the packages in package.json and all their dependent packages.
  • gulpfile.js
    • Used by Gulp, this file is manually edited to declare and configure any automated tasks. Example tasks are SCSS to CSS conversion, SCSS and JS linting (code format checking), automated styleguide creation, and JS and CSS aggregation.
  • .eslintrc
    • Used by the Gulp plugin “gulp-eslint” to enforce JS coding standards.
  • .scss-lint.yml
    • Used by the Gulp plugin “gulp-sass-lint” to enforce SCSS coding standards.

Basic Steps to Set Up Automation for a Drupal Theme

In the past, I used Grunt and Ruby in my Drupal themes, but now I just use Gulp to achieve the same goals. This is a typical setup for Mac and Linux. I don’t have experience using Node.js on Windows or other operating systems.

Install Node.js globally

The first step is to install Node.js globally. To find out if you have it installed already, type “node -v” in a terminal window. If the terminal doesn’t return anything back, you need to install Node.js. If the terminal returns a version number, you can either skip to the next section, or run the installer if you wish to update.

To install Node.js, download and run the appropriate installer for your operating system from the Node.js website. If you don’t know which version to get, stick with a Long Term Support (LTS) version.

Install NVM

I’m going to briefly go over the installation of NVM, so please read and follow the installation instructions on the NVM github site. A quote from the site, “...make sure your system has a c++ compiler. For OS X, Xcode will work, for Ubuntu, the build-essential and libssl-dev packages work.” For Mac, you just need the Command Line Tools (this is also mentioned in the NVM installation instructions.)

Install AVN

As with NVM, it is best to read the installation instructions on the AVN github page https://github.com/wbyoung/avn. Installation is pretty straightforward as of this writing; just run two terminal commands:

  • npm install -g avn avn-nvm avn-n
  • avn setup

Write a package.json file

At the end of this article, there is an example package.json file. Documentation of how to write a package.json file can be found on the NPM site.

Add a line to your ~/.bash_profile

If ~/.bash_profile doesn’t exist on your system, create it; it is just a text file. Add the following line to it:

  • export PATH=./node_modules/.bin:$PATH

Install the packages

Change to your theme directory and run “npm install” in the terminal. You may have to run “sudo npm install” if the terminal reports errors related to permissions.

Create the npm-shrinkwrap.json file

After installing the packages, run “npm shrinkwrap --dev” in the terminal. This terminal command will create the npm-shrinkwrap.json file.

In the future, when packages are added, removed, or updated in package.json, some extra commands are needed. Run the “npm install” command to install new packages. Run the "npm update” command to update packages. Run the “npm prune” command to remove old unused packages. Finally, run “npm shrinkwrap --dev” to re-generate the npm-shrinkwrap.json file.

Create the gulpfile.js file

At the end of this article, there is an example gulpfile.js. I know it’s long, but it contains many good examples of common tasks. See https://github.com/gulpjs/gulp for more information on writing gulpfile.js files.

Create the .node-version file

Create a new empty text file called .node-version. Inside it, add one line which contains the version of Node.js that should be used for the project. An example is “5.0.0” (without the quotes).

Results

Now, when a developer changes to the project directory, the specified local version of Node.js and all JS packages are used.

Conclusion

Many projects use Node.js for automation. I find setting up and maintaining these projects to be rather complex. Using Node.js for theme automation requires careful control over which version of Node.js is used and over which versions of JS packages are used. Hopefully the methods outlined above help make the process a little easier for others. I’m always looking to improve and so welcome comments and criticisms. :) Thanks for reading!

Appendix: Example files

Example package.json file

Notice how gulp is included. This is important as many developers will have gulp installed globally but we don’t want to allow them to use their global version. Note: you must make the addition to ~/.bash_profile as instructed above if you want to enforce which version of packages are allowed to be used with the project.

{
 "name": "exampletheme",
 "version": "1.0.0",
 "description": "The Example Theme is a demo theme for this blog post.",
 "main": "gulpfile.js",
 "dependencies": {},
 "devDependencies": {
   "breakpoint-sass": "2.7.x",
   "compass-mixins": "0.12.x",
   "drupal-breakpoints-scss": "1.1.x",
   "eslint": "1.10.x",
   "gulp": "3.9.x",
   "gulp-autoprefixer": "2.3.x",
   "gulp-concat": "2.6.x",
   "gulp-cssmin": "0.1.x",
   "gulp-eslint": "1.1.x",
   "gulp-insert": "0.5.x",
   "gulp-livereload": "3.8.x",
   "gulp-load-plugins": "1.2.x",
   "gulp-plumber": "1.1.x",
   "gulp-rename": "1.1.x",
   "gulp-sass": "2.2.x",
   "gulp-sass-lint": "1.1.x",
   "gulp-sourcemaps": "1.6.x",
   "gulp-strip-css-comments": "1.2.x",
   "gulp-uglify": "1.5.x",
   "gulp-uncss": "1.0.x",
   "gulp-watch": "4.3.x",
   "kss": "kss-node/kss-node#52dd3a1d84654072dfefde8920c2fec59617d204",
   "modularscale-sass": "2.1.x",
   "node-sass-globbing": "0.0.x",
   "susy": "2.2.x"
 },
 "scripts": {},
 "author": "",
 "license": "GPL-3.0",
 "repository": {}
}

Example gulpfile.js file

/**
* Type "gulp" on the command line to watch file changes.
*/
'use strict';
var $ = require('gulp-load-plugins')();
var autoprefixer = require('gulp-autoprefixer');
var concat = require('gulp-concat');
var cssmin = require('gulp-cssmin');
var drupalBreakpoints = require('drupal-breakpoints-scss');
var gulp = require('gulp');
var importer = require('node-sass-globbing');
var insert = require('gulp-insert');
var kss = require('kss');
var livereload = require('gulp-livereload');
var plumber = require('gulp-plumber');
var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps');
var stripCssComments = require('gulp-strip-css-comments');
var uglify = require('gulp-uglify');
var uncss = require('gulp-uncss');
var rename = require('gulp-rename');
var sassLint = require('gulp-sass-lint');

var paths = {
 rootPath: {
   project: __dirname + '/',
   styleGuide: __dirname + '/styleguide/',
   theme: __dirname + '/'
 },
 theme: {
   root: __dirname + '/',
   css: __dirname + '/' + 'css/',
   sass: __dirname + '/' + 'scss/',
   js: __dirname + '/' + 'js/'
 }
};

var options = {
 autoprefixer: {
   browsers: ['> 1%']
 },
 concat: {
   files: [
     paths.theme.js + 'custom/**/*.js'
   ]
 },
 eslint: {
   files: [
     paths.theme.js + 'custom/**/*.js'
   ]
 },
 sass: {
   importer: importer,
   includePaths: [
     'node_modules/breakpoint-sass/stylesheets/',
     'node_modules/susy/sass/',
     'node_modules/modularscale-sass/stylesheets',
     'node_modules/compass-mixins/lib/'
   ]
 },
 scssLint: {
   // maxBuffer default is 300 * 1024
   'maxBuffer': 1000 * 1024,
   rules: {
     'class-name-format': 0,
     'empty-args': 0,
     'empty-line-between-blocks': 0,
     'force-element-nesting': 0,
     'nesting-depth': 0,
     'no-vendor-prefixes': 0,
     'property-sort-order': 0
   },
   'config': paths.rootPath.project + '.scss-lint.yml'
 },
 styleguide: {
   source: [
     paths.theme.sass + 'components/',
     paths.theme.css + 'style-guide/'
   ],
   destination: paths.rootPath.styleGuide,

   // The css and js paths are URLs, like '/misc/jquery.js'.
   // The following paths are relative to the generated style guide.
   css: [
     path.relative(paths.rootPath.styleGuide, paths.theme.css + 'style.css'),
     path.relative(paths.rootPath.styleGuide, paths.theme.css + 'style-guide/chroma-kss-styles.css'),
     path.relative(paths.rootPath.styleGuide, paths.theme.css + 'style-guide/kss-only.css')
   ],
   js: [],

   homepage: 'homepage.md',
   builder: paths.rootPath.styleGuide + 'custom-builder',
   title: ''
 },
 uglify: {
   compress: {
     unused: false
   }
 }

};

// JS tasks.
gulp.task('js-watch', function() {
 gulp.src(options.eslint.files)
   .pipe($.eslint())
   .pipe($.eslint.format());
 gulp.src(options.concat.files)
   .pipe(concat('script.js'))
   .pipe(gulp.dest('js_min'))
   .pipe(uglify(options.uglify))
   .pipe(gulp.dest('js_min'));
});

// Sass tasks.
gulp.task('breakpoints-watch', function () {
 gulp.src(paths.rootPath.theme + 'exampletheme.breakpoints.yml')
   .pipe(drupalBreakpoints.ymlToScss())
   .pipe(rename('_breakpoints-auto-generated.scss'))
   .pipe(insert.prepend('// Warning: This file is automatically generated. Do not modify it directly.\n// Instead, run Gulp and modify exampletheme.breakpoints.yml to regenerate the file.\n// See gulpfile.js in this theme for more details.\n'))
   .pipe(gulp.dest(paths.theme.sass))
});

gulp.task('sass-watch', function() {
 gulp.src(paths.theme.sass + 'components/**/*.scss')
   .pipe(sassLint(options.scssLint))
   .pipe(sassLint.format())
   .pipe(sassLint.failOnError());
 gulp.src(paths.theme.sass + '**/*.scss')
   .pipe(plumber())
   .pipe(sourcemaps.init())
   .pipe(sass(options.sass).on('error', sass.logError))
   .pipe(autoprefixer(options.autoprefixer))
   .pipe(stripCssComments({preserve: false}))
   .pipe(gulp.dest('./css'));
});

gulp.task('styleguide-watch', function(files) {
 return kss(options.styleguide, files);
});

gulp.task('default', function() {
 livereload.listen();
 gulp.watch(paths.rootPath.theme + 'exampletheme.breakpoints.yml', ['breakpoints-watch', 'sass-watch', 'styleguide-watch']);
 gulp.watch('./scss/**/*.scss', ['sass-watch', 'styleguide-watch']);
 gulp.watch('./scss/**/*.hbs', ['styleguide-watch']);
 gulp.watch('./js/**/*.js', ['js-watch']);
 gulp.watch(['./css/style.css', './**/*.twig', './js_min/*.js'], function(files) {
   livereload.changed(files)
 });
});