Accueil / Blog / Métier / 2014 / Drupal, Bootstrap, LESS & Gulp : how to make a Drupal theme

Drupal, Bootstrap, LESS & Gulp : how to make a Drupal theme

Par Sébastien Corbin publié 15/09/2014, édité le 16/02/2016
More and more articles with these words are appearing right now: here's our approach for a front-end theme complying with Web good practices.
Drupal, Bootstrap, LESS & Gulp : how to make a Drupal theme

Presentation of the main actors

Bootstrap

The project in which we adopted this approach is destined to be used only on iPad tablets (for now) and managed with Agile method. In this context, a CSS framework was appropriate as the client and its webdesigner were looking for our advice for this medium. For the choice of the framework, only few other than Bootstrap allow a precise customization of variables while still being highly modulable.

LESS

Bootstrap is written with LESS then rewritten in SASS by the community. Many other articles have stated the differences between SASS and LESS, so I'll let you use your favorite search engine. In our case, developers were more friendly with LESS syntax which is more simple yet more limited than SASS's.

Gulp

Gulp is a command line tool to manage redundant tasks that you would have done manually a while ago. For example: minify et compress JS and CSS files, images, etc. We started the project with Grunt, the first to offer this kind of automation then we switched to Gulp, where the community rapidly caught up the number of Grunt plugins. The big difference between the two is that Gulp works with streams whereas Grunt work with files (more info). Hence, we have less file I/O and less garbage to delete after a task.

Our awesome sauce

Now, Gulp and LESS are very useful for a direct implementation in a framework like Symfony, but in Drupal (CMF) which is not MVC-friendly, so we hit some walls.

HTML Markup

Bootstrap needs simple HTML markup but it is far from the one output by Drupal. To solve this, you can use the base theme Drupal Bootstrap and create a subtheme for customizations. For the content, as always: no front-end Views, and only custom templates for each list/content; a bit time-consuming, but we have full control over our markup.

CSS and JS

In Drupal, each module provide its own lot of CSS and JS resources, resulting in a long list of files included in <head> (if not aggregated). Take the example of www.nasa.gov: 43 CSS files (with Omega framework) and 30 JS files. On our side, the frontend theme is used only on the front-end (!), and we have a strict policy on contrib modules: if we can do the same in a few lines of code, we don't use the module. There are also some JS and CSS files from poorly written contrib that are included on every page.

CSS

To solve this on the CSS side, here's our sauce :

/**
 * Implements hook_css_alter().
 */
function mon_theme_css_alter(&$css) {
  // single_css means we work in a production environment, so we analyse the
  // list of CSS and include only the ones we need.
  if (variable_get('single_css', TRUE)) {

    $whitelist = array(
      'sites/all/libraries/datetimepicker/jquery.datetimepicker.css',
    );

    // Here we do a whitelist only on our file and inline CSS
    $theme_path = drupal_get_path('theme', 'mon_theme');
    foreach ($css as $name => $settings) {
      if ($settings['type'] == 'inline' || $name == $theme_path . '/dist/style.min.css') {
        // This will disable @import commands, better for caching HTTP requests
        $css[$name]['preprocess'] = FALSE;
      }
      elseif (!in_array($name, $whitelist, TRUE)) {
        unset($css[$name]);
      }
    }

  }
}

To explain simply, a bootstrap subtheme defines its CSS in a single LESS file, translated to a style.min.css with Gulp. In production, we only need this file and few other white-listed.

JavaScript

The JavaScript side is more tricky: Bootstrap has numerous JS files to include, as in our custom, contrib and core modules that we can't omit (jQuery Update, Drupal Ajax framework, form API, …). In this situation, we also need a whitelist, but it is difficult to know beforehand which files need to be included: we need to build the list on-the-fly to be read by Gulp afterwards.

/**
 * Implements hook_js_alter().
 */
