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 `
+
+
+
+${headContent}
+
+
+
+
+
+`;
+}
+
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