diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts
index 97dac77132..fb0c2ec5e3 100644
--- a/packages/lint/src/rules/core.test.ts
+++ b/packages/lint/src/rules/core.test.ts
@@ -962,6 +962,30 @@ describe("core rules", () => {
).toBeUndefined();
});
+ it.each([
+ ["adjacent", `AB`],
+ ["spaces", `AB`],
+ ["newline", `AB`],
+ ])(
+ "does not flag valid sibling spans when the closing tag uses %s whitespace",
+ async (_label, body) => {
+ const result = await lintHyperframeHtml(compositionWithBodyPrefix(body));
+ expect(
+ result.findings.find((f) => f.code === "unclosed_tag_swallowed_element"),
+ ).toBeUndefined();
+ },
+ );
+
+ it.each([`B`, `B`])(
+ "still flags a malformed span start tag that swallows its next element",
+ async (body) => {
+ const result = await lintHyperframeHtml(compositionWithBodyPrefix(body));
+ const finding = result.findings.find((f) => f.code === "unclosed_tag_swallowed_element");
+ expect(finding?.severity).toBe("error");
+ expect(finding?.snippet).toContain(" {
const html = compositionWithBodyPrefix(`hi
`);
const result = await lintHyperframeHtml(html);
diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts
index 41663ff9d8..b6d201ec6c 100644
--- a/packages/lint/src/utils.test.ts
+++ b/packages/lint/src/utils.test.ts
@@ -1,9 +1,81 @@
import { describe, it, expect } from "vitest";
-import { stripCssComments, stripJsComments, stripJsStringLiterals } from "./utils.js";
+import {
+ parseHtmlStructure,
+ stripCssComments,
+ stripJsComments,
+ stripJsStringLiterals,
+} from "./utils.js";
const scan = (src: string) => stripJsStringLiterals(stripJsComments(src));
const findsRaf = (src: string) => /requestAnimationFrame\s*\(/.test(scan(src));
+describe("parseHtmlStructure source ranges", () => {
+ it("does not include ignored markup inside a preceding malformed closing tag", () => {
+ const source = "x
y";
+ const tags = parseHtmlStructure(source).tags;
+ expect(tags[1]).toMatchObject({ name: "span", raw: "", attrs: "", index: 22 });
+ });
+
+ it("keeps a less-than inside a malformed tag name in the original source range", () => {
+ const tags = parseHtmlStructure("body
").tags;
+ expect(tags.map(({ name, raw, attrs, index }) => ({ name, raw, attrs, index }))).toEqual([
+ { name: "div", attrs: "", index: 0 },
+ ]);
+ });
+
+ it("uses the original name boundary when Unicode lowercasing changes its length", () => {
+ const source = 'body';
+ const tags = parseHtmlStructure(source).tags;
+ expect(tags.map(({ name, raw, attrs, index }) => ({ name, raw, attrs, index }))).toEqual([
+ {
+ name: "ai̇i̇i̇",
+ raw: '',
+ attrs: ' data-check="<" data-flag=x',
+ index: 0,
+ },
+ ]);
+ });
+
+ it("keeps malformed names and quoted less-than values after a multiline close", () => {
+ const source = 'x
y
';
+ const tags = parseHtmlStructure(source).tags;
+ expect(tags[1]).toMatchObject({
+ name: "div',
+ attrs: ' data-expr="a < b"',
+ index: 11,
+ });
+ });
+
+ it("does not reuse implied-open origins for the following explicit tag", () => {
+ const source = '
text';
+ const tags = parseHtmlStructure(source).tags;
+ expect(tags.map(({ name, raw, attrs, index }) => ({ name, raw, attrs, index }))).toEqual([
+ { name: "p", raw: "", attrs: "p", index: 0 },
+ { name: "br", raw: "", attrs: "r", index: 4 },
+ { name: "ai̇", raw: '', attrs: ' data-expr="x < y"', index: 9 },
+ ]);
+ });
+
+ it("does not use apparent tags in comments or raw-text scripts as an opening origin", () => {
+ const source = 'x
';
+ const tags = parseHtmlStructure(source).tags;
+ expect(tags.map(({ name, raw, attrs }) => ({ name, raw, attrs }))).toEqual([
+ { name: "script", raw: "