Accueil / Blog / Métier / 2014 / Drupal, Bootstrap, LESS et Gulp : comment faire un thème Drupal

Drupal, Bootstrap, LESS et Gulp : comment faire un thème Drupal

Par Sébastien Corbin publié 15/09/2014, édité le 29/01/2016
Voici notre approche pour un thème Drupal front-end respectant les bonnes pratiques Web.
Drupal, Bootstrap, LESS et Gulp : comment faire un thème Drupal

De plus en plus d'articles avec ces 4 mots apparaissent ces temps-ci, voici notre point de vue.

Présentation des acteurs

Bootstrap

Le projet sur lequel nous avons adopté cette méthode est destiné uniquement aux tablettes iPad (pour le moment) et exécuté en mode Agile. Dans ce contexte, un framework CSS était d'autant plus approprié que le client et son graphiste étaient à l'écoute de nos suggestions sur le design. Pour le choix du framework CSS, peu de frameworks autres que Bootstrap permettent une fine customisation des variables tout en restant modulable.

LESS

Bootstrap est codé en LESS puis réécrit en SASS par la communauté. Bien d'autres articles ont déjà établi les différences entre SASS et LESS, je vous laisserai donc le soin d'utiliser votre moteur de recherche favori. Dans notre cas, les développeurs sont plus habitués à LESS dont la syntaxe est plus simple que SASS, mais les possibilités aussi un peu plus limitées.

Gulp

Gulp est un utilitaire en ligne de commande pour gérer des tâches redondantes que vous auriez faites autrefois une par une, et cela automatiquement. Par exemple : minifier et compresser les fichiers JS, CSS, images, etc. Nous étions partis en début de projet sur Grunt, le premier à avoir proposé ce genre d'automatisation, puis nous sommes passés sur Gulp, dont la communauté a rapidement rattrapé le nombre de plugins de Grunt. La grosse différence entre ces deux acteurs est que Gulp travaille sur des flux alors que Grunt travaille sur des fichiers (plus d'info). Nous avons donc moins d'I/O sur le système de fichiers et moins de nettoyage à faire en fin de tâche.

La méthode

Maintenant, Gulp et LESS sont très pratiques dans une utilisation directe pour une intégration avec un framework tel que Symfony, mais dans le cas de Drupal (CMF) qui ne suit pas la méthode MVC, on se heurte vite à certaines limitations.

Le Markup HTML

Bootstrap requiert un markup HTML assez simple mais loin de celui proposé de base par Drupal. Pour palier à cela, nous avons utilisé le thème de base Drupal Bootstrap et créé un sous-thème pour nos personnalisations. Pour le contenu, comme d'habitude : pas de Views en front-end, et des templates personnalisés pour chaque liste; c'est plus long, mais nous avons le contrôle total sur notre markup.

Le CSS et JS

Dans Drupal, chaque module apporte son lot de ressources CSS et JS, donnant une liste importante de fichiers inclus dans le <head> (si l'aggrégation n'est pas activée). Prenons l'exemple de www.nasa.gov : 43 fichiers CSS (dont le framework Omega) et 30 fichiers JS. De notre côté, le thème front-end n'est utilisé que pour le front-end, et nous avons une politique drastique sur les modules contrib : si on peut le faire plus simplement en recopiant du code, on n'inclut pas le module. Il nous reste cependant quelques fichiers CSS ou JS qui traînent venant de modules contrib mal codés qui incluent leurs fichiers sur chaque page par exemple.

Le CSS

Pour remédier à cela côté CSS, voici notre 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]);
      }
    }

  }
}

Pour expliquer simplement, un sous-thème bootstrap définit son CSS dans un seul fichier LESS, que nous transformons en style.min.css avec Gulp. En production, nous n'avons besoin que de ce fichier et de quelques autres, whitelistés.

Le JavaScript

Côté JavaScript ça se complique : Bootstrap a plusieurs fichiers JS à inclure, nous avons nous-même des modules custom ainsi que des modules contrib et core dont nous ne pouvons nous passer (jQuery Update, le framework Ajax de Drupal, les JS de la form API, …). Pour se sortir de cette situation, il nous faut également une whitelist, mais difficile de savoir à l'avance quels seront les fichiers inclus. Il va donc falloir la construire dynamiquement pour être lue par Gulp.

/**
 * Implements hook_js_alter().
 */
function mon_theme_js_alter(&$js) {
  $theme_path = drupal_get_path('theme', 'mon_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");
    }
  }
}

