Guide

Template Collection & Conditional Helpers

Since v1.8.3. SSG's Go template engine ships a set of generic helpers for filtering, sorting, grouping, slicing and testing content — so a theme can build "recently updated guides", "related posts" or "grouped archives" without any code changes.

The collection is always the final argument, so helpers chain naturally in Go template pipelines:

{{ $recentGuides := .Site.Pages
    | where "Type" "guide"
    | sort "Modified" "desc"
    | first 5
}}
{{ range $recentGuides }}
  <article>
    <h2><a href="{{ .URL }}">{{ .Title }}</a></h2>
    <time>{{ formatDate .Modified }}</time>
  </article>
{{ end }}

Helpers work on []models.Page and generically on slices of structs, pointers to structs, string-keyed maps, and primitives (via reflection). They never mutate their input and never panic — invalid usage stops template execution with a descriptive error such as:

1where: field "ModifiedAt" does not exist on models.Page
2sort: unsupported direction "newest"; expected "asc" or "desc"
3first: count must be greater than or equal to zero
4matches: invalid regular expression "["
5filter: unsupported operator "newest"; expected one of eq, ne, gt, ge, lt, le, …

What you do NOT need helpers for

if, else if, with, range, eq/ne/lt/le/gt/ge, and/or/not are native Go template features — use them directly:

{{ if .Page.HasMath }}math{{ else if eq .Page.Type "guide" }}guide{{ else }}other{{ end }}

Native switch/case does not exist in Go templates and SSG deliberately does not emulate it — compose if / else if with the conditional helpers below.

Supported comparison types

where, filter, sort and the equality-based helpers compare: strings, booleans (false < true), all integer/float kinds (compared cross-type, so int(5) equals float64(5)), time.Time (and convertible aliases). Anything else (slices, maps, structs) errors in ordering contexts; equality falls back to deep comparison.


Collection helpers

{{ range related . 5 }}<a href="{{ .Link }}">{{ .Title }}</a>{{ end }}

related <page> <n> returns up to n posts most related to <page>, ranked by the number of shared tags and keywords (then recency, then slug for a stable order). It reads the already-loaded posts — whatever the content source — so it needs no network and is reproducible; posts with no overlap are excluded.

