astwerk

Content & collections

Markdown with TOML front matter, and the list-plus-detail pattern.

The content package reads markdown files with TOML front matter. Combined with Node.Generate, that gives you a collection: one index page plus one page per file.

Documentation

Page

type Page struct {
	Meta map[string]any // front matter between +++ markers, or nil
	HTML string         // the rendered markdown body
}

Page is what every loader returns. The front matter fence is content.Delimiter ("+++"); a document without a leading fence is all body. Meta is the raw front matter map — Decode turns it into your own struct.

Load and Parse

func Load(path string) (Page, error)
func Parse(src string) (Page, error)

Load reads a file, Parse takes a string. Both render through goldmark with raw HTML passed through, so a page can drop into hand-written markup.

LoadDir and LoadTree

func LoadDir(dir string) (map[string]Page, error)  // non-recursive, keyed by slug
func LoadTree(dir string) (map[string]Page, error) // recursive, keys like "2024/hello"

LoadDir reads the *.md files in one directory, keyed by filename without the extension — the slug. LoadTree is the recursive variant, keyed by path relative to dir: content/blog/2024/hello.md becomes 2024/hello. Keys are slash-separated on every platform and may nest, so they drop straight into Node.Children:

Generate: func() map[string]ssg.Node {
	pages, _ := content.LoadTree("content/blog")
	out := map[string]ssg.Node{}
	for _, slug := range content.Slugs(pages) { // "2024/hello"
		out[slug] = ssg.Node{Page: post(pages[slug])}
	}
	return out
},

Slugs

func Slugs(pages map[string]Page) []string // sorted — always range this

Ranging the map directly gives a different order on every build, which shuffles any index page built from it.

How this bit the first site

The first site built with astwerk produced byte-identical output on every page except the collection index, which shuffled its tiles on each run. Slugs exists so that failure mode is one explicit call away from impossible.

Decode

+++
Title = "Dice Dungeon"
Description = "A roguelike"
Tags = ["game", "go"]
+++

Body here.
type FrontMatter struct {
	Title       string
	Description string
	Tags        []string
}

fm, err := content.Decode[FrontMatter](page)

Front matter is deliberately a generic map, not a fixed struct — every site wants different fields, so Decode unmarshals into whatever you define. A page with no front matter decodes to the zero value without error.

Why Meta is a map, not a struct

There is no front matter schema the library could know — every site wants different fields. So Page.Meta stays a generic map and Decode is the bridge to your own struct: the map is the storage, your struct is the interface.

Chunk

func Chunk[T any](items []T, size int) [][]T

Splits a slice into groups of at most size, in order, for paginating a collection. It's deliberately just the arithmetic — turning the groups into pages is three lines, and every site wants them slightly differently.

Paging a collection
"blog": {
	Page: index(pages[0]), // page one is the collection index
	Generate: func() map[string]ssg.Node {
		out := map[string]ssg.Node{}
		for i, group := range content.Chunk(posts, 10) {
			if i > 0 {
				out["page/"+strconv.Itoa(i+1)] = ssg.Node{Page: index(group)}
			}
			for _, p := range group {
				out[p.Slug] = ssg.Node{Title: p.Title, Page: post(p)}
			}
		}
		return out
	},
},

A size of zero or less returns one group holding everything, so a site that hasn't decided on a page size yet doesn't have to special-case it.

Custom markdown

The default renderer is goldmark with raw HTML enabled. To add extensions, build your own goldmark and pass it to a Loader:

md := goldmark.New(
	goldmark.WithExtensions(
		extension.GFM,
		highlighting.NewHighlighting(
			highlighting.WithStyle("github"),
			highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
		),
	),
	goldmark.WithRendererOptions(html.WithUnsafe()),
)

pages, err := content.NewLoader(md).LoadDir("content/docs")

The docs you're reading are rendered exactly that way — highlighting happens at build time via chroma, so no JavaScript highlighter ships to the browser.

Example

func projects(dir string) ssg.Node {
	pages, err := content.LoadDir(dir)
	if err != nil {
		log.Fatal(err)
	}

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

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

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

// "projects": projects("content/projects"),

Each child gets its path from the walker, so adding a markdown file adds a page and a tile with nothing else to update. Tile links should be bare slugs (href="thing/") so they resolve relative to whichever index page they're on.