diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 5f601e8583..8f4cd6fa82 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -112,6 +112,37 @@ describe("core rules", () => { expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); }); + it("skips a leading defs block when detecting the composition root", async () => { + // Regression: two independent reports of a leading ... + // block (icon/gradient/filter plumbing referenced via url(#id) elsewhere) + // getting mistaken for the composition root, since findRootTag returned + // the first non-script/style/meta/link/title body child unconditionally. + // The here carries no composition markers, so it must be skipped in + // favor of the real root that follows it. + const html = ` + + + + +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); + }); + + it("still treats an as the root when it carries composition markers itself", async () => { + const html = ` + + + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); + }); + it("reports error when timeline registry is missing", async () => { const html = ` diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index 65362db2e9..a5dd1d07e8 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -79,6 +79,7 @@ export function findHtmlTag(source: string): OpenTag | null { }; } +// fallow-ignore-next-line complexity export function findRootTag(source: string): OpenTag | null { const bodyOpenMatch = /]*)>/i.exec(source); const bodyCloseMatch = /<\/body>/i.exec(source); @@ -102,8 +103,34 @@ export function findRootTag(source: string): OpenTag | null { : source.length; const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source; const bodyTags = extractOpenTags(bodyContent); + // Set when a leading defs block is skipped (see below) — extractOpenTags + // is a flat, nesting-unaware scan, so without this the very next tag it + // returns is the svg's own nested child (, , ...), not the + // sibling that follows the closed . + let skipBefore = -1; for (const tag of bodyTags) { + if (tag.index < skipBefore) continue; if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue; + // A leading block (icon/gradient/filter , referenced by url(#id) + // from elsewhere in the document) is shared visual plumbing, not the + // composition root — two independent reports of this being mistaken for + // the root, manufacturing root_missing_composition_id/root_missing_dimensions + // on an otherwise-correct composition. Only skip it when it carries none of + // the composition markers itself, so an intentionally SVG-rooted composition + // (data-composition-id/data-width/data-height directly on the ) is + // still eligible as the root. + if ( + tag.name === "svg" && + !readAttr(tag.raw, "data-composition-id") && + !readAttr(tag.raw, "data-width") && + !readAttr(tag.raw, "data-height") + ) { + const closeMatch = /<\/svg\s*>/i.exec(bodyContent.slice(tag.index)); + // No closing tag found (malformed HTML) — skip everything rather than + // risk returning one of the svg's own children as the root. + skipBefore = closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity; + continue; + } return { ...tag, index: tag.index + bodyStart }; } return null;