From 6c3597f0566324e04bd935ef48b12cc161705b77 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 23 Sep 2026 11:27:18 -0700 Subject: [PATCH] feat: report comments that stand alone in an open tag `OPEN_TAG.return` ignored every child but an expression, so a `//` or `/* */` comment that did not follow a value was consumed without an event, and consumers dropped it. It is now reported through a new `onOpenTagComment` handler, separate from `onComment` so that consumers which add every comment to the current body are unaffected until they handle it. A comment that follows a value is still part of it, like a comment anywhere else inside JavaScript code. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/open-tag-comments.md | 5 ++ README.md | 13 +++++ ...8-20-report-comments-inside-an-open-tag.md | 12 ----- .../open-tag-comments-concise.expected.txt | 10 ++-- .../open-tag-comments.expected.txt | 47 +++++++++++++++++++ .../fixtures/open-tag-comments/input.marko | 8 ++++ src/__tests__/main.test.ts | 3 ++ src/states/CONCISE_HTML_CONTENT.ts | 18 +------ src/states/HTML_CONTENT.ts | 18 +------ src/states/JS_COMMENT_LINE.ts | 25 +++++++++- src/states/OPEN_TAG.ts | 13 ++++- src/util/constants.ts | 1 + 12 files changed, 124 insertions(+), 49 deletions(-) create mode 100644 .changeset/open-tag-comments.md delete mode 100644 agent-feedback/items/2026-08-20-report-comments-inside-an-open-tag.md create mode 100644 src/__tests__/fixtures/open-tag-comments/__snapshots__/open-tag-comments.expected.txt create mode 100644 src/__tests__/fixtures/open-tag-comments/input.marko diff --git a/.changeset/open-tag-comments.md b/.changeset/open-tag-comments.md new file mode 100644 index 00000000..daf9e42e --- /dev/null +++ b/.changeset/open-tag-comments.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +Add an `onOpenTagComment` handler for a JavaScript comment that stands alone in an open tag, eg `
` or a `// why` line between attributes, which the parser used to consume without an event. A comment that follows a value is still part of that value. It is a separate handler from `onComment`, so a consumer only sees these comments once it handles them. diff --git a/README.md b/README.md index 70b79aae..b31bdec9 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,19 @@ const parser = createParser({ range.value; // Another range that includes only the value itself without the leading ... }, + /** + * Called after parsing a JavaScript comment that stands alone in an open tag. + * A comment that follows a value is part of that value instead. + * + * @example + * 1╭─
+ * │ │ ╰─ openTagComment.value " hi " + * ╰─ ╰─ openTagComment "/* hi */" + */ + onOpenTagComment(range) { + range.value; // Another range that only includes the contents of the comment. + }, + /** * Called once we've completed parsing the open tag. * diff --git a/agent-feedback/items/2026-08-20-report-comments-inside-an-open-tag.md b/agent-feedback/items/2026-08-20-report-comments-inside-an-open-tag.md deleted file mode 100644 index a69ba0e0..00000000 --- a/agent-feedback/items/2026-08-20-report-comments-inside-an-open-tag.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -type: bug -impact: med -effort: low -site: src/states/OPEN_TAG.ts › OPEN_TAG ---- - -# Report the JavaScript comments that sit between attributes in an open tag - -`OPEN_TAG.parse` enters `STATE.JS_COMMENT_LINE`/`STATE.JS_COMMENT_BLOCK` for a `//` or `/*` inside an open tag, but `OPEN_TAG.return` bails on every child that is not `STATE.EXPRESSION`, so the comment is consumed and `onComment` never fires. A comment trailing an attribute value survives because `EXPRESSION` folds it into the value range, which is what `src/__tests__/fixtures/comments-within-open-tag` records; a comment that stands on its own is unreportable, so `
x
` and the `// why` line of a multi-line attribute list emit attribute events only, with no error. Consumers cannot round-trip either one — prettier-plugin-marko drops both from its output — while `OPEN_TAG.parse`'s own `INVALID_HTML_COMMENT` text tells authors to "Use a JavaScript comment (// or /\* \*/) instead", pointing them at the form that disappears. `HTML_CONTENT.return` already emits both shapes with the delimiters stripped from `value`, so the fix is that switch in `OPEN_TAG.return`, with `comments-within-open-tag` and `open-tag-comments-concise` as the snapshots that move. - -Check: `node --input-type=module -e 'import{createParser}from"./src/index.ts";const p=createParser({onComment:r=>console.log("comment",JSON.stringify(p.read(r))),onAttrName:r=>console.log("attrName",JSON.stringify(p.read(r)))});p.parse("
x
")'` prints only `attrName "class"` today; expect a `comment "/* c */"` line before it. diff --git a/src/__tests__/fixtures/open-tag-comments-concise/__snapshots__/open-tag-comments-concise.expected.txt b/src/__tests__/fixtures/open-tag-comments-concise/__snapshots__/open-tag-comments-concise.expected.txt index e8afccdd..a1ccfacb 100644 --- a/src/__tests__/fixtures/open-tag-comments-concise/__snapshots__/open-tag-comments-concise.expected.txt +++ b/src/__tests__/fixtures/open-tag-comments-concise/__snapshots__/open-tag-comments-concise.expected.txt @@ -1,9 +1,13 @@ 1╭─ div /* c */ a=1 - │ │ ││╰─ attrValue.value - │ │ │╰─ attrValue "=1" - │ │ ╰─ attrName + │ │ │ │ ││╰─ attrValue.value + │ │ │ │ │╰─ attrValue "=1" + │ │ │ │ ╰─ attrName + │ │ │ ╰─ openTagComment.value " c " + │ │ ╰─ openTagComment "/* c */" ╰─ ╰─ tagName "div" 2╭─ span // d + │ │ │ ╰─ openTagComment.value " d" + │ │ ╰─ openTagComment "// d" │ ├─ closeTagEnd(div) │ ├─ openTagEnd ╰─ ╰─ tagName "span" diff --git a/src/__tests__/fixtures/open-tag-comments/__snapshots__/open-tag-comments.expected.txt b/src/__tests__/fixtures/open-tag-comments/__snapshots__/open-tag-comments.expected.txt new file mode 100644 index 00000000..f585f2f1 --- /dev/null +++ b/src/__tests__/fixtures/open-tag-comments/__snapshots__/open-tag-comments.expected.txt @@ -0,0 +1,47 @@ +1╭─
x
+ │ ││ │ │ │ ││ │││ │ ╰─ closeTagEnd(div) + │ ││ │ │ │ ││ │││ ╰─ closeTagName "div" + │ ││ │ │ │ ││ ││╰─ closeTagStart " + │ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │ │╰─ attrValue.value "\"box\"" + │ │ ╰─ attrValue "=\"box\"" + ╰─ ╰─ attrName "class" +5╭─ + │ │ ││ ╰─ openTagEnd + │ │ │╰─ attrValue.value "\"text\"" + │ │ ╰─ attrValue "=\"text\"" + ╰─ ╰─ attrName "type" +7╭─ + │ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │ │╰─ attrValue.value "\"home\"" + │ │ ╰─ attrValue "=\"home\"" + ╰─ ╰─ attrName "title" +9╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/open-tag-comments/input.marko b/src/__tests__/fixtures/open-tag-comments/input.marko new file mode 100644 index 00000000..5b95b9d5 --- /dev/null +++ b/src/__tests__/fixtures/open-tag-comments/input.marko @@ -0,0 +1,8 @@ +
x
+
+ + diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 0138e50d..d9708936 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -105,6 +105,9 @@ for (const entry of fs.readdirSync(FIXTURES)) { onComment(range) { addValueRange("comment", range); }, + onOpenTagComment(range) { + addValueRange("openTagComment", range); + }, onOpenTagStart(range) { addRange("openTagStart", range); }, diff --git a/src/states/CONCISE_HTML_CONTENT.ts b/src/states/CONCISE_HTML_CONTENT.ts index 02ac221d..bfaf39d1 100644 --- a/src/states/CONCISE_HTML_CONTENT.ts +++ b/src/states/CONCISE_HTML_CONTENT.ts @@ -160,24 +160,10 @@ export const CONCISE_HTML_CONTENT: StateDefinition = { switch (child.state) { case STATE.JS_COMMENT_LINE: - this.options.onComment?.({ - start: child.start, - end: child.end, - value: { - start: child.start + 2, // strip // - end: child.end, - }, - }); + this.options.onComment?.(STATE.getJSCommentRange(child)); break; case STATE.JS_COMMENT_BLOCK: { - this.options.onComment?.({ - start: child.start, - end: child.end, - value: { - start: child.start + 2, // strip /* - end: child.end - 2, // strip */, - }, - }); + this.options.onComment?.(STATE.getJSCommentRange(child)); if (!this.consumeWhitespaceOnLine(0)) { // Make sure there is only whitespace on the line diff --git a/src/states/HTML_CONTENT.ts b/src/states/HTML_CONTENT.ts index 8eca4723..637d9d60 100644 --- a/src/states/HTML_CONTENT.ts +++ b/src/states/HTML_CONTENT.ts @@ -178,24 +178,10 @@ export const HTML_CONTENT: StateDefinition = { return(child) { switch (child.state) { case STATE.JS_COMMENT_LINE: - this.options.onComment?.({ - start: child.start, - end: child.end, - value: { - start: child.start + 2, // strip // - end: child.end, - }, - }); + this.options.onComment?.(STATE.getJSCommentRange(child)); break; case STATE.JS_COMMENT_BLOCK: { - this.options.onComment?.({ - start: child.start, - end: child.end, - value: { - start: child.start + 2, // strip /* - end: child.end - 2, // strip */, - }, - }); + this.options.onComment?.(STATE.getJSCommentRange(child)); break; } } diff --git a/src/states/JS_COMMENT_LINE.ts b/src/states/JS_COMMENT_LINE.ts index 5a9894ea..93d52460 100644 --- a/src/states/JS_COMMENT_LINE.ts +++ b/src/states/JS_COMMENT_LINE.ts @@ -1,4 +1,9 @@ -import { STATE, type StateDefinition } from "../internal.ts"; +import { + type Meta, + type Ranges, + STATE, + type StateDefinition, +} from "../internal.ts"; import * as CODE from "../util/codes.ts"; import * as TagType from "../util/tag-type.ts"; @@ -47,3 +52,21 @@ export const JS_COMMENT_LINE: StateDefinition = { /* node:coverage ignore next */ // never has child states return() {}, }; + +/** + * The range of a JavaScript line or block comment that a state returned from, + * with the delimiters stripped from its value. + */ +export function getJSCommentRange(comment: Meta): Ranges.Value { + return { + start: comment.start, + end: comment.end, + value: { + start: comment.start + 2, // strip // or /* + end: + comment.state === STATE.JS_COMMENT_BLOCK + ? comment.end - 2 // strip */ + : comment.end, + }, + }; +} diff --git a/src/states/OPEN_TAG.ts b/src/states/OPEN_TAG.ts index 4c70f007..cae5edec 100644 --- a/src/states/OPEN_TAG.ts +++ b/src/states/OPEN_TAG.ts @@ -401,7 +401,18 @@ export const OPEN_TAG: StateDefinition = { }, return(child, tag) { - if (child.state !== STATE.EXPRESSION) return; + switch (child.state) { + case STATE.JS_COMMENT_LINE: + case STATE.JS_COMMENT_BLOCK: + // A separate event, since a consumer that adds every `onComment` to + // the current body would otherwise put these in the tag's body. + this.options.onOpenTagComment?.(STATE.getJSCommentRange(child)); + return; + case STATE.EXPRESSION: + break; + default: + return; + } switch (tag.stage) { case TAG_STAGE.VAR: { diff --git a/src/util/constants.ts b/src/util/constants.ts index e9b7ee3b..0ac1ba95 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -98,6 +98,7 @@ export interface ParserOptions { onAttrValue?(data: Ranges.AttrValue): void; onAttrMethod?(data: Ranges.AttrMethod): void; onAttrSpread?(data: Ranges.Value): void; + onOpenTagComment?(data: Ranges.Value): void; onOpenTagEnd?(data: Ranges.OpenTagEnd): void; onCloseTagStart?(data: Range): void; onCloseTagName?(data: Range): void;