Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/async-attr-method-shorthand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"htmljs-parser": minor
---

Support an `async` keyword before shorthand methods, eg `<button async onClick() {}>` and the default attribute form `<foo async (event) {}>`. `onAttrMethod` reports these with `async: true` and a range starting at the keyword. A shorthand method can no longer be named `async`, but an `async` that is not followed by a shorthand method is still an ordinary attribute, so `<script async src="x">` is unchanged.
8 changes: 8 additions & 0 deletions agent-feedback/bugs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ Out-of-scope defects noticed while working on something else. Format and rules:
`src/states/PARSED_TEXT_CONTENT.ts` › `PARSED_TEXT_CONTENT` | 2026-07-30 | impact:med | effort:low

Inside a `TagType.text` body the only backslash handling is `STATE.checkForPlaceholder`, which declines unless the run of backslashes leads to `${`, so in `PARSED_TEXT_CONTENT.parse` a `\` falls through to the eager text run and the `"`/`'` after it still enters `STATE.PARSED_STRING`; `src/states/PARSED_STRING.ts` › `PARSED_STRING` has the mirror hole, where the quote of `\"` matches `str.quoteCharCode` and closes the string. An odd number of escaped quotes therefore runs to EOF and `PARSED_STRING.parse` emits `INVALID_TEMPLATE_STRING` "EOF reached while parsing string expression" — marko, which maps `<style>`/`<script>` to `TagType.text`, surfaces it as a code frame on the line _after_ the tag. Escaped quotes are legal in CSS selectors and `content:` strings, so `<style>.a\" { color: red }</style>` and `content: 'it\'s'` are both unparseable today; `src/__tests__/fixtures/parsed-text-style-tag` only passes because its escaped quotes happen to come in pairs, and `\2014` parses fine, so the defect is quote-specific rather than backslash-general. The fix is symmetric in the two files: when `checkForPlaceholder` declines a `CODE.BACK_SLASH` and the next char is a quote (the active `quoteCharCode` in `PARSED_STRING`), consume both chars as text instead of letting the quote change state. Re-verify with `node --input-type=module -e 'import{createParser,TagType}from"./src/index.ts";const p=createParser({onError:e=>console.log("ERR",e.message),onText:r=>console.log("text",JSON.stringify(p.read(r))),onOpenTagName:()=>TagType.text});p.parse("<style>.a\\\" { color: red }</style>")'` — it prints `ERR EOF reached while parsing string expression` today and should print a single `text` range; lock it in with a new `src/__tests__/fixtures/` dir plus `pnpm test:update`.

## Emit the trailing attribute name when EOF ends an HTML mode open tag

`src/states/EXPRESSION.ts` › `EXPRESSION` | 2026-08-11 | impact:med | effort:med

An attribute name is only emitted from `ATTRIBUTE.return`, which requires its `STATE.EXPRESSION` child to terminate, so in HTML mode a name that runs into EOF is never reported: `<div foo` emits only the `MALFORMED_OPEN_TAG` error, while `<div foo bar` emits `attrName "foo"` and drops `bar`. Concise mode is already correct — the EOF branch of `EXPRESSION.parse` calls `exitState()` when `this.isConcise`, so `div foo` does emit `attrName "foo"` — so this is the HTML-only half of that branch, where the `!attr.spread && !attr.name` case returns `emitError` instead. It matters because that is the state a document is in while an attribute is being typed, leaving `packages/language-tools` in marko-js/language-server with no name range to anchor completions or hover to.

