Templates
What it is for
Section titled “What it is for”gas/template stores raw template content and hands it out by name. It does
not parse or render anything: gas/ui renders pages and
gas/email renders messages, and both read their
templates through gas.TemplateProvider, the interface declared in core. Where
templates live (the binary, a directory, a database) is decided where the app
is wired, and the renderers never notice.
gas.TemplateProvider has four methods: Get(ctx, name) returns the raw
bytes, List(ctx) returns every name, Register(ctx, name, content) adds or
replaces one, and RegisterFS(ctx, fsys) registers every template file of an
fs.FS. Each store in this module implements it:
| Package | Store | Writes | Use it for |
|---|---|---|---|
template/memory |
An in-process map | Yes | Tests, and the writable layer of a composite |
template/fs |
Any fs.FS, such as an embed.FS |
No | Templates shipped in the binary |
template/dir |
A directory on disk, plus an in-memory overlay | To the overlay | Reading templates from disk without a rebuild |
template/db |
The __gas_templates table on PostgreSQL, MySQL, or SQLite |
Yes | Templates edited at runtime and shared by every instance |
template/composite |
One writable store in front of read-only fallbacks | To the writable store | Overrides over shipped defaults |
template/templatetest |
MockTemplate |
Tests of code that takes a gas.TemplateProvider |
The module reads no configuration.
Install
Section titled “Install”go get github.com/gasmod/gas/templateWiring
Section titled “Wiring”The common case is templates embedded in the binary, served by the fs store:
// embedded holds ./templates, compiled into the binary.////go:embed templatesvar embedded embed.FSapp := gas.NewApp( gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Read-only templates from the binary: "home.html", // "emails/welcome.html", and so on. gas.WithSingletonService[gas.TemplateProvider](templatefs.NewStore(shipped)),)shipped is fs.Sub(embedded, "templates"), so the names are relative to the
templates directory. In development, pass os.DirFS("templates") instead and
the same code reads from disk.
How each store is built and registered differs:
| Store | Constructor | Resolves from the container | Register with |
|---|---|---|---|
fs |
templatefs.NewStore(fsys) returns a constructor |
Nothing | gas.WithSingletonService[gas.TemplateProvider] |
db |
templatedb.NewStore(opts...) returns a constructor |
gas.DatabaseProvider, gas.Logger, gas.MigrationManager |
gas.WithSingletonService[gas.TemplateProvider] |
memory |
memory.NewStore() returns the store |
gas.WithServiceInstance[gas.TemplateProvider] |
|
composite |
composite.NewStore(writable, readers...) returns the store |
gas.WithServiceInstance[gas.TemplateProvider] |
|
dir |
templatedir.NewStore(dir) returns a constructor |
Nothing | Cannot be registered; see Directory store |
The constructors that return a constructor never fail; the error result is always nil, and it is there for the container’s constructor shape.
Template names
Section titled “Template names”The file-based stores and every RegisterFS recognize only files ending in
.html, and name each template by its slash-separated path relative to the
root, such as layouts/base.html or emails/welcome.html. List on the fs
and dir stores returns only those .html names. Get reads whatever path it
is given. The memory and db stores keep any name passed to Register.
Reading templates
Section titled “Reading templates”Code that needs a template takes gas.TemplateProvider and never imports this
module’s stores:
// Mailer reads raw template content through the interface and tells a// missing template apart from a failing store.type Mailer struct { templates gas.TemplateProvider}
func (m *Mailer) Body(ctx context.Context, name string) ([]byte, error) { content, err := m.templates.Get(ctx, name) if template.IsNotFound(err) { return nil, fmt.Errorf("no email template %q", name) } if err != nil { return nil, err } return content, nil}Every store’s Get returns template.ErrTemplateNotFound for a missing name,
and template.IsNotFound(err) reports whether an error wraps it. The fs and
dir stores report any failure to read the file, a permission error included,
as ErrTemplateNotFound.
The memory store returns the slice it stored, not a copy, so do not modify
what Get returns.
Overriding shipped templates
Section titled “Overriding shipped templates”composite.NewStore(writable, readers...) puts one writable store in front of
any number of fallbacks:
// overridable puts a writable memory store in front of the shipped// templates. Register writes to the memory store, and Get finds an override// there before it falls back to the shipped file.func overridable(shipped fs.FS) (gas.Option, error) { readOnly, err := templatefs.NewStore(shipped)() if err != nil { return nil, err } store := composite.NewStore(memory.NewStore(), readOnly)
return gas.WithServiceInstance[gas.TemplateProvider](store), nil}RegisterandRegisterFSgo to the writable store only.Gettries the writable store, then each reader in order, and returns the first success. When none succeeds it returnsErrTemplateNotFound, whatever the stores’ own errors were, so a failing backend also reads as not found.Listmerges the names from every store, sorted and deduplicated. A store whoseListfails is skipped; the first such error is returned only when no store returned any name.
With a db store as the writable layer instead of memory, edits survive a
restart and every instance sees them, while the embedded files stay the
fallback.
Database store
Section titled “Database store”// databaseStore keeps templates in the __gas_templates table of the SQLite// file at path. The store's migration is registered with the MigrationManager, so// gas/migrate must be registered under that interface.func databaseStore(path string) []gas.Option { dbCfg := database.DefaultConfig() dbCfg.Database.Driver = "sqlite" dbCfg.Database.DSN = path
return []gas.Option{ gas.WithSingletonService[gas.DatabaseProvider](database.New(database.WithConfig(dbCfg))), gas.WithSingletonService[gas.MigrationManager](migrate.New()), gas.WithSingletonService[gas.TemplateProvider]( templatedb.NewStore(templatedb.WithNamespace("marketing")), ),
// Migrations run after every service has started, so seed the // table from a ready function, not from another service's Init. gas.WithReadyFunc(func(c *gas.ServiceContainer) error { store, err := c.Resolve[gas.TemplateProvider]() if err != nil { return err } return store.Register(context.Background(), "banner.html", []byte("<p>Spring sale</p>")) }), }}templatedb.NewStore keeps templates in the __gas_templates table. When the
store starts, it picks the queries for the database provider’s driver:
postgres or pgx, mysql, sqlite or sqlite3. Any other driver fails
startup with gas/template/db: unsupported driver. It then registers the
migration that creates the table (version 20260322235959, “create templates
table”) with the gas.MigrationManager, so register
gas/migrate under that interface.
On MySQL the DSN needs two parameters. multiStatements=true, because the
MySQL migration file holds two statements (the CREATE TABLE and a
CREATE INDEX) and gas/migrate runs a file as one statement batch; without it
the migration fails with a syntax error (Error 1064) at the CREATE INDEX
(KI-049).
And parseTime=true, which gas/migrate needs on MySQL; see
Migrations.
templatedb.WithNamespace(ns) scopes every query to a namespace, "default"
when not given, so stores with different namespaces share the table without
seeing each other’s templates. Register upserts by namespace and name, and
List returns the namespace’s names sorted.
*templatedb.Store has two methods beyond the interface. Exists(name)
reports whether a template exists, and Delete(name) removes one and returns
ErrTemplateNotFound when there was none. Both run on context.Background()
rather than taking a context. Database errors from every method are wrapped
with the operation and, where there is one, the template name, as in
template: get "home.html": ....
Directory store
Section titled “Directory store”templatedir.NewStore(dir) reads templates from a directory on disk. The
directory is opened as an os.Root on the first Get or List that reaches
disk, which confines reads to it. Register and RegisterFS write to an
in-memory overlay, never to disk, and Get checks the overlay before the disk.
If opening the directory fails, the Get or List that tried and every later
one that reaches disk return that error: List always does, even when the overlay has names, and
Get does for a name not in the overlay. Register still succeeds, and Get
of an overlay name still returns its content. List
returns the .html files on disk and the overlay’s names, sorted and
deduplicated. If walking the directory fails, List stops there and returns
what it found without an error.
// directoryStore serves templates from ./templates on disk. A dir.Store// cannot be registered in the container (KI-033), so build it yourself,// use it as a value, and close it yourself.func directoryStore() (gas.TemplateProvider, func() error, error) { onDisk, err := templatedir.NewStore("templates")() if err != nil { return nil, nil, err } return composite.NewStore(onDisk), onDisk.Close, nil}Wrapping the store in a composite gives the container a value with no Close,
so registering the result with gas.WithServiceInstance[gas.TemplateProvider]
works. Close releases the root and drops the overlay, waiting for reads in
flight; a second Close returns nil. After Close, Get, List, Register,
and RegisterFS return template.ErrClosed, and the store cannot be opened
again.
Errors
Section titled “Errors”| Error | Returned by | When |
|---|---|---|
template.ErrTemplateNotFound |
Get on every store; templatedb.Store.Delete |
No template has the name |
template.ErrReadOnly |
Register and RegisterFS on the fs store |
Always |
template.ErrClosed |
Get, List, Register, RegisterFS on the dir store |
After Close |
gas/template/db: unsupported driver: "..." |
db store startup |
The database driver is not one the store supports |
template.IsNotFound(err) is errors.Is(err, template.ErrTemplateNotFound).
On the memory, dir, and db stores, RegisterFS wraps a read or register
failure with the file’s path, under template: register fs:. Through a composite, Register errors are wrapped
under composite: register:, so errors.Is(err, template.ErrReadOnly) still
matches.
Health and readiness
Section titled “Health and readiness”No store implements gas.HealthReporter or gas.ReadyReporter. The db
store’s health is the database’s: see Database.
Testing
Section titled “Testing”templatetest.MockTemplate implements gas.TemplateProvider. Each method
records the call in Calls and delegates to GetFn, ListFn, RegisterFn, or
RegisterFSFn when set. Unset, every method returns zero values: Get returns
nil content and a nil error, not ErrTemplateNotFound. CallCount(method)
counts the calls to one method, and Reset clears them.
func TestMailerReportsMissingTemplate(t *testing.T) { t.Parallel() mock := &templatetest.MockTemplate{ GetFn: func(context.Context, string) ([]byte, error) { return nil, template.ErrTemplateNotFound }, } m := &Mailer{templates: mock}
if _, err := m.Body(context.Background(), "welcome.html"); err == nil { t.Fatal("Body: want an error for a missing template") } if got := mock.CallCount("Get"); got != 1 { t.Fatalf("Get calls = %d, want 1", got) }}When the test needs real content rather than call records, a memory store
filled with Register is often simpler.