Shipping
Static export
Render the site to static files with collage export — what is written, what is skipped and why, dynamic paths, and publishing to a static host.
A site whose pages do not depend on the request does not need a server at all.
collage export renders every page that can be a file into dist/, copies your
static files beside them, and writes the site's own 404.html. Put the directory on
any static host.
It is the same program that serves the site, rendering through the same templates, data handlers and plugins. There is no second build, and nothing to keep in step with the server. The pages you are reading were produced this way.
collage export # -> dist/
collage export -clean # empty dist/ first
collage serve # look at dist/ the way a static host would serve it
How it runs
collage export does not load your application — it cannot, since your application
is your code. It runs your program in a special mode instead:
go run . -collage-build -out dist # plus -clean when you passed it
| Flag | Default | Meaning |
|---|---|---|
-out dir |
dist |
Where the files are written. |
-clean |
off | Remove the directory's existing contents first. |
The scaffolded main.go honours that contract: on -collage-build it builds the
application as usual and, instead of serving it, hands it to collage's builder and
prints what happened. This is the collage-docs version of that function:
func staticBuild(app *collage.App, outDir string, clean bool) error {
loaded, err := site.Load(content.FS)
if err != nil {
return err
}
builder, err := collage.NewBuilder(app, collage.BuildOptions{
OutDir: outDir,
Clean: clean,
PathProvider: docPaths{loaded},
})
if err != nil {
return err
}
report, buildErr := builder.Build(context.Background())
collage.PrintBuildReport(os.Stdout, report, buildErr)
return buildErr
}
Build returns a report even when it also returns an error: one failing page does
not stop the others, and every failure is in report.Errors. The returned error is
all of them joined, and main exits non-zero on it, so a CI job fails when a page
could not be written. Skipped pages and warnings do not fail the build.
If you rewrite main.go, keep the -collage-build, -out and -clean flags, or
collage export stops doing anything useful.
What is written
| What | Where |
|---|---|
A Static() or Incremental(ttl) page |
dist/<path>/index.html; / is dist/index.html |
| The same page in a non-default locale | Under the locale's prefix: dist/tr/<path>/index.html |
A Static() or Incremental(ttl) document |
Its literal path: /sitemap.xml is dist/sitemap.xml |
| The same document in a non-default locale | Under the locale's prefix: dist/tr/sitemap.xml |
A document built with AtRoot |
Its bare path, in every configuration: dist/robots.txt |
A default-locale page or document, with PrefixDefault |
Under its prefix too, dist/en/<path>/index.html and dist/en/sitemap.xml, and dist/index.html sends the reader to /en/ |
| The not-found page | dist/404.html, and dist/<locale>/404.html for each other locale |
| Every mounted static file system | Under its prefix: /static/app.css is dist/static/app.css |
A page becomes a directory with an index.html, which is what every static host
looks for when asked for /about. A document is written at exactly
its path, because a crawler asking for /robots.txt must get a file.
The not-found page comes from app.RegisterNotFoundPage, and its render strategy
is not consulted — it is almost always Dynamic(), because it is never worth
caching, and it still belongs in the export. Hosts differ on whether they look for
a nested tr/404.html, so both are written. A site with no not-found page gets
no file, and an unknown URL shows whatever the host decides to.
Mounted assets are copied under their original names and under the
content-addressed names {{asset}} links to (app.3a3663df.css), so both kinds of
link work. A mount that is served from a CDN in production, or is too large to
duplicate, can opt out:
app.Mount("/media/", mediaFS, collage.WithoutBuildCopy())
See Static assets.
What is skipped
Some pages cannot be files. The build leaves them out and names each one in the report, with the reason:
Dynamic()pages and documents. They exist to render per request.- Pages with a form. A page whose render contains
{{csrfToken}}is skipped: a form needs a server to post to, and a forgery token belongs to one reader. The page may beStatic()on purpose — cached, and invalidated by the action it posts to — and it is served rather than exported. The scaffold's/featurespage is one. - A
{param}pattern with no path provider./blog/{slug}cannot be written until something says which slugs exist. See below. - Two documents on one file. A
DocumentPathProviderthat returns one path twice resolves two tasks to one file; the first is written and the rest are skipped withcollage.ErrDuplicateOutputPath. One pattern in two locales is not this — each locale is written under its own prefix. See Documents.
Error pages registered without a path are not listed at all: they are not URLs.
Not exported, and not reported, because they are not pages: actions,
handlers mounted with app.Handle, and middleware. An export renders without a
request, so no middleware runs and collage.Vary is never called — each page is
written in the version a request with no preferences would get.
What is warned about
A page that declared, with WithCacheParams, which query parameters it reads
renders differently for each of them. A file has no query string: a static host
answers /blog?page=2 with the /blog file. The page is written without a query,
and the report says so, rather than letting a paginated archive look like it works.
A document with WithCacheParams — a paginated feed — is warned about the same way
(since v0.10.0; before, only pages were).
If pagination has to work in an export, put the page number in the path —
/blog/page/{n} — and list the pages with a path provider.
What fails
These are errors: the page is not written, the build reports it and exits non-zero.
- A degraded render. A page where a fragment failed is refused with
collage.ErrDegradedRender, naming the fragment. A served page with a failed fragment is shown but never cached; a file has no TTL to recover through, so it would keep that failure until the next export. SetBuildOptions.AllowDegradedif a page with a missing sidebar is better than no page. - An empty render,
collage.ErrEmptyRender, and an empty document,collage.ErrEmptyDocumentBody. A zero-byteindex.htmlis never written. - A panic in one page,
collage.ErrBuildPanic. It is recovered and recorded against that page; the rest of the build continues. - A not-found page with a form,
collage.ErrUnresolvedToken, because a static host needs that one as a file. - Two pages on one output path,
collage.ErrOutputPathCollision— two patterns that differ only in a trailing slash, or a path provider that returns a path twice. No page is rendered when this is found; the documents, the404.htmlpages and the mounted assets are still written, and the build still fails.
Dynamic paths: PathProvider
A page at /blog/{slug} is one page with many URLs. The builder asks a
collage.PathProvider for them:
type PathProvider interface {
Paths(ctx context.Context, page *collage.Page, locale string) ([]collage.PathInstance, error)
}
It is called once per dynamic page and locale, and returns the concrete paths and
the parameters each one captures. These docs are one page, doc, at
/docs/{slug}; the provider lists every page of the documentation:
// docPaths tells the static build which /docs/{slug} pages exist: every page of
// the documentation, and nothing else.
type docPaths struct{ site *site.Site }
func (d docPaths) Paths(_ context.Context, page *collage.Page, _ string) ([]collage.PathInstance, error) {
if page.Name != "doc" {
return nil, nil
}
var paths []collage.PathInstance
for _, p := range d.site.Pages() {
paths = append(paths, collage.PathInstance{Path: p.URL(), Params: map[string]string{"slug": p.Slug}})
}
return paths, nil
}
- Check the page. One provider answers for every dynamic page. Returning
nilfor a page you do not know writes nothing for it; that is not an error. Paramsis what the data handlers see. It is laid over what the router captured fromPath, sorc.Param("slug")has the value a live request would have.Pathis without the locale prefix. Return the pattern's path,/blog/hello; the builder writes a non-default locale under its own directory.- Returning an error records it against that page and locale and carries on.
Documents with a {param} have their own interface, BuildOptions.DocumentPathProvider,
described in Documents.
Every path a provider returns is checked before its own file is written: a path
that would resolve outside the output directory — /../../etc — is refused with
collage.ErrPathEscapesOutDir, and so is a write through a symlink that leads out
of it. The refusal fails that one path, not the build around it: the other pages
are still rendered and written, and the error is in the report.
Build options
| Field | Meaning |
|---|---|
OutDir |
Where to write. Required. |
Clean |
Remove OutDir's contents (not the directory itself) first. |
Locales |
Build only these locales. Empty builds every locale a page declares. |
Concurrency |
How many pages render and write at once. 0 or 1 is one at a time. The report is in the same order either way. |
PathProvider |
Concrete paths for pages with a {param}. |
DocumentPathProvider |
Concrete paths for documents with a {param}. |
AllowDegraded |
Write pages whose render had a failed fragment. |
The builder refuses an OutDir that resolves to the root of the file system, and
refuses to Clean one that is the root of a repository
(collage.ErrDangerousOutDir) — -out . with -clean would otherwise delete your
project.
Plugins in an export
The export renders in the state the server does. Every plugin's Init runs first,
so a plugin reads the same configuration; OnBeforeRender, OnAfterRender and
OnDocumentRendered fire for every page and document, so what a minifier or a
structured-data plugin does to a served page it does to the file. OnPageResolved
does not fire, because an export is not a request. See Using plugins.
Reading the report
collage.PrintBuildReport prints what the build did. For the project collage new
scaffolds, it looks like this:
✓ 8 files written
dist/404.html
dist/index.html
dist/static/app.3a3663df973f06fb.js
dist/static/app.7050c2518057f5c5.css
dist/static/app.css
dist/static/app.js
dist/static/favicon.936907e03c8c09ad.svg
dist/static/favicon.svg
▲ 3 skipped
hello page uses the dynamic render strategy, which cannot be built statically
features (en) page carries {{csrfToken}}; a form needs a server to submit to, so it is served rather than exported
health document uses the dynamic render strategy, which cannot be built statically
8 written · 3 skipped · 0 failed · 2.5ms
Written files are summarised after ten, because a build that wrote three hundred
must not bury the one page it skipped. Skips, warnings and failures are never
truncated. The last line has every count and is coloured by the worst of them.
Skips are counted as routes, pages and documents together — 3 skipped, not
3 pages skipped, since v0.10.0.
To act on the report in code, read report.Skipped, report.Warnings and
report.Errors yourself. Each skip is a collage.SkipRecord with the route's name
(Page), its Locale, a Reason for people and, since v0.10.0, an Err to match
with errors.Is — see Errors.
Testing turns that into a test.
Colour and the ✓ ▲ ✗ marks appear only on a terminal, and not when NO_COLOR is
set. In a CI log the marks are plain ASCII (+ ! x).
Looking at it: collage serve
Opening dist/index.html in a browser does not work: a file:// page has no root,
so every absolute link and stylesheet is broken. collage serve serves the export
the way a static host does:
collage serve # http://localhost:4000
collage serve -dir public -port 8000
/aboutis answered withabout/index.html.- A directory without an
index.htmlis a 404 — no listings. - An unknown path gets
404.htmlwith a 404 status. - Nothing is cached, so exporting again and reloading shows the new output.
Its flags are -dir (default dist), -host (default localhost) and -port
(default 4000 — not 3000, so it can run next to collage dev while you compare
them). It serves files; it does not run your project.
Hosting
The output is plain files with absolute links, so any static host serves it. Three things to check on any of them:
- The site must be at the root of its domain. Links and asset URLs start at
/, and collage has no base-path setting, so a site published under a sub-path — such as a GitHub project page atuser.github.io/project/— has broken links. Use a custom domain, or a host that gives the site its own. 404.htmlis at the root. Most hosts pick it up by that name without any configuration.TrailingSlashis on. A page is written as<path>/index.html, and a host serves it at/about/and redirects/aboutthere. With the setting on, every link collage builds is already the address the host answers, rather than a redirect to it.
GitHub Pages
This site is published by a workflow that runs the tests, exports and uploads
dist/:
name: pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: go test ./...
- run: go run . -collage-build -out dist -clean
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
The export step runs the program directly rather than through collage export, so
the runner does not need the collage CLI installed. Set the repository's Pages
source to GitHub Actions, and give it a custom domain unless it is your
user.github.io repository.
Cloudflare Pages
Export in CI, the same way, and upload the directory with Wrangler:
go run . -collage-build -out dist -clean
npx wrangler pages deploy dist --project-name mysite
Cloudflare Pages serves 404.html for unknown paths when one is at the root, which
the export always writes when the site has a not-found page.
Anything else
Netlify, S3 behind CloudFront, an nginx directory — each needs only the contents
of dist/ and, if it does not do so already, 404.html configured as the error
page. When the site needs forms, previews or per-request pages, it needs a server
instead: see Deployment.