HTML & CSS
Article

Building a Static Site Generator Grunt Plugin

By Matthew Daly

You’ve probably heard of static site generators like Jekyll and Wintersmith before, and you may even have used them. But you may be surprised that it isn’t overly difficult to write your own static site generator.

In this tutorial, I’ll show you how to build your own Grunt plugin that will generate a static site for you from templates and Markdown files. You can then use it in conjunction with whatever other Grunt plugins you wish to create your static site.

Why use Grunt?

You may ask, Why use Grunt for this?

  • If nothing else, this will be a good way to learn how to create your own Grunt tasks.
  • It provides access to Grunt’s API, which simplifies a lot of tasks.
  • Building this as a Grunt plugin provides a lot of flexibility — you can use it with other Grunt plugins to get exactly the workflow you want. For instance, you can choose whatever CSS preprocessor you want, or you can deploy via Rsync or to Github Pages by changing the other plugins you use and amending the configuration. Our plugin will only need to take the Markdown files and templates and generate the HTML.
  • You can easily add additional functionality as plugins — I use an existing Grunt plugin to generate my sitemap, for instance.
  • You can edit this to work with different templating systems. For example, I’ll be using Handlebars as my templating system, but it would be trivial to use Jade instead.

Setting Things Up

Our first step is to install everything we need to create our plugin skeleton. I’m assuming you already have Git, Node.js and grunt-cli installed. First, we need to install grunt-init:

npm install -g grunt-init

Next, install the gruntplugin template:

git clone git://github.com/gruntjs/grunt-init-gruntplugin.git ~/.grunt-init/gruntplugin

Now, create a folder for your plugin, which I’m calling grunt-mini-static-blog. Navigate to that folder and run the following command:

grunt-init gruntplugin

You’ll be asked a few questions about your plugin, which will be used to generate your package.json file. Don’t worry if you don’t know what to answer yet, just go with the defaults; you can update the file later. This command will generate a boilerplate for your plugin.

Next, install your dependencies:

npm install

You’ll also need a few additional Node modules to do some of the heavy lifting for you:

npm install handlebars highlight.js meta-marked moment rss lodash --save-dev

Generating the posts

Our first task is to generate the individual blog posts. First, let’s set up the default configuration. Open up Gruntfile.js and amend the configuration for mini_static_blog:

// Configuration to be run (and then tested).
mini_static_blog: {
  default: {
    options: {
      data: {
        author: "My Name",
        url: "http://www.example.com",
        disqus: "",
        title: 'My blog',
        description: 'A blog'
      },
      template: {
        post: 'templates/post.hbs',
        page: 'templates/page.hbs',
        index: 'templates/index.hbs',
        header: 'templates/partials/header.hbs',
        footer: 'templates/partials/footer.hbs',
        notfound: 'templates/404.hbs'
      },
      src: {
        posts: 'content/posts/',
        pages: 'content/pages/'
      },
      www: {
        dest: 'build'
      }
    }
  }
}

Here we’re defining default values for the variables we’ll be passing through to our plugin. The data object defines miscellaneous data we’ll be passing through, while the template object defines the various templates we’ll be using to assemble our static site. The src object defines where the plugin should look for the actual content, while the www object defines where the output should be saved.

These are just default values for our plugin — when using it in production, you’d override these in the project’s Gruntfile, and would use your own custom templates. You’ll also probably want to remove the nodeunit task and its configuration, as well as the entire test folder.

Note that the value of disqus is blank by default, meaning comments are off. If the user wants to use Disqus, they can specify a username in the appropriate field. If you would prefer to use another comment system, such as Facebook comments, it should be straightforward to implement that instead.

We’ll also create some basic templates so we can see it in action:

