The IR
The core resolves structural markup into an intermediate representation (IR) first, and a host acts as a thin emitter that prints that IR into its target framework’s own public input language.
“Prints into its target framework’s own public input language” is the important constraint. A host does not emit compiled or optimized output — it emits ordinary JSX source text, or an ordinary string-concatenation function body, exactly the shape a human author would have written by hand. The host’s normal toolchain (Babel, a bundler, the target framework’s own compiler) then runs on that text unmodified, the same way it runs on anyone else’s code. This is what keeps a host from becoming a second compiler for its target framework: it is a printer, not a compiler.
A host’s job is to: resolve structure once in the core, then write an emitter for one IR to one target syntax.
The node kinds
Every kind carries a source position, which is what lets a diagnostic and a source map point back at the original MX file.
| Kind | What it holds |
|---|---|
Text |
Literal text, already whitespace-normalized by Marko’s own rules |
Interpolation |
An expression, plus whether it is escaped (${}) or raw ($!{}) |
Element |
A native HTML/SVG/MathML element: attributes, children |
Component |
A component call: attributes, children, attribute tags, tag params |
IfChain |
The whole <if> / <else-if> / <else> chain as a list of branches |
For |
Any <for> form — its source is of, in, or a range (with an optional step) — plus the resolved key |
Define |
A <define>, with its name and params |
Const |
A <const> binding: name and initializer |
DelegatedTag |
A tag this host claimed, with whatever resolveDelegatedTag recorded in its data slot |
DocumentType |
<!doctype html> |
Comment |
A comment, and whether it was an HTML comment or a // line comment |
Four more kinds are module-level and never reach the emitter’s walk. resolve() lifts them out of the body into Ir’s own fields, so a host places them from there rather than filtering the tree: Import, Static, Export, and InputInterface. Each carries an end position beside its start, because they are mapped whole-block rather than per expression.
The emitter side
A host implements Emitter<Out> — one method per kind, plus done() returning the finished output — and the core’s drive() owns the walk:
interface Emitter<Out> {
text(node): void;
interpolation(node): void;
element(node): void;
component(node): void;
ifChain(node): void;
forLoop(node): void;
define(node): void;
constant(node): void;
hoisted(node): void;
delegatedTag(node): void;
documentType(node): void;
comment(node): void;
done(): Out;
}
Out is the host’s output type — a string for every host shipped today, whether that string is a JSX expression, an Astro template, or a __mxOut += function body.
A host that cannot express a kind throws, naming the construct. There is no optional method and no default no-op, deliberately: an emitter that could silently skip a kind would compile a template and quietly drop part of it, which is exactly the failure the IR split was meant to make impossible.
Where a host’s own decisions live
Nothing target-specific leaks into the IR. A host-specific decision made at resolve time travels in DelegatedTag.data, opaque to the core — which is how <try> becomes <Loading>/<Errored> on Solid, a try/catch on the html target, and an error boundary on React, from one IR kind and no special-casing in packages/core.
The two exceptions prove the rule: For carries a key, and a range source carries step. Both are structural facts any Marko-syntax host needs regardless of target, not a concession to one framework.