Key Takeaways
- Utilizing Grunt to build a static site generator allows for extensive customization and integration with various plugins, enhancing workflow flexibility and efficiency.
- The tutorial demonstrates the step-by-step process of creating a Grunt plugin, from setting up the development environment to writing the necessary code for generating posts and pages.
- Key functionalities of the static site generator include generating individual blog posts, creating paginated index pages, and implementing additional features like RSS feeds and custom 404 pages.
- The plugin architecture supports various templating systems and allows for easy addition of features such as sitemaps, commenting systems, or different deployment methods.
- For developers looking to extend their static site generator, possibilities include integrating search functionality, categorization, and alternative templating or commenting systems, leveraging the modular nature of Grunt plugins.
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 installgrunt-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 upGruntfile.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 &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" && 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
Frequently Asked Questions (FAQs) about Building a Static Site Generator with Grunt Plugin
What is the main purpose of using a Grunt plugin in building a static site generator?
Grunt is a JavaScript task runner that automates repetitive tasks like minification, compilation, unit testing, and linting. It’s used in building a static site generator to streamline and automate the process. This allows developers to focus more on the actual development of the site rather than the repetitive tasks. Grunt plugins provide additional functionalities that can further enhance the development process.
How does Grunt compare to other task runners like Gulp or Webpack?
Grunt, Gulp, and Webpack are all popular task runners, but they each have their unique features. Grunt is known for its wide range of plugins and configuration-based approach. Gulp, on the other hand, uses a code-based approach and is known for its speed. Webpack is more of a module bundler but can function as a task runner as well. The choice between these tools depends on the specific needs of your project.
Can I use Grunt for large-scale projects?
Yes, Grunt is suitable for both small and large-scale projects. Its flexibility and wide range of plugins make it a versatile tool that can handle the demands of any project size. However, for very large projects, you might need to optimize your Gruntfile to ensure efficient task running.
How can I optimize my Grunt tasks for better performance?
There are several ways to optimize your Grunt tasks. One way is to use the grunt-contrib-watch
plugin to only run tasks when necessary. Another way is to use the grunt-concurrent
plugin to run tasks concurrently. You can also split your Gruntfile into multiple files for easier management.
What are some common issues I might encounter when using Grunt and how can I solve them?
Some common issues include slow task running, difficulty in managing complex Gruntfiles, and compatibility issues with certain plugins. These can be solved by optimizing your tasks, splitting your Gruntfile, and ensuring that your plugins are up-to-date, respectively.
How can I contribute to the Grunt community?
You can contribute to the Grunt community by creating and sharing your own plugins, contributing to the Grunt documentation, or helping to solve issues in the Grunt GitHub repository.
Can I use Grunt with other JavaScript frameworks like Angular or React?
Yes, Grunt can be used with any JavaScript framework. There are specific plugins available for frameworks like Angular and React that can help streamline the development process.
How can I debug my Grunt tasks?
You can debug your Grunt tasks by using the --debug
flag when running your tasks. This will provide detailed information about what the task is doing and can help identify any issues.
Can I use Grunt with other programming languages?
While Grunt is a JavaScript task runner, it can be used with other programming languages through the use of plugins. For example, there are plugins available for compiling Sass to CSS, linting Python code, and more.
How can I learn more about Grunt and its plugins?
The official Grunt website is a great resource for learning more about Grunt and its plugins. It provides detailed documentation, tutorials, and a list of all available plugins. You can also find many tutorials and articles online that provide in-depth information about specific aspects of Grunt.
Matthew is a web and mobile app developer for Astutech. He works mainly with Python, JavaScript and PHP, and particularly enjoys working with Django and Node.js. He also blogs at matthewdaly.co.uk.