Skip to content
68 changes: 65 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,53 @@ function makeVideoElement(overrides: Partial<TimelineMediaElement> = {}): Timeli
}

describe("generateHyperframesHtml", () => {
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("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 +205,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 +214,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
19 changes: 14 additions & 5 deletions packages/core/src/generators/hyperframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
import { COMPOSITION_ATTRIBUTES } from "../compositionContract.js";

function escapeHtmlAttributeValue(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
Inter: "400;500;600;700;800;900",
Expand Down Expand Up @@ -318,7 +327,7 @@
// Serialize zoom keyframes to data attribute
const zoomKeyframesAttr =
stageZoomKeyframes && stageZoomKeyframes.length > 0
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "&#39;")}'`
? ` data-zoom-keyframes='${escapeHtmlAttributeValue(JSON.stringify(stageZoomKeyframes))}'`
: "";

let styleTags = "";
Expand Down Expand Up @@ -361,27 +370,27 @@
: "";

const customStylesAttr = customStyles
? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "&#39;")}'`
? ` data-custom-styles='${escapeHtmlAttributeValue(JSON.stringify(customStyles))}'`
: "";

const resolutionAttr = ` data-resolution="${resolution}"`;

return `<!DOCTYPE html>
<html data-composition-id="${compositionId}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}>
<html data-composition-id="${escapeHtmlAttributeValue(compositionId)}" data-composition-duration="${calculatedDuration}"${resolutionAttr}${customStylesAttr}>
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${googleFontsLink}
${gsapCdnTag}
${styleTags ? ` ${styleTags}` : ""}

Check warning

Code scanning / CodeQL

Unsafe HTML constructed from library input Medium

This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow
cross-site scripting
.
This HTML construction which depends on library input might later allow cross-site scripting.
This HTML construction which depends on library input might later allow cross-site scripting.
This HTML construction which depends on library input might later allow cross-site scripting.
This HTML construction which depends on library input might later allow cross-site scripting.
This HTML construction which depends on library input might later allow cross-site scripting.
</head>
<body>
<div id="stage">
<div id="stage-zoom-container"${zoomKeyframesAttr}>
${elementsHtml}

Check warning

Code scanning / CodeQL

Unsafe HTML constructed from library input Medium

This HTML construction which depends on
library input
might later allow
cross-site scripting
.
</div>
</div>
${gsapScriptTag}

Check warning

Code scanning / CodeQL

Unsafe HTML constructed from library input Medium

This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
This HTML construction which depends on
library input
might later allow cross-site scripting.
</body>
</html>`;
}
Expand Down Expand Up @@ -470,7 +479,7 @@
// Serialize keyframes to data attribute if present
if (keyframes && keyframes.length > 0) {
const kfJson = JSON.stringify(keyframes);
baseAttrs.push(`data-keyframes='${kfJson.replace(/'/g, "&#39;")}'`);
baseAttrs.push(`data-keyframes='${escapeHtmlAttributeValue(kfJson)}'`);
}

if (isTextElement(element)) {
Expand Down Expand Up @@ -532,7 +541,7 @@
}
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const varJson = JSON.stringify(element.variableValues);
compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "&#39;")}'`);
compositionAttrs.push(`data-variable-values='${escapeHtmlAttributeValue(varJson)}'`);
}
const attrs = compositionAttrs.join(" ");
// Build iframe src with variable values as query params if present
Expand Down
160 changes: 160 additions & 0 deletions packages/parsers/src/hfIdAssignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Non-editable / non-visual elements that should never receive a stable id.
export const EXCLUDED_TAGS = new Set([
"script",
"style",
"template",
"meta",
"link",
"noscript",
"base",
]);

// 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random.
function fnv1a(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}

// 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision
// ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic
// clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId
// resolves the rare collision; width is deliberately small for readable ids.
function toHfId(hash: number): string {
const s = (hash >>> 0).toString(36);
// Use suffix (most-avalanched bits) for better distribution within the 4-char window.
const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0");
return `hf-${four}`;
}

// Element's own direct text (TEXT_NODE children), not descendants'.
function ownText(el: Element): string {
let text = "";
el.childNodes.forEach((n) => {
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
});
return text.trim();
}

