mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { applyVariableBindings } from "./applyVariableBindings";
|
||||
import { getVariables } from "./getVariables";
|
||||
|
||||
type TestWindow = Window & {
|
||||
__hfVariables?: unknown;
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
__hyperframes?: { getVariables?: () => Record<string, unknown> };
|
||||
};
|
||||
|
||||
const win = window as TestWindow;
|
||||
|
||||
beforeEach(() => {
|
||||
win.__hyperframes = { getVariables };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete win.__hfVariables;
|
||||
delete win.__hfVariablesByComp;
|
||||
delete win.__hyperframes;
|
||||
document.documentElement.removeAttribute("data-composition-variables");
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function setDeclared(decls: unknown[]): void {
|
||||
document.documentElement.setAttribute("data-composition-variables", JSON.stringify(decls));
|
||||
}
|
||||
|
||||
describe("applyVariableBindings", () => {
|
||||
it("sets src from a string variable via data-var-src", () => {
|
||||
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root data-composition-id="c1">
|
||||
<img id="img" data-var-src="hero" src="fallback.jpg" />
|
||||
</div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.getElementById("img")?.getAttribute("src")).toBe("default.jpg");
|
||||
});
|
||||
|
||||
it("render-time overrides win, and {url} image values resolve", () => {
|
||||
setDeclared([{ id: "hero", type: "image", label: "Hero", default: "default.jpg" }]);
|
||||
win.__hfVariables = { hero: { url: "https://cdn/override.png" } };
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root><video data-var-src="hero" src="fallback.mp4"></video></div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("https://cdn/override.png");
|
||||
});
|
||||
|
||||
it("keeps the authored src when the variable resolves to nothing", () => {
|
||||
document.body.innerHTML = `<div data-hf-root><img data-var-src="ghost" src="keep.jpg" /></div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.querySelector("img")?.getAttribute("src")).toBe("keep.jpg");
|
||||
});
|
||||
|
||||
it("sets text content from a scalar via data-var-text", () => {
|
||||
setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]);
|
||||
win.__hfVariables = { title: "Overridden" };
|
||||
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">Authored</h1></div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.querySelector("h1")?.textContent).toBe("Overridden");
|
||||
});
|
||||
|
||||
it("applies scalar variables as --{id} custom props on the root", () => {
|
||||
setDeclared([
|
||||
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
|
||||
{ id: "count", type: "number", label: "Count", default: 3 },
|
||||
]);
|
||||
win.__hfVariables = { accent: "#ff0000" };
|
||||
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
|
||||
applyVariableBindings(document);
|
||||
const root = document.getElementById("root");
|
||||
expect(root?.style.getPropertyValue("--accent")).toBe("#ff0000");
|
||||
expect(root?.style.getPropertyValue("--count")).toBe("3");
|
||||
});
|
||||
|
||||
it("applies a font value's family name, and skips other objects", () => {
|
||||
win.__hfVariables = {
|
||||
brandFont: { name: "Inter", source: "https://fonts" },
|
||||
img: { url: "x" },
|
||||
};
|
||||
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
|
||||
applyVariableBindings(document);
|
||||
const root = document.getElementById("root");
|
||||
expect(root?.style.getPropertyValue("--brandFont")).toBe("Inter");
|
||||
expect(root?.style.getPropertyValue("--img")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves element children when binding text on a container", () => {
|
||||
win.__hfVariables = { title: "Replaced" };
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root>
|
||||
<h1 data-var-text="title">Hello <em id="kid" class="clip">world</em></h1>
|
||||
</div>`;
|
||||
applyVariableBindings(document);
|
||||
const h1 = document.querySelector("h1");
|
||||
expect(document.getElementById("kid")?.textContent).toBe("world");
|
||||
expect(h1?.childNodes[0]?.nodeValue).toBe("Replaced");
|
||||
});
|
||||
|
||||
it("is idempotent across re-application (loader re-apply path)", () => {
|
||||
win.__hfVariables = { title: "Once" };
|
||||
document.body.innerHTML = `<div data-hf-root><h1 data-var-text="title">t</h1></div>`;
|
||||
applyVariableBindings(document);
|
||||
applyVariableBindings(document);
|
||||
expect(document.querySelector("h1")?.textContent).toBe("Once");
|
||||
});
|
||||
|
||||
it("resolves sub-composition elements against their scoped values", () => {
|
||||
win.__hfVariablesByComp = { sub: { label: "Scoped" } };
|
||||
win.__hfVariables = { label: "TopLevel" };
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root data-composition-id="main">
|
||||
<p id="top" data-var-text="label">t</p>
|
||||
<div data-composition-id="sub"><p id="inner" data-var-text="label">s</p></div>
|
||||
</div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.getElementById("inner")?.textContent).toBe("Scoped");
|
||||
expect(document.getElementById("top")?.textContent).toBe("TopLevel");
|
||||
});
|
||||
|
||||
describe("security", () => {
|
||||
it("refuses data-var-src on a non-media tag (XSS sink)", () => {
|
||||
win.__hfVariables = { evil: "javascript:alert(document.cookie)" };
|
||||
document.body.innerHTML = `<div data-hf-root><iframe id="f" data-var-src="evil"></iframe></div>`;
|
||||
applyVariableBindings(document);
|
||||
// No src written — the iframe can't be turned into a javascript: executor.
|
||||
expect(document.getElementById("f")?.hasAttribute("src")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an unsafe URL protocol even on an allowed media tag", () => {
|
||||
win.__hfVariables = {
|
||||
evil: "javascript:alert(1)",
|
||||
data: "data:text/html,<script>x</script>",
|
||||
};
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root>
|
||||
<img id="a" data-var-src="evil" src="keep.jpg" />
|
||||
<video id="b" data-var-src="data" src="keep.mp4"></video>
|
||||
</div>`;
|
||||
applyVariableBindings(document);
|
||||
// Authored src preserved; the unsafe value is not applied.
|
||||
expect(document.getElementById("a")?.getAttribute("src")).toBe("keep.jpg");
|
||||
expect(document.getElementById("b")?.getAttribute("src")).toBe("keep.mp4");
|
||||
});
|
||||
|
||||
it("allows https, blob, relative, and image data: URLs on media tags", () => {
|
||||
win.__hfVariables = {
|
||||
https: "https://cdn/x.png",
|
||||
rel: "./local.png",
|
||||
img: "data:image/png;base64,AAAA",
|
||||
};
|
||||
document.body.innerHTML = `
|
||||
<div data-hf-root>
|
||||
<img id="h" data-var-src="https" src="f.png" />
|
||||
<img id="r" data-var-src="rel" src="f.png" />
|
||||
<img id="d" data-var-src="img" src="f.png" />
|
||||
</div>`;
|
||||
applyVariableBindings(document);
|
||||
expect(document.getElementById("h")?.getAttribute("src")).toBe("https://cdn/x.png");
|
||||
expect(document.getElementById("r")?.getAttribute("src")).toBe("./local.png");
|
||||
expect(document.getElementById("d")?.getAttribute("src")).toBe("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("strips declaration-smuggling characters from a CSS custom property value", () => {
|
||||
setDeclared([{ id: "accent", type: "string", label: "Accent", default: "red" }]);
|
||||
win.__hfVariables = { accent: "red; background: url(//evil?data=secret)" };
|
||||
document.body.innerHTML = `<div id="root" data-hf-root></div>`;
|
||||
applyVariableBindings(document);
|
||||
const css = document.getElementById("root")?.style.getPropertyValue("--accent") ?? "";
|
||||
expect(css).not.toContain(";");
|
||||
expect(css).not.toContain("{");
|
||||
expect(css).not.toContain("<");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Declarative variable bindings — the no-script consumption channel for
|
||||
* composition variables (values are fixed for the page's lifetime, so this is
|
||||
* seek-safe and deterministic):
|
||||
*
|
||||
* - `data-var-src="id"` — sets the element's `src` from the variable value
|
||||
* (a URL string or an image value `{url}`). Only allowed on media elements
|
||||
* (img/video/audio/source) and only for safe URL protocols — a src on a
|
||||
* script-executing tag or a `javascript:`/`data:text/html` value is refused.
|
||||
* The authored src stays as the fallback when the variable resolves to nothing.
|
||||
* - `data-var-text="id"` — sets the element's OWN text from a scalar variable
|
||||
* value. Elements with element children keep them: only the direct text
|
||||
* node is replaced, mirroring the SDK's setOwnText semantics — a text
|
||||
* binding must never delete nested clips or animation targets.
|
||||
* - Every scalar variable (and a font value's family name) is applied as a
|
||||
* `--{id}` CSS custom property on its composition root, so CSS bindings
|
||||
* like `color: var(--accent)` respond to render/preview overrides instead
|
||||
* of only the persisted default.
|
||||
*
|
||||
* Values resolve against the element's owning composition — the same scope
|
||||
* chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
|
||||
* inlined sub-compositions, then the top-level merged `getVariables()`.
|
||||
*
|
||||
* Applied at init AND re-applied after the composition loader inlines
|
||||
* external / template sub-compositions (their DOM and per-instance scoped
|
||||
* values don't exist at init). Idempotent: re-applying writes the same
|
||||
* values.
|
||||
*/
|
||||
|
||||
import { readVariablesForElement } from "./variableScope";
|
||||
import { isScalarVariableValue as isScalar } from "@hyperframes/parsers/composition";
|
||||
|
||||
// data-var-src only rebinds media `src` on media elements. A user-controlled
|
||||
// variable value assigned to a src is an XSS surface on tags whose src executes
|
||||
// (`<iframe src="javascript:…">`, `<script src="data:…">`, `<embed>`), so the
|
||||
// binding is scoped to elements where `src` is purely a media reference.
|
||||
const VAR_SRC_TAGS = new Set(["img", "video", "audio", "source"]);
|
||||
|
||||
function resolveUrl(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const url = (value as { url?: unknown }).url;
|
||||
if (typeof url === "string" && url.length > 0) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol allowlist for a resolved media URL. Relative URLs (no scheme) resolve
|
||||
* against the page origin and are always safe. Absolute URLs are restricted to
|
||||
* http(s)/blob and image data: URIs — defense-in-depth alongside VAR_SRC_TAGS,
|
||||
* blocking `javascript:`, `data:text/html`, `file:`, etc. even if a future tag
|
||||
* slips past the element guard. Control chars are stripped before the scheme
|
||||
* test because browsers ignore them when parsing the URL (`java\tscript:`).
|
||||
*/
|
||||
function isSafeMediaUrl(url: string): boolean {
|
||||
// Browsers ignore ASCII control chars/whitespace when parsing a URL, so strip
|
||||
// them before reading the scheme (defeats `java\tscript:` style bypasses).
|
||||
// oxlint-disable-next-line no-control-regex -- control chars are the target here
|
||||
const normalized = url.replace(/[\u0000-\u0020]/g, "");
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
|
||||
if (!scheme) return true;
|
||||
const proto = scheme[1].toLowerCase();
|
||||
if (proto === "https" || proto === "http" || proto === "blob") return true;
|
||||
if (proto === "data") return /^data:image\//i.test(normalized);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip characters that could smuggle additional declarations or markup out of
|
||||
* a var() substitution site. A scalar value folded into `background: var(--x)`
|
||||
* or `background-image: url(var(--x))` must not be able to close the declaration
|
||||
* and inject a new one (`red; background: url(//evil?data=…)`) — none of these
|
||||
* characters is legal in a scalar variable value (string, number, color, font
|
||||
* family), so removing them is lossless for real inputs and neutralizes the
|
||||
* declaration/URL-exfiltration channel.
|
||||
*/
|
||||
function sanitizeCssValue(value: string): string {
|
||||
return value.replace(/[;{}<>\r\n]/g, "");
|
||||
}
|
||||
|
||||
/** CSS custom-property value for a variable, or null when not CSS-applicable. */
|
||||
function cssValueFor(value: unknown): string | null {
|
||||
if (isScalar(value)) return String(value);
|
||||
if (value !== null && typeof value === "object") {
|
||||
// Font values apply their family name; the face itself must be loaded by
|
||||
// the composition (or the media pipeline).
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === "string" && name.length > 0) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-run memo of scope element → resolved values, so N bound elements in
|
||||
* one scope pay for one resolution (the top-level path re-parses the
|
||||
* declarations attribute on every getVariables() call).
|
||||
*/
|
||||
type ScopeValuesCache = Map<Element | null, Record<string, unknown>>;
|
||||
|
||||
function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string, unknown> {
|
||||
const scope = el.closest("[data-composition-id]");
|
||||
const cached = cache.get(scope);
|
||||
if (cached) return cached;
|
||||
const values = readVariablesForElement(el);
|
||||
cache.set(scope, values);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the element's own text while preserving element children (nested
|
||||
* clips, animation-target spans). Mirrors the SDK's setOwnText: write the
|
||||
* first direct text node, clear the others; append when none exists.
|
||||
*/
|
||||
function setOwnTextPreservingChildren(el: Element, text: string): void {
|
||||
if (el.childElementCount === 0) {
|
||||
el.textContent = text;
|
||||
return;
|
||||
}
|
||||
let written = false;
|
||||
for (const node of Array.from(el.childNodes)) {
|
||||
if (node.nodeType !== Node.TEXT_NODE) continue;
|
||||
node.nodeValue = written ? "" : text;
|
||||
written = true;
|
||||
}
|
||||
if (!written) {
|
||||
el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition root, matching the SDK's findRoot chain exactly — the SDK
|
||||
* persists `--{id}` defaults on this element, so the runtime must write
|
||||
* overrides to the SAME element or an inline default on a descendant would
|
||||
* shadow an override applied higher up.
|
||||
*/
|
||||
function findTopRoot(doc: Document): Element | null {
|
||||
return (
|
||||
doc.querySelector("[data-hf-root]") ??
|
||||
doc.getElementById("stage") ??
|
||||
doc.body?.firstElementChild ??
|
||||
doc.body
|
||||
);
|
||||
}
|
||||
|
||||
function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void {
|
||||
// Top-level root plus every inlined sub-composition root; custom props
|
||||
// inherit, so descendants of each root see its scope's values.
|
||||
const roots = new Set<Element>();
|
||||
const topRoot = findTopRoot(doc);
|
||||
if (topRoot) roots.add(topRoot);
|
||||
for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) {
|
||||
roots.add(el);
|
||||
}
|
||||
for (const root of roots) {
|
||||
const values = valuesForElement(root, cache);
|
||||
for (const [id, value] of Object.entries(values)) {
|
||||
const css = cssValueFor(value);
|
||||
if (css !== null && root instanceof HTMLElement) {
|
||||
root.style.setProperty(`--${id}`, sanitizeCssValue(css));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVariableBindings(doc: Document): void {
|
||||
const cache: ScopeValuesCache = new Map();
|
||||
applyCssCustomProperties(doc, cache);
|
||||
|
||||
for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) {
|
||||
const id = el.getAttribute("data-var-src")?.trim();
|
||||
if (!id) continue;
|
||||
// Only media elements may take a variable-driven src (see VAR_SRC_TAGS) — a
|
||||
// src on <iframe>/<script>/<embed> is a code-execution sink, not a media ref.
|
||||
if (!VAR_SRC_TAGS.has(el.tagName.toLowerCase())) {
|
||||
console.warn(
|
||||
`[hyperframes] Ignoring data-var-src on <${el.tagName.toLowerCase()}>: variable-bound src is only allowed on ${Array.from(VAR_SRC_TAGS).join("/")}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const url = resolveUrl(valuesForElement(el, cache)[id]);
|
||||
if (url === null) continue;
|
||||
if (!isSafeMediaUrl(url)) {
|
||||
console.warn(`[hyperframes] Ignoring data-var-src="${id}": unsafe URL protocol.`);
|
||||
continue;
|
||||
}
|
||||
el.setAttribute("src", url);
|
||||
}
|
||||
|
||||
for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) {
|
||||
const id = el.getAttribute("data-var-text")?.trim();
|
||||
if (!id) continue;
|
||||
const value = valuesForElement(el, cache)[id];
|
||||
if (isScalar(value)) setOwnTextPreservingChildren(el, String(value));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
type HfColorGradingVariableMap,
|
||||
type HfColorGradingTarget,
|
||||
type NormalizedHfColorGrading,
|
||||
} from "../colorGrading";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
type CubeLutVec3,
|
||||
} from "../colorLuts";
|
||||
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
|
||||
import { readVariablesForElement } from "./variableScope";
|
||||
import { swallow } from "./diagnostics";
|
||||
|
||||
type ColorGradingMediaElement = HTMLVideoElement | HTMLImageElement;
|
||||
@@ -207,20 +207,6 @@ const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
|
||||
lineWidth: 2,
|
||||
};
|
||||
|
||||
function readVariablesForElement(element: Element): HfColorGradingVariableMap {
|
||||
const win = window as WindowWithColorGrading;
|
||||
const scope = element.closest("[data-composition-id]");
|
||||
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
|
||||
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
|
||||
if (scoped) return scoped;
|
||||
|
||||
const fromHelper = win.__hyperframes?.getVariables?.();
|
||||
if (fromHelper && typeof fromHelper === "object") {
|
||||
return fromHelper;
|
||||
}
|
||||
return win.__hfVariables ?? {};
|
||||
}
|
||||
|
||||
function readColorGradingAttribute(element: Element): NormalizedHfColorGrading | null {
|
||||
const raw = element.getAttribute(HF_COLOR_GRADING_ATTR);
|
||||
if (raw == null) return null;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createClipTree } from "./clipTree";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { applyCaptionOverrides } from "./captionOverrides";
|
||||
import { applyPositionEdits } from "./positionEdits";
|
||||
import { applyVariableBindings } from "./applyVariableBindings";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
@@ -85,6 +86,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
// parsed their tweens, so GSAP (when present) won't fold the translate.
|
||||
// Re-applied on every timeline bind for the rebind/soft-reload paths.
|
||||
applyPositionEdits(document);
|
||||
// Declarative variable bindings (data-var-src / data-var-text / --{id} CSS
|
||||
// custom props) — values are fixed for the page's lifetime, so applying
|
||||
// once at init keeps renders deterministic and seeks safe.
|
||||
applyVariableBindings(document);
|
||||
const exportRenderFps = resolveExportRenderFps();
|
||||
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
|
||||
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
|
||||
@@ -2015,6 +2020,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
bindMediaMetadataListeners();
|
||||
installAssetFailureDiagnostics();
|
||||
applyCaptionOverrides();
|
||||
// Runtime-loaded sub-compositions (and their per-instance scoped
|
||||
// values) don't exist at the init-time binding pass — re-apply so
|
||||
// data-var-* / --{id} bindings inside them resolve. Idempotent.
|
||||
applyVariableBindings(document);
|
||||
maybePublishRenderReady();
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Resolve the composition-variable values an element should see: the scoped
|
||||
* per-instance table for inlined sub-compositions, then the top-level merged
|
||||
* getVariables(), then the raw render-injection global. Shared by every
|
||||
* runtime consumer of variables (color grading, declarative bindings) so the
|
||||
* scope chain can never diverge between channels.
|
||||
*/
|
||||
|
||||
type VariablesWindow = Window & {
|
||||
__hfVariables?: Record<string, unknown>;
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
__hyperframes?: { getVariables?: () => Record<string, unknown> };
|
||||
};
|
||||
|
||||
export function readVariablesForElement(element: Element): Record<string, unknown> {
|
||||
const win = window as VariablesWindow;
|
||||
const scope = element.closest("[data-composition-id]");
|
||||
const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
|
||||
const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
|
||||
if (scoped) return scoped;
|
||||
const fromHelper = win.__hyperframes?.getVariables?.();
|
||||
if (fromHelper && typeof fromHelper === "object") {
|
||||
return fromHelper;
|
||||
}
|
||||
return win.__hfVariables ?? {};
|
||||
}
|
||||
Reference in New Issue
Block a user