import postcss, { type AtRule, type Node, type Rule } from "postcss";
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
const INNER_ROOT_ATTR = "data-hf-inner-root";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeCssAttributeValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function escapeCssIdentifier(value: string): string {
if (!value) return value;
const escaped = value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
return escaped.replace(/^-?\d/, (match) => `\\${match}`);
}
function getAuthoredRootIdSelectorForms(authoredRootId: string): string[] {
const trimmed = authoredRootId.trim();
if (!trimmed) return [];
return Array.from(new Set([trimmed, escapeCssIdentifier(trimmed)])).filter(Boolean);
}
function isSelectorNameChar(char: string | undefined): boolean {
return !!char && /[\w-]/.test(char);
}
function replaceAuthoredRootIdSelectors(
selector: string,
authoredRootId: string,
replacement: string,
): string {
const forms = getAuthoredRootIdSelectorForms(authoredRootId).sort((a, b) => b.length - a.length);
if (forms.length === 0) return selector;
let result = "";
let bracketDepth = 0;
let quote: '"' | "'" | null = null;
for (let index = 0; index < selector.length; index += 1) {
const char = selector[index];
const previousChar = index > 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
const matchedForm = forms.find((form) => selector.startsWith(form, index + 1));
if (matchedForm) {
const nextChar = selector[index + 1 + matchedForm.length];
if (!isSelectorNameChar(nextChar)) {
result += replacement;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
}
function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: string | null): string {
const trimmed = authoredRootId?.trim();
if (!trimmed) return selector;
return replaceAuthoredRootIdSelectors(
selector,
trimmed,
`[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(trimmed)}"]`,
);
}
/** The composition's own box: the host when it renders content directly, or the
* flattened inner root when one is preserved below the host. Used both for a
* bare composition-root selector and for remapped document-level selectors.
* Relies on `:has()` (Chrome 105 / Safari 15.4 / Firefox 121) — an existing
* baseline for the bare-root case, noted here for new callers. */
function compositionBoxSelector(scope: string): string {
return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`;
}
function scopeSelector(
selector: string,
scope: string,
compositionId: string,
authoredRootId?: string | null,
compoundAuthoredRoot?: boolean,
scopeRootSelectors?: boolean,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
selectorWithoutAuthoredRootId,
scope,
compositionId,
);
const trimmed = selectorWithoutRootTiming.trim();
if (!trimmed) return selector;
if (trimmed === "*") return selector;
if (/^(html|body|:root)$/i.test(trimmed)) {
// A mounted/inlined sub-comp's document-level selectors must not style the
// PARENT (a sub-comp `body { width/height/overflow }` would clobber the host
//
and clip the preview/render). Remap to the comp's own box. A
// top-level compile (scopeRootSelectors falsy) legitimately owns the document.
//
// Coverage is intentionally BARE-only: compound forms (`body.dark`,
// `body[data-theme]`, `body:hover`, `html body`, `:root .x`) fall through to
// general scoping below. That is byte-identical to pre-fix behavior — those
// selectors never matched the parent (it has no data-composition-id),
// so there was no clobber to fix. The bare forms are the ones that actually
// caused the parent-body clobber, which is what this remap targets.
return scopeRootSelectors ? compositionBoxSelector(scope) : selector;
}
const compositionIdPattern = new RegExp(
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
);
if (compositionIdPattern.test(trimmed)) {
const isRootBoxSelector = trimmed.replace(compositionIdPattern, "").trim() === "";
if (isRootBoxSelector) {
// A bare root selector styles the composition's own box (flex/grid/
// position/padding). When flattenInnerRoot preserves the authored root
// as a wrapper below `scope` (see prepareFlattenedInnerRoot), that
// wrapper is the element real children are laid out in, not `scope`
// itself, so the box styling must land there instead. It must land on
// exactly one of the two: applying it to both compounds any additive
// property (padding, margin, non-zero transform) since the wrapper
// sits nested inside the host and would inherit the effect twice.
return compositionBoxSelector(scope);
}
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
if (compoundAuthoredRoot) {
const authoredRootAttr = authoredRootId
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
: null;
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
const rest = trimmed.slice(authoredRootAttr.length);
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
}
}
return `${leading}${scope} ${trimmed}${trailing}`;
}
function normalizeCompositionRootSelector(
selector: string,
scope: string,
compositionId: string,
): string {
const quotedCompId = escapeRegExp(compositionId);
const compAttr = String.raw`\[\s*data-composition-id\s*=\s*(?:"${quotedCompId}"|'${quotedCompId}')\s*\]`;
const timingAttr = String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`;
return selector
.replace(new RegExp(`${compAttr}(?:${timingAttr})+`, "g"), scope)
.replace(new RegExp(`(?:${timingAttr})+${compAttr}`, "g"), scope);
}
const GLOBAL_AT_RULES = new Set(["keyframes", "-webkit-keyframes", "font-face"]);
function isAtRuleNode(node: Node["parent"]): node is AtRule {
return node?.type === "atrule";
}
function isInsideGlobalAtRule(rule: Rule): boolean {
let current: Node["parent"] = rule.parent;
while (current) {
if (isAtRuleNode(current) && GLOBAL_AT_RULES.has(current.name.toLowerCase())) {
return true;
}
current = current.parent;
}
return false;
}
/**
* A Rule nested inside another Rule (CSS Nesting Module Level 1) already
* inherits scope from its parent's `&` prefix at match time — re-applying
* the composition scope to the nested selector produces
* ` .child`, which matches nothing when the composition
* root only appears once in the DOM. Only top-level rules get scoped;
* their nested descendants inherit the scope naturally via CSS nesting.
* See #2721 for the reproducer that motivated this.
*/
function isNestedInsideAnotherRule(rule: Rule): boolean {
let current: Node["parent"] = rule.parent;
while (current) {
if (current.type === "rule") return true;
current = current.parent;
}
return false;
}
export function scopeCssToComposition(
css: string,
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
options?: { compoundAuthoredRoot?: boolean; scopeRootSelectors?: boolean },
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
const scope =
scopeSelectorOverride ||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const root = postcss.parse(css);
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
if (isNestedInsideAnotherRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
scopeSelector(
selector,
scope,
trimmedCompositionId,
authoredRootId,
options?.compoundAuthoredRoot,
options?.scopeRootSelectors,
),
);
});
return root.toResult({ map: false }).css;
}
/**
* Serialize a value as a JS literal safe to emit inside a `` would close
* the element early and have the remainder parsed as markup. Rewriting every
* `<` to `<` removes the only byte that can start a closing tag, and is
* transparent to both `JSON.parse` and the JS string grammar, so the value the
* runtime reads is unchanged.
*
* Every dynamic literal in an emitted script body must go through here: a
* per-value guard on this surface has already been missed once, since the
* composition id reaches the emitted script through four separate literals.
*/
function jsonScriptLiteral(value: unknown): string {
return JSON.stringify(value).replace(/ 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
var matchedForm = null;
for (var formIndex = 0; formIndex < __hfAuthoredRootIdForms.length; formIndex += 1) {
var form = __hfAuthoredRootIdForms[formIndex];
if (selector.slice(index + 1, index + 1 + form.length) === form) {
matchedForm = form;
break;
}
}
if (matchedForm) {
var nextChar = selector[index + 1 + matchedForm.length];
if (!__hfIsSelectorNameChar(nextChar)) {
result += __hfAuthoredRootSelector;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
};
var __hfNormalizeSelector = function(selector) {
if (!__hfCompId || typeof selector !== "string") return selector;
var normalized = selector
.replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector)
.replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector);
if (__hfAuthoredRootSelector) {
normalized = __hfReplaceAuthoredRootIdSelectors(normalized);
}
return normalized;
};
var __hfFindRoot = function() {
if (!__hfRoot && __hfRootSelector) {
__hfRoot = window.document.querySelector(__hfRootSelector);
}
return __hfRoot;
};
var __hfContains = function(node) {
var root = __hfFindRoot();
return !root || node === root || root.contains(node);
};
var __hfQueryAll = function(selector) {
var root = __hfFindRoot();
if (!root || typeof selector !== "string") {
return window.document.querySelectorAll(selector);
}
return Array.prototype.filter.call(window.document.querySelectorAll(__hfNormalizeSelector(selector)), function(node) {
return __hfContains(node);
});
};
var __hfQueryOne = function(selector) {
var matches = __hfQueryAll(selector);
return matches[0] || null;
};
var __hfGetElementById = function(id) {
var found = window.document.getElementById(id);
if (found && __hfContains(found)) return found;
var root = __hfFindRoot();
if (!root) return found || null;
var idValue = id + "";
if (__hfAuthoredRootId && __hfAuthoredRootId === idValue && root.getAttribute && root.getAttribute(__hfAuthoredRootAttr) === idValue) {
return root;
}
if (root.id === idValue) return root;
if (typeof root.querySelector !== "function") return null;
try {
var authoredRootMatch = root.querySelector('[' + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(idValue) + '"]');
if (authoredRootMatch) return authoredRootMatch;
} catch {}
if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") {
try {
return root.querySelector("#" + CSS.escape(idValue)) || null;
} catch {}
}
try {
return root.querySelector('[id="' + __hfEscapeAttr(idValue) + '"]') || null;
} catch {}
return null;
};
var __hfScopedDocument = typeof Proxy === "function"
? new Proxy(window.document, {
get: function(target, prop, receiver) {
if (prop === "querySelector") return __hfQueryOne;
if (prop === "querySelectorAll") return __hfQueryAll;
if (prop === "getElementById") return __hfGetElementById;
var value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
})
: 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) {
if (prop !== __hfCompId) {
return Reflect.get(target, prop, target);
}
var authoredValue = Reflect.get(target, prop, target);
return authoredValue === undefined
? Reflect.get(target, __hfTimelineCompId, target)
: authoredValue;
},
set: function(target, prop, value, receiver) {
if (prop !== __hfCompId) {
return Reflect.set(target, prop, value, target);
}
// The authored node remains in the compiled DOM when its local id
// differs from the runtime mount id, so readiness legitimately sees
// both compositions. Publish the same timeline under both identities
// instead of replacing one with the other.
var authoredSet = Reflect.set(target, __hfCompId, value, target);
var runtimeSet = Reflect.set(target, __hfTimelineCompId, value, target);
return authoredSet && runtimeSet;
},
});
}
return __hfTimelineRegistryProxy;
};
var __hfScopedWindow = typeof Proxy === "function"
? new Proxy(window, {
get: function(target, prop, receiver) {
if (prop === "__timelines") return __hfGetTimelineRegistry();
// Inside a sub-composition, __hyperframes is passed as a bare script
// param bound to the SCOPED variant (per-comp getVariables). But
// authors routinely write the documented window.__hyperframes.
// getVariables() form, which would otherwise fall through to the host
// page's base __hyperframes and return the WRONG (or empty) variables
// for this instance. Route it to the scoped variant too so both
// spellings resolve to this composition's own variables.
// (__hfScopedHyperframes is a hoisted var assigned below, before any
// sub-comp script -- the only code that reads this -- runs.)
if (prop === "__hyperframes") return __hfScopedHyperframes;
return Reflect.get(target, prop, target);
},
set: function(target, prop, value, receiver) {
if (prop === "__timelines") {
// Common authoring boilerplate assigns the registry back to
// itself (window.__timelines = window.__timelines || {}). The
// getter above returns our proxy; do not replace the canonical
// registry with that proxy or later wrappers will stack proxies.
if (value === __hfTimelineRegistryProxy) return true;
target.__timelines = value || {};
__hfTimelineRegistryProxy = null;
return true;
}
return Reflect.set(target, prop, value, target);
},
})
: window;
var __hfResolveGsapTarget = function(target) {
if (typeof target !== "string") return target;
return __hfQueryAll(target);
};
var __hfScopeTimeline = function(timeline) {
if (!timeline || timeline.__hfScopedCompositionRoot === __hfFindRoot()) return timeline;
["to", "from", "fromTo", "set"].forEach(function(method) {
var original = timeline[method];
if (typeof original !== "function") return;
timeline[method] = function(target) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(target);
return original.apply(timeline, args);
};
});
try {
Object.defineProperty(timeline, "__hfScopedCompositionRoot", {
value: __hfFindRoot(),
configurable: true,
});
} catch {
// Best-effort: timelines coming from user code may have a frozen target
// or a non-extensible defineProperty path. Swallow — the scoped root
// is an enrichment, not a correctness invariant for playback.
}
return timeline;
};
var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap;
var __hfScopedGsap = !__hfBaseGsap || typeof Proxy !== "function"
? __hfBaseGsap
: new Proxy(__hfBaseGsap, {
get: function(target, prop, receiver) {
if (prop === "timeline") {
return function() {
return __hfScopeTimeline(target.timeline.apply(target, arguments));
};
}
if (prop === "to" || prop === "from" || prop === "fromTo" || prop === "set") {
return function(firstArg) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(firstArg);
return target[prop].apply(target, args);
};
}
if (prop === "utils" && target.utils && typeof Proxy === "function") {
return new Proxy(target.utils, {
get: function(utilsTarget, utilsProp, utilsReceiver) {
if (utilsProp === "toArray") {
return function(firstArg) {
var args = Array.prototype.slice.call(arguments);
args[0] = __hfResolveGsapTarget(firstArg);
return utilsTarget.toArray.apply(utilsTarget, args);
};
}
if (utilsProp === "selector") {
return function(base) {
var baseEl = typeof base === "string" ? __hfQueryOne(base) : base;
var root = baseEl || __hfFindRoot();
return function(selector) {
if (!root || typeof selector !== "string") return [];
return Array.prototype.filter.call(
window.document.querySelectorAll(__hfNormalizeSelector(selector)),
function(node) {
return node === root || (typeof root.contains === "function" && root.contains(node));
},
);
};
};
}
var value = Reflect.get(utilsTarget, utilsProp, utilsTarget);
return typeof value === "function" ? value.bind(utilsTarget) : value;
},
});
}
var value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
var __hfBaseHyperframes = window.__hyperframes;
var __hfScopedHyperframes = !__hfBaseHyperframes
? __hfBaseHyperframes
: Object.assign({}, __hfBaseHyperframes, {
getVariables: function() {
var byComp = window.__hfVariablesByComp;
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
return scoped ? Object.assign({}, scoped) : {};
},
});
var __hfRun = function() {
try {
(function(document, gsap, window, __hyperframes) {
${source.replace(/<\/(script)/gi, "<\\/$1")}
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
};
__hfFindRoot();
__hfRun();
})();`;
}
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
return `(function(){ try { Function(${jsonScriptLiteral(source)}).call(window); } catch (_err) { console.error(${jsonScriptLiteral(errorLabel)}, _err); } })();`;
}
/**
* Build the statement that populates `window.__hfVariablesByComp` — the table
* the scoped `getVariables` above reads. Returns `null` when there are no
* per-instance values.
*
* The WRITER lives next to the READER (the scoped `getVariables` in
* `wrapScopedCompositionScript`) on purpose: every compile path that wraps the
* reader MUST also emit this writer before the sub-comp scripts run. The
* render compiler (`htmlCompiler`) inlined the reader scripts but never emitted
* the writer while the preview bundler (`htmlBundler`) did, so
* `getVariables()` returned `{}` only during render — parametrized sub-comps
* silently shipped blank/default text in the final MP4 while snapshot QA passed
* (issue #2064). Both callers now share this one builder so they can't drift.
*
* Values, keys and composition ids are all attacker-reachable, so the whole
* table goes through `jsonScriptLiteral` — see there for why.
*/
export function buildVariablesByCompScript(
variablesByComp: Record>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
const json = jsonScriptLiteral(variablesByComp);
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${json});`;
}