diff --git a/.changeset/close-tag-less-than.md b/.changeset/close-tag-less-than.md new file mode 100644 index 00000000..05dac1a3 --- /dev/null +++ b/.changeset/close-tag-less-than.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": patch +--- + +In HTML mode, a "=` in an unenclosed attribute value is now parsed as a comparison operator (eg `= 10>`), and a whitespace-preceded `>` that looks like a split comparison (eg ` 10>`) now reports an error suggesting parentheses instead of silently ending the tag. diff --git a/agent-feedback/bugs.md b/agent-feedback/bugs.md index b516434e..2b85c2ec 100644 --- a/agent-feedback/bugs.md +++ b/agent-feedback/bugs.md @@ -2,12 +2,6 @@ Out-of-scope defects noticed while working on something else. Format and rules: [README.md](README.md). -## Emit an error when a `>=` truncates an unquoted attribute-value expression - -`src/states/ATTRIBUTE.ts` › `shouldTerminateHtmlAttrValue` | 2026-07-30 | impact:med | effort:low - -`shouldTerminateHtmlAttrValue` terminates on the first unenclosed `CODE.CLOSE_ANGLE_BRACKET` with a single look-behind exception for `=>`, and nothing covers the sibling `>=`, so `= 0/>` tokenizes as `attrValue "input.delta"`, `openTagEnd`, then text `"= 0/>"` and raises no `onError` at all — marko turns that into `const positive = input.delta` plus a literal `= 0/>` text node, a silent miscompile of an ordinary comparison. Unlike a lone `>`, which is genuinely ambiguous with idiomatic HTML (`
text
`), a tag close immediately followed by `=` is almost never intended, so this form is decidable here rather than in a consumer's heuristic. Add the look-ahead to the `CLOSE_ANGLE_BRACKET` case: keep terminating, but when `pos !== this.start && data.charCodeAt(pos + 1) === CODE.EQUAL` record it on the `ExpressionMeta` so `ATTRIBUTE.return`'s `ATTR_STAGE.VALUE` case — which already calls `this.emitError` for `INVALID_ATTRIBUTE_VALUE` — can emit `ErrorCode.INVALID_EXPRESSION`; the emit has to land there because `shouldTerminate` is handed no `Parser`. The one false positive to weigh is `
=1
`, whose text legitimately begins with `=`. 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))),onAttrValue:r=>console.log("value",JSON.stringify(p.read(r.value))),onOpenTagName:()=>TagType.html});p.parse("= 0>yes")'` — it prints `value "input.delta"` and `text "= 0>yes"` with no `ERR` line today, and `pnpm test` must stay green after the change. - ## Treat a backslash-escaped quote as text in parsed-text bodies and parsed strings `src/states/PARSED_TEXT_CONTENT.ts` › `PARSED_TEXT_CONTENT` | 2026-07-30 | impact:med | effort:low diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/__snapshots__/attr-ambiguous-right-angle-bracket-arrow.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/__snapshots__/attr-ambiguous-right-angle-bracket-arrow.expected.txt new file mode 100644 index 00000000..940e513a --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/__snapshots__/attr-ambiguous-right-angle-bracket-arrow.expected.txt @@ -0,0 +1,5 @@ +1╭─ y.map(z => z)>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "x > y.map(z => z)" was intended as a single expression, wrap it in parentheses, eg "=(x > y.map(z => z))". If the tag was instead meant to end at the first ">", leaving "y.map(z => z)>" as body content, remove the whitespace before that ">".) "x > y.map(z => z)" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/input.marko new file mode 100644 index 00000000..f9e35f07 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/input.marko @@ -0,0 +1 @@ + y.map(z => z)>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/__snapshots__/attr-ambiguous-right-angle-bracket-bare-equal.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/__snapshots__/attr-ambiguous-right-angle-bracket-bare-equal.expected.txt new file mode 100644 index 00000000..d66cd8d5 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/__snapshots__/attr-ambiguous-right-angle-bracket-bare-equal.expected.txt @@ -0,0 +1,11 @@ +1╭─ = 10>Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "Hi" + │ ││ ││ ╰─ openTagEnd + │ ││ │╰─ attrValue.value "count" + │ ││ ├─ attrValue "=count" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/input.marko new file mode 100644 index 00000000..e192805f --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/input.marko @@ -0,0 +1 @@ + = 10>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/__snapshots__/attr-ambiguous-right-angle-bracket-eof.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/__snapshots__/attr-ambiguous-right-angle-bracket-eof.expected.txt new file mode 100644 index 00000000..539b5f9a --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/__snapshots__/attr-ambiguous-right-angle-bracket-eof.expected.txt @@ -0,0 +1,9 @@ +1╭─ b + c + │ ││ ││ │╰─ text " b + c" + │ ││ ││ ╰─ openTagEnd + │ ││ │╰─ attrValue.value + │ ││ ├─ attrValue "=a" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + │ ├─ error(MISSING_END_TAG:Missing ending "if" tag) "" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/input.marko new file mode 100644 index 00000000..39161554 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/input.marko @@ -0,0 +1 @@ + b + c \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/__snapshots__/attr-ambiguous-right-angle-bracket-grouped.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/__snapshots__/attr-ambiguous-right-angle-bracket-grouped.expected.txt new file mode 100644 index 00000000..25091377 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/__snapshots__/attr-ambiguous-right-angle-bracket-grouped.expected.txt @@ -0,0 +1,5 @@ +1╭─ (b >= c)>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "a > (b >= c)" was intended as a single expression, wrap it in parentheses, eg "=(a > (b >= c))". If the tag was instead meant to end at the first ">", leaving "(b >= c)>" as body content, remove the whitespace before that ">".) "a > (b >= c)" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/input.marko new file mode 100644 index 00000000..2876c2e3 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/input.marko @@ -0,0 +1 @@ + (b >= c)>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/__snapshots__/attr-ambiguous-right-angle-bracket-index-division.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/__snapshots__/attr-ambiguous-right-angle-bracket-index-division.expected.txt new file mode 100644 index 00000000..102cd022 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/__snapshots__/attr-ambiguous-right-angle-bracket-index-division.expected.txt @@ -0,0 +1,5 @@ +1╭─ b[0] / c>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "a > b[0] / c" was intended as a single expression, wrap it in parentheses, eg "=(a > b[0] / c)". If the tag was instead meant to end at the first ">", leaving "b[0] / c>" as body content, remove the whitespace before that ">".) "a > b[0] / c" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/input.marko new file mode 100644 index 00000000..7a67d015 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/input.marko @@ -0,0 +1 @@ + b[0] / c>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/__snapshots__/attr-ambiguous-right-angle-bracket-logical.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/__snapshots__/attr-ambiguous-right-angle-bracket-logical.expected.txt new file mode 100644 index 00000000..e67510d0 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/__snapshots__/attr-ambiguous-right-angle-bracket-logical.expected.txt @@ -0,0 +1,5 @@ +1╭─ 10 && flag>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "count > 10 && flag" was intended as a single expression, wrap it in parentheses, eg "=(count > 10 && flag)". If the tag was instead meant to end at the first ">", leaving "10 && flag>" as body content, remove the whitespace before that ">".) "count > 10 && flag" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/input.marko new file mode 100644 index 00000000..1b948c52 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/input.marko @@ -0,0 +1 @@ + 10 && flag>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/__snapshots__/attr-ambiguous-right-angle-bracket-name.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/__snapshots__/attr-ambiguous-right-angle-bracket-name.expected.txt new file mode 100644 index 00000000..cf174273 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/__snapshots__/attr-ambiguous-right-angle-bracket-name.expected.txt @@ -0,0 +1,5 @@ +1╭─ 10>Hi + │ ││ ├─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "count > 10" was intended as a single expression, wrap it in parentheses, eg "=(count > 10)". If the tag was instead meant to end at the first ">", leaving "10>" as body content, remove the whitespace before that ">".) "count > 10" + │ ││ ╰─ attrName "count" + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/input.marko new file mode 100644 index 00000000..0e0fb417 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/input.marko @@ -0,0 +1 @@ + 10>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/__snapshots__/attr-ambiguous-right-angle-bracket-nested-gte.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/__snapshots__/attr-ambiguous-right-angle-bracket-nested-gte.expected.txt new file mode 100644 index 00000000..9386d407 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/__snapshots__/attr-ambiguous-right-angle-bracket-nested-gte.expected.txt @@ -0,0 +1,5 @@ +1╭─ b >= c>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "a > b >= c" was intended as a single expression, wrap it in parentheses, eg "=(a > b >= c)". If the tag was instead meant to end at the first ">", leaving "b >= c>" as body content, remove the whitespace before that ">".) "a > b >= c" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/input.marko new file mode 100644 index 00000000..6bf1862d --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/input.marko @@ -0,0 +1 @@ + b >= c>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/__snapshots__/attr-ambiguous-right-angle-bracket-regex.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/__snapshots__/attr-ambiguous-right-angle-bracket-regex.expected.txt new file mode 100644 index 00000000..8f47605f --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/__snapshots__/attr-ambiguous-right-angle-bracket-regex.expected.txt @@ -0,0 +1,11 @@ +1╭─ /b/>Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "Hi" + │ ││ ││ ╰─ openTagEnd + │ ││ │╰─ attrValue.value + │ ││ ├─ attrValue "=a" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/input.marko new file mode 100644 index 00000000..afac5bb9 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/input.marko @@ -0,0 +1 @@ + /b/>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/__snapshots__/attr-ambiguous-right-angle-bracket-self-close.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/__snapshots__/attr-ambiguous-right-angle-bracket-self-close.expected.txt new file mode 100644 index 00000000..4dbbcf54 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/__snapshots__/attr-ambiguous-right-angle-bracket-self-close.expected.txt @@ -0,0 +1,5 @@ +1╭─ 10/> + │ ││ │ ╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "count > 10" was intended as a single expression, wrap it in parentheses, eg "=(count > 10)". If the tag was instead meant to end at the first ">", leaving "10/>" as body content, remove the whitespace before that ">".) "count > 10" + │ ││ ╰─ attrName "value" + │ │╰─ tagName "input" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/input.marko new file mode 100644 index 00000000..50d2a174 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/input.marko @@ -0,0 +1 @@ + 10/> \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/__snapshots__/attr-ambiguous-right-angle-bracket-string.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/__snapshots__/attr-ambiguous-right-angle-bracket-string.expected.txt new file mode 100644 index 00000000..5e510276 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/__snapshots__/attr-ambiguous-right-angle-bracket-string.expected.txt @@ -0,0 +1,11 @@ +1╭─ "b>" >Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "\" >Hi" + │ ││ ││ ╰─ openTagEnd + │ ││ │╰─ attrValue.value + │ ││ ├─ attrValue "=a" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/input.marko new file mode 100644 index 00000000..574f83f3 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/input.marko @@ -0,0 +1 @@ + "b>" >Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/__snapshots__/attr-ambiguous-right-angle-bracket-trailing-space.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/__snapshots__/attr-ambiguous-right-angle-bracket-trailing-space.expected.txt new file mode 100644 index 00000000..2e83ee08 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/__snapshots__/attr-ambiguous-right-angle-bracket-trailing-space.expected.txt @@ -0,0 +1,5 @@ +1╭─ 10 && flag /> + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "count > 10 && flag" was intended as a single expression, wrap it in parentheses, eg "=(count > 10 && flag)". If the tag was instead meant to end at the first ">", leaving "10 && flag />" as body content, remove the whitespace before that ">".) "count > 10 && flag" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/input.marko new file mode 100644 index 00000000..ed781b0f --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/input.marko @@ -0,0 +1 @@ + 10 && flag /> \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/__snapshots__/attr-ambiguous-right-angle-bracket-unbalanced-group.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/__snapshots__/attr-ambiguous-right-angle-bracket-unbalanced-group.expected.txt new file mode 100644 index 00000000..66813675 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/__snapshots__/attr-ambiguous-right-angle-bracket-unbalanced-group.expected.txt @@ -0,0 +1,11 @@ +1╭─
foo(bar>
+ │ ││ │ ││ ││ │ │ ╰─ closeTagEnd(div) + │ ││ │ ││ ││ │ ╰─ closeTagName "div" + │ ││ │ ││ ││ ╰─ closeTagStart "" + │ ││ │ ││ ╰─ openTagEnd + │ ││ │ │╰─ attrValue.value + │ ││ │ ╰─ attrValue "=x" + │ ││ ╰─ attrName "class" + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/input.marko new file mode 100644 index 00000000..ed135453 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/input.marko @@ -0,0 +1 @@ +
foo(bar>
\ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/__snapshots__/attr-ambiguous-right-angle-bracket-unmatched-close.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/__snapshots__/attr-ambiguous-right-angle-bracket-unmatched-close.expected.txt new file mode 100644 index 00000000..4552cf73 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/__snapshots__/attr-ambiguous-right-angle-bracket-unmatched-close.expected.txt @@ -0,0 +1,11 @@ +1╭─
oops)>
+ │ ││ │ ││ ││ │ │ ╰─ closeTagEnd(div) + │ ││ │ ││ ││ │ ╰─ closeTagName "div" + │ ││ │ ││ ││ ╰─ closeTagStart "" + │ ││ │ ││ ╰─ openTagEnd + │ ││ │ │╰─ attrValue.value + │ ││ │ ╰─ attrValue "=x" + │ ││ ╰─ attrName "class" + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/input.marko new file mode 100644 index 00000000..0eba416a --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/input.marko @@ -0,0 +1 @@ +
oops)>
\ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/__snapshots__/attr-ambiguous-right-angle-bracket.expected.txt b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/__snapshots__/attr-ambiguous-right-angle-bracket.expected.txt new file mode 100644 index 00000000..50395e73 --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/__snapshots__/attr-ambiguous-right-angle-bracket.expected.txt @@ -0,0 +1,5 @@ +1╭─ 10>Hi + │ ││ │╰─ error(AMBIGUOUS_ATTRIBUTE_VALUE:Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "count > 10" was intended as a single expression, wrap it in parentheses, eg "=(count > 10)". If the tag was instead meant to end at the first ">", leaving "10>" as body content, remove the whitespace before that ">".) "count > 10" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/input.marko b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/input.marko new file mode 100644 index 00000000..5d26551e --- /dev/null +++ b/src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/input.marko @@ -0,0 +1 @@ + 10>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-close-tag-in-unenclosed-value/__snapshots__/attr-close-tag-in-unenclosed-value.expected.txt b/src/__tests__/fixtures/attr-close-tag-in-unenclosed-value/__snapshots__/attr-close-tag-in-unenclosed-value.expected.txt new file mode 100644 index 00000000..e5e201f0 --- /dev/null +++ b/src/__tests__/fixtures/attr-close-tag-in-unenclosed-value/__snapshots__/attr-close-tag-in-unenclosed-value.expected.txt @@ -0,0 +1,7 @@ +1╭─
+ │ ││ │ ││ ╰─ error(MALFORMED_OPEN_TAG:A close tag was found before the "div" open tag was closed. If the " \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-less-than-regex/__snapshots__/attr-less-than-regex.expected.txt b/src/__tests__/fixtures/attr-less-than-regex/__snapshots__/attr-less-than-regex.expected.txt new file mode 100644 index 00000000..1e58d1e6 --- /dev/null +++ b/src/__tests__/fixtures/attr-less-than-regex/__snapshots__/attr-less-than-regex.expected.txt @@ -0,0 +1,11 @@ +1╭─ Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-less-than/__snapshots__/attr-less-than.expected.txt b/src/__tests__/fixtures/attr-less-than/__snapshots__/attr-less-than.expected.txt new file mode 100644 index 00000000..e27a2d3d --- /dev/null +++ b/src/__tests__/fixtures/attr-less-than/__snapshots__/attr-less-than.expected.txt @@ -0,0 +1,11 @@ +1╭─ Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-operators-newline-after/__snapshots__/attr-operators-newline-after.expected.txt b/src/__tests__/fixtures/attr-operators-newline-after/__snapshots__/attr-operators-newline-after.expected.txt index 7558659a..3245b268 100644 --- a/src/__tests__/fixtures/attr-operators-newline-after/__snapshots__/attr-operators-newline-after.expected.txt +++ b/src/__tests__/fixtures/attr-operators-newline-after/__snapshots__/attr-operators-newline-after.expected.txt @@ -683,17 +683,14 @@ │ │╰─ openTagEnd:selfClosed "/>" ╰─ ╰─ attrName 159╭─ = - │ ││││ │╰─ text "=\ny " - │ ││││ ╰─ openTagEnd - │ │││╰─ attrValue.value - │ ││├─ attrValue "=x" + │ │││╰─ attrValue.value "x >=\ny" + │ ││├─ attrValue "=x >=\ny" │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart -160╭─ y - │ │ │╰─ closeTagEnd(a) - │ │ ╰─ closeTagName - ╰─ ╰─ closeTagStart " + │ │╰─ openTagEnd:selfClosed "/>" + ╰─ ╰─ attrName 161╭─ = -y +y a/> " ╰─ ╰─ attrName 45╭─ = y" + │ ││├─ attrValue "=x\n>= y" │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart -46╭─ >= y - │ ││ │ │╰─ closeTagEnd(a) - │ ││ │ ╰─ closeTagName - │ ││ ╰─ closeTagStart "= y a/> + │ │╰─ openTagEnd:selfClosed "/>" + ╰─ ╰─ attrName 47╭─ = y +>= y a/> =y - │ ││││ ││ │ │╰─ closeTagEnd(a) - │ ││││ ││ │ ╰─ closeTagName - │ ││││ ││ ╰─ closeTagStart "=y a/> + │ ││││ │╰─ openTagEnd:selfClosed "/>" + │ ││││ ╰─ attrName + │ │││╰─ attrValue.value "x >=y" + │ ││├─ attrValue "=x >=y" │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart diff --git a/src/__tests__/fixtures/attr-operators-space-before/input.marko b/src/__tests__/fixtures/attr-operators-space-before/input.marko index 7c1d7a8d..99f50df5 100644 --- a/src/__tests__/fixtures/attr-operators-space-before/input.marko +++ b/src/__tests__/fixtures/attr-operators-space-before/input.marko @@ -63,7 +63,7 @@ a=( x ) {y } a -=y +=y a/> diff --git a/src/__tests__/fixtures/attr-operators-space-between/__snapshots__/attr-operators-space-between.expected.txt b/src/__tests__/fixtures/attr-operators-space-between/__snapshots__/attr-operators-space-between.expected.txt index a3d07cb0..5e40d3a8 100644 --- a/src/__tests__/fixtures/attr-operators-space-between/__snapshots__/attr-operators-space-between.expected.txt +++ b/src/__tests__/fixtures/attr-operators-space-between/__snapshots__/attr-operators-space-between.expected.txt @@ -707,14 +707,11 @@ │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart -90╭─ = y - │ ││││ ││ │ │╰─ closeTagEnd(a) - │ ││││ ││ │ ╰─ closeTagName - │ ││││ ││ ╰─ closeTagStart "= y a/> + │ ││││ │╰─ openTagEnd:selfClosed "/>" + │ ││││ ╰─ attrName + │ │││╰─ attrValue.value "x >= y" + │ ││├─ attrValue "=x >= y" │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart diff --git a/src/__tests__/fixtures/attr-operators-space-between/input.marko b/src/__tests__/fixtures/attr-operators-space-between/input.marko index 7242fec4..09ae6696 100644 --- a/src/__tests__/fixtures/attr-operators-space-between/input.marko +++ b/src/__tests__/fixtures/attr-operators-space-between/input.marko @@ -87,7 +87,7 @@ a = async function (x) { console.log("y") } a -= y += y a/> diff --git a/src/__tests__/fixtures/attr-right-angle-bracket-gte/__snapshots__/attr-right-angle-bracket-gte.expected.txt b/src/__tests__/fixtures/attr-right-angle-bracket-gte/__snapshots__/attr-right-angle-bracket-gte.expected.txt new file mode 100644 index 00000000..e27308f6 --- /dev/null +++ b/src/__tests__/fixtures/attr-right-angle-bracket-gte/__snapshots__/attr-right-angle-bracket-gte.expected.txt @@ -0,0 +1,11 @@ +1╭─ = 10>Hi + │ ││ ││ ││ │ │ ╰─ closeTagEnd(if) + │ ││ ││ ││ │ ╰─ closeTagName "if" + │ ││ ││ ││ ╰─ closeTagStart "= 10" + │ ││ ├─ attrValue "=count >= 10" + │ ││ ╰─ attrName + │ │╰─ tagName "if" + ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-right-angle-bracket-gte/input.marko b/src/__tests__/fixtures/attr-right-angle-bracket-gte/input.marko new file mode 100644 index 00000000..bc1f0ff6 --- /dev/null +++ b/src/__tests__/fixtures/attr-right-angle-bracket-gte/input.marko @@ -0,0 +1 @@ += 10>Hi \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-with-right-angle-bracket-whitespace/__snapshots__/attr-with-right-angle-bracket-whitespace.expected.txt b/src/__tests__/fixtures/attr-with-right-angle-bracket-whitespace/__snapshots__/attr-with-right-angle-bracket-whitespace.expected.txt new file mode 100644 index 00000000..d4629954 --- /dev/null +++ b/src/__tests__/fixtures/attr-with-right-angle-bracket-whitespace/__snapshots__/attr-with-right-angle-bracket-whitespace.expected.txt @@ -0,0 +1,48 @@ +1╭─
Hello
+ │ ││ │ ││ ││ │ │ ╰─ closeTagEnd(div) + │ ││ │ ││ ││ │ ╰─ closeTagName "div" + │ ││ │ ││ ││ ╰─ closeTagStart " >> + │ ││ │ ││ ││ │ │ ╰─ closeTagEnd(span) + │ ││ │ ││ ││ │ ╰─ closeTagName "span" + │ ││ │ ││ ││ ╰─ closeTagStart "> " + │ ││ │ ││ ╰─ openTagEnd + │ ││ │ │╰─ attrValue.value "sep" + │ ││ │ ╰─ attrValue "=sep" + │ ││ ╰─ attrName "class" + │ │╰─ tagName "span" + ╰─ ╰─ openTagStart +3╭─
10 items>
+ │ ││ │ ││ ││ │ │ ╰─ closeTagEnd(div) + │ ││ │ ││ ││ │ ╰─ closeTagName "div" + │ ││ │ ││ ││ ╰─ closeTagStart "" + │ ││ │ ││ ╰─ openTagEnd + │ ││ │ │╰─ attrValue.value "foo" + │ ││ │ ╰─ attrValue "=foo" + │ ││ ╰─ attrName "class" + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +4╭─
+ ╰─ ╰─ openTagEnd +7╭─ 10>text + ╰─ ╰─ text "\n 10>text\n" +8╭─
+ │ │ │ ╰─ closeTagEnd(div) + │ │ ╰─ closeTagName "div" + ╰─ ╰─ closeTagStart " Hello
+ >> +
10 items>
+
+ 10>text +
\ No newline at end of file diff --git a/src/states/ATTRIBUTE.ts b/src/states/ATTRIBUTE.ts index 915e9fd3..ad7553a6 100644 --- a/src/states/ATTRIBUTE.ts +++ b/src/states/ATTRIBUTE.ts @@ -1,5 +1,7 @@ import { + isIndentCode, isWhitespaceCode, + isWordCode, matchesCloseAngleBracket, matchesCloseCurlyBrace, matchesCloseParen, @@ -129,6 +131,15 @@ export const ATTRIBUTE: StateDefinition = { return; } else if (attr.stage === ATTR_STAGE.UNKNOWN) { if (code === CODE.OPEN_ANGLE_BRACKET) { + if (data.charCodeAt(this.pos + 1) === CODE.FORWARD_SLASH) { + return this.emitError( + this.pos, + ErrorCode.MALFORMED_OPEN_TAG, + 'A close tag was found before the "' + + this.read(this.activeTag!.tagName) + + '" open tag was closed. If the " = { }; this.options.onAttrName?.(attr.name); + + if (!this.isConcise && detectAmbiguousCloseAngleBracket(this, child)) { + return; + } break; } case ATTR_STAGE.ARGUMENT: { @@ -276,6 +291,10 @@ export const ATTRIBUTE: StateDefinition = { ); } + if (!this.isConcise && detectAmbiguousCloseAngleBracket(this, child)) { + return; + } + if (attr.spread) { this.options.onAttrSpread?.({ start: attr.valueStart, @@ -304,6 +323,162 @@ export const ATTRIBUTE: StateDefinition = { }, }; +/** + * In HTML mode a ">" after an unenclosed attribute always ends the tag, but a + * whitespace preceded ">" is often intended as a comparison operator, eg + * ` 10>` which actually parses as `` followed by the + * body content " 10>". Both interpretations are valid so this is truly + * ambiguous; rather than silently picking one, when the attribute is + * followed by whitespace and a ">" this looks ahead for the telltale tail of + * a split expression — operator connected operands ending in a second ">" + * (or "/>") on the same line — and reports an error that shows how to + * disambiguate. Anything else (including anything this lookahead does not + * understand, such as string literals) keeps the existing tag-end behavior. + */ +function detectAmbiguousCloseAngleBracket(parser: Parser, child: Meta) { + const { data, maxPos } = parser; + let pos = parser.pos; + + // Only an expression that stopped at horizontal whitespace followed by ">" + // on the same line is ambiguous. + if (!isIndentCode(data.charCodeAt(pos))) return false; + do pos++; + while (isIndentCode(data.charCodeAt(pos))); + if (data.charCodeAt(pos) !== CODE.CLOSE_ANGLE_BRACKET) return false; + + let sawOperand = false; + // Set when an operand ended and another operand would need an operator + // between them, eg text like "10 items>" is not a split expression. + let operatorPending = false; + // Tracks "(" and "[" nesting; the tag can only end at the top level. + let groupDepth = 0; + // A whitespace preceded ">=" is always continued as a comparison by + // shouldTerminateHtmlAttrValue, so the ">" here is never part of a ">=". + let lookPos = pos + 1; + + for (; lookPos < maxPos; lookPos++) { + const code = data.charCodeAt(lookPos); + + // A ">=" (anywhere) or a grouped ">" is a comparison, not the tag end. + if ( + code === CODE.CLOSE_ANGLE_BRACKET && + (groupDepth || data.charCodeAt(lookPos + 1) === CODE.EQUAL) + ) { + if (data.charCodeAt(lookPos + 1) === CODE.EQUAL) lookPos++; // skip = + operatorPending = false; + continue; + } + + if ( + !groupDepth && + (code === CODE.CLOSE_ANGLE_BRACKET || + (code === CODE.FORWARD_SLASH && + data.charCodeAt(lookPos + 1) === CODE.CLOSE_ANGLE_BRACKET)) + ) { + // Ignore horizontal whitespace between the final operand and the ">". + let exprEnd = lookPos; + while (isIndentCode(data.charCodeAt(exprEnd - 1))) exprEnd--; + if (sawOperand && isOperandEndCode(data.charCodeAt(exprEnd - 1))) { + const expression = data.slice(child.start, exprEnd); + const tail = data + .slice(pos + 1, lookPos + (code === CODE.FORWARD_SLASH ? 2 : 1)) + .trim(); + parser.emitError( + { start: child.start, end: exprEnd }, + ErrorCode.AMBIGUOUS_ATTRIBUTE_VALUE, + 'Ambiguous ">" in attribute. A ">" preceded by whitespace ends the tag. If "' + + expression + + '" was intended as a single expression, wrap it in parentheses, eg "=(' + + expression + + ')". If the tag was instead meant to end at the first ">", leaving "' + + tail + + '" as body content, remove the whitespace before that ">".', + ); + return true; + } + return false; + } + + if (isWordCode(code)) { + if (operatorPending) return false; + sawOperand = true; + continue; + } + + if (isIndentCode(code)) { + if (sawOperand && isOperandEndCode(data.charCodeAt(lookPos - 1))) { + operatorPending = true; + } + continue; + } + + switch (code) { + case CODE.EQUAL: + // An "=>" arrow connects operands; a bare "=" is not understood. + if (data.charCodeAt(lookPos + 1) !== CODE.CLOSE_ANGLE_BRACKET) { + return false; + } + lookPos++; // skip the ">" of "=>" + operatorPending = false; + continue; + case CODE.FORWARD_SLASH: { + // A "/" is only understood as division; where a regex could start + // (no operand before it) this does not look like a split expression. + let prevPos = lookPos - 1; + while (isIndentCode(data.charCodeAt(prevPos))) prevPos--; + if (!isOperandEndCode(data.charCodeAt(prevPos))) return false; + operatorPending = false; + continue; + } + case CODE.OPEN_PAREN: + groupDepth++; + operatorPending = false; + continue; + case CODE.OPEN_SQUARE_BRACKET: + groupDepth++; + operatorPending = false; + continue; + case CODE.CLOSE_PAREN: + case CODE.CLOSE_SQUARE_BRACKET: + // An unmatched closer means this is not a split expression. + if (!groupDepth) return false; + groupDepth--; + operatorPending = false; + continue; + case CODE.AMPERSAND: + case CODE.ASTERISK: + case CODE.CARET: + case CODE.COLON: + case CODE.EXCLAMATION: + case CODE.HYPHEN: + case CODE.PERCENT: + case CODE.PERIOD: + case CODE.PIPE: + case CODE.PLUS: + case CODE.QUESTION: + case CODE.TILDE: + operatorPending = false; + continue; + default: + // Newlines, "<", quotes, and anything else not recognized above + // means this does not look like a split expression. + return false; + } + } + + return false; +} + +function isOperandEndCode(code: number) { + switch (code) { + case CODE.CLOSE_PAREN: + case CODE.CLOSE_SQUARE_BRACKET: + return true; + default: + return isWordCode(code); + } +} + function ensureAttrName(parser: Parser, attr: AttrMeta) { if (!attr.name) { parser.options.onAttrName?.({ @@ -341,11 +516,20 @@ export function shouldTerminateHtmlAttrValue( return true; case CODE.FORWARD_SLASH: return data.charCodeAt(pos + 1) === CODE.CLOSE_ANGLE_BRACKET; - case CODE.CLOSE_ANGLE_BRACKET: - // Add special case for => - // We only look behind to match => if we're not at the start of the expression + case CODE.CLOSE_ANGLE_BRACKET: { + // We only look around the ">" if we're not at the start of the expression // otherwise this would match something like "". - return pos === this.start || data.charCodeAt(pos - 1) !== CODE.EQUAL; + if (pos === this.start) return true; + // Add special case for => + if (data.charCodeAt(pos - 1) === CODE.EQUAL) return false; + // A whitespace preceded ">" immediately followed by "=" is always a ">=" + // comparison operator, since a closed tag would instead put the "=" in + // its body content, eg `= 10>`. + return !( + isWhitespaceCode(data.charCodeAt(pos - 1)) && + data.charCodeAt(pos + 1) === CODE.EQUAL + ); + } default: return false; } diff --git a/src/states/EXPRESSION.ts b/src/states/EXPRESSION.ts index 63f4eb11..f4dc7f26 100644 --- a/src/states/EXPRESSION.ts +++ b/src/states/EXPRESSION.ts @@ -1,6 +1,7 @@ import { isIndentCode, isWhitespaceCode, + isWordCode, type Meta, Parser, STATE, @@ -419,6 +420,16 @@ function checkForOperators( pos + 1, ); + // In HTML mode a "= CODE.UPPER_A && code <= CODE.UPPER_Z) || - (code >= CODE.LOWER_A && code <= CODE.LOWER_Z) || - (code >= CODE.NUMBER_0 && code <= CODE.NUMBER_9) || - code == CODE.DOLLAR || - code === CODE.UNDERSCORE - ); -} - function lookAheadWhile( match: (code: number) => boolean, data: string, diff --git a/src/util/error-code.ts b/src/util/error-code.ts index 3b5527a0..01ea0337 100644 --- a/src/util/error-code.ts +++ b/src/util/error-code.ts @@ -27,3 +27,4 @@ export const ROOT_TAG_ONLY = 25; export const INVALID_TAG_PARAMS = 26; export const INVALID_TAG_TYPES = 27; export const INVALID_ATTR_TYPE_PARAMS = 28; +export const AMBIGUOUS_ATTRIBUTE_VALUE = 29; diff --git a/src/util/util.ts b/src/util/util.ts index 6124a609..a63bbf82 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -96,6 +96,16 @@ export function htmlEOF(this: Parser) { } } +export function isWordCode(code: number) { + return ( + (code >= CODE.UPPER_A && code <= CODE.UPPER_Z) || + (code >= CODE.LOWER_A && code <= CODE.LOWER_Z) || + (code >= CODE.NUMBER_0 && code <= CODE.NUMBER_9) || + code == CODE.DOLLAR || + code === CODE.UNDERSCORE + ); +} + export function matchesCloseAngleBracket(code: number) { return code === CODE.CLOSE_ANGLE_BRACKET; }