Create a simple Markdown-based blog in Node.js

This post is about the process of creating a simple Markdown blog in Node.js.

TOC (Table Of Contents)

Intro

A year ago, I wrote a short post about the same subject: Markdown blog with EJS.
This year's post brings a lot of improvements.
First of all the stack has completely changed. We'll be using modern modules (Hono, Hono Node-Server, Eta and Marked) instead of old and aging ones (Express, EJS, Gray-Matter and Markdown-It). The benefit of this stack compared to the previous one is that we're using only 4 modules instead of 90 and the bundle size gets divided 12 times, from 862KB to 71KB!
The content is organized in a better way, where each step is thoroughly explained, allowing the reader to easily understand the process.
The code is written asynchronously, uses import statements instead of require and is split into reusable blocks to be maintainable.

What is Markdown good for?

When a browser displays some content, it's rendering the HTML structure of a webpage along with its styles and scripts.
HTML is the Web's core markup language.
Developers use HTML to define elements, combine them and produce a document.
A document begins with <!DOCTYPE html> followed by the <html> element which includes a <head> and a <body>.
Each one of the last two elements contains other elements working together, like a painting, so we can see the whole picture.
Let's take a look at a basic HTML document:

<!DOCTYPE html>
<html lang="en">
    <head>
        <!-- The title that appears in a browser's tab -->
        <title>My webpage title</title>
    </head>
    <body>
        <!-- An H1 heading -->
        <h1>My webpage title</h1>
        <!-- A paragraph -->
        <p>
            This is a paragraph with a link to a
            <!-- A link -->
            <a href="contact.html">contact</a>
            form page.
        </p>
        <!-- this is a comment -->
    </body>
</html>

As you can see, the content of a webpage lives in the <body> and each HTML element begins with an opening tag <tag> and ends with a closing one </tag>.
Nota bene: Some elements, Void elements, don't have a closing tag.

Now, imagine a webpage with a lot of content.
Imagine how the structure would be huge, crowded and difficult to write...
If you can't visualize it, right-click anywhere on this page then click on View Page Source and see for yourself...
This is where Markdown comes in!

Markdown allows you to write using an easy-to-read, easy-to-write plain text format, then convert it to structurally valid XHTML (or HTML).

So instead of writing a complex structure, we just use plain text!
How amazing is that?!
And this is why Markdown is gaining popularity among developers.

There are many editors, online and offline, to use Markdown.
In our case, a Node.js application that converts a Markdown file contents into data for a blog, we'll need the following:

To write in Markdown in one of those editors, simply create a file ending with the .md extension.
Let's create on the computer's desktop a folder named node-markdown-blog.
Open this folder in your code editor and create a file named from-markdown-to-html.md.
Copy and paste the following code into this file and save:

---
title: From Markdown to HTML in Node.js
description: A post about converting the contents of a Markdown file to HTML in a Node.js application
date: 2023-08-31
tags: [Node.js, Markdown, HTML]
---

## Intro

To read the content of a Markdown file in Node.js,<br />
we need an interpreter that understands Markdown,<br />
and can convert what he understood to HTML.<br />
So what we need is just like a translator,<br />
who can translate a context from one language to another.

## The translator

