The whole language on one page: what interpolation means, every : family,
the module and composition tags, and the names the runtime supplies.
| Syntax | Meaning |
|---|---|
${expr} in text |
Reactive text content. |
${expr} in CSS |
Reactive stylesheet content — the whole sheet is one binding, so see below. |
attr=${expr} |
Reactive plain attribute; no : needed. null/undefined removes it. |
An interpolation's extent is found by parsing it, so HTML's rules for
attribute values stop applying inside one. > does not close the tag, a
quote does not end the value, and strings, template literals, object literals
and nested ${...} end where JavaScript says they end. Attributes may also
span lines, with comments between them.
So what a tag declares are the properties and methods of a scope — a JavaScript object — rather than strings in an attribute, and a definition holding real logic reads as a unit instead of as a long line. The two sections below give the details.
<style>, <title> and <textarea> hold text rather than markup, and an
interpolation inside one cannot be wrapped in the comment markers that
delimit dynamic text elsewhere — a browser would show them. So these hold
their whole content as a single value.
Which means one ${...} makes the entire sheet reactive, and every
change re-serializes all of it. Keep what changes in its own sheet:
<style>
:root { --accent: ${accent}; } <!-- reactive: a few tokens -->
</style>
<style>
.panel { border-color: var(--accent); } <!-- static: never re-serialized -->
</style>Splitting a 4.6KB stylesheet this way in the Orbit demo left 0.8KB reactive and the rest inert. Nothing else in the language has this granularity: an ordinary attribute or text interpolation is its own binding.
An attribute value can be unquoted, "double quoted" or 'single quoted',
and any of them may contain ${...}. What quoting does not do is decide
the type — that depends on whether an expression fills the value on its own:
| Written | Result |
|---|---|
:x=${expr} |
the expression's value, whatever its type |
:x="${expr}" |
identical: quoting changes nothing here |
:x='${expr}' |
identical |
:x="text ${expr}" |
interpolation — always a string |
:x="${a}${b}" |
interpolation — always a string |
:x="literal" |
the string literal |
So a lone expression is passed through with its type intact — object, array, number, boolean, function — while anything combining literal text with expressions, or more than one expression, is assembled into a string.
That distinction matters most for :prop-, where a component is expecting
something an attribute could never carry:
<sl-select :prop-options="${items}" <!-- the array itself -->
:prop-label="Pick one of ${items.length}"> <!-- a string -->NOTE: "on its own" is literal — whitespace is text like any other, so
:x=" ${expr}" interpolates and yields a string.
Inside ${...} the attribute's own quote is ordinary JavaScript, so
:v="${"x"}" and :v='${'x'}' both parse. HTML would have ended the value at
that second quote, and this does not, for the same reason a > inside an
expression does not end the tag: an expression is JavaScript, and the
delimiters around it stop applying until it closes. That leaves nothing to
remember about which quote to reach for — one fewer rule rather than one
more.
A plain attribute replaces what was there. Two of them hold a set rather than a value, and those two can also be contributed to:
| Written | Meaning |
|---|---|
class="a b" |
sets the class attribute — replaces |
class+="a b" |
adds those classes to whatever is there |
class-="a" |
takes them away |
class!="a b" |
replaces, and says so — see below |
style+="color: red" |
adds those declarations |
style-="color" |
takes those properties away |
style!="color: red" |
replaces, and says so |
Which is the whole rule, and it has exactly two members because HTML has
exactly two composite attributes. That is the same fact that gives class
and style a dash-case family and gives href none: class+= is the
whole-set form of :class-x, and class-= of :class-x=${false}.
| one name | a set | |
|---|---|---|
| replace | — | class=, class!= |
| add | :class-x |
class+= |
| remove | :class-x=${false} |
class-= |
They are not : attributes, deliberately. : names what HTML has no name
for, and class has a name — what is new here is the operation, and an
operation is not a name. += on anything else is a compile error, since a
title holds a value and there is nothing there to add to.
The case they exist for is a usage site arguing with a definition that
sets class itself. A class written there replaces the one the component
computed, which is the language's rule and the right one; class+= is how
to say the other thing:
<bs-alert ::variant="warning" class+="mb-0 shadow-sm">Careful</bs-alert>
<bs-alert ::dismissible class-="fade">No animation, please</bs-alert>Replacing it outright is still legal and still means what it says, but it is almost never what someone wants from a component that derives its own classes, so it is said out loud:
warning: <bs-alert> sets "class" itself, and a "class" here replaces it -- did you mean "class+=", or "class!=" if you meant to replace it?
A warning rather than an error, on the same footing as nothing reads "varient" below: a judgment about the page rather than a fact about whether
it can be built.
And class!= is the answer that agrees with it. Sometimes replacing is
exactly the intention — a component whose classes are a starting point, a
one-off that has to look nothing like the rest:
<bs-alert ::variant="warning" class!="my-own-alert">Nothing of the kit's, thanks</bs-alert>It compiles to precisely what class= compiles to. The whole of what it adds
is the statement, which the compiler takes as its answer and says no more —
and which the next person to read the line gets for free, since a plain
class on a component is ambiguous between "I meant this" and "I did not
know". style!= is the same for the other one.
! because it is not a set operation like the other two: += and -=
describe what happens to the set, and this describes intent about a
collision. CSS spells that idea !important, for the same reason.
It expects something to replace. On a plain element, or on a component that
sets no class of its own, there is nobody it can be addressing, and it says
so — while going on working, since it is a class either way:
warning: <div> is not a component, so "class!=" replaces nothing -- "class=" is what this is
Which is what catches the stale one: a component that stops setting a class
leaves every class!= aimed at it saying something that is no longer true.
A literal is read the way HTML spells that attribute; an expression carries
the value itself. Three of the four take a set of names, and style+= a
map, because addition assigns and removal only names:
| literal | expression | |
|---|---|---|
class+= |
"mb-0 shadow-sm" |
string[] |
class-= |
"fade" |
string[] |
style+= |
"color: red; gap: 1rem" |
{ [property]: value } |
style-= |
"color gap" |
string[] |
Which is decided by the compiler rather than guessed from the value: a lone
${...} keeps its type, and anything holding literal text beside an
expression is an interpolation and so a string. class+="mb-0 ${extra}" is
therefore refused, and written class+=${['mb-0', ...extra]}.
Base, then every addition, then every removal — whatever order they appear
in. So class-="fade" means the same thing whether it stands before or
after the class+= it is arguing with, and a falsy :class-x is a removal
like any other.
Nothing writes the attribute whole, which is what makes any of this hold:
each input says what it contributes, the four together say what the set
should be, and only the difference is applied. A class this page never
put on — one Bootstrap's own JS added to a modal it was handed — is in
neither set and so is never touched. Before this, a reactive class=${...}
re-running turned box box-red mine into box box-green, silently, and
only once the variant happened to change.
value=${v} on an <input> reads as "this is the value" and behaves as
"this was the initial value". HTML gives a handful of attributes a dirty
flag: from the user's first keystroke or click, the element's own state is
independent of both the content attribute and the content, and nothing
written to either is consulted again. So v = '' after a submit empties the
model and leaves the typed text sitting in the box.
The attribute is still needed — it is what the element is served with, and a page rendered on the server hydrates onto it. What is missing is the other half:
<input value=${note}
:prop-value=${note}
:on-input=${e => note = e.target.value}>value= is what it is served with; :prop-value= is what it shows from then
on. Both, together. The pairs are:
| Element | Attribute | Written beside it |
|---|---|---|
<input> (a type that is typed in) |
value=${...} |
:prop-value=${...} |
<input type="checkbox">, <input type="radio"> |
:attr-checked=${...} |
:prop-checked=${...} |
<textarea> |
the content, or value=${...} |
:prop-value=${...} |
<option> |
:attr-selected=${...} |
:prop-selected=${...} |
value on a submit, a button, a hidden field, a checkbox or a radio is not
on the list, because none of those is the thing being typed: HTML keeps them
reflecting the attribute for as long as the element exists, and there the
attribute alone is exactly right.
The compiler warns when it sees one without the other, which is the only
reason this is safe to leave as two spellings rather than one. Making
value= quietly write the property when it happens to be on an input would
be the shape-guessing that two intents, two
spellings exists to
prevent — one attribute meaning two different things depending on the
element it sits on. The alternative to magic is not silence: it is being
told, with a file and a line, the moment half the pair is written.
| Syntax | Meaning |
|---|---|
:name=${expr} |
Declares a reactive value on the current scope. |
::name=${expr} |
On a <:define>, declares a parameter; on a usage site, passes one. Reserved at every usage of that tag — see a usage site is a call, and an element. |
:server-name=${expr} |
Declares value name, but the expression runs on the server only — the client is handed its result. Server-only. |
:const-name=${expr} |
A compile-time constant: computed while the page is built and written into every expression that reads it. Nothing of it reaches the runtime. |
:aka="name" |
Names the current scope so descendants can reference it. A literal, not an expression. |
:attr-name=${expr} |
Toggles whether attribute name is PRESENT, as boolean and custom-element attributes need. Bare :attr-name implies true. . and : are allowed, for data-x.y and xlink:href. |
:prop-name=${expr} |
Assigns the element's JS property name, for what an attribute can't carry. Browser-only: skipped when server rendering. |
:class-name |
Toggles the name CSS class. |
:style-name |
Writes the name CSS property. |
class+=${expr} |
Adds classes to whatever class holds, rather than replacing it. A literal is a list of names, an expression a string[] — see a composite attribute is added to. |
class!=${expr} |
Sets class, exactly as class= does, and states that replacing a component's own is meant — which is what the warning about it asks. style!= likewise. |
class-=${expr} |
Takes those classes away, the whole-set form of :class-name=${false}. |
style+=${expr} |
Adds declarations to whatever style holds. A literal is CSS text, an expression a { property: value } map. |
style-=${expr} |
Takes those properties away. Names, not declarations: removal names what to drop. |
:on-click=${() => ...} |
Binds an event handler. The name is the event type verbatim, so . and : are allowed for the sake of shown.bs.modal, click.mine and the like. |
:handle-name=${(v) => ...} |
Runs when value name changes, and once at start, with its value. For driving the view imperatively; browser-only. |
:did-init=${() => ...} |
Runs once, when this scope has come up. Browser-only. |
:did-attach=${() => ...} |
Runs when this scope's markup enters the page, and again each time it comes back. Browser-only. |
:will-detach=${() => ...} |
Runs before that markup leaves. Browser-only. |
:will-dispose=${() => ...} |
Runs once, before this scope stops existing. Browser-only. |
:name and :aka are read back as ${name}, so the name has to be
something an expression can say: a JS identifier, no dash (reserved for the
dash-case families above) and no $ (reserved for the runtime's own). A
reserved word or a leading digit is refused for the same reason — ${if}
and ${9lives} don't parse, so the value could be declared but never read:
<div :while=${ready}> <!-- error: "while" is a reserved word -->
<div :ready=${ready}> <!-- fine -->That rejection is what makes the reserved words available for something
else. A name no page can declare is a name a directive can take with no
prefix and no possibility of collision, which is where :if comes from —
and the reason ${if} not parsing is a feature rather than an awkwardness.
The names in the dash-case families are element-facing — CSS properties,
attribute names, event types — so they keep their dashes and this rule
doesn't apply to them. Each takes what its own world uses: dash-case for
class names, CSS properties and JS properties, and additionally . and :
for :attr- and :on-, whose names reach setAttribute and
addEventListener exactly as written. $ stays out everywhere, being the
runtime's own prefix.
Two pairs, answering two different questions.
:did-init and :will-dispose bracket the scope: what it set up when it
came into being, and has to let go of when it stops existing. A timer, a
subscription, anything whose lifetime is the component's.
:did-attach and :will-detach bracket its markup: what has to exist
while the element is in the page, and be taken apart when it leaves. A
third-party plugin holding your element, an observer, a measurement.
They are not the same thing, which is why there are four rather than two. A
:for-data region's markup leaves the page and comes back without its scope
ever going away, so it detaches and attaches repeatedly and never disposes.
A :for-each replica that is dropped does both, in that order — its markup
goes, then it does.
<span :did-attach=${() => globalThis.bootstrap.Tooltip.getOrCreateInstance($dom)}
:will-detach=${() => globalThis.bootstrap.Tooltip.getInstance($dom)?.dispose()}>Order within a pass: parents before children on the way in, children before parents on the way out — things are taken apart in the order they were built, reversed.
All four are browser-only, like :handle-, and all four take a literal arrow
like every other callback family. The suffixes are a closed set: :did-mount
is a compile error rather than a callback that never runs.
A stencil announces nothing. A :for-each host and a :for-data region with
nothing to show evaluate none of their values, and for the same reason they
report none of these — what a stencil is, is a prototype.
Almost everything is declarative: state what a thing should be, and the
runtime keeps it that way. Some of the DOM isn't reachable that way, because
it is a verb rather than a value — focus(), showModal(), play().
:handle-name is the door. It runs when name changes, and once at start,
receiving the value; $dom is the element it belongs to:
<dialog :open=${false} :handle-open=${(v) => v ? $dom.showModal() : $dom.close()}>
...
</dialog>Both are browser-only. A served page has no element to drive, so $dom is
absent and handlers do not run while server rendering. Anything that should
show up in the served markup therefore belongs in a value rather than in a
handler — a handler is for the part of the view that markup can't express.
A handler depends on the value it names, and only that. References inside its body are not dependencies, so it does not re-run because something it happens to touch changed. They still have to resolve, though — a name that is nowhere is a compile error wherever it is written, handler bodies included, rather than a failure waiting for the first click.
The four callback families take a function literal, written at that
spot — an arrow or a classic function, either way. What is refused is a
reference to one: ${handler} is an error even where handler holds a
function, because a callback's dependencies are read from what stands here,
and a name is not a body:
<button :on-click=${() => count++}> <!-- yes -->
<button :on-click=${async () => save()}> <!-- yes -->
<button :on-click=${function () { count++ }}> <!-- yes -->
<button :on-click=${handler}> <!-- error -->There is no rule against classic functions anywhere else either. One may
appear anywhere inside any ${...}, nested as deeply as you like, and it
sees the scope exactly as an arrow does — an expression reaches its scope
through an argument the compiler passes it, not through this, so nothing
about function can take it away.
The one name an expression may not declare is $, which is that
argument. ${items.map($ => $.x)} is a compile error, and any other name
works.
A design token never changes, and :const- says so:
<html :const-accent="#6f42c1" :const-gutter=${16}>
<head><style>:root { --accent: ${accent}; --gutter: ${gutter}px }</style></head>The value is computed while the page is built and written into every
expression that reads it, so nothing of it reaches the runtime — no
scope entry, no dependency edge, no cell that can never fire. It matters
most in a stylesheet, where one interpolation otherwise makes the whole
sheet a binding; with :const- there is no
binding at all.
const- is a modifier, like :server- and unlike
the :class-/:on- families: a family names something in another world —
a CSS class, a DOM event — and the dash-case part is that other thing's real
name, while what a modifier marks is an ordinary markout value, declared and
read under its own name. :const-accent is read as plain ${accent}.
Which buys something beyond tidiness. Because the modifier is not part of what the value is called, an import site can override a constant with an ordinary reactive value and nothing that reads it changes:
<head :radius=${dark ? '0' : '1rem'}> <!-- the kit's :const-radius, live -->
<:import src="/some-kit/all.htm" />The kit goes on writing ${radius} in its stylesheet. Every page that
doesn't need this pays nothing, and the one that does pays for a binding
exactly where it asked for one.
One rule keeps it honest: a :const- value may read only literals and
other :const- values. Reading an ordinary value, $id, the DOM or a handler is a
compile error — never a quiet fall back to being reactive, which would hand
the page exactly the cost the marker was meant to avoid. The result also has
to be a primitive; substituting an object would give every reader a separate
copy.
And the limit worth knowing before reaching for it: a :const- value cannot
participate in runtime theming. A light/dark switch changes values while
the page runs, and these are gone by then. :const- is for what is fixed when
the page is built — which is why a page that needs one of a kit's tokens to
move declares that name plainly, as above, rather than reaching for :const-.
A kit's tokens are :const- for exactly this reason, and a page overrides them
where it imports the kit — see root attributes:
<head :const-bsRadius="1rem">
<:import src="/bootstrap-kit/all.htm" />Hydration re-derives every value by running its expression again in the
browser. :server-name=${expr} says that this one can't be run there:
<html :server-startedAt=${Date.now()}>
<body>Started at ${startedAt}</body>
</html>The server evaluates it once, sends the result alongside the page, and the
client uses that result — so startedAt is the same number in both, instead
of the server's value being replaced by the browser's the moment the page
comes alive.
It is for expressions that cannot be re-run, not for ones that are merely
slow. Three kinds qualify: a value only the server can produce (a file, an
environment variable, a session), one that isn't deterministic (Date.now(),
Math.random()), and one assigned imperatively later, which has no expression
to re-run at all.
The value is frozen in the browser: it arrives with a result and no expression, so nothing re-derives it. Values that read it are ordinary and keep updating as usual — which gives the one rule worth remembering:
Mark the source, never the derivation.
<html :server-user=${{ name: 'Ada' }} :greeting=${'Hi ' + user.name}>user is marked because the browser can't produce it; greeting is left alone
so it still tracks user. Marking greeting instead would pin it, and a
later change to user would silently never reach it.
The one directive :server- may mark, and it means what it says everywhere
else: the condition runs on the server and the answer crosses frozen, so the
browser never decides this branch again.
<div :server-if=${user.isAdmin}>
<a href="/admin">Danger zone</a>
</div>That matters because of what an ordinary :if has to do. Its condition is
live — the browser may turn it — so the markup of the branch that did not
show still travels, in the stencil it would be built from. Behind
${user.isAdmin} that is the admin panel, its links and its labels, in the
page source of every visitor who is not an admin.
A :server-if that did not show can never show, so there is nothing to
build and its markup is not sent at all. One that did show is in the page as
usual and hydrates normally; only the decision is frozen.
Markup, precisely: the elements, their attributes and their text. The
expressions of values written inside the branch are compiled into the
page's props, which are a function of the source rather than of the request,
so they travel whatever the branch decided. <a href="/admin"> is gone;
${budget * 2} is still there as $.budget*2. Where the logic itself is the
secret, keep it in a :server- value, whose expression the browser never
receives.
Two things follow, and both are the point rather than limitations:
- It cannot change afterwards. Not on a click, not on new data. If the
branch has to be able to turn, it is an ordinary
:ifand the markup travels; that is what the markup is for. - A page with no render has no decision. Served in client mode, or built with nothing to evaluate it, the condition is simply absent and the branch does not appear.
:else and :else-if after one are ordinary branches and behave as they
always do — the server decides the first, and the rest answer to it.
:server- on any other directive is still refused: :for-each and the rest
declare no value for it to mark.
A :server- expression may produce a promise. The server waits for it and
sends what it resolved to, so the page is served complete:
<html :server-rows=${fetch(url).then(r => r.json())}>
<body><div :for-each=${rows ?? []}>${data.name}</div></body>
</html>Async is allowed exactly where the result can be sent. An unmarked value's
promise would have to resolve in the browser too, and hydration is
synchronous — there is nothing there to wait with — so only :server- values
are settled.
One result may feed the next. The server keeps going until nothing is left in flight, so a value that needs another's result to build its request works:
<html :server-user=${fetch('/me').then(r => r.json())}
:server-orders=${user ? fetch(`/orders/${user.id}`).then(r => r.json()) : null}>Two limits bound the wait, and they mean different things. A deadline (5s) bounds how long a visitor waits for a slow network. A depth cap (5 links) bounds how long a chain may get; a page past it has a bug, and saying so beats stalling until the deadline on every request.
A value that rejects, times out, or is still waiting at the cap becomes
undefined and is reported — the same rule an expression that throws already
follows. The page is still served: the rest of it is what the visitor came
for.
A promise never reaches the page. While one is in flight the value reads as
undefined — the runtime holds the promise aside rather than letting it into
the reactive system — so everything downstream is written against data and
nothing else. That is what makes the guard above do what it looks like it
does: user is not "a promise that is truthy", it is simply not there yet.
Two things to know before using it:
- The result is published. It travels in the page source as plain text, so never mark anything derived from a credential, a session, or another user's data.
- It has to be sendable. Numbers, strings, plain objects and arrays,
undefined,Date,Map,Set,RegExp,BigInt. Not functions, not class instances, not a structure that refers to itself — those are reported as errors, and the value isundefinedin the browser.
And one thing to know about delivery: a page compiled ahead of time into static
assets has no request behind its render, so a :server- value that needed one
cannot produce anything — and because such a value crosses frozen, nothing in
the browser can make up for it. markout build therefore fails on one that
throws, rather than shipping a page permanently missing what it was for. A value
that reads nothing of the request is fine there, and its answer is baked into
the markup. See
rendering.
:server- marks declared values only. It is an error on :attr-, :class-,
:style-, :prop- (which re-derive for free once the value they read is
marked), on the callback families (which hold functions), and on :aka,
:slot and the :for- attributes, which name no value.
Between the attributes of an opening tag, // comments to end of line and
/* … */ block comments are both allowed. They are stripped at parse time
and never reach the served markup.
That matters more than it sounds, because attributes may also span lines. A tag declaring a handful of values stops being a long line to scan and becomes something closer to a declaration, with its parts grouped and labelled:
<:define tag="my-component:div"
// parameters
::width=${100}
// private
:_w="${width}px"
>${_w}</:define>Commenting out a single attribute works the way it does in code — the
comment simply hides it from the parser. One left unterminated runs to the
end of the file, which shows up as the enclosing tag never being closed
(Unterminated tag DIV) rather than as anything about comments.
These belong to a tag. In text content they are ordinary text; use
<!-- … --> there.
What makes
_wprivate is the missing::, not the underscore: only what a definition marks is part of its interface, so a plain:on that root is the component's own and no usage site can set it. The underscore says so to a reader at a glance, which is worth keeping — but nothing in the language treats_specially, and it is the compiler that enforces this.
<style>, <title> and <textarea> hold text rather than markup: a browser
reads what is between their tags as characters. So an interpolation there is
the element's whole content as one value rather than one binding per
${...}, and changing any part of it rewrites the lot. Nothing about writing
it differs:
<textarea :on-input=${(ev) => draft = ev.target.value}>${draft}</textarea><code> goes further: its content is not parsed at all. Everything between
the tags is text until the closing </code>, so an interpolation stays as the
characters you typed and markup inside it is content rather than elements —
which is what makes a page able to show markout source without escaping every
${...} in it:
<pre><code><div :count=${0}>${count}</div></code></pre>That renders as written. <script> is treated the same way, for the reason it
always has been.
Three things follow, and the third is the one that surprises:
- The tag's own attributes are ordinary —
<code class="lang-${lang}" :if=${shown}>interpolates and binds like any element. It is the content that is skipped, not the element. <pre>is not in this set.<pre>${x}</pre>interpolates normally, so a sample belongs in the<code>inside it.<code>cannot nest. The first</code>ends the content whatever came before it, so<code>a <code>b</code> c</code>fails as a tag that never closed rather than as anything about nesting.
| Syntax | Meaning |
|---|---|
:if=${expr} |
Render this element when the expression is truthy, not at all otherwise. Binds nothing. |
:else-if=${expr} |
Another condition for the position, tried only if every branch before it failed. Goes on the element immediately after an :if or another :else-if. |
:else |
The branch for when none of the conditions before it held. Takes no expression, and ends the chain. |
:for-each=${expr} |
Repeat once per item in an iterable. null/undefined means zero items. |
:for-as="name" |
Rename the per-item binding from the default data. |
:for-key=${expr} |
Give each item an identity, so reordering moves replicas instead of rewriting them. Evaluated per item, and may read the per-item binding. Refused on :for-data, which has only ever one. |
:for-data=${expr} |
Render once if expr is neither null nor undefined, not at all otherwise. Binds the item like :for-each. |
:if, :for-each and :for-data all answer "how many times does this
render", so an element may answer once: any two together is a compile error.
:else-if and :else are the same answer under other spellings, and so
count as :if here — an element is one branch, not the choice between two.
None of them may go on <html>, <head> or <body>, which is also a
compile error. A region's markup moves into a stencil, and those three are
where a compiled page keeps what makes it work — every stencil in the head,
the props and the runtime in the body. Whatever you meant goes on an
element inside.
On a <:define> they answer for every instance of the tag. A definition
renders nowhere itself, so the only thing an arity on one can say is how
many times each use of it renders — which lets a component carry its own
guard instead of asking every caller to remember one:
<:define tag="my-alert:div" ::msg=${null} :if=${msg} class="alert">
${msg}
</:define>
<my-alert ::msg=${error} /> <!-- nothing at all while `error` is null -->The condition is the instance's, so it reads the instance's parameters, and
each use of the tag decides for itself. :for-as and :for-key go beside
the :for-each that declares the loop, wherever that is. :else and
:else-if are the exception: they are resolved by position among siblings,
and a definition has none where its instances stand, so both are refused
there.
The single-answer rule holds across the two sites. A tag whose definition
declares an arity is already answered, so an :if at a usage site is a
compile error naming both — keep the condition that belongs to every
instance in the definition, and write one at a usage site only where the
definition declares none. :for-as and :for-key at a usage site are
refused for the same reason: the item they rename or key is the one the
definition's body reads, and a caller has no name for it.
A chain shows the first branch whose condition holds and no other:
<p :if=${n === 0}>nothing yet</p>
<p :else-if=${n === 1}>one thing</p>
<p :else>${n} things</p>Which branch an :else belongs to is said by position and by nothing else,
so it has to be the very next element after the branch before it.
Whitespace and comments in between are fine — neither renders — but
anything that does render is a compile error, since it would appear between
two alternatives at most one of which is showing. Everything true of :if
stays true of the others: a branch that isn't showing evaluates nothing
inside itself, and its element is held by its scope rather than rebuilt when
it comes back.
:if and :for-data differ in the question they ask. :for-data is
!= null, so 0 and '' are data — right for an item, wrong for a
condition. :if is plain truthiness, so ${count} and ${name} mean what
they look like, and it binds no item: data inside an :if still means
whatever it meant outside.
Neither evaluates its body while it isn't showing, which is what makes
${user.name} safe to write inside one.
The host element becomes an inert <template> and every visible item is a
clone of it. That <template> is in <head>, with a comment standing where
the element was written — so a replicated list's children are its replicas
and nothing else, and :first-child and :nth-child mean what they say. See
replication.
Every directive above goes on an element, which makes the element the unit:
one condition, one thing shown. <:group> lifts that. It is a tag that
never renders, and the directives it carries apply to its contents —
however many nodes those are.
<tbody>
<:group :for-each=${lines} :for-as="line" :for-key=${line.id}>
<tr><td>${line.name}</td><td class="num">${line.total}</td></tr>
<tr class="note"><td colspan="2">${line.blurb}</td></tr>
</:group>
</tbody>Two rows per item, and no wrapper — which matters here because there is no
element you are allowed to put between <tbody> and <tr>. The same holds
for <dt>/<dd> pairs, for <option>s, and for a branch that is a
heading and a paragraph rather than a <div> around both.
It takes the branch and replication attributes — :if, :else-if,
:else, :server-if, :for-each, :for-as, :for-key, :for-data —
and behaves exactly as they do on an element, nesting included.
What it costs depends on what is inside, and the three cases are worth knowing because they are what the compiled page contains:
| the group | what it becomes |
|---|---|
| no directives on it | nothing: the tag is dropped and its contents stay where they are |
| a directive, and a single element inside | the directive moves onto that element — <:group :if=${x}><p>…</p></:group> compiles byte-for-byte as <p :if=${x}>…</p> |
| a directive, and anything else inside | a region: a marker comment at each end, and the run between them is what shows, hides or repeats |
So the range machinery exists only where there is a range, and adding or removing a sibling inside a group changes the output without ever changing the meaning.
A group carries directives and nothing else. It has no element, so
there is nothing for class, style, an event handler or a plain HTML
attribute to land on; and it has no scope, so a value like :n=${1} has
nowhere to live. Both are compile errors that name the attribute and say
where to put it instead — on an element inside the group, or on one around
it. A <:logic> is the tag for holding values with no element of its own.
A region is not built while it isn't showing — that is what "evaluates nothing" means — so the scopes inside one exist only while it does. A name declared in there is therefore a name that may not be there, and the language asks for it to be read the way JavaScript reads anything that may not be there:
<div :aka="panel" :if=${open}>
<input :aka="field" :text=${''}>
</div>
<p>${panel.field?.text ?? 'nothing yet'}</p>The ?. is required, not merely permitted — without it the reference used to
compile clean and fail when the page ran, with a message about undefined
that named nothing the author had written. With it, the reference reads
undefined while the region is away and the real value while it is showing,
and a change inside the region reaches the reader outside it.
A write needs $set, because a?.b = c isn't JavaScript. a?.b(c) is,
so a write spelled as a call inherits the guard:
<button :on-click=${() => panel.field?.$set('text', '')}>clear</button>It answers whether it landed — true when the write went through, and the
whole expression is undefined when the region was away — so a caller that
needs to know can ask, and one that doesn't can ignore it. Plain assignment
into a region stays a compile error, since there is no way to write ?. on
the left of an =.
The name has to be a literal, so the compiler can check it. A name it cannot
follow would be a write that quietly lands nowhere, which is the thing $set
exists to have a spelling for.
:for-each is refused outright, guarded or not. ?. says "this may be
absent", and a loop's difficulty is different: the name means as many scopes
as there are items, and none of them in particular.
Two things need no guard. A value on the region host — ${panel.open} —
since that scope exists whether or not it is showing, which is how a region's
own condition is read. And anything read from inside the same region,
where everything is built together and stops existing together.
| Syntax | Meaning |
|---|---|
<:include src="file.htm" /> |
Splices another file into the current document. |
<:include src="file.txt" as="pre" /> |
Includes a file as a literal element named pre containing its text. |
<:include src="page.html" as="pre" escaping /> |
The same, escaped: the file is shown as source rather than landing as markup. |
<:import src="file.htm" /> |
Splices a fragment into the page; each file is only imported once per page. |
<:define tag="x-y:button">...</:define> |
Declares a reusable custom tag. |
<:group :if=${expr}>...</:group> |
Applies a branch or replication directive to its contents rather than to an element. See <:group>. |
<:logic :aka="x" :n=${1} /> |
Declares a scope with no element of its own. |
<:define tag="x-y:logic"> |
A custom tag whose instances are scopes with no element. |
:when-used="tag-a tag-b" |
Keep this element only while one of those tags survives treeshaking. Build-time; nothing of it reaches the runtime. |
<:slot /> |
In a definition: where a usage site's content goes. Its own content is the fallback. |
<:slot name="x" /> |
A named slot. |
:slot="x" |
On a usage site's child: which slot it fills. Unaddressed content fills the unnamed one. A literal, not an expression. |
Two conventions, and the file extension picks between them.
A page (.html) is indented like HTML: attributes that wrap line up under
the first one. Someone opening it should see the page they already had, with
some attributes added — which is what adopting markout is supposed to feel
like, and it is worth more than the tidiness the other shape buys.
A fragment (.htm) is indented like code: attributes sit one step in from
their tag, and the closing > goes back at the tag's own indent.
<:define tag="bs-alert:div"
role="alert"
// parameters
::variant=${'primary'}
::dismissible=${false}
class=${_class}
:class-fade=${dismissible}
>This is what a fragment already is. A <:define> header is a parameter
list, its body holds arrow functions and template literals, and the closing
> on its own line was already a block delimiter — only the column the
attributes sat in disagreed.
Aligning has two costs a fragment feels and a page mostly doesn't. The
column is derived from the tag's name, so renaming a tag re-indents every
attribute under it and a one-word change lands in git blame as a rewrite.
And the column moves, so how deep an attribute sits — and therefore whether
a line is short enough to keep — depends on how long the tag happens to be,
which is why two adjacent components in the same file used to break
differently.
The VS Code extension formats to this — both shapes, chosen by the file's extension — so it is something to run rather than something to remember. It only ever changes indentation, and only inside an open tag: where an attribute list wraps is a judgment about how a component reads, and stays yours.
<bs-alert ::variant="danger"> is two things written as one. It is a
call, whose attributes are arguments; and it is an element in your
markup, which can hold state of its own the way any native element can.
:: is which:
<:define tag="bs-alert:div"
::variant=${'primary'} // the interface: what a usage may set
::heading=${null}
:_cls=${'alert alert-' + variant} // private; no usage can reach it
>…</:define>
<bs-alert ::variant="danger" // an ARGUMENT: `bs-alert` takes a `variant`
:count=${0} // a LOCAL: yours, and the component never sees it
:on-click=${() => count++}>${count}</bs-alert>An argument goes to the component. It overrides the default, the definition's body reads it, and its own expression resolves out at the call site — which is what makes the pass-through idiom mean what it looks like:
<bs-badge ::variant=${variant} /> <!-- the `variant` from out HERE -->A local stays where it was written. It is a value on the usage site, so
the attributes beside it see it, the tag's slotted content sees it, and a
handler can write to it — everything that is true of :count on a <span>.
It is per replica, so <my-row :for-each=${rows} :draft=${''} /> gives
each row its own with no wrapper element to hold it.
A definition's interface is what it says, and a tag reserves it. Only
:: names are settable, so a plain : on a define root — :_cls above — is
the component's own, and a usage may declare a local of that very name
without either one knowing. And at a usage site the two spellings are not
interchangeable: :variant where the tag takes a variant is a compile
error, and so is ::varient where it takes no such thing. One says the name
is yours and the other says it is the component's, and only one of them is
true.
The reservation is per tag, not global: <bs-alert :count=${0}> is fine
while bs-alert declares no count, whatever some other tag declares.
It also means a component gaining a parameter is a change its callers are
told about. A kit that adds ::label in a later version turns a caller's
existing :label local into an error naming the tag and the name, fixed by
renaming that local — rather than quietly taking a name someone was already
using.
One shape the reservation cannot catch is a misspelling, since :varient
claims the name for you and so is a perfectly legal local. What notices it
is that nothing reads it:
warning: nothing reads "varient": <bs-alert> takes "variant" -- did you mean "::variant"?
A warning, not an error — the page builds and is served, because unlike everything else the compiler reports this is a judgment about the page rather than a fact about whether it can be built. A local a handler writes and nothing displays is state, not a mistake, and says nothing.
Everything else on a usage site is unchanged, being neither: :if,
:for-each, :aka and :slot name no value, and :class-, :style-,
:attr-, :prop- and :on- apply to the instance's own element. The one
of those a definition can answer first is the arity — :if, :for-each or
:for-data on the <:define> decides for every instance, and then a
usage site writing one too is a compile error rather than a second answer.
See replication.
Everything else that declares values is markup that happens to carry them.
State that belongs to the page, rather than to anything on it, had nowhere
to live but an element invented to hold it — and that element is then real:
in the document, in the accessibility tree, and counted by every
:first-child and * + * around it.
<:logic :aka="app"
:services=${[]}
:span=${24}
:_healthy=${services.filter(s => s.state === 'ok').length} />
<p>${app._healthy} of ${app.services.length} healthy</p>It is a scope like any other — named with :aka, read as app.something,
reactive, and bracketed by :did-init / :will-dispose — and it leaves
nothing in the served page.
The name is optional. Values on an unnamed one are reachable from nowhere, which is the point when what it declares is behaviour rather than data:
<!-- a timer, and the value it writes; nothing needs to refer to this -->
<:logic :_timer=${null}
:did-init=${() => _timer = setInterval(tick, 1000)}
:will-dispose=${() => clearInterval(_timer)} />It takes a condition, and the condition is its lifetime. :if,
:else-if, :else and :for-data are what decide whether the scope exists
at all — so :did-init runs when the condition becomes true and
:will-dispose when it stops being, once per lifetime as always, with the
lifetimes now able to repeat:
<!-- listening only while dragging, and nowhere else -->
<:logic :if=${dragging}
:_move=${(e) => track(e)}
:did-init=${() => window.addEventListener('pointermove', _move)}
:will-dispose=${() => window.removeEventListener('pointermove', _move)} />A named one that may come and go is a name that may not answer, so every
reference to it needs ?. — ${app?.foo} — and the compiler says so rather
than letting the read be evaluated once against a name that was not there. See
a name inside a region is read with ?..
What it refuses, because there is no element:
:class-, :style-, :on-, plain attributes |
nothing to apply them to |
:for-each, :slot |
nothing to replicate or slot |
| content of any kind | it holds values, not markup |
:for-each stays refused where the conditionals are allowed, and the
difference is the point: the objection there was never lifetime but arity.
A name that means as many scopes as there are items is not fixed by knowing
when each of them ends.
And where it refuses to go: inside a :for-each, a :for-data, an :if, a
<:define>, or a custom tag's content. Each of those turns a declaration
that reads as one-per-page into one per item, one per instance, or one that
comes and goes — a timer started per row is not something to discover at
runtime. A condition written on the <:logic> is the supported way to
have one that comes and goes, and says so where a reader is looking.
The same idea as <:logic>, one level up: that one is a scope with no
element, this is a tag whose instances are.
<:define tag="std-data:logic" ::url="" ::data=${null} … />
<std-data :aka="rows" ::url="/api/rows" />
<p>${rows.data?.length ?? 0} rows</p>A component that is a source rather than a sight — a datasource, a router,
a media query, a socket — otherwise pays for an element per usage and an
attribute to hide it. This is std-data's own definition, and what it used
to be was tag="std-data:span" hidden.
It takes the same refusals as <:logic>: nothing that needs an element to
apply to, and no content. Its instances take a condition the same way, and
mean the same thing by it — <std-data :if=${open} /> opens its socket when
the panel opens and closes it when the panel closes. It does not take <:logic>'s placement
rules — a <:logic> is a singleton declaration, so it is refused where it
would silently become many, while an instance is written deliberately and
<std-data :for-each=${urls} /> means exactly what it says.
The base tag is spelled out rather than left off. tag="my-panel" with no
base at all would read as this, and it is much more often a typo — so it
stays the error it has always been.
Built, apart from :prop- and static plain attributes, which are refused
for reasons given below. See
conditional scopes for the whole design.
A <:logic> has no element and wants none. A mode has none of its own and
borrows the nearest one above it, which is what lets it carry the families
that need an element — and take them back when its condition goes false:
<div class="card">
<:mode :if=${dragging} :on-pointermove=${(e) => track(e)} />
…the card, which never re-renders…
</div>The element stays. That is the whole difference from :if on the element,
which takes the markup away and loses focus, scroll position and anything else
the DOM was holding — and from a handler bound once and guarded from inside,
which goes on firing for every pointermove in order to decide it has nothing
to do.
A mode is a scope, so it holds values of its own, and they last exactly as long as the modality does:
<:mode :if=${editing} :_from=${null} :on-pointerup=${() => commit(_from)} />_from belongs to the edit rather than to the card, and it is gone when the
edit is. That is the argument for the tag more than the listener is: without
it, a modality's state lives on the element and has to be cleared by hand.
Its children are built and destroyed, not parked. That is the one place a
mode departs from the region machinery rather than reusing it: every region
here preserves — :if moves markup aside so a hide keeps focus, a scroll
offset, a playing video — and a modality wants the opposite, so the next one
starts clean:
<div class="panel">
<:mode :if=${editing} :_draft=${text}>
<button :on-click=${() => save(_draft)}>Save</button>
<button :on-click=${() => editing = false}>Cancel</button>
</:mode>
<p>${text}</p>
</div>The buttons appear where the tag is written, and _draft starts from text
every time the edit begins rather than resuming the last one.
Its classes are its own. A mode's class set starts EMPTY rather than from what the element is already wearing, so it can neither claim the element's own classes nor lose them:
<div class="card" :class-selected=${chosen}>
<:mode :if=${dragging} :class-dragging />
</div>dragging arrives and leaves with the modality; card and selected are the
element's own throughout, and go on changing while the mode is applied.
An attribute has one owner at a time, and while a mode is on, the owner is the mode:
<div title=${label}>
<:mode :if=${dragging} :attr-aria-grabbed=${true} title=${"Drop me somewhere"} />
</div>Nothing is remembered and nothing is restored from a snapshot. What an
element's title is, is whatever the innermost live declaration says — and the
one underneath was live the whole time the mode was over it, evaluating as its
own dependencies changed, simply not the one writing. So handing back is asking
it to say again. Where nobody underneath declares it, the attribute existed
only because the mode did, and goes with it.
Two modes on one element declaring the same attribute at the same rank is a
compile error: a class is a set and two modes adding one are no conflict, but
an attribute is one answer to one question. :priority is what settles it —
higher owns the attribute while both are on, and hands it back down the stack
rather than to the element when it leaves:
<div title=${label}>
<:mode :if=${editing} title=${"Editing"} />
<:mode :if=${dragging} :priority=${1} title=${"Drop me somewhere"} />
</div>Absent is the rank every mode shares, and it has to be a number written there: one worked out while the page runs could tie, and the error this exists to give would arrive as a silent last-write-wins instead.
A style property works the same way and for the same reason — it is one
answer to one question, so it has an owner, and :style-color on a mode
overrides the element's while the modality is on and hands it back when it
goes.
What it takes: :on-, :class-, :style-, :attr-, plain attributes written
as expressions, children, values of its own, the lifecycle callbacks, a
condition, :priority, and :aka. What it refuses:
:for-each, :for-as, :for-key |
one delta on one element — nothing to replicate |
:slot |
no markup to put in a slot |
:else and :else-if are refused with children, since a branch chain is
resolved by position among siblings and a mode's condition becomes an arity.
| :prop- | a DOM property is state on the element itself, so there is nothing underneath to hand it back to |
| a static plain attribute | a mode has no markup of its own for one to be written in — title=${…} sets it on the element |
A mode's classes and styles both start from nothing rather than from what the element is already wearing, which is what keeps the two apart: a mode can neither claim the element's own nor lose them.
The part after the colon in tag="x-y:button" is the element the
definition becomes, and it has to be one HTML already has — or logic,
for a definition that becomes no element at all (above). A definition
cannot be based on another definition:
<:define tag="my-box:div" class="box"><:slot /></:define>
<!-- compile error: <my-box> is itself a definition -->
<:define tag="my-card:my-box">…</:define>Composing is the ordinary case and is unaffected — a definition's body may use any other definition, including its own base-to-be:
<:define tag="my-card:section">
<my-box><p>the body</p></my-box>
</:define>The difference is that the second one wraps rather than specializes, so
<my-card> is a <section> containing a <div class="box"> rather than
a <div class="box"> with different defaults. Where that extra element
matters — a position: sticky child can only stick within its parent's
box — the region has to stay where it is.
A usage's content goes to one place, so a name may be slotted once per
definition. A second <:slot> of the same name could never be filled — it
would render whatever it holds itself, and never the caller's markup — so it
is a compile error rather than a silent first-wins.
It is worth knowing because of the shape that invites it. A component that renders one of two ways wants the caller's content in whichever is showing, and a slot in each branch is the obvious way to ask:
<:define tag="my-box:div">
<div class="wide" :if=${wide}><:slot /></div> <!-- error: two unnamed slots -->
<div class="narrow" :else><:slot /></div>
</:define>Give each branch a slot of its own instead, and the component adapts — each branch takes its own markup from the call site, and switching swaps the markup along with the wrapper:
<:define tag="my-box:div">
<div class="wide" :if=${wide}><:slot name="wide" /></div>
<div class="narrow" :else><:slot name="narrow" /></div>
</:define>
<my-box><b :slot="wide">…</b><i :slot="narrow">…</i></my-box>Where the two branches want the same markup, the way to have it is one slot and a wrapper whose class is conditional, rather than two branches — which is what the Bootstrap kit's toast does between its two layouts.
Both name something while the page is being compiled — a scope's name is
resolved by the compiler, and which slot content fills is decided as the
tree is assembled — so neither has anything to evaluate an expression
against. :aka=${x} and :slot=${x} are compile errors.
They are also the only two system attributes spelled as ordinary
identifiers rather than reserved words, which is a deliberate
trade: no reserved word reads as well as either. The price is that a page
cannot declare values named aka or slot, and the error above is what
keeps that price visible rather than silent.
A <style> written as a direct child of a <:define> is that component's,
and nothing has to say so:
<:define tag="x-card:div" class="card">
<style>.card { border: 1px solid }</style>
<:slot />
</:define>It is served once, immediately before the definition, however many
instances the page has — and it is dropped along with the definition when
no page writes <x-card>. Ownership here is a matter of where the
stylesheet is, not of what anyone claims about it, so there is no way to
state it wrongly.
Where it lands is part of the promise: just before the definition, not
appended to the end of <head>. Imported fragments therefore cascade in
the order they were imported, and a page's own rules — written later —
still win an equal-specificity tie against a component's.
Two cases are deliberately left where they were written. A <style> that
interpolates a value renders once per instance, each with its own text, so
there is no single copy to lift out. And one nested deeper — inside an
:if or a :for-each — is conditional markup, which is the
author having already answered this question differently.
A definition in <body> cannot carry one. Lifted out it would be invalid
markup where it stands and would land somewhere else if moved, so it is
refused rather than guessed at; put the definition in <head>, or in a
file the page imports.
Class names are global: nothing here is rewritten or hashed, so a page is
free to apply .card by hand. If it does, and it never writes <x-card>,
the definition is dropped and the rules go with it — and the compiler says
so, naming the classes that lost them:
warning: <x-card> is never used, so its <style> went with it -- but "card"
is still applied by markup that stayed, which now renders unstyled.
Write <x-card>, or move those rules out of the definition
It is a warning rather than an error because the fix is a judgement: write
the tag, or move those rules back to the page. And it is reported only when
it has actually happened — a page that wears .card and writes
<x-card> has lost nothing and hears nothing.
An unused <:define> is dropped, but a <style> next to it is not — and
should not be, since a stylesheet beside some definitions is not
necessarily their stylesheet. :when-used is how an asset says it is:
<lib>
<style :when-used="x-chart">.x-chart { … }</style>
<:define tag="x-chart:div">…</:define>
</lib>A page that never writes <x-chart> gets neither the definition nor its
CSS. One that does gets both. Naming more than one tag keeps the element
while any of them survives, which is what a stylesheet shared by a family
of components wants.
This is decided when the page is built, so unlike :if it costs
nothing at runtime — the element is there or it is not, and the attribute
never reaches the browser. It gives the element no scope either.
A name no <:define> declares is a compile error. That is the point: a
renamed component would otherwise leave its stylesheet waiting on a name
nothing will ever use, and every page would silently lose the styling.
as names an element to build and puts the file's text inside it, untouched:
that is what an inlined <style>, <script> or svg needs, where an escaped
< would be a syntax error rather than a character.
A file being shown needs the opposite, and escaping is how it says so:
<pre><:include src="/examples/counter.html" as="code" escaping /></pre>The file arrives as text — <html> reaches the browser as <html> and
is read rather than parsed — so a sample and the page it runs in can be the
same file, and cannot drift apart. The flag is written bare or as
escaping="true" / escaping="false"; anything else is refused, and it needs
an as to apply to.
An included or imported file is unwrapped: its root element disappears and
its children are spliced in. So a fragment needs exactly one root element
holding everything, conventionally <lib>:
<lib>
<:define tag="app-panel:div">…</:define>
<:define tag="app-chart:div">…</:define>
</lib>Without it the parser supplies the missing structure the way it does for any
loose markup — which means an implicit <body>, and splicing that into a
page that already has one is an error rather than a merge.
The root element's own attributes are copied onto whatever contains the
<:import> or <:include> — unless that element already declares them.
So a fragment states its defaults on its root, and the page overrides them
where it brings the fragment in:
<!-- app/sources.htm -->
<lib :apiBase="/api">
<app-data :aka="people" :url=${apiBase + '/people'} />
</lib><body :apiBase="https://staging.example.test/api">
<:include src="/app/sources.htm" />This is how the Bootstrap kit's URL and theme tokens work, and it is not something kits get and applications don't: it is the same mechanism wherever a fragment is brought in, and worth reaching for whenever a fragment has a setting its callers might want to move.
Note which element receives them — the one the directive sits in. An
<:import> is only allowed directly in <head>, so its root attributes land
on <head>; an <:include> written in <body> puts them on <body>.
<html>, <head> and <body> always have scopes of their own, named
page, head and body, so shared state has an obvious home that any
descendant can read.
Available on every scope; not declared, and reserved from user code.
| Name | Meaning |
|---|---|
$id |
This scope's identifier, unique in the page. For building HTML ids. |
$parent |
The enclosing scope — where this markup was WRITTEN. |
$host |
The custom-tag instance this markup ended up INSIDE, or nothing outside any. |
$outer("my-tag") |
The nearest enclosing instance of that tag, or nothing. Excludes this scope. |
$value("key") |
Looks a value up by key. |
$set("key", v) |
Assigns to a value by name, and answers whether it landed. For writing where = cannot go — see below. |
$dom |
This scope's own element, or nothing if it has none. Browser-only. |
$host is the instance immediately enclosing this markup; $outer is the
nearest one of a named tag, however far up it is:
<:define tag="my-level:div" ::depth=${($outer('my-level')?.depth ?? -1) + 1}>
<:slot />
</:define>Each instance asks the nearest one above it and adds one, so nesting composes without any level being told its own ancestry.
A walk rather than a parent, because the enclosing instance is reliably an
ancestor and never reliably the parent: a region, a :for-each or a <div>
carrying a value each add a scope in between. It excludes itself, or the
default above would be defined in terms of the instance it is defaulting.
It answers nothing when there is no such tag above, which is a component
standing on its own rather than a fault.
Both answer from where the expression is evaluated, which for most markup
is where it sits — but a component's arguments and slotted text are
evaluated at the call site, by the rule that makes ::title=${title} mean
"the title from out here" rather than the instance's own. Structure follows
names there, since one expression runs against one scope:
<my-box>
<b :x=${$outer('my-box')}>…</b> <!-- finds it: an element's own value -->
<other-tag ::what=${$outer('my-box')}/> <!-- nothing: an argument, evaluated out there -->
${$outer('my-box')} <!-- nothing: slotted text, likewise -->
</my-box>Put it on an element rather than in an argument, and it reaches. The same is
true of $host, which has always worked this way — this is a property of
where a value lives, not of these two names.
The tag is written out, and has to be. It resolves when the scope links, so
what it finds is an ordinary dependency and a reader re-runs when that scope
moves — a tag worked out while the page runs could not be depended on, so it
would answer once and never again. $outer(someName) is refused for that
reason rather than silently doing the weaker thing.
The same thing until slotting separates them, and then they answer the two questions markup slotted into a component actually has: what did I come from, and what am I part of.
<:define tag="my-item:li"
// the list I was slotted into, whichever one that is
:_group=${$host ? $host.$id : null}>$parent is lexical, so for slotted markup it is the call site — which is
what keeps a definition from reading whatever its caller happened to
declare. $host is structural: the nearest enclosing instance, whether or
not the markup was written there. It is what a component reads to coordinate
with the one containing it, and reading it takes writing it, so the
isolation still holds by default.
A component outside any instance has no $host at all, which is what lets
it stand on its own rather than requiring a container.
Which instance $host lands on is a property of each usage, so the
compiler doesn't resolve through it: $host.x records that it read x —
so a change still propagates — and doesn't check that x is there, in the
same way $dom.whatever isn't checked.
A name an expression uses is looked up on the scope chain, and the JS standard library is its last link. So an expression is plain JavaScript in the way it looks:
<p :n=${Math.max(1, 2)}>${JSON.stringify({ n })}</p>Available: Array, BigInt, Boolean, Date, Error, Infinity, Intl,
JSON, Map, Math, NaN, Number, Object, Promise, RegExp, Set,
String, Symbol, WeakMap, WeakSet, clearInterval, clearTimeout,
console, decodeURI, decodeURIComponent, encodeURI,
encodeURIComponent, fetch, globalThis, isFinite, isNaN,
parseFloat, parseInt, queueMicrotask, setInterval, setTimeout,
structuredClone, URL, undefined, $origin and $url.
The timers, fetch and URL look like browser things and are on the list
for the same reason as everything else: Node has them too, and they mean the
same thing there. That is the whole test. A name available in only one of the
two environments would make a page that worked in the browser and failed on
the server, or the reverse, and it would fail at the point of use rather than
anywhere a reader would think to look.
The odd ones out, and the only names here that aren't JavaScript's: the
page's own address. $origin is https://example.test; $url is the whole
of it, as a URL. The server takes them from the request, the browser from
location, and they mean the same thing in both — which is the bar
everything on this list has to clear.
It exists because the server has no page to be relative to. A :server-
value fetching /data.json is not asking for a different address, it is not
asking for an address at all, so something has to say where the page is:
<html :server-rows=${fetch($origin + '/data.json').then(r => r.json())}>$url is the same fact, unabridged, for a page that wants the part of the
address the visitor asked for:
<a class="here" :class-active=${$url.pathname === '/about'}>About</a>
<p>Searching for ${$url.searchParams.get('q')}</p>It is a URL because URL was already a name expressions could use, so
searchParams comes with it and there is no new shape to learn. $origin
is $url.origin and stays a name of its own: a page that wants the origin
should not have to reach through an address to say so.
Both are spelled with a $ because they are the runtime's rather than
JavaScript's, and that also means a page cannot declare a value over them —
$ is reserved in a declared name. Where there is no server, both are
undefined — except in a build told what to be relative to, since
markout build --origin <url> is exactly the answer to "where is this page
going to be", and there $url is that origin with the page's own path.
$url is also the one name here that changes while the page is up.
Everything else in this list is fixed for the life of a render, which is
why reading one is not a dependency — there would be nothing to wake. An
address is not fixed: a navigation that keeps the document moves it, and
every expression that read it re-runs.
It is read-only, in whole and in part. $url is where the page is, so
a page assigning it would be claiming to have arrived somewhere it has not,
and both $url = '/about' and $url.pathname = '/about' are refused with
a message rather than quietly doing nothing.
Navigating is a side effect with a lifetime — a history entry to decide, scroll to restore — so it belongs to a component rather than to the language. It is written where it happens, through the name this list offers for exactly that:
<button :on-click=${() => globalThis.location.assign('/about')}>About</button>$url follows on its own once the address changes — a traversal, a
fragment link, or a navigation a router kept in the document. The one
change it cannot see is history.pushState, which announces itself to
nobody by design, so code that calls it says so.
Nothing else about the request is offered, and that is deliberate. Headers, cookies and the method have no browser counterpart, so a page reading one would render something it cannot hydrate to — and would publish a session while doing it.
Because it is the last link, a declared value of the same name shadows it —
:Math=${...} means yours from there down.
Apart from $url, a global is not a dependency: it can't change, so
nothing re-evaluates because of one.
document, localStorage, and whatever libraries the
page loads exist in the browser and not on the server. Naming one directly
would give a page an expression that works in one half of an isomorphic
render and throws in the other, with nothing in the source to say which.
They are reached through globalThis, which is on the list, so that the
environment a line depends on is visible in the line itself:
<div :open=${false}
:handle-open=${(v) => globalThis.bootstrap.Modal
.getOrCreateInstance($dom)[v ? 'show' : 'hide']()}>The same rule as $dom, which is browser-only for the same reason: anything
that has to show up in the served markup belongs in a value, and anything
that needs a browser belongs in a handler.
${...}is the only expression syntax, and anything containing one is reactive —:names things HTML has no name for, rather than marking reactivity by itself.- An expression resolves where it was WRITTEN. A definition's body sees the definition's scope; a usage site's attributes and slotted content see what that site declares, and then the call site around it.
- The compiler is responsible for qualification and dependency extraction.
- The runtime executes the generated graph; it does not discover dependencies on its own.