clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / app / templates / templates.go
templates.go 3.9K
1// Package templates holds the application's server-rendered HTML, one file per2// region, mirroring the one-CSS-file-per-region system carried over from the3// reference implementation (issue #5 §4.5).4//5// Templates render; they do not decide. Every display decision -- legend6// algebra, AND-across/OR-within filtering, label ladders, reading the7// conventions -- happens in Go before a template is given anything, so a8// template that needs an `if` about policy is a signal that the policy is in9// the wrong place.10//11// # The rules these files are held to12//13// One file per region, named for the region, mirroring the one-file-per-region14// stylesheet system carried into internal/app/assets/css. A region's template15// renders that region's own container, so a first paint and a swap produce the16// same bytes and there is no third spelling of the page.17//18// No hx-on. Every interaction is an hx-* attribute naming a route, and the19// only glue on the page is the seam's boot module (issue #5 §4.3).20//21// Inheritance is explicit. hx-vals:inherited and hx-swap:inherited are22// declared once, on the shell, and nothing else inherits: an attribute that23// travels without ":inherited" spelled out is spooky action, and the point of24// declaring it at the top is that a reader of one template can see everything25// acting on it.26//27// The hx-* vocabulary lives here and nowhere else (issue #5 §4.3). The routes,28// the region names, the partial envelope and the viewport state node are all29// framework-neutral contracts, so replacing HTMX would be an afternoon in this30// directory rather than an architecture event.31//32// What must not drift is the names: the region names below, the element ids33// they render, and the partial envelope internal/app wraps them in. Those are34// the contract the handler, the stylesheet system and the seam all agree on.35package templates3637import (38 "embed"39 "fmt"40 "html/template"41 "io"42 "io/fs"43 "sync"44)4546//go:embed *.tmpl47var files embed.FS4849// Regions are the names a template may be rendered under, in the order the50// page lays them out. One file per region, named for the region.51var Regions = []string{52 "shell",53 "topbar",54 "legend",55 "dock",56 "detail",57 "grid-navigator",58 "overview",59 "viewport",60 "mint",61 "empty-state",62 "import",63}6465// parsed is the whole template set. Parsing at package initialisation would66// be init magic; this is a package-level value built by a function the tests67// can also call, and swapped whole by Reload.68var (69 mu sync.RWMutex70 parsed = template.Must(parse(files))71)7273func parse(tree fs.FS) (*template.Template, error) {74 return template.New("atlas").ParseFS(tree, "*.tmpl")75}7677// Reload re-parses the set from another tree, for the development loop:78// `atlas dev` watches the working copy and calls this when a file changes, so79// a template edit is visible on the next request rather than the next build.80//81// A tree that will not parse is refused whole and the set in hand is kept. An82// unbalanced {{if}} halfway through an edit is a normal thing to type, and83// taking the running page down over it would make the loop worse than a84// rebuild.85//86// The embedded tree is what a shipped binary serves and nothing calls this87// there; the fs.FS argument is how the rule that the application never touches88// a filesystem survives having a development loop (issue #5 §3.3).89func Reload(tree fs.FS) error {90 fresh, err := parse(tree)91 if err != nil {92 return fmt.Errorf("reload templates: %w", err)93 }94 mu.Lock()95 defer mu.Unlock()96 parsed = fresh97 return nil98}99100func held() *template.Template {101 mu.RLock()102 defer mu.RUnlock()103 return parsed104}105106// Render writes one region.107func Render(w io.Writer, region string, data any) error {108 if err := held().ExecuteTemplate(w, region, data); err != nil {109 return fmt.Errorf("render %s: %w", region, err)110 }111 return nil112}113114// Has reports whether a region has a template.115func Has(region string) bool { return held().Lookup(region) != nil }