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
related / relatedFromMddb — related posts
{{ 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][]stringon 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
sliceoverrides Go's builtinslice(value, i, j)sub-slicing function. Bundled themes do not use the builtin; if yours does, switch toprintf "%.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(aliashtml) — Emits a value as HTML with no processing at all — the plaintemplate.HTMLcast. 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>safeHTMLis 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 value— Renders 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,.mdlink rewriting,link_rewrites, sanitizing — and returnstemplate.HTMLso the result is not escaped again.A content field piped through anything else is emitted as raw Markdown.
{{ .Content }}renders# Titleliterally 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/templatestrips 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 throughsafeHTML:{{ "<!--email_off-->" | safeHTML }} … {{ "<!--/email_off-->" | safeHTML }} -
decodeHTML value— Unescapes standard HTML entity sequences (e.g.&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 Gotime.Timedate 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 frommetadata.json.{{ getCategoryName .Category }}getCategorySlug id— Looks up and returns the slug of a category by its integer ID frommetadata.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 silent0.{{ 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,mulanddivaccept a numeric string directly too, so{{ add 1 .Attrs.offset }}needs no conversion.isValidCategory id— Returnstrueunless the ID resolves to the exporter's catch-all term (Uncategorized,Bez kategoriiand their translations, matched on slug or name). Since 1.8.56 the ID itself means nothing:1is 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 frommetadata.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.termURLcovers 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— Returnstrueif 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 firstnposts. Safely clamps the count at both ends to prevent slice panics.{{ range recentPosts 5 }}...{{ end }}default defaultVal val— ReturnsdefaultValifvalis empty,nil,"", or0. Otherwise returnsval.{{ 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 likeimageResize.{{ $image := imageResize "photo.jpg" (dict "width" 300 "height" 200 "mode" "fill") }}dictis 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 anddivdivides integrally (div 7 2→3); a float operand anywhere gives a float (div 7.0 2→3.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/filterordering requires comparable field types (see above).groupBy/indexBy/uniqkeys must be scalar.uniqacross mixed numeric types dedupes by rendered value (1≡uint8(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).