Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/core/src/compiler/htmlParityContract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { extractCompiledHtmlParityContract } from "./htmlParityContract";

describe("extractCompiledHtmlParityContract", () => {
it("normalizes legacy timing and nested variable JSON", () => {
const contract = extractCompiledHtmlParityContract(`<main data-composition-id="main"
data-variable-values='{"z":{"b":2,"a":1},"a":[{"d":4,"c":3}]}' data-start="1" data-end="4" data-layer="2">
<div id="child" data-start="2" data-end="3" data-layer="5"></div>
</main>`);
expect(contract.compositions[0]).toMatchObject({
start: 1,
duration: 3,
trackIndex: 2,
variableValues: '{"a":[{"c":3,"d":4}],"z":{"a":1,"b":2}}',
});
expect(contract.timedElements[0]).toMatchObject({ start: 2, duration: 1, trackIndex: 5 });
});

it("treats embedded and project-relative resources as the same local contract", () => {
const embedded = extractCompiledHtmlParityContract(
`<img id="logo" src="data:image/svg+xml,x" />`,
);
const project = extractCompiledHtmlParityContract(`<img id="logo" src="assets/logo.svg" />`);
expect(embedded.resources).toEqual(project.resources);
});
});
168 changes: 168 additions & 0 deletions packages/core/src/compiler/htmlParityContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { parseHTML } from "linkedom";

export interface HtmlParityComposition {
id: string;
originalId: string | null;
start: number;
duration: number | null;
trackIndex: number | null;
width: number | null;
height: number | null;
variableValues: string | null;
}

export interface HtmlParityTimedElement {
identity: string;
start: number;
duration: number | null;
trackIndex: number | null;
}

export interface HtmlParityResource {
identity: string;
attribute: "src" | "href" | "poster";
locality: "local" | "remote";
}

export interface CompiledHtmlParityContract {
compositions: HtmlParityComposition[];
timedElements: HtmlParityTimedElement[];
authoredStyleSignatures: string[];
parityFontFamilies: string[];
resources: HtmlParityResource[];
runtimeBootstrap: boolean;
variableBootstrap: boolean;
}

function finiteAttribute(element: Element, name: string): number | null {
const raw = element.getAttribute(name);
if (raw === null || raw.trim() === "") return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}

function timing(element: Element): {
start: number;
duration: number | null;
trackIndex: number | null;
} {
const start = finiteAttribute(element, "data-start") ?? 0;
const duration =
finiteAttribute(element, "data-duration") ??
(() => {
const end = finiteAttribute(element, "data-end");
return end === null ? null : Math.max(0, end - start);
})();
return {
start,
duration,
trackIndex:
finiteAttribute(element, "data-track-index") ?? finiteAttribute(element, "data-layer"),
};
}

function normalizeJson(value: string | null): string | null {
if (value === null) return null;
try {
return JSON.stringify(canonicalizeJson(JSON.parse(value) as unknown));
} catch {
return value.trim();
}
}

function canonicalizeJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalizeJson);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, canonicalizeJson(nested)]),
);
}

function styleSignatures(document: Document): string[] {
return [...document.querySelectorAll("style")]
.map((style) => style.textContent ?? "")
.flatMap((css) => css.match(/[^{}]+\{[^{}]*--parity-contract\s*:[^{}]+\}/g) ?? [])
.map((rule) =>
rule
.replace(/\s+/g, " ")
.replace(/\s*([:;{},])\s*/g, "$1")
.trim(),
)
.sort();
}