function contentKey(el: Element): string {
// Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash.
// Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization.
const attrs = Array.from(el.attributes)
.filter((a) => !a.name.startsWith("data-hf-"))
.map((a) => `${a.name}\x00${a.value}`)
.sort()
.join("\x01");
return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`;
}

/**
* Collision tiebreak for byte-identical siblings: document-order dup counter
* (`hash(key#N)`). This IS order-dependent — two identical `<span></span>`
* get different ids based on which comes first in the DOM. This is unavoidable:
* unique ids for byte-identical elements require a positional signal.
*
* Why this is safe in practice: once `ensureHfIds` write-back persists
* `data-hf-id` to source the attribute is physically bound to its element.
* Reordering identical siblings carries the attribute along → zero
* order-dependence post-persist. `ensureHfIds` skips pinned elements
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
* never re-exposes the ordering after first persist.
*/
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
// preview route relies on mintHfId producing identical ids across mint contexts
// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts
// "bundle returning untagged HTML gets same ids as disk". Any change that adds
// positional, session, or random input to the hash breaks that invariant and
// makes hf- ids diverge between disk and served HTML, silently corrupting
// drag-to-edit targeting.
export function mintHfId(el: Element, assigned: Set<string>): string {
const key = contentKey(el);
let id = toHfId(fnv1a(key));
let dup = 0;
while (assigned.has(id)) {
dup += 1;
// Graceful fallback instead of a hard throw: rehashing only fails to find a
// free 4-char slot in a pathological document (~1.6M identical elements).
// Rather than crash the whole parse, widen the id with the dup counter —
// still deterministic and unique, just longer than the 4-char norm.
if (dup > 10000) {
id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`;
break;
}
id = toHfId(fnv1a(`${key}#${dup}`));
}
assigned.add(id);
return id;
}

/**
* True for a sub-composition authoring template whose content the studio preview
* unwraps into the served body. Two accepted forms:
* A) `<template data-composition-id="X">…` — the id on the template itself.
* B) `<template id="X-template"><div data-composition-id="X">…` — the id on the
* wrapped root div (the form `hyperframes add` scaffolds and registry blocks use).
* Only these are treated as transparent containers for hf-id purposes. A plain
* `<template>` (runtime clone-source: list item, particle, etc.) must NOT get
* inner ids — its content is cloned N times into the live DOM, so a persisted
* inner id would be duplicated across every clone. Form B is distinguished from
* a clone-source by the presence of a direct `[data-composition-id]` child.
*/
function getChildElements(parent: Element): Element[] {
const directChildren = Array.from(parent.children);
if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren;
const content = (parent as HTMLTemplateElement).content;
if (content?.children.length) return Array.from(content.children);
return directChildren;
}

export function isCompositionTemplate(el: Element): boolean {
if (el.tagName.toLowerCase() !== "template") return false;
if (el.getAttribute("data-composition-id") !== null) return true;
for (const child of getChildElements(el)) {
if (child.getAttribute("data-composition-id") !== null) return true;
}
return false;
}

/**
* Walk document-order descendants, descending through composition templates
* while keeping plain templates inert. linkedom's querySelectorAll does not
* expose template contents, so callers that model the served composition use
* this traversal instead.
*/
export function walkCompositionDescendants(
root: Document | Element,
visit: (el: Element) => void,
): void {
const rootElement: Element | null =
root.nodeType === 9 ? (root as Document).documentElement : (root as Element);
if (!rootElement) return;

const walk = (parent: Element): void => {
for (const child of getChildElements(parent)) {
const isTemplate = child.tagName.toLowerCase() === "template";
if (isTemplate && !isCompositionTemplate(child)) continue;
visit(child);
walk(child);
}
};

walk(rootElement);
}

// Internal DOM-only assignment: callers retain their parser and document identity.
export function assignHfIds(body: Element): void {
const assigned = new Set<string>();
walkCompositionDescendants(body, (el) => {
const existing = el.getAttribute("data-hf-id");
if (existing) assigned.add(existing);
});
walkCompositionDescendants(body, (el) => {
if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) return;
if (el.getAttribute("data-hf-id")) return;
el.setAttribute("data-hf-id", mintHfId(el, assigned));
});
}
Loading
Loading