astwerk

5. A collection

The list-plus-detail pattern — an index page and one page per markdown file — is what the content package exists for. Drop a file in content/projects/:

go
+++
Title = "Hello"
Description = "The first project."
+++

The first thing this site can show. Markdown body, rendered by the build.

The +++ block is TOML front matter; the rest renders as HTML. The two pages — the index and one page per file — go in pages/projects.templ:

templ
package pages

// Tile is one entry in the projects index.
type Tile struct {
	Title       string
	Description string
	Slug        string
}

templ Projects(title string, path string, prefix string, tiles []Tile) {
	@Layout(title, path, prefix) {
		<h1>{ title }</h1>
		if len(tiles) == 0 {
			<p>Nothing here yet.</p>
		}
		for _, t := range tiles {
			<div class="tile">
				<a href={ t.Slug + "/" }>{ t.Title }</a>
				<p>{ t.Description }</p>
			</div>
		}
	}
}

// Project renders one markdown file. The body is markdown the build already
// rendered to HTML, so it is trusted markup.
templ Project(title string, path string, prefix string, body string) {
	@Layout(title, path, prefix) {
		<h1>{ title }</h1>
		@templ.Raw(body)
	}
}

main.go gains one function that reads the directory and returns a subtree. This is the whole collection mechanism, and it is just Go:

go
package main

import (
	"log"

	"github.com/LukasDerBaum42/astwerk/content"
	"github.com/LukasDerBaum42/astwerk/ssg"
	"github.com/a-h/templ"
	"example.com/mysite/pages"
)

// FrontMatter is the shape of the TOML block in content/projects/*.md.
type FrontMatter struct {
	Title       string
	Description string
}

func main() {
	root := ssg.Node{
		Title: "My Site",
		Page:  ssg.Templ(pages.Home),
		Children: map[string]ssg.Node{
			"about":    {Title: "About", Page: ssg.Templ(pages.About)},
			"projects": projects(),
		},
	}
	if err := ssg.Build(root, ssg.BuildOptions{}); err != nil {
		log.Fatal(err)
	}
}

// projects reads content/projects and returns the collection subtree: an
// index page and one page per markdown file, generated at build time.
func projects() ssg.Node {
	md, err := content.LoadDir("content/projects")
	if err != nil {
		log.Fatal(err)
	}

	var tiles []pages.Tile
	children := map[string]ssg.Node{}
	for _, slug := range content.Slugs(md) {
		fm, _ := content.Decode[FrontMatter](md[slug])
		body := md[slug].HTML

		children[slug] = ssg.Node{
			Title: fm.Title,
			Page: func(c ssg.Ctx) templ.Component {
				return pages.Project(c.Title, c.Path, c.Prefix, body)
			},
		}
		tiles = append(tiles, pages.Tile{Title: fm.Title, Description: fm.Description, Slug: slug})
	}

	return ssg.Node{
		Title: "Projects",
		Page: func(c ssg.Ctx) templ.Component {
			return pages.Projects(c.Title, c.Path, c.Prefix, tiles)
		},
		Children: children,
	}
}

content.Slugs gives a stable, sorted order (a map range would reshuffle the index every build). Each page closure receives its ssg.Ctx — title, path, prefix — from the walker, same as every other page. Rebuild and you have build/projects/, build/projects/hello/ and one page per file you add from now on.

Try it. Drop a second markdown file into content/projects/ and rebuild.

What you should see

build/projects/ gains a second directory — one index page and one page per file, with nothing to update in the tree.

Why range over content.Slugs instead of the map?

What is the +++ block at the top of a content file?