The core
Status: accepted design (decisions 183 and 184, 2026-10-07), not released. Marko behavior below
was probed on marko 6.4.0, @marko/compiler 5.42.10, htmljs-parser 5.18.0.
The core is what every layer builds on and none may change: the parser and its default row, the IR, the lowering driver, and the runtime shapes the hosts share.
The default row is Marko’s grammar
A .mx file in a project that declares no syntax parses exactly as Marko
parses a .marko file. The Marko parity oracle holds on this row and only
here. Consequences, each checked against Marko:
| Form | Marko 6.4.0 | MX core |
|---|---|---|
<a#x=1> |
<a value=1 id=x> |
same |
<a :> |
<a value:> (attribute value:, empty) |
same; upstream proposal: report Invalid attribute name. |
<a :b(x)> |
error: Unsupported arguments on the `value:b` attribute. |
same message |
<input :email> |
attribute value:email |
same; the name sugar is a layer-2 trigger in Mesh |
<div #id>, <div .c>, <div=x #id> |
error: Invalid attribute name. |
same; spaced sugars are layer-2 triggers in Mesh, and an upstream proposal (error space, non-breaking) |
<a #x=1>, kind #name(p) { b } |
Invalid attribute name. / “The value attribute cannot be a function.” |
same; the former MX readings are withdrawn |
<if(x)> |
error: “Tag does not support arguments. Write the condition as a value attribute instead: <if=condition>.” |
same message |
x=:draft, { k: :a } (atoms) |
syntax error | same; atoms are a layer-2 trigger in Mesh |
<div class="a" id="x" class="b"> |
last wins, <div id=x class=b> |
same |
What remains different from Marko lives in the hosts, not the core: how a
JSX emitter binds this in an event handler, how <define> fills params
from attribute tags, calling a plain function as a dynamic tag, React’s
<try> mapping, one text node per placeholder on JSX hosts. Each is a target’s
choice because the target is not Marko’s runtime, and each is documented on
its host page.
The syntax table
The parser takes one plain-data object per parse. The default row is a constant equal to today’s grammar.
interface SyntaxTable {
placeholder: { open: string; close: string } | null; // "${" and "}"; "$!{" is derived
inlineScript: { trigger: string } | null; // "$ " at line start
blockTag: { open: string; close: string } | null; // null on the default row
filter: { open: string; close: string } | null; // null on the default row
concise: boolean; // true on the default row
expressionTriggers: Trigger[]; // empty on the default row
attributeTriggers: Trigger[]; // empty on the default row
textTriggers: Trigger[]; // empty on the default row, always
tagTypes: Record<string, TagType>; // from the taglib and discovered parseOptions
expressionLanguage: "ts"; // reserved
}
interface Trigger {
id: string;
chars: string; // first characters that arm it: ":" or "A-Z"
match: string; // anchored regex source, RE2 subset
standIn: "number" | "identifier" | "keep";
node: "string" | "identifier" | "attribute" | { call: string };
terminatesValue?: boolean; // attribute triggers: a space then this ends the preceding value
}
Not in the table, on purpose: the tag-open character, the tag-name grammar, attribute syntax beyond the first character of a name, string and comment forms. Each is wired into every parser state, both tree-sitter grammars, the TextMate grammar and the parity; changing one is a different parser.
Resolution. defaultRow(fileKind) overlaid by the nearest
package.json#mx.syntax, validated, frozen, hashed; compiled tables are
cached by hash. A dependency’s files use the dependency’s manifest.
Validation, positioned at the manifest or descriptor that contributed the
entry: two triggers on one first character with overlapping matchers; a
trigger starting on an expression token ($, _, a letter, a digit, a quote,
a bracket) without standIn: "identifier" or "keep"; placeholder.open,
blockTag.open, filter.open not pairwise distinct, or starting with <; a
text trigger on <, $, /, \ or a newline; a matcher outside the RE2
subset.
The event boundary
The parser reports through handlers (onText, onPlaceholder,
onOpenTagName, …). That set is the parser’s contract and the shape a
native lexer would emit. Every handler is fire-and-forget. The one handler
that returns a decision today, onOpenTagName returning a TagType, is
replaced by the tagTypes field, computed before the parse from the same
information. Three events are added: onTrigger(id, start, end, standIn),
onBlockTag(start, end, openEnd, closeStart), onFilter(name, bodyStart, bodyEnd).
Hooks
Post-parse only, plain functions, declared by a syntax module (layer 2) or a descriptor (layer 3):
lowerTrigger(id, text, span, ctx)builds the node a trigger produced; default implementations cover thenodekinds of the table.lowerBlockTag(text, span, ctx)turns a block form into IR throughctx.build.lowerFilter(name, body, span, ctx)returns IR for a filter block.afterLower(ir, ctx)runs once per unit after lowering, before emit.productNamenames the language in every diagnostic.
Attribute tags: one runtime shape
Every host emits Marko’s attribute-tag record: the first occurrence’s
attributes are the record’s own properties, later occurrences live in a
hidden rest list, and Symbol.iterator yields them in order. MX adds a
prototype with map, filter, length, at and toArray over that
iterator. Own-property semantics are unchanged, so code written for Marko
(spread, for of, [...x || []], x.title) behaves identically; prototype
members are neither spread nor serialized. The type AttrTag<T> is a
supertype of Marko.AttrTag<T>. An absent tag is undefined.
Cardinality is not read from a callee’s Input type. The emitted code of
a template never depends on a type: types are erasable, and output that
depends on which file module resolution finds for a callee differs between
tools. A contract may later declare attributeTags.x.repeat to receive a real
array, which is declaration-driven and local to the tag, the mechanism Marko’s
own marko-tag.json uses.