Getting started
A first goapplib app: one page, rendered on the server through goapplib's layout.
This page builds a small app with one page, rendered by Go through goapplib’s layout. The code is docsite/examples/hello in goapplib’s repo, included here as it is, and a test renders its page on every pull request. So if you copy it from here, it builds against the goapplib on main. (We started doing it this way after finding that the old guides’ examples didn’t compile.)
Install ¶
go get github.com/panyam/goapplib@v0.6.9
goapplib’s layouts (BasePage.html, the header, the components) are templates, not Go, so your app needs them on disk at run time. There are two ways to get them there, and either is fine to start with.
Read them from the module cache. Go already downloaded them with the module, and go list says where:
go list -m -f '{{.Dir}}' github.com/panyam/goapplib
Pass that directory’s templates/ folder to goapplib.SetupTemplates after your own, which is what the example does. It’s the quickest way to start, and mostly what you’d want for development.
Vendor them with templar. templar, the template library under goapplib, can fetch a pinned copy of goapplib’s templates into your repo, which is better for a deploy that has no module cache. Add a templar.yaml:
sources:
goapplib:
url: github.com/panyam/goapplib
path: templates
ref: v0.6.9
vendor_dir: ./templar_modules
search_paths:
- ./templates
- ./templar_modules
Then run templar get (go install github.com/panyam/templar/cmd/templar@latest installs it), load the config with tmplr.NewSourceLoaderFromConfig, and refer to goapplib’s files as @goapplib/BasePage.html. Give NewSourceLoaderFromConfig an absolute path (filepath.Abs it first), since a relative one can fail depending on the working directory. templar’s vendoring guide has the rest.
The app ¶
The app is one Go package and one template. Here’s the Go:
// Package hello is the app the getting-started guide walks through. The guide includes these
// files as they are, and hello_test.go renders the page, so what a reader copies is what CI built.
package hello
import (
"net/http"
goal "github.com/panyam/goapplib"
)
// Site is the app context: what every page can reach, built once at startup and handed to each
// page's Load as app.Context.
type Site struct {
Name string
Tagline string
}
// Header is the data goapplib's Header template reads, as the page's .Header.
type Header struct {
AppName string
IsLoggedIn bool
Username string
}
// NavItem is one link in the header's navigation, as goapplib's NavigationTabs template reads it.
type NavItem struct {
Href string
Label string
Active bool
}
// HomePage is a page. Embedding goal.BasePage gives it the fields goapplib's BasePage template
// reads (Title, BodyClass, and so on). goapplib's layout also reads .Header and .NavigationItems
// from every page, so a page has those fields too. Load fills them in per request.
type HomePage struct {
goal.BasePage
Header Header
NavigationItems []NavItem
Tagline string
}
// Load runs on every request, before the page renders. Returning true as the second value means
// Load wrote the response itself (a redirect, say), so nothing renders.
func (p *HomePage) Load(r *http.Request, w http.ResponseWriter, app *goal.App[*Site]) (error, bool) {
p.Title = "Home"
p.Header.AppName = app.Context.Name
p.NavigationItems = []NavItem{{Href: "/", Label: "Home", Active: true}}
p.Tagline = app.Context.Tagline
return nil, false
}
// NewHandler builds the app: templates from the app's folder first, then goapplib's, and one page
// at "/". goapplibTemplates is where goapplib's templates/ folder is on disk.
func NewHandler(site *Site, appTemplates, goapplibTemplates string) http.Handler {
templates := goal.SetupTemplates(appTemplates, goapplibTemplates)
app := goal.NewApp(site, templates)
mux := http.NewServeMux()
goal.Register[*HomePage](app, mux, "/{$}")
return mux
}
A few things in it are easy to get wrong.
- The app context is your type.
goal.App[*Site]carries a*Site, built once at startup, and every page’sLoadgets it asapp.Context. It’s the place for the services and settings that pages share. Loadruns once per request, on a fresh page value, before rendering. It returns(error, bool): an error renders as a 500, andtruemeansLoadalready wrote the response itself (a redirect, say), so nothing renders.- Register the pointer type.
goal.Register[*HomePage], notRegister[HomePage], which doesn’t compile, sinceLoadhas a pointer receiver. goapplib makes a freshHomePagefor each request from that pointer type. - The type’s name picks the template.
HomePagerendersHomePage.html, starting at the template defined asHomePage.goal.WithTemplate("pages/Other:Block")changes both (filepages/Other.html, starting atBlock), and the block name defaults to the file’s base name. - Embed
goal.BasePage. It carries the fields goapplib’s layout reads (Title,MetaTitle,BodyClassand the rest). Without it, the page fails to render withcan't evaluate field MetaTitle. - The layout reads
.Headerand.NavigationItemstoo. goapplib’s header template reads.Header.AppName,.Header.IsLoggedInand.Header.Username, and its navigation reads.NavigationItems, a list of{Href, Label, Active}. goapplib doesn’t define types for these yet, so the page brings its own, as above.
The template extends goapplib’s BasePage and fills in its body:
{{# namespace "Goal" "BasePage.html" #}}
{{ define "HomeBody" }}
<main class="max-w-2xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold">{{ .Header.AppName }}</h1>
<p>{{ .Tagline }}</p>
</main>
{{ end }}
{{# extend "Goal:BasePage" "HomeLayout" "Goal:BodySection" "HomeBody" #}}
{{ define "HomePage" }}{{ template "HomeLayout" . }}{{ end }}
namespace loads goapplib’s BasePage.html under the name Goal, and extend makes a copy of its BasePage called HomeLayout, with BodySection swapped for this page’s HomeBody. You can’t just define BodySection again in the page’s own file, because Go’s templates refuse a second definition (multiple definition of template "BodySection"). BasePage has more sections to swap the same way: TitleSection, ExtraHeadSection, HeaderSection, FooterSection, PostScriptsSection and others, listed at the top of goapplib’s templates/BasePage.html.
And a main to serve it:
// Command serve runs the hello app:
//
// go run ./serve -goapplib-templates "$(go list -m -f '{{.Dir}}' github.com/panyam/goapplib)/templates"
package main
import (
"flag"
"log"
"net/http"
"github.com/panyam/goapplib/docsite/examples/hello"
)
func main() {
addr := flag.String("addr", ":8080", "address to serve on")
goapplibTemplates := flag.String("goapplib-templates", "", "goapplib's templates/ folder")
flag.Parse()
site := &hello.Site{Name: "Hello", Tagline: "A goapplib app."}
log.Printf("serving on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, hello.NewHandler(site, "templates", *goapplibTemplates)))
}
Run it from examples/hello, with goapplib’s templates from the module cache:
go run ./serve -goapplib-templates "$(go list -m -f '{{.Dir}}' github.com/panyam/goapplib)/templates"
http://localhost:8080/ shows the page, with goapplib’s header and your body. It’s unstyled, because goapplib’s layout loads /static/css/tailwind.css, which is the app’s to build and serve (mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir("./static"))))). htmx comes from a CDN, so it works as is. templar logs a Template not found warning for each file it looks for in your folder before finding it in goapplib’s. That’s expected, and it’s a bit noisy.
Testing a page ¶
The example’s test renders the page through httptest and checks what came out, which is a quick way to catch a template that no longer matches its page:
package hello
import (
"io"
"net/http/httptest"
"strings"
"testing"
goal "github.com/panyam/goapplib"
)
func TestHomePageRenders(t *testing.T) {
h := NewHandler(&Site{Name: "Hello", Tagline: "A goapplib app."}, "templates", "../../../templates")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
body, _ := io.ReadAll(rec.Body)
if rec.Code != 200 {
t.Fatalf("GET /: %d\n%s", rec.Code, body)
}
for _, want := range []string{
"<title>Home</title>", // BasePage's TitleSection, from p.Title
`<h1 class="text-3xl font-bold">Hello</h1>`, // the page's BodySection
"<p>A goapplib app.</p>",
`id="theme-toggle-button"`, // goapplib's Header, so the layout came from goapplib
} {
if !strings.Contains(string(body), want) {
t.Errorf("the page has no %q", want)
}
}
}
func TestListPageChainsMixinsThroughLoadAll(t *testing.T) {
app := goal.NewApp(&Site{Name: "Hello"}, nil)
var p ListPage
err, done := p.Load(httptest.NewRequest("GET", "/?page=2&q=go", nil), httptest.NewRecorder(), app)
if err != nil || done {
t.Fatalf("Load: %v, %v", err, done)
}
if p.BodyClass == "" || p.CurrentPage != 2 || p.Query != "go" || p.Header.AppName != "Hello" || len(p.Items) != 1 {
t.Errorf("a step didn't run: %+v", p)
}
}
Check the body as well as the status. A template that fails partway through still answers 200, with the page cut off where it failed (#108).
Deploying to App Engine ¶
Nothing in goapplib is specific to App Engine, but the old integration guide deployed there, so here’s the short version. Read the port from PORT, serve static/ through App Engine’s own handler, and send everything else to the app:
runtime: go124
handlers:
- url: /static
static_dir: static
- url: /.*
script: auto
Pick the runtime that matches your go.mod, and ship goapplib’s templates with the app, since App Engine has no module cache at run time (vendor them with templar, as above).
Where next ¶
Concepts covers how a request flows through goapplib, what the app context is for, and how loaders chain. For apps built with it, see lilbattle’s web/ (a game site with auth and pagination) and excaliframe’s site/ (a small marketing site).