With the recent release of Jekyll 4.0, it was a good time to revisit how I manage my blog’s assets. The web and its tools continue to move in a break-neck speed and new versions like to break things so finding up to date best practices and code samples was a bit difficult. This is one reason why I’m writing this post.

My previous pipeline was built on Ruby’s Rake, so the tasks were defined in a Rakefile.

  1. I had previously used Bootstrap as the framework for styling so one task compiled the LESS files.
  2. Jekyll’s own build process would resize and transform images using bespoke plugins1.
  3. Another task ran imageoptim and pngcrush on the destination (_site/images) to optimize the transformations.
  4. Finally, one task deployed the built site using rsync.

The tools were varied and scattered all around the place. I wasn’t looking forward to update my plugins to take advantage of the new version of Jekyll2. However, there’s no real disadvantage in using Rake if you’re using Jekyll - both are Ruby so there is not that much new to learn.

So, one option was to build everything in Jekyll and continue use Rake tasks. I had already started to move to use SCSS, which was supported in Jekyll so this was at least in the realm of possibility.

However, why maintain your own tools when there are much better options available3.

Enter Gulp

The Gulp logo

The other option was to move fully to use a Node based build flow, of which there are many4. For no particular reason I went with Gulp. In fact, I first made sure that Gulp worked with the tools I wanted to use: Sass5, PostCSS and Imagemin.

The general idea of the architecture I was bulding was that

  1. Tasks are defined in Gulp’s gulpfile.js
  2. Gulp compiles and minifes Sass to _site/css
  3. Gulp resizes and optimizes from _images to _site/images
  4. Jekyll builds only the HTML content
  5. Other Rake tasks are ported to a Gulp tasks (like rsync)

First thing to note that normally Jekyll destroys the destination folder before building, so here Jekyll’s configuration needs to be updated so that it leaves _site/images and _site/css untouched in _config.yml.

You might also want to exclude build related files from your site.

# _config.yml
keep_files:
  - images
  - css
exclude:
#  - Rakefile
  - gulpfile.js
  - package.json
  - package-lock.json

The whole Gulpfile

Here is the full Gulpfile I’m using to build this site in its Gulp 4.0 style syntax (exported normal functions instead of task()s).

// gulpfile.js
'use strict';

// Required packages grouped roughly by task
// Remember to add below packages using npm:
// npm install <package-name> --save-dev
const gulp = require('gulp');
const cp = require('child_process');

const sass = require('gulp-sass');
const postcss = require('gulp-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');

const imagemin = require('gulp-imagemin');
const newer = require('gulp-newer');
const pngquant = require('imagemin-pngquant');
const mozjpeg = require ('imagemin-mozjpeg');

const flatMap = require('flat-map').default
const scaleImages = require('gulp-scale-images')
const path = require('path')

const rsync = require('gulp-rsync');

// Sass compilation. Additionally builds sourcemaps and runs
// the stylesheet through CSS Nano and Autoprefixer as PostCSS
// plug-ins
sass.compiler = require('node-sass');

function buildSass(cb) {
  return gulp.src('./_sass/**/*.scss')
    .pipe(sass({
      outputStyle: "compressed",
      includePaths: ["node_modules"]
    }).on('error', sass.logError))
    .pipe(postcss([
      autoprefixer(),
      cssnano()
    ]))
    .pipe(gulp.dest('./_site/css'));
}

// Task to watch changes to source files and rebuild
function watchSass(cb) {
  gulp.watch('./_sass/**/*.{scss,css}', buildSass);
}

// Image resizing using gulp-scale-images and
// optimization using imagemin with non-default plugins

// This function makes two variants of each source image
const retinaVersions = (file, cb) => {
  const normalVersion = file.clone()
  normalVersion.scale = { maxWidth: 576, maxHeight: 500, fit: 'inside' }
  const retinaVersion = file.clone()
  retinaVersion.scale = { maxWidth: 576*2, maxHeight: 500*2, suffix: '@2x', fit: 'inside' }

  cb(null, [normalVersion, retinaVersion])
}

// By default, gulp-scale-images names files with dimensions
const imageFileName = (output, scale, cb) => {
  const fileName = [
    path.basename(output.path, output.extname),
    scale.suffix || "",
    output.extname
  ].join('')

  cb(null, fileName)
}

function minimizeImages(cb) {
  return gulp.src('./_images/**/*.{jpg,jpeg,png}')
    .pipe(newer('./_site/images/'))
    .pipe(flatMap(retinaVersions))
    .pipe(scaleImages(imageFileName))
    .pipe(imagemin([mozjpeg(), pngquant()]))
    .pipe(gulp.dest('./_site/images/'));
}

// Task to build Jekyll
// Note that here Bundler is used to manage the Ruby gems
// If you are not using Bundler, just call jekyll build
function buildJekyll(cb) {
  cp.exec('bundle exec jekyll build', function(err, stdout, stderr) {
    console.log(stdout)
    console.log(stderr)
    cb(err)
  });
}

// Task to clean _site/ and other Jekyll caches
function cleanJekyll(cb) {
  cp.exec('bundle exec jekyll clean', function(err, stdout, stderr) {
    console.log(stdout)
    console.log(stderr)
    cb(err)
  });
}

// Task to rsync files to the web server
function deploy(cb) {
  return gulp.src('_site/**')
  .pipe(rsync({
    root: '_site',
    username: 'USERNAME',
    hostname: 'HOSTNAME.TLD',
    destination: 'destination/to/blog/',
    compress: true,
    incremental: true,
  }));
}

