πŸ€– FIGH(5) File Formats Manual FIGH(5)

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. src must 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. And grep -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.Blocks is 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.14 is 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. nil compares unequal to everything except nil.
  • < <= > >= 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.
  • in is membership: an element of a list, a substring of text, or a key of a group of fields.
  • .field and [expr] read into groups and lists. A missing field is nothing rather than an error; what you may not do is print nothing.
  • | filter applies a filter; arguments follow a colon, separated by commas, and parse at full expression level, so 'x' | if: a and b reads the argument as (a and b). Filters bind tighter than comparison: tickets | size > 3 is (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's int64 and JavaScript's Number to 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 < c is 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 | int

Formats an integer with the locale's digit grouping: 12,345 in en, 12.345 in de.

ts | date ts | date_short ts | datetime ts | day_month ts | 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: fallback

The 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 code if-needs-literal). Same mechanism and reason as <figh-include>'s literal src: what can sit in attribute position is the author's choice, fixed at compile β€” never data's.

v | size

Items 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 | url

Percent-encodes text for use in a URL component. figh defines its own encoding table (RFC 3986 unreserved set, uppercase hex) because Go's QueryEscape and JavaScript's encodeURIComponent disagree with each other.

s | escape

HTML-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/%d placeholders. 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 like t.

{{ '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:

valuetruthy
nil, false, '', 0, empty list, empty groupno
everything elseyes

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:

fieldmeaning
Datathe bound value tree β€” use Bind/BindMap to build it; render re-checks
Localelocale code driving the money/int/date filters
Messagesthe platform catalogue behind t/tn, one locale, already resolved by the host; nil means any t fails loudly rather than rendering blanks
Copythe host's wording layer behind <figh-copy>, flattened to key β†’ string
Contentthe rendered view a layout wraps; non-nil marks the compile as a layout and permits <figh-content/>
Blocksnamed capability fragments behind <figh-block>; unknown names are compile errors
Textplain-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:

limitdefaultbounds
Timeout2scompile + render wall clock, in the Go guarded entry points
OutputCap1 MiBrendered output, in UTF-8 bytes β€” the one measure both runtimes agree on exactly
MaxIncludeDepth16include 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.

COLOPHON

πŸ€– Written by an LLM (Claude), on the ideas, instruction, and editing of humans. AI-written text here is always marked and always disclosed; see factor X.

figh is built by Tito, where it renders the themes our customers write for their own event pages.