How to create a custom Static Site Generator for Blogs in 2023
Introduction
Ever wondered how to set up a blazingly fast Blog for your personal project or company site without having to deal with complex security issues?
If so, you might have come around Static Site Generators. There are countless options out there that all cover specific use cases.
This however is also the issues with out-of-the-box solutions. They were built with a specific use case in mind.
At Codesphere, we are currently ramping up the decoupling of our website and blog from our main monorepo to become more lean in our marketing engineering efforts. We looked at different options and none fit our use case perfectly. Since we have a DIY-mindset here and like to build custom solutions for ourselves, we set out to build a custom static site generator for our blog. Turns out this isn't as hard of a task as it might initially sound.
In this article, I will explain, how we realized this. Feel free to code along or just clone and host the repo yourself for free with only a few clicks.
Let's get to it!
Project Setup
We will be using a fairly basic setup to not bloat our project.
Our static site generator will be running on a Node.js server using Express. The pages will be implemented using EJS and styled using TailwindCSS. The Posts data itself will be provided to our EJS template through a JSON file on our server.
You can also do the styling without Tailwind. You will find the copyStylesheets function in the generatePosts.js file of our repo. If you uncomment it, this will copy the stylesheets from your src to your repo directly. You can then simply uninstall tailwind using npm uninstall.
Static Site Generator
Our generator essentially consists of 3 functions (4 if you do it without Tailwind as described above).
- generateIndexContent: Dynamically renders the index page template using EJS, including featured posts and a list of recent posts.
- generatePostContent: generates individual post pages, utilizing the EJS template for each post's content.
- generateStaticSite: consolidates generateIndexContent and generatePostContent and uses the fs module to write the done sites to the public directory.
// const GhostContentAPI = require('@tryghost/content-api');
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
require('dotenv').config();
const outputDirectory = path.join(__dirname, '../public/posts/');
const indexDirectory = path.join(__dirname, '../public/');
const srcDirectory = path.join(__dirname, '../src/');
const postsData = path.join(__dirname, 'data/', 'data.json');
function generateIndexContent(posts) {
// Use EJS to render the index template with the posts data
const template = fs.readFileSync(path.join(__dirname, 'templates', 'indexTemplate.ejs'), 'utf-8');
return ejs.render(template, {
posts: posts,
featuredPost: posts[0]
}, {
root: path.join(__dirname, 'templates')
});
}
function ensureDirectoryExistence(directory) {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
}
function generatePostContent(post) {
ensureDirectoryExistence(path.join(__dirname, '../public/posts/'));
// Use EJS to render the post template with the post data
return ejs.render(
fs.readFileSync(path.join(__dirname, 'templates', 'postTemplate.ejs'), 'utf-8'),
{
image: post.feature_image,
title: post.title,
content: post.html, // Content
excerpt: post.excerpt
}, {
root: path.join(__dirname, 'templates') //Root directory for views
}
);
}
function saveToFile(filename, content) {
fs.writeFile(filename, content, (err) => {
if (err) {
console.error(`Error saving ${filename}:`, err);
} else {
// console.log(`${filename} generated successfully!`);
}
});
}
async function generateStaticSite() {
const posts = JSON.parse(fs.readFileSync(postsData, 'utf-8'));
// Generate the main index page
const indexContent = generateIndexContent(posts);
saveToFile(path.join(indexDirectory, 'index.html'), indexContent);
// Generate individual post pages
for (const post of posts) {
// console.log(post); // Debugging: print the post object
const postContent = generatePostContent(post);
saveToFile(path.join(outputDirectory, `${post.slug}.html`), postContent);
}
console.log("Generated static Post Pages!");
}
// const stylesheets = ['index.css', 'article.css'];
// async function copyStylesheets() {
// for (const stylesheet of stylesheets) {
// const srcPath = path.join(srcDirectory, './styles', stylesheet);
// const destPath = path.join(indexDirectory, stylesheet);
// fs.copyFile(srcPath, destPath, err => {
// if (err) {
// console.log(`Error copying ${stylesheet}:`, err);
// } else {
// console.log(`${stylesheet} copied successfully!`);
// }
// });
// }
// }
generateStaticSite();
// copyStylesheets();
Server Setup
The server itself is also kept very minimalistic to reduce any overhead. Essentially, it initializes the EJS templating engine and creates dynamic routes based on the files in the posts directory, created by the generateStaticSite function.
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const port = 3000;
const expressLayouts = require('express-ejs-layouts');
app.use(express.json());
app.use(express.static('public'));
app.use('/scripts', express.static(__dirname + '/node_modules/flowbite/dist/'));
//Set Templating Engine
app.use(expressLayouts);
app.set("view engine", "ejs");
// Function to read the generated HTML files
function getHTMLContent(slug) {
const filePath = path.join(__dirname, 'public', 'posts', `${slug}.html`);
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (error) {
console.error(`Error reading HTML file for ${slug}:`, error);
return null;
}
}
// Dynamic route creation
function createDynamicRoutes() {
const pagesDirectory = path.join(__dirname, 'public', 'posts');
fs.readdirSync(pagesDirectory).forEach((file) => {
if (file.endsWith('.html')) {
const slug = file.replace('.html', '');
app.get(`/posts/${slug}`, (req, res) => {
const pageContent = getHTMLContent(slug);
if (pageContent) {
res.send(pageContent);
} else {
res.status(404).send(`${slug} page not found`);
}
});
}
});
}
createDynamicRoutes();
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
EJS templates
With that up and running, the only thing missing are the EJS templates.
EJS, or Embedded JavaScript, is a templating engine that allows us to generate HTML content by embedding JavaScript directly within the template.
The data that can be used in the templates is passed using the .render method, provided by the express-ejs-layouts npm package. In our case this is executed, when the static pages are generated using the generateStaticSite function.
This leaves us with the following templates:
indexTemplate.ejs
<!DOCTYPE html>
<html>
<head>
<title>Codesphere blog</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="./main.css" rel="stylesheet" />
<link href="./index.css" rel="stylesheet" />
<!-- Additional meta tags, styles, scripts... -->
<script>
// Your JavaScript filtering logic...
</script>
</head>
<body>
<%- include("/partials/header.ejs") %>
<section class="w-full bg-dark-bg flex justify-center items-center pb-44 pt-60 -mt-20 h-fit relative">
<% for(let i = 0; i < 1 && i < posts.length; i++) { %>
<% let post = posts[i]; %>
<div class="container w-4/5 h-full flex flex-col align-middle items-center gap-12 mx-auto justify-between z-40 featured-post">
<!-- Image Div -->
<div class="h-auto object-cover flex flex-shrink-0 rounded-md overflow-hidden w-full lg:w-1/3 lg:items-start lg:justify-start">
<img class="h-auto object-cover m-0 w-full" src="<%= post.feature_image %>" alt="<%= post.title %>" />
</div>
<!-- Text Div -->
<div class="flex-shrink flex gap-4 flex-col relative text-center ">
<h2 class="text-white text-5xl font-semibold break-words"><%= post.title %></h2>
<p class="text-gray-500 text-sm"><%= new Date(post.published_at).toLocaleDateString('en-US') %></p>
<p class="text-gray-300 text-md font-300"><%= post.excerpt %></p>
<a class="text-white text-md font-semibold" href="/posts/<%= post.slug %>">Read the full article</a>
</div>
</div>
<% } %>
</section>
<div class="container mx-auto px-8">
<section class="w-full flex justify-start items-start pb-44 pt-60 -mt-20 h-fit relative flex-col">
<h1 class="text-black text-4xl font-semibold break-words">All Articles</h1>
<div class="posts-grid grid grid-cols-3 gap-8 pt-16">
<% for(let i = 0; i < posts.length; i++) { %>
<div class="post-item flex w-full flex-col">
<!-- Image Div -->
<div class="post-image h-auto object-cover flex flex-shrink-0 rounded-md overflow-hidden w-full">
<img class="h-auto object-cover m-0 w-full" src="<%= posts[i].feature_image %>" alt="<%= posts[i].title %>" />
</div>
<!-- Text Div -->
<div class="post-text flex-shrink flex gap-4 flex-col relative text-left">
<h2 class="text-black text-2xl font-semibold break-words mt-4"><%= posts[i].title %></h2>
<p class="text-gray-500 text-sm"><%= new Date(posts[i].published_at).toLocaleDateString('en-US') %></p>
<p class="text-gray-500 text-md font-300"><%= posts[i].excerpt %></p>
<a class="text-black text-md font-semibold" href="/posts/<%= posts[i].slug %>">Read the full article</a>
</div>
</div>
<% } %>
</div>
</section>
</div>
</body>
</html>
postTemplate.ejs
<!DOCTYPE html>
<html>
<head>
<title>Codesphere blog</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="../main.css" rel="stylesheet" />
<link href="../article.css" rel="stylesheet" />
<!-- Additional meta tags, styles, scripts... -->
</head>
<body>
<!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.0">
<title><%= title %></title>
</head>
<body class="bg-gray-100">
<%- include("/partials/header.ejs") %>
<section class="text-gray-700 body-font">
<div class="container mx-auto flex px-5 py-24 flex-col items-center">
<article class="mx-auto w-full max-w-2xl format format-sm sm:format-base lg:format-lg format-blue dark:format-invert">
<h1 class="title-font sm:text-4xl text-3xl mb-4 font-bold text-gray-900">
<%= title %>
</h1>
<p class="mb-8 leading-relaxed">
<%= excerpt %>
</p>
<div class="post-image h-auto object-cover flex flex-shrink-0 rounded-md overflow-hidden w-full">
<img class="h-auto object-cover m-0 w-full" src="<%= image %>" alt="<%= title %>" />
</div>
<div class="mt-16 w-full">
<%- content %>
</div>
</article>
</div>
</section>
</body>
</html>
</body>
</html>
Starting the server
We created a few scripts that will suit your needs depending on if you want to actively work on your blog or run it on the server for production.
To start the server and have the templates and css files watched, run:
npm watch:generate
To start the server for production, run:
npm start
Or just use Codesphere to host your project in a matter of minutes!
Setting up in Codesphere
You can now deploy your static blog for free on Codesphere.
Just open up a new workspace using the GitHub Repo link, run the CI steps from the UI and you're done!
Wrapping up
In conclusion, building a custom static site generator might seem like a daunting task, but with the right approach, it's entirely feasible. By using a combination of Node.js, Express, EJS, and TailwindCSS, we were able to create a lean, efficient, and customizable generator tailored to our specific needs at Codesphere. Not only does this setup offer flexibility, but it also provides the speed and security benefits inherent to static sites.
Happy coding!