ADR 147: wildcard children and tag aliases
Status: accepted 2026-10-04 (decision 147 in the decisions log; the IR alias and the “no code in contracts” rule were settled in the same discussion). Depends on: contract extension E2 (children), ADR 145 (defaultTag), ADR 146 (:name). Implementation: one core PR, one data/docs PR.
Context
What happens to a tag name nobody declared:
- On the HTML-emitting targets, Marko’s rule: a name that is neither a custom tag nor a builtin is a native element (
<foo>renders<foo>; dashed names are custom elements). Never an error. - On the data target,
unknownTags: "allow"(the library default) passes it through with no contract;"reject"(whatmx-tscuses) is a positioned error with a suggestion. Independently, a parent whosechildrenrecord is closed rejects it (E2).
A vocabulary often wants the middle ground: “any child name is fine here, and every one of them is an attribute”. Writing
<attributes>
<title type="string" required/>
<body type="string"/>
</attributes>
should be legal under a parent that says so, with title and body checked exactly like <attribute>.
Decision
children gains a "*" entry: one object or a list, first match in declaration order. Each entry has an optional pattern and either a contract reference or an inline contract:
children: {
attribute: { ... }, // explicit entries win
"*": [
{ pattern: "^[a-z][a-z0-9_]*$", contract: "attribute" },
{ pattern: "^on_(?<event>[a-z]+)$", contract: "hook" },
{ attributes: { ... } } // no pattern: catch-all, inline
]
}
patternis a JavaScript regex source matched against the whole tag name; MX anchors it, so authors never escape^/$in JSON. A name that matches no entry is a positioned error that lists the patterns and the explicit names.contract: "<tag>"validates the child with that tag’s whole contract: attributes, attribute tags, children,defaultTag. Inline contracts have the same shape as any declaration.- The child keeps its authored tag name.
<title>is the tagtitlein the tree, checked likeattribute. Only<:title>(ADR 146) produces<attribute name="title">. - In the IR the tag’s
nameis the resolved (canonical) contract tag and a newaliasfield carries the authored spelling, its span, and the pattern’s named capture groups. Every existing consumer keys onname; the data tree exposes both (tag: "title",contract: "attribute"). - Explicit entries win over
"*". With a"*"present, matched children are not “unknown” forunknownTags: "reject". - “Unknown” is target-neutral: no builtin, no custom tag, no sidecar or
mx.contractsentry. On an HTML-emitting target the wildcard can only fire inside a contract parent, so native elements outside one are untouched. - Guard: a wildcard child whose name is within did-you-mean distance of an explicit child of the same parent gets a warning (an error under
mx-tsc’s strict defaults). - Registration errors: invalid regex;
contractnaming an unreachable tag; a reference the resolver cannot terminate.
What the alias opens
The alias is cheap now and forces the canonical-versus-authored decision early. Doors it opens, in order of value:
- Names as data. Capture groups make tag names a grammar:
^on_(?<event>[a-z]+)$turns<on_click>intohookwithevent: "click"; likewise^h(?<level>[1-6])$,^col-(?<span>\d+)$. No new syntax, but the grammar lives in regexes, so diagnostics must print the match (<on_click>matchedhookby^on_…). - Keyed collections.
<env><PORT>3000</PORT></env>isvarwith aliasPORT; the alias is the map key. Data targets get maps and records from plain nesting. - Vocabulary evolution. Renames become aliases with a deprecation warning; domain or localized spellings map to one canonical tag; migrations rewrite by alias.
- Target-neutral authored names. The same authored name can resolve to a different canonical tag per target through each target’s contracts, while core, diagnostics and the language server reason about one name.
- Opt-in bridge to ADR 146. A contract may later declare
aliasAttribute: "name", making<title>and<:title>the same tree. Off by default: the alias is an IR fact, never a silent attribute.
Alternatives considered
| option | rejected because |
|---|---|
an as entry that renames the child (<title> becomes <attribute name="title">) |
conflates tag names with the name attribute; two spellings produce one tree with no trace of which was written; ADR 146 already covers the explicit form |
open children plus unknownTags: "allow" |
no validation of the unknown children at all; the typo at the top of the file passes, which is the failure the tooling exists to prevent |
| a mapping function in the tag declaration | see below |
keep the status quo (explicit <attribute name=…> everywhere) |
the cost falls on every data vocabulary and every agent writing it; the sugar is where the language earns its keep |
Why contracts stay declarative
A mapping or processing function in a tag declaration was considered and rejected. Contracts are read, diffed, documented and explained by the language server, mx-tsc, Vite, the data check and every target without executing anything. A function turns the declaration into a black box that must run inside every tool, in every target’s process, deterministically, with positions preserved; the first vocabulary to use it loses did-you-mean, hover and “names must match …” messages. It would also be the first place core executes vocabulary code at compile time on hosts.
Processing already has a home by kind:
- Validation beyond the declarative set (conditional required, one-of, cross-references): the sidecar
analyzehook, which reports positioned errors and whichmx.contractsmodules can export. - Tree rewriting (synthesize attributes, reorder, turn
<on_click>into something else): transform tags, the tag kind that exists for it and takeschildrencontracts like any other tag. The rewrite is visible as a tag, not hidden in configuration. - Semantics (what
alias,groupsandnamemean): the target consumer, for example Mesh’s compiler reading the data tree. Core’s job ends at a faithful, annotated tree.
When a need recurs across vocabularies, the answer is a declarative key (aliasAttribute, a groups-to-attributes mapping), never a hook.
Consequences
- One identity per tag:
parents,children, duplicate rules, did-you-mean andunknownTagskey on the canonical name; the alias is never a second identity. Messages print both:<title>(asattribute). - Contextual grammar is accepted here because it is per-vocabulary opt-in, in the same category as contracts, and never applies to native elements outside a contract parent.
- HTML-emitting targets receive the canonical name and ignore the alias unless a host opts in; every existing golden is byte-identical.
- Marko has no children contracts, so this is an mx-only extension of E2, recorded under E2’s row in
divergences.md.