mist

package module
v0.39.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 24, 2026 License: MIT Imports: 21 Imported by: 0

README

mist

CI Go Reference OpenSSF Scorecard OpenSSF Best Practices

A single-pass Go renderer for most of Liquid, fast enough to try before falling back to a full engine.

mist evaluates as it scans, with no parse tree and no allocations on the hot path: about 10–80× faster than liquidjs on its benchmark templates. For every template it accepts, its output matches liquidjs 10 (lenientIf: true) byte for byte. Anything it can't render exactly returns ErrUnsupported, so you render that template with your full engine instead.

Use it in front of liquidjs (or an engine that behaves like it) when you render many templates and most of them are ordinary. It isn't a complete Liquid engine on its own.

Install

go get github.com/hownowstephen/mist

Requires Go 1.25 or newer.

Quickstart

out, err := mist.Render(tpl, vars, strict)
switch {
case errors.Is(err, mist.ErrUnsupported):
	out, err = fullEngine.Render(tpl, vars, strict) // outside what mist renders exactly
case errors.Is(err, mist.ErrUndefined):
	// strict mode, undefined variable; the full engine fails too
}

vars is plain JSON-shaped data (map[string]any). mist.Append renders into a reusable buffer without allocating.

Will my templates render?

go run github.com/hownowstephen/mist/cmd/mistcheck@latest template.liquid

It reports the first construct each template uses that mist doesn't render; -stats summarizes a whole set. See docs/mistcheck.md.

Docs

docs/supported.md What renders, by tag, expression and filter, and what falls back
docs/extending.md Engine: custom tags and filters, chains of templates, dialects for other Liquid engines
docs/mistcheck.md The template checker
SPEC.md The normative grammar and semantics, and how parity is verified
pkg.go.dev API reference with runnable examples
CONTRIBUTING.md · AGENTS.md Working on mist, for people and coding agents
SECURITY.md Reporting vulnerabilities, verifying releases

Releases and their notes are on the releases page. Report bugs, including any output that differs from liquidjs, in issues.

License

MIT

Documentation

Overview

Package mist renders a strict subset of Liquid in a single pass, bailing with ErrUnsupported on anything outside the subset so the caller can fall back to a full engine. See SPEC.md for the exact subset.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrUnsupported means the template is outside the subset; render it with the full engine instead.
	ErrUnsupported = errors.New("mist: unsupported")
	// ErrUndefined is a strict-mode undefined variable. The full engine fails too, though a
	// syntax error later in the template may take precedence there.
	ErrUndefined = errors.New("mist: undefined variable")
	// ErrBuiltin returned by a FilterFunc applies the built-in filter of the same name
	// to the same input and arguments, as if none were registered.
	ErrBuiltin = errors.New("mist: use the built-in filter")
	// ErrUnchanged returned by a FilterFunc leaves the filter's input as it was. Unlike
	// returning Filter.Input, it keeps an undefined input undefined rather than null.
	ErrUnchanged = errors.New("mist: leave the input unchanged")
	// ErrInternal is a panic in mist or in a registered tag, filter or dialect hook,
	// recovered so the caller can fall back. It wraps ErrUnsupported. It means a bug, so
	// report it: Error.Stack says where it happened.
	ErrInternal = fmt.Errorf("%w: internal error", ErrUnsupported)
)

Functions

func Append added in v0.2.0

func Append(dst []byte, tpl string, vars map[string]any, strict bool) ([]byte, error)

Append is Render appending to dst, so callers can reuse a buffer and render without allocating. On error out is nil.

func Check

func Check(tpl string) error

Check reports whether tpl is inside the subset, parsing every branch without data. Templates that pass can still bail at render time on data-dependent rules (see SPEC.md).

Example
package main

import (
	"errors"
	"fmt"

	"github.com/hownowstephen/mist"
)

func main() {
	fmt.Println(mist.Check("{% if a %}{{ a | upcase }}{% endif %}"))
	fmt.Println(errors.Is(mist.Check("{% tablerow x in xs %}{% endtablerow %}"), mist.ErrUnsupported))
}
Output:
<nil>
true

func Location added in v0.37.0

func Location(name string) (*time.Location, bool)

Location is the IANA zone name as the date filter loads it, or false where Go can't load it or it's a name Go loads but JavaScript's Intl rejects (see ianaName). Locations are cached.

func Render

func Render(tpl string, vars map[string]any, strict bool) (string, error)

Render renders tpl with vars. strict makes undefined variables in output an error.

Example
package main

import (
	"fmt"

	"github.com/hownowstephen/mist"
)

