Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ function scopeSelector(selector: string, scope: string, compositionId: string):
if (!trimmed) return selector;
if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector;
const compositionIdPattern = new RegExp(
`data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1`,
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
);
if (compositionIdPattern.test(trimmed)) return selectorWithoutRootTiming;
if (compositionIdPattern.test(trimmed)) {
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
return `${leading}${scope} ${trimmed}${trailing}`;
Expand Down Expand Up @@ -56,10 +59,16 @@ function isInsideGlobalAtRule(rule: Rule): boolean {
return false;
}

export function scopeCssToComposition(css: string, compositionId: string): string {
export function scopeCssToComposition(
css: string,
compositionId: string,
scopeSelectorOverride?: string,
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
const scope = `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const scope =
scopeSelectorOverride ||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const root = postcss.parse(css);

root.walkRules((rule) => {
Expand All @@ -76,10 +85,14 @@ export function wrapScopedCompositionScript(
source: string,
compositionId: string,
errorLabel = "[HyperFrames] composition script error:",
scopeSelectorOverride?: string,
timelineCompositionId = compositionId,
): string {
const compositionIdLiteral = JSON.stringify(compositionId);
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
const errorLabelLiteral = JSON.stringify(errorLabel);
const escapedCompositionId = escapeRegExp(compositionId);
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = JSON.stringify(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
);
Expand All @@ -88,13 +101,14 @@ export function wrapScopedCompositionScript(
);
return `(function(){
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
};
var __hfRootSelector = __hfCompId
var __hfRootSelector = ${scopeSelectorLiteral} || (__hfCompId
? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]'
: "";
: "");
var __hfRoot = null;
var __hfRootSelectorPattern = ${rootSelectorPatternLiteral};
var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral};
Expand Down Expand Up @@ -143,6 +157,41 @@ export function wrapScopedCompositionScript(
},
})
: window.document;
var __hfTimelineRegistryProxy = null;
var __hfGetTimelineRegistry = function() {
window.__timelines = window.__timelines || {};
if (!__hfCompId || __hfCompId === __hfTimelineCompId || typeof Proxy !== "function") {
return window.__timelines;
}
if (!__hfTimelineRegistryProxy) {
__hfTimelineRegistryProxy = new Proxy(window.__timelines, {
get: function(target, prop, receiver) {
return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, receiver);
},
set: function(target, prop, value, receiver) {
return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, receiver);
},
});
}
return __hfTimelineRegistryProxy;
};
var __hfScopedWindow = typeof Proxy === "function"
? new Proxy(window, {
get: function(target, prop, receiver) {
if (prop === "__timelines") return __hfGetTimelineRegistry();
var value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
target.__timelines = value || {};
__hfTimelineRegistryProxy = null;
return true;
}
return Reflect.set(target, prop, value, receiver);
},
})
: window;
var __hfResolveGsapTarget = function(target) {
if (typeof target !== "string") return target;
return __hfQueryAll(target);
Expand Down Expand Up @@ -214,9 +263,9 @@ export function wrapScopedCompositionScript(
});
var __hfRun = function() {
try {
(function(document, gsap) {
(function(document, gsap, window) {
${source}
}).call(window, __hfScopedDocument, __hfScopedGsap);
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
Expand Down
58 changes: 57 additions & 1 deletion packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain(".logo");

// Scripts from template should be included
expect(bundled).toContain('window.__timelines["logo-reveal"]');
expect(bundled).toContain('__timelines["logo-reveal"]');
});

it("does not inline template when host already has content", async () => {
Expand Down Expand Up @@ -284,6 +284,62 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('tl.to(".title"');
});

it("isolates sibling instances of the same external sub-composition", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div
id="scene-a"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="5"></div>
<div
id="scene-b"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="5"
data-duration="5"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="scene"] .title { opacity: 0; }</style>
<h1 class="title">Scene</h1>
<script>
const tl = gsap.timeline({ paused: true });
tl.to('[data-composition-id="scene"] .title', { opacity: 1 });
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = tl;
</script>
</div>
</template>`,
});

const bundled = await bundleToSingleHtml(dir);

const { document } = parseHTML(bundled);
const sceneA = document.querySelector("#scene-a");
const sceneB = document.querySelector("#scene-b");
const sceneAId = sceneA?.getAttribute("data-composition-id") ?? "";
const sceneBId = sceneB?.getAttribute("data-composition-id") ?? "";

expect(sceneAId).not.toBe("scene");
expect(sceneBId).not.toBe("scene");
expect(sceneAId).not.toBe(sceneBId);
expect(sceneA?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(sceneB?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(bundled).toContain(`[data-composition-id="${sceneAId}"] .title`);
expect(bundled).toContain(`[data-composition-id="${sceneBId}"] .title`);
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf1"');
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf2"');
expect(bundled).not.toContain('[data-composition-id="scene"] .title { opacity: 0; }');
});

it("rewrites CSS url(...) asset paths from sub-compositions when styles are hoisted", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
Expand Down
44 changes: 41 additions & 3 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
);
}

function cssAttributeSelector(attr: string, value: string): string {
return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
}

function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}

function enforceCompositionPixelSizing(document: Document): void {
const compositionEls = [
...document.querySelectorAll("[data-composition-id][data-width][data-height]"),
Expand Down Expand Up @@ -422,7 +430,15 @@ export async function bundleToSingleHtml(
const compStyleChunks: string[] = [];
const compScriptChunks: string[] = [];
const compExternalScriptSrcs: string[] = [];
for (const hostEl of [...document.querySelectorAll("[data-composition-src]")]) {
const subCompositionHosts = [...document.querySelectorAll("[data-composition-src]")];
const hostCountsByCompositionId = new Map<string, number>();
for (const hostEl of subCompositionHosts) {
const compId = (hostEl.getAttribute("data-composition-id") || "").trim();
if (!compId) continue;
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
}
const hostInstanceByCompositionId = new Map<string, number>();
for (const hostEl of subCompositionHosts) {
const src = hostEl.getAttribute("data-composition-src");
if (!src || !isRelativeUrl(src)) continue;
const compPath = safePath(projectDir, src);
Expand All @@ -442,6 +458,22 @@ export async function bundleToSingleHtml(
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const scopeCompId = compId || inferredCompId;
const duplicateInstance = scopeCompId && (hostCountsByCompositionId.get(scopeCompId) || 0) > 1;
const instanceIndex = duplicateInstance
? (hostInstanceByCompositionId.get(scopeCompId) || 0) + 1
: 0;
if (duplicateInstance) hostInstanceByCompositionId.set(scopeCompId, instanceIndex);
const runtimeCompId =
duplicateInstance && scopeCompId
? uniqueCompositionId(scopeCompId, instanceIndex)
: scopeCompId;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
: "";
if (duplicateInstance && runtimeCompId) {
hostEl.setAttribute("data-hf-original-composition-id", scopeCompId);
hostEl.setAttribute("data-composition-id", runtimeCompId);
}

// When a sub-composition is a full HTML document (no <template>), styles
// and scripts in <head> are not part of contentDoc (which only has body
Expand All @@ -450,7 +482,9 @@ export async function bundleToSingleHtml(
if (!contentRoot && compDoc.head) {
for (const s of [...compDoc.head.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
);
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
Expand All @@ -462,7 +496,9 @@ export async function bundleToSingleHtml(

for (const s of [...contentDoc.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
);
s.remove();
}
for (const s of [...contentDoc.querySelectorAll("script")]) {
Expand All @@ -480,6 +516,8 @@ export async function bundleToSingleHtml(
s.textContent || "",
scopeCompId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || scopeCompId,
)
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
Expand Down
Loading