From 6c0c43614ff1a310e2d4315636134f9b18317f25 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Fri, 7 Aug 2026 08:58:50 -0700 Subject: [PATCH 1/2] Disambiguate whitespace-preceded ">" in HTML attributes --- .changeset/quiet-pears-refuse.md | 5 + agent-feedback/bugs.md | 12 +- ...ous-right-angle-bracket-arrow.expected.txt | 5 + .../input.marko | 1 + ...ight-angle-bracket-bare-equal.expected.txt | 11 ++ .../input.marko | 1 + ...guous-right-angle-bracket-eof.expected.txt | 9 + .../input.marko | 1 + ...s-right-angle-bracket-grouped.expected.txt | 5 + .../input.marko | 1 + ...-angle-bracket-index-division.expected.txt | 5 + .../input.marko | 1 + ...s-right-angle-bracket-logical.expected.txt | 5 + .../input.marko | 1 + ...uous-right-angle-bracket-name.expected.txt | 5 + .../input.marko | 1 + ...ight-angle-bracket-nested-gte.expected.txt | 5 + .../input.marko | 1 + ...ous-right-angle-bracket-regex.expected.txt | 11 ++ .../input.marko | 1 + ...ight-angle-bracket-self-close.expected.txt | 5 + .../input.marko | 1 + ...us-right-angle-bracket-string.expected.txt | 11 ++ .../input.marko | 1 + ...-angle-bracket-trailing-space.expected.txt | 5 + .../input.marko | 1 + ...ngle-bracket-unbalanced-group.expected.txt | 11 ++ .../input.marko | 1 + ...angle-bracket-unmatched-close.expected.txt | 11 ++ .../input.marko | 1 + ...ambiguous-right-angle-bracket.expected.txt | 5 + .../input.marko | 1 + .../attr-operators-newline-after.expected.txt | 13 +- .../attr-operators-newline-after/input.marko | 2 +- ...attr-operators-newline-before.expected.txt | 13 +- .../attr-operators-newline-before/input.marko | 2 +- .../attr-operators-space-before.expected.txt | 13 +- .../attr-operators-space-before/input.marko | 2 +- .../attr-operators-space-between.expected.txt | 13 +- .../attr-operators-space-between/input.marko | 2 +- .../attr-right-angle-bracket-gte.expected.txt | 11 ++ .../attr-right-angle-bracket-gte/input.marko | 1 + ...ight-angle-bracket-whitespace.expected.txt | 48 +++++ .../input.marko | 8 + src/states/ATTRIBUTE.ts | 183 +++++++++++++++++- src/states/EXPRESSION.ts | 11 +- src/util/error-code.ts | 1 + src/util/util.ts | 10 + 48 files changed, 418 insertions(+), 56 deletions(-) create mode 100644 .changeset/quiet-pears-refuse.md create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/__snapshots__/attr-ambiguous-right-angle-bracket-arrow.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-arrow/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/__snapshots__/attr-ambiguous-right-angle-bracket-bare-equal.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-bare-equal/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/__snapshots__/attr-ambiguous-right-angle-bracket-eof.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-eof/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/__snapshots__/attr-ambiguous-right-angle-bracket-grouped.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-grouped/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/__snapshots__/attr-ambiguous-right-angle-bracket-index-division.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-index-division/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/__snapshots__/attr-ambiguous-right-angle-bracket-logical.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-logical/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/__snapshots__/attr-ambiguous-right-angle-bracket-name.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-name/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/__snapshots__/attr-ambiguous-right-angle-bracket-nested-gte.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-nested-gte/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/__snapshots__/attr-ambiguous-right-angle-bracket-regex.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-regex/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/__snapshots__/attr-ambiguous-right-angle-bracket-self-close.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-self-close/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/__snapshots__/attr-ambiguous-right-angle-bracket-string.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-string/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/__snapshots__/attr-ambiguous-right-angle-bracket-trailing-space.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-trailing-space/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/__snapshots__/attr-ambiguous-right-angle-bracket-unbalanced-group.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unbalanced-group/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/__snapshots__/attr-ambiguous-right-angle-bracket-unmatched-close.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket-unmatched-close/input.marko create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/__snapshots__/attr-ambiguous-right-angle-bracket.expected.txt create mode 100644 src/__tests__/fixtures/attr-ambiguous-right-angle-bracket/input.marko create mode 100644 src/__tests__/fixtures/attr-right-angle-bracket-gte/__snapshots__/attr-right-angle-bracket-gte.expected.txt create mode 100644 src/__tests__/fixtures/attr-right-angle-bracket-gte/input.marko create mode 100644 src/__tests__/fixtures/attr-with-right-angle-bracket-whitespace/__snapshots__/attr-with-right-angle-bracket-whitespace.expected.txt create mode 100644 src/__tests__/fixtures/attr-with-right-angle-bracket-whitespace/input.marko diff --git a/.changeset/quiet-pears-refuse.md b/.changeset/quiet-pears-refuse.md new file mode 100644 index 00000000..7cc69233 --- /dev/null +++ b/.changeset/quiet-pears-refuse.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +In HTML mode, a whitespace-preceded `>=` 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..2cc46c54 100644 --- a/agent-feedback/bugs.md +++ b/agent-feedback/bugs.md @@ -2,14 +2,14 @@ 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 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 `` 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("")'` — 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`. + +## An unenclosed attribute value followed by `<` consumes a close tag as a less-than operand + +`src/states/EXPRESSION.ts` › `lookAheadForOperator` | 2026-08-06 | impact:med | effort:med + +`lookAheadForOperator` unconditionally treats `CODE.OPEN_ANGLE_BRACKET` as a binary operator, so in HTML mode an unenclosed attribute value that already spans an operator keeps going through a following close tag: `
` parses as `attrValue "x + y ` ambiguity that `detectAmbiguousCloseAngleBracket` in `src/states/ATTRIBUTE.ts` now reports; here the input is already erroneous, so the ask is a better diagnostic, not a behavior change: when continuing an expression across `<`, a cheap look-ahead for a tag-like sequence (`/`, name chars, `>`) could emit a targeted error suggesting parentheses. Re-verify with `node --input-type=module -e 'import{createParser}from"./src/index.ts";const p=createParser({onError:e=>console.log("ERR",e.message),onAttrValue:r=>console.log("value",JSON.stringify(p.read(r.value)))});p.parse("
")'` — it prints `value "x + y 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-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..25528a69 100644 --- a/src/states/ATTRIBUTE.ts +++ b/src/states/ATTRIBUTE.ts @@ -1,5 +1,7 @@ import { + isIndentCode, isWhitespaceCode, + isWordCode, matchesCloseAngleBracket, matchesCloseCurlyBrace, matchesCloseParen, @@ -177,6 +179,10 @@ export const ATTRIBUTE: StateDefinition = { }; this.options.onAttrName?.(attr.name); + + if (!this.isConcise && detectAmbiguousCloseAngleBracket(this, child)) { + return; + } break; } case ATTR_STAGE.ARGUMENT: { @@ -276,6 +282,10 @@ export const ATTRIBUTE: StateDefinition = { ); } + if (!this.isConcise && detectAmbiguousCloseAngleBracket(this, child)) { + return; + } + if (attr.spread) { this.options.onAttrSpread?.({ start: attr.valueStart, @@ -304,6 +314,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 +507,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..6f8cafb6 100644 --- a/src/states/EXPRESSION.ts +++ b/src/states/EXPRESSION.ts @@ -1,6 +1,7 @@ import { isIndentCode, isWhitespaceCode, + isWordCode, type Meta, Parser, STATE, @@ -608,16 +609,6 @@ function isWordOrPeriodCode(code: number) { return code === CODE.PERIOD || isWordCode(code); } -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 - ); -} - 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; } From 2e5d2d01509a828472e9579411384ffc4fc8491e Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Fri, 7 Aug 2026 09:08:11 -0700 Subject: [PATCH 2/2] Treat "`/`