From 910b4f4971f9d90c9e958c665de271808c7d63fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 25 Jun 2026 23:40:46 +0000 Subject: [PATCH 1/4] fix(core): lint leaked head text in compositions --- packages/core/src/lint/rules/core.test.ts | 119 ++++++++++++++++++++++ packages/core/src/lint/rules/core.ts | 49 +++++++++ 2 files changed, 168 insertions(+) diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 5d2f65a55..b7b9921d9 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -134,6 +134,125 @@ describe("core rules", () => { expect(finding).toBeUndefined(); }); + it("reports error when CSS text is left outside a style block in the document head", async () => { + const html = ` + + + + + /* Decorative Elements */ + .particle { + position: absolute; + width: 4px; + height: 4px; + background: #fff; + } + + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain(""); + expect(finding?.snippet).toContain(".particle"); + }); + + it("reports error when a stray style close tag is left in the document head", async () => { + const html = ` + + + + + + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeDefined(); + expect(finding?.snippet).toContain(""); + }); + + it("reports error when markdown code fences leak into the document head", async () => { + const withLanguage = ` + + + \`\`\`css + .particle { + position: absolute; + } + \`\`\` + + +
+ + +`; + const withoutLanguage = ` + + + \`\`\` + .particle { + position: absolute; + } + \`\`\` + + +
+ + +`; + const withLanguageResult = await lintHyperframeHtml(withLanguage); + const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage); + const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text"); + const unlabeledFinding = withoutLanguageResult.findings.find( + (f) => f.code === "head_leaked_text", + ); + + expect(languageFinding).toBeDefined(); + expect(languageFinding?.snippet).toContain("```css"); + expect(unlabeledFinding).toBeDefined(); + expect(unlabeledFinding?.snippet).toContain("```"); + }); + + it("does not report orphan CSS for valid head metadata and style blocks", async () => { + const html = ` + + + Particle Field + + + + + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeUndefined(); + }); + describe("timeline_id_mismatch", () => { it("accepts dot timeline registration", async () => { const html = ` diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index 5a057ad4b..ef41dbe9c 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -57,6 +57,38 @@ function describeStudioElement(tag: { raw: string; name: string }): string { return parts.join(""); } +const HEAD_BLOCKS_TO_IGNORE_PATTERN = + /<(?:style|script|template|title)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title)>/gi; +const HTML_TAG_PATTERN = /<[^>]+>/g; +const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)\s*>/i; +const MARKDOWN_CODE_FENCE_PATTERN = + /```(?:html|css|js|javascript|typescript|ts)?(?:\s|$)[\s\S]*?```/i; +const ORPHAN_CSS_RULE_PATTERN = + /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i; + +function findLeakedTextInHead(rawSource: string): string | null { + const headMatches = [...rawSource.matchAll(/]*>([\s\S]*?)<\/head>/gi)]; + for (const match of headMatches) { + const headContent = match[1] ?? ""; + const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " "); + + const codeFenceMatch = MARKDOWN_CODE_FENCE_PATTERN.exec(withoutValidBlocks); + if (codeFenceMatch?.[0]) return codeFenceMatch[0]; + + const residualText = headContent + .replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ") + .replace(HTML_TAG_PATTERN, " "); + const orphanCssMatch = ORPHAN_CSS_RULE_PATTERN.exec(residualText); + if (orphanCssMatch?.[0]) return orphanCssMatch[0]; + + const strayCloseMatch = STRAY_HEAD_CLOSE_PATTERN.exec(withoutValidBlocks); + if (strayCloseMatch) { + return withoutValidBlocks.slice(strayCloseMatch.index, strayCloseMatch.index + 160); + } + } + return null; +} + export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // root_missing_composition_id + root_missing_dimensions ({ rootTag }) => { @@ -84,6 +116,23 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // head_leaked_text + ({ rawSource }) => { + const snippet = findLeakedTextInHead(rawSource); + if (!snippet) return []; + return [ + { + code: "head_leaked_text", + severity: "error", + message: + "Detected leaked code or CSS text in the document ``. Browsers render this as visible text in the video.", + fixHint: + "Move CSS into a single `` block and remove stray close tags, markdown fences, or code text from ``.", + snippet: truncateSnippet(snippet), + }, + ]; + }, + // missing_timeline_registry + timeline_registry_missing_init ({ source, rawSource, options }) => { // Sub-compositions inherit window.__timelines from the host composition From 3050f3e096ac75138df4f472cc2eb2696b52a148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 25 Jun 2026 23:56:43 +0000 Subject: [PATCH 2/4] fix(core): harden leaked head text lint --- packages/core/src/lint/rules/core.test.ts | 190 +++++++++++++++++----- packages/core/src/lint/rules/core.ts | 58 ++++--- 2 files changed, 182 insertions(+), 66 deletions(-) diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index b7b9921d9..7393ee764 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -1,6 +1,33 @@ import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; +function compositionWithHead(headContent: string): string { + return ` + + +${headContent} + + +
+ + +`; +} + +function templateCompositionWithHead(headContent: string): string { + return ` +`; +} + describe("core rules", () => { it("reports error when root is missing data-composition-id", async () => { const html = ` @@ -135,9 +162,7 @@ describe("core rules", () => { }); it("reports error when CSS text is left outside a style block in the document head", async () => { - const html = ` - - + const html = compositionWithHead(` @@ -149,12 +174,7 @@ describe("core rules", () => { height: 4px; background: #fff; } - - -
- - -`; +`); const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "head_leaked_text"); @@ -165,19 +185,12 @@ describe("core rules", () => { }); it("reports error when a stray style close tag is left in the document head", async () => { - const html = ` - - + const html = compositionWithHead(` - - -
- - -`; +`); const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "head_leaked_text"); @@ -185,55 +198,135 @@ describe("core rules", () => { expect(finding?.snippet).toContain(""); }); + it("reports error when a stray script close tag is left in the document head", async () => { + const html = compositionWithHead(` + + +`); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeDefined(); + expect(finding?.snippet).toContain(""); + }); + + it("does not report leaked head text for valid closing tags with trailing whitespace", async () => { + const html = compositionWithHead(` + + + Particle Field +`); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeUndefined(); + }); + it("reports error when markdown code fences leak into the document head", async () => { - const withLanguage = ` - - + const withLanguage = compositionWithHead(` \`\`\`css .particle { position: absolute; } \`\`\` - - -
- - -`; - const withoutLanguage = ` - - +`); + const withoutLanguage = compositionWithHead(` \`\`\` .particle { position: absolute; } \`\`\` - - -
- - -`; +`); + const withTsxLanguage = compositionWithHead(` + \`\`\`tsx + export function Particle() { + return
; + } + \`\`\` +`); const withLanguageResult = await lintHyperframeHtml(withLanguage); const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage); + const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage); const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text"); const unlabeledFinding = withoutLanguageResult.findings.find( (f) => f.code === "head_leaked_text", ); + const tsxLanguageFinding = withTsxLanguageResult.findings.find( + (f) => f.code === "head_leaked_text", + ); expect(languageFinding).toBeDefined(); expect(languageFinding?.snippet).toContain("```css"); expect(unlabeledFinding).toBeDefined(); expect(unlabeledFinding?.snippet).toContain("```"); + expect(tsxLanguageFinding).toBeDefined(); + expect(tsxLanguageFinding?.snippet).toContain("```tsx"); + }); + + it("reports error when CSS at-rules leak into the document head", async () => { + const html = compositionWithHead(` + @media (min-width: 800px) { + .particle { + transform: scale(1.2); + } + } +`); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeDefined(); + expect(finding?.snippet).toContain("@media"); + }); + + it("reports leaked CSS when a style block is unclosed in the document head", async () => { + const html = compositionWithHead(` + - - -
- - -`; +`); const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "head_leaked_text"); expect(finding).toBeUndefined(); }); + it("reports leaked head text inside template-wrapped sub-compositions", async () => { + const html = templateCompositionWithHead(` + + .particle { color: white; } +`); + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + const finding = result.findings.find((f) => f.code === "head_leaked_text"); + + expect(finding).toBeDefined(); + expect(finding?.snippet).toContain(".particle"); + }); + describe("timeline_id_mismatch", () => { it("accepts dot timeline registration", async () => { const html = ` diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index ef41dbe9c..2e503914e 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -58,33 +58,49 @@ function describeStudioElement(tag: { raw: string; name: string }): string { } const HEAD_BLOCKS_TO_IGNORE_PATTERN = - /<(?:style|script|template|title)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title)>/gi; + /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)\s*>/gi; const HTML_TAG_PATTERN = /<[^>]+>/g; +const HEAD_CONTENT_PATTERN = /]*>([\s\S]*?)(?:<\/head>|/i; -const MARKDOWN_CODE_FENCE_PATTERN = - /```(?:html|css|js|javascript|typescript|ts)?(?:\s|$)[\s\S]*?```/i; +const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i; +const ORPHAN_CSS_AT_RULE_PATTERN = + /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i; const ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i; -function findLeakedTextInHead(rawSource: string): string | null { - const headMatches = [...rawSource.matchAll(/]*>([\s\S]*?)<\/head>/gi)]; - for (const match of headMatches) { - const headContent = match[1] ?? ""; - const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " "); +function findCodeFenceLeak(headWithoutValidBlocks: string): string | null { + return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null; +} - const codeFenceMatch = MARKDOWN_CODE_FENCE_PATTERN.exec(withoutValidBlocks); - if (codeFenceMatch?.[0]) return codeFenceMatch[0]; +function findOrphanCssLeak(headContent: string): string | null { + const residualText = headContent + .replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ") + .replace(HTML_TAG_PATTERN, " "); + return ( + ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ?? + ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ?? + null + ); +} - const residualText = headContent - .replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ") - .replace(HTML_TAG_PATTERN, " "); - const orphanCssMatch = ORPHAN_CSS_RULE_PATTERN.exec(residualText); - if (orphanCssMatch?.[0]) return orphanCssMatch[0]; +function findStrayCloseLeak(headWithoutValidBlocks: string): string | null { + return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null; +} - const strayCloseMatch = STRAY_HEAD_CLOSE_PATTERN.exec(withoutValidBlocks); - if (strayCloseMatch) { - return withoutValidBlocks.slice(strayCloseMatch.index, strayCloseMatch.index + 160); - } +function findLeakedTextInHeadContent(headContent: string): string | null { + const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " "); + return ( + findCodeFenceLeak(withoutValidBlocks) ?? + findOrphanCssLeak(headContent) ?? + findStrayCloseLeak(withoutValidBlocks) + ); +} + +function findLeakedTextInHead(rawSource: string): string | null { + const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)]; + for (const match of headMatches) { + const leakedText = findLeakedTextInHeadContent(match[1] ?? ""); + if (leakedText) return leakedText; } return null; } @@ -117,8 +133,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }, // head_leaked_text - ({ rawSource }) => { - const snippet = findLeakedTextInHead(rawSource); + ({ source }) => { + const snippet = findLeakedTextInHead(source); if (!snippet) return []; return [ { From 552480659656e7fe57669d1b26f3da1f0d2e63f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 26 Jun 2026 00:55:22 +0000 Subject: [PATCH 3/4] chore: refresh code scanning status From cb96d66d262fca424e98c9a243d9aa1111ba78b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 26 Jun 2026 01:04:38 +0000 Subject: [PATCH 4/4] fix(core): cover parser-error head close tags --- packages/core/src/lint/rules/core.test.ts | 4 ++-- packages/core/src/lint/rules/core.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 7393ee764..e3f99e76f 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -216,11 +216,11 @@ describe("core rules", () => { const html = compositionWithHead(` + + data-parser-error-close> Particle Field `); const result = await lintHyperframeHtml(html); diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index 2e503914e..19da6a753 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -58,10 +58,10 @@ function describeStudioElement(tag: { raw: string; name: string }): string { } const HEAD_BLOCKS_TO_IGNORE_PATTERN = - /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)\s*>/gi; + /<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi; const HTML_TAG_PATTERN = /<[^>]+>/g; const HEAD_CONTENT_PATTERN = /]*>([\s\S]*?)(?:<\/head>|/i; +const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i; const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i; const ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;