Building a simple static site generator using Node.js

A colleague of mine was recently looking into setting up a blog and asked me for recommendations. After doing a bit of research into static site generators and blog engines, I decided that Hugo would be a great choice. However, my colleague also had a couple of requirements like wanting a custom url for blogs and a custom css theme. While all of this is possible with Hugo, I decided to skip the learning curve and see if I could make a really simple static site generator given that she already had the html ready to go and that she had no problems writing her blog posts in html.

The static site generator script turned out be 100 lines and hardly any magic. The code and a sample blog can be found here. Note that Gitlab provides free hosting for static pages and also comes with a CI/CD feature which allows you to compile your pages before deployment.

The following is a tutorial on setting up your own static site generator using Node.js >= 8.11.x. Let us first setup the project:

npm init
npm i --save-exact bluebird chokidar fs-extra mustache
mkdir src
mkdir public

The first order of business is to ask the question - why exactly does one need a static site generator? The answer is in fact that you don't really. If all you are doing is running a low traffic blog, you could very simply create your html pages by hand and publish them. In fact this is how most web publishing was done for the longest time before the rise of server-side programming. However, once you have a few pages and some content, it can get tedious to make changes to sections that are common across all pages, like the footer for example. Therefore, it would be ideal if we could have some sort of a simple templating engine that could allow splitting of common content and inserting it where required.

Before we start looking into templating engines, let us first setup our website. For the moment, we will create 2 folders under the project root, src (where our current website lives) and public (which will contain our generated website). For our initial attempt we will simply copy the contents of src over to public. Create the following index.js under your project root:

const Promise = require("bluebird");
const fse = require("fs-extra"); Promise.resolve().then(async () => { await main();
}); const main = async() => { await generateSite();
}; const generateSite = async() => { await copyAssets();
}; const copyAssets = async() => { await fse.emptyDir("public"); await fse.copy("src", "public");

Run this script via node index.js and bask in the glory that is programming.

Congratulations! You are a backend developer now.

As a second step we will add a file watcher so that any changes within our src folder will re-generate the website. Since this will be a blog with 500-1000 files in total (assuming 100 blog entries), we can afford to regenerate the entire website on any change:

const chokidar = require("chokidar"); const main = async() => { await generateSite(); watchFiles();
}; const watchFiles = () => { const watcher = [ "src" ], { ignored: /(^|[\/\\])\../, // chokidar will watch folders recursively ignoreInitial: false, persistent: true } ); watcher.on("change", async path => { console.log("changed " + path + ", recompiling"); await generateSite(); }); // catch ctrl+c event and exit normally process.on("SIGINT", function() { watcher.close(); });

The above code makes it clear now why the initial version had a function called generateSite. We can start our static site generator now via node index.js and if we now edit any file under src, the changes should be reflected under public. At this point, we will also add an environment variable to differentiate between development and production modes. In development mode we will watch for changes and regenerate the website and in production mode we simply regenerate:

const env = process.env.NODE_ENV || "dev"; const main = async () => { console.log("Running app in " + env); await generateSite(); if (env === "dev") { watchFiles(); }

We can run the above via: export NODE_ENV=prod || set NODE_ENV=prod && node index.js. Note that watching the source directory for changes and recompiling is not entirely necessary, you could always skip this step and simply run the script everytime you make changes but programming is all about avoiding repetitive tasks.

Interestingly, we are almost done! All that is really required now is to come back to the original question of creating a static site generator in the first place - templating. We will use Mustache.js for templating mostly because it is the simplest and our needs are not too complicated. Let us create a folder src/partials which will hold our common sections. We then modify our website structure slightly so that all our pages now live under src/pages. Now all that is remaining to do is to load all partials, load the pages and render them using Mustache:

const fs = require("fs"); const generateSite = async () => { await copyAssets(); await buildContent();
}; const buildContent = async () => { const pages = await compilePages(); await writePages(pages);
}; const compilePages = async () => { const partials = await loadPartials(); const result = {}; const pagesDir = path.join("src", "pages"); const fileNames = await fs.readdirAsync(pagesDir); for (const fileName of fileNames) { const name = path.parse(fileName).name; const fileContent = await fs.readFileAsync(path.join(pagesDir, fileName)); result[name] = Mustache.render(fileContent.toString(), {}, partials); } return result;
}; const loadPartials = async () => { const result = {}; const partialsDir = path.join("src", "partials"); const fileNames = await fs.readdirAsync(partialsDir); for (const fileName of fileNames) { const name = path.parse(fileName).name; const content = await fs.readFileAsync(path.join(partialsDir, fileName)); result[name] = content.toString(); } return result;
}; const writePages = async pages => { for (const page of Object.keys(pages)) { await fs.writeFileAsync(path.join("public", page + ".html"), pages[page]); }

To see the final version check out the Software Dawg project. There are a few minor differences from the tutorial here:

  • The script itself is under the src folder.
  • It is slightly over 100 lines, 130 at the time of writing, mostly due to clean code practices, i.e. constants instead of strings for folder paths, etc.
  • Instead of copying over the entire src folder, the script only copies necessary assets such as css, images, etc.
  • The project also uses node-sass to compile the template css. This dependency is however not required as the compiled css is also checked into git.

As a bonus, you could also install the browser-sync package globally and run that via the provided command npm run live-reload so that your browser automatically refreshes on any change. Note that this does not work out that well on windows unfortunately, due to the fact that we are regenerating the entire site on any change.

Gitlab provides static website hosting for free and all that is needed is a .gitlab-ci.yml configuration file. What is really incredible is that you can define the build process which means that in our case, we can generate the website before deployment! See here for more details on this feature.

This concludes the tutorial and my colleague is extremely satisfied with her current solution as it is ultra flexible and allows her to customise it to her liking. She is looking into creating custom paths for blog posts at the moment :)

HackerNews submission / discussion

Back to the article list.