NAME
figh β figh interpolates guarded HTML
The name is a recursive acronym. It is the only recursion in the language.
SYNOPSIS
<h1>{{ event.name }}</h1>
<figh-if test="tickets">
<ul>
<figh-for each="t in tickets">
<li>{{ t.holder | default: 'unclaimed' }} β {{ t.price | money: 'eur' }}</li>
</figh-for>
</ul>
<figh-else/>
<p>{{ 'public.event.sold_out' | t }}</p>
</figh-if>
<figh-include src="partials/footer.figh"/>
Rendering, from Go and from the browser, respectively:
out, err := figh.RenderGuarded("event.figh", loader, env, figh.Limits{})
const res = figh.renderToResult("event.figh", loader, env)
In every example on this page, figh's own surface is lit and
the host HTML is dim. That is the whole parser story: figh scans for
{{ }} and <figh-β¦> and copies everything else
through untouched. It never parses your markup.
DESCRIPTION
figh is a template language for templates you didn't write: a customer uploads a theme, and it renders on your infrastructure, next to everybody else's data. Every design decision follows from that one assumption.
- It escapes by default.
{{ }}always HTML-escapes, and there is no configuration that turns that off. The only unescaped output is the<figh-raw>element: greppable, and impossible to hide inside an attribute value. - It always halts. There is no
while, no user-defined functions and therefore no recursion, and the include graph is static: cycles are a compile error. Termination is a property of the grammar, not a timeout. - It sees nothing. No filesystem, no network, no clock, no randomness, no host objects. A template is a pure function of the data you bind for it.
- It cannot disagree with itself. figh is implemented twice, in Go and in JavaScript, and a golden corpus holds both to byte-identical output, including error text and column numbers. An in-browser preview cannot drift from production.
A template file uses the extension .figh and contains two
kinds of figh construct and no more: {{ expression }} puts a
value in (INTERPOLATION), and
<figh-β¦> elements direct control flow
(DIRECTIVES).
CONTENTS
INTERPOLATION
{{ expression }} evaluates the expression and writes the
result, HTML-escaped, into the output. Always escaped: there is no raw
variant of the braces, no {{{ }}}, and no | raw
filter to bury in a chain.
<p>Welcome back, {{ user.first_name }}.</p>
Three kinds of value are printable: text, numbers, and
true/false. Printing a list or a group of fields
is an error rather than a silent concatenation; it is almost always a
mistake, and figh's position on mistakes is a line and a column.
A missing field evaluates to nothing, and printing nothing is also an error. That pairing is deliberate: reading an optional field is fine, forgetting that it is optional is not. Declare the fallback where the value is used:
{{ ticket.note | default: 'β' }}
To emit a literal {{ or a literal <figh-,
wrap the text in <figh-verbatim>.
To emit markup unescaped, you want
<figh-raw>, and you want to think
about it first.
DIRECTIVES
Eight directives; the list is meant to stay this short. Directives live
in the figh- namespace so they can never collide with an element
you meant to write. <figh-else-if/>,
<figh-else/> and <figh-empty/> are
self-closing separators inside their parent, not containers, so every
construct has exactly one closing tag, which is what keeps "unclosed" and
"unexpected close" errors pointing at a real line.
<figh-if test="expr">-
Conditional. Branches with
<figh-else-if test="expr"/>and<figh-else/>; at most one else, and it comes last. The test uses figh's one truthiness rule.<figh-if test="order.paid"> <p>Paid.</p> <figh-else-if test="order.total == 0"/> <p>Free.</p> <figh-else/> <p>Awaiting payment.</p> </figh-if> <figh-for each="item in list">-
The only iteration in the language: over a list that already exists in bound data. An optional
index="i"names the zero-based position. The body after<figh-empty/>renders when the list is empty, including when the field is absent altogether.<figh-for each="t in tickets" index="i"> <li>{{ i }}: {{ t.name }}</li> <figh-empty/> <li>No tickets yet.</li> </figh-for>Looping over a group of fields yields
{key, value}pairs in sorted key order. Sorted, because Go randomizes map order and JavaScript uses insertion order, and neither survives a JSON round-trip:<figh-for each="field in order.metadata"> <dt>{{ field.key }}</dt><dd>{{ field.value }}</dd> </figh-for> <figh-include src="path" β¦/>-
Renders a partial.
srcmust be a string literal (not an expression), which makes the include graph fully static: cycles are a compile error, not a runtime depth counter. Paths are plain relative paths (no leading/, no.or..segments, printable ASCII) and resolve only through the Loader the host supplies.<figh-include src="partials/nav.figh" active="'tickets'"/>Every other attribute is an expression, evaluated in the caller's scope and passed as a parameter. The partial sees the caller's scope plus its parameters; the parameters vanish when it returns.
<figh-raw value="expr"/>-
The only unescaped author-controlled output in the language. It is an element rather than a filter for a reason: an element cannot appear inside an HTML attribute value, so the worst injection site β raw text spliced into
href="β¦"β is unreachable by construction. Andgrep -r '<figh-raw'finds every unescaped output in a codebase.<figh-raw value="page.body_html"/> <figh-block name="β¦"/>-
Emits a host-provided capability fragment β server-rendered markup like a payment form or an account panel, which the template wraps rather than writes. Emitted raw, because it is the host's own markup, never author input. A name the host didn't put in
Env.Blocksis a compile error, so a template cannot invent a capability β and a page cannot silently ship without its pay button.<figh-block name="order.actions"/> <figh-content/>-
The layout slot: emits the already-rendered view the layout wraps, raw (it was escaped wherever escaping applied when it rendered). Permitted only when the host marks the compile as a layout by setting
Env.Content, and then required exactly once β zero swallows the page, two has no sensible reading, and both are compile errors. <figh-copy key="β¦" β¦/>-
Emits a string from the host's wording layer: copy the host's own users can edit, resolved by the host through its own override chain and handed to figh as a flat map. figh does the
{variable}substitution only; remaining attributes supply the variables.<figh-copy key="public.order.youre_in" name="user.first_name"/> <figh-verbatim>-
Copies its body through with no scanning at all: the escape hatch for documentation pages that need to show a literal
{{or a literal<figh-. (This page would need it.)<figh-verbatim>{{ this is not figh }}</figh-verbatim>
EXPRESSIONS
The expression language, complete:
expr := or
or := and ( "or" and )*
and := cmp ( "and" cmp )*
cmp := unary ( ("==" | "!=" | "<" | "<=" | ">" | ">=" | "in") unary )?
unary := "not" unary | postfix
postfix := primary ( "." ident | "[" expr "]" )* ( "|" filter )*
filter := ident ( ":" expr ("," expr)* )?
primary := string | int | "true" | "false" | "nil" | ident | "(" expr ")"
- Strings are single-quoted, with
\'and\\as the only escapes. Directive attributes are delimited by double quotes, so a string literal never collides with its container. - Numbers are integers.
3.14is a parse error, not a float β see VALUES. ==and!=compare same-type values only; comparing a number to text is an error, never a coercion.nilcompares unequal to everything exceptnil.< <= > >=are defined for numbers and for text. Text compares by Unicode code point, pinned in both runtimes and corpus-tested with astral characters, because JavaScript's own<would disagree.inis membership: an element of a list, a substring of text, or a key of a group of fields..fieldand[expr]read into groups and lists. A missing field is nothing rather than an error; what you may not do is print nothing.| filterapplies a filter; arguments follow a colon, separated by commas, and parse at full expression level, so'x' | if: a and breads the argument as(a and b). Filters bind tighter than comparison:tickets | size > 3is(tickets | size) > 3. A second pipe after an argument binds into the argument, not the result β parenthesize for the other reading.
Absent on purpose, each for a reason worth stating:
- No function calls. A call syntax is a request for a standard library, and a standard library is a supply of capabilities.
- No arithmetic.
+ - * /would force figh to hold Go'sint64and JavaScript'sNumberto one overflow story forever. Money is integer cents formatted by a filter; nothing a template does needs to compute. - No assignment. State inside a template is how a template language grows into a programming language.
- No chained comparison.
a < b < cis an error, not a surprise.
FILTERS
Fifteen filters: a fixed allowlist of pure formatting functions.
There is no way for a host to register one and no way for a template to
define one. Everything locale-aware is formatted from shipped data tables
by integer arithmetic: neither runtime may consult its
environment. No Intl, no toLocaleString, no
time.Local, no tzdata β any of those would make a preview
depend on the author's laptop.
cents | money: 'eur'Formats a whole number of cents as an amount in the given currency, localized:
{{ 24900 | money: 'eur' }}ββ¬249.00. There is no float money anywhere in figh.n | intFormats an integer with the locale's digit grouping:
12,345inen,12.345inde.ts | datets | date_shortts | datetimets | day_monthts | weekday_date-
The date family. Input is a unix timestamp in seconds; the optional argument is a time-zone offset in whole minutes (β1080 to 1080), because figh has no tzdata and refuses to guess:
{{ event.starts | date }} 2 January 2027 {{ event.starts | date_short }} 2 Jan 2027 {{ event.starts | datetime: 60 }} 2 Jan 2027, 19:30 (UTC+1) {{ event.starts | day_month }} 2 January {{ event.starts | weekday_date }} Sat 2 Jan 2027 v | default: fallbackThe fallback when a value is missing or falsy, and the only filter that accepts nothing, by definition; it exists to turn an absent field into something printable.
'text' | if: condition-
Emits the text when the condition is truthy β by figh's own truthiness table, so
0,'', an empty list and a missing field are all falsy β and nothing otherwise, through normal escaping. Built for conditional attributes inside a tag; a false condition leaves a harmless extra space.<x-row {{ 'sold-out' | if: t.sold_out }}> true β <x-row sold-out> false β <x-row >The value before
| if:must be a string literal, checked when the template compiles (error codeif-needs-literal). Same mechanism and reason as<figh-include>'s literalsrc: what can sit in attribute position is the author's choice, fixed at compile β never data's. v | sizeItems in a list, characters in text (counted in runes, the only count both runtimes agree on and an author would predict), fields in a group. Nothing has size 0.
list | join: ', 'Joins a list of text and numbers with a separator.
s | urlPercent-encodes text for use in a URL component. figh defines its own encoding table (RFC 3986 unreserved set, uppercase hex) because Go's
QueryEscapeand JavaScript'sencodeURIComponentdisagree with each other.s | escapeHTML-escapes explicitly β on top of the automatic escaping, so the entities themselves survive to the page. For showing markup as text.
'key' | t: valueβ¦Looks a message up in the platform catalogue the host supplies and substitutes up to nine values into its
%s/%dplaceholders. The key must be a string literal, and it is checked when the template compiles, against the catalogue and against the host's rule for which keys a template may name at all. A bad key is a compile error with a column, never a blank in front of a visitor.{{ 'public.pay.waiting' | t: user.name, order.ref }}'key' | tn: count, valueβ¦t's plural-aware sibling: the first argument is a count that selects the message's plural form by the locale's CLDR rules, then substitutes liket.{{ 'public.event.left' | tn: type.left }}
VALUES
The value model is six types and nothing else:
nil bool string int64 list map
Data enters through Bind, which walks the host's tree and
rejects anything outside the model: a struct, pointer, function or
channel fails at bind time, loudly, naming the path. So no reflection path
into host values exists for the evaluator to walk. This is the boundary
that makes an engine safe to point at somebody else's template: a drop map
that accidentally carried a *sql.DB is a bind error, not a
capability.
There are no floats. Go and JavaScript print floating
point by separately-specified algorithms; rather than test that they agree
forever, figh doesn't have them. Money is integer cents, percentages are
basis points, anything genuinely fractional arrives pre-formatted as a
string. One concession to how data actually travels: JSON delivers every
number as a float, so a float that is exactly a whole number is
admitted and converted. 2500 binds; 25.5 is an
error naming the field.
Integers are bounded to Β±2β΅Β³β1, the range a JavaScript
Number represents exactly. A bigger value is a bind error on
the Go side rather than a preview that silently disagrees with
production.
Truthiness is one table, written out rather than inherited from either
host language β JavaScript would call [] truthy and Go has no
opinion at all:
| value | truthy |
|---|---|
nil, false, '', 0, empty list, empty group | no |
| everything else | yes |
EMBEDDING: GO
The host supplies three things: templates through a
Loader, data through Bind, and an
Env that is everything the render can see. This is the API as
it runs in production today; see
AVAILABILITY for where the module is.
// Templates arrive through a Loader β figh never reads the filesystem.
// MapLoader is the built-in in-memory one; any Load(path) will do.
loader := figh.MapLoader{
"event.figh": `<h1>{{ event.name }} β {{ event.starts | date }}</h1>
<figh-for each="t in tickets">
<li>{{ t.name }} β {{ t.price | money: 'eur' }}</li>
<figh-empty/>
<li>{{ 'public.event.sold_out' | t }}</li>
</figh-for>`,
}
// Data crosses through Bind: nil, bool, string, int64, lists and maps.
// Anything else β a struct, a *sql.DB, a float β is rejected here.
data, err := figh.BindMap(map[string]any{
"event": map[string]any{
"name": "Vector Fest",
"starts": int64(1798918200), // unix seconds; a filter formats it
},
"tickets": []map[string]any{
{"name": "Early bird", "price": int64(24900)},
{"name": "Regular", "price": int64(34900)},
},
})
if err != nil {
// a host bug caught in tests β never something a template triggers
}
// Env is everything the template can see. There is nothing else: no
// request, no database handle, no server.
env := &figh.Env{
Data: data,
Locale: "en",
}
// Compile + render inside the rails: timeout, output cap, panic
// recovery. The zero Limits value means the defaults (see LIMITS).
out, err := figh.RenderGuarded("event.figh", loader, env, figh.Limits{})
The full set of things an Env can carry:
| field | meaning |
|---|---|
Data | the bound value tree β use Bind/BindMap to build it; render re-checks |
Locale | locale code driving the money/int/date filters |
Messages | the platform catalogue behind t/tn, one locale, already resolved by the host; nil means any t fails loudly rather than rendering blanks |
Copy | the host's wording layer behind <figh-copy>, flattened to key β string |
Content | the rendered view a layout wraps; non-nil marks the compile as a layout and permits <figh-content/> |
Blocks | named capability fragments behind <figh-block>; unknown names are compile errors |
Text | plain-text mode: output is a data field, not a page, so interpolation stops HTML-escaping |
Validating without rendering. To check account-authored source before storing it (a push, an import, a dry run), compile under the same rails and throw the program away:
prog, err := figh.CompileGuarded("theme/layout.figh", loader, env, figh.Limits{})
// err carries path, line and column; prog answers questions like
// "does this template use that block?" before you decide to render it.
Supplying your own Loader. A loader resolves a
partial's path to its source. Missing is (nil, false, nil),
not an error, so the engine can say "there is no template at β¦" with a
position:
type Loader interface {
Load(path string) ([]byte, bool, error)
}
In production this is the account's own theme store; in a CLI it is a containment-checked directory; in the corpus it is a map. It is never a bare filesystem call, because figh templates should not know what a filesystem is.
EMBEDDING: JAVASCRIPT
The browser runtime is one ES module plus one generated locale table: no bundler, no dependencies. It exists so an editor can re-render a themed page as the author types, with no server round-trip, and the corpus is what makes that preview trustworthy.
import * as figh from "./figh.js";
// A loader is anything with load(path) β source, or null for missing.
const files = {
"event.figh": editor.value,
"partials/nav.figh": "β¦",
};
const loader = { load: (path) => files[path] ?? null };
// Data is plain JSON values. bind() applies the same six-type check as
// Go β whole numbers only, within Β±2^53β1 β so a host bug fails here
// exactly as it fails in a Go test.
const env = {
data: {
event: { name: "Vector Fest", starts: 1798918200 },
tickets: [{ name: "Early bird", price: 24900 }],
},
locale: "de",
};
// render() throws FighError; renderToResult() never throws.
const res = figh.renderToResult("event.figh", loader, env);
if (res.ok) {
preview.srcdoc = res.output;
} else {
status.textContent = res.error; // "event.figh:3:12: β¦" β the same
// bytes the Go side would produce
}
Because every figh program halts and the output cap still applies,
re-rendering on every keystroke is just an input listener
around the call above. The env object takes the same fields
as Go's, lower-cased: data, locale,
messages, copy, content,
blocks, text.
Also exported: compile(root, loader, env) for
validate-only paths, bind(value) to check data by itself, and
DEFAULT_LIMITS. There is no timeout in the browser limits β
termination is the grammar's job, and the cap catches the merely
enormous.
ERRORS
Every failure β compile and render alike β carries a template path, a 1-based line, a 1-based column, a stable machine code, and a sentence written for the person editing the template:
event.figh:4:18: there is no "titlecase" filter in figh
views/order.figh:12:9: this <figh-if> is never closed β add </figh-if>
layout.figh:1:1: a layout must emit the page it wraps β add <figh-content/>
Columns are counted in Unicode code points (not bytes, not UTF-16 units) because that is the one definition Go and JavaScript can both compute without disagreeing above U+FFFF. Both runtimes produce these strings byte-for-byte identically; the corpus asserts it, message text, columns and all.
The rule behind the wording: a template author should learn about a mistake from a line and a column, not from a visitor.
LIMITS
Rails, not the safety story: a terminating program can still be a slow one. Zero values mean the defaults:
| limit | default | bounds |
|---|---|---|
Timeout | 2s | compile + render wall clock, in the Go guarded entry points |
OutputCap | 1 MiB | rendered output, in UTF-8 bytes β the one measure both runtimes agree on exactly |
MaxIncludeDepth | 16 | include nesting; cycles are already a compile error, this bounds diamond-shaped blow-up |
Output is otherwise bounded by construction: template size times the product of the bound lists' lengths.
CONFORMANCE
A golden corpus of 190 cases runs every template through both runtimes and asserts byte-identical output, including error messages and their column numbers, and money and date formatting in all 22 supported locales. Every case round-trips its data through JSON, so the path data actually travels is the path under test.
Two independent implementations of an underspecified language drift. The corpus is what makes "identical" a property rather than an intention.
AVAILABILITY
figh runs in production at Tito, where it renders account-authored themes and custom pages. It currently lives inside that codebase; extraction into a standalone module is in progress. Until that lands, treat fighdev/figh as the place the work is happening, not yet as a dependency you can add. This manual describes the language as it runs today.
SEE ALSO
figh(7) β the overview.
fighdev/figh β source and
extraction notes.
fighdev/website β this
site. Built locally with Eleventy; what ships is static files with no
JavaScript.