Sam Maton

Building a static site generator in GO

When building out this simple static website I setup a basic server in Go, added a few routes and pages and I was off. Easy. If you run it with something like Air then you get live reload, which is something you should no longer ever go without.

When it comes to hosting a static site you don't want to run a server, you just want to generate the static files and then drop them on something like Cloudflare pages. With this in mind I decided to take my simple Go server and add in a static site generator. In Go, this is surpisingly easy to do.

Firstly lets outline the basic project structure:

cmd/
  build/
    main.go
  server/
    main.go
internal/
  template/
    template.go
ui/
  html/
  static/
    

We esentially have two entry points into our app. One which runs the server and one which builds static output. When you are working on your site you run 'go run ./cmd/server' to run the live server and then you run 'go run ./cmd/build' to build it.

The core logic, which is used by both paths, is in the 'template' internal package in 'internal/template/template.go'. It looks something like this:

    
type TemplateCache map[string]*template.Template

type Application struct {
	Cache TemplateCache
}

func NewTemplateCache() (TemplateCache, error) {
	cache := make(TemplateCache)

	pages, err := filepath.Glob("./ui/html/pages/*.html")
	if err != nil {
		return nil, err
	}

	for _, page := range pages {
		name := filepath.Base(page)

		t, err := template.ParseFiles("./ui/html/base.html")
		if err != nil {
			return nil, err
		}

		t, err = t.ParseFiles(page)
		if err != nil {
			return nil, err
		}

		cache[name] = t
	}  
}
    
  

The 'NewTemplateCache' function reads all the HTML pages from the 'ui/html/pages' directory, parses them along with the base template, and stores them in a cache which can simply be used by either path. This way we can check for any errors when parsing the templates.

Then we add a simple render function:

    
func (app *Application) Render(page string, w io.Writer, data any) error {
  t, ok := app.Cache[page]
  if !ok {
    return errors.New("Page not in cache")
  }

  err := t.Execute(w, data)
  if err != nil {
    return errors.New("Cannot execute template")
  }

  return nil
}
    
  

This render function simply looks up the requested page in the template cache and then executes it with the provided data. They key here is the 'io.Writer' interface that we write the output template to. This allows us to either write directly to the HTTP response in the server path or to a new file in the build path.

First lets take a look at the server path:

    
cache, err := template.NewTemplateCache()
if err != nil {
  fmt.Println(err)
  log.Fatal("There was an error creating the template cache")
}

app := template.Application{
  Cache: cache,
}

http.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir("./ui/static/"))))

http.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  err := app.Render("index.html", w, data)
  if err != nil {
    http.Error(w, err.Error(), 500)
  }
}))
    
  

First we set up the template cache and the application struct and then we define our HTTP handlers which use the render function to write the template output to the http response. All thats left is to serve up the static assets and the server path is ready to go!

The build requires a little extra work but its still quite straightforward.

    
cache, err := template.NewTemplateCache()
if err != nil {
  fmt.Println(err)
  log.Fatal("There was an error creating the template cache")
}

app := template.Application{
  Cache: cache,
}

os.RemoveAll("public")
os.MkdirAll("public", 0755)

for k := range app.Cache {
  folder := "public/" + strings.TrimSuffix(k, ".html")
  path := strings.TrimSuffix(folder, "index")
  os.MkdirAll(path, 0755)

  f, err := os.Create(path + "/index.html")
  if err != nil {
		panic(err)
	}
  
  defer f.Close()

  app.Render(k, f, data)
}

err = copyStaticFiles()
if err != nil {
  fmt.Println(err)
}
    
  

As you can see the build process follows a similar pattern. We first set up the template cache and the application struct, clean the output directory and then we create the pages. The second argument to the render function is the file we are creating which implements that 'io.Writer' interface!

A nice little trick here is that instead of creating each page using its full path, we can create a folder for each page and then create an 'index.html' file inside it. This allows us to have clean URLs without the '.html' extension. For example the page 'about.html' would be accessible at '/about/' instead of '/about.html'.

Finally we copy the static files to the output directory to complete the build process. Now this is a slightly more involved process than just serving the files dynamically, but it allows us to have a fully static site that can be deployed anywhere. I have extracted logic into a 'utils' file because it is quite long.

    
func copyStaticFiles() error {

  return filepath.WalkDir("ui/static", func(path string, d fs.DirEntry, err error) error {
    if err != nil {
      fmt.Println(err)
      return err
    }
    assetPath := "public" + strings.TrimPrefix(path, "ui")
    if d.IsDir() {
      os.MkdirAll(assetPath, 0755)
    }

    if d.Type().IsRegular() {
      info, err := d.Info()
      if err != nil {
        return err
      }
      in, err := os.OpenFile(path, os.O_RDONLY, info.Mode())
      if err != nil {
        return err
      }

      defer in.Close()

      out, err := os.OpenFile(assetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
      if err != nil {
        return err
      }

      defer out.Close()

      if _, err := io.Copy(out, in); err != nil {
        return err
      }

      err = out.Sync()
      if err != nil {
        return err
      }
    }

    return nil
  })
}
    
  

I won't explain this whole function step by step (AI can do that for you) but essentially it copies all static files from the 'ui/static' directory to the 'public/static' directory, preserving the directory structure and file permissions.

And there we have it! Our static site is now fully generated and ready to be deployed. I suggest setting up Air for live reloading during development. So go forth and build your next static site with Go!