docs/template-guide.md

# Template Guide

tilstream's templates are plain Go `text/template`. This document
lists the variables each template receives, the partials you can
compose from, and some example overrides.

For how templates fit into the pipeline, see
[docs/architecture.md](/src/tilstream/docs-architecture-md/).

## Templates

There are four top-level templates:

| Name          | Purpose                          |
|---------------|----------------------------------|
| `base.tmpl`   | HTML shell used by all pages     |
| `post.tmpl`   | single post page                 |
| `index.tmpl`  | home page, list of posts         |
| `tag.tmpl`    | filtered list for a single tag   |

`base.tmpl` defines blocks (`content`, `title`, `head_extra`) that
the child templates fill. Look at
[`templates/post.tmpl`](/src/tilstream/templates-post-tmpl/) for the
canonical example of extending `base.tmpl`.

## How to override

Supply `--templates path/to/dir` and put files with the same names
there. Each file must `{{ define "name" }}...{{ end }}` blocks; the
base shell picks them up.

Start by copying the built-in templates. They are short.

## Variables

### Common (all templates)

| Variable           | Type              | Meaning                                |
|--------------------|-------------------|----------------------------------------|
| `.Site.Title`      | string            | from site config                       |
| `.Site.URL`        | string            | canonical URL, trailing slash          |
| `.Site.Author`     | string            | author name                            |
| `.Site.BuiltAt`    | time.Time         | build start time                       |
| `.Site.Posts`      | []Post            | every published post, newest first     |
| `.Site.Drafts`     | []Post            | present only when `--drafts` was set   |
| `.Site.Tags`       | map[string][]Post | tag name to posts                      |

### `post.tmpl` extras

    .Post         Post       // the current post
    .Prev         *Post      // previous by date, or nil
    .Next         *Post      // next by date, or nil

### `index.tmpl` extras

    .Posts        []Post     // ordered newest first; drafts only if flag set

### `tag.tmpl` extras

    .Tag          string     // which tag we're rendering
    .Posts        []Post     // posts in that tag

## `Post` fields available in templates

    .Post.Slug        string
    .Post.Title       string
    .Post.Date        time.Time
    .Post.Tags        []string
    .Post.Categories  []string
    .Post.Draft       bool
    .Post.Source      string            // original filename
    .Post.BodyHTML    template.HTML
    .Post.BodyText    string            // plain text, stripped
    .Post.Meta        map[string]any    // any other front-matter keys

Anything you put in a post's front-matter that isn't one of the
known keys ends up in `.Post.Meta`. For example, a custom
`cover_image` field is reachable as `{{ .Post.Meta.cover_image }}`.

## Built-in template functions

In addition to Go's stdlib funcs (`printf`, `len`, `eq`, etc.):

| Function           | Signature                         | Purpose                                |
|--------------------|-----------------------------------|----------------------------------------|
| `date`             | `date "2006-01-02" t time.Time`   | format time                            |
| `markdown`         | `markdown s string`               | render a string as markdown            |
| `trim`             | `trim s string`                   | strings.TrimSpace                      |
| `slug`             | `slug s string`                   | normalise to URL slug                  |
| `join`             | `join xs []string sep string`     | strings.Join                           |
| `truncate`         | `truncate s string n int`         | word-respecting truncation             |
| `readingTime`      | `readingTime s string`            | minutes at 200 wpm                     |

## Partials

Any template file under `--templates/partials/` is available as
`{{ template "partials/FILENAME" . }}`. The context is the caller's
context by default; pass something else explicitly if you want.

Built-in partials:

- `partials/nav.tmpl` - site navigation
- `partials/tag-list.tmpl` - inline tag list for a post
- `partials/post-list.tmpl` - list of post cards, reused by index
  and tag pages
- `partials/footer.tmpl` - footer

### Example: custom nav

`templates/partials/nav.tmpl`:

    {{ define "partials/nav.tmpl" }}
    <nav>
      <a href="{{ .Site.URL }}">Home</a>
      <a href="{{ .Site.URL }}tags/">Tags</a>
      <a href="{{ .Site.URL }}feed.xml">RSS</a>
    </nav>
    {{ end }}

## Example: minimal `post.tmpl`

    {{ define "title" }}{{ .Post.Title }} - {{ .Site.Title }}{{ end }}

    {{ define "content" }}
    <article>
      <header>
        <h1>{{ .Post.Title }}</h1>
        <time datetime="{{ date "2006-01-02" .Post.Date }}">
          {{ date "Jan 2, 2006" .Post.Date }}
        </time>
        {{ if .Post.Tags }}
          {{ template "partials/tag-list.tmpl" .Post }}
        {{ end }}
      </header>
      {{ .Post.BodyHTML }}
      <footer>
        {{ if .Prev }}<a href="{{ .Prev.Slug }}">&larr; {{ .Prev.Title }}</a>{{ end }}
        {{ if .Next }}<a href="{{ .Next.Slug }}">{{ .Next.Title }} &rarr;</a>{{ end }}
      </footer>
    </article>
    {{ end }}

## Example: index page grouped by year

    {{ define "content" }}
    {{ range $year, $posts := .Posts | groupBy "Year" }}
      <h2>{{ $year }}</h2>
      <ul>
        {{ range $posts }}
          <li>
            <a href="{{ .Slug }}">{{ .Title }}</a>
            <time>{{ date "Jan 2" .Date }}</time>
          </li>
        {{ end }}
      </ul>
    {{ end }}
    {{ end }}

`groupBy` is not a built-in. Add it in your own template set - see
"adding functions" below.

## Adding template functions

You can't add functions via templates alone. For that, patch
`internal/render/render.go` to add to `funcMap`:

    funcMap["groupBy"] = func(posts []Post, field string) map[any][]Post {
        m := map[any][]Post{}
        for _, p := range posts {
            switch field {
            case "Year":
                m[p.Date.Year()] = append(m[p.Date.Year()], p)
            ...
            }
        }
        return m
    }

This is a code change, not a template override. I have resisted
adding a plugin system; the tool is small, and hacking the render
package is fine.

## Live reload

`tilstream serve` injects a small JS snippet at the end of `<body>`
that opens an SSE connection to `/__tilstream/reload` and refreshes
on each message. The snippet is only injected in `serve` mode, not
`build` output. If you override the template shell, the snippet
goes in automatically - you don't need to add it.

## Gotchas

- Template parse errors are fatal at startup. The error message
  includes the offending file and line.
- `template.HTML` bypasses escaping. Only use it for content that
  is already safe HTML (like `.Post.BodyHTML`, which came from
  goldmark and is sanitised at render time).
- A template that references a missing field prints `<no value>` in
  text mode; in tilstream it's a runtime error instead. We use the
  `missingkey=error` option, so typos fail loudly.

## Shipping your theme

If you've built a template set you like, put the directory under
version control and pass `--templates` in your build command. No
plugin install step. Copy-pasteable themes are the model.