mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(lint): flag an unclosed tag that swallows a nested element as bogus attribute text (#3869)
A start tag missing its closing `>` before the next `<` (e.g. a bad string-replace that leaves `<img ... <div class="hl"></div>` behind) is parsed leniently by the browser's HTML5 tokenizer: the `<div` text is consumed as a bogus attribute name on the still-open `img` tag, and the intended element never becomes a real DOM node. No existing lint/check gate catches this — they all operate on the resolved DOM, which looks structurally valid once the browser has already dropped the element. Adds `unclosed_tag_swallowed_element`, a new core lint rule that flags any parsed tag whose attribute text contains a `<` outside a quoted value (a legitimate attribute value may itself contain a raw `<`, e.g. `data-expr="x < y"`, which htmlparser2 parses correctly and is not flagged). PRINFRA-668 Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bafc7b4e00
commit
2ef76bf97b
@@ -942,6 +942,35 @@ describe("core rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unclosed_tag_swallowed_element", () => {
|
||||
it("flags an <img> tag whose unclosed start tag swallows a nested <div> as bogus attribute text", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
`<img class="browser-img" src="a.png" <div class="hl"></div></figure>`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "unclosed_tag_swallowed_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.snippet).toContain("<img");
|
||||
});
|
||||
|
||||
it("does not flag a normal <img> tag", async () => {
|
||||
const html = compositionWithBodyPrefix(`<img class="browser-img" src="a.png" />`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "unclosed_tag_swallowed_element"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag a legitimate attribute value containing a raw <", async () => {
|
||||
const html = compositionWithBodyPrefix(`<div data-expr="x < y">hi</div>`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "unclosed_tag_swallowed_element"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => {
|
||||
it("reports a css_parse_error finding for unparseable CSS", async () => {
|
||||
const html = `<html><body>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractCompositionIdsFromCss,
|
||||
extractTimelineRegistryKeys,
|
||||
getInlineScriptSyntaxError,
|
||||
hasUnquotedLessThan,
|
||||
TIMELINE_REGISTRY_INIT_PATTERN,
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN,
|
||||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
|
||||
@@ -435,6 +436,22 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
return findings;
|
||||
},
|
||||
|
||||
// unclosed_tag_swallowed_element
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (!hasUnquotedLessThan(tag.attrs)) continue;
|
||||
findings.push({
|
||||
code: "unclosed_tag_swallowed_element",
|
||||
severity: "error",
|
||||
message: `<${tag.name}> is missing its closing \`>\` before the next \`<\` — the following element is swallowed as bogus attribute text and never becomes a real node.`,
|
||||
fixHint: "Close the previous tag's `>` before opening the next element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// invalid_inline_script_syntax (malformed close tag)
|
||||
({ source }) => {
|
||||
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
|
||||
|
||||
@@ -166,6 +166,34 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tag's attribute text contains a `<` outside any quoted value.
|
||||
*
|
||||
* A legitimate attribute value may itself contain a raw `<` (e.g.
|
||||
* `data-expr="x < y"`) — that's fine, htmlparser2 (and browsers) parse it as
|
||||
* ordinary attribute text. But a `<` OUTSIDE any quotes means a following
|
||||
* start tag never got its own `<`: the HTML tokenizer swallowed it as bogus
|
||||
* attribute-name text on the tag currently open, and the intended element
|
||||
* never becomes a real node. `<img src="a.png" <div class="hl">` is exactly
|
||||
* this: `attrs` comes back as ` src="a.png" <div class="hl"` and the `.hl`
|
||||
* div silently never renders.
|
||||
*/
|
||||
export function hasUnquotedLessThan(attrs: string): boolean {
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (const ch of attrs) {
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === "<") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readAttr(tagSource: string, attr: string): string | null {
|
||||
if (!tagSource) return null;
|
||||
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
Reference in New Issue
Block a user