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 themarkdown
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:
- The
r.Delims()
function sets the characters we decide are the template tags that we're going to use in our templates. - The
r.GET
function defines the route and will respond when/
so the root is accessed - We list the files of the directory with
ioutil.ReadDir
- We loop over the files and put the names into
posts
withappend
- We respond with
c.HTML
, rendering the index template and passing in theposts
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:
r.GET("/:postName"
enables us to use the name of the post inside our route:postName := c.Param("postName")
- we're reading the contents of the markdown file with
ioutil.Readfile
and convert it to HTML withtemplate.HTML(blackfriday.MarkdownCommon([]byte(mdfile)))
- we're responding with the error template and a
404
error if the file isn't found - 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()
}