Getting started
Your first page
A hands-on tutorial — build a recipe page with a layout, a typed data handler and a template, then add a second fragment in a slot.
This tutorial builds a small recipe site in a fresh project: a page at
/recipes/{slug} that loads a recipe and renders it inside the site's layout,
and then a second fragment — a list of other recipes — placed in a slot of the
first. It takes about fifteen minutes, and every piece of it is one you will use
on every page you write.
You need Go and the collage CLI; see Installation.
Start a project
collage new cookbook --template minimal
cd cookbook
go mod tidy
collage dev
Open http://localhost:3000 and leave collage dev
running. From here on, every Go file you save is rebuilt and the browser reloads
itself; every template you save shows up on the next render without a rebuild.
Look at the layout
A minimal project already has one layout, and every page uses it. It is two
files. The fragment, in fragments/layouts/main.go:
func Layout() *collage.Fragment {
return collage.NewFragment("layout", "layouts/default.html").
WithDataHandler(collage.Effect(func(_ context.Context, rc *collage.RenderContext) error {
rc.HoistTitle("cookbook")
return nil
})).
WithSlot("content", true, false).
Build()
}
And its template, templates/layouts/default.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{hoist "head"}}
<link rel="stylesheet" href="{{asset "/static/app.css"}}">
</head>
<body>
{{slot "content"}}
</body>
</html>
WithSlot("content", true, false) declares a slot named content that is
required and holds one fragment. {{slot "content"}} is where it renders. You
never fill this slot yourself: when a page is registered, collage puts the page's
content fragment into it. That is what makes one layout shareable by every page.
There is no <title> in the template. The layout's handler declares one with
rc.HoistTitle, and {{hoist "head"}} is where it lands. A page that declares its
own title replaces the site's rather than adding a second one — the recipe page
will, in a moment. See Head and SEO.
Where the content comes from
A real site loads recipes from a database or a CMS. Here, a map will do. Create
recipes/recipes.go:
// Package recipes is where this site's content comes from.
package recipes
import (
"context"
"fmt"
"sort"
"github.com/Elagoht/collage/pkg/collage"
)
type Recipe struct {
Slug string
Title string
Minutes int
Ingredients []string
}
var all = map[string]Recipe{
"pancakes": {Slug: "pancakes", Title: "Pancakes", Minutes: 20, Ingredients: []string{"flour", "milk", "eggs", "butter"}},
"omelette": {Slug: "omelette", Title: "Omelette", Minutes: 10, Ingredients: []string{"eggs", "butter", "salt"}},
"flatbread": {Slug: "flatbread", Title: "Flatbread", Minutes: 30, Ingredients: []string{"flour", "water", "salt", "olive oil"}},
}
// Get returns the recipe with slug, or an error wrapping collage.ErrNotFound.
func Get(ctx context.Context, slug string) (Recipe, error) {
if err := ctx.Err(); err != nil {
return Recipe{}, err
}
recipe, ok := all[slug]
if !ok {
return Recipe{}, fmt.Errorf("recipes: no recipe %q: %w", slug, collage.ErrNotFound)
}
return recipe, nil
}
// List returns every recipe, by title.
func List(ctx context.Context) ([]Recipe, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
list := make([]Recipe, 0, len(all))
for _, recipe := range all {
list = append(list, recipe)
}
sort.Slice(list, func(i, j int) bool { return list[i].Title < list[j].Title })
return list, nil
}
Two details matter later. A missing recipe is reported by wrapping
collage.ErrNotFound with %w, which is how the framework tells "this does not
exist" (a 404) from "this broke" (a 500). And both functions take a
context.Context, because a data handler's time is bounded through its context.
The content fragment
A fragment is a template plus, optionally, a function that fetches what the
template renders. Create pages/recipe.go:
package pages
import (
"context"
"github.com/Elagoht/collage/pkg/collage"
"cookbook/fragments/layouts"
"cookbook/recipes"
)
// RecipePage is one recipe, at /recipes/{slug}.
func RecipePage() *collage.Page {
content := collage.NewFragment("recipe-content", "pages/recipe.html").
WithDataHandler(collage.DataHandler(loadRecipe)).
Required().
Build()
return collage.NewPage("recipe").
WithLayout(layouts.Layout()).
WithContent(content).
WithPath("en", "/recipes/{slug}").
Build()
}
// loadRecipe is the content fragment's data handler.
func loadRecipe(ctx context.Context, rc *collage.RenderContext) (recipes.Recipe, []string, error) {
recipe, err := recipes.Get(ctx, rc.Param("slug"))
if err != nil {
return recipes.Recipe{}, nil, err
}
rc.HoistTitle(recipe.Title + " — cookbook")
return recipe, []string{"recipe:" + recipe.Slug}, nil
}
Take it a line at a time.
NewFragment("recipe-content", "pages/recipe.html")names the fragment and its template. The template path is relative totemplates/, extension included.collage.DataHandler(loadRecipe)adapts a handler written against your own type.loadRecipereturns arecipes.Recipe, so the template receives one, and nothing in your code has to deal in untyped values.- The handler returns three things: the data, the dependency tags it was built
from, and an error. The tag
recipe:pancakessays "this page shows the pancakes recipe", which is what lets a cached copy be thrown away when that recipe changes. See Data handlers. rc.Param("slug")is the{slug}the URL matched.rc.HoistTitlegives the page its own<title>. The content fragment sits inside the layout, and the innermost declaration wins, so it replaces the layout'scookbook.Required()says the page cannot exist without this fragment. Its failure fails the page, and itsErrNotFoundmakes the page a 404.NewPage("recipe")names the page. The name is how links, tests and plugins refer to it, so it should not change when the URL does.WithPath("en", "/recipes/{slug}")is the URL, in the site's default locale.
A page with no strategy method is Dynamic(): rendered on every request, never
cached. That is the right default while you are building it. Once it works,
Incremental(10 * time.Minute) or Static() would cache it — see
Caching.
The template
Create templates/pages/recipe.html:
<main class="recipe">
<h1>{{.Title}}</h1>
<p>Ready in {{.Minutes}} minutes.</p>
<h2>Ingredients</h2>
<ul>
{{range .Ingredients}}<li>{{.}}</li>{{end}}
</ul>
</main>
. is the recipes.Recipe the handler returned. This is Go's html/template, so
every value is escaped for where it appears; a recipe titled
<script> would be printed, not run. See Templates.
Register the page
A page does nothing until the application knows about it. Open routes.go and add
pages.RecipePage() to the list:
for _, page := range []*collage.Page{pages.HomePage(), pages.RecipePage()} {
if err := app.RegisterPage(page); err != nil {
return fmt.Errorf("register page %q: %w", page.Name, err)
}
}
Registration is where mistakes are caught. A template path with a typo, a required slot with nothing in it, two pages with one name, a malformed path — each one stops the program at startup with an error naming the page, rather than waiting for the first visitor to find it.
See it
Save, and watch the terminal: collage dev rebuilds and restarts. Now open
localhost:3000/recipes/pancakes.
The page is the layout with your fragment in its content slot. Edit
templates/pages/recipe.html — add a sentence, change a heading — and the browser
reloads with the change; no rebuild happened, because templates are read from disk
on every request in development.
Now try /recipes/lasagne. recipes.Get
wrapped collage.ErrNotFound, the fragment is Required(), so the response is a
404. The body is collage's own plain not-found page, because the project has not
registered one; app.RegisterNotFoundPage gives the site its own — see
Pages and layouts.
Break something on purpose to see what failure looks like. Change {{.Title}} to
{{.Name}} in the template, save, and reload: the field does not exist, the
required fragment fails, and the page is a 500. In development the error page
names recipe-content as the fragment where the failure started and prints the
whole error chain, down to pages/recipe.html:2:8 and the field it could not
find. Put it back.
Add a second fragment in a slot
A page is rarely one piece. Add a list of other recipes, as its own fragment with
its own data. Create pages/more.go:
package pages
import (
"context"
"github.com/Elagoht/collage/pkg/collage"
"cookbook/recipes"
)
// moreView is what the "more recipes" fragment renders.
type moreView struct {
Recipes []recipes.Recipe
}
// MoreRecipes lists every recipe but the one on the page.
func MoreRecipes() *collage.Fragment {
return collage.NewFragment("more-recipes", "fragments/more-recipes.html").
WithDataHandler(collage.DataHandler(loadMore)).
Build()
}
func loadMore(ctx context.Context, rc *collage.RenderContext) (moreView, []string, error) {
list, err := recipes.List(ctx)
if err != nil {
return moreView{}, nil, err
}
var view moreView
for _, recipe := range list {
if recipe.Slug != rc.Param("slug") {
view.Recipes = append(view.Recipes, recipe)
}
}
return view, []string{"recipes"}, nil
}
Its template, templates/fragments/more-recipes.html:
<aside class="more-recipes">
<h2>More recipes</h2>
<ul>
{{range .Recipes}}
<li><a href="{{pageURL "recipe" "slug" .Slug}}">{{.Title}}</a></li>
{{end}}
</ul>
</aside>
pageURL "recipe" "slug" .Slug builds the link from the page's name rather than
from its path, so it follows the page if /recipes/{slug} ever becomes
/r/{slug}. See Links and locales.
Now give the recipe fragment a slot and put the list in it. In pages/recipe.go:
content := collage.NewFragment("recipe-content", "pages/recipe.html").
WithDataHandler(collage.DataHandler(loadRecipe)).
WithSlot("more", false, false).
WithSlotFragment("more", MoreRecipes()).
Required().
Build()
And render the slot where it belongs, in templates/pages/recipe.html, before
</main>:
{{slot "more"}}
Save both. The recipe page now has a list of the other two recipes, each linking to its own page.
Three things are true of this page that were not written down anywhere.
- The list is optional.
WithSlot("more", false, false)declared the slot not required, andMoreRecipesis notRequired(). IfloadMorefails, the page is served without the list — and, in development, with a panel saying which fragment failed and why. A broken sidebar is a missing sidebar, not a 500. - The page's tags are both fragments' tags. The page now depends on
recipe:pancakesand onrecipes. Once it is cached, invalidatingrecipesdrops every recipe page — which is what adding a recipe should do — and invalidatingrecipe:pancakesdrops only this one. - The two handlers did not wait on each other more than they had to. A child's handler starts once its parent's has returned; siblings in the slots of one fragment start together. Give the recipe fragment a second slot with a slow fragment in it and the page waits for the slowest of them, not for their sum. See Data handlers.
Test it
A collage application is tested without a server: app.Handler() is an ordinary
http.Handler, and net/http/httptest drives it. Create main_test.go with a
helper that builds the application through the same newApp that main uses, and
a test:
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func get(t *testing.T, target string) *httptest.ResponseRecorder {
t.Helper()
cacheDir = t.TempDir() // a disk cache of its own, not the last run's
app, err := newApp(false, 0)
if err != nil {
t.Fatalf("newApp() = %v", err)
}
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
func TestRecipePage(t *testing.T) {
rec := get(t, "/recipes/pancakes")
if rec.Code != http.StatusOK {
t.Fatalf("GET /recipes/pancakes = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), `href="/recipes/omelette"`) {
t.Errorf("the page does not link to the other recipes:\n%s", rec.Body.String())
}
if rec := get(t, "/recipes/lasagne"); rec.Code != http.StatusNotFound {
t.Errorf("GET /recipes/lasagne = %d, want 404", rec.Code)
}
}
go test ./...
Where to go next
- Pages and layouts — paths, strategies, error pages, and what registration does to a page.
- Fragments and slots — slots, failure policy, and slots filled from content.
- Data handlers — the handler contract, sharing data between fragments, and timeouts.
- Caching — turning this page from
Dynamic()into one that is rendered once and thrown away when a recipe changes.