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/:
+++
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:
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:
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?
Go randomises map iteration order; Slugs returns a sorted order so builds are reproducible.
What is the +++ block at the top of a content file?
The front matter between +++ markers is TOML, decoded into your own struct with content.Decode; the rest of the file renders as markdown.