func main() {
	out, err := mist.Render("Hi {{ name | capitalize }}!", map[string]any{"name": "ada"}, true)
	fmt.Println(out, err)
}
Output:
Hi Ada! <nil>
Example (Fallback)

Templates outside the subset return ErrUnsupported; render those with the full engine.

package main

import (
	"errors"
	"fmt"

	"github.com/hownowstephen/mist"
)

func main() {
	_, err := mist.Render("{% tablerow x in xs %}{% endtablerow %}", nil, false)
	if errors.Is(err, mist.ErrUnsupported) {
		fmt.Println("render with the full engine")
	}
}
Output:
render with the full engine

Types

type Comparer added in v0.23.0

type Comparer interface {
	// Compare evaluates v op other for ==, !=, <, >, <=, >=, with other as dialect
	// hooks see values. When v is on the right, op is mirrored: a < v is v > a.
	// Returning an error that wraps ErrUnsupported bails; any other stops rendering.
	Compare(op string, other any) (bool, error)
}

Comparer is a value a registered filter or tag returns that compares itself in conditions, as a JavaScript object with its own semantics does in liquidjs (a decimal.js Decimal, say). Elsewhere mist treats it as any other unknown type.

type Constructs added in v0.5.0

type Constructs uint32

Constructs is a set of template constructs a Dialect can reject.

const (
	TrimMarkers       Constructs = 1 << iota // {{- -}} {%- -%}
	NegativeLiterals                         // -3, including negative indexes
	UnspacedOperators                        // a comparison operator with no whitespace before it: x==2
	RawBlocks                                // {% raw %}…{% endraw %}
	BlankKeyword                             // == blank, != blank
	EmptyKeyword                             // == empty, != empty
)

The constructs a Dialect can reject.

type Dialect added in v0.5.0

type Dialect struct {
	// Output appends a printed non-string value: a number, bool, array, object, or
	// the Blank or Empty keyword. Strings, nil and undefined never reach it.
	Output func(dst []byte, v any) ([]byte, error)

	// Compare evaluates ==, !=, <, >, <=, >= in conditions, and matches when values
	// against the case subject with ==.
	Compare func(op string, a, b any) (bool, error)

	// Contains evaluates a contains b in conditions. With Compare set and Contains
	// nil, contains bails.
	Contains func(a, b any) (bool, error)

	// Reject makes these constructs bail, in both Render and Check.
	Reject Constructs

	// NoDefaultLeniency turns off liquidjs's rule that a leading `| default`
	// makes an undefined input lenient under strict.
	NoDefaultLeniency bool

	// NumberText hands number literals to hooks and registered filters as
	// json.Number holding their text, so an engine that distinguishes 2.0 from 2
	// (Ruby's Float and Integer) sees which was written.
	NumberText bool

	// NoBuiltinFilters makes every filter that isn't in Engine.Filters bail,
	// built-ins included, for an engine whose filters all differ from liquidjs's.
	// A registered filter returning ErrBuiltin bails too.
	NoBuiltinFilters bool

	// StrictAssign makes an undefined variable in an assign's value ErrUndefined under
	// strict, as Ruby Liquid's strict_variables does; liquidjs reads it leniently.
	StrictAssign bool

	// RubyLookups looks variables up as Ruby Liquid's Context#variable does: when the
	// first segment is nil or false the whole lookup is that value, and past it a
	// property or index of nil or false is undefined (ErrUndefined under strict).
	RubyLookups bool
}

Dialect changes how values print and compare, and which constructs mist accepts, so an application can target a Liquid engine that differs from liquidjs. With a dialect set, matching that engine is up to the dialect's author.

type Engine added in v0.3.0

type Engine struct {
	// Tags maps inline tag names, as in {% name args %}, to their implementations.
	// Built-in tag names can't be overridden.
	Tags map[string]TagFunc
	// Filters maps filter names, as in {{ x | name: arg }}, to their implementations.
	// They override built-in filters of the same name, as registerFilter does in liquidjs.
	Filters map[string]FilterFunc
	// Dialect, if set, changes how values print and compare and which constructs
	// bail. nil means liquidjs, the SPEC parity target.
	Dialect *Dialect
	// Now is the clock for the date filter's 'now' and 'today'. nil means time.Now.
	Now func() time.Time
	// PassUnknownFilters passes the input of a filter that is neither built in nor in
	// Filters through unchanged, after evaluating its arguments, as liquidjs does with
	// strictFilters off (its default). Leave it false, so such templates bail, unless
	// Filters already covers every filter the full engine defines.
	PassUnknownFilters bool
	// Limits bound each render; zero fields take the defaults.
	Limits Limits
}

