Zero dependency templating engine built for workflows, document generation, and data transformations.
{{ response | from_json | key:'orders' | filter:'status','paid' | pluck:'total' | sum | decimal:2 }}
- Pipe syntax: chain built-in modifiers to transform any value in a single expression
- Nested data: maps, slices, structs, and pointers are all resolved and rendered recursively
- Conditionals:
{{ if x }} … {{ else }} … {{ endif }}blocks - Loops:
{{ for v in items }} … {{ endfor }}over slices and maps, with auto-bound index/key helpers - Nested templates: the
templatemodifier re-enters the engine to render a loaded string (e.g. a file's contents) as its own template, guarded against runaway recursion - Typed errors: missing variables, unknown functions, and malformed tokens are surfaced as distinct errors
- Extensible: register your own modifiers alongside the built-ins, or override a built-in by name
- Zero dependencies: the core engine only imports the Go standard library
go get github.com/toaweme/sintaxpackage main
import (
"fmt"
"log"
"github.com/toaweme/sintax"
"github.com/toaweme/sintax/defaults"
)
func main() {
s := sintax.New(defaults.All())
out, err := s.Render("Hello, {{ name | title }}!", map[string]any{"name": "alice-cooper"})
if err != nil {
log.Fatal(err)
}
fmt.Println(out) // Hello, Alice Cooper!
}sintax.New(opts...) starts from nothing and knows only the modifiers you pass it, so an engine can never
call something you did not wire. defaults.All(safeDirs...) is the batteries-included shortcut, bundling
every built-in into one option - pass one or more directories to enable the file modifier against that
allowlist; with none, file reads stay disabled.
Render returns any because a template that is a single expression yields that value's own Go type (a
{{ items | filter:... }} hands back a real slice you can keep using). When you want text, which is the
usual case for document generation, RenderString runs the same render and returns a string, stringifying
a lone {{ x }} exactly as it would render embedded in surrounding text:
out, err := s.RenderString("Hello, {{ name | title }}!", map[string]any{"name": "alice-cooper"})
if err != nil {
log.Fatal(err)
}
fmt.Println(out) // Hello, Alice Cooper!Because the engine imports no modifiers itself, a size-conscious program can skip defaults entirely and
compose only the groups it uses. Each group under functions/* exposes a Modifiers() constructor, and
WithModifiers merges rather than replaces, so groups stack in a single call:
import (
"github.com/toaweme/sintax"
casing "github.com/toaweme/sintax/functions/text/case"
"github.com/toaweme/sintax/functions/boolean"
)
s := sintax.New( // links only these groups, not the whole battery
sintax.WithModifiers(casing.Modifiers()),
sintax.WithModifiers(boolean.Modifiers()),
)Options apply in order, so layering one on top of defaults.All() replaces any built-in of the same name:
s := sintax.New(defaults.All(), sintax.WithModifiers(overrides))template is not a global modifier. It renders its input as a nested template, so it needs live render
state rather than only its piped value, and it is wired through WithContextualModifiers instead.
defaults.All() includes it. If you compose groups by hand, ask for it explicitly:
s := sintax.New(
sintax.WithModifiers(fs.Modifiers([]string{"partials"})),
sintax.WithContextualModifiers(render.ContextualModifiers()),
)
// `{{ p | file | template }}` now reads a partial and expands itWithMaxDepth(n) bounds how deeply template may re-enter the engine before ErrMaxDepthExceeded,
guarding against a template that renders itself. It defaults to 10.
| Pattern | Example |
|---|---|
| Variable | {{ name }} |
| Nested field access | {{ user | key:'name' }} |
| Modifier chain | {{ text | trim | upper }} |
| Modifier with args | {{ items | join:',' }} |
| Variable as argument | {{ text | trim_prefix:prefix_var }} |
| Fallback to literal | {{ value | default:'n/a' }} |
| Fallback to empty array | {{ items | default:[] }} |
| Fallback to empty object | {{ user | default:{} }} |
| If / else | {{ if active }}yes{{ else }}no{{ endif }} |
| Loop over a slice | {{ for x in items }}- {{ x }}\n{{ endfor }} |
| Loop with index | {{ for i, x in items }}{{ i }}:{{ x }} {{ endfor }} |
| Loop over a map | {{ for k, v in headers }}{{ k }}={{ v }} {{ endfor }} |
Modifier syntax: the name and the first argument are separated by :, additional arguments by ,.
String literals use single or double quotes; [] and {} are empty-collection literals; other unquoted
tokens resolve as variables, numbers, or booleans.
Variable names are literal keys, not paths. A variable is looked up by its exact name in the vars map -
there is no obj.field dot-notation. {{ user.name }} looks for a variable literally named user.name; it does
not descend into a user map. To read a nested field, pipe the value through the key modifier:
{{ user | key:'name' }}. key also accepts a dotted path to reach deeper, e.g. {{ order | key:'meta.total' }}.
Block tags use endif and endfor to close.
A leading or trailing - inside a tag eats whitespace on that side, the same way Jinja and Go templates do:
| Pattern | Effect |
|---|---|
{{- expr }} |
strip trailing whitespace (including newlines) from the text before this tag |
{{ expr -}} |
strip leading whitespace (including newlines) from the text after this tag |
{{- expr -}} |
both at once |
Block control tags (if/else/endif/for/endfor) that sit alone on their own line are auto-trimmed:
the surrounding indentation and the line's newline are removed automatically, so you don't have to write - on
every block tag just to keep your output clean. Use the explicit {{- / -}} form when a tag shares a line
with text or you want extra whitespace eaten.
{{ for v in xs }} iterates over slices, arrays, and maps. Two binding forms are supported:
{{ for v in xs }} … {{ endfor }} # bind value
{{ for i, v in xs }} … {{ endfor }} # bind index/key + value
Inside the loop body the following helpers are available, where <v> is whatever name you bound the value to:
| Binding | Set on |
|---|---|
<v>_index |
both slice and map iterations (0-based) |
<v>_first, <v>_last |
both booleans for the first/last iteration |
<v>_key |
map iterations only, when no explicit key name was bound |
Map iteration order is sorted by key. Loops nest freely and parent variables remain visible inside the body.
{{ for item in cart }}{{ item | key:'name' }} × {{ item | key:'qty' }}{{ if item_last }}.{{ else }}, {{ endif }}{{ endfor }}
Trim, case-shift, slugify, split, and reshape strings.
| Item | Description | Example |
|---|---|---|
concat |
Concat appends one or more strings to the value. | {{ greeting | concat:'!' }} |
join |
Join combines an array of strings into a single string with a separator. | {{ tags | join:',' }} |
lines |
Lines splits a string or byte slice into an array of lines. | {{ note | lines }} |
lower |
ToLower converts a string to lowercase. | {{ email | lower }} |
replace |
Replace replaces all occurrences of a substring within the string value. | {{ greeting | replace:'world','everyone' }} |
replace_pattern |
ReplacePattern replaces all regex matches within the string value. | {{ text | replace_pattern:'\s+',' ' }} |
reverse |
Reverse reverses the characters in a string. | {{ name | reverse }} |
shorten |
Shorten truncates a string to the given maximum character length. | {{ description | shorten:30 }} |
slug |
Slug converts a string to a URL-friendly slug. | {{ title | slug }} |
split |
Split splits a string into an array using a separator. | {{ csv_line | split:',' }} |
template |
Template renders its incoming string value as a nested sintax template, so a value loaded from a file (or any string variable) can itself contain {{ ... }} markup. |
{{ "partial.tpl" | file | template }} |
title |
Title converts a hyphen-separated slug into a title-cased string. | {{ slug | title }} |
title_model |
ModelTitle formats an AI model identifier into a human-readable title. | {{ model_id | title_model }} |
trim |
Trim removes leading and trailing whitespace, or the given character set. | {{ name | trim }} |
trim_prefix |
TrimPrefix removes a leading prefix string or leading whitespace from the value. | {{ path | trim_prefix:'/' }} |
trim_suffix |
TrimSuffix removes a trailing suffix string or trailing whitespace from the value. | {{ url | trim_suffix:'/' }} |
upper |
ToUpper converts a string to uppercase. | {{ name | upper }} |
Sort, filter, find, and reshape arrays and maps.
| Item | Description | Example |
|---|---|---|
filter |
Filter returns a subset of a slice where a nested field matches a value. | {{ items | filter:'status','active' }} |
find |
Find returns the first element in a slice or map where a field equals the given value. | {{ users | find:'id',42 }} |
first |
First returns the first character of a string or the first element of a slice. | {{ items | first }} |
flatten |
Flatten flattens a slice of slices by one level. | {{ groups | pluck:'items' | flatten }} |
has |
Has returns true if the slice or map contains the given value. | {{ tags | has:'featured' }} |
is |
Is returns true if the value equals any of the given parameters. | {{ status | is:'active' }} |
key |
Key extracts a value from a map or slice by key path or index. | {{ user | key:'name' }} |
last |
Last returns the last character of a string or the last element of a slice. | {{ items | last }} |
map |
Map converts a slice of maps into a map keyed by the given field's string value. | {{ users | map:'id' }} |
merge |
Merge converts a slice of maps into a map keyed by the given field's string value. | {{ users | merge:'id' }} |
pluck |
Pluck extracts a single field from each element of a slice of maps and returns a slice of values. | {{ users | pluck:'id' }} |
sort |
Sort sorts a slice in ascending or descending order. | {{ names | sort }} |
sum |
Sum returns the numeric sum of the elements of a slice. | {{ amounts | sum }} |
wrap |
Wrap wraps the value in a map under the given key. | {{ name | wrap:'user' }} |
Compare values for use inside if/else blocks and conditional expressions.
| Item | Description | Example |
|---|---|---|
eq |
Eq returns true if the value equals the given parameter. | {{ status | eq:'active' }} |
gt |
Gt returns true if the numeric value is greater than the threshold. | {{ items_in_cart | gt:0 }} |
gte |
Gte returns true if the numeric value is greater than or equal to the threshold. | {{ qty | gte:1 }} |
not |
Not inverts the truthiness of the value. | {{ is_active | not }} |
Move between Go values, JSON, YAML, and other serialized formats.
| Item | Description | Example |
|---|---|---|
from_csv |
Parses a CSV string into a list of rows keyed by the header row. | {{ body | from_csv }} |
from_json |
Parses a JSON object string into a map. | {{ body | from_json }} |
from_yaml |
Parses a YAML document into a map. Ships as a stub until you inject a codec. | {{ body | from_yaml }} |
json |
JSON serializes the value to a JSON string. | {{ user | json }} |
markdown |
Markdown converts an HTML string to Markdown. | {{ html_content | markdown }} |
yaml |
YAML serializes or parses a value as YAML. | {{ config | yaml }} |
Defaults, lengths, line numbers, and date formatting.
| Item | Description | Example |
|---|---|---|
decimal |
Decimal formats a number with a fixed number of decimal places. | {{ amount | decimal:2 }} |
default |
Default returns the fallback value if the input is nil or an empty string. | {{ name | default:'anonymous' }} |
format |
Format formats a time.Time value using a date format string. | {{ created_at | format:'YYYY-MM-DD' }} |
length |
Length returns the number of characters in a string, bytes in a byte slice, or elements in a slice/array/map. | {{ name | length }} |
line_numbers |
LineNumbers prepends each line of the string with its one-based line number, or a number counting up from a given start with line_numbers:6. |
{{ note | line_numbers }} |
Pull pieces out of file paths - directory, name, and extension - and read files from an allowlist of directories.
| Item | Description | Example |
|---|---|---|
dirname |
Dirname returns the directory portion of a file path. | {{ file_path | dirname }} |
ext |
FilenameExt returns the file extension without the leading dot. | {{ file_path | ext }} |
ext_dot |
FilenameExtDot returns the file extension including the leading dot. | {{ file_path | ext_dot }} |
ext_prepend |
FilenamePrependExt inserts an additional extension before the existing file extension. | {{ file_path | ext_prepend:'min' }} |
ext_trim |
FilenameTrimExt returns the file path without its extension. | {{ file_path | ext_trim }} |
file |
File reads a file's contents as a string. The path is resolved against the safeDirs passed to defaults.New (or fs.Modifiers), and .. traversal outside them is rejected. |
{{ "greeting.tpl" | file }} |
filename |
Filename returns the base file name from a path, including the extension. | {{ file_path | filename }} |
Convert numbers between currency units like dollars and cents.
| Item | Description | Example |
|---|---|---|
currency |
Currency converts a numeric value between currency units by applying a unit multiplier ratio. | {{ price | currency:1,100 }} |
The sections below are for embedding sintax inside a Go program: instantiating the engine, registering custom modifiers, and surfacing typed errors. The template syntax itself is documented above.
The engine speaks through a small, stable set of interfaces. Everything else (StringParser,
TokenRenderer, BaseToken) is an implementation detail.
type Sintax interface {
Render(template string, vars map[string]any) (any, error)
}type Parser interface {
Parse(template string) ([]Token, error)
}type Renderer interface {
Render(tokens []Token, vars map[string]any) (any, error)
}type Token interface {
Type() TokenType
Raw() string
Name() string
Params() []string
WithDefault() bool
LoopExpr() string
}Every failure mode has a sentinel error you can match with errors.Is. Wrap or compare, never string-match.
_, err := s.Render(template, vars)
switch {
case errors.Is(err, sintax.ErrVariableNotFound):
// referenced variable missing, use | default:'...' to avoid this
case errors.Is(err, sintax.ErrFunctionNotFound):
// a modifier name in the pipe chain isn't registered
case errors.Is(err, sintax.ErrFunctionApplyFailed):
// a modifier returned an error or the value type was unexpected
case errors.Is(err, sintax.ErrInvalidTokenType):
// the parser produced a token the renderer doesn't know how to handle
case errors.Is(err, sintax.ErrMaxDepthExceeded):
// the `template` modifier recursed past the nesting limit
case err != nil:
// unclassified failure, e.g. a malformed if/for block
}Pass your own modifiers with WithModifiers. A later option wins, so registering a built-in's name
replaces it, which is useful for sandboxing or instrumenting a modifier.
overrides := map[string]functions.GlobalModifier{
"redact": func(value any, params []any) (any, error) {
return "***", nil
},
}
s := sintax.New(defaults.All(), sintax.WithModifiers(overrides))
// {{ secret | redact }} → ***functions.GlobalModifier (aliased as sintax.GlobalModifier) is func(value any, params []any) (any, error). The first positional argument from the
template flows in as value; everything after the modifier name (separated by ,) shows up in params.
yaml and markdown ship as stubs that return an error until you inject a real implementation, so the
core stays dependency-light. Wire in whatever library you prefer; the examples below just show one option each.
YAML serialization: the example uses gopkg.in/yaml.v3, but any YAML library works
overrides := map[string]functions.GlobalModifier{
"yaml": func(value any, params []any) (any, error) {
b, err := yaml.Marshal(value)
return string(b), err
},
}
s := sintax.New(defaults.All(), sintax.WithModifiers(overrides))HTML to Markdown: the example uses github.com/JohannesKaufmann/html-to-markdown/v2, but any converter works
overrides := map[string]functions.GlobalModifier{
"markdown": func(value any, params []any) (any, error) {
html, err := functions.ValueString(value)
if err != nil {
return nil, err
}
conv := converter.NewConverter(converter.WithPlugins(base.NewBasePlugin()))
return conv.ConvertString(html)
},
}
s := sintax.New(defaults.All(), sintax.WithModifiers(overrides))sintax uses an issue-first workflow. Open an issue describing the change and wait for a maintainer to approve the approach (the approved label) before you open a pull request. PRs that don't reference an approved issue are flagged by a bot and usually closed, so the issue step saves you wasted work.
Every commit must be signed off for the Developer Certificate of Origin with git commit -s. A CI check enforces this on every commit in a pull request.
Full flow in CONTRIBUTING.md, ground rules in CODE_OF_CONDUCT.md.
Reports for this repo are hosted by our code viewer, which also serves the badges and cards above.
Made with ❤️ in Lithuania 🇱🇹.