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:

  1. Evaluate — parse a template and run it against a JSON context to get a Node: the normative, host-agnostic document tree (node-json.md).
  2. Fold — walk that Node and 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.

The specification and corpus feed five ports: tramaj (PureScript) with tramaj-halogen and tramaj-cli, tramaj-hs, tramaj-rs with tramaj-cli-rs, tramaj-js with tramaj-react, and tramaj-py

Host languageGet the NodeWrite the fold
PureScriptdepend on tramaj-purs directlyreuse tramaj-halogen, or write your own
Haskelldepend on tramaj-hs directlywrite your own — none shipped yet
Rustdepend on tramaj-rs directly, or shell out to tramaj-cli-rswrite your own — none shipped yet
JavaScript / TypeScriptdepend on tramaj-js directly, or shell out to tramaj-clireuse tramaj-react, or write your own
Pythondepend on tramaj-py directly, or shell out to python -m tramajwrite your own — none shipped yet
anything elseshell out to tramaj-cli or tramaj-cli-rs, or reimplement the evaluatorwrite 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 EvalError

Mode 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 EvalError

run_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.json

which 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).