Replacing that `emitError` with `this.exitState()` (letting `ATTRIBUTE.parse`'s own EOF branch report) does fix it, but it is not a self-contained change and wants its own PR: it breaks two fixtures whose behavior a maintainer should sign off on. `src/__tests__/fixtures/eof-attr-name` (`<a><b selected`) gains `attrName "selected"` and its error text shifts from "EOF reached while parsing attribute name for the \"b\" tag" to "EOF reached while parsing attribute \"selected\" for the \"b\" tag" — an improvement. But `src/__tests__/fixtures/attr-eof-default-value` (`<div =foo`) also changes: it starts emitting `attrValue "=foo"` and its error coarsens to "EOF reached while parsing open tag", so the fix reaches value parsing and loses a specific diagnostic, not just names. Re-verify the defect with `node --input-type=module -e 'import{createParser}from"./src/index.ts";const p=createParser({onAttrName:r=>console.log("name",JSON.stringify(p.read(r))),onError:e=>console.log("ERR",e.message)});p.parse("<div foo")'` — it prints only `ERR` today and should also print `name "foo"`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
1╭─ <script async src="x"/>
│ ││ │ │ ││ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ │╰─ attrValue.value "\"x\""
│ ││ │ │ ╰─ attrValue "=\"x\""
│ ││ │ ╰─ attrName "src"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "script"
╰─ ╰─ openTagStart
2├─
3╭─ <div async/>
│ ││ │ ╰─ openTagEnd:selfClosed "/>"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
4├─
5╭─ <div async>hi</div>
│ ││ │ ││ │ │ ╰─ closeTagEnd(div)
│ ││ │ ││ │ ╰─ closeTagName "div"
│ ││ │ ││ ╰─ closeTagStart "</"
│ ││ │ │╰─ text "hi"
│ ││ │ ╰─ openTagEnd
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
6├─
7╭─ <div async=true/>
│ ││ │ ││ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │╰─ attrValue.value "true"
│ ││ │ ╰─ attrValue "=true"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
8├─
9╭─ <div async foo bar/>
│ ││ │ │ │ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ ╰─ attrName "bar"
│ ││ │ ╰─ attrName "foo"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
10├─
11╭─ <div async foo(1)/>
│ ││ │ │ ││ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ │╰─ attrArgs.value
│ ││ │ │ ╰─ attrArgs "(1)"
│ ││ │ ╰─ attrName "foo"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
12├─
13╭─ <div async foo=1/>
│ ││ │ │ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ │╰─ attrValue.value
│ ││ │ │ ╰─ attrValue "=1"
│ ││ │ ╰─ attrName "foo"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
14├─
15╭─ <div async ...spread/>
│ ││ │ │ │ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ ╰─ attrSpread.value "spread"
│ ││ │ ╰─ attrSpread "...spread"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
16├─
17╭─ <div async:mod onClick() {}/>
│ ││ │ │ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ ││ │╰─ attrMethod.body.value
│ ││ │ │ ││ ╰─ attrMethod.body "{}"
│ ││ │ │ │╰─ attrMethod.params.value
│ ││ │ │ ├─ attrMethod.params "()"
│ ││ │ │ ╰─ attrMethod "() {}"
│ ││ │ ╰─ attrName "onClick"
│ ││ ╰─ attrName "async:mod"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
18├─
19╭─ <div asyncfoo onClick() {}/>
│ ││ │ │ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ ││ │╰─ attrMethod.body.value
│ ││ │ │ ││ ╰─ attrMethod.body "{}"
│ ││ │ │ │╰─ attrMethod.params.value
│ ││ │ │ ├─ attrMethod.params "()"
│ ││ │ │ ╰─ attrMethod "() {}"
│ ││ │ ╰─ attrName "onClick"
│ ││ ╰─ attrName "asyncfoo"
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
20├─
21╭─ <foo async(1)/>
│ ││ │ ││ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │╰─ attrArgs.value
│ ││ │ ╰─ attrArgs "(1)"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
22├─
23╭─ <foo async (1)/>
│ ││ │ ││ ╰─ openTagEnd:selfClosed "/>"
│ ││ │ │╰─ attrArgs.value
│ ││ │ ╰─ attrArgs "(1)"
│ ││ ╰─ attrName "async"
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
24├─
25╭─ <foo async<T> />
│ ││ │ ╰─ error(INVALID_ATTR_TYPE_PARAMS:Attribute cannot contain type parameters unless it is a shorthand method)
│ ││ ╰─ attrName "async"
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
26├─
27├─ div [
28├─ async
29├─ onClick() {}
30├─ ]
31╰─
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script async src="x"/>

<div async/>

<div async>hi</div>

<div async=true/>

<div async foo bar/>

<div async foo(1)/>

<div async foo=1/>

<div async ...spread/>

<div async:mod onClick() {}/>

<div asyncfoo onClick() {}/>

<foo async(1)/>

<foo async (1)/>

<foo async<T> />

div [
async
onClick() {}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
1╭─ <button async onClick() {}/>
│ ││ │ │ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ │ ││ │╰─ attrMethod.body.value
│ ││ │ │ ││ ╰─ attrMethod.body "{}"
│ ││ │ │ │╰─ attrMethod.params.value
│ ││ │ │ ╰─ attrMethod.params "()"
│ ││ │ ╰─ attrName "onClick"
│ ││ ╰─ attrMethod:async "async onClick() {}"
│ │╰─ tagName "button"
╰─ ╰─ openTagStart
2├─
3╭─ <button async onClick(event) {
│ ││ │ │ ││ ╰─ attrMethod.body "{\n await save(event);\n}"
│ ││ │ │ │╰─ attrMethod.params.value "event"
│ ││ │ │ ╰─ attrMethod.params "(event)"
│ ││ │ ╰─ attrName "onClick"
│ ││ ╰─ attrMethod:async "async onClick(event) {\n await save(event);\n}"
│ │╰─ tagName "button"
╰─ ╰─ openTagStart
4╭─ await save(event);
╰─ ╰─ attrMethod.body.value "\n await save(event);\n"
5╭─ }/>
╰─ ╰─ openTagEnd:selfClosed "/>"
6├─
7╭─ <button async onClick<T>(event: T) {
│ ││ │ │ ││ ││ ╰─ attrMethod.body "{\n await save(event);\n}"
│ ││ │ │ ││ │╰─ attrMethod.params.value "event: T"
│ ││ │ │ ││ ╰─ attrMethod.params "(event: T)"
│ ││ │ │ │╰─ attrMethod.typeParams.value
│ ││ │ │ ╰─ attrMethod.typeParams "<T>"
│ ││ │ ╰─ attrName "onClick"
│ ││ ╰─ attrMethod:async "async onClick<T>(event: T) {\n await save(event);\n}"
│ │╰─ tagName "button"
╰─ ╰─ openTagStart
8╭─ await save(event);
╰─ ╰─ attrMethod.body.value "\n await save(event);\n"
9╭─ }/>
╰─ ╰─ openTagEnd:selfClosed "/>"
10├─
11╭─ <button
│ │╰─ tagName "button"
╰─ ╰─ openTagStart
12╭─ async
╰─ ╰─ attrMethod:async "async\n onClick() {}"
13╭─ onClick() {}
│ │ ││ │╰─ attrMethod.body.value
│ │ ││ ╰─ attrMethod.body "{}"
│ │ │╰─ attrMethod.params.value
│ │ ╰─ attrMethod.params "()"
╰─ ╰─ attrName "onClick"
14╭─ />
╰─ ╰─ openTagEnd:selfClosed "/>"
15├─
16╭─ button async onClick() {}
│ │ │ │ ││ │╰─ attrMethod.body.value
│ │ │ │ ││ ╰─ attrMethod.body "{}"
│ │ │ │ │╰─ attrMethod.params.value
│ │ │ │ ╰─ attrMethod.params "()"
│ │ │ ╰─ attrName "onClick"
│ │ ╰─ attrMethod:async "async onClick() {}"
╰─ ╰─ tagName "button"
17╭─
╰─ ╰─ openTagEnd
18╭─ button async onClick(event) {
│ │ │ │ ││ ╰─ attrMethod.body "{\n await save(event);\n}"
│ │ │ │ │╰─ attrMethod.params.value "event"
│ │ │ │ ╰─ attrMethod.params "(event)"
│ │ │ ╰─ attrName "onClick"
│ │ ╰─ attrMethod:async "async onClick(event) {\n await save(event);\n}"
│ ├─ closeTagEnd(button)
╰─ ╰─ tagName "button"
19╭─ await save(event);
╰─ ╰─ attrMethod.body.value "\n await save(event);\n"
20├─ }
21╭─
╰─ ╰─ openTagEnd
22╭─ <a async b() {} async c() {}/>
│ ││ │ │││ ││ │ │││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ │││ ││ │ │││ │╰─ attrMethod.body.value
│ ││ │ │││ ││ │ │││ ╰─ attrMethod.body "{}"
│ ││ │ │││ ││ │ ││╰─ attrMethod.params.value
│ ││ │ │││ ││ │ │╰─ attrMethod.params "()"
│ ││ │ │││ ││ │ ╰─ attrName
│ ││ │ │││ ││ ╰─ attrMethod:async "async c() {}"
│ ││ │ │││ │╰─ attrMethod.body.value
│ ││ │ │││ ╰─ attrMethod.body "{}"
│ ││ │ ││╰─ attrMethod.params.value
│ ││ │ │╰─ attrMethod.params "()"
│ ││ │ ╰─ attrName
│ ││ ╰─ attrMethod:async "async b() {}"
│ │╰─ tagName
│ ├─ closeTagEnd(button)
╰─ ╰─ openTagStart
23├─
24╭─ <foo async (event) {
│ ││ │ ││ ╰─ attrMethod.body "{\n await save(event);\n}"
│ ││ │ │╰─ attrMethod.params.value "event"
│ ││ │ ╰─ attrMethod.params "(event)"
│ ││ ├─ attrMethod:async "async (event) {\n await save(event);\n}"
│ ││ ╰─ attrName
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
25╭─ await save(event);
╰─ ╰─ attrMethod.body.value "\n await save(event);\n"
26╭─ }/>
╰─ ╰─ openTagEnd:selfClosed "/>"
27├─
28╭─ <foo async(event) {}/>
│ ││ │ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ ││ │╰─ attrMethod.body.value
│ ││ │ ││ ╰─ attrMethod.body "{}"
│ ││ │ │╰─ attrMethod.params.value "event"
│ ││ │ ╰─ attrMethod.params "(event)"
│ ││ ├─ attrMethod:async "async(event) {}"
│ ││ ╰─ attrName
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
29├─
30╭─ <foo async<T>(a: T) {}/>
│ ││ │ ││ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ ││ ││ │╰─ attrMethod.body.value
│ ││ │ ││ ││ ╰─ attrMethod.body "{}"
│ ││ │ ││ │╰─ attrMethod.params.value "a: T"
│ ││ │ ││ ╰─ attrMethod.params "(a: T)"
│ ││ │ │╰─ attrMethod.typeParams.value
│ ││ │ ╰─ attrMethod.typeParams "<T>"
│ ││ ├─ attrMethod:async "async<T>(a: T) {}"
│ ││ ╰─ attrName
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
31├─
32╭─ <foo async <T>(a: T) {}/>
│ ││ │ ││ ││ ││╰─ openTagEnd:selfClosed "/>"
│ ││ │ ││ ││ │╰─ attrMethod.body.value
│ ││ │ ││ ││ ╰─ attrMethod.body "{}"
│ ││ │ ││ │╰─ attrMethod.params.value "a: T"
│ ││ │ ││ ╰─ attrMethod.params "(a: T)"
│ ││ │ │╰─ attrMethod.typeParams.value
│ ││ │ ╰─ attrMethod.typeParams "<T>"
│ ││ ├─ attrMethod:async "async <T>(a: T) {}"
│ ││ ╰─ attrName
│ │╰─ tagName "foo"
╰─ ╰─ openTagStart
33├─
34╭─ foo async (event) {
│ │ │ ││ ╰─ attrMethod.body "{\n await save(event);\n}"
│ │ │ │╰─ attrMethod.params.value "event"
│ │ │ ╰─ attrMethod.params "(event)"
│ │ ├─ attrMethod:async "async (event) {\n await save(event);\n}"
│ │ ╰─ attrName
╰─ ╰─ tagName "foo"
35╭─ await save(event);
╰─ ╰─ attrMethod.body.value "\n await save(event);\n"
36├─ }
37╭─
╰─ ╰─ openTagEnd
38╭─ foo async(event) {}
│ │ │ ││ │╰─ attrMethod.body.value
│ │ │ ││ ╰─ attrMethod.body "{}"
│ │ │ │╰─ attrMethod.params.value "event"
│ │ │ ╰─ attrMethod.params "(event)"
│ │ ├─ attrMethod:async "async(event) {}"
│ │ ╰─ attrName
│ ├─ closeTagEnd(foo)
╰─ ╰─ tagName "foo"
39╭─
│ ├─ openTagEnd
╰─ ╰─ closeTagEnd(foo)
38 changes: 38 additions & 0 deletions src/__tests__/fixtures/attr-method-shorthand-async/input.marko
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<button async onClick() {}/>

<button async onClick(event) {
await save(event);
}/>

<button async onClick<T>(event: T) {
await save(event);
}/>

<button
async
onClick() {}
/>

button async onClick() {}

button async onClick(event) {
await save(event);
}

<a async b() {} async c() {}/>

<foo async (event) {
await save(event);
}/>

<foo async(event) {}/>

<foo async<T>(a: T) {}/>

<foo async <T>(a: T) {}/>

foo async (event) {
await save(event);
}

foo async(event) {}
2 changes: 1 addition & 1 deletion src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ for (const entry of fs.readdirSync(FIXTURES)) {
addValueRange(range.bound ? `attrValue:bound` : `attrValue`, range);
},
onAttrMethod(range) {
addRange("attrMethod", range);
addRange(range.async ? "attrMethod:async" : "attrMethod", range);
if (range.typeParams)
addValueRange("attrMethod.typeParams", range.typeParams);
addValueRange("attrMethod.params", range.params);
Expand Down
Loading