Creating a simple Markdown Blog with Go and Gin

In this post we're going to have a look at how to create a simple Markdown powered blog with Go(lang) and gin. The blog will be able to look up a markdown file by title / filename, display a list of all blog posts, display a single blog post and give the user an error page when a link can not be found.

Requirements:
  • You have Go installed
  • You have (very) basic terminal command experience

Setting up the Project Structure

The purpose of the blog is to render markdown files to good looking blog posts, so let's create some directories and create our server file:

mkdir markdown templates
touch main.go

Now all the files that are going to be our blog posts go into markdown, all template files:

  • index.tmpl.html
  • post.tmpl.html
  • error.tmpl.html

go into our templates directory.

The main.go file is where our server code will live.

List posts and display on Index

Let's go ahead and list all the markdown files we create in the markdown directory, so an overview of all available posts if you will:

main.go
package main

import (
    "fmt"
    "html/template"
    "io/ioutil"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {

    r := gin.Default()
    r.Use(gin.Logger())
    r.Delims("{{", "}}")

    r.LoadHTMLGlob("./templates/*.tmpl.html")

    r.GET("/", func(c *gin.Context) {
        var posts []string

        files, err := ioutil.ReadDir("./markdown/")
        if err != nil {
            log.Fatal(err)
        }

        for _, file := range files {
            fmt.Println(file.Name())
            posts = append(posts, file.Name())
        }

        c.HTML(http.StatusOK, "index.tmpl.html", gin.H{
            "posts": posts,
        })
    })

  r.Run()
}

Let's go through the file bit by bit:

  1. The r.Delims() function sets the characters we decide are the template tags that we're going to use in our templates.
  2. The r.GET function defines the route and will respond when / so the root is accessed
  3. We list the files of the directory with ioutil.ReadDir
  4. We loop over the files and put the names into posts with append
  5. We respond with c.HTML, rendering the index template and passing in the posts string array

Next we'll need an index.tmpl.html file that looks something like this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>Blog: Index</title>
</head>
<body>
  <div class="container">
    <ul>
      {{ range .posts }}
        <li><a href="/{{ . }}">{{ . }}</a></li>
      {{ end }}
    </ul>
  </div>
</body>
</html>

Noteworthy about this one is the template loop range that will iterate over the posts. Since it's a flat array {{ . }} will display the string.

Let's insert our first post, hello-world.md in our markdown directory. Insert what you like, I have made it:

# Hello World!

This is my first blog post ever!

Compiling Markdown to your Template

Next we want the links to work, so we want to display the individual (compiled) markdown files when somebody tries to access /hello-world.md. For that purpose we're going to make use of another third party library, blackfriday

The Post struct is what we will populate with our Markdown before passing it on to the render function.

It contains a Title and a Content property. The title admittedly is not very useful right now, because it literally is the file name, but we'll work on that later.

Let's have a look at how a route could look when serving these MarkDown files to our blog readers:

r.GET("/:postName", func(c *gin.Context) {
  postName := c.Param("postName")

  mdfile, err := ioutil.ReadFile("./markdown/" + postName)

  if err != nil {
    fmt.Println(err)
    c.HTML(http.StatusNotFound, "error.tmpl.html", nil)
    c.Abort()
    return
  }

  postHTML := template.HTML(blackfriday.MarkdownCommon([]byte(mdfile)))

  post := Post{Title: postName, Content: postHTML}

  c.HTML(http.StatusOK, "post.tmpl.html", gin.H{
    "Title":   post.Title,
    "Content": post.Content,
  })
})

The important parts of this route function are:

  1. r.GET("/:postName" enables us to use the name of the post inside our route: postName := c.Param("postName")
  2. we're reading the contents of the markdown file with ioutil.Readfile and convert it to HTML with template.HTML(blackfriday.MarkdownCommon([]byte(mdfile)))
  3. we're responding with the error template and a 404 error if the file isn't found
  4. we're inserting the compiled HTML into the Post struct

Now for the template, we're going to duplicate the index template and change what's in the class="container" div:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>{{.Title}}</title>
</head>
<body>
  <div class="container">
    {{.Content}}
  </div>
</body>
</html>

Which will even make use of our... slightly filenamy title :)

Low let's try to restart our gin server and click on the link on the front page.

You should be seen the content of your MarkDown file in HTML now. This is also the reason why we use the type template.HTML, because we else would see the plain HTML, escaped and literally as the code and not the compiled version that gets interpreted by the browser.

Adding an 404 Error Page

So far, if we try to access, let's say: http://localhost:8080/wombat we get a blank page and also our server crashes. That's because we have not created our error page yet, that we're using in our blog post display route!

I've gone with the following error page template:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <title>404 - Page not Found</title>
</head>
<body>
  <div class="container">
    <h1>404, the article you seek, can not be found!</h1>
    <p><a href="/">Go back to the front page, lost one!</a></p>
  </div>
</body>
</html>

Using c.HTML(http.StatusNotFound, "error.tmpl.html", nil) also sends a 404 status code through HTTP.

Directly below that in our main.go file we're using return which will cancel the execution of this gin context (request).

Adding Static Assets (Images and Stylesheets)

So far, so good, but what about images and stylesheets?

Luckily somebody wrote static asset serving as a middleware already: gin-contrib/static.

Importing "github.com/gin-contrib/static" will make it available and if we want to serve files from a directory called assets, we can register the static files like this:

r.Use(static.Serve("/assets", static.LocalFile("/assets", false)))

If we want to link images in our post we can now do:

![image of a wombat](/assets/wombat-cute.jpg)

If we want to use a stylesheet in the templates, we can reference them as:

<link rel="stylesheet" href="/assets/style.css">

Next Steps and Full Code

Thank you very much for reading! If you want to take your blog adventure further, you could try implementing the following things:

  • Proper post titles
  • Post dates
  • Visitor count statistics

Here's the full example code for a very minimal golang blog web app:

package main

import (
    "fmt"
    "html/template"
    "io/ioutil"
    "log"
    "net/http"

    "github.com/gin-contrib/static"
    "github.com/gin-gonic/gin"
    "github.com/russross/blackfriday"
)

type Post struct {
    Title   string
    Content template.HTML
}

func main() {

    r := gin.Default()
    r.Use(gin.Logger())
    r.Delims("{{", "}}")

    r.Use(static.Serve("/assets", static.LocalFile("/assets", false)))
    r.LoadHTMLGlob("./templates/*.tmpl.html")

    r.GET("/", func(c *gin.Context) {
        var posts []string

        files, err := ioutil.ReadDir("./markdown/")
        if err != nil {
            log.Fatal(err)
        }

        for _, file := range files {
            fmt.Println(file.Name())
            posts = append(posts, file.Name())
        }

        c.HTML(http.StatusOK, "index.tmpl.html", gin.H{
            "posts": posts,
        })
    })

    r.GET("/:postName", func(c *gin.Context) {
        postName := c.Param("postName")

        mdfile, err := ioutil.ReadFile("./markdown/" + postName)

        // if the file can not be found
        if err != nil {
            fmt.Println(err)
            c.HTML(http.StatusNotFound, "error.tmpl.html", nil)
            return
        }

        postHTML := template.HTML(blackfriday.MarkdownCommon([]byte(mdfile)))

        post := Post{Title: postName, Content: postHTML}

        c.HTML(http.StatusOK, "post.tmpl.html", gin.H{
            "Title":   post.Title,
            "Content": post.Content,
        })
    })

    r.Run()
}
Tagged with: #gin #golang #markdown

Thank you for reading! If you have any comments, additions or questions, please tweet or toot them at me!