DocsSintaxQuick Guide

Quick Guide

Construct the engine, register your own modifiers, and read what a render returns or fails with.

This guide covers constructing the engine, wiring the built-in modifiers, adding your own, and reading what a render returns or fails with. For the 57 built-in modifiers themselves, see the modifier reference.

Install

go get github.com/toaweme/sintax

Rendering a template

sintax.Render parses and renders in one call. It takes the template, the variables, and any number of options, and returns the rendered value.

import (
	"github.com/toaweme/sintax"
	"github.com/toaweme/sintax/defaults"
)

out, err := sintax.Render(
	"{{ users | filter:'active',true | pluck:'email' }}",
	map[string]any{"users": users},
	defaults.All(),
)

Render returns any, not a string, and that is deliberate. When a template is a single expression like {{ users | pluck:'email' }}, you get back the real Go value it produced, so the call above hands you a []string of emails to range over rather than a stringified blob you would have to parse again. Add any surrounding text and the result comes back as a normal string.

When a string is all you want, reach for RenderString. It renders the same way and then formats the result the way sintax formats a value sitting inside surrounding text, so RenderString of a bare {{ x }} reads exactly like {{ x }} embedded in a larger template. This is the path for document generation, where the output is always text.

text, err := sintax.RenderString(
	"Hello, {{ name | title }}!",
	map[string]any{"name": "ada"},
	defaults.All(),
)
// text == "Hello, Ada!"

Reusing the engine

Render builds a fresh engine on every call. When you render more than once, build the engine once with New and call its Render method.

engine := sintax.New(defaults.All())

for _, u := range users {
	out, err := engine.Render("Hello, {{ name | title }}!", u)
	// ...
}

Options

New starts from nothing and takes functional options, so the engine carries exactly the modifiers you give it. defaults.All is the whole battery bundled as one option.

  • defaults.All(safeDirs ...string) bundles every built-in modifier, global and contextual alike, into a single option for sintax.New.
  • sintax.WithModifiers(funcs map[string]GlobalModifier) adds global modifiers keyed by their template names.
  • sintax.WithContextualModifiers(funcs map[string]ContextualModifier) adds contextual modifiers keyed by their template names, merging on the same terms as WithModifiers.
  • sintax.WithMaxDepth(depth int) bounds how deeply the template modifier may re-enter the engine before ErrMaxDepthExceeded, guarding against self-referential templates that would otherwise recurse forever.
  • sintax.WithOptions(opts ...Option) bundles opts into a single Option, so a package can hand out a whole preconfigured engine setup as one value that callers can still layer their own options on top of.

Options merge in order, and a later one overrides an earlier registration of the same name, so you can start from defaults.All() and replace a single built-in.

The file allowlist

The file modifier reads from disk, so it stays closed until you hand it an allowlist. Pass the directories it may read from to defaults.All (or defaults.New). With none, every file read is refused.

engine := sintax.New(defaults.All("/etc/app/templates", "/var/app/partials"))

A path that resolves outside the allowlist reports a miss rather than reading the file, so {{ p | file | default:'' }} renders empty instead of failing.

Your own modifiers

A modifier is one function. sintax.GlobalModifier takes the piped value and the call parameters and returns the new value.

shout := func(value any, params []any) (any, error) {
	s, err := functions.ValueString(value)
	if err != nil {
		return nil, err
	}
	return strings.ToUpper(s) + "!", nil
}

engine := sintax.New(
	defaults.All(),
	sintax.WithModifiers(map[string]sintax.GlobalModifier{"shout": shout}),
)

Because options merge by name, registering "upper" here would replace the built-in upper for this engine.

Writing the type coercion by hand is easy to get subtly wrong, so the functions package adapts a plain typed function into a modifier for you. functions.Wrap covers a no-parameter function, WrapOne and WrapTwo cover fixed arities, and WrapVariadic covers one repeated parameter type.

import "github.com/toaweme/sintax/functions"

// func(string) (string, error) becomes a modifier that coerces its value to a
// string and rejects any parameters.
reverse := functions.Wrap(func(s string) (string, error) {
	return reverseRunes(s), nil
})

When a modifier behaves differently by value shape (a string versus a slice), write one typed clause per shape and combine them with functions.Overload, which tries each in order and takes the first that accepts the value and its parameters.

How modifiers fail

Not every empty result is a failure. A modifier that goes looking for data and finds none reports a miss, and a miss is catchable. It travels down the pipe as nil, where default can replace it with a fallback, an if reads it as false, and a for iterates nothing. Report one from your own modifier with functions.Miss, which keeps the catchable marker reachable through errors.Is while still reading as your own message.

if len(rows) == 0 {
	return nil, functions.Miss("find matched no row")
}

Being handed the wrong kind of value is a different thing, and it stays terminal. A type the modifier cannot accept, a missing parameter, or a bad parameter means the template itself is wrong, so no default rescues it. The Wrap adapters return the bare type and arity sentinels (functions.ErrInvalidValueType, functions.ErrInvalidParamType, functions.ErrMissingParam) for exactly this, and functions.IsParamError reports whether a failure was a parameter rejection.

When a modifier does fail, the engine names it. A chain like {{ text | trim | upper:'z' | lower }} has several places to go wrong, so the error reads modifier "upper": .... It carries the failing name as a field on sintax.ModifierError, reachable with errors.As rather than by parsing the message.

var modErr *sintax.ModifierError
if errors.As(err, &modErr) {
	log.Printf("modifier %q failed on variable %q", modErr.Modifier, modErr.Variable)
}

Engine errors

Parse and render failures surface as sentinel errors you can branch on with errors.Is.

  • sintax.ErrVariableNotFound when a template names a variable the data does not carry.
  • sintax.ErrFunctionNotFound when a template names a modifier the engine was not given.
  • sintax.ErrFunctionApplyFailed when a modifier returns an error, the sentinel the ModifierError above wraps.
  • sintax.ErrMaxDepthExceeded when the template modifier re-enters the engine past its depth guard.
  • sintax.ErrInvalidTokenType when the parser yields a malformed token.

API reference

The whole public surface in one place.

Entry points

  • func New(opts ...Option) *sintax creates a Sintax configured by opts.
  • func Render(template string, vars map[string]any, opts ...Option) (any, error) parses and renders template against vars in one call, using an engine configured by opts.
  • func RenderString(template string, vars map[string]any, opts ...Option) (string, error) parses and renders template against vars in one call and returns the result as text, using an engine configured by opts.

Contracts

The interfaces the engine is assembled from. You implement one only to replace a stage of the pipeline; New returns ready-made ones, so most callers never name them.

  • Sintax renders a template string against a variable set.
  • Parser tokenizes a template string.
  • Renderer renders a token stream against a variable set.
  • Token is a single parsed unit of a template, such as a text run, a variable reference, or a control-flow marker.