Integrating tramaj into a host
On Fri, 25 Sep 2026, by @lucasdicioccio, 1592 words, 7 code snippets, 25 links, 1images.
Integrating tramaj into a host
Every integration has the same two moving parts, regardless of language:
- Evaluate — parse a template and run it against a JSON context to get
a
Node: the normative, host-agnostic document tree (node-json.md). - Fold — walk that
Nodeand turn it into whatever your host actually renders: real DOM, a UI framework’s virtual tree, another markup language, a config file.
Nothing about Node or the evaluator knows about HTML, a browser, or any
particular UI framework (see Properties) — the fold is
always something you write for your host, even where this repository
already ships one for you. Which of the six paths below applies depends
only on what language your host is written in.
The implementations and how they relate: one specification and corpus, five independently written ports, and the folds and CLIs layered on top of two of them.

| Host language | Get the Node | Write the fold |
|---|---|---|
| PureScript | depend on tramaj-purs directly | reuse tramaj-halogen, or write your own |
| Haskell | depend on tramaj-hs directly | write your own — none shipped yet |
| Rust | depend on tramaj-rs directly, or shell out to tramaj-cli-rs | write your own — none shipped yet |
| JavaScript / TypeScript | depend on tramaj-js directly, or shell out to tramaj-cli | reuse tramaj-react, or write your own |
| Python | depend on tramaj-py directly, or shell out to python -m tramaj | write your own — none shipped yet |
| anything else | shell out to tramaj-cli or tramaj-cli-rs, or reimplement the evaluator | write your own, over the JSON |
PureScript — import as a library, fold with tramaj-halogen
tramaj-purs, tramaj-halogen, tramaj-cli, and playground are members of one
Spago workspace rooted at this
repository. A new PureScript package placed as a subdirectory of the
workspace, with tramaj-purs (and tramaj-halogen if you’re rendering through
Halogen) listed in its spago.yaml dependencies, is picked up by Spago’s
workspace discovery with no registry publishing step needed.
The core API:
-- Tramaj.Parser
parseProgram :: String -> Either ParseError Program
-- Tramaj.Eval
runProgram :: Mode -> LibraryTable -> Json -> Program -> Either EvalError Json
Mode is Concrete or Symbolic (v3-symbols.md);
LibraryTable supplies whatever the template’s import(...) calls need.
runProgram gives you the evaluated document as JSON directly, already
encoded per node-json.md. If you need the
Node value itself rather than its JSON encoding — to fold it without a
round-trip through JSON — evalProgramWithEmissions is the lower-level
entry point that stops one step earlier.
If your host renders through Halogen,
don’t write a fold at all — tramaj-halogen already has one:
-- Tramaj.Halogen
foldToHalogen
:: forall action slots m
. (String -> String -> Json -> Maybe action)
-> Node
-> Array (ComponentHTML action slots m)
The first argument is a dispatch function: event type, action key, and
JSON payload in, an optional host action out. A read-only host supplies
\_ _ _ -> Nothing. The result is an Array, not a single element, because
a fragment contributes several siblings with no wrapper element. Call
validateAttrNames on the Node first — attribute names aren’t validated
by the fold itself.
playground/src/Playground/Main.purs
is the canonical worked example of the whole pipeline —
Tramaj.Parser → Tramaj.Eval → Tramaj.Halogen.foldToHalogen — including
dispatchAction, which turns action(...) clicks into real Halogen actions
for an interactive host (a read-only host just passes const Nothing
instead).
Haskell — the library exists, the fold doesn’t yet
tramaj-hs is an independent Haskell port, built on megaparsec and aeson,
that targets the same grammar as the PureScript tramaj-purs and shares its
conformance corpus. It’s a normal Cabal library exposing Tramaj.Ast,
Tramaj.Parser, Tramaj.Eval, Tramaj.Node, Tramaj.Analysis, and
Tramaj.Types — so a Haskell host can depend on it directly (as a
source-repository-package or path dependency; it isn’t published to
Hackage), the same way a PureScript host depends on tramaj-purs.
What it does not have yet is a tramaj-halogen equivalent — no fold to
Lucid, Blaze, or any other Haskell HTML/UI library ships in this
repository. A Haskell host gets the parser, the evaluator, and the Node
JSON encoding (kept in lockstep with the PureScript Tramaj.Node); writing
the fold to your actual rendering library is on you. The shape to follow is
tramaj-halogen’s: case on Text/Element/Fragment, recurse into
children, and decide what an action(...) payload means in your host.
Rust — the library exists, plus its own CLI
tramaj-rs is an independent, hand-written Rust port of the same grammar
and evaluation semantics as the PureScript tramaj-purs and Haskell tramaj-hs,
held to the same shared conformance corpus (corpus/cases/ — full parity,
including v3-symbols.md’s symbols/constraints
and v4-types.md’s nominal types, not just the
core language). It’s a normal Cargo library crate — tramaj_rs::{ast, parser, eval, node, analysis, types} — depended on as a path or git dependency (not
published to crates.io yet), the same way a PureScript host depends on
tramaj-purs or a Haskell host depends on tramaj-hs:
// tramaj_rs::parser
pub fn parse_program(src: &str) -> Result<Program, ParseError>;
// tramaj_rs::eval
pub fn run_program(
mode: Mode,
libs: &LibraryTable,
ctx: &serde_json::Value,
program: &Program,
) -> Result<serde_json::Value, EvalError>;run_program gives you the evaluated result as JSON directly, already
encoded per node-json.md when the program
produces a document, or the concrete/symbolic envelope from
v3-symbols.md §5 otherwise — the same shape
runProgram returns on the PureScript and Haskell sides. As with Haskell,
there’s no tramaj-halogen equivalent shipped yet: a Rust host gets the
parser, evaluator, Node JSON codec, and static analysis, and writes its
own fold following the same shape (case on Text/Element/Fragment,
recurse into children, decide what an action(...) payload means in your
host).
Unlike the Haskell port, Rust also has a tramaj-cli equivalent —
tramaj-cli-rs — mirroring tramaj-cli’s command surface field-for-field:
the same evaluate/analyze <imports|actions|holes|unsupplied|constraints|symbols|types|card|all>
subcommands, the same --lib name=path/--mode concrete|symbolic flags,
and the same JSON output shapes. If you’d rather shell out than link a Rust
library into your host — including from a non-Rust host — tramaj-cli-rs is
a drop-in alternative to tramaj-cli with no Node.js runtime required.
JavaScript / TypeScript — import as a library, fold with tramaj-react
tramaj-js is an independent, hand-written TypeScript port of the same
grammar and evaluation semantics as the PureScript tramaj-purs, Haskell
tramaj-hs, and Rust tramaj-rs, held to the same shared conformance
corpus (corpus/cases/ — full parity, including
v3-symbols.md’s symbols/constraints and
v4-types.md’s nominal types, not just the
core language). It’s a normal npm package with no React or DOM dependency
— a plain Node or browser host can depend on it the same way a PureScript
host depends on tramaj-purs:
// parseProgram, tryParseProgram
function parseProgram(src: string): Program; // throws ParseError
// runProgram
function runProgram(
mode: Mode,
libs: LibraryTable,
ctx: Json,
program: Program,
): Json; // throws EvalErrorMode is "concrete" or "symbolic" (v3-symbols.md);
runProgram gives you the evaluated result as JSON directly, already
encoded per node-json.md when the program
produces a document, or the concrete/symbolic envelope from
v3-symbols.md §5 otherwise — the same shape
runProgram returns on the PureScript, Haskell, and Rust sides. evalProgram
is the lower-level entry point if you want the Node value itself rather
than its JSON encoding, to fold it without a round-trip through JSON.
If your host renders through React, don’t write a fold at all —
tramaj-react already has one, the same shape as tramaj-halogen’s:
type Dispatch = (
event: string,
key: string,
payload: Json,
) => MouseEventHandler<Element> | undefined | null;
function foldToReact(dispatch: Dispatch, node: Node): ReactNode[];dispatch decides which action(...) entries become a real event handler
— it inspects the event type itself (foldToReact doesn’t), and returns
undefined/null for ones it wants to ignore. Every action a dispatch
call accepts is wired to onClick. The result is an array, not a single
element, because a fragment contributes several siblings with no wrapper —
splice it into your own container, <>{foldToReact(dispatch, node)}</>.
Call validateAttrNames on the Node first, same caveat as
tramaj-halogen: attribute names aren’t validated by the fold itself.
If you’d rather not add a JS dependency at all — or your host isn’t Node —
tramaj-cli (or tramaj-cli-rs) as a subprocess is still the same option
open to any other language; see Getting started for
the exact CLI invocations, and Any other language below for the general
shape of folding its JSON output by hand.
Python — import as a library, standard library only
tramaj-py is an independent, hand-written Python port of the same grammar
and evaluation semantics as the other four implementations, held to the
same shared conformance corpus (corpus/cases/ — full parity, including
v3-symbols.md’s symbols/constraints and
v4-types.md’s nominal types). It needs
nothing outside the standard library (Python 3.9+), so it drops into a data
pipeline, a notebook or a backend service without a Node or PureScript
toolchain as a runtime dependency. It’s a normal package — tramaj with
ast, parser, evaluator, node, analysis, typesys and jsonval
modules, laid out one-to-one like tramaj-js — installed from the
repository’s tramaj-py/ directory (not published to PyPI yet):
from tramaj import parse_program, run_program
program = parse_program('.p($ctx.name)') # raises ParseError
run_program("concrete", {}, {"name": "web"}, program) # raises EvalErrorrun_program gives you the evaluated result as plain Python JSON values
(dict/list/str/numbers/bool/None), already encoded per
node-json.md when the program produces a
document, or the concrete/symbolic envelope from
v3-symbols.md §5 otherwise — the same shape
runProgram returns on every other side. A library table is a dict from
import name to parsed program; the mode is "concrete" or "symbolic".
eval_program is the lower-level entry point if you want the Node value
itself rather than its JSON encoding. Numbers keep the language’s double
semantics: an exact integer comes back as an int, and str renders as
ECMAScript’s Number::toString does, so interpolated text matches the
other implementations byte-for-byte.
As with Haskell and Rust, no fold is shipped: a Python host writes its own
following the same shape (case on text/element/fragment, recurse into
children, decide what an action(...) payload means in your host). Python
also has its own tramaj-cli equivalent, python -m tramaj, mirroring
tramaj-cli-rs’s evaluate/analyze subcommands, flags and JSON output
shapes, so a non-Python host with a Python interpreter around can shell out
to it instead of to Node.
Any other language
For a host in a language with no PureScript, Haskell, Rust, JavaScript, or Python interop story — Go, Java, whatever — there are two options:
(a) Full reimplementation. Port the parser and evaluator to your
language, the way tramaj-hs ports the PureScript original. Worth it only
if you need to evaluate templates natively (e.g. embedding evaluation in
a process that can’t shell out, or needs it to run in-process for
performance). reference.md is the full
grammar and evaluation semantics to implement against, and the conformance
corpus (corpus/cases/ in the repository — template + context + expected
output triples) is what the five existing implementations are held to; a
new implementation should be checked against the same corpus.
(b) Partial implementation, starting from tramaj-cli’s output. For
most hosts this is the pragmatic choice: don’t reimplement the parser or
evaluator at all. Shell out to tramaj-cli:
tramaj-cli template.tramaj context.jsonwhich prints the evaluated Node as JSON on stdout, exactly per
node-json.md. Your host only needs to write
the fold — parse that JSON, case on whether each node is text, an
element, or a fragment, recurse into children, and decide what each
element’s action(...) entries mean in your host — the same shape as
tramaj-halogen’s fold, just written against decoded JSON instead of a
native Node value. tramaj-cli’s analyze subcommands
(imports/actions/holes/unsupplied/constraints/symbols/types)
are available the same way, without a context file, if your host wants the
static-analysis facts from Properties rather than a
rendered document.
This is also the fastest way to prototype an integration in any language before deciding whether a full reimplementation is ever worth it.
See the Roadmap for what a new implementation needs to
cover and where to start if you want to build one; Rust, JavaScript and
Python each have one now (tramaj-rs, tramaj-js and tramaj-py, above).