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:
mainRoute.js
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.