Expanding a component
Macro expansion for diagrams — interpolation, content projection, and the name capture problem every macro system has to solve.
In the code: frontend/components.py
<define name="service"> makes a reusable, parameterised sub-diagram, used by
writing <service/>. The engine never learns about it: components are expanded
into ordinary elements before layout runs, so this is macro expansion, and it
inherits macro expansion's classic problem.
Three mechanisms
Interpolation. ${param} in the template is replaced by the value of that
attribute on the instance, falling back to a default declared on the <define>.
Substitution is textual and happens on attribute values and text content alike.
Projection. <slot/> in the template is replaced by the instance's own
children — the same idea as a Web Component's slot, or {children} in React.
It lets a component be a frame around content it knows nothing about.
Scoping. Discussed below, and the reason the other two are safe.
<diagram padding="16">
<style>
.cool { fill: #eef2f7; }
.warm { fill: #f3f0fa; }
@media (prefers-color-scheme: dark) {
.cool { fill: #1c2432; }
.warm { fill: #262034; }
}
</style>
<define name="service" tone="cool">
<contains label="${name}" padding="10" gap="8" class="${tone}">
<rect id="in">in</rect>
<slot/>
<rect id="out">out</rect>
</contains>
</define>
<row gap="20" align="start">
<service id="api" name="API"><rect>handler</rect></service>
<service id="worker" name="Worker" tone="warm"><rect>queue</rect></service>
</row>
<connect from="api.out" to="worker.in" routing="curved"/>
</diagram>
Two instances of one definition, each with its own parameters and its own
projected children — and one wire between them, api.out to worker.in, which
is only expressible because of the scoping described next.
The name capture problem
Here is the bit that matters. The template contains ids — a service has an in
port and an out port — and ids must be unique across a diagram, because
<connect from="..." to="..."> resolves them globally. Instantiate the same
component twice and you have two elements called in.
This is name capture, the problem hygienic macro systems exist to solve. In
Scheme, syntax-rules renames a macro's introduced bindings so they cannot
collide with, or shadow, names at the call site. The situation here is the same
shape with a smaller vocabulary: template ids are the introduced names, and the
surrounding diagram is the call site.
The resolution is to rename on expansion. An id inside a component becomes
instance.local — the prefix is the instance's id, not its name — so the
two services above really do carry api.in / api.out and worker.in /
worker.out, which is what the wire in that demo connects. (Omit the id and
the expander mints an internal one, so the parts simply aren't addressable.)
Three things follow, and all three are needed for it to hold together:
- internal references are rewritten too. A
<connect from="in" to="out"/>inside the template becomesapi.intoapi.out. Renaming the definitions without the uses would simply break every component that wires itself up; - the compound name is the public one.
api.outis deliberately reachable from outside, so a component can expose ports and the enclosing diagram can connect to them. This is renaming for uniqueness, not for privacy — the parts stay addressable, they just get a namespace; - projected children keep the caller's scope. Content that came in through
<slot/>was written outside the component and must not be renamed as if it belonged to it. Getting this backwards makes ids in your own markup silently change when you wrap them in a component.
Cycles
A component may instantiate another, so expansion recurses — and a definition that reaches itself, directly or through a chain, would recurse forever. Expansion therefore carries the chain of components currently being expanded and refuses to re-enter one already in it.
This is the same guard a macro expander or a template engine needs, and the same one an import graph needs. Catching it produces a comprehensible error naming the cycle; not catching it produces a stack overflow several thousand frames deep, which tells the author nothing.
Why before layout, and not during
Expansion is purely syntactic: XML in, more XML out, with no geometry involved. Keeping it strictly in the front-end means the layout engine has no idea components exist, and the whole feature costs the engine nothing — it can be understood, tested, and changed entirely on its own.
That is the same separation the layered architecture is built on, and components are the clearest illustration of the payoff: a substantial language feature that touches exactly one module.
Further reading. Kohlbecker et al., Hygienic Macro Expansion (1986) for where the capture problem was first properly solved; the Web Components slot specification for the projection half.