Integrating tramaj into a host

On Fri, 25 Sep 2026, by @lucasdicioccio, 2784 words, 9 code snippets, 33 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, six independently written ports, and the folds and CLIs layered on top of two of them.

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

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
Godepend on tramaj-go directly, or shell out to its tramaj-go CLIwrite your own — none shipped yet
anything elseshell out to tramaj-cli or tramaj-cli-rs, or reimplement the evaluatorwrite your own, over the JSON

Numbers at the boundary

The language has two number types, an integer and a float, and a JSON number is typed by its text: 3 is an integer, 3.0 and 3e0 are floats (reference, section 3). This matters to a host in three places.

  • Reading the context. The host’s JSON parser must keep the difference between 3 and 3.0, and must not round a large integer. JavaScript’s JSON.parse and Haskell’s aeson Value do neither, which is why tramaj-purs and tramaj-hs each ship a Tramaj.Json module with a reader (jsonParser) and a writer (stringify). tramaj-rs has the same in tramaj_rs::json (parse, stringify). serde_json keeps 3 and 3.0 apart, but rounds an integer beyond 64 bits and reads -0 as a float.
  • Reading the result. The output keeps the type: an integer is written as digits and a float always with a fraction or an exponent (node-json.md, Numbers). A fold that renders a scalar child should write it the same way, as str does: tramaj-halogen renders the float 1.0 as 1.0.
  • Choosing whether templates may compute. The ten arithmetic builtins are an optional profile, chosen by the host for each evaluation and off by default. In symbolic mode with the profile on, a result may hold a term, {"$term": "sum", "arguments": [...]}: arithmetic over a symbol, left for the host to evaluate (v3-symbols.md, section 1.9). A host that has not turned the profile on never receives one.
ImplementationTwo number typesArithmetic profile, with roundsort-by, sort-by-descending, format-numberInteger range
tramaj-purs, tramaj-halogen, tramaj-cli, playgroundyesyesyes-(2^53 - 1) to 2^53 - 1
tramaj-hsyesyesyessigned 64-bit
tramaj-js, tramaj-reactyesyesyes-(2^53 - 1) to 2^53 - 1
tramaj-rs, tramaj-cli-rsyesyesyessigned 64-bit
tramaj-goyesyesyessigned 64-bit
tramaj-pyyesyesyessigned 64-bit

Every row is on the main branch and in no release yet. The packages already published (tramaj-rs and tramaj-cli-rs 0.1.0 on crates.io, tramaj 0.1.0 on PyPI) predate all of it: there every number is a double, str(1.0) is 1, and the arithmetic, sorting and formatting names are unbound. On main the six implementations give the same output for the same template, except where a result depends on the integer range: the corpus cases that do are marked "int53" or "int64" in their "profiles", and an implementation skips the cases of the range it does not have. An existing host moving to the split should read Migrating to integers and floats.

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
runProgramWith :: Options -> LibraryTable -> Json -> Program -> Either EvalError Json

type Options = { mode :: Mode, arithmetic :: Boolean }
defaultOptions :: Options   -- { mode: Concrete, arithmetic: false }

-- Tramaj.Json
jsonParser :: String -> Either String Json
stringify  :: Json -> String

Mode is Concrete or Symbolic (v3-symbols.md); LibraryTable supplies whatever the template’s import(...) calls need. Json is Tramaj.Json.Json, not Argonaut’s: a JSON value whose numbers keep their type. Read the context with jsonParser and write the result with stringify; JSON.parse and JSON.stringify cannot tell 3 from 3.0. runProgram runs without the arithmetic profile; runProgramWith (defaultOptions { arithmetic = true }) turns it on. Tramaj.Analysis.deepArithmeticOps reports which arithmetic names a program and its libraries reference, so a host without the profile can refuse one before running it. 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.Json, 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.

The evaluator takes and returns Tramaj.Json.Json, a JSON value with separate JInt and JFloat constructors, where it used to take Data.Aeson.Value. jsonParser reads JSON text and types each number by its text; stringify writes it back. fromAeson and toAeson bridge to aeson, with a loss: aeson has already forgotten whether a number was written 3 or 3.0, so fromAeson makes every whole number that fits 64 bits an integer. Integers cover the signed 64-bit range here, wider than the PureScript implementation’s. Arithmetic is off in runProgram and on with runProgramWith defaultOptions {optArithmetic = True}.

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/, including v3-symbols.md’s symbols/constraints and v4-types.md’s nominal types, not just the core language, with the two number types and the arithmetic profile, see Numbers at the boundary). It’s a normal Cargo library crate — tramaj_rs::{ast, parser, eval, json, node, analysis, types} — depended on as a path or git dependency for what this section describes (tramaj-rs 0.1.0 is on crates.io, but predates the number split and takes serde_json::Value), 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::json
pub fn parse(src: &str) -> Result<Json, String>;
pub fn stringify(v: &Json) -> String;

// tramaj_rs::eval
pub struct Options { pub mode: Mode, pub arithmetic: bool }

pub fn run_program(
    mode: Mode,
    libs: &LibraryTable,
    ctx: &Json,
    program: &Program,
) -> Result<Json, EvalError>;

pub fn run_program_with(
    options: &Options,
    libs: &LibraryTable,
    ctx: &Json,
    program: &Program,
) -> Result<Json, EvalError>;