Et dans notre mon_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'

Grâce à ce bout de code, notre fichier map.json est généré et contient la liste des scripts inclus par les modules. Il peut être versionné et modifié manuellement pour exclure certain fichiers JavaScript inutiles en front.

{
    "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\/mon_profile\/themes\/mon_theme\/bootstrap\/js\/button.js": true,
    "profiles\/mon_profile\/themes\/mon_theme\/bootstrap\/js\/carousel.js": true,
    "profiles\/mon_profile\/themes\/mon_theme\/bootstrap\/js\/modal.js": true,
    "profiles\/mon_profile\/themes\/mon_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
}

Le gulpfile

Maintenant que l'on a toutes nos ressources prêtes à être traitées par Gulp, on peut définir les tâches dans un fichier gulpfile.js à la racine du sous-thème :

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');

// Concaténation et minification JS, en lisant la 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/'));
});

// Vérification de la syntaxe JS
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 en 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/'));
});

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

// Sprite et minification SVG
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 des bonnes pratiques Web

Fiou ! Ça en fait des trucs à faire pour un bon workflow front-end... Mais voyons les avantages :

  • pas de modules contrib (hormis le thème Bootstrap) pour lire des fichiers LESS ou compresser des images : ce ne devrait pas être le travail de Drupal
  • 2 fichiers, CSS et JS, minifiés et compressés avec les meilleures techniques actuelles
  • donc 2 requêtes HTTP, mises en cache par le navigateur bien entendu
  • un code et une structure de dossiers clairs et respectant les standards
  • la joie de l'intégrateur

Annexe : iPad et le Retina

Oh, j'oubliais, vous savez ce truc du Retina où les images doivent avoir une résolution plus importante car plus de détails dans les images... Et bien sachez qu'il n'y a pas (encore) de remède miracle, les capacités du navigateur sont détectées en JS (ou avec un parsing du User-Agent via Varnish, mais hors-scope). Tant que la balise <picture> n'est pas un standard, on a donc des scripts qui détectent les capacités après que l'image originale soit téléchargée : on a donc un premier téléchargement de l'image basse résolution puis un second pour la haute résolution. Nous avons donc opté pour le Retina-only, puisque les navigateurs qui ne supportent pas le Retina savent quand même diviser des valeurs par 2 d'une part, et d'autre part le parc des non-Retina décroit de plus en plus. Moins de bande passante consommée pour le serveur, et aussi moins de prise de tête.

Charge au rédacteur de renseigner des images de haute résolution et pour implémenter ce principe côté Drupal, rien de plus simple :

/**
 * Overrides theme_image_style().
 *
 * @param $vars
 * @return string
 */
function mon_theme_image_style($vars) {
  // Determine the dimensions of the styled image.
  $dimensions = array(
    'width'  => $vars['width'],
    'height' => $vars['height'],
  );

  image_style_transform_dimensions($vars['style_name'], $dimensions);

  // Retina trick
  $vars['width'] = $dimensions['width'] / 2;
  $vars['height'] = $dimensions['height'] / 2;

  // Determine the URL for the styled image.
  $vars['path'] = image_style_url($vars['style_name'], $vars['path']);
  return theme('image', $vars);
}

Les attributs width et height des images sont ainsi divisés par 2.

Contenus corrélés

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
Sortie de Drupal 9 : préparez-vous ! Sortie de Drupal 9 : préparez-vous ! 28/05/2020

Dans quelques jours, le 3 juin 2020, aura lieu la sortie de Drupal 9 en version stable. À quels ...

Résolution de problèmes Drupal : construction de site (2/4) Résolution de problèmes Drupal : construction de site (2/4) 09/08/2013

Dans cette série d'articles, nous tentons de vous aider à vous sortir seuls de situations ...

Gérer sa newsletter avec Drupal Gérer sa newsletter avec Drupal 10/02/2014

Drupal offre plusieurs plusieurs possibilités pour mettre en place une newsletter.

Résolution de problèmes Drupal : Installation (1/4) Résolution de problèmes Drupal : Installation (1/4) 02/08/2013

Dans cette série d'articles, nous tentons de vous aider à vous sortir seuls de situations ...

Drupal & SEO : améliorer le référencement naturel de votre site Drupal & SEO : améliorer le référencement naturel de votre site 20/06/2016

Une vision subjective et argumentée des modules à utiliser pour améliorer le référencement ...