relatedFromMddb <page> <n> does the same by querying the mddb server (Search filtered by the page's tags/keywords), so it can surface articles from the whole corpus, not just the pages built into this site. It is a live query (not cached) and returns nothing when mddb is not configured or the query fails. relatedIn <page> <n> <collection> scores an explicit collection instead of the loaded posts, and ranks differently: shared tags (3) > shared categories (2) > same author (1). Until 1.8.23 it was registered under the name related as well, where it was unreachable — use related for keyword and tag scoring over the site's own posts, and relatedIn when you have a collection in hand.

MDDB metadata is flat. Anything the helpers hand back from an MDDB collection went through map[string][]string on the way in, so a nested structure in the source frontmatter arrives as text unless the producer encoded it. A theme reaching for that field gets a string where it expected a list, and the failure looks like a template bug rather than a storage one. See Structured frontmatter through MDDB's flat meta for the producer contract — you meet the constraint through this helper long before you meet it in the config file.

See examples/related-posts/ for the keyword, mddb and embeddings/vector approaches.

feed — a declared feed's items

{{ range feed "/planet.xml" }}
  <article>
    <span class="badge">{{ .Label }}</span>
    <h3><a href="{{ .URL }}">{{ .Title }}</a></h3>
    <p>{{ .Summary }}</p>
  </article>
{{ end }}

feed <path-or-name> returns the items of a feed declared in feeds: — already merged, filtered, deduplicated, sorted newest first and carrying its provenance .Label. A page built this way and the feed itself come from the same computation, so they cannot drift.

Each item exposes .Title, .URL, .ID, .Summary, .ContentHTML, .Published, .Updated, .Tags, .Author, .Source and .Label.

Look a feed up by its path, or by an optional name: on the spec. It returns the whole feed, not one page — paginate the HTML yourself with first / offset if you want that. An undeclared feed yields nothing and warns, rather than failing the render.

concat / flatten — compose collections

{{ $all := concat .Site.Posts .ExternalData.mddb.items }}
{{ range $all | sort "Published" "desc" | first 12 }}…{{ end }}
{{ flatten (slice $a $b) }}

Go templates cannot join two collections: slice a b builds a list of two lists, and nothing unnests it. concat joins any number of collections into one []any — which the helpers above handle by reflection, so it composes with sort, first and the rest. Element types need not match. A nil input contributes nothing rather than a hole for range to trip on. flatten unnests one level.

where — filter by field equality

{{ .Site.Pages | where "Type" "guide" }}
{{ .Site.Pages | where "Status" "publish" }}
{{ .Site.Pages | where "HasMath" false }}

Signature where(field, expected, collection). Matches struct fields, pointer-to-struct fields and map keys exactly; preserves input order and element type. A missing field/key is an error (no silent fallback).

filter — filter with an operator

{{ .Site.Pages | filter "Modified" "gt" $cutoff }}
{{ .Site.Pages | filter "Tags" "contains" "go" }}
{{ .Site.Pages | filter "Type" "in" (slice "guide" "tutorial") }}
{{ .Site.Pages | filter "Link" "hasPrefix" "/special/" }}
{{ .Site.Posts | filter "Title" "matches" "^Baby " }}

Signature filter(field, operator, expected, collection). Operators: eq ne gt ge lt le contains notContains in notIn hasPrefix hasSuffix matches. contains searches strings (substring) and slices/arrays (element); in/notIn test the field value against a provided collection.

hasPrefix, hasSuffix and matches (regular expression) are anchored string tests, and need a string field and a string value — applied to a []string field they report an error rather than quietly answering false. Reach for them where contains is too loose: contains "/special/" also matches /not-special/thing/, while hasPrefix "/special/" does not.

sort — stable sort by field

{{ .Site.Pages | sort "Modified" "desc" }}
{{ .Site.Pages | sort "Title" "asc" }}

Signature sort(field, direction, collection); direction is asc or desc. Stable, non-mutating (returns a sorted copy). Field must exist on every element and hold a comparable type.

The collection must be a slice or a string-keyed map. The site's own taxonomy maps are int-keyed (.Site.Categories and .Site.Tags are map[int]Category), so {{ .Site.Categories | sort "Name" "asc" }} fails with "expected a slice or array, got map". Iterate those through the taxonomy helpers instead, which return an already-ordered slice of terms:

{{ range taxonomyTerms "category" }}{{ .Name }} ({{ .Count }}){{ end }}

first / last / limit / offset — pagination

{{ .Site.Pages | first 5 }}
{{ .Site.Pages | last 3 }}
{{ .Site.Pages | sort "Modified" "desc" | limit 5 }}   {{/* limit = first */}}
{{ .Site.Pages | offset 10 | limit 10 }}               {{/* page 2 of 10 */}}

Negative counts error; counts past the end clamp (offset past the end yields an empty collection); inputs are never mutated.

groupBy — group by scalar field

{{ range $category, $pages := (.Site.Pages | groupBy "Category") }}
  <h2>{{ $category }}</h2>
  {{ range $pages }}…{{ end }}
{{ end }}

Returns map[key] → slice. Item order inside each group is preserved. Go templates iterate maps in sorted key order, so output is deterministic. Keys must be scalar (string/bool/number/time.Time → RFC 3339); slices, maps and other structs error.

uniq / uniqBy — deduplicate

{{ .Site.Pages | pluck "Category" | uniq }}   {{/* primitives only */}}
{{ .Site.Pages | uniqBy "Category" }}         {{/* structs, by field */}}

First occurrence wins. uniq on structs/maps errors — use uniqBy.

reverse — reversed copy

{{ .Site.Pages | reverse }}

slice — build a list inline

{{ slice "guide" "tutorial" "docs" }}
{{ if in .Page.Type (slice "guide" "tutorial") }}…{{ end }}

⚠️ Registering slice overrides Go's builtin slice(value, i, j) sub-slicing function. Bundled themes do not use the builtin; if yours does, switch to printf "%.10s" for strings or restructure the data.

append — add to a list

{{ $kids := slice }}
{{ range .Site.Pages }}
  {{ if and (ne .GetURL $.URL) (hasPrefix .GetURL $.URL) }}
    {{ $kids = append $kids . }}
  {{ end }}
{{ end }}
{{ range $kids }}<a href="{{ .GetURL }}">{{ .Title }}</a>{{ end }}

slice builds a literal; append is what lets a loop accumulate one, so a theme can derive a collection — "the sub-pages of this section" — from the page tree instead of from per-site configuration.

The collection may be either the first or the last argument, so both the Go form (append $kids .) and the pipeline form ($kids | append .) work; when both ends are lists the first is the one appended to, as in Go. The input list is never modified, so appending twice to the same base gives two independent results.

Values that do not share the list's element type widen the result to a generic list rather than failing.

pluck — extract one field

{{ $titles := .Site.Pages | pluck "Title" }}

Returns a []any of field values (nil for nil-pointer elements).

indexBy — build a lookup map

{{ $bySlug := .Site.Pages | indexBy "Slug" }}
{{ $page := index $bySlug "getting-started" }}

Duplicate keys and empty keys are errors (no silent overwrites).


String helpers

hasPrefix, hasSuffix, startsWith, endsWith, matches, trimPrefix and trimSuffix all take plain strings.

{{ trimSuffix .Page.Link ".html" }}

trimPrefix/trimSuffix exist because testing for an affix without being able to remove one left no way to strip an extension inside a template (#103).

toJSON — a value as inline JSON

<script id="ssg-consent-config" type="application/json">{{ .Ctx.Vars.cookie_consent | toJSON }}</script>

Marshals any value — a config map, a list, a struct — for embedding in a <script>. It returns template.JS, so the result is not HTML-escaped again; json.Marshal escapes <, > and &, which is what makes that safe inside a script element.

This is how a theme passes a configuration block from .ssg.yaml to a client script without the theme having to know the block's shape — the pattern ssgtheme uses for the cookie banner.

Taxonomy helpers

taxonomies, taxonomy, taxonomyTerms, pageTerms, termURL, hasTerm and pagesByTerm are documented with the feature they belong to, in TAXONOMIES.

termURL is also the answer to "where is urlize/slugify" — there is no general slugify helper, and termURL "category" "Marsaskala" is how a term link is built. The built-in taxonomy names are category, tag and series.

Conditional helpers

Helper Signature Example
in in value collection → bool {{ if in .Page.Type (slice "guide" "docs") }}
notIn notIn value collection → bool {{ if notIn .Page.Type (slice "draft") }}
contains contains container value → bool {{ if contains .Page.Tags "ssg" }} — string→substring, slice→element, map→key
startsWith startsWith value prefix → bool {{ if startsWith .Page.Slug "guide-" }}
endsWith endsWith value suffix → bool {{ if endsWith .Page.SourceFile ".md" }}
hasPrefix alias of startsWith (v1.8.5) {{ if hasPrefix .Page.Slug "guide-" }}
hasSuffix alias of endsWith (v1.8.5) {{ if hasSuffix .Page.SourceFile ".md" }}
matches matches pattern value → bool {{ if matches `^guide-` .Page.Slug }} — RE2; compiled patterns are cached; invalid patterns error
isNil isNil value → bool true for nil interfaces/pointers/maps/slices/funcs/chans; never panics
isEmpty isEmpty value → bool Go template truthiness: nil, "", 0, false, empty slice/map ⇒ empty. Structs are never empty (zero time.Time included — use .IsZero)
ternary ternary cond a b → any {{ ternary .Page.HasMath "math" "plain" }} — for values, not control flow

in takes the value first, collection second (the canonical form above). For pipeline-style membership tests use filter … "in" … instead.


Content helpers (wrappers)

Helper Equivalent to Example
latest field n c sort field "desc" \| first n {{ .Site.Posts \| latest "Modified" 5 }}
published c where "Status" "publish" {{ .Site.Pages \| published }}
byTag t c filter "Tags" "contains" t {{ .Site.Posts \| byTag "go" }}
byCategory name c (site-aware) {{ .Site.Posts \| byCategory "guides" }} — matches frontmatter Category or resolved category names/slugs, case-insensitive; []models.Page only
byAuthor a c (site-aware) {{ .Site.Posts \| byAuthor "jan-kowalski" }} — by ID, name or slug; []models.Page only
relatedIn page n c (scored) {{ .Site.Posts \| relatedIn .Page 3 }} — ranks by shared tags (3) > shared categories (2) > same author (1), recency breaks ties, excludes the current page, only positive scores

Classic utility helpers

SSG registers several helper functions in the Go template engine for date formatting, HTML cleaning, metadata lookup, and logic controls.

HTML and String Utilities

  • raw value (alias html) — Emits a value as HTML with no processing at all — the plain template.HTML cast. Use it for markup that comes from data: inline SVG, a pre-rendered snippet, an embedded config blob.

    <svg viewBox="0 0 24 24">{{ index .Data.icons .Name | raw }}</svg>
    

    safeHTML is not a substitute here: in a page template it runs the Markdown pipeline, which wraps the fragment in a <p>. Inside <svg> that is invalid, so nothing draws and nothing warns.

  • safeHTML valueRenders a page's Markdown body. Despite the name, this is not only an escaping escape hatch: it is the pipeline that turns the raw Markdown source into HTML — shortcodes, the table of contents marker, Markdown conversion, .md link rewriting, link_rewrites, sanitizing — and returns template.HTML so the result is not escaped again.

    A content field piped through anything else is emitted as raw Markdown. {{ .Content }} renders # Title literally instead of an <h1>, which is the single most common template mistake:

    {{ .Post.Content | safeHTML }}   {{/* posts   — correct */}}
    {{ .Page.Content | safeHTML }}   {{/* pages   — correct */}}
    {{ .Content }}                   {{/* WRONG — raw Markdown reaches the reader */}}
    

    Use it for shortcode output and other pre-rendered HTML too.

    It is also the only way to emit an HTML comment. html/template strips comments while parsing, so <!--email_off--> written literally in a template is simply not in the output — silently, with a green build. Any comment a host is meant to read (Cloudflare's Email Obfuscation opt-out, SSI/ESI markers, a CDN directive) has to go through safeHTML:

    {{ "<!--email_off-->" | safeHTML }}
    …
    {{ "<!--/email_off-->" | safeHTML }}
    
  • decodeHTML value — Unescapes standard HTML entity sequences (e.g. &amp; becomes &).

    {{ decodeHTML .Title }}
    
  • stripHTML value — Strips all HTML tags (<...> pattern) from the string.

    {{ .Content | stripHTML }}
    
  • stripShortcodes value — Strips YouTube and embed WordPress-style bracket shortcodes ([youtube]...[/youtube], [embed]...[/embed]) from the text.

    {{ .Content | stripShortcodes }}
    

Date Formatting

  • formatDate value — Formats a date. If a string is passed, it returns it as-is.
    {{ formatDate .Date }}
    
  • formatDatePL date — Formats a Go time.Time date using Polish month names (e.g., 14 lipca 2026).
    {{ formatDatePL .Date }}
    

Taxonomy and Metadata Lookup

  • getCategoryName id — Looks up and returns the name of a category by its integer ID from metadata.json.
    {{ getCategoryName .Category }}
    
  • getCategorySlug id — Looks up and returns the slug of a category by its integer ID from metadata.json.
    {{ getCategorySlug .Category }}
    
  • int value — Converts a string, int or float to a whole number, truncating toward zero. Every shortcode attribute arrives as a string, so this is what lets [reviews limit="3"] feed a helper that counts. A value that is not a number is a template error, never a silent 0.
    {{ range first (int .Attrs.limit) .SiteData.reviews }}…{{ end }}
    {{ .Attrs.limit | default "6" | int }}   {{/* default composes in front */}}
    
  • float value — The same conversion keeping the fractional part, for a numeric comparison.
    {{ filter "rating" "ge" (float .Attrs.min) .SiteData.reviews }}
    
    add, sub, mul and div accept a numeric string directly too, so {{ add 1 .Attrs.offset }} needs no conversion.
  • isValidCategory id — Returns true unless the ID resolves to the exporter's catch-all term (Uncategorized, Bez kategorii and their translations, matched on slug or name). Since 1.8.56 the ID itself means nothing: 1 is whatever the export numbered first, and a real category there is valid like any other.
    {{ if isValidCategory .Category }}...{{ end }}
    
  • getAuthorName id — Looks up and returns the name of an author by their integer ID from metadata.json.
    {{ getAuthorName .Author }}
    
  • authorURL author — Returns the address of an author's archive, /author/<slug>/, or "" when the value names no author. Accepts the author ID a post carries, the post itself, an author record or a display name. termURL covers registered taxonomies only, so this is how a byline links the archive the build published.
    <a href="{{ authorURL .Post.Author }}">{{ getAuthorName .Post.Author }}</a>
    
  • hasValidCategories page — Returns true if the page or post is filed under at least one category that is not the catch-all term.
    {{ if hasValidCategories . }}...{{ end }}
    

Pages and URLs

  • getURL page — Helper function that returns the calculated URL path for the page or post. Equivalent to calling .GetURL.
    {{ getURL . }}
    
  • getCanonical page — Helper function that returns the full canonical URL for the page or post. Equivalent to calling .GetCanonical .Domain.
    {{ getCanonical . }}
    

Miscellaneous Helpers

  • thumbnailFromYoutube value — Extracts the YouTube video ID from a YouTube WordPress-style shortcode and returns its high-quality video thumbnail URL (https://img.youtube.com/vi/<id>/hqdefault.jpg).
    {{ thumbnailFromYoutube .Content }}
    
  • recentPosts n — Returns a list of the first n posts. Safely clamps the count at both ends to prevent slice panics.
    {{ range recentPosts 5 }}...{{ end }}
    
  • default defaultVal val — Returns defaultVal if val is empty, nil, "", or 0. Otherwise returns val.
    {{ default "No title" .Title }}
    
  • dict key1 val1 key2 val2 ... — Creates a dictionary (map) from key-value arguments. Useful for passing complex parameters into other helpers like imageResize.
    {{ $image := imageResize "photo.jpg" (dict "width" 300 "height" 200 "mode" "fill") }}
    
    dict is also how a partial receives a computed context:
    {{ template "site-head" (dict "Title" .Page.Title "Ctx" .) }}
    

Arithmetic

Go templates have no arithmetic of their own; these four cover the cases a theme actually needs (column splits, "page N of M", index offsets).

  • add a b, sub a b, mul a b, div a b — Integer operands give an integer result and div divides integrally (div 7 23); a float operand anywhere gives a float (div 7.0 23.5). Dividing by zero is a template error rather than infinity, and a non-numeric argument fails the render with a message naming the helper.
    {{ $half := div (add (len .Site.Pages) 1) 2 }}
    {{ range $i, $p := .Site.Pages }}{{ if lt $i $half }}…{{ end }}{{ end }}
    

Availability

  • Theme templates (base/index/post/page/category.html, layouts, partials): every helper above.
  • Shortcode templates: the safe, deterministic subset — slice, append, in, notIn, contains, startsWith, endsWith, hasPrefix, hasSuffix, matches, isNil, isEmpty, ternary — plus the image helpers (imageResize, imageSrcSet, …) and the read-only external-source helpers (getExternal, getExternalMeta). Collection helpers that walk site-wide data stay theme-only.
  • Alt engines (pongo2/mustache/handlebars): not applicable — those engines ship their own filter syntax; these helpers are Go-template only.

Limitations

  • No custom predicates/lambdas, no switch/case, no JS expressions (by design).
  • sort/filter ordering requires comparable field types (see above).
  • groupBy/indexBy/uniq keys must be scalar.
  • uniq across mixed numeric types dedupes by rendered value (1uint8(1)).

Numbered pagination

.Pager carries the neighbouring pages (PrevURL, NextURL) and, since 1.8.38, every page with its address — so a theme can draw the numbered control its source site shows instead of only "← →".

{{range .Pager.Pages}}
  {{if .Current}}<span class="page-numbers current">{{.Number}}</span>
  <a class="page-numbers" href="{{.URL}}">{{.Number}}</a>

A long archive is better drawn windowed — first, last, and a few either side of the current page, with for each gap, which is what WordPress renders:

{{range .Pager.Window 2}}
  {{if .Ellipsis}}<span class="page-numbers dots">…</span>
  {{else if .Current}}<span class="page-numbers current">{{.Number}}</span>
  <a class="page-numbers" href="{{.URL}}">{{.Number}}</a>

The addresses come from the generator, so they follow posts_page, a language prefix and the archive's own base without a theme guessing the URL shape. Current, Total, PerPage, PrevURL and NextURL are unchanged.

Structured data

.Schema is the JSON-LD SSG would inject for the page, already merged in precedence order — site-wide schema: < derived from frontmatter < schema_defaults for the section < the page's own schema:. Render it with toJSON:

<script type="application/ld+json">{{ toJSON .Schema }}</script>

You need it when your theme writes JSON-LD of its own. Auto-injection is gated on the theme having emitted none: one hand-written application/ld+json block turns it off for the whole page, so a theme with, say, an FAQPage partial silently loses whatever schema_defaults declared for that section. Emitting .Schema beside your own block restores it without reimplementing the merge in the theme:

<script type="application/ld+json">{{ toJSON .Schema }}</script>
{{ partial "schema-faq" . }}

Two sibling blocks are what Google asks for when a Recipe and an FAQPage describe the same page. If you would rather ship one block, put both in a graph:

<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[{{ toJSON .Schema }}, {{ toJSON .FAQ }}]}
</script>

check_schema accepts either shape, and reports a section's @type that reached neither (see Validating structured data).