tramaj_rs::json::Json is the JSON type of this port: like serde_json::Value, with an integer (Json::Int, an i64) and a float (Json::Float) as two variants. Read the context with json::parse and write the result with json::stringify, so that 3 and 3.0 stay two values both ways. json::from_serde and json::to_serde bridge to a serde_json::Value a host already holds; their documentation says what each direction loses. Integers cover the signed 64-bit range, as in the Haskell implementation: a literal, a context number or a result outside it is refused, never rounded. Arithmetic is off in run_program and on with run_program_with(&Options { mode, arithmetic: true }, ...); analysis::deep_arithmetic_ops lists the arithmetic names a program and its libraries reference, for a host that leaves the profile off.

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|arithmetic|types|card|all> subcommands, the same --lib name=path/--mode concrete|symbolic/--arithmetic 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 an alternative to tramaj-cli with no Node.js runtime required. Its integer range is the signed 64-bit one, where tramaj-cli has the guaranteed 53 bits.

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/, including v3-symbols.md’s symbols/constraints and v4-types.md’s nominal types, not just the core language, and the two number types with the ten arithmetic builtins; see Numbers in tramaj-js below). It’s a normal npm package, npm install @lucasdicioccio/tramaj-js, 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 (npm install @lucasdicioccio/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.

Numbers in tramaj-js. A JavaScript number has no type of its own, so the port classifies it: a number that is a safe integer is an integer, and any other number is a float. The float 3.0 is written new Float(3) (or float(3)), and the result of an evaluation uses a Float for a whole-valued float and a plain number for everything else. The integer range is the guaranteed one, -(2^53 - 1) to 2^53 - 1. JSON.parse and JSON.stringify lose the type, so the package has its own reader and writer, parseJson and stringify. Arithmetic is an option of each evaluation, off by default:

const ctx = parseJson('{"qty": 3, "price": 9.0}');
const out = runProgramWith({ mode: "concrete", arithmetic: true }, libs, ctx, program);
stringify(out); // an integer as digits, a float with a fraction or an exponent

runProgram and evalProgram run without it, where the ten names are unbound, and deepArithmeticOps(libs, program) tells a host beforehand whether a program uses them. tramaj-react’s renderScalar writes a number as str does and a term as the call that built it.

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 implementations, held to the same shared conformance corpus (corpus/cases/, including v3-symbols.md’s symbols/constraints and v4-types.md’s nominal types; the two number types and the ten arithmetic builtins are in this port, on main). 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 for what this section describes (tramaj 0.1.0 is on PyPI, but predates the number split):

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. The two number types are the two Python types: an int is an integer and a float a float, in the context and in the result, so str(1.0) is 1.0 here as in the other five implementations. Integers cover the signed 64-bit range; Python’s own are unbounded, so one outside the range is refused, never rounded. tramaj.parse_json reads JSON text with each number typed by its text, and compact_json and pretty_json write a float with a fraction or an exponent. Arithmetic is off by default and on with run_program(..., arithmetic=True); deep_arithmetic_ops lists the arithmetic names a program and its libraries reference.

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.

Go — import as a module, standard library only

tramaj-go is an independent, hand-written Go port of the same grammar and evaluation semantics as the other implementations, held to the same shared conformance corpus (corpus/cases/, including v3-symbols.md’s symbols/constraints and v4-types.md’s nominal types, the two number types and the arithmetic profile). It needs nothing outside the standard library (Go 1.21+), so it compiles into a service or a static binary with no Node, Python or PureScript toolchain as a runtime dependency. It’s one package, tramaj, in the repository’s tramaj-go/ directory (no tagged module release yet):

import tramaj "github.com/lucasdicioccio/tramaj/tramaj-go"

program, err := tramaj.ParseProgram(`.p($ctx.name)`) // err is a *tramaj.ParseError
ctx, _ := tramaj.ParseJSON([]byte(`{"name": "web"}`))
out, err := tramaj.RunProgram(tramaj.Concrete, nil, ctx, program) // err is a *tramaj.EvalError
fmt.Println(tramaj.PrettyJSON(out))

RunProgram gives you the evaluated result as a tramaj.JSON value (nil, bool, int64, float64, string, []tramaj.JSON or *tramaj.Object, an object that keeps its keys in order), 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 tramaj.Libraries, a map from import name to parsed program; the mode is tramaj.Concrete or tramaj.Symbolic. EvalProgram is the lower-level entry point if you want the *tramaj.Node itself rather than its JSON encoding. A number is an int64 when it is an integer and a float64 when it is a float, and tramaj.ParseJSON types each by its text (3 and 3.0 are two values); the integer range is the signed 64-bit one. The arithmetic profile is off unless the host turns it on: tramaj.RunProgramWith(tramaj.Options{Mode: tramaj.Concrete, Arithmetic: true}, ...), and tramaj.DeepArithmeticOps lists the arithmetic names a program references. On the main branch, in no release yet.

As with Haskell, Rust and Python, no fold is shipped: a Go host writes its own following the same shape (switch on the node’s Type, recurse into Children, decide what an action attribute means in your host). Go also has its own tramaj-cli equivalent, tramaj-go (go build ./cmd/tramaj-go), mirroring tramaj-cli-rs’s evaluate/analyze subcommands, flags and JSON output shapes.

Any other language

For a host in a language with no PureScript, Haskell, Rust, JavaScript, Python or Go interop story — Java, C#, 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 six 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
tramaj-cli --arithmetic template.tramaj context.json   # with the arithmetic builtins

which prints the evaluated Node as JSON on stdout, exactly per node-json.md. In that JSON 3 is an integer and 3.0 a float; a host that needs the difference reads it with a parser that keeps it. 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/arithmetic/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, Python and Go each have one now (tramaj-rs, tramaj-js, tramaj-py and tramaj-go, above).