mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): catch visible css before composition root (#1751)
This commit is contained in:
@@ -14,6 +14,54 @@ ${headContent}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithHeadBoundary(boundaryContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${boundaryContent}
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${prefixContent}
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
${rootContent}
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${prefixContent}
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function templateCompositionWithHead(headContent: string): string {
|
||||
return `
|
||||
<template>
|
||||
@@ -184,6 +232,144 @@ describe("core rules", () => {
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
it("reports error when CSS variables leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
--bg-color: #F5F1E8;
|
||||
--text-color: #212121;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("<head>");
|
||||
expect(finding?.snippet).toContain("body");
|
||||
});
|
||||
|
||||
it("reports error when stray close tags leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
</style>
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</style>");
|
||||
});
|
||||
|
||||
it("reports error when markdown code fences leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
\`\`\`css
|
||||
.particle {
|
||||
color: white;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("```css");
|
||||
});
|
||||
|
||||
it("reports error when CSS at-rules leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
@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("does not report leaked text for valid script and style blocks around the head boundary", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script>
|
||||
<template>
|
||||
<style>
|
||||
.template-only { color: red; }
|
||||
</style>
|
||||
</template>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
.orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".orphan");
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
|
||||
const html = compositionWithImplicitBodyPrefix(`
|
||||
.implicit-body-orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".implicit-body-orphan");
|
||||
});
|
||||
|
||||
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
<style>
|
||||
.pre-root-helper { color: red; }
|
||||
</style>
|
||||
<script>
|
||||
window.__preRootReady = true;
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report CSS-looking educational text inside the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
<pre>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</pre>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when a stray style close tag is left in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
|
||||
@@ -61,6 +61,7 @@ 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 = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
||||
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
||||
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 =
|
||||
@@ -105,6 +106,27 @@ function findLeakedTextInHead(rawSource: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
|
||||
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
||||
for (const match of boundaryMatches) {
|
||||
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
||||
if (leakedText) return leakedText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBeforeCompositionRoot(
|
||||
source: string,
|
||||
rootTag: LintContext["rootTag"],
|
||||
): string | null {
|
||||
if (!rootTag || rootTag.name === "body") return null;
|
||||
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
||||
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
||||
const prefixEnd = rootTag.index;
|
||||
if (prefixEnd <= prefixStart) return null;
|
||||
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
||||
}
|
||||
|
||||
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// root_missing_composition_id + root_missing_dimensions
|
||||
({ rootTag }) => {
|
||||
@@ -133,17 +155,20 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// head_leaked_text
|
||||
({ source }) => {
|
||||
const snippet = findLeakedTextInHead(source);
|
||||
({ source, rootTag }) => {
|
||||
const snippet =
|
||||
findLeakedTextInHead(source) ??
|
||||
findLeakedTextBetweenHeadAndBody(source) ??
|
||||
findLeakedTextBeforeCompositionRoot(source, rootTag);
|
||||
if (!snippet) return [];
|
||||
return [
|
||||
{
|
||||
code: "head_leaked_text",
|
||||
severity: "error",
|
||||
message:
|
||||
"Detected leaked code or CSS text in the document `<head>`. Browsers render this as visible text in the video.",
|
||||
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
||||
fixHint:
|
||||
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`.",
|
||||
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
||||
snippet: truncateSnippet(snippet),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user