# 📝 Templating and Localization

Templates are parsed **once at startup**. The same template engine is used for all output formats. When a custom
HTML template is set via `--html-template`, the `--template-name` and `--rotation-mode` flags are ignored.

### Go template primer

Error pages uses the standard Go [`text/template`][go-text-template] package (with HTML output treated as text to
preserve full control over markup). If you have never written a Go template before, do not worry - it is genuinely one
of the **simplest** templating languages around. The full mental model fits in a few minutes.

**Key concepts:**

- `{{ }}` - action delimiters; everything outside them is emitted verbatim.
- `{{ .Field }}` - output a field from the data object (`.` is "current value").
- `{{ if .Cond }} ... {{ else }} ... {{ end }}` - conditional.
- `{{ range .Slice }} {{ . }} {{ end }}` - iteration (`.` becomes each element).
- `{{ .Value | funcName }}` - pipeline; passes the value on the left as the last argument to the function on the right.
- `{{ /* comment */ }}` - comment (not emitted).

**Documentation**:

- [`text/template` package - the complete, authoritative reference][go-text-template].
- [Go Templates Guide](https://hackmd.io/@gekart/go-templates?utm_source=chatgpt.com) - syntax explained with
  examples (recommended for beginners).

[go-text-template]: https://pkg.go.dev/text/template

> [!WARNING]
> `{{` and `}}` are reserved as Go template delimiters - any literal occurrence causes a parse error. This is
> common in JSDoc type annotations (`/** @param {{ id: number }} ❌ */`), CSS, etc. To work around this, you can simply
> add a single space between the braces: `/** @param { { id: number } } ✅ */`.

### Template data

All templates receive a data object with the following fields:

| Field                        | Type     | Description                                                            |
|------------------------------|----------|------------------------------------------------------------------------|
| `.StatusCode`                | `uint16` | HTTP status code (e.g. `404`)                                          |
| `.Message`                   | `string` | Short status text (e.g. `Not Found`)                                   |
| `.Description`               | `string` | Longer description (e.g. `The server can not find the requested page`) |
| `.OriginalURI`               | `string` | Request URI that caused the error *                                    |
| `.Namespace`                 | `string` | Kubernetes namespace of the backend service *                          |
| `.IngressName`               | `string` | Name of the Ingress resource *                                         |
| `.ServiceName`               | `string` | Name of the backend service *                                          |
| `.ServicePort`               | `string` | Port of the backend service *                                          |
| `.RequestID`                 | `string` | Unique request ID *                                                    |
| `.ForwardedFor`              | `string` | Original client IP(s) from `X-Forwarded-For` *                         |
| `.Host`                      | `string` | Request `Host` header *                                                |
| `.HomepageURL`               | `string`    | Homepage URL set via `--homepage-url` (empty if not configured)        |
| `.Links`                     | `[]Link`    | Extra links set via `--add-link` (empty slice if not configured)       |
| `.Config.ShowRequestDetails` | `bool`      | Whether `--show-details` is enabled                                    |
| `.Config.L10nDisabled`       | `bool`      | Whether `--disable-l10n` is set                                        |

> `*` - Requires `--show-details`

Each element of `.Links` has the following sub-fields:

| Sub-field     | Type     | Description           |
|---------------|----------|-----------------------|
| `.Label`      | `string` | Link text             |
| `.URL`        | `string` | Target URL            |

Example usage in a custom template:

```html
{{ if .Links }}
<nav>
  {{ range .Links }}<a href="{{ .URL }}">{{ .Label }}</a>{{ end }}
</nav>
{{ end }}
```

In addition to the fields above, templates also have access to a set of built-in functions (see below), which are
pipeline-friendly (needle before haystack): `{{ .Message | default "Unknown" | upper }}`.

| Function                     | Description                                      | Example                                                        |
|------------------------------|--------------------------------------------------|----------------------------------------------------------------|
| `now`                        | Current time (`time.Time`)                       | `{{ now.Format "2006-01-02" }}` / `{{ now.Unix }}`             |
| `hostname`                   | Server hostname                                  | `{{ hostname }}`                                               |
| `version`                    | Application version string                       | `{{ version }}`                                                |
| `env "KEY"`                  | Env var value (sensitive keys masked with `***`) | `{{ env "STAGE" }}`                                            |
| `toJson` / `toJSON`          | JSON-encode a value                              | `{{ .Message \| toJson }}`                                     |
| `toInt` / `int`              | Convert to integer                               | `{{ .StatusCode \| int }}`                                     |
| `toString` / `str`           | Convert to string                                | `{{ .StatusCode \| str }}`                                     |
| `escape`                     | HTML-escape                                      | `{{ .OriginalURI \| escape }}`                                 |
| `urlEncode`                  | URL-encode                                       | `{{ .OriginalURI \| urlEncode }}`                              |
| `trim`                       | Strip leading/trailing whitespace                | `{{ .Message \| trim }}`                                       |
| `trimPrefix`                 | Remove prefix                                    | `{{ .Message \| trimPrefix "Error: " }}`                       |
| `trimSuffix` / `trimPostfix` | Remove suffix                                    | `{{ .Message \| trimSuffix "!" }}`                             |
| `trimAll`                    | Strip specific characters                        | `{{ ".test." \| trimAll "." }}`                                |
| `lower` / `upper`            | Change case                                      | `{{ .Message \| upper }}`                                      |
| `replace`                    | Replace all occurrences                          | `{{ .Message \| replace " " "_" }}`                            |
| `contains`                   | Substring check                                  | `{{ .Message \| contains "test" }}...{{ end }}`                |
| `hasPrefix` / `hasSuffix`    | Prefix/suffix check                              | `{{ .Message \| hasPrefix "test" }}...{{ end }}`               |
| `split`                      | Split string by separator                        | `{{ split ";" "a;b;c" }}`                                      |
| `join`                       | Join slice with separator                        | `{{ split ";" "a;b;c" \| join ", " }}`                         |
| `fields`                     | Split string by whitespace                       | `{{ fields "foo bar baz" \| join "-" }}`                       |
| `substr`                     | Substring by rune index and length               | `{{ "Hello, World!" \| substr 7 5 }}`                          |
| `truncate`                   | Truncate with `...` appended                     | `{{ .Description \| truncate 80 }}`                            |
| `repeat`                     | Repeat string N times                            | `{{ "Ha" \| repeat 3 }}`                                       |
| `quote` / `squote`           | Wrap in double/single quotes                     | `{{ .Message \| quote }}`                                      |
| `count`                      | Count substring occurrences                      | `{{ "test" \| count "t" }}`                                    |
| `default`                    | Fallback value for empty input                   | `{{ .OriginalURI \| default "N/A" }}`                          |
| `coalesce`                   | First non-empty value from a list                | `{{ coalesce .Message .Description "error" }}`                 |
| `ternary`                    | Inline conditional                               | `{{ .Config.ShowRequestDetails \| ternary "shown" "hidden" }}` |
| `isEmpty` / `isNotEmpty`     | Emptiness check                                  | `{{ if isNotEmpty .Description }}...{{ end }}`                 |
| `l10nScript`                 | Inline the localization JS script                | `<script>{{ l10nScript }}</script>`                            |

> [!NOTE]
> `env` masks values whose key (split by `_`) contains `PASSWORD`, `SECRET`, `KEY`, `TOKEN`, `PASS`, `PWD`,
> or `CRED` (case-insensitive). Those calls return a string of `*` characters instead of the actual value.

### Localization

HTML pages support automatic **client-side localization** in 15+ languages, templates that want l10n must:

1. Add `data-l10n` attributes to elements whose text content should be translated
2. Include `<script>{{ l10nScript }}</script>` in the HTML

The browser detects the visitor's preferred language via `navigator.languages` and translates all `[data-l10n]`
elements in-place - no server round-trip required. Localization can be disabled globally with `--disable-l10n`
(or via env `DISABLE_L10N=true`).

You can find more details on handling localization in templates in the [Localization documentation](../l10n/readme.md).
