mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): escape digit-leading id selectors in standalone sub-composition preview
A CSS identifier cannot start with a digit, so an authored rule like
`#01-wall-pushes-back { ... }` is an invalid selector and the browser drops
the whole rule — taking the root's size/background with it. A full
composition masks this (the host stretches/paints the frame), but a
standalone preview has no host, so the root collapses to height:0 +
transparent and renders blank.
extractFullDocumentParts now rewrites `#<digit-leading-id>` selectors to
their escaped valid form (`#\30 1-...`, still matching the element id),
scoped to ids actually present and matched only as `#id` not followed by an
ident char so hex colors are never touched. Also harden the <template>
inner-HTML extraction to use the DOM instead of a greedy regex. Tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
aca40cde0a
commit
e43b377fc0
@@ -134,4 +134,136 @@ describe("buildSubCompositionHtml", () => {
|
||||
expect(html).not.toContain("url('../poster.png')");
|
||||
expect(html).not.toContain('url("../fonts/brand.woff2")');
|
||||
});
|
||||
|
||||
it("promotes the <template>'s data-composition-id onto the root element when the content lacks one", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"compositions/frames/02-music.html": `<template data-composition-id="02-music">
|
||||
<style>#music-scene { background: #121212; }</style>
|
||||
<div id="music-scene" class="clip" data-start="0" data-duration="3.213" data-track-index="0">
|
||||
<div id="music-headline">ready when you are.</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["02-music"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/frames/02-music.html",
|
||||
"/api/runtime.js",
|
||||
);
|
||||
|
||||
// Root rendered element gains the id so the runtime can bind the timeline
|
||||
// (attribute order is serializer-dependent, so match the tag as a whole).
|
||||
expect(html).toMatch(
|
||||
/<div\b(?=[^>]*\sid="music-scene")(?=[^>]*\sdata-composition-id="02-music")[^>]*>/,
|
||||
);
|
||||
// The <style>/<script> siblings must NOT receive it.
|
||||
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
|
||||
expect(html).not.toMatch(/<script[^>]*data-composition-id/i);
|
||||
});
|
||||
|
||||
it("extracts the real <template> even when a head comment mentions the literal text <template>", () => {
|
||||
// Regression: a greedy /<template>([\s\S]*)<\/template>/ regex latches onto
|
||||
// the "<template>" inside the head comment, mis-slicing the capture so the
|
||||
// real content stays wrapped in an inert <template> in the output — leaving
|
||||
// the preview with no [data-composition-id] element and rendering blank.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"compositions/frames/03-force-pair.html": `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- head is metadata only; the HF runtime clones ONLY <template> contents -->
|
||||
</head>
|
||||
<body>
|
||||
<template>
|
||||
<style>#fp-root { width: 1920px; height: 1080px; }</style>
|
||||
<div id="fp-root" data-composition-id="03-force-pair" data-width="1920" data-height="1080">
|
||||
<div class="headline">forces come in pairs</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["03-force-pair"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/frames/03-force-pair.html",
|
||||
"/api/runtime.js",
|
||||
"/api/projects/demo/preview/",
|
||||
);
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
// The real composition content is rendered into <body> directly...
|
||||
expect(html).toContain('data-composition-id="03-force-pair"');
|
||||
expect(html).toContain("forces come in pairs");
|
||||
expect(html).toContain('__timelines["03-force-pair"]');
|
||||
// ...and is NOT re-wrapped in an inert <template> (which the browser would
|
||||
// never render). The comment fragment must not leak through either.
|
||||
const bodyStart = html!.indexOf("<body>");
|
||||
const body = html!.slice(bodyStart);
|
||||
expect(body).not.toContain("<template");
|
||||
expect(body).not.toContain("clones ONLY");
|
||||
});
|
||||
|
||||
it("escapes #id selectors whose id starts with a digit so the rule is not dropped", () => {
|
||||
// A CSS ident can't start with a digit, so `#01-wall { ... }` is an invalid
|
||||
// selector and the browser drops the whole rule — the root loses its size and
|
||||
// background and a standalone preview renders blank. Escape it to a valid form.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"compositions/frames/01-wall.html": `<template>
|
||||
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="5.5" data-track-index="0">
|
||||
<style>
|
||||
#01-wall { position: absolute; inset: 0; width: 1920px; height: 1080px; background-color: #F0EBDE; }
|
||||
.arrow { background: #1F2BE0; color: #1A2B3C; }
|
||||
</style>
|
||||
<div class="arrow">action</div>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/frames/01-wall.html",
|
||||
"/api/runtime.js",
|
||||
);
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
// The invalid `#01-wall` selector is rewritten to its escaped, valid form.
|
||||
expect(html).toContain("#\\30 1-wall {");
|
||||
expect(html).not.toMatch(/#01-wall\s*\{/);
|
||||
// Digit-leading hex color VALUES (not element ids) must be left untouched.
|
||||
expect(html).toContain("background: #1F2BE0;");
|
||||
expect(html).toContain("color: #1A2B3C;");
|
||||
// The element's id attribute itself is unchanged (only the selector is escaped).
|
||||
expect(html).toContain('id="01-wall"');
|
||||
});
|
||||
|
||||
it("does not add a duplicate data-composition-id when the root element already has one", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"compositions/frames/04-allinone.html": `<template data-composition-id="04-allinone">
|
||||
<div id="c04" data-composition-id="04-allinone" data-start="0" data-duration="5.695" data-track-index="0">
|
||||
<div class="headline">all in one app.</div>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/frames/04-allinone.html",
|
||||
"/api/runtime.js",
|
||||
);
|
||||
const occurrences = html?.match(/data-composition-id="04-allinone"/g) ?? [];
|
||||
expect(occurrences).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,53 @@ function rewriteRelativePaths(root: ParentNode, compPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a CSS identifier whose first character is a digit so it is a valid
|
||||
* selector. A CSS ident cannot start with a digit, so it must be written as an
|
||||
* escaped code point: `01-foo` → `\30 1-foo` (leading `0` → `\30 `, rest kept).
|
||||
*/
|
||||
function escapeLeadingDigitIdent(id: string): string {
|
||||
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
|
||||
}
|
||||
|
||||
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/**
|
||||
* Fix `#<digit-leading-id>` selectors in the tree's `<style>` blocks.
|
||||
*
|
||||
* CSS identifiers cannot start with a digit, so an authored rule like
|
||||
* `#01-wall-pushes-back { width: 1920px; height: 1080px; background: #F0EBDE }`
|
||||
* is an invalid selector and the browser silently drops the WHOLE rule — taking
|
||||
* the root's size and background with it. In a full composition the frame is
|
||||
* stretched/painted by its `data-composition-src` host so the collapse is
|
||||
* masked, but a standalone preview has no host: the root falls back to
|
||||
* `height: 0` + transparent and the frame renders blank (black).
|
||||
*
|
||||
* Rewrite each such selector to its escaped, valid form (`#\30 1-wall-pushes-back`,
|
||||
* which still matches `id="01-wall-pushes-back"`) so the rule applies and the
|
||||
* whole declaration block — size, background, position, container-type — comes
|
||||
* back. Scoped to ids that are actually present on elements in the content and
|
||||
* matched only as `#id` not followed by another ident char, so hex colors
|
||||
* (`#1F2BE0`) and other values are never touched (they are not element ids).
|
||||
*/
|
||||
function fixDigitLeadingIdSelectors(root: ParentNode): void {
|
||||
const digitIds = new Set<string>();
|
||||
for (const el of root.querySelectorAll("[id]")) {
|
||||
const id = el.getAttribute("id");
|
||||
if (id && /^\d/.test(id)) digitIds.add(id);
|
||||
}
|
||||
if (digitIds.size === 0) return;
|
||||
|
||||
for (const styleEl of root.querySelectorAll("style")) {
|
||||
let css = styleEl.textContent || "";
|
||||
for (const id of digitIds) {
|
||||
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
|
||||
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
|
||||
}
|
||||
styleEl.textContent = css;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full HTML document and extract its head elements and body
|
||||
* content separately, so they can be reassembled into a clean standalone
|
||||
@@ -68,6 +115,9 @@ function extractFullDocumentParts(
|
||||
for (const target of rewriteTargets) {
|
||||
rewriteRelativePaths(target, compPath);
|
||||
}
|
||||
// Run on the whole document: ids live in <body> but their rules may live in
|
||||
// a <head> <style>, so the scope must span both.
|
||||
fixDigitLeadingIdSelectors(doc);
|
||||
|
||||
const headContent = doc.head?.innerHTML ?? "";
|
||||
const bodyContent = doc.body?.innerHTML ?? "";
|
||||
@@ -79,6 +129,26 @@ function extractFullDocumentParts(
|
||||
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner HTML of the composition's wrapping `<template>` element, or
|
||||
* `null` if the source has no `<template>`.
|
||||
*
|
||||
* Located via the DOM rather than a regex. A greedy
|
||||
* `/<template[^>]*>([\s\S]*)<\/template>/` can latch onto a literal
|
||||
* `"<template>"` that appears inside an HTML comment — e.g. a head note such as
|
||||
* "the HF runtime clones ONLY <template> contents" — and mis-slice the capture,
|
||||
* leaving the real composition content re-wrapped in an inert `<template>` in
|
||||
* the output. That template is never rendered by the browser, so the standalone
|
||||
* preview has no `[data-composition-id]` element and no registered timeline, and
|
||||
* renders blank. `querySelector("template")` only ever matches a real element
|
||||
* node, so comment text can't fool it.
|
||||
*/
|
||||
function extractTemplateInnerHtml(rawComp: string): string | null {
|
||||
const { document: doc } = parseHTML(rawComp);
|
||||
const template = doc.querySelector("template");
|
||||
return template ? template.innerHTML : null;
|
||||
}
|
||||
|
||||
function extractElementAttrs(el: Element): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < el.attributes.length; i++) {
|
||||
@@ -92,6 +162,38 @@ function extractElementAttrs(el: Element): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
const NON_RENDERED_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
|
||||
|
||||
/**
|
||||
* Carry the `<template>`'s `data-composition-id` onto the content's root
|
||||
* rendered element when the author declared it only on the `<template>` tag.
|
||||
*
|
||||
* In a full composition, each sub-composition is mounted under a wrapper
|
||||
* element (the `data-composition-src` host) that carries the composition id,
|
||||
* which is how the runtime binds `window.__timelines[id]` into the player's
|
||||
* master timeline. A standalone preview has no such wrapper, so it relies on
|
||||
* the frame's own root element carrying `data-composition-id`. If the id lives
|
||||
* only on the inert `<template>` tag (a common authoring pattern), the rendered
|
||||
* body has no `[data-composition-id]` element — the runtime then never selects
|
||||
* a root composition, the registered GSAP timeline stays unbound, and seeking
|
||||
* does nothing. The frame renders at its pre-animation state (GSAP `fromTo`
|
||||
* pins `opacity:0`), producing a blank preview/thumbnail.
|
||||
*
|
||||
* This is a no-op when the content already exposes a `[data-composition-id]`
|
||||
* element (e.g. the id is authored on the root div), so compositions that
|
||||
* already render correctly are untouched.
|
||||
*/
|
||||
function promoteTemplateCompositionId(rawComp: string, body: Element): void {
|
||||
const templateCompositionId = rawComp.match(
|
||||
/<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i,
|
||||
)?.[1];
|
||||
if (!templateCompositionId) return;
|
||||
if (body.querySelector("[data-composition-id]")) return;
|
||||
|
||||
const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
|
||||
root?.setAttribute("data-composition-id", templateCompositionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standalone HTML page for a sub-composition.
|
||||
*
|
||||
@@ -126,15 +228,16 @@ export function buildSubCompositionHtml(
|
||||
let htmlAttrs = "";
|
||||
let bodyAttrs = "";
|
||||
|
||||
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
const templateInner = extractTemplateInnerHtml(rawComp);
|
||||
|
||||
if (templateMatch) {
|
||||
const content = templateMatch[1];
|
||||
if (templateInner != null) {
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
|
||||
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
rewrittenContent = contentDoc.body.innerHTML || content!;
|
||||
fixDigitLeadingIdSelectors(contentDoc);
|
||||
promoteTemplateCompositionId(rawComp, contentDoc.body);
|
||||
rewrittenContent = contentDoc.body.innerHTML || templateInner;
|
||||
} else if (isFullHtmlDocument(rawComp)) {
|
||||
const parts = extractFullDocumentParts(rawComp, compPath);
|
||||
compHeadContent = parts.headContent;
|
||||
@@ -146,6 +249,7 @@ export function buildSubCompositionHtml(
|
||||
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
fixDigitLeadingIdSelectors(contentDoc);
|
||||
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user