Building a site
Static assets
Serving stylesheets, scripts, images and downloads from a directory or an embedded file system, with URLs that can be cached for a year.
Stylesheets, scripts, images, fonts and downloads are served by an asset mount: a file system served under a URL prefix, as files.
root, err := os.OpenRoot("static")
if err != nil {
log.Fatal(err)
}
if err := app.Mount("/static/", root.FS()); err != nil {
log.Fatal(err)
}
app.Mount takes any fs.FS, which covers both sources that matter: a directory
on disk and an embed.FS compiled into the binary. static/app.css is now
/static/app.css.
Mounts are not pages. They never enter the page cache, are not
tagged and are not reached by InvalidateTags. A 50 MB video in a cache sized for
pages would push out thousands of them; so files are served as files.
Use os.OpenRoot, not os.DirFS
os.DirFS looks like the obvious way to serve a directory, and it is the wrong
one for the internet. Its own documentation says it does not prevent symlink
traversal: a symlink inside the directory that points outside it is followed, and
whatever it points at is served.
os.OpenRoot is enforced by the operating system. Every path it opens is resolved
inside the directory it holds, and a symlink that leaves the directory fails to
open at all. It has been in Go since 1.24.
collage cannot close this gap for you. It is handed an fs.FS and calls Open on
it; whether the file system stays inside its directory is a property of the file
system. What collage does do is clean the path in the URL, with path.Clean,
before Open is ever called. A .. or . that stays inside the mount is
normalised and served — /static/css/../app.css is /static/app.css — and one
that climbs out of it, or a path that starts with an empty element
(/static//…), is refused with a 404. That stops a traversal written in the URL,
not one built out of symlinks on disk.
Keep the *os.Root open for the life of the process: its file system serves every
request.
Embedding
An embed.FS has no symlinks and is safe by construction. It also lets the binary
run from any working directory. Use fs.Sub to strip the directory name, or the
stylesheet answers at /static/static/app.css:
//go:embed all:static
var staticFS embed.FS
func mountAssets(app *collage.App) error {
static, err := fs.Sub(staticFS, "static")
if err != nil {
return err
}
return app.Mount("/static/", static)
}
In development you want the directory on disk instead — the embedded copy was fixed when the binary was built, so editing a file would change nothing. A scaffolded project does this:
func staticFiles(devMode bool) (fs.FS, error) {
if devMode {
if root, err := os.OpenRoot("static"); err == nil {
return root.FS(), nil
}
}
return fs.Sub(staticFS, "static")
}
collage makes this choice for templates itself, because Template.Root tells it
where they are on disk. A mount is handed only an fs.FS, so where its files came
from is something only your code knows.
Content-addressed URLs
Link a mounted file with asset, not by writing its path:
<link rel="stylesheet" href="{{asset "/static/app.css"}}">
<script src="{{asset "/static/app.js"}}" defer></script>
What goes into the page is the file's name with a hash of its content in it:
<link rel="stylesheet" href="/static/app.0d5f2b53aebf6c72.css">
and that URL is served with
Cache-Control: public, max-age=31536000, immutable
— a year, and a promise that it will never change. The promise is honest because the name comes from the bytes: when the file changes, the name changes, pages link the new one, and the old URL is simply never asked for again. Browsers and CDNs keep the file for a year and never revalidate it, and nobody is ever served a stylesheet that does not match the page.
A plain name cannot be cached that way at any lifetime. Sooner or later it hands someone yesterday's stylesheet with today's HTML.
Three details:
- A file that does not exist is an error, not a URL.
{{asset "/static/typo.css"}}fails the fragment withcollage.ErrUnknownAsset. The alternative is a page that renders "successfully" while linking a stylesheet that 404s. - The hash in a request is checked. A URL whose hash is not the file's current one is a 404, not the file. Serving it would pin a wrong answer in front of everyone behind a shared cache, for a year.
- Plain names still work.
/static/app.cssis served too, with the mount's own, shorterCache-Control. Use it where a URL must stay the same — a favicon referenced from outside the site, a file linked from an email.
From Go
A data handler that needs the URL — for an Open Graph image, a preload hint — asks the render context:
cover, err := rc.Asset("/static/covers/" + post.Slug + ".jpg")
if err != nil {
return postView{}, nil, err
}
rc.HoistProperty("og:image", siteOrigin+cover)
rc.Asset returns exactly what {{asset}} renders, and collage.ErrUnknownAsset
for a path no mount serves. It works in a document's
handler too, since v0.10.0 — an icon URL in a web manifest. For stylesheets,
rc.HoistStylesheet and {{stylesheet}} do the lookup and the <link> in one
step — see Head and SEO.
Options
err := app.Mount("/media/", media.FS(),
collage.WithCacheControl("public, max-age=86400"),
collage.WithoutBuildCopy(),
)
| Option | Effect |
|---|---|
collage.WithCacheControl(value) |
The Cache-Control for files requested by their plain name. Default public, max-age=3600 |
collage.WithoutBuildCopy() |
Static export does not copy this mount into its output |
WithCacheControl is about plain names only; content-addressed URLs are always
served with a year and immutable, because that is what their names earn. How
long a plain name may be kept depends on how often the file changes under it, which
only you know — so it is yours to set.
WithoutBuildCopy is for a mount that production serves from somewhere else, such
as a CDN, or one too large to duplicate into every build.
What a file response contains
A mount is a thin layer over fs.Open and Go's http.ServeContent, not
http.FileServer:
| Methods | GET and HEAD. Anything else is a 405 with Allow: GET, HEAD |
Content-Type |
From the file extension, falling back to sniffing the first 512 bytes |
ETag |
A strong hash of the file's content, computed on first request and remembered |
Range |
Supported, with If-Range and 206 Partial Content |
| Directories | Never listed. A directory, or the bare prefix, is a 404 |
index.html |
Never served implicitly |
| A missing file | A plain-text 404, no-store — never your HTML not-found page |
Range requests are what let a browser seek in an audio or video file, or
resume a download, without fetching it from the start. They come from
http.ServeContent, and they are the reason files are a separate mechanism from
documents, whose bodies are produced whole in memory.
The ETag is a content hash because the most common source of assets in a Go
program, embed.FS, reports a zero modification time for every file — so
Last-Modified and size-and-date validation do not work for it at all. Only the
hash is remembered, never the file, so the memory it costs grows with the number of
files, not their size.
A missing stylesheet gets a plain-text 404 rather than your site's not-found page on purpose: an HTML error page handed to a browser that asked for CSS is a mistake in either direction.
Prefixes and routes
A prefix must begin and end with /, and cannot be / alone — a mount at the root
would swallow every page (collage.ErrInvalidPrefix). A nil file system is
collage.ErrNilFS.
A request under a mount's prefix is answered by the mount and never reaches the
router. That is safe because startup refuses a prefix that would hide a URL a page,
document or redirect already answers — including locale-prefixed ones, so a mount
at /tr/ fails on a site with Turkish pages (collage.ErrMountShadowsRoute). Two
mounts whose prefixes overlap are refused too (collage.ErrMountConflict). Both
checks run when registration closes, so the order you call Mount and
RegisterPage in does not matter.
The check compares prefixes with registered patterns, so it cannot see a catch-all
above the prefix: a page at /{rest...} would have matched /static/…, and the
mount takes those URLs. That is the trade of claiming a prefix.
In development
A content-addressed URL and a file you are editing are in conflict. Outside development the hash is computed once and remembered, and the URL is promised for a year; edit the file under a running process and the page would go on linking the old name, which the browser was told can never change.
So with DevMode on, every mount recomputes a file's hash on each request and
serves everything no-store. An edited file gets a new URL, the page links it, and
the browser fetches it — and the development reload script refreshes the page when
a mounted file changes. (A directory that is not mounted, such as Markdown your
handlers read, reloads the page once it is named in
Config.DevWatch.) Outside development, a file
changed on disk keeps its remembered hash until the process restarts: mounts are
for files that are deployed, not edited under a running server.
Static export
A static export copies every mount into its output under its
prefix — /static/app.css becomes dist/static/app.css — along with a
content-addressed copy of every file a page actually linked with {{asset}}. Only
those: a media directory is not doubled for names no page uses.
collage.WithoutBuildCopy() leaves a mount out.
What mounts do not do
- No compression. No gzip or Brotli, and no pre-compressed sidecar files. Put a
reverse proxy or CDN in front, or wrap
app.Handler(). - No bundling, minification or image processing. A mount serves the bytes it is given; plugins can do more.
- Only
fs.FSsources. An object store such as S3 is not one. Serve it yourself withapp.Handle, or link to it directly.