# Static files

Every app publishes its public CSS, images, fonts and scripts through
`App.Static`. Cicadel serves each file under the app's namespace with a
cache-busting URL, and can collect the files for a CDN or reverse proxy.

## Ask your agent

```text
Read https://cicadel.fauzaanu.com/en/dev/howto/static-files/index.md.
Publish [files] from [app] through App.Static. Resolve each URL in the handler
with static.URL and pass it to the component as a typed prop. Do not add a
route for static files. Run the app tests and go run manage.go collectstatic.
```

## Publish an app's files

Embed a directory and pass it as `App.Static`:

```go
//go:embed all:static
var public embed.FS

func New() cicadel.App {
    files, err := fs.Sub(public, "static")
    if err != nil {
        panic(err)
    }
    return cicadel.App{Name: "notes", Routes: URLs(), Static: files}
}
```

Generated code already does this:

- `startapp` generates this layout with an empty `static/` directory.
- The starter project publishes `project/static/` as the `project` app.
- The accounts package publishes its stylesheet as the `accounts` app.

## How files are served

A file `static/css/notes.css` in the `notes` app is available at two URLs:

| URL | Caching |
| :--- | :--- |
| `/static/notes/css/notes.3f1c9a2b7d4e.css` | `public, max-age=31536000, immutable` |
| `/static/notes/css/notes.css` | `no-cache` with a strong content `ETag` |

- **Hashed name.** The 12 characters are the start of the file's SHA-256 digest.
  The URL changes whenever the content changes.
- **App prefix.** Two apps can never publish the same path. A project cannot
  silently replace a package's file.

### What is not published

- Files and directories starting with `.` are never published.
- `App.Resources` stays private. Nothing in it is served.
- Symbolic links, duplicate app names, and an authored file whose name equals
  another file's hashed name are construction errors.

## Link to a file

Resolve URLs in the handler and pass them to components as typed props:

```go
func Index(w http.ResponseWriter, r *http.Request) {
    stylesheet, err := static.URL(r.Context(), "notes", "css/notes.css")
    if err != nil {
        response.Error(w, http.StatusInternalServerError)
        return
    }
    page := layout.Page.Call(layout.Props{Title: "Notes", Stylesheet: stylesheet},
        layout.Slots{Children: ui.Text("Notes")})
    if err := (ui.Renderer{}).HTML(r.Context(), w, http.StatusOK, page); err != nil && !ui.Committed(err) {
        response.Error(w, http.StatusInternalServerError)
    }
}
```

`static.URL` fails for an unpublished file instead of producing a broken link.

The application puts its manifest in every request context. Outside a request, use
`app.Static().URL("notes", "css/notes.css")`. Tests use the same method to find
the URL to fetch.

## Configure the URL

`settings.StaticURL` (environment: `CICADEL_STATIC_URL`) defaults to `/static/`.
It must end in `/` and must not contain a query or fragment.

| Value | Behavior |
| :--- | :--- |
| Local path, such as `/static/` or `/assets/v2/` | The application serves the files. Requests under the prefix are answered after host validation and the standard security headers, but before global middleware. |
| Absolute URL, such as `https://cdn.example.com/static/` | URLs point at that origin, and the application does not serve them. Upload `collectstatic` output there. Production requires `https`; `http` is accepted only with debug on. |

> [!WARNING]
> With a local path, registering a route under the prefix is a startup error
> while any app publishes files.

## Collect files for a CDN or proxy

```sh
go run manage.go collectstatic public
```

`collectstatic` runs offline. It does not open PostgreSQL or start app services.

- It writes each authored and hashed file under `public/<app>/`.
- It writes `public/manifest.json`, mapping `app/path` to the hashed path.
- It writes files atomically.
- It keeps earlier hashed files, so pages from the previous release keep working
  during a rolling deployment. Delete old ones yourself when no deployed release
  references them.
- If a hashed path already exists with different bytes, it stops with an error.

Serve the hashed files with a long immutable cache lifetime and the authored
names with revalidation, matching the table above.

## Limits

- Source filesystems must not change while the application runs. Embedded files
  are the supported source. The development server restarts after rebuilding CSS,
  which recomputes hashes.
- References inside a file are not rewritten. A stylesheet's relative
  `url(font.woff2)` resolves to the authored name. That name is served with
  revalidation, not an immutable lifetime.
- Precompressed variants, `findstatic` and source maps are not generated.
- The bundled htmx runtime is still served from its own versioned path. See
  [interactive behavior](https://cicadel.fauzaanu.com/en/dev/howto/htmx/index.md).
