<button @click="x()">hi</button>
/t.html:2:9: Unterminated tag BUTTON
:on-click=${…} and data-x="y" on the same markup are fine.
It is not just @
Every one of these fails identically, and all of them are legal HTML:
| written |
result |
@click="x" |
Unterminated tag BUTTON |
#slot="x" |
Unterminated tag BUTTON |
[prop]="x" |
Unterminated tag BUTTON |
(evt)="x" |
Unterminated tag BUTTON |
%x="x" |
Unterminated tag BUTTON |
^y="x" |
Unterminated tag BUTTON |
~z="x" |
Unterminated tag BUTTON |
The HTML attribute-name production forbids only controls, space, ", ', >, /, = and noncharacters. Everything above is permitted, so this is Markout being stricter than HTML — against the pitch that HTML is the syntax.
It shows up in practice when porting markup from Vue, Alpine or Angular, which is where it was found (building the benchmark ports, 2026-08-25). The attribute is inert to Markout either way; the page just has to parse.
Where
skipName() in packages/core/src/html/parser.ts is an allowlist: letters, digits, -, _, :, plus ., $, *, +, ! when it is an attribute. Anything else ends the name, so the = that follows is read as stray text and the tag never closes.
Its own doc comment states the policy the fix should follow:
The lexer stays permissive about where they appear — which names are meaningful is the compiler's judgement, not the parser's.
That is exactly right, and @ is where it is not being followed. The natural fix is to invert the test — accept anything the spec does not forbid — rather than extend the allowlist one character at a time.
The diagnostic is wrong regardless
Unterminated tag BUTTON names the tag and points at column 9, which is the @. Even if some character stays rejected, the message should say the attribute name is what it cannot read. As it stands the error describes a consequence several tokens downstream of the cause, and the author is left looking for a missing >.
Care needed
+, ! and = interact: class+= and class!= are composite-attribute operators, so whatever replaces the allowlist has to keep = terminating a name while +/! immediately before it do not. Worth a test per operator alongside the new characters.
:on-click=${…}anddata-x="y"on the same markup are fine.It is not just
@Every one of these fails identically, and all of them are legal HTML:
@click="x"Unterminated tag BUTTON#slot="x"Unterminated tag BUTTON[prop]="x"Unterminated tag BUTTON(evt)="x"Unterminated tag BUTTON%x="x"Unterminated tag BUTTON^y="x"Unterminated tag BUTTON~z="x"Unterminated tag BUTTONThe HTML attribute-name production forbids only controls, space,
",',>,/,=and noncharacters. Everything above is permitted, so this is Markout being stricter than HTML — against the pitch that HTML is the syntax.It shows up in practice when porting markup from Vue, Alpine or Angular, which is where it was found (building the benchmark ports, 2026-08-25). The attribute is inert to Markout either way; the page just has to parse.
Where
skipName()inpackages/core/src/html/parser.tsis an allowlist: letters, digits,-,_,:, plus.,$,*,+,!when it is an attribute. Anything else ends the name, so the=that follows is read as stray text and the tag never closes.Its own doc comment states the policy the fix should follow:
That is exactly right, and
@is where it is not being followed. The natural fix is to invert the test — accept anything the spec does not forbid — rather than extend the allowlist one character at a time.The diagnostic is wrong regardless
Unterminated tag BUTTONnames the tag and points at column 9, which is the@. Even if some character stays rejected, the message should say the attribute name is what it cannot read. As it stands the error describes a consequence several tokens downstream of the cause, and the author is left looking for a missing>.Care needed
+,!and=interact:class+=andclass!=are composite-attribute operators, so whatever replaces the allowlist has to keep=terminating a name while+/!immediately before it do not. Worth a test per operator alongside the new characters.