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 = '

xy'; + 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 = '