// Task to serve the website locally
function serveJekyll(cb) {
  cp.exec('bundle exec jekyll serve', function(err, stdout, stderr) {
    console.log(stdout)
    console.log(stderr)
    cb(err)
  });
}

exports.sass = buildSass
exports.watch = watchSass
exports.jekyll = buildJekyll
exports.serve = serveJekyll
exports.clean = cleanJekyll
exports.imagemin = minimizeImages
exports.deploy = deploy

exports.build = gulp.series(
  gulp.parallel(buildSass, minimizeImages),
  buildJekyll
)

One could use this in development by calling gulp watch and gulp serve (or better yet, make a parallel task of these two). Building for deployment can be achived with gulp build && gulp deploy (or again, by making an additional task that runs these as a series).

As for the file structure, it’s possible to split tasks to different files, and many examples do this. One neat approach to this is to create a folder called gulpfile.js and have a main file index.js and tasks as their own .js files. I don’t think my file is large or complex enough to warrant this. However, the above file will work identically if saved as gulpfile.js/index.js.

Another improvement would be to add variables for sources and destinations.

The other tasks are quite self-explanatory but CSS and image handling have a bit more complex flows that I will explain next.

CSS management

In my case, the source files are .css and .scss files in /_sass folder6.

function buildSass(cb) {
  return gulp.src('./_sass/**/*.{scss,css}')
    // .pipe(sourcemaps.init()) // Create sourcemaps, this is only useful for development
    .pipe(sass({ // Sass options
      outputStyle: "compressed",
      includePaths: ["node_modules"]
    }).on('error', sass.logError))
    .pipe(postcss([ // PostCSS plugins
      autoprefixer(),
      cssnano()
    ]))
    // .pipe(sourcemaps.write('.'))
    .pipe(gulp.dest('./_site/css'));

Above, I have added an example (commented out) how to add gulp-sourcemaps for development purposes.

In my case, my main stylsheet imports from sanitize.css which I bring in as an npm package.

@import "sanitize.css/sanitize";

For Sass compiler to find this packaged file, I need to tell it to look for it in node_modules using includePaths. For reference, if you are using Jekyll’s built-in Sass compiler, you can achieve similar thing with configuration option:

# _config.yml
sass:
  load_paths:
    - node_modules

The above is irrelevant if you are using gulp-sass to compile your CSS.

Another way to import sanitize.css (or normalize.css) file would be to bring it in using Gulp and as the PostCSS plugin postcss-normalize.

After compilation, the scripts runs the stylesheet through PostCSS plugins. Here through CSS nano for minification7 and Autoprefixer for automatic vendor prefixing of CSS rules8. There are various other PostCSS plugins that you can add to the array, for example the beforementioned postcss-normalize or stylelint.

Another aside is that if you’re using Kramdown and Rouge for syntax highlighting, you can get a .css of Rouge’s themes with a shell command like

bundle exec rougify style github --scope .highlighter-rouge > _sass/rouge-github.css

Note the scope parameter, Kramdown’s default HTML converter adds .highlighter-rouge class which is different from Rouge’s default .highlight.

Image management

function minimizeImages(cb) {
  return gulp.src('./_images/**/*.{jpg,jpeg,png}')
    .pipe(newer('./_site/images/')) // Only consider files that do not exist at destination
    .pipe(flatMap(retinaVersions)) // flatMap is used to create additional variants of iamges
    .pipe(scaleImages(imageFileName)) // optional parameter is used to format output file names
    .pipe(imagemin([mozjpeg(), pngquant()])) // Here imagemin is instructed to use mozjpeg and pngquant instead of default (lossless) algorithms
    .pipe(gulp.dest('./_site/images/'));
}

My images in _images are in various sizes and formats. The above piece of code makes resized versions of the images that fit the layout of my site and optimizes the resulting files with mozjpeg and pngquant. Note that both of these are lossy, imagemin defaults to lossless algorithms.

The resizing and variants are accomplished with gulp-scale-images. To inject variants to the stream, I use flatMap (as instructed in gulp-scale-images documentation). If you only need one version of your images, you can remove the .pipe(flatMap(retinaVersions)) row and the retinaVersions function.

Note that you will in any case need a function that adds the file.scale map for gulp-image-scale to instruct how to process the file. This can be achieved for example using through2 as shown in gulp-scale-images documentation and clarifying issue.9

const through = require('through2')
const scaleImages = require('gulp-scale-images')

const scaleInstructions = (file, _, cb) => {
  file.scale = {
    maxWidth: 400,
    maxHeight: 300,
    format: 'jpeg'
  }
  cb(null, file)
}

gulp.src()
.pipe(through.obj(scaleInstructions))
.pipe(scaleImages())
.pipe(gulp.dest())

Conversely, you can also add further variants for thumbnails and such in the function called by flatMap. Note that you would need to add support in your Jekyll to actually use the retina or thumbnail variant (through a Liquid filter or other plugin). In my case, my Kramdown converter scans for a retina version and adds a srcset to the <img> tag if one is found10.

Conclusion

In the end, what did I actually get from bringing in npm and it’s thousands of packages into my project? Most importantly, I get access to the best practices tools like Sass, PostCSS and Imagemin. I also get a clean separation of duties where I can more easily change from Jekyll to something else if need be.

Everyone’s workflow is a bit different to scratch their own personal itch. The same applies to this. I would recommend to use it as starting point for your own tasks.

It is also possible that by the time you read this, a breaking update has made the syntax or parameters invalid. I recommend checking each plugin’s and tool’s up to date documentation for reference. That is how I ended up with the above, I found outdated snippets from various blogs and attempted to bring them up to date by referencing the source documentation.