Writing a simple blog in Go
There’s tons of blogging tools out there already. But they all suck. So let’s just skip to building the only one that doesn’t suck.
We’re going to build a little http server in Go that can serve up all the blogs I wanna write.
One big thing is that I really don’t want to be managing a database when I could be writing files instead. Basically this is why static site generators are out there.
So I want to just write up simple files that I can check into git and keep my blog saved for all of time. I can do it with a folder per blog containing:
- a
blog.yml
for any global blog configuration - a
posts
folder with a<postname>.yml
and<postname>.md
for each of our posts. - an
html
folder with- index.html go template
- post.html go template
- any static files to serve
So an example blog configuration looks pretty much like this:
sites
└── ds0nt.com
├── blog.yml
└── html
├── index.css
├── index.html
└── post.html
└── posts
├── 00-blog.md
├── 00-blog.yml
├── 01-pg-streaming.md
├── 01-pg-streaming.yml
├── 02-multiple-displays.md
└── 02-multiple-displays.yml
└── blog.webcmd.io
├── ...
And have our command signature like:
blog --blog sites/ds0nt.com --blog sites/blog.webcmd.io
The Go Stuff
So our blog program will have a few things to do.
- Load all of our configuration files.
- Render HTML templates into memory
- Run HTTP Server
Loading the files
Since go is awesome, loading yml is easy business.
For our go side, let’s define two structs.
// Blog represents a blog. A blog has many posts.
type Blog struct {
Title string `yaml:"title"`
Domains []string `yaml:"domains"`
Posts map[string]*Post // all our loaded posts, by filename
urls map[string]*Post // all our loaded posts, by url (used for routing in http handler)
indexHTML string // our rendered index.html
}
// Post is a blog post.
type Post struct {
Markdown string `yaml:"markdown"` // raw markdown
Name string `yaml:"name"`
URL string `yaml:"url"`
html string // rendered blog html
Prev, Next *Post
}
Now you pretty much need a package main..
package main
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"path/filepath"
"strings"
"text/template"
"github.com/alecthomas/kingpin"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"github.com/pkg/errors"
"github.com/shurcooL/github_flavored_markdown"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
)
// type Blog ...
// type Post ...
var (
blogs []*Blog
blogsByDomain map[string]*Blog
blogFolder = kingpin.Flag("blog", "blog folders.").Strings()
)
func main() {
blogs = []*Blog{}
blogsByDomain = map[string]*Blog{}
kingpin.Parse()
// load and render
for _, f := range *blogFolder {
b := &Blog{
Posts: map[string]*Post{},
urls: map[string]*Post{},
htmlDir: filepath.Join(f, "html"),
}
blog, err := ioutil.ReadFile(filepath.Join(f, "blog.yml"))
if err != nil {
return err
}
err = yaml.Unmarshal(blog, b)
if err != nil {
return err
}
files, err := ioutil.ReadDir(filepath.Join(f, "posts"))
if err != nil {
return err
}
var prev *Post
for _, file := range files {
fileName := filepath.Join(f, "posts", file.Name())
name := fileName
var isMD, isYML bool
if strings.HasSuffix(fileName, ".md") {
isMD = true
name = strings.TrimSuffix(fileName, ".md")
} else if strings.HasSuffix(fileName, ".yml") {
isYML = true
name = strings.TrimSuffix(name, ".yml")
} else {
continue
}
p, ok := b.Posts[name]
if !ok {
p = &Post{}
b.Posts[name] = p
}
if isMD {
raw, err := ioutil.ReadFile(fileName)
if err != nil {
return err
}
p.Markdown = string(github_flavored_markdown.Markdown(raw))
continue
}
if isYML {
bytes, err := ioutil.ReadFile(fileName)
if err != nil {
return err
}
md := p.Markdown
err = yaml.Unmarshal(bytes, p)
if err != nil {
return err
}
p.Markdown = md
b.urls[p.URL] = p
if prev != nil {
p.Prev = prev
prev.Next = p
}
prev = p
}
}
tmpl := template.Must(template.ParseFiles(filepath.Join(b.htmlDir, "index.html")))
postTmpl := template.Must(template.ParseFiles(filepath.Join(b.htmlDir, "post.html")))
buf := bytes.NewBuffer([]byte{})
err = tmpl.Execute(buf, b)
if err != nil {
return err
}
b.indexHTML = buf.String()
for _, post := range b.Posts {
buf = bytes.NewBuffer([]byte{})
err = postTmpl.Execute(buf, post)
if err != nil {
return err
}
post.html = buf.String()
}
blogs = append(blogs, b)
for _, domain := range b.Domains {
blogsByDomain[domain] = b
}
}
fmt.Fatalln(http.ListenAndServe(":80", httpHandler()))
}
func httpHandler() http.Handler {
e := echo.New()
e.Use(middleware.Logger())
e.GET("/", echo.HandlerFunc(func(c echo.Context) error {
blog, err := getBlog(c)
if err != nil {
return c.String(http.StatusNotFound, err.Error())
}
return c.HTML(http.StatusOK, blog.indexHTML)
}))
e.GET("/*", echo.HandlerFunc(func(c echo.Context) error {
blog, err := getBlog(c)
if err != nil {
return c.String(http.StatusNotFound, err.Error())
}
uri := c.Request().RequestURI
if strings.Contains(uri, ".") {
return c.File(filepath.Join(blog.htmlDir, uri))
}
post, ok := blog.urls[uri]
if !ok {
return c.String(http.StatusNotFound, fmt.Sprintf("the post is not found: %s!", c.Request().RequestURI))
}
return c.HTML(http.StatusOK, post.html)
}))
return e
}
func getBlog(c echo.Context) (*Blog, error) {
// for my setup with traefik, the original hostname gets put into the Via header.
host := c.Request().Header.Get("Via")
if len(host) > 0 {
parts := strings.Split(host, " ")
host = parts[len(parts)-1]
} else {
host = c.Request().Host
}
blog, ok := blogsByDomain[host]
if !ok {
return nil, errors.Errorf("the blog at host %s was not found!", host)
}
return blog, nil
}
Now for some templates:
html/index.html
<html>
<head>
<meta charset="utf-8" />
<title>{{ .Title }}</title>
<link href="index.css" media="all" rel="stylesheet" type="text/css" />
<link href="//cdnjs.cloudflare.com/ajax/libs/octicons/2.1.2/octicons.css" media="all" rel="stylesheet" type="text/css" />
</head>
<body>
<header class="blog-header">
<h1>ds0nt's blog</h1>
<h2>from one misguided developer to another.</h2>
</header>
<article class="markdown-body entry-content" style="padding: 30px;">
<h3>Articles</h3>
{{range .Posts}}
<div><a href="{{.URL}}">{{.Name}}</a></div>
{{end}}
</article>
</body>
</html>
html/post.html
<html>
<head>
<meta charset="utf-8" />
<title>{{ .Name }}</title>
<link href="index.css" media="all" rel="stylesheet" type="text/css" />
<link href="//cdnjs.cloudflare.com/ajax/libs/octicons/2.1.2/octicons.css" media="all" rel="stylesheet" type="text/css" />
</head>
<body>
<header class="blog-header">
<h1>ds0nt's blog</h1>
</header>
<article class="markdown-body entry-content" style="padding: 30px;">
{{ .Markdown }}
</article>
<footer class="blog-footer">
{{ if .Prev }}
<div>Previous Post: <a href="{{ .Prev.URL }}">{{ .Prev.Name }}</a> </div>
{{ end }}
{{ if .Next }}
<div>Next Post: <a href="{{ .Next.URL }}">{{ .Next.Name }}</a> </div>
{{ end }}
<div> <a href="/">Back Home</a></div>
</footer>
</body>
</html>
html/index.css
body {
max-width: 960;
margin: auto;
}
.blog-header {
text-align: center;
padding: 1em;
border-bottom: 1px solid rgba(0, 0, 0, 0.315);
font-family: monospace;
margin-bottom: 1em;
}
.blog-footer {
padding: 1em;
margin-top: 1em;
line-height: 18px;
}
/* and more from github.com/shurcooL/github_flavored_markdown */
posts/00-blog.yml
name: Writing a simple blog in Go
url: /writing-a-simple-blog-in-go
posts/00-blog.md
# Writing a simple blog in Go
There's tons of blogging tools out there already. But they all suck. So let's just skip to building the only one that doesn't suck.
...
and last but not least the blog.yml
title: ds0nt's blog
domains:
- ds0nt.com
- blog.localhost:8000
- ds0nt.pipes-3.pw