Skip to content
13 changes: 13 additions & 0 deletions docs/packages/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ const html = generateHyperframesHtml(elements, 6, {
The second argument is the requested duration in seconds. Pass a stable
`compositionId` when output must be reproducible.

Composition generators require trusted authors for code-bearing inputs. `styles` and
`generateHyperframesStyles` preserve authored CSS, which can load external resources.
`animations` may contain `__raw:` values that are emitted as JavaScript;
`includeScripts: true` includes executable timeline code. `serializeGsapAnimations`
also accepts raw `preamble`, `postamble`, and a code-bearing `timelineVar`. Never fill
these inputs with untrusted data. Attribute encoding and closing-tag containment
are not a sandbox; render untrusted compositions in an appropriately isolated
execution environment and never serve them on a privileged origin.

Text content retains the supported inline-formatting sanitizer contract. The clip
parser intentionally flattens inner formatting to text, so parse/generate is not
a lossless replacement for editing the source HTML.

## Read and validate variables

Inside a composition script, `getVariables()` reads declared defaults plus the
Expand Down
15 changes: 15 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ npm install @hyperframes/core
| **Runtime** | IIFE script injected into the browser — manages seek, media playback, and the `window.__hf` protocol |
| **Frame Adapters** | Pluggable animation drivers (GSAP, Lottie, CSS, or custom) |

## Generated composition trust

Composition generators require trusted authors for code-bearing inputs. `styles` and
`generateHyperframesStyles` preserve authored CSS, which can load external resources.
`animations` may contain `__raw:` values that are emitted as JavaScript;
`includeScripts: true` includes executable timeline code. `serializeGsapAnimations`
also accepts raw `preamble`, `postamble`, and a code-bearing `timelineVar`. Never fill
these inputs with untrusted data. Attribute encoding and closing-tag containment
are not a sandbox; render untrusted compositions in an appropriately isolated
execution environment and never serve them on a privileged origin.

Text content retains the supported inline-formatting sanitizer contract. The clip
parser intentionally flattens inner formatting to text, so parse/generate is not
a lossless replacement for editing the source HTML.

## Frame Adapters

A frame adapter tells the engine how to seek your animation to a specific frame:
Expand Down
187 changes: 184 additions & 3 deletions packages/core/src/generators/hyperframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import {
generateGsapTimelineScript,
generateHyperframesStyles,
} from "./hyperframes.js";
import { parseHtml } from "@hyperframes/parsers";
import { GSAP_CDN } from "../templates/constants.js";
import type { TimelineTextElement, TimelineMediaElement } from "../core.types";
import type {
TimelineTextElement,
TimelineMediaElement,
TimelineCompositionElement,
} from "../core.types";

function makeTextElement(overrides: Partial<TimelineTextElement> = {}): TimelineTextElement {
return {
Expand Down Expand Up @@ -37,6 +42,172 @@ function makeVideoElement(overrides: Partial<TimelineMediaElement> = {}): Timeli
}

describe("generateHyperframesHtml", () => {
it("contains mixed-case style closing tags in authored CSS", () => {
const styles = '.label::after { content: "</StYlE><script>bad()</script>"; }';
const doc = new DOMParser().parseFromString(
generateHyperframesHtml([], 1, { styles, includeStyles: true }),
"text/html",
);
expect(doc.querySelectorAll("style")).toHaveLength(2);
expect(doc.querySelector("script")).toBeNull();
expect(doc.querySelector("style[data-hf-custom]")?.textContent).toContain("StYlE");
});

it("contains script closing tags while retaining JS string values and raw expressions", () => {
const targetSelector = '#x"</ScRiPt><script>bad()</script>';
const position = 'label"; bad(); //';
const doc = new DOMParser().parseFromString(
generateHyperframesHtml([], 1, {
includeScripts: true,
animations: [
{ targetSelector, method: "to", position, properties: { x: "__raw:1 < 2 ? 3 : 4" } },
],
}),
"text/html",
);
expect(doc.querySelectorAll("script")).toHaveLength(2);
const script = doc.querySelector("script:not([src])")?.textContent ?? "";
const calls: unknown[][] = [];
const gsap = { timeline: () => ({ to: (...args: unknown[]) => calls.push(args) }) };
new Function("gsap", script)(gsap);
expect(calls).toEqual([[targetSelector, { x: 3 }, position]]);
});

it("round-trips element attribute values without creating event handlers", () => {
const marker = `x" onmouseover="bad()&quot;<`;
const element = makeVideoElement({ id: marker, name: marker, src: marker });
const doc = new DOMParser().parseFromString(generateHyperframesHtml([element], 1), "text/html");
const video = doc.querySelector("video");
for (const name of ["id", "data-hf-id", "data-name", "src"])
expect(video?.getAttribute(name)).toBe(marker);
expect(video?.hasAttribute("onmouseover")).toBe(false);
expect(doc.querySelector("script")).toBeNull();
});

it("keeps special IDs targeted by generated visibility animations", () => {
const element = makeTextElement({ id: '9 title"[x],#other', name: "Title" });
const doc = new DOMParser().parseFromString(generateHyperframesHtml([element], 1), "text/html");
const targets: string[] = [];
const gsap = { timeline: () => ({ set: (selector: string) => targets.push(selector) }) };
new Function("gsap", generateGsapTimelineScript([element], 1))(gsap);
expect(targets.length).toBeGreaterThan(0);
for (const selector of targets) expect(doc.querySelector(selector)?.id).toBe(element.id);
});

it("preserves supported rich text while removing executable markup", () => {
const content =
'<strong>A<span style="font-size: 32px; color: red" onclick="bad()">B</span></strong><br><sup>C</sup><script>bad()</script><svg onload="bad()"></svg><span style="background-color: url(evil)">D</span>';
const html = generateHyperframesHtml([makeTextElement({ content })], 1);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(doc.querySelector("strong span")?.getAttribute("style")).toContain("font-size: 32px");
expect(doc.querySelector("strong span")?.hasAttribute("onclick")).toBe(false);
expect(doc.querySelector("br")).not.toBeNull();
expect(doc.querySelector("script,svg,sup,[onclick]")).toBeNull();
expect(doc.querySelector("#text-1")?.textContent).toBe("ABCD");
expect(html).not.toContain("url(evil)");
expect(parseHtml(html).elements[0]).toMatchObject({ content: "ABCD" });
});

it.each(["", "&amp;quot; &amp;#39; &amp;lt;"])(
"preserves empty and entity-looking caption content: %s",
(content) => {
const html = generateHyperframesHtml([makeTextElement({ content })], 1);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(doc.querySelector("#text-1")?.textContent).toBe(content ? "&quot; &#39; &lt;" : "");
expect(parseHtml(html).elements[0]).toMatchObject({
content: content ? "&quot; &#39; &lt;" : "",
});
},
);

it.each([
"javascript:bad()",
"java\nscript:bad()",
"vbscript:bad()",
"data:text/html,<script>bad()</script>",
])("rejects executable source URLs: %s", (src) => {
expect(() => generateHyperframesHtml([makeVideoElement({ src })], 1)).toThrow(
"Unsafe media or composition source URL",
);
});

it("rejects JavaScript supplied through a numeric element field", () => {
const element = { ...makeTextElement(), startTime: "0); bad(); //" };
expect(() => Reflect.apply(generateGsapTimelineScript, undefined, [[element], 1])).toThrow(
"finite generator numeric value",
);
});

it("rejects declaration breakouts in generated color values", () => {
expect(() =>
generateHyperframesStyles(
[makeTextElement({ color: "red; } body { color: blue" })],
"landscape",
),
).toThrow("Invalid generated CSS value");
});

it("keeps composition identifiers inside their attribute and round-trips entities", () => {
const compositionId = `x" autofocus onfocus="alert(1)'><script>bad()</script>&quot;&`;
const doc = new DOMParser().parseFromString(
generateHyperframesHtml([], 1, { compositionId }),
"text/html",
);
expect(doc.documentElement.getAttribute("data-composition-id")).toBe(compositionId);
expect(doc.documentElement.hasAttribute("autofocus")).toBe(false);
expect(doc.documentElement.hasAttribute("onfocus")).toBe(false);
expect(doc.querySelector("script")).toBeNull();
});

it("contains resolution values supplied by JavaScript callers inside their attribute", () => {
const resolution = `x" autofocus onfocus="alert(1)'><script>bad()</script>&quot;&`;
const html = Reflect.apply(generateHyperframesHtml, undefined, [
[],
1,
{ resolution, includeStyles: false, includeScripts: false },
]);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(doc.documentElement.getAttribute("data-resolution")).toBe(resolution);
expect(doc.documentElement.hasAttribute("autofocus")).toBe(false);
expect(doc.documentElement.hasAttribute("onfocus")).toBe(false);
expect(doc.querySelector("script")).toBeNull();
});

it("round-trips entity-bearing CSS through the JSON metadata attribute", () => {
const styles = `.x::after { content: "&quot; &#39; &amp; < > '"; }`;
const doc = new DOMParser().parseFromString(
generateHyperframesHtml([], 1, { styles }),
"text/html",
);
expect(JSON.parse(doc.documentElement.getAttribute("data-custom-styles")!)).toBe(styles);
expect(doc.querySelector("style")).toBeNull();
expect(parseHtml(generateHyperframesHtml([], 1, { styles })).styles).toBe(styles);
});

it("round-trips composition variable metadata through the public parser", () => {
const variableValues = { label: `&quot; &#39; &amp; < > '`, count: 2, enabled: true };
const element: TimelineCompositionElement = {
id: "nested",
type: "composition",
name: "Nested",
startTime: 0,
duration: 1,
zIndex: 0,
src: "nested.html",
compositionId: "nested-comp",
variableValues,
};
const html = generateHyperframesHtml([element], 1);
const parsed = parseHtml(html).elements[0];
expect(parsed?.type).toBe("composition");
if (parsed?.type !== "composition") throw new Error("Expected composition");
expect(parsed.variableValues).toEqual(variableValues);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(JSON.parse(doc.getElementById("nested")!.getAttribute("data-variable-values")!)).toEqual(
variableValues,
);
});

it("generates valid HTML with proper data attributes", () => {
const elements = [makeTextElement()];
const html = generateHyperframesHtml(elements, 5);
Expand Down Expand Up @@ -153,7 +324,7 @@ describe("generateHyperframesHtml", () => {
const elements = [makeTextElement({ id: "text-kf" })];
const keyframes = {
"text-kf": [
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf1 &quot; &#39; < >", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
],
};
Expand All @@ -162,17 +333,27 @@ describe("generateHyperframesHtml", () => {
expect(html).toContain("data-keyframes=");
expect(html).toContain("kf1");
expect(html).toContain("kf2");
expect(parseHtml(html).keyframes["text-kf"]?.[0]?.id).toBe(keyframes["text-kf"][0]!.id);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(JSON.parse(doc.getElementById("text-kf")!.getAttribute("data-keyframes")!)).toEqual(
keyframes["text-kf"],
);
});

it("serializes zoom keyframes on zoom container", () => {
const elements = [makeTextElement()];
const stageZoomKeyframes = [
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z1 &quot; &#39; < >", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z2", time: 5, zoom: { scale: 2, focusX: 400, focusY: 300 } },
];
const html = generateHyperframesHtml(elements, 10, { stageZoomKeyframes });

expect(html).toContain("data-zoom-keyframes=");
expect(parseHtml(html).stageZoomKeyframes?.[0]?.id).toBe(stageZoomKeyframes[0]!.id);
const doc = new DOMParser().parseFromString(html, "text/html");
expect(
JSON.parse(doc.getElementById("stage-zoom-container")!.getAttribute("data-zoom-keyframes")!),
).toEqual(stageZoomKeyframes);
});

it("includes x, y, scale data attributes for non-default values", () => {
Expand Down
Loading
Loading