function parityFontFamilies(html: string): string[] {
const families = new Set<string>();
for (const match of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
for (const family of match[1]!.split(",")) {
const normalized = family.trim().replace(/^['"]|['"]$/g, "");
if (normalized.startsWith("Parity")) families.add(normalized);
}
}
return [...families].sort();
}

function resources(document: Document): HtmlParityResource[] {
const resources: HtmlParityResource[] = [];
for (const [index, element] of [
...document.querySelectorAll("[src], [href], [poster]"),
].entries()) {
for (const attribute of ["src", "href", "poster"] as const) {
const value = element.getAttribute(attribute)?.trim();
if (!value) continue;
if (element.tagName.toLowerCase() === "script" && /hyperframes|runtime/i.test(value))
continue;
resources.push({
identity:
element.getAttribute("data-hf-id") ??
element.getAttribute("id") ??
`${element.tagName.toLowerCase()}[${index}]`,
attribute,
locality: /^https?:\/\//i.test(value) ? "remote" : "local",
});
}
}
return resources.sort((left, right) =>
`${left.identity}:${left.attribute}`.localeCompare(`${right.identity}:${right.attribute}`),
);
}

/** Extract the browser-visible contract shared by preview and render compilation. */
export function extractCompiledHtmlParityContract(html: string): CompiledHtmlParityContract {
const { document } = parseHTML(html);
const compositions = [...document.querySelectorAll("[data-composition-id]")].map((element) => {
const resolved = timing(element);
return {
id: element.getAttribute("data-composition-id") ?? "",
originalId: element.getAttribute("data-hf-original-composition-id"),
...resolved,
width: finiteAttribute(element, "data-width"),
height: finiteAttribute(element, "data-height"),
variableValues: normalizeJson(element.getAttribute("data-variable-values")),
};
});
const timedElements = [
...document.querySelectorAll(
"[data-start], [data-duration], [data-end], [data-track-index], [data-layer]",
),
]
.filter((element) => !element.hasAttribute("data-composition-id"))
.map((element, index) => ({
identity:
element.getAttribute("data-hf-id") ??
element.getAttribute("id") ??
`${element.tagName.toLowerCase()}[${index}]`,
...timing(element),
}));
return {
compositions,
timedElements,
authoredStyleSignatures: styleSignatures(document),
parityFontFamilies: parityFontFamilies(html),
resources: resources(document),
runtimeBootstrap: /__hyperframes|data-hyperframes-(?:preview-)?runtime/i.test(html),
variableBootstrap: /__hfVariables(?:ByComp)?/.test(html),
};
}
8 changes: 8 additions & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export {
} from "./htmlBundler";
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";

export {
extractCompiledHtmlParityContract,
type CompiledHtmlParityContract,
type HtmlParityComposition,
type HtmlParityResource,
type HtmlParityTimedElement,
} from "./htmlParityContract";

export {
RUNTIME_BOOTSTRAP_ATTR,
injectScriptsAtHeadStart,
Expand Down
96 changes: 96 additions & 0 deletions packages/producer/src/services/htmlCompiler.parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
bundleToSingleHtml,
extractCompiledHtmlParityContract,
injectScriptsIntoHtml,
} from "@hyperframes/core/compiler";
import { compileForRender } from "./htmlCompiler.js";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";

const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

function project(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-compiler-parity-"));
tempDirs.push(dir);
for (const [relative, content] of Object.entries(files)) {
const path = join(dir, relative);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}
return dir;
}

async function contracts(files: Record<string, string>) {
const dir = project(files);
const preview = await bundleToSingleHtml(dir);
const render = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
allowSystemFontCapture: false,
});
const servedRender = injectScriptsIntoHtml(
render.html,
[getVerifiedHyperframeRuntimeSource()],
[],
true,
);
return {
preview: extractCompiledHtmlParityContract(preview),
render: extractCompiledHtmlParityContract(servedRender),
};
}

const shell = (body: string, head = "") => `<!doctype html>
<html><head>${head}</head><body>${body}
<script>window.__timelines = window.__timelines || {};</script></body></html>`;

describe("preview/render semantic compilation parity", () => {
it("preserves canonical timing, track, authored style, font, and resource contracts", async () => {
const result = await contracts({
"index.html": shell(
`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="4">
<div id="title" data-start="1" data-duration="2" data-track-index="3" class="clip parity-card">Title</div>
<img id="logo" src="assets/logo.svg" alt="" />
</main>`,
`<style>@font-face { font-family: "ParityDisplay"; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
.parity-card { --parity-contract: 1; color: red; font-family: "ParityDisplay", sans-serif; }</style>`,
),
"assets/logo.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`,
});
expect(result.render).toEqual(result.preview);
});

it("keeps legacy end/layer timing semantically identical", async () => {
const result = await contracts({
"index.html":
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="5">
<div id="legacy" class="clip" data-start="1.5" data-end="4" data-layer="2">Legacy timing</div>
</main>`),
});
expect(result.render).toEqual(result.preview);
});

it("keeps flattened sub-composition identity and variable bootstrap identical", async () => {
const result = await contracts({
"index.html":
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6">
<section id="card-host" data-composition-id="card" data-composition-src="compositions/card.html"
data-start="1" data-duration="3" data-variable-values='{"title":"Pro"}'></section>
</main>`),
"compositions/card.html": `<template id="card-template">
<article data-composition-id="card" data-width="800" data-height="600">
<h2 id="card-title" class="parity-card">Card</h2>
<style>@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
.parity-card { --parity-contract: 2; font-family: ParityBody, sans-serif; }</style>
</article>
</template>
<script>window.__timelines = window.__timelines || {};</script>`,
});
expect(result.render).toEqual(result.preview);
});
});
Loading