diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 5d2f65a55e..e3f99e76ff 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 = ` @@ -134,6 +161,198 @@ 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 = compositionWithHead(` + + + /* 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 = 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("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 = compositionWithHead(` + \`\`\`css + .particle { + position: absolute; + } + \`\`\` +`); + 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 5a057ad4bf..19da6a753b 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -57,6 +57,54 @@ function describeStudioElement(tag: { raw: string; name: string }): string { return parts.join(""); } +const HEAD_BLOCKS_TO_IGNORE_PATTERN = + /<(?: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 = /```[^\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 findCodeFenceLeak(headWithoutValidBlocks: string): string | null { + return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null; +} + +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 + ); +} + +function findStrayCloseLeak(headWithoutValidBlocks: string): string | null { + return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null; +} + +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; +} + export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // root_missing_composition_id + root_missing_dimensions ({ rootTag }) => { @@ -84,6 +132,23 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // head_leaked_text + ({ source }) => { + const snippet = findLeakedTextInHead(source); + 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