A Go template engine at github.com/kaptinlin/template. Django-inspired control flow + Liquid-compatible filter syntax. The public design is centered on one concept:
- Engine —
New(...)withWithLoader(...),WithFormat(...), and optionalWithLayout(). It handles source parsing, named-template loading,include/extends/block/{{ block.super }}/raw/safe, and HTML auto-escape.
Avoid rebuilding parallel entry points. New behavior should slot into Engine, Format, or Feature.
Go 1.26+.
task test # go test -race ./...
task lint # golangci-lint + go mod tidy check
task verify # deps + fmt + vet + lint + test
go test ./... # direct test run
go test -cover ./...CI runs task test and task lint on push/PR to main.
- Google Go style guide: imports grouped (stdlib, external, internal), doc comments start with function name, table-driven subtests
- golangci-lint: errcheck, govet, staticcheck, revive, gosec, exhaustive, err113, errorlint, gci (see
.golangci.yml) - Formatters: gofmt, goimports, gci
- Receiver names: short and consistent (
pfor Parser,lfor Lexer,vfor Value,efor Engine,nfor Node types,ecfor RenderContext) - Sentinel errors wrapped with
fmt.Errorf("%w: %s", ErrSomething, detail) - All errors defined as sentinel
varinerrors.go
Source → Lexer → Tokens → Parser → AST → Template.Execute → Output
↑
Engine.Load caches Templates here and
threads its private tag/filter
registries + Loader through
| File | Purpose |
|---|---|
compile.go |
Internal compileForEngine(src, engine) |
template.go |
Template struct, Execute with extends-chain walk, executeRoot |
lexer.go |
Tokenizer; allowRaw opt-in for {% raw %} block |
token.go |
Token types and keywords |
parser.go |
Main parser; Parser.engine/parent/blocks/hasNonTrivialContent fields |
parser_helpers.go |
ParseError, Match/Expect helpers |
expr.go |
Expression parser |
nodes.go |
AST nodes; OutputNode.Execute auto-escape path; FilterNode.Evaluate engine-aware lookup |
value.go |
Value wrapper |
data.go |
Data, RenderContext (with engine/autoescape/includeDepth/currentLeaf) |
errors.go |
All sentinel errors |
filters.go |
Registry (with parent fallback), global defaultRegistry |
filter_string.go |
Built-in string filters; escape/escape_once (global string) + escapeFilterSafe/escapeOnceFilterSafe (FormatHTML SafeString) |
filter_*.go |
Other built-in filters (math, array, map, date, number, format) |
tags.go |
TagRegistry (with parent fallback); builtinTags (4) vs layoutTags (3) |
tag_if.go/tag_for.go/tag_break.go/tag_continue.go |
Global built-in tag parsers |
tag_include.go |
{% include %} parser + IncludeNode; FeatureLayout only |
tag_extends.go |
{% extends %} parser + ExtendsNode; FeatureLayout only; first-tag constraint |
tag_block.go |
{% block %} parser + BlockNode; FeatureLayout only; block.super chain |
safe.go |
SafeString type + safeFilter (FeatureLayout only) |
loader.go |
Loader interface + ValidateName + memoryLoader/dirLoader (os.Root)/fsLoader/chainLoader |
engine.go |
Engine struct + New + Format/Feature + cache + parsing-set + Reset + WithLoader/WithFormat/WithFeatures/WithDefaults/WithFilters/WithTags |
utils.go |
toString, toInteger internal helpers |
Both *TagRegistry and *Registry (filter) carry an optional parent field. When a lookup misses locally, the parent is consulted. Engine constructs its own private registry with parent = defaultXxxRegistry, then registers feature-gated tags / safe filters into the private layer only.
This is the key invariant: optional behavior lives in engine-private layers. Built-in registries provide the base language; features and overrides stay local to an engine instance.
parser.go:parseTag()consultsp.engine.tagsfirst, thendefaultTagRegistrynodes.go:FilterNode.Evaluate()consultsctx.engine.filtersfirst, then globallexer.go'sallowRawis set bycompileForEngineonly whenFeatureLayoutis enabled
| Concern | Behavior |
|---|---|
| Raw lexer | Enabled only when FeatureLayout is on |
| Available tags | Built-ins by default; include/extends/block gated by FeatureLayout |
| Available filters | Built-ins by default; safe gated by FeatureLayout; escape/h overrides gated by FormatHTML |
OutputNode.Execute escape |
FormatHTML: on; FormatText: off |
escape filter return |
string by default; SafeString in FormatHTML engines |
If you add a feature, ask "does this belong in Engine, Format, or Feature?" Avoid creating a second mental model.
Add to the appropriate filter_*.go file, register in its register*Filters() function. FilterFunc signature:
func(value any, args ...any) (any, error)Create tag_<name>.go, add to builtinTags slice in tags.go. For global tags only — layout tags go in layoutTags.
func(doc *Parser, start *Token, arguments *Parser) (Statement, error)Create tag_<name>.go, add to layoutTags slice in tags.go. The parser can access the owning *Engine via args.Engine() to resolve referenced templates at parse time.
Add to errors.go with a doc comment, wrap at use-sites with fmt.Errorf("%w: ...", ErrName, ...).
Same file as source with _test.go suffix, table-driven subtests with t.Parallel(), direct if got != want assertions, errors.Is for error matching.
Prefer assertions around Engine, FormatHTML, FormatText, and FeatureLayout. Test adapters may exist for legacy scenarios, but they are not design targets.
Statement—Execute(ctx *RenderContext, w io.Writer) error+Position()+String()Expression—Evaluate(ctx *RenderContext) (*Value, error)+Position()+String()Loader—Open(name string) (source, resolved string, err error)
All three are intentionally open so external packages can implement custom tags, nodes, or loaders.
Conventional commits: type: description
Types: feat, fix, refactor, style, test, chore, build, docs
Examples from history:
feat: add multi-file template system (Loader, layout tags, autoescape)refactor: unify template execution around Enginetest: add security matrix for path traversal and symlink escape