There are many [Markdown to HTML translators](https://github.com/markdown/markdown.github.com/wiki/Implementations) out there.<br />
Since we're building a Node.js app, we need a translator that works in this environnement.<br />  
We'll be using, the best one in my humble opinion for this task,<br />
[Marked](https://github.com/markedjs/marked), a markdown parser and compiler.

## Installation

To install Marked in a Node.js application,<br />
type the following command in the terminal of your code editor:

```bash
npm install marked
```

You now have a powerful Markdown converter at your service 😉

## Usage

To start using Marked, all we have to do is to fill its `parse()` method with some Markdown.<br />
It's as simple as the following block of code:

```js
import { marked } from "marked"
const html = marked.parse("# An H1 heading converted from Markdown to HTML with Marked!")
```

The output of the code above would be:

```html
<h1>An H1 heading converted from Markdown to HTML with Marked!</h1>
```

That's all folks, CU!

If you're using VS Code, like me, press Ctrl + K then press V, abracadabra!
You can now see a preview of the Markdown file.
If you look closer, the block that is delimited by a pair of 3 hyphens --- is not being rendered.
That's because it's a YAML front matter and doesn't get parsed by a Markdown parser.
A front matter holds data in a Markdown file.
We will see later how they will be used.

Converting Markdown to HTML

We have seen how to display a preview of a Markdown file, Let's now use Marked to get the HTML out of it.
First things first, we have to initialize our Node.js project.
Type the following command in the terminal:

npm init -y

This will create a package.json file in the project:

{
    "name": "markdown-blog",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
}

This file will hold all the information about our application, its scripts and dependencies.
Now install Marked with the following command:

npm install marked

If you have read the content of our Markdown file, you now know that we need to use the parse() method of Marked to get its corresponding HTML.
To get there, we first have to instruct our application to read the content of the file and then to convert it to HTML with Marked.

The file system in Node.js allows us to interact with files.
The only synchronous line that we will use in our code is the readFileSync() method of the fs module.
The readFileSync() method returns the contents of a defined path, a file in our system, and this is exactly what we are looking for.

Create a file named index.js, this will be the main file of our application, also called the server file.
If you look at package.json above, you'll see that there is a main property with a value of index.js.
Now you should remember that a Markdown parser doesn't parse a front matter, like we mentioned previously, so we must write a function that will do this job and make its data accessible.
In index.js paste the following function:

function parseFrontMatter(text, delimiter = "---") {
    const lines = text.split("\n")
    const frontmatter = {}

    let i = 0
    if (!lines[i].startsWith(delimiter)) {
        throw new Error("Front matter delimiter not found.")
    }

    i++
    while (i < lines.length && !lines[i].startsWith(delimiter)) {
        const line = lines[i].trim()
        const separatorIndex = line.indexOf(":")
        if (separatorIndex === -1) {
            throw new Error(`Invalid front matter syntax: ${line}`)
        }
        const key = line.slice(0, separatorIndex).trim()
        let value = line.slice(separatorIndex + 1).trim()

        // Check if value is wrapped in brackets
        if (value.startsWith("[") && value.endsWith("]")) {
            // Remove brackets and split into array elements
            const trimmedValue = value.slice(1, -1).trim()
            if (trimmedValue.length > 0) {
                value = trimmedValue.split(",").map((item) => item.trim())
            } else {
                value = []
            }
        }

        frontmatter[key] = value
        i++
    }

    if (i === lines.length) {
        throw new Error("End of front matter not found.")
    }

    const content = lines.slice(i + 1).join("\n")

    return {
        frontmatter,
        content,
    }
}

This function extracts front matter from a text and returns it as an object named frontmatter, along with the remaining content after the front matter.
It verifies that the text begins with a front matter delimited by a pair of 3 hyphens --- and that each property and value in the front matter are separated by a colon :.
It also checks for any value in the front matter wrapped in square brackets [ ]. If so, it removes the brackets and splits the value by commas, creating an array of values, like the tags property in from-markdown-to-html.md.
Finally, the function returns a JavaScript object with two properties:

  1. frontmatter which is a JavaScript object allowing us to access the front matter data
  2. content which is a string that will be parsed with Marked to get the the corresponding HTML

Just before jumping to the code, we must add a line to package.json to use import statements instead of require.
This line will allow us to use ES modules. If you're new to the modules system, you should read this tutorial.
Open package.json and add the following line "type": "module" just under "main": "index.js", don't forget to add a coma at the end of each line and save Ctrl + S.

Now that everything is in place, let's begin!
In index.js, under the parseFrontMatter() function, import the readFileSync() method from the fs module to read the contents of our Markdown file then use the newly created function on the contents:

function parseFrontMatter(text, delimiter = "---") {
... // Code inside the function
}

import { readFileSync } from "node:fs"

const contents = readFileSync("from-markdown-to-html.md", "utf-8")
const data = parseFrontMatter(contents)

If we debug the code by setting a breakpoint in front of the data constant then Step Over it, we'll get the following output in the debug panel:

Output of a function in the debug panel of VS Code

As you can see, the returned variable, data, is an accessible object in which we have:

  1. The content of our Markdown file as a string
  2. Its frontmatter as an object with:
    1. The date
    2. The description
    3. The tags as an array

This is just awesome! The most difficult and critical part of our application is done!
Concerning the debugging part, VS Code has also a Node.js debugging documentation.

Back to the code, we are one step away from turning the content of the Markdown file to HTML.
Always in index.js, we'll now import Marked to use its parse() method to finally get the HTML out of our Markdown file.
Update the code like so:

function parseFrontMatter(text, delimiter = "---") {
... // Code inside the function
}

import { readFileSync } from "node:fs"
import { marked } from "marked"

const contents = readFileSync("from-markdown-to-html.md", "utf-8")
const data = parseFrontMatter(contents)
const html = marked.parse(data.content) // Accessing data content with dot notation.

Since data is an object, we can access its properties with the dot notation or bracket notation.
Now, if you add a breakpoint in front of the html constant and run the debugger by hitting on F5 then step over it, You'll notice that the html variable in the debug panel displays the returned HTML from the content of the Markdown file 🥳

Using Hono and Eta

Having all the ingredients at our disposal, it's now time to cook and serve!
To give our blog a nice design, download the free Bootstrap blog template DevBlog.
Once downloaded and extracted, open the folder devblog-bs5-v3.0 in your code editor.
Depending on your code editor, you can use an extension to launch a local server and display the template in your default browser.
If you're using VS Code, you can use Live Server or Live Preview if you prefer to display the code in the workspace.
Or, you can simply browse the template's live demo.

Looking at the demo, locally or on the web, you can see that it has 3 pages:

  1. Blog Home to display the latest posts, corresponding to index.html
  2. Blog Post to display a single post, corresponding to blog-post.html
  3. About Me to display a single page, corresponding to about.html

Open the 3 files in your code editor and have a quick look at each page structure.
You'll notice that they share almost the same <head>, blog-post.html has an additional line, and their <body> have the same <header> and the content of each page is wrapped inside a <div class="main-wrapper"> which holds the same <footer>.

Now our goal is to write some files in Markdown and render:

  1. A list of the latest posts on the main route, or home
  2. Each post on a route matching the post's filename
  3. Each page on a route that matches the page's filename

Our backend will use Hono as a web framework and Eta as a template engine.
Back then I used to prefer EJS, nowadays I recommend Eta because it's a faster, more lightweight, and more configurable EJS alternative, without any dependency!

To install Hono and Eta, type the following command in your terminal:

npm install eta hono @hono/node-server

To test our web framework, let's add the following code in index.js under everything else:

... // All previous code

import { serve } from "@hono/node-server"
import { Hono } from "hono"

const app = new Hono()
app.get("/", (c) => c.text("Hono meets Node.js"))

serve(app, ({port}) => {
    console.log(`Listening on http://localhost:${port}`)
})

The code is simple, We have done the following:

  1. Imported Hono and its Node.js adapter
  2. Assigned the framework to a constant named app
  3. Defined a text to be displayed on the entry route /
  4. Served the app on a port that is 3000 by default

Type in the terminal node index and you'll get:

Listening on http://localhost:3000

Visit the link in your browser and you'll see the defined text on the entry route.
To stop the server press Ctrl + C to clear the terminal type clear.
Great! Before going further, we should begin to organize our code because we can't put everything in index.js.
To do so create the following:

  1. A functions folder 📁 that will hold various functions
  2. A routes folder 📁 that will hold the app's routes
  3. A static folder 📁 that will hold the frontend styles, scripts and images
  4. A views folder 📁 where the templates and the Markdown files will be stored
  5. Under the views folder create:
    1. A components folder 📁 for the shared components of the app
    2. A layouts folder 📁 for the different layouts of the app
    3. A pages folder 📁 that will hold the Markdown files of the pages
    4. A posts folder 📁 that will hold the Markdown files of the posts

Move from-markdown-to-html.md inside the posts folder and you should have the following structure in alphabetical order under the main folder node-markdown-blog:

📁 node-markdown-blog
├── 📁 functions
├── 📄 index.js
├── 📁 node_modules
├── 📄 package-lock.json
├── 📄 package.json
├── 📁 routes
├── 📁 static
└── 📂 views
  ├── 📁 components
  ├── 📁 layouts
  ├── 📁 pages
  ├── 📂 posts
  │  └── 📄 from-markdown-to-html.md

The blog

As we have seen previously, the main route /, also known as the entry route or the home route, displays the most recent posts.
Let's begin by creating the shared components, then we'll move on to the layout of the main route or the blog.
We know that we have a common <head>, <header> and <footer>.
Create in the components folder 3 files:

  1. head.html
  2. header.html
  3. footer.html

Copy inside each file the corresponding code:

<head>
    <title>Home | Markdown-based blog</title>

    <!-- Meta -->
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="List of the most recent posts" />
    <link rel="shortcut icon" href="/static/images/favicon.ico" />

    <!-- FontAwesome JS-->
    <script defer src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.2/js/all.min.js"></script>

    <!-- Theme CSS -->
    <link id="theme-style" rel="stylesheet" href="/static/css/theme-1.css" />
</head>
<header class="header text-center">
    <h1 class="blog-name pt-lg-4 mb-0"><a class="no-text-decoration" href="/">My Markdown Blog</a></h1>

    <nav class="navbar navbar-expand-lg navbar-dark">
        <button
            class="navbar-toggler"
            type="button"
            data-bs-toggle="collapse"
            data-bs-target="#navigation"
            aria-controls="navigation"
            aria-expanded="false"
            aria-label="Toggle navigation"
        >
            <span class="navbar-toggler-icon"></span>
        </button>

        <div id="navigation" class="collapse navbar-collapse flex-column">
            <div class="profile-section pt-3 pt-lg-0">
                <img class="profile-image mb-3 rounded-circle mx-auto" src="/static/images/profile.png" alt="image" />

                <div class="bio mb-3">
                    Hi, this is my Markdown blog. It's a Node.js app that I have created using Eta, Hono and Marked. You
                    can read more about the original application in the following link.
                    <br />
                    <a href="https://github.com/LebCit/blog-doc" target="_blank">Find out more about Blog-Doc</a>
                </div>
                <!--//bio-->
                <ul class="social-list list-inline py-3 mx-auto">
                    <li class="list-inline-item">
                        <a href="#"><i class="fab fa-twitter fa-fw"></i></a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#"><i class="fab fa-linkedin-in fa-fw"></i></a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#"><i class="fab fa-github-alt fa-fw"></i></a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#"><i class="fab fa-stack-overflow fa-fw"></i></a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#"><i class="fab fa-codepen fa-fw"></i></a>
                    </li>
                </ul>
                <!--//social-list-->
                <hr />
            </div>
            <!--//profile-section-->

            <ul class="navbar-nav flex-column text-start">
                <li class="nav-item">
                    <a class="nav-link active" href="/">
                        <i class="fas fa-home fa-fw me-2"></i>
                        Blog Home
                        <span class="sr-only">(current)</span>
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="blog-post.html">
                        <i class="fas fa-bookmark fa-fw me-2"></i>
                        Blog Post
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="about.html">
                        <i class="fas fa-user fa-fw me-2"></i>
                        About Me
                    </a>
                </li>
            </ul>

            <div class="my-2 my-md-3">
                <a class="btn btn-primary" href="https://themes.3rdwavemedia.com/" target="_blank">Get in Touch</a>
            </div>
        </div>
    </nav>
</header>
<footer class="footer text-center py-2 theme-bg-dark">
    <!--/* This template is free as long as you keep the footer attribution link. If you'd like to use the template without the attribution link, you can buy the commercial license via our website: themes.3rdwavemedia.com Thank you for your support. :) */-->
    <small class="copyright">
        Designed with
        <span class="sr-only">love</span>
        <i class="fas fa-heart" style="color: #fb866a"></i>
        by
        <a href="https://themes.3rdwavemedia.com" target="_blank">Xiaoying Riley</a>
        for developers
        <!-- You can remove or modify the following line, including this comment, but everything before must stay in place. -->
        <br />
        Node.js App developed by
        <a href="https://lebcit.github.io/" target="_blank">LebCit</a>
    </small>
</footer>

We have slightly modified the original code of the three components. The important part to notice is that we have replaced assets/ by /static/ since this folder will serve the frontend's static assets.
Please note that in head.html, I've kept the default theme:

<!-- Theme CSS -->
<link id="theme-style" rel="stylesheet" href="/static/css/theme-1.css" />

Create inside the static folder a css folder, then copy inside it the theme-1.css file from devblog-bs5-v3.0/assets/css/.
Nota bene: You can of course choose anyone of the eight available color schemes that you like, but keep in mind that you'll have to replace the theme number according to your choice and import the corresponding file!

Our principal components are ready, we can attack the layout of the blog.
The blog is made of 2 blocks each one inside a <section> tag.
The first section is a call to action displaying the blog title, its description, a welcome message and a subscription form.
The second section displays the latest posts with a navigation at their bottom to visit previous posts.
To keep this tutorial as simple as possible, and since our goal is to create a simple Markdown-based blog with Node.js, I won't implement the subscription form or the posts' navigation.
Inside the layouts folder, create a base.html file and paste the following code inside it:

<!DOCTYPE html>
<html lang="en">
    <!-- Include the content of head.html file here -->
    <%~ include('../components/head.html', it) %>
    <body>
        <!-- Include the content of header.html file here -->
        <%~ include('../components/header.html') %>
        <div class="main-wrapper">
            <!-- Here we open the if...else statement @see https://javascript.info/ifelse -->
            <% if (it.mainRoute) { %>
            <!-- If we are on the main route, the route that displays the list of posts -->

            <!-- BEGINNING OF THE BLOG'S STRUCTURE -->

            <!-- Start blog's intro -->
            <section class="cta-section theme-bg-light py-5">
                <div class="container text-center single-col-max-width">
                    <h2 class="heading">Markdown-based blog - A Markdown Blog Made With Node.js</h2>
                    <div class="intro">Welcome to my blog. Hope that you'll find useful information in my posts.</div>
                </div>
            </section>
            <!-- End blog's intro -->

            <!-- Start blog's posts' list -->
            <section class="blog-list px-3 py-5 p-md-5">
                <div class="container single-col-max-width">
                    <!-- Include the content of index.html file here -->
                    <%~ include('./index.html', it) %>
                </div>
            </section>
            <!-- End blog's posts' list -->

            <!-- ENDING OF THE BLOG'S STRUCTURE -->

            <% } else { %>
            <!-- Else we are not on the main route, we are on a route that displays a Markdown file -->

            <!-- Start Markdown file content -->

            <!-- Include the content of markdown.html file here -->
            <%~ include('./markdown.html', it) %>

            <!-- End Markdown file content -->

            <% } %>
            <!-- Here we close the if...else statement @see https://javascript.info/ifelse -->

            <!-- Start footer -->

            <!-- Include the content of footer.html file here -->
            <%~ include('../components/footer.html') %>

            <!-- End footer -->
        </div>

        <!-- Bootstrap's JavaScript -->
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    </body>
</html>

Don't be scared by Eta markup, every single step is commented on, It's pretty straightforward if you read it carefully!
If you look closely, we have used plain JavaScript, like the if...else statement, inside an HTML file without a <script> tag. This is one of Eta's powers.
We also have used the include() function of Eta to include a partial in a desired location by indicating its relative path.
Concerning the it variable, it's an object containing the data that we pass on a route to a template, we will see it in action shortly.
Did you notice that we have included 2 templates, index.html and markdown.html, that do not exist?
Go ahead and create them in the layouts folder, the same folder where base.html is located.

For now, the main route / displays a text message as we can read in index.js:

app.get("/", (c) => c.text("Hono meets Node.js"))

Let's dive into the blog transformation.
For that we will need the following:

  1. A function to get the Markdown files from their respective directory. The directory could be the pages folder or the posts folder.
  2. A function to get the Markdown data from the Markdown files depending on their directory.
  3. A function that returns the posts' data and sorts them in chronological order, from the newest to the oldest.
  4. While at it, a function that returns the pages' data.
  5. A route that renders a template to display all the posts.

For simplicity and elegance, I'll wrap those functions with the previous one parseFrontMatter() under a class.
In the functions folder, create a file named node-markdown-blog.js and paste the following code inside it:

import { readFileSync } from "node:fs"
import { readdir } from "node:fs/promises"

class NodeMarkdownBlog {
    // Method to parse front-matter from Markdown files.
    parseFrontMatter(text, delimiter = "---") {
        const lines = text.split("\n")
        const frontmatter = {}

        let i = 0
        if (!lines[i].startsWith(delimiter)) {
            throw new Error("Front matter delimiter not found.")
        }

        i++
        while (i < lines.length && !lines[i].startsWith(delimiter)) {
            const line = lines[i].trim()
            const separatorIndex = line.indexOf(":")
            if (separatorIndex === -1) {
                throw new Error(`Invalid front matter syntax: ${line}`)
            }
            const key = line.slice(0, separatorIndex).trim()
            let value = line.slice(separatorIndex + 1).trim()

            // Check if value is wrapped in brackets
            if (value.startsWith("[") && value.endsWith("]")) {
                // Remove brackets and split into array elements
                const trimmedValue = value.slice(1, -1).trim()
                if (trimmedValue.length > 0) {
                    value = trimmedValue.split(",").map((item) => item.trim())
                } else {
                    value = []
                }
            }

            frontmatter[key] = value
            i++
        }

        if (i === lines.length) {
            throw new Error("End of front matter not found.")
        }

        const content = lines.slice(i + 1).join("\n")

        return {
            frontmatter,
            content,
        }
    }

    // Method to get files from their directory recursively.
    async getFiles(dirName) {
        let files = []
        const items = await readdir(dirName, { withFileTypes: true })

        try {
            for (const item of items) {
                if (item.isDirectory()) {
                    files = [...files, ...(await getFiles(`${dirName}/${item.name}`))]
                } else {
                    files.push(`${dirName}/${item.name}`)
                }
            }
        } catch (error) {
            console.error(error)
        }

        return files
    }

    // Method to get the pages and posts data.
    async getMarkdownData(dirname) {
        const files = await getFiles(dirname)
        const mdFiles = files.filter((file) => file.endsWith(".md"))
        const data = []

        mdFiles.forEach((file) => {
            const fileName = file.split("/").pop()
            const contents = readFileSync(file, "utf-8")
            const fileData = parseFrontMatter(contents)
            const filePath = `${dirname}/${fileName}`
            const fileDir = filePath.split("/")[1]
            const obj = { 0: fileName, 1: fileData }
            obj.path = filePath
            obj.dir = fileDir
            data.push(obj)
        })

        return data
    }

    // Method to get the pages data.
    getPages() {
        const pagesData = getMarkdownData("views/pages")
        return pagesData
    }

    // Method to get the posts data.
    async getPosts() {
        const postsData = await getMarkdownData("views/posts")
        const newPostsData = postsData
            .map((obj) => {
                return { ...obj, date: new Date(obj[1].frontmatter.date) }
            })
            .sort((objA, objB) => Number(objB.date) - Number(objA.date))

        return newPostsData
    }
}

export const { parseFrontMatter, getFiles, getMarkdownData, getPages, getPosts } = new NodeMarkdownBlog()

We have created a class NodeMarkdownBlog() containing the desired functions then export and destructurize it to make its methods directly available outside the file.
You already know how parseFrontMatter() works.
The getFiles() function returns all the files inside a particular folder as an array, we just have to provide the folder's path.
Why having a getMarkdownData() method since we already have parseFrontMatter()?
Simply because we need to know some more information about a Markdown file to render it on the frontend.
The getMarkdownData() method uses the getFiles() and parseFrontMatter() methods to read and parse Markdown files, but it also gives us the file path, its directory and its name then returns all of them as an accessible object, we'll see that in a moment.
Finally, the getPages() and getPosts() methods use getMarkdownData() and return as their names indicate the data of the Markdown files respectively in the pages and posts folders.
Now let's create the logic that will display the available Markdown files inside the posts folder, we only have one for now, and render them on the main route or entry route /.
Create in the routes folder a file named mainRoute.js and add to it the following code:

import { join } from "path"
import { Hono } from "hono"
import { Eta } from "eta"
import { getPosts } from "../functions/node-markdown-blog.js"

const app = new Hono()
const eta = new Eta({ views: join(process.cwd(), "views") })

export const mainRoute = app.get("/", async (c) => {
    // Main Route data
    const data = {
        title: "Home",
        description: "List of the most recent posts",
    }

    const res = eta.render("layouts/base.html", {
        // Passing Route data
        mainRoute: true,
        // Passing document data
        data: data,
        posts: await getPosts(),
        // Passing needed settings for the template
        siteTitle: "Markdown-based blog",
    })
    return c.html(res)
})

This block of code begins by importing the necessary modules and initializing an instance of Hono() and another one of Eta().
The eta constant defines the views folder as the one where the templates are located.
The mainRoute constant is exported to be used outside this file. Inside it, a response constant res renders the template located in views/layouts/base.html where some data are passed and can be accessed with the it variable.
Finally, the response constant res is returned as HTML on the defined route /.

Now change the line 2 of head.html, the <title> tag, to:

<title><%= it.data.title %> | <%= it.siteTitle %></title>

Also change the line 8 of head.html, the <meta> description tag, to:

<meta name="description" content="<%= it.data.description %>" />

Those changes will allow us to use the defined title, description and siteTitle for each route. This way, we use only one head that will dynamically assign the correct variables for each document.

Back to base.html where we've defined a template named index.html for the main route and created it in the same folder as base.html, open this index.html file and add the following code:

<% it.posts.forEach(post => { %>

<!-- GETTING THE DATA OF EACH POST -->

<!-- Get the Markdown filename without its extension -->
<% const postFilename = post[0].replace(".md", "") %>

<!-- Get the Markdown post title from its front matter -->
<% const postTitle = post[1].frontmatter.title %>

<!-- Get the post date from its front matter -->
<% const postDate = post[1].frontmatter.date %>

<!-- Get the post description from its front matter -->
<% const postDescription = post[1].frontmatter.description %>

<!-- Get the post tags from its front matter -->
<% const postTags = post[1].frontmatter.tags %>

<div class="item mb-5">
    <div class="row g-3 g-xl-0">
        <div class="col-2 col-xl-3">
            <img class="img-fluid post-thumb" src="assets/images/blog/blog-post-thumb-1.jpg" alt="image" />
        </div>
        <div class="col">
            <h3 class="title mb-1">
                <a class="text-link" href="/posts/<%= postFilename %>"><%= postTitle %></a>
            </h3>
            <div class="meta mb-1">
                <span class="date"><%= postDate %></span>
                <span>
                    <!-- Start post tag(s) -->
                    <% postTags.forEach(tag => { %>
                    <!-- Start each tag markup -->
                    <%= tag %>
                    <!-- End each tag markup -->
                    <% }) %>
                    <!-- End post tag(s) -->
                </span>
            </div>
            <div class="intro"><%= postDescription %></div>
            <a class="text-link" href="/posts/<%= postFilename %>">Read more &rarr;</a>
        </div>
    </div>
</div>

<% }) %>

Using a forEach() loop, thanks to Eta, we define the markup for each post preview on the homepage.
Now, to see what our homepage looks like, remove everything from index.js and paste the following code:

import { Hono } from "hono"
import { serveStatic } from "@hono/node-server/serve-static"
import { serve } from "@hono/node-server"

const app = new Hono()

// Serve Static files
app.use("/static/*", serveStatic({ root: "./" }))

// Routes
import { mainRoute } from "./routes/mainRoute.js"

app.route("/", mainRoute)

serve(app, ({ port }) => {
    console.log(`Listening on http://localhost:${port}`)
})

Type node index in the terminal and visit the link, abracadabra!
I know that some elements are missing and that the footer is not at the bottom. It's okay, don't worry, we'll fix that later on.

Okay, but you must be asking yourself where those variables defined in index.html came from?!
Do you remember that in mainRoute.js we have passed to the template, in the data object, a posts property with a value of await getPosts() and that in the template we have used a forEach() loop on those posts?
Let's test it. Add to mainRoute.js under the last import the following line:

const test = await getPosts()

Add a breakpoint in front of this line and launch the debugger then step over it. Look at the test variable in the debug panel and you'll see how every post is returned inside an array as an accessible object like this image:

Output of a function in the debug panel of VS Code

So each post is returned as an object containing:

  1. The post filename with its extension at index 0
  2. An object returned by parseFrontMatter() at index 1
  3. A dir and path properties, directly accessible

Now remove or comment on this added line since we don't need it in the code.

A single blog post, a single page

In this section, we will learn how to render a post and a page on the frontend and we will fix the issues of the previous section regarding the missing elements and the footer's position.
So why do we have a separate section in the table of contents for rendering a page?
It's just to let you know ahead that you'll be able to render posts as well as pages.

If you look at the structure of blog-post.html in bevblog-bs5-v3.0, you'll see that after the <div class="main-wrapper">, that we already have in base.html, each post is made of:

  1. An <article> tag followed by a <div class="container ..." in which we have:
    1. A post <header>
    2. A <div class="blog-post-body">
    3. A posts navigation
    4. A comments section
  2. A promo <section>

I will only include the post header and body, the posts' navigation and comments section are outside the scope of this tutorial, feel free to include the promo section by copying its HTML and pasting it after the article closing tag </article>.
With this decision, the about.html file representing a page has now a very close design to the post. I will use the post structure for both of them to keep this tutorial as simple as possible.
For the blog, I started with the template, Usually, we start with the route to pass the variables then we construct the template.
Create a markdownRoute.js in the routes folder and add to it the following code:

import { join } from "path"
import { Hono } from "hono"
import { Eta } from "eta"
import { marked } from "marked"
import { getPages, getPosts } from "../functions/node-markdown-blog.js"

const app = new Hono()
const eta = new Eta({ views: join(process.cwd(), "views") })

export const markdownRoute = app.get("/:folder/:filename", async (c, next) => {
    // Merge the pages and the posts arrays into a single array named mdFiles
    const pages = await getPages()
    const posts = await getPosts()
    const mdFiles = pages.concat(posts)

    const currentFile = mdFiles.find(
        (file) => file.path === `views/${c.req.param("folder")}/${c.req.param("filename")}.md`
    )

    if (currentFile) {
        const fileData = currentFile[1].frontmatter
        const fileContent = marked.parse(currentFile[1].content)
        const fileDirectory = currentFile.dir
        const res = eta.render("layouts/base.html", {
            // Passing Route data
            mdRoute: true,
            // Passing Markdown file data
            data: fileData,
            content: fileContent,
            dirname: fileDirectory,
            // Passing needed settings for the template
            siteTitle: "Markdown-based blog",
        })
        return c.html(res)
    } else {
        // Proceed to the 404 route if no file is found
        await next()
    }
})

Here we did the following:

  1. Initialize Hono and Eta like previously. Import Marked, the pages and the posts
  2. Export a markdownRoute constant to be used outside this file
  3. Define a dynamic path "/:folder/:filename" that will look for a filename in a folder
  4. Merge the pages and posts in a mdFiles array
  5. The currentFile looks for an existing file matching the folder (pages or posts) and the filename
  6. If the file exists, we pass the desired variables inside the template and return its HTML
  7. Otherwise, the 404 Not Found message will be displayed

For this route to work on the frontend, we must call it inside the server file index.js like so:

// Add this line under: import { mainRoute }...
import { markdownRoute } from "./routes/markdownRoute.js"

// Add this line under: app.route("/", mainRoute)
app.route("/", markdownRoute)

To attach an image to a Markdown file, just add an image property to the file's front matter.
We can also add an imageAlt (alternative text) and imageCredit properties.
It's always good to give credit where credit is due!
Download the medium size of the following image from Unsplash and save it inside the /static/img/ folder of our application.
Let's add those properties to the front matter of our Markdown file like so:

---
title: From Markdown to HTML in Node.js
description: A post about converting the contents of a Markdown file to HTML in a Node.js application
date: 2023-08-31
tags: [Node.js, Markdown, HTML]
image: /static/img/pankaj-patel-Fi-GJaLRGKc-unsplash.jpg
imageAlt: JavaScript code inside ES6 templates by Pankaj Patel
imageCredit: https://unsplash.com/@pankajpatel
---

You can, of course, put the direct link to an image instead of downloading it and saving it in the img folder.
As an example, we could have put in front of the image property the following link:

image: https://source.unsplash.com/Fi-GJaLRGKc

The template that renders Markdown files in our application is markdown.html, Open it and paste the following code:

<article class="blog-post px-3 py-5 p-md-5">
    <div class="container single-col-max-width">
        <header class="blog-post-header">
            <h2 class="title mb-2"><%= it.data.title %></h2>
            <h3><%= it.data.description %></h3>
            <% if (it.dirname === "posts") { %>
            <div class="meta mb-3">
                <span class="date"><%= it.data.date %></span>
                <span>
                    <!-- Start post tag(s) -->
                    <% it.data.tags.forEach(tag => { %>
                    <!-- Start each tag markup -->
                    <%= tag %>
                    <!-- End each tag markup -->
                    <% }) %>
                    <!-- End post tag(s) -->
                </span>
            </div>
            <% } %>
        </header>

        <div class="blog-post-body">
            <!-- Markdown file image -->
            <figure class="blog-banner">
                <a href="<%= it.data.imageCredit %>" target="_blank">
                    <img class="img-fluid" src="<%= it.data.image %>" alt="<%= it.data.imageAlt %>" />
                </a>
                <figcaption class="mt-2 text-center image-caption">
                    Image Credit:
                    <a class="theme-link" href="<%= it.data.imageCredit %>" target="_blank"><%= it.data.imageAlt %></a>
                </figcaption>
            </figure>

            <!-- Markdown file content -->
            <%~ it.content %>
        </div>
    </div>
</article>

Look closely at the <header>, the date and tags will only be displayed if the file's directory is posts.
In other words, if the file is a post display the date and tags otherwise don't, and that's because a page doesn't usually have those properties in its front matter.
Type node index in the terminal, follow the link and click on the link of our only article or its Read more → button, abracadara!

Now, let's fix the image of the post preview on the blog.
For this, add the following variables under the existing ones in index.html:

<!-- Get the post image from its front matter -->
<% const postImage = post[1].frontmatter.image %>

<!-- Get the post image alternative text from its front matter -->
<% const postImageAlt = post[1].frontmatter.imageAlt %>

One more adjustment and our blog will render the image of a post.
Always in index.html make the following modification:

<!-- Replace this line -->
<img class="img-fluid post-thumb" src="assets/images/blog/blog-post-thumb-1.jpg" alt="image" />
<!-- By the following line -->
<img class="img-fluid post-thumb" src="<%= postImage %>" alt="<%= postImageAlt %>" />

If the server is still running, restart it by pressing Ctrl + C then type node index in the terminal.
The defined image of a post is now correctly displayed in the post's preview on the blog.

To fix the footer's position on the blog, you must add at least two more posts to the blog, I'll leave this exercise to you so you can familiarize yourself with the process of creating a blog post.
Concerning the profile image in header.html, you can download any image that suits your needs into the img folder and replace profile.png with the filename of the downloaded one.
Don't forget to modify in header.html the social media icons and links to yours. Also, change the menu titles and links in header.html according to your needs.

Finally, the process of creating a page is the same as creating a post. The only difference is that the page's front matter doesn't have a date or tags properties.
As an example, the following front matter for a page:

---
title: The title of the page
description: The description of the page's contents
image: The path or URL to the page's image
imageAlt: The alternative text of the image
imageCredit: The link to the image or its author
---

Blog-Doc

This tutorial is a simplified approach to how the blog, the pages and the posts are rendered on the frontend in Blog-Doc: The simplest Node.js CMS and SSG!
Blog-Doc allows you to create a working site without touching a line of code and to choose the look of your site from the available themes.
Soon, the template used for this tutorial will be fully integrated into Blog-Doc as its third theme!
If you have a GitHub account, please consider giving a star to Blog-Doc and also for the GitHub repository of this tutorial where you can ask me anything about it in the Q&A section of the discussions.

I've dedicated one week and a half, from 31 August to 11 September, of my free time to write this long and detailed tutorial.
If you read it on my Hashnode blog and found it useful, please consider liking it and leaving a little comment.
Anyway, thank you for your time and patience, I hope that this tutorial will be useful and instructive in many ways for all its readers.

SYA,
LebCit.