Turn a Markdown blog to a simple SSG

This post is about improving a Markdown blog and turn it into a simple SSG.

Intro

Over the past few weeks, I mostly wrote on how to template a Node.js application with EJS using Express.
Then, I wrote an article showing how to create a Markdown blog in Node.js using EJS, Express, gray-matter and markdown-it.
Today, I'll combine those tutorials to turn the Markdown blog, from the last tutorial, into a simple SSG.

Jamstack & SSG

Wherever you head on the web, the content you'll get as a client is made of HTML, CSS and some JavaScript.
Think of HTML as a skeleton, CSS as it's external shapes and colors, and JavaScript as it's internal functionalities.
With this in mind, you can ask yourself about the need of server side rendering...
Static sites are way performant and secure than dynamic ones.
But what about the need of interacting with a database or sending data on a particular page...?
This is where the JAMstack (JavaScript, APIs, and Markup) steps in to leverage the static notion from "fixed" to "on demand dynamic functionalities" like submitting forms, posting comments, making payments, searching content, authentication, and so on...
Today Jamstack is a continuous evolving ecosystem allowing developers to build better, faster and more secure applications, without losing the advantage of using dynamic features.

All of this is great but easier said than done.
I took the time, excitement, ambition and hard work of great developers to bring this concept to life.
Today, you can head over the list of Static Site Generators for Jamstack sites, choose what suits your needs, read the documentation, and build your project effortlessly in no time.
A simple explanation of a SSG is that it takes data passed to templates and generates HTML pages, just like a blender that gives you a ready-to-drink cocktail.
At the date of writing this article, the Jamstack site lists more than 300 SSGs used to pre-built ready to serve HTML pages.
But if you want to have your own dead simple SSG, that's also possible!

Improving our Markdown blog

The Markdown blog from the last tutorial was already a simple SSG, since it was taking data passed to templates and generating HTML pages out of them!
But everything was inside the main server file and if we wanted to add more functionalities, the file would become crowded and unmaintainable.
This is why, I'll show you how to improve this backend and make it ready to adopt more features.

LiveReload


It would be a waist of time if we had to reload the server and refresh the browser to see our changes each time we make some of them.
In Templating a Node.js app with EJS, we've learned how to implement a live reload from the main server file.
This time, we'll put this feature as a separate file.
Let's bring in three development dependencies:

npm i -D nodemon livereload connect-livereload

In the main server file, index.js, add a line to export app:

// /index.js
const express = require("express")
const app = express()
exports.app = app 👈
...

Now we can call app in other files 😉
At the root of the application, create a file called liveReload.js with the following content:

// /liveReload.js
module.exports = () => {
    /** Start LiveReload implementation  */
    const livereload = require("livereload")
    const connectLiveReload = require("connect-livereload")
    const { app } = require("./index") 👈

    // Create a server with livereload and fire it up
    const liveReloadServer = livereload.createServer()
    // Refresh the browser after each saved change on the server with a delay of 100 ms
    liveReloadServer.server.once("connection", () => {
        setTimeout(() => {
            liveReloadServer.refresh("/")
        }, 100)
    })
    // Add livereload script to the response
    app.use(connectLiveReload())
    /** End LiveReload implementation  */
}

Now, back to index.js, let's require this function:

// /index.js
...
app.set("view engine", "ejs")
app.use(express.static("public"))

// LiveReload
const liveReload = require("./liveReload")
liveReload()
...

Finally, let's add a script to package.json:

// /package.json
...
  "scripts": {
    "watch": "nodemon -e js,ejs,css,md"
  },
...

Now, the following command npm run watch will tell Nodemon to watch for any changes in .js, .ejs, .css and .md files, and restart the server while liveReload() will reload the browser.

Routing


The routes where defined in index.js, let's also put them in their own directory.
At the root of the application, create a folder called routes.
Inside this folder, create 2 files:

  1. mainRoute.js
  2. postsRoute.js

The first one is obviously for the homepage, and the second one is for each individual post.

Since we have multiple routes and each one will be in it's own file, let's use a global router in Express for a DRY code.
In index.js, parse an express.Router() to a global.router assigned to a router variable, then use it in the app:

// /index.js
...
// LiveReload
const liveReload = require("./liveReload")
liveReload()

// Express global router
const router = (global.router = express.Router())
app.use(router)
...

Now, let's move the logic of each route in it's own file:

// /routes/mainRoute.js
const router = global.router

const fs = require("fs")
const matter = require("gray-matter")

const getPosts = () => {
    // Get the posts from their directory
    const posts = fs.readdirSync(`${__dirname}/../views/posts`).filter((post) => post.endsWith(".md"))
    // Set the post content as an empty array
    const postContent = []
    // Inject into the post content array the front matter
    posts.forEach((post) => {
        postContent.push(matter.read(`${__dirname}/../views/posts/${post}`))
    })

    /**
     * 1- Return a list of posts as a two dimensional array containing for each one:
     * . the post filename with it's extension (e.g: postFilename.md)
     * . the post content as an object {content:"Markdown content as a string", data:{front matter}, excerpt:""}
     * 2- Return each array as an object and create a Date instance from it's date front matter
     * 3- Sort posts by publication's date in descending order (newest to oldest)
     */
    const postsList = posts
        .map(function (post, i) {
            return [post, postContent[i]]
        })
        .map((obj) => {
            return { ...obj, date: new Date(obj[1].data.date) }
        })
        .sort((objA, objB) => Number(objB.date) - Number(objA.date))

    return postsList
}