function my_theme_js_alter(&$js) {
  $theme_path = drupal_get_path('theme', 'my_theme');

  // The whitelist in case of single_js == TRUE, the blacklist otherwise
  $whitelist = array(
    "sites/all/libraries/datetimepicker/jquery.datetimepicker.js",
    drupal_get_path('module', 'mon_scheduler') . '/mon_scheduler.js',
  );

  // single_js means we work in a production environment, so include only
  // our single JS file, generated by gulp, and some whitelisted ones
  if (variable_get('single_js', TRUE)) {

    foreach ($js as $name => $settings) {
      // Exclude all files except for our own, and Drupal.settings
      if ($name != 'settings' && $name != $theme_path . '/dist/script.min.js' && !in_array($name, $whitelist, TRUE)) {
        unset($js[$name]);
      }
    }

    // Change its group and weight to be the first one included.
    // In that way, all other scripts whitelisted don't have to take care about
    // group and weight regarding to this global aggregate scripts which may
    // contains libraries.
    $js[$theme_path . '/dist/script.min.js']['group'] = JS_DEFAULT;
    $js[$theme_path . '/dist/script.min.js']['weight'] = -100;
  }
  else {
    // On development environments, we will try to populate the map.json file
    // with JS files that are included on the fly, excluding the one we generated
    $path = $theme_path . '/js/map.json';

    // Load previous list from map.json
    $contents = file_get_contents($path);
    $list = FALSE;
    if ($contents !== FALSE) {
      // We have a list, parse it
      $list = drupal_json_decode($contents);
    }
    // else reading file failed, and we won't update it programmatically, but
    // we will still remove our single file

    foreach ($js as $name => $settings) {
      if ($name == $theme_path . '/dist/script.min.js') {
        // We won't need our single file here
        unset($js[$name]);
        continue;
      }

      // If we have a list, update it
      if ($list !== FALSE && $name != 'settings' && !in_array($name, $whitelist, TRUE) && strstr($name, 'languages/fr') === FALSE) {
        // TRUE will add it, FALSE will remove it, edit the map.json file
        // directly if you need to exclude a file
        $list[$name] = isset($list[$name]) ? $list[$name] : TRUE;
      }

    }
    // Udpate map.json
    if ($list !== FALSE && defined('JSON_PRETTY_PRINT')) {
      file_put_contents($path, json_encode($list, JSON_PRETTY_PRINT) . "\n");
    }
  }
}

And in my_theme.info :

;;;;;;;;;;;;;;;;;;;;;
;; Stylesheets
;;;;;;;;;;;;;;;;;;;;;

; This is the JS for our theme, unminified
scripts[] = js/script.js
; This is the global JS for the project, minified
scripts[] = dist/script.min.js

; Other libraries
scripts[] = js/switchery.js

;;;;;;;;;;;;;;;;;;;;;
;; Bootstrap Scripts
;;;;;;;;;;;;;;;;;;;;;

;scripts[] = 'bootstrap/js/affix.js'
scripts[] = 'bootstrap/js/alert.js'
scripts[] = 'bootstrap/js/button.js'
scripts[] = 'bootstrap/js/carousel.js'
;scripts[] = 'bootstrap/js/collapse.js'
;scripts[] = 'bootstrap/js/dropdown.js'
scripts[] = 'bootstrap/js/modal.js'
;scripts[] = 'bootstrap/js/tooltip.js'
;scripts[] = 'bootstrap/js/popover.js'
;scripts[] = 'bootstrap/js/scrollspy.js'
;scripts[] = 'bootstrap/js/tab.js'
scripts[] = 'bootstrap/js/transition.js'

Thanks to this snippet, our map.json file is generated automatically and contains the list of scripts added by modules. It can be versioned and edited manually to exclude some JavaScript files useless in front-end.

{
    "sites\/all\/modules\/jquery_update\/replace\/jquery\/1.10\/jquery.js": true,
    "misc\/drupal.js": true,
    "misc\/jquery.once.js": true,
    "sites\/all\/themes\/bootstrap\/js\/bootstrap.js": true,
    "profiles\/my_profile\/themes\/my_theme\/bootstrap\/js\/button.js": true,
    "profiles\/my_profile\/themes\/my_theme\/bootstrap\/js\/carousel.js": true,
    "profiles\/my_profile\/themes\/my_theme\/bootstrap\/js\/modal.js": true,
    "profiles\/my_profile\/themes\/my_theme\/bootstrap\/js\/transition.js": true,
    "misc\/form.js": false,
    "sites\/all\/themes\/bootstrap\/js\/misc\/_progress.js": true,
    "sites\/all\/modules\/jquery_update\/js\/jquery_update.js": true,
    "sites\/all\/modules\/jquery_update\/replace\/misc\/jquery.form.js": true,
    "misc\/states.js": true
}