templates/partials/header.hbs

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0">
        <meta name="description" content="{{ data.description }}">
        <link rel="alternate" type="application/rss+xml" title="{{data.title}} - feed" href="/atom.xml" />
        <title>{{#if meta.title}}{{meta.title}} - {{/if}}{{data.title}}</title>
    </head>
    <body>

        <header>
            <h1><a href="/">{{data.title}}</a></h1>
            <h2>{{ data.description }}</h2>
        </header>

templates/partials/footer.hbs

<footer>
            <p>Copyright &amp;copy; {{ data.author }} {{ year }}.</p>
        </footer>
    </body>
</html>

templates/404.hbs

{{> header }}

<div class="container">
    <h1>Whoops, that page doesn't seem to exist</h1>
    <p>You might want to go back to <a href="/">the home page</a></p>
</div>
{{> footer }}

templates.index.hbs

{{> header }}

{{#each posts}}
    <article>
        <p>{{ this.meta.formattedDate }}</p>
        <h1><a href="{{ this.path }}">{{this.meta.title}}</a></h1>
        {{{ this.post.content }}}
    </article>
{{/each}}
{{#if prevChunk}}
    <a href="/posts/{{ prevChunk }}/">Newer</a>
{{/if}}

{{#if nextChunk}}
    <a href="/posts/{{ nextChunk }}/">Older</a>
{{/if}}
{{> footer }}

templates/page.hbs

{{> header }}

    <article class="post">
        <h1>{{meta.title}}</h1>
        {{{ post.content }}}
    </article>

{{> footer }}

templates/post.hbs

{{> header }}

    <article class="post">
        <p class="date">{{ this.meta.formattedDate }}</p>
        <h1>{{meta.title}}</h1>
        {{{ post.content }}}
      <section class="comments">
          {{#if data.disqus }}
          <div id="disqus_thread"></div>
          <script type="text/javascript">
            window.disqus_identifier="";
            window.disqus_url="{{ data.url }}{{ path }}/";
            window.disqus_title="{{meta.title}}";
          </script>
          <script type="text/javascript" src="http://disqus.com/forums/{{ data.disqus }}/embed.js"></script>
          <noscript><a href="http://{{ data.disqus }}.disqus.com/?url=ref">View the discussion thread.</a></noscript>
        {{/if}}
      </section>
    </article>

    {{#if next}}
    <a href="{{ next.path }}">{{next.title}}</a>
    {{/if}}
    {{#if prev}}
    <a href="{{ prev.path }}">{{prev.title}}</a>
    {{/if}}

{{> footer }}

With those in place, we can start work on the plugin proper. The generated boilerplate will include a folder called tasks, and there will be a file in here called mini_static_blog.js. Find the section that begins with grunt.registerMultiTask — all of our code will need to go inside the function body. Add this at the top:

// Import external libraries
var Handlebars = require('handlebars'),
  Moment = require('moment'),
  RSS = require('rss'),
  hljs = require('highlight.js'),
  MarkedMetadata = require('meta-marked'),
  _ = require('lodash'),
  parseUrl = require('url');

// Declare variables
var output, path;

// Import options
var options = this.options({
  year: new Date().getFullYear(),
  size: 5
});
options.domain = parseUrl.parse(options.data.url).hostname;

Here we import the external libraries we’ll be using and declare a couple more variables. We also fetch the year and the size of each page, and get the domain name from the hostname defined in the Gruntfile.

Next, we register the header and footer templates as partials so they can be used by the other templates:

// Register partials
Handlebars.registerPartial({
  header: grunt.file.read(options.template.header),
  footer: grunt.file.read(options.template.footer)
});

Note the use of grunt.file.read to actually fetch the template file contents.

We then configure our Markdown parser to support GitHub-flavored Markdown and syntax highlighting with Highlight.js (please note that you’ll need to include the CSS for Highlight.js to actually see it highlighted).

// Get languages
var langs = hljs.listLanguages();

// Get Marked Metadata
MarkedMetadata.setOptions({
  gfm: true,
  tables: true,
  smartLists: true,
  smartypants: true,
  langPrefix: 'hljs lang-',
  highlight: function (code, lang) {
    if (typeof lang !== "undefined" &amp;&amp; langs.indexOf(lang) > 0) {
      return hljs.highlight(lang, code).value;
    } else {
      return hljs.highlightAuto(code).value;
    }
  }
});

Note that we first get a list of the available languages, and then in the highlight function, we check to see if the language has been detected and if so, explicitly choose that language.

We then fetch the Markdown files containing the page and post source:

// Get matching files
var posts = grunt.file.expand(options.src.posts + '*.md', options.src.posts + '*.markdown');
var pages = grunt.file.expand(options.src.pages + '*.md', options.src.pages + '*.markdown');

Note here that we’re using the Grunt file API again — here we’re using expand to get all the files in the posts and pages directories.

We also compile our Handlebars templates:

// Get Handlebars templates
var postTemplate = Handlebars.compile(grunt.file.read(options.template.post));
var pageTemplate = Handlebars.compile(grunt.file.read(options.template.page));
var indexTemplate = Handlebars.compile(grunt.file.read(options.template.index));
var notFoundTemplate = Handlebars.compile(grunt.file.read(options.template.notfound));

As before, we use grunt.file.read to fetch the contents of the template files and compile them with Handlebars.

Our next step is to generate the posts:

// Generate posts
var post_items = [];
posts.forEach(function (file) {
  // Convert it to Markdown
  var content = grunt.file.read(file);
  var md = new MarkedMetadata(content);
  var mdcontent = md.html;
  var meta = md.meta;

  // Get path
  var permalink = '/blog/' + (file.replace(options.src.posts, '').replace(/(\d{4})-(\d{2})-(\d{2})-/, '$1/$2/$3/').replace('.markdown', '').replace('.md', ''));
  var path = options.www.dest + permalink;

  // Render the Handlebars template with the content
  var data = {
    year: options.year,
    data: options.data,
    domain: options.domain,
    path: permalink + '/',
    meta: {
      title: meta.title.replace(/"/g, ''),
      date: meta.date,
      formattedDate: new Moment(new Date(meta.date)).format('Do MMMM YYYY h:mm a'),
      categories: meta.categories
    },
    post: {
      content: mdcontent,
      rawcontent: content
    }
  };
  post_items.push(data);
});

// Sort posts
post_items = _.sortBy(post_items, function (item) {
  return item.meta.date;
});

// Get recent posts
var recent_posts = post_items.slice(Math.max(post_items.length - 5, 1)).reverse();

// Output them
post_items.forEach(function (data, index, list) {
  // Get next and previous
  if (index < (list.length - 1)) {
    data.next = {
      title: list[index + 1].meta.title,
      path: list[index + 1].path
    };
  }
  if (index > 0) {
    data.prev = {
      title: list[index - 1].meta.title,
      path: list[index - 1].path
    };
  }

  // Get recent posts
  data.recent_posts = recent_posts;

  // Render template
  var output = postTemplate(data);

  // Write post to destination
  grunt.file.mkdir(options.www.dest + data.path);
  grunt.file.write(options.www.dest + data.path + '/index.html', output);

We loop through the posts, read the contents of each, and extract the content and metadata. We then define a file path for each one, based on its filename. Each post should be named something like 2015-04-06-my-post.md, and the path of the file generated will be something like /blog/2015/04/05/my-post/. You can change the URLs if you wish by amending how the value of the permalink variable is determined.

Next, we store the data in an object and add it to the post_items array. Then we sort them by date, and fetch the five most recent. We then loop through the posts again and get the next and previous post for each one. Finally, we create a directory for each post, render the template, and write the content to an index.html file inside it. Note that this means we can refer to each file by its directory only, making for nice clean URLs.

Let’s test it out. Save the following to content/posts/2015-04-12-my-post.md:

---
title: "My blog post"
date: 2015-02-15 18:11:22 +0000
---

This is my blog post.

If you run grunt, you should find a brand new HTML file at build/blog/2015/04/12/my-post/index.html.

Generating the pages

Generating the pages is slightly simpler as we don’t have to worry about the dates:

// Generate pages
pages.forEach(function (file) {
  // Convert it to Markdown
  var content = grunt.file.read(file);
  var md = new MarkedMetadata(content);
  var mdcontent = md.html;
  var meta = md.meta;
  var permalink = '/' + (file.replace(options.src.pages, '').replace('.markdown', '').replace('.md', ''));
  var path = options.www.dest + permalink;

  // Render the Handlebars template with the content
  var data = {
    year: options.year,
    data: options.data,
    domain: options.domain,
    path: path,
    meta: {
      title: meta.title.replace(/"/g, ''),
      date: meta.date
    },
    post: {
      content: mdcontent,
      rawcontent: content
    },
    recent_posts: recent_posts
  };
  var output = pageTemplate(data);

  // Write page to destination
  grunt.file.mkdir(path);
  grunt.file.write(path + '/index.html', output);
});

The basic principle is the same — we loop through the Markdown files in the pages folder, and render each one with the appropriate template. If you save the following to content/pages/about.md:

---
title: "About me"
---

All about me

You should then find that running Grunt again will generate a new file at build/about/index.html.

Implementing an RSS feed and a 404 page

Our next task is to generate an RSS feed and a 404 page. We can create the feed using the RSS module we installed earlier:

// Generate RSS feed
var feed = new RSS({
    title: options.data.title,
    description: options.data.description,
    url: options.data.url
});

// Get the posts
for (var post in post_items.reverse().slice(0, 20)) {
  // Add to feed
  feed.item({
    title: post_items[post].meta.title,
    description: post_items[post].post.content,
    url: options.data.url + post_items[post].path,
    date: post_items[post].meta.date
  });
}

// Write the content to the file
path = options.www.dest + '/atom.xml';
grunt.file.write(path, feed.xml({indent: true}));

// Create 404 page
var newObj = {
  data: options.data,
  year: options.year,
  domain: options.domain
};
output = notFoundTemplate(newObj);
path = options.www.dest;
grunt.file.mkdir(path);
grunt.file.write(path + '/404.html', output);

We first define our feed’s title, URL and description from the data passed through from the Gruntfile. We then get the 20 most recent posts, loop through them, and add each as an item, before saving the result in atom.xml.

To generate the 404 page, we pass through a few of our parameters to the template and save the output in 404.html.

Creating the paginated index pages

We also want to create a paginated list of posts:

// Generate index
// First, break it into chunks
var postChunks = [];
while (post_items.length > 0) {
  postChunks.push(post_items.splice(0, options.size));
}

// Then, loop through each chunk and write the content to the file
for (var chunk in postChunks) {
  var data = {
    year: options.year,
    data: options.data,
    domain: options.domain,
    posts: []
  };

  // Get the posts
  for (post in postChunks[chunk]) {
    data.posts.push(postChunks[chunk][post]);
  }

  // Generate content
  if (Number(chunk) + 1 < postChunks.length) {
    data.nextChunk = Number(chunk) + 2;
  }
  if (Number(chunk) + 1 > 1) {
    data.prevChunk = Number(chunk);
  }
  data.recent_posts = recent_posts;
  output = indexTemplate(data);

  // If this is the first page, also write it as the index
  if (chunk === "0") {
    grunt.file.write(options.www.dest + '/index.html', output);
  }

  // Write the content to the file
  path = options.www.dest + '/posts/' + (Number(chunk) + 1);
  grunt.file.mkdir(path);
  grunt.file.write(path + '/index.html', output);
}

First, we break our list of posts into chunks of 5. We then generate the HTML for each chunk, and write it to a file. The path format I’ve chosen means that a typical path will be something like /posts/1/index.html. We also save the first page as the home page of the site.

Ideas for further development

In practice, this plugin will only be one part of your tool chain for generating and deploying your blog. You’ll need to combine it with other Grunt plugins and override the templates to create a useful method of creating and deploying a working static blog. But as long as you’re willing to spend the time configuring and installing the other Grunt plugins you need, this can be a very powerful and flexible method of maintaining a blog. You can find the source here.

There’s plenty of scope for developing this further. Some ideas you might want to explore include:

  • Implementing search with Lunr.js
  • Implementing categories
  • Changing the templating or comment system

You might want to check out grunt-blogbuilder, which is a more complete version of this plugin, for ideas on how to implement these.

I hope this tutorial has given you some idea what’s involved in building a static site generator by leveraging Grunt to do some of the work, and I look forward to seeing what you come up with.

Free Guide:

7 Habits of Successful CTOs

"What makes a great CTO?" Engineering skills? Business savvy? An innate tendency to channel a mythical creature (ahem, unicorn)? All of the above? Discover the top traits of the most successful CTOs in this free guide.

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in Front-end, once a week, for free.