TACIX.AT About
Writing my own static site generator
2020/05/18 13:37

Hey, first post! I wanted to set up a blog to track the things that I work on. I figured you can’t beat free, so I set up GitHub Pages. Jekyll is the default for Pages, so I dove in to check it out. I could not really figure out a minimal example. Do I just clone in an entire theme to my repo and start working from there? What are all these directories? I need to install Ruby? Figured I could write one in ~100 lines of code, so I set off.

Golang

I ended up clocking in about ~150 lines for my main file, which I thought would be the heart of the code. The main idea is loop over some markdown posts, md -> html using Blackfriday, then generate the index from the posts.

func main() {
	id := &IndexData{
		Posts: []PostData{},
	}

	genPosts(id)

	writeTemplate(
		[]string{
			"_templates/index.gohtml",
			"_templates/base.gohtml",
		}, "index.html", id)

	genAbout()
}

Since the heart of things is the genPosts function, we’ll look at that. The first block is listing out the markdown files in the _posts folder. Then we kick it off looping over those and generating an HTML file for each.

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

for _, f := range files {
	// ...
}

In our for loop, the first step is reading the file in, which is pretty straight forward Go code.

	md, err := ioutil.ReadFile("_posts/" + f.Name())
	if err != nil {
		log.Println(err)
		continue
	}

Now I jumped right into rendering the markdown using Blackfriday and I kept getting awful parser errors. I was seriously wondering how such a buggy repo could have so many stars! It was user error though. I’m on a Windows host, and the \r\n is apparently not handled by Blackfriday! This was the most time consuming thing of the project. That gives us this beautiful function which is just a bytes.Replace().

	md = windowsBad(md)

In my review of SSGs I noticed they all had some metadata at the top of the file. This is called Front Matter. It seemed simple enough, you have some JSON, YAML, or TOML followed by a delimeter. I chose TOML since I had never used it before. I have a strange affinity for JSON but it is kinda a pain to write. I chose the delimiter ---.

Simply split on that, grab the chunk before and treat it as TOML, rejoin the chunk after and treat it as markdown.

	s := bytes.Split(md, []byte("\n---\n"))
	if len(s) < 2 {
		log.Println("Missing post metadata.")
		continue
	}
	md = bytes.Join(s[1:], []byte("\n---\n"))

	var pd PostData
	_, err = toml.Decode(string(s[0]), &pd)
	if err != nil {
		log.Println(err)
		continue
	}

The TOML reads right into the PostData struct. We have a post title and a date. The tags aren’t used right now, but it should be easy enough to whip up some JS for filtering, or individual pages for each tag. The field Raw is where the markdown rendered to HTML goes.

type PostData struct {
	Title string
	Tags  []string
	Date  time.Time
	Raw   template.HTML
}

After that we render and pass PostData to our templates. Then we append the post to the IndexData structure we passed in so we can go back and write out the index.

	cr := NewChromaRenderer(
		ChromaOptions(html.TabWidth(4)))
	pd.Raw = template.HTML(blackfriday.Run(
		md, blackfriday.WithRenderer(cr)))
	writeTemplate(
		[]string{
			"_templates/post.gohtml",
			"_templates/base.gohtml",
		}, fmt.Sprintf("posts/%s.html", pd.Title), pd)
	id.Posts = append(id.Posts, pd)

The function writeTemplate is just a convenience wrapper I made for executing templates then writing them to a file. Now that I’m writing this I see I should probably check the error that it returns.

func writeTemplate(t []string, o string, d interface{}) error {
	it, err := template.ParseFiles(t...)
	if err != nil {
		return err
	}

	f, err := os.Create(o)
	if err != nil {
		return err
	}
	defer f.Close()

	return it.ExecuteTemplate(f, "base.gohtml", d)
}

I skipped over the NewChromaRenderer above. There is a really nice library called bfchroma that adds syntax highlighting support to Blackfriday (amazing!). It doesn’t load though.

Blackfriday got cute with their packaging and are hosting it on gopkg.in. I had not really seen this before, but it seems like it knocked a lot of things out of sync. Some places are importing github.com/russross/blackfriday/v2. The repo tells you to go get gopkg.in/russross/blackfriday.v2 though, and import that same URL. When installing bfchroma you can an error that it can’t find that github.com URL.

I tried forking the repo and swapping out all the URLs for the gopkg.in one, but something with the module wanted to store it there but import from github.com. It was a mess, I don’t know if it was on my end or the packages’. I just ended up grabbing the one file from bfchroma, doing some small modifications, and hosting it in this repo.

Templates

I learned a bit in this project, one of the things I had never touched before was nesting Go templates. I created a base template for the whole site, then define a few sub templates that get jammed in there. The following is out of base.gohtml.

<body>
  <header>
    <span id=title><a href=/>TACIX.AT</a></span>
    <span id=about><a href=/about>About</a></span>
  </header>

  <div id=main>
  {{ template "main" . }}
  </div>

  <footer>
  </footer>
</body>
</html>

Then in the specific template, e.g. index.gohtml we can just define main.

{{ define "main" }}
  {{ range $p := .Posts }}
    <div class=entry>
      <div class=date>{{ $p.Datef }}</div>
      <div class=post>
      	<a href="/posts/{{ $p.Title }}.html">{{ $p.Title }}</a>
      </div>
    </div>
  {{ end }}
{{ end }}

The writeTemplate function above handles loading multiple templates and then executing from base.gohtml. Neat! I also have one for injecting CSS. In the future when I make some more interactive blogs I will add one for JavaScript.

Conclusion

This took me about a day of programming with a nice break in the middle to take the dog to the dog park with the girlfriend and see the bats in Austin. I really feel setting up a standard static site generator would have taken as long. The base code is easy enough, the big time sink was styling the blog! I think that is the win with existing SSGs, having prebuilt themes. I shouldn’t lie to myself though, I would have definitely taken as long tweaking a prebuilt theme as I did doing my own.

I do like doing things this way. This gives me a base to build future projects off of. I have a static site generator now that I fully understand and can easily add features to. A thought that came to mind with this, it would be pretty easy to take this tech and make a technical-focused markdown-based blogging site. Now if I ever want to build that up, I have a great starting point.

All the code is public so feel free to take a look! The Go code is in _sssg (the extra s was for simple).