ds0nt's blog

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:

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.

  1. Load all of our configuration files.
  2. Render HTML templates into memory
  3. 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