Building a Go Static Site Generator Using elem-go and goldmark
Tags: go, webdev, tutorial, frontend
Published: 2023-12-07 21:43:40

In this tutorial, we'll build a static site generator using `elem-go` for HTML templating and `goldmark` for Markdown processing. This generator will convert Markdown files into HTML and create an index page listing all posts.

> **Note**: You can clone [this template](https://github.com/chasefleming/elem-ssg) to use or follow along with the tutorial.

## Explaining the Dependencies

- [`elem-go`](https://github.com/chasefleming/elem-go): A Go library for type-safe HTML element creation. It simplifies the process of building HTML templates in Go.
- [`goldmark`](https://github.com/yuin/goldmark): A Markdown parser and renderer for Go. It converts Markdown files into HTML.

## Creating Our Project

We'll structure our project with a `main` Go file, a `posts` directory for Markdown, a `public` directory for the generated HTML, and a Go module file. In the end, it will look like this:

```
my-static-site/
│
├── main.go             # Your main Go file
├── posts/              # Directory containing Markdown files
│   ├── post1.md
│   ├── post2.md
│   └── ...
├── public/             # Directory where generated HTML files will be stored
└── go.mod              # Go module file
```

To begin, let's create our project directory, navigate to it, and initialize a new Go project:

```shell
mkdir my-static-site
cd my-static-site
go mod init my-static-site
```

Next, we need to install our dependencies:

```go
go get github.com/chasefleming/elem-go
go get github.com/yuin/goldmark
```

## Adding Our Content

Next, create a `posts` directory and a `hello-world.md` Markdown file within it. This file will serve as your first post on the site.

```shell
mkdir posts
touch posts/hello-world.md
```

Let's also add some content:

```
# Hello, World!  
  
This is a sample post to get you started. You can edit or delete this post and begin adding your content.
```
## Building Our Generator

Our generator code will take the Markdown files we have and convert them into HTML pages. The conversion of Markdown to HTML will happen with the `goldmark` library, but the templating of the pages will happen in `elem-go`. It enables writing type-safe Go code to generate HTML, eliminating the need to parse separate template files.

Let's first create our `main.go` file:

```shell
touch main.go
```

In `main.go`, let's import the dependencies we need:

```go
package main  
  
import (  
    "github.com/chasefleming/elem-go"
    "github.com/chasefleming/elem-go/attrs"
    "github.com/chasefleming/elem-go/styles"
    "github.com/yuin/goldmark"
)
```

You'll notice the `elem-go` has two subpackages. These come with `elem-go` and provide type-safe ways of handling attributes and styles. They help avoid having to write common strings and come with other utility methods.

Next, let's make a `layout` function that takes a `title` and `content` and returns the html structure of our page using the arguments passed in:

```go
func layout(title string, content elem.Node) string {  
    htmlPage := elem.Html(nil,  
       elem.Head(nil,  
          elem.Title(nil, elem.Text(title)),  
       ),       elem.Body(nil,  
          elem.Header(nil, elem.H1(nil, elem.Text(title))),  
          elem.Main(nil, content),  
          elem.Footer(nil, elem.Text("Footer content here")),  
       ),    )  
    return htmlPage.Render()  
}
```

You'll notice that instead of having to write HTML, you can use `elem-go` to generate HTML with methods like `elem.Main` which generates a `<main>` element.

After that, let's create another function to actually write the HTML generated with `layout` to a file based on the `title`. Since we generated a chunk of HTML using `goldmark` and it's not `elem-go` syntax, we'll pass it into `elem.Raw` which takes raw HTML and returns an `elem.Node` type which is compatible with the library:

```go
func createHTMLPage(title, content string) string {  
    htmlOutput := layout(title, elem.Raw(content))  
  
    postFilename := title + ".html"  
    filepath := filepath.Join("public", postFilename)  
    os.WriteFile(filepath, []byte(htmlOutput), 0644)  
    return postFilename  
}
```

We also need a function that will take our Markdown and use `goldmark` to convert it to HTML. Let's add that:

```go
func markdownToHTML(content string) string {  
    var buf bytes.Buffer  
    md := goldmark.New()  
    if err := md.Convert([]byte(content), &buf); err != nil {  
       log.Fatal(err)  
    }    return buf.String()  
}
```

Now that we have that, we'll additionally need a function that will walk through all of our posts, read the file, and pass the found markdown to the `markdownToHTML` function we just created. The return of the function should be an array of all the converted posts:

```go
func readMarkdownPosts(dir string) []string {  
    var posts []string  
    err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {  
       if !info.IsDir() {  
          content, err := os.ReadFile(path)  
          if err != nil {  
             return err  
          }  
  
          htmlContent := markdownToHTML(string(content))  
          title := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))  
          postFilename := createHTMLPage(title, htmlContent)  
  
          posts = append(posts, postFilename)  
       }       return nil  
    })  
    if err != nil {  
       log.Fatal(err)  
    }    return posts  
}
```

All of our posts pages aren't great if we don't have an index page to find them all from. Again using `elem-go`, make a simple list of items with `elem.A`, `elem.Ul`, and `elem.Ul` and pass it to our layout. We also use the `attrs` subpackage to add the `href` using `attrs.Href` inside of `attrs.Props{}` to the `<a>` element. After that we'll save it to `public/index.html`:

```go
func createIndexPage(postFilenames []string) {  
    listItems := make([]elem.Node, len(postFilenames))  
    for i, filename := range postFilenames {  
       link := elem.A(attrs.Props{attrs.Href: "./" + filename}, elem.Text(filename))  
       listItems[i] = elem.Li(nil, link)  
    }  
    indexContent := elem.Ul(nil, listItems...)  
    htmlOutput := layout("Home", indexContent)  
  
    filepath := filepath.Join("public", "index.html")  
    os.WriteFile(filepath, []byte(htmlOutput), 0644)  
}
```

When we run our project remember we'll not have created the `posts` or `public` directories yet, so let's make a function to create those directories if they don't exist:

```go
func createDirIfNotExist(dir string) {  
    if _, err := os.Stat(dir); os.IsNotExist(err) {  
       err := os.Mkdir(dir, 0755) // or 0700 if you need it to be private  
       if err != nil {  
          log.Fatal(err)  
       }    }}
```

Now in our `main` function we can put it all together to first create the required directories, then read and create all the posts, then create our index page:

```go
func main() {  
    createDirIfNotExist("posts")  
    createDirIfNotExist("public")  
  
    posts := readMarkdownPosts("posts")  
    createIndexPage(posts)  
}
```

## Styling Our Site

`elem-go`'s `styles` subpackage lets you create groups of style properties in a type-safe way. Let's create some styles to make our site look better:

```go
headerStyle := styles.Props{  
    styles.BackgroundColor: "lightblue",  
    styles.Color:           "white",  
    styles.Padding:         "10px",  
    styles.TextAlign:       "center",  
}  
  
footerStyle := styles.Props{  
    styles.BackgroundColor: "lightgrey",  
    styles.Color:           "black",  
    styles.Padding:         "10px",  
    styles.TextAlign:       "center",  
}  
  
mainStyle := styles.Props{  
    styles.Padding: "20px",  
}
```

Then we'll use the `attrs` subpackage to add them as an inline-style and convert the style object to an inline style string using `ToInline()`:

```go
elem.Body(nil,  
    elem.Header(attrs.Props{attrs.Style: headerStyle.ToInline()},  
       elem.H1(nil, elem.Text(title)),  
    ),    elem.Main(attrs.Props{attrs.Style: mainStyle.ToInline()},  
       content,  
    ),    elem.Footer(attrs.Props{attrs.Style: footerStyle.ToInline()},  
       elem.Text("Footer content here"),  
    ),),
```

## Opening Our Site

We did it! We now have a static site. Open `public/index.html` in your browser to see it working:

```shell
open public/index.html
```

Or if you're on Windows, run:

```shell
start public\index.html
```