Engine renders templates with custom tags and filters. The zero value has none, and the package-level functions use it. An Engine is safe for concurrent use as long as nothing modifies it or its maps while it renders.

Example (CustomFilter)
package main

import (
	"fmt"
	"strings"

	"github.com/hownowstephen/mist"
)

func main() {
	e := mist.Engine{Filters: map[string]mist.FilterFunc{
		"shout": func(f mist.Filter) (any, error) {
			s, ok := f.Input.(string)
			if !ok {
				return nil, mist.ErrUnsupported
			}
			return strings.ToUpper(s) + "!", nil
		},
	}}
	out, _ := e.Render("{{ greeting | shout }}", map[string]any{"greeting": "hello"}, true)
	fmt.Println(out)
}
Output:
HELLO!
Example (CustomTag)
package main

import (
	"fmt"

	"github.com/hownowstephen/mist"
)

func main() {
	e := mist.Engine{Tags: map[string]mist.TagFunc{
		"link": func(dst []byte, t mist.Tag) ([]byte, error) {
			url, ok := t.Lookup(t.Args)
			if !ok {
				return nil, mist.ErrUnsupported
			}
			return fmt.Appendf(dst, `<a href="%s">`, url), nil
		},
	}}
	out, _ := e.Render("{% for p in products %}{% link p.url %}{% endfor %}",
		map[string]any{"products": []any{map[string]any{"url": "/a"}, map[string]any{"url": "/b"}}}, true)
	fmt.Println(out)
}
Output:
<a href="/a"><a href="/b">

func (Engine) Append added in v0.3.0

func (e Engine) Append(dst []byte, tpl string, vars map[string]any, strict bool) (out []byte, err error)

Append is the package-level Append with e's configuration.

func (Engine) Check added in v0.3.0

func (e Engine) Check(tpl string) (err error)

Check is the package-level Check with e's configuration.

func (Engine) Render added in v0.3.0

func (e Engine) Render(tpl string, vars map[string]any, strict bool) (string, error)

Render is the package-level Render with e's configuration.

func (Engine) RenderChain added in v0.3.0

func (e Engine) RenderChain(steps []Step, vars map[string]any) (res []Result, n int, hydrated map[string]any)

RenderChain is the package-level RenderChain with e's configuration.

type Error

type Error struct {
	// Kind is ErrUnsupported, ErrUndefined, ErrInternal, or the error a registered tag,
	// filter or dialect hook returned.
	Kind error
	// Code names the reason in a form that stays the same across releases, for metrics
	// and grouping: "undefined", "internal", "tag:NAME" or "filter:NAME" for a tag or
	// filter mist doesn't support, "hook:tag:NAME", "hook:filter:NAME" or
	// "hook:dialect:output" for a hook's error, and otherwise a kebab-case reason such as
	// "nesting-too-deep" (testdata/codes.txt lists them all). Msg is for people and may change.
	Code string
	Pos  int // byte offset into the template
	// Line and Col are Pos as liquidjs counts them: lines split on \n, columns in UTF-16
	// code units, both from 1.
	Line, Col int
	Msg       string
	// Path, for ErrUndefined, is what liquidjs's error names: the keys up to and including
	// the first undefined one, bracket indexes as their values' JavaScript strings, so its
	// message is "undefined variable: " + strings.Join(Path, ".") + ", line:Line, col:Col".
	// It's nil where mist can't say.
	Path  []string
	Stack []byte // ErrInternal: the stack where the panic happened
}

Error is the error Render, Append and Check return. errors.Is matches its Kind.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Filter added in v0.4.0

type Filter struct {
	Name   string
	Input  any   // nil for both nil and undefined; inside arrays and objects, undefined is Undefined
	Args   []any // positional arguments
	Strict bool
	Now    func() time.Time // the engine's clock: Engine.Now, or time.Now when that's nil
	// contains filtered or unexported fields
}

Filter is one application of a registered filter.

func (Filter) ArgUndefined added in v0.39.0

func (f Filter) ArgUndefined(i int) bool

ArgUndefined reports whether a nil Args[i] is JavaScript's undefined rather than null.

func (Filter) InputUndefined added in v0.39.0

func (f Filter) InputUndefined() bool

InputUndefined reports whether a nil Input is JavaScript's undefined, such as a missing variable or a built-in filter's undefined result, rather than null.

type FilterFunc added in v0.4.0

type FilterFunc func(f Filter) (any, error)

FilterFunc implements a filter. Returning an error that wraps ErrUnsupported hands the template to the full engine, ErrBuiltin applies the built-in filter of the same name instead, and ErrUnchanged leaves the input as it was; any other error stops rendering.