// Render the list of posts on the main route
router.get("/", (req, res) => {
    res.render("postsList", {
        posts: getPosts(),
    })
})

module.exports = router
// /routes/postsRoute.js
const router = global.router

const matter = require("gray-matter")

// Using a route parameter to render each post on a route matching it's filename
router.get("/posts/:post", (req, res) => {
    const postTitle = req.params.post // Get the Markdown filename

    // Read the Markdown file and parse it's front matter
    const post = matter.read(`${__dirname}/../views/posts/${postTitle}.md`)

    // Convert the Markdown file content to HTML with markdown-it
    const md = require("markdown-it")({ html: true }) // Allows HTML tags inside the Markdown file
    const content = post.content // Read the Markdown file content
    const html = md.render(content) // Convert the Markdown file content to HTML

    // Render the postsTemplate for each post and pass it's front matter as a data object into postsTemplate
    res.render("postsTemplate", {
        title: post.data.title,
        date: post.data.date,
        postContent: html,
    })
})

module.exports = router

Nota bene: in both files, I've replaced concatenation with template strings for paths.

Update index.js to require those routes:

// /index.js - COMPLETE FILE
const express = require("express")
const app = express()
exports.app = app

app.set("view engine", "ejs")
app.use(express.static("public"))

// LiveReload
const liveReload = require("./liveReload")
liveReload()

// Express global router
const router = (global.router = express.Router())
app.use(router)

// Routes
app.use("/", require("./routes/mainRoute"))
app.use("/", require("./routes/postsRoute"))

// Launching the application on port 3000
app.listen(3000, () => {
    console.log(`App 🚀 @ http://localhost:3000`)
})

Now that's a clean server file 👍

Styles and Scripts


Important subsection ahead!

In a common webapp, we would have a main stylesheet as well as a main scripts file.
Both files would be rendered on each and every page of the application, but we all know that we do not need all the styles nor all the scripts on every page!
If you look closer to postsRoute.js, even in index.js from the last tutorial, we passed an option along with markdown-it to allow HTML tags inside the Markdown files:

const md = require("markdown-it")({ html: true })

So we can use <style> and <script> tags inside our Markdown files 😉
Let's try to change the color of the title in my-first-article.md:

---
title: My first article
date: 2022/07/25
---

This is the content of my first article

<style>h1{color:red}</style>
<!-- /views/posts/my-first-article.md -->

Take a look at this post, the title is now red!
But if you look at a-second-post.md, the title is still black!
This is awesome, we can load individual styles for each and every post 🥳

The same logic is applicable for scripts:

---
title: My first article
date: 2022/07/25
---

This is the content of my first article

<style>h1{color:red}</style>
<script>alert("Hello from my-first-article.md")</script>
<!-- /views/posts/my-first-article.md -->

Okay, but what if a page has a decent amount of individual styles or scripts and we don't want to put the whole block inside the Markdown file?
Good question! Easy-peasy, just load it as you would normally do it.
Let's say that I have some particular styles and scripts for a-second-post.md.
Create a folder at the root of the application called public and under it create two folders called css and scripts.
In css, create a file called second-post-styles.css with the following content:

/* /public/css/second-post-styles.css */
h1 {
    color: blue;
}

In scripts, create a file called second-post-scripts.js with the following content:

/* /public/scripts/second-post-scripts.js */
console.log("Hello from second-post-scripts.js")

Now, update a-second-post.md to look like this:

---
title: A second post
date: 2022/07/28
---

Here goes the content of my second post

<link rel="stylesheet" href="/css/second-post-styles.css">
<script src="/scripts/second-post-scripts.js"></script>
<!--- /views/posts/a-second-post.md -->

Take a look at this post, the title is now blue and if you open the browser's console F12, you'll see the message 🥳

⚠️ The first slash / in the paths href and src is mandatory, if you omit it you'll get an error in the console.
The reason is because the link and script tags are treated as a Markdown content, converted to HTML, injected in EJS template, rendered on the frontend.
If we omit the first slash, the Markdown parser will think that those folder are in the same folder as the post, the posts folder, then those incorrect paths will be converted to HTML and injected into the EJS template that will render the post on the frontend where the browser will respond with a 404 Not Found.
By putting a slash / at the beginning of the path, Express will understand that we are asking for folders and files living under the the root directory from which to serve static assets, the public folder:

app.use(express.static("public"))

Nota bene: the first slash / is not mandatory for a path directly defined in a template, an .ejs file.
In our case, postsTemplate.ejs is an exception because it's rendered on a dynamic route where the content comes from a parsed Markdown file, so in this file and every similar file, if we want to use the public folder, all our paths must begin with a slash /.

Conclusion

From here, you can take control over this simple SSG and maybe add a pagesTemplate.ejs, a pagination, a contact form, a searchbox...

I hope that this was helpful. Thanks for reading so far.

Note: The background image is a photo by Laura Ockel.

SYA,
LebCit.