gulpfile

Now that we have all our resources ready to be processed by Gulp, we can define the different tasks in the gulpfile.js in the root of the subtheme :

var gulp = require('gulp');
var uglify = require('gulp-uglify');
var jshint = require('gulp-jshint');
var less = require('gulp-less');
var cssmin = require('gulp-cssmin');
var concat = require('gulp-concat');
var gzip = require('gulp-gzip');
var imagemin = require('gulp-imagemin');
var rename = require('gulp-rename');
var pngcrush = require('imagemin-pngcrush');
var svgstore = require('gulp-svgstore');
var svgmin = require('gulp-svgmin');

// Concatenation and minification JS, reading map.json
gulp.task('js', function () {
  var map = require('./js/map.json'), list = [];
  for (var i in map) {
    if (map.hasOwnProperty(i) && map[i]) {
      // Make relative to drupal path
      list.push('../../../../' + i);
    }
  }
  return gulp.src(list)
    .pipe(concat('script.min.js'))
    .pipe(uglify())
    .pipe(gulp.dest('./dist/'))
    .pipe(gzip())
    .pipe(gulp.dest('./dist/'));
});

// JS syntax validation
gulp.task('jshint', function () {
  return gulp.src([
    './js/*.js',
    '../../modules/**/*.js',
    '!./js/xt*.js',
    '!./js/switchery.js',
  ])
    .pipe(jshint())
    .pipe(jshint.reporter('default'))
    .pipe(jshint.reporter('fail'));
});

// Compilation: LESS into CSS
gulp.task('less', function () {
  return gulp.src('./less/style.less')
    .pipe(less())
    .pipe(cssmin())
    .pipe(rename({suffix: '.min'}))
    .pipe(gulp.dest('./dist/'))
    .pipe(gzip())
    .pipe(gulp.dest('./dist/'));
});

// Images optimization
gulp.task('images', function () {
  return gulp.src('./img/*')
    .pipe(imagemin({
      progressive: true,
      svgoPlugins: [{removeViewBox: false}],
      use: [pngcrush()]
    }))
    .pipe(gulp.dest('img'));
});

// Sprite and SVG minification
gulp.task('svg', function () {
  return gulp.src('./svg/*.svg')
    .pipe(svgmin([{
      collapseGroups:false
    }]))
    .pipe(svgstore({
      fileName: 'icons.svg',
      prefix: 'icon-',
      inlineSvg: true
    }))
    .pipe(gulp.dest('./dist/'))
});

gulp.task('default', ['less', 'jshint', 'js', 'images', 'svg']);

gulp.task('watch', function () {
  gulp.watch(['./js/*.js', '../../modules/**/*.js'], ['js', 'jshint']);
  gulp.watch('./less/*.less', ['less']);
  gulp.watch('./img/*', ['images']);
  gulp.watch('./svg/*', ['svg']);
});

Conclusion: Respect of Web good practices

Phew! That's a lot to do for a good front-end workflow... But let's see the pros:

  • no contrib modules (Bootstrap base theme aside) to process LESS files, minify JS or compress images: this should not be Drupal's task anyway
  • only 2 JS and CSS files, minified and gzip-ed with the current best techniques
  • so only 2 HTTP requests, cached by the web browser
  • a clear code and structure complying with standards
  • a happy front-end developer

If you have any comment, don't hesitate to tweet us.

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
Howto: using Twig in Drupal 7 Howto: using Twig in Drupal 7 25/06/2015

Using Twig in Drupal 7 is indeed possible: here's how to anticipate Drupal 8, and use right now the ...

Desperately seeking drupal modules Desperately seeking drupal modules 23/06/2013

A customized Google query to quickly find Drupal modules.

Challenges of maintaining a highly-used Drupal module Challenges of maintaining a highly-used Drupal module 02/03/2011

A story of a Drupal contrib maintainer.

The state of localize.drupal.org in 2014 30/01/2014

A summary about the current situation of the Drupal localization project.

Drupal and SEO: HowTo Drupal and SEO: HowTo 21/11/2013

A subjective (but well-argued) review of modules that you can use to properly add SEO to your ...