type Keyword added in v0.5.0

type Keyword string

Keyword is how a keyword literal reaches Dialect.Compare and Dialect.Output.

const (
	Blank Keyword = "blank"
	Empty Keyword = "empty"
)

Blank and Empty are the blank and empty keywords as the hooks see them.

type Limits added in v0.21.0

type Limits struct {
	// Memory is a budget in liquidjs's memoryLimit units. Each filter and range spends at
	// least what liquidjs charges it (the lengths of a filter's input and arguments, join's
	// and slice's sizes, and a range's items), so with Memory at or below the full engine's
	// memoryLimit, mist never renders a template that engine rejects. Default 10,000,000.
	Memory int
	// Output is the most bytes one render writes, captures included. Default 4 MiB.
	Output int
	// Iterations is the most for-loop iterations in one render. Default 1,000,000.
	Iterations int
}

Limits bound one render. Going over one bails with ErrUnsupported, so the full engine, with its own limits, decides.

type Result

type Result struct {
	Out string
	Err error // ErrUndefined or a custom tag's error; unsupported steps are never returned
}

Result is one step's output in a RenderChain.

func RenderChain

func RenderChain(steps []Step, vars map[string]any) (res []Result, n int, hydrated map[string]any)

RenderChain renders steps in order, binding each output into the vars seen by later steps (see SPEC.md § Chains). It stops at the first step mist can't render and returns n, its index; the caller renders steps[n:] with the full engine using the returned vars. The caller's maps are never mutated.

type Step

type Step struct {
	Body   string
	Key    []string // where the output is bound for later steps
	Strict bool
	Vars   map[string]any // replaces the chain vars for this step only
}

Step is one template in a RenderChain.

type Tag added in v0.3.0

type Tag struct {
	Name   string
	Args   string         // raw text after the name, trimmed
	Source string         // the tag as written, {% to %}; a {% liquid %} line has no delimiters
	Vars   map[string]any // the data passed to Render, without assigns or loop variables
	Strict bool
	Depth  int // how many Tag.Render calls the tag runs inside: 0 in the template itself
	// contains filtered or unexported fields
}

Tag is one use of a custom tag, valid only while its TagFunc runs.

func (Tag) Lookup added in v0.3.0

func (t Tag) Lookup(path string) (v any, ok bool)

Lookup resolves a variable path such as `product.url` or `items[0]` in the tag's scope: loop variables, then assigns, then Vars. ok is false if it's undefined or not a valid path; undefined items of an array or object it returns are Undefined.

func (Tag) Render added in v0.24.0

func (t Tag) Render(dst []byte, src string) (out []byte, err error)

Render appends src rendered as Liquid in the tag's scope, as liquidjs renders a template with the caller's Context: src sees the tag's loop variables, assigns and Vars, and its assigns, counters and cycles persist after it. It shares the render's limits. Blocks src opens must close in it, and its break and continue can't reach the tag's loops. A TagFunc can return its error as is: an *Error stops the outer render with the same Kind and Code, at the tag. On error out is nil.

type TagFunc added in v0.3.0

type TagFunc func(dst []byte, t Tag) ([]byte, error)

TagFunc appends a custom tag's output to dst. Returning an error that wraps ErrUnsupported hands the template to the full engine; any other error stops rendering.

type Undefined added in v0.21.0

type Undefined struct{}

Undefined is JavaScript's undefined, a missing value, which liquidjs distinguishes from null: only undefined trips strict mode, and undefined != null under ==. Hooks see it inside arrays and objects mist builds, such as `map` of a missing property; at the top level they get nil for it instead.

Directories

Path Synopsis
cmd
mistcheck command
Command mistcheck reports whether Liquid templates are inside the mist subset.
Command mistcheck reports whether Liquid templates are inside the mist subset.
scripts
fuzzcases command
Command fuzzcases turns Go fuzz corpus files (testdata/fuzz, or the fuzz cache under $(go env GOCACHE)/fuzz) into oracle.mjs input, so what fuzzing found gets a liquidjs differential.
Command fuzzcases turns Go fuzz corpus files (testdata/fuzz, or the fuzz cache under $(go env GOCACHE)/fuzz) into oracle.mjs input, so what fuzzing found gets a liquidjs differential.
gen command
Command gen writes random templates drawn from the SPEC.md grammar, with random data, in harvest.rb's format for oracle.mjs.
Command gen writes random templates drawn from the SPEC.md grammar, with random data, in harvest.rb's format for oracle.mjs.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL