mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files * fix(studio-server): a GSAP save on a served id writes that id to the file too
This commit is contained in:
@@ -45,6 +45,15 @@ A `StudioApiAdapter` supplies:
|
||||
Use the interface exported by your installed version as the exact contract. It
|
||||
changes as Studio gains capabilities.
|
||||
|
||||
`bundle(projectDir, options)` must pass `options` through to `bundleToSingleHtml`,
|
||||
spread over your own options. The preview sets `stampHfIds` there so each element
|
||||
is served with the id its source file mints. If a host drops it, a composition
|
||||
the bundler inlines can be served with ids that a later save does not mint.
|
||||
|
||||
```ts
|
||||
bundle: (dir, options) => bundleToSingleHtml(dir, { runtime: "inline", ...options }),
|
||||
```
|
||||
|
||||
## Other entry points
|
||||
|
||||
| Import | Purpose |
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { createStudioServer, type StudioServer } from "./studioServer.js";
|
||||
|
||||
const SCENES = Array.from({ length: 24 }, (_, i) => `s${String(i).padStart(2, "0")}`);
|
||||
@@ -53,6 +54,24 @@ describe("opening a film of external scenes", () => {
|
||||
expect(stamped).toContain("data-hf-id=");
|
||||
});
|
||||
|
||||
it("serves a scene added after open with the ids its source mints, so a save can find them", async () => {
|
||||
server = createStudioServer({ projectDir: dir, projectName: "film" });
|
||||
const added = `<template id="added-template"><div data-composition-id="added" data-width="320" data-height="180"><img class="logo" src="logo.png"></div></template>`;
|
||||
fs.writeFileSync(path.join(dir, "compositions", "added.html"), added);
|
||||
fs.writeFileSync(path.join(dir, "compositions", "logo.png"), "png");
|
||||
fs.writeFileSync(path.join(dir, "index.html"), root(["added"]));
|
||||
|
||||
const html = await (await server.app.request("/api/projects/film/preview")).text();
|
||||
|
||||
const img = /<img[^>]*class="logo"[^>]*>/.exec(html)?.[0] ?? "";
|
||||
expect(img).toContain("compositions/logo.png");
|
||||
expect(/data-hf-id="([^"]+)"/.exec(img)?.[1]).toBe(
|
||||
/<img[^>]*data-hf-id="([^"]+)"/.exec(ensureHfIds(added))?.[1],
|
||||
);
|
||||
expect(fs.readFileSync(path.join(dir, "compositions", "added.html"), "utf-8")).toBe(added);
|
||||
expect(fs.readFileSync(path.join(dir, "index.html"), "utf-8")).toBe(root(["added"]));
|
||||
});
|
||||
|
||||
it("stamps a composition rewritten since the last open even when its size and mtime match", () => {
|
||||
const file = path.join(dir, "compositions", "s00.html");
|
||||
const pinned = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
@@ -467,14 +467,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
resolveProject: (id: string) => (id === projectId ? project : null),
|
||||
|
||||
async bundle(dir: string): Promise<string | null> {
|
||||
async bundle(dir, options): Promise<string | null> {
|
||||
try {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
// Studio dev server: ask the bundler for an empty `src=""` placeholder so
|
||||
// we can point it at our hot-reloadable local runtime endpoint. Inlining
|
||||
// ~150 KB of runtime body on every preview render would defeat browser
|
||||
// caching across composition edits.
|
||||
let html = await bundleToSingleHtml(dir, PREVIEW_BUNDLE_OPTIONS);
|
||||
let html = await bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options });
|
||||
html = html.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ensureExternalScriptTag } from "./externalScripts";
|
||||
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
|
||||
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
import { ensureHfIds } from "../parsers/hfIds";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
@@ -2005,3 +2006,31 @@ describe("bundleToSingleHtml script order", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampHfIds", () => {
|
||||
const project = () =>
|
||||
makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080"><div id="s" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="3"></div></div></body></html>`,
|
||||
"compositions/scene.html": `<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
|
||||
"compositions/logo.png": "png",
|
||||
});
|
||||
const logo = (html: string) => /<img[^>]*class="logo"[^>]*>/.exec(html)?.[0] ?? "";
|
||||
|
||||
it("gives an inlined element the id its source file mints, though the bundle re-points its src", async () => {
|
||||
const dir = project();
|
||||
const html = await bundleToSingleHtml(dir, { inlineAssets: false, stampHfIds: true });
|
||||
const source = ensureHfIds(
|
||||
`<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
|
||||
);
|
||||
expect(logo(html)).toContain("compositions/logo.png");
|
||||
expect(/data-hf-id="([^"]+)"/.exec(logo(html))?.[1]).toBe(
|
||||
/<img[^>]*data-hf-id="([^"]+)"/.exec(source)?.[1],
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a render's bundle without ids", async () => {
|
||||
const html = await bundleToSingleHtml(project(), { inlineAssets: false });
|
||||
expect(logo(html)).toContain("compositions/logo.png");
|
||||
expect(logo(html)).not.toContain("data-hf-id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import { readDeclaredDefaults } from "../runtime/getVariables";
|
||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||
import { queryByAttr } from "../utils/cssSelector";
|
||||
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
|
||||
|
||||
const DEFAULT_RUNTIME_SCRIPT_URL = "";
|
||||
@@ -763,6 +764,7 @@ function stripJsCommentsParserSafe(source: string): string {
|
||||
export interface BundleOptions {
|
||||
/** Project-relative HTML entry to bundle. Defaults to `index.html`. */
|
||||
entryFile?: string;
|
||||
stampHfIds?: boolean;
|
||||
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
|
||||
probeMediaDuration?: MediaDurationProber;
|
||||
/**
|
||||
@@ -922,7 +924,8 @@ export async function bundleToSingleHtml(
|
||||
return isSafePath(projectDir, resolved) ? resolved : null;
|
||||
};
|
||||
|
||||
const rawHtml = readFileSync(indexPath, "utf-8");
|
||||
const readSource = options?.stampHfIds ? ensureHfIds : (html: string) => html;
|
||||
const rawHtml = readSource(readFileSync(indexPath, "utf-8"));
|
||||
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
|
||||
|
||||
if (options?.staticGuard !== false) {
|
||||
@@ -983,7 +986,8 @@ export async function bundleToSingleHtml(
|
||||
resolveHtml: (srcPath: string) => {
|
||||
if (!isRelativeUrl(srcPath)) return null;
|
||||
const compPath = resolveEntryPath(srcPath);
|
||||
return compPath ? safeReadFile(compPath) : null;
|
||||
const html = compPath ? safeReadFile(compPath) : null;
|
||||
return html === null ? null : readSource(html);
|
||||
},
|
||||
parseHtml: parseHTMLContent,
|
||||
hostIdentityMap: hostIdentityByElement,
|
||||
|
||||
@@ -62,17 +62,17 @@ function contentKey(el: Element): string {
|
||||
* 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 (getContractAttribute(el, "data-hf-id")) continue`), so normal operation
|
||||
* never re-exposes the ordering after first persist.
|
||||
* Why this is safe in practice: once `data-hf-id` is on disk (stamped at project open, or by the
|
||||
* first save) it is bound to its element, so reordering identical siblings carries it along, and
|
||||
* `ensureHfIds` skips pinned elements (`if (getContractAttribute(el, "data-hf-id")) continue`).
|
||||
* Before that, an outside edit changes what is minted (an element's attributes or own text, or a
|
||||
* twin added or removed), so a save carrying an id served before the edit misses or hits the other
|
||||
* twin until the watcher reloads the preview.
|
||||
*/
|
||||
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
|
||||
// WIRE CONTRACT: id minting is content-keyed (FNV1a of tag, sorted attrs, own text). 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
|
||||
// (served copy vs. the source a save parses) — see preview.test.ts
|
||||
// "bundle returning untagged HTML gets the ids minted from the source file". 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.
|
||||
|
||||
@@ -2,73 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { persistHfIdsIfNeeded, stampFileHfIds } from "./hfIdPersist.js";
|
||||
|
||||
describe("persistHfIdsIfNeeded", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
function tmpFile(content: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
|
||||
tmpDirs.push(dir);
|
||||
const file = join(dir, "index.html");
|
||||
writeFileSync(file, content, "utf-8");
|
||||
return file;
|
||||
}
|
||||
|
||||
it("writes data-hf-id to disk when source is untagged", () => {
|
||||
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
|
||||
const file = tmpFile(raw);
|
||||
const returned = persistHfIdsIfNeeded(file, raw);
|
||||
expect(returned).toContain('data-hf-id="hf-');
|
||||
const onDisk = readFileSync(file, "utf-8");
|
||||
expect(onDisk).toContain('data-hf-id="hf-');
|
||||
expect(onDisk).toBe(returned);
|
||||
});
|
||||
|
||||
it("does not rewrite disk when source is already tagged", () => {
|
||||
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
|
||||
const file = tmpFile(raw);
|
||||
const tagged = persistHfIdsIfNeeded(file, raw);
|
||||
const diskAfterFirst = readFileSync(file, "utf-8");
|
||||
const returned2 = persistHfIdsIfNeeded(file, tagged);
|
||||
expect(returned2).toBe(tagged);
|
||||
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
|
||||
});
|
||||
|
||||
it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
|
||||
// Single-quoted attrs would cause a false-positive write under string-equality
|
||||
// change detection; count-based detection handles this correctly.
|
||||
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
|
||||
const file = tmpFile(alreadyTagged);
|
||||
persistHfIdsIfNeeded(file, alreadyTagged);
|
||||
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
|
||||
});
|
||||
|
||||
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
|
||||
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
|
||||
const file = tmpFile(raw);
|
||||
const result = persistHfIdsIfNeeded(file, raw);
|
||||
const onDisk = readFileSync(file, "utf-8");
|
||||
expect(result).toBe(onDisk);
|
||||
});
|
||||
|
||||
it("skips write if file was modified concurrently (TOCTOU guard)", () => {
|
||||
const old = `<!doctype html><html><body><div>original</div></body></html>`;
|
||||
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
|
||||
// Disk has newer content — simulates a concurrent save after the server read old.
|
||||
const file = tmpFile(newer);
|
||||
const returned = persistHfIdsIfNeeded(file, old);
|
||||
// Serve-time HTML gets ids based on what we read.
|
||||
expect(returned).toContain('data-hf-id="hf-');
|
||||
// Disk must not be overwritten — user's concurrent save is preserved.
|
||||
expect(readFileSync(file, "utf-8")).toBe(newer);
|
||||
});
|
||||
});
|
||||
import { stampFileHfIds } from "./hfIdPersist.js";
|
||||
|
||||
describe("stampFileHfIds", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -57,42 +56,6 @@ export function stampProjectHfIds(projectDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `html` has `data-hf-id` attributes minted, and write the result back
|
||||
* to `filePath` if new ids were added.
|
||||
*
|
||||
* **Invariant:** `html` must be the raw file content read from `filePath` just
|
||||
* before this call. If `html` is constructed or transformed HTML the TOCTOU
|
||||
* guard (`current === html`) will never match and writes will silently be
|
||||
* skipped — no ids will reach disk.
|
||||
*/
|
||||
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
|
||||
const normalized = ensureHfIds(html);
|
||||
// Use attribute count instead of string equality: linkedom serialization may
|
||||
// normalize quote style and whitespace even when no ids were actually minted,
|
||||
// which would cause spurious writes on every request.
|
||||
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
|
||||
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
|
||||
if (idsAfter > idsBefore) {
|
||||
try {
|
||||
// Re-read before writing to guard against concurrent user saves. If the
|
||||
// file changed since we read it, skip the write — serving with ids is
|
||||
// still correct; the next request will re-persist. Best-effort only: a
|
||||
// user save landing between readFileSync and writeFileSync below can
|
||||
// still be overwritten (microsecond window).
|
||||
const current = readFileSync(filePath, "utf-8");
|
||||
if (current === html) {
|
||||
replaceFileAtomically(filePath, normalized, statSync(filePath).mode);
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — serve with ids even if the disk write fails (e.g. read-only
|
||||
// filesystem, sandboxed environment). Log so the failure is diagnosable.
|
||||
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function openNoFollow(filePath: string, flags: number): number | null {
|
||||
// O_NOFOLLOW is undefined on Windows; opening without it is the platform norm there.
|
||||
const noFollow = constants.O_NOFOLLOW ?? 0;
|
||||
@@ -118,8 +81,7 @@ function openNoFollow(filePath: string, flags: number): number | null {
|
||||
* Returns null when the file is missing, unreadable, or not a regular file.
|
||||
*
|
||||
* Best-effort on concurrent saves: a user save landing between the read and
|
||||
* the write below can still be overwritten (same microsecond window
|
||||
* persistHfIdsIfNeeded documents) — the next save simply re-persists.
|
||||
* the write below can still be overwritten — the next save simply re-persists.
|
||||
*/
|
||||
export function stampFileHfIds(filePath: string): string | null {
|
||||
let fd: number | null = openNoFollow(filePath, constants.O_RDWR);
|
||||
@@ -134,7 +96,7 @@ export function stampFileHfIds(filePath: string): string | null {
|
||||
const html = readFileSync(fd, "utf-8");
|
||||
const normalized = ensureHfIds(html);
|
||||
// Attribute count, not string equality — linkedom serialization normalizes
|
||||
// quote style/whitespace even when no ids were minted (see persistHfIdsIfNeeded).
|
||||
// quote style/whitespace even when no ids were minted.
|
||||
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
|
||||
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
|
||||
if (writable && idsAfter > idsBefore) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
@@ -80,7 +81,7 @@ describe("removeElementFromHtml", () => {
|
||||
it("supports fragment html by returning updated body markup", () => {
|
||||
const html = `<div id="photo"></div><div id="rest"></div>`;
|
||||
|
||||
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
|
||||
expect(removeElementFromHtml(html, { id: "photo" })).toBe(ensureHfIds(`<div id="rest"></div>`));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ export interface SourceMutationTarget {
|
||||
selectorIndex?: number;
|
||||
}
|
||||
|
||||
export function parseSourceDocument(source: string): {
|
||||
export function parseSourceDocument(raw: string): {
|
||||
document: Document;
|
||||
wrappedFragment: boolean;
|
||||
} {
|
||||
const source = ensureHfIds(raw);
|
||||
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
|
||||
if (hasDocumentShell) {
|
||||
return { document: parseHTML(source).document, wrappedFragment: false };
|
||||
|
||||
@@ -274,8 +274,8 @@ export function buildSubCompositionHtml(
|
||||
if (!existsSync(compFile)) return null;
|
||||
|
||||
// rawOverride lets the preview route thread the hf-id-stamped content in
|
||||
// directly, so the build uses pinned ids even when the persist-to-disk write
|
||||
// was skipped (read-only fs, concurrent-save TOCTOU guard).
|
||||
// directly, so the build uses ids minted from the raw file, which is never
|
||||
// written by serving.
|
||||
const rawComp = rawOverride ?? readFileSync(compFile, "utf-8");
|
||||
|
||||
let compHeadContent = "";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -1517,6 +1518,48 @@ const tl = gsap.timeline({ paused: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["gsap-mutations", "<script>const tl = gsap.timeline({ paused: true });</script>"],
|
||||
["gsap-mutations-batch", "<script>const tl = gsap.timeline({ paused: true });</script>"],
|
||||
["gsap-mutations", ""],
|
||||
])(
|
||||
"%s saves a tween on a served id together with that id (script: %j)",
|
||||
async (route, script) => {
|
||||
const projectDir = createProjectDir();
|
||||
const scene = `<div data-composition-id="scene"><div class="box">Hi</div>${script}</div>`;
|
||||
writeHtml(projectDir, "scene.html", scene);
|
||||
const servedBox = parseHTML(ensureHfIds(scene)).document.querySelector(".box");
|
||||
const servedId = servedBox?.getAttribute("data-hf-id");
|
||||
expect(servedId).toMatch(/^hf-/);
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
const add = {
|
||||
type: "add",
|
||||
targetSelector: `[data-hf-id="${servedId}"]`,
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { opacity: 0.5 },
|
||||
};
|
||||
|
||||
const res = await app.request(`http://localhost/projects/demo/${route}/scene.html`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(route === "gsap-mutations" ? add : { mutations: [add] }),
|
||||
});
|
||||
|
||||
const result = (await res.json()) as {
|
||||
after: string;
|
||||
parsed: { animations: Array<{ targetSelector: string }> };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(result.parsed.animations.map((a) => a.targetSelector)).toEqual([add.targetSelector]);
|
||||
const saved = readFileSync(join(projectDir, "scene.html"), "utf8");
|
||||
expect(saved).toBe(result.after);
|
||||
const savedBox = parseHTML(saved).document.querySelector(".box");
|
||||
expect(savedBox?.getAttribute("data-hf-id")).toBe(servedId);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects raw JavaScript expressions at the GSAP mutation boundary", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeHtml(projectDir, "comp.html", FROMTO_COMP);
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
type ElementRebase,
|
||||
} from "../helpers/sourceMutation.js";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import {
|
||||
CompositionInsertionError,
|
||||
insertCompositionIntoSource,
|
||||
@@ -644,16 +645,16 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string):
|
||||
// ── GSAP script extraction ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse an HTML string with linkedom, locate the inline `<script>` that
|
||||
* contains GSAP timeline code, and return both its text content and a
|
||||
* function that replaces that script block and serialises back to HTML.
|
||||
* Mint the HTML's ids (so a tween saved on a served id writes that id too), parse it with
|
||||
* linkedom, locate the inline `<script>` holding GSAP timeline code, and return its text and
|
||||
* a function that replaces that script block and serialises back to HTML.
|
||||
*/
|
||||
function extractGsapScriptBlock(html: string): {
|
||||
scriptText: string;
|
||||
document: Document;
|
||||
replaceScript: (newText: string) => string;
|
||||
} | null {
|
||||
const { document } = parseHTML(html);
|
||||
const { document } = parseHTML(ensureHfIds(html));
|
||||
const scripts = [
|
||||
...document.querySelectorAll("script:not([src])"),
|
||||
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
|
||||
|
||||
@@ -7,14 +7,18 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerPreviewRoutes } from "./preview";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { PREVIEW_BUNDLE_OPTIONS, registerPreviewRoutes } from "./preview";
|
||||
import { registerFileRoutes } from "./files";
|
||||
import { createPreviewDocumentStore } from "../helpers/previewDocumentStore";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
@@ -495,6 +499,93 @@ describe("built preview reuse", () => {
|
||||
});
|
||||
|
||||
describe("hf-id surfacing in preview route", () => {
|
||||
const idsOf = (html: string) =>
|
||||
[...html.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]).sort();
|
||||
const postPatch = (app: Hono, file: string, hfId: string | undefined) =>
|
||||
app.request(`http://localhost/projects/demo/file-mutations/patch-element/${file}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: { hfId },
|
||||
operations: [{ type: "inline-style", property: "opacity", value: "0.5" }],
|
||||
}),
|
||||
});
|
||||
|
||||
it("serving the preview and a sub-comp twice leaves both files' bytes and mtime unchanged", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const files = [join(projectDir, "index.html"), join(projectDir, "scene.html")];
|
||||
writeFileSync(
|
||||
files[0]!,
|
||||
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
|
||||
);
|
||||
writeFileSync(
|
||||
files[1]!,
|
||||
`<div class="clip" data-start="0" data-end="3"><img src="logo.png"></div>`,
|
||||
);
|
||||
const snapshot = () => files.map((f) => [readFileSync(f, "utf-8"), statSync(f).mtimeMs]);
|
||||
const before = snapshot();
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect((await app.request("http://localhost/projects/demo/preview")).status).toBe(200);
|
||||
const comp = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
|
||||
expect(comp.status).toBe(200);
|
||||
}
|
||||
expect(snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it("a save finds an element by the id the sub-comp route served, though the file has no ids", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const compPath = join(projectDir, "scene.html");
|
||||
writeFileSync(
|
||||
compPath,
|
||||
`<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`,
|
||||
);
|
||||
const app = new Hono();
|
||||
const adapter = createAdapter(projectDir);
|
||||
registerPreviewRoutes(app, adapter);
|
||||
registerFileRoutes(app, adapter);
|
||||
const served = await (
|
||||
await app.request("http://localhost/projects/demo/preview/comp/scene.html")
|
||||
).text();
|
||||
const imgId = /<img[^>]*data-hf-id="(hf-[a-z0-9]+)"/.exec(served)?.[1];
|
||||
expect(imgId).toBeDefined();
|
||||
const res = await postPatch(app, "scene.html", imgId);
|
||||
expect(await res.json()).toMatchObject({ matched: true, changed: true });
|
||||
expect(readFileSync(compPath, "utf-8")).toMatch(/<img[^>]*opacity: ?0\.5/);
|
||||
});
|
||||
|
||||
it("a save finds an element whose src the bundler rewrote, though its file has no ids", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
mkdirSync(join(projectDir, "compositions"));
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080"><div id="s" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="3"></div></div></body></html>`,
|
||||
);
|
||||
const compPath = join(projectDir, "compositions", "scene.html");
|
||||
writeFileSync(
|
||||
compPath,
|
||||
`<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
|
||||
);
|
||||
writeFileSync(join(projectDir, "compositions", "logo.png"), "png");
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const app = new Hono();
|
||||
const adapter = createAdapter(projectDir, {
|
||||
bundle: (dir, options) => bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options }),
|
||||
});
|
||||
registerPreviewRoutes(app, adapter);
|
||||
registerFileRoutes(app, adapter);
|
||||
const served = await (await app.request("http://localhost/projects/demo/preview")).text();
|
||||
const img = /<img[^>]*class="logo"[^>]*>/.exec(served)?.[0] ?? "";
|
||||
expect(img).not.toContain('src="logo.png"');
|
||||
const res = await postPatch(
|
||||
app,
|
||||
"compositions/scene.html",
|
||||
/data-hf-id="(hf-[a-z0-9]+)"/.exec(img)?.[1],
|
||||
);
|
||||
expect(await res.json()).toMatchObject({ matched: true, changed: true });
|
||||
});
|
||||
|
||||
it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(
|
||||
@@ -511,28 +602,7 @@ describe("hf-id surfacing in preview route", () => {
|
||||
expect(ids?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("writes data-hf-id back to disk on first serve", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const projectDir = createProjectDir();
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
writeFileSync(
|
||||
indexPath,
|
||||
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
|
||||
);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
await app.request("http://localhost/projects/demo/preview");
|
||||
const onDisk = readFileSync(indexPath, "utf-8");
|
||||
expect(onDisk).toContain('data-hf-id="hf-');
|
||||
});
|
||||
|
||||
it("bundle returning untagged HTML gets same ids as disk — content-hash is stable across mint contexts", async () => {
|
||||
// Regression guard for bundle-vs-disk id divergence: if the bundler reads from
|
||||
// a pre-write cache snapshot (no ids), ensureHfIds mints ids on the bundle output.
|
||||
// Because ids are content-keyed (FNV1a of element content), the minted ids must
|
||||
// equal the ids persisted to disk for the same source HTML — otherwise a
|
||||
// drag-to-edit patch keyed by a wire-time id would fail to apply on disk.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
it("bundle returning untagged HTML gets the ids minted from the source file", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
const sourceHtml = `<!doctype html><html><head></head><body><div class="card"><p>hello</p></div></body></html>`;
|
||||
@@ -544,27 +614,9 @@ describe("hf-id surfacing in preview route", () => {
|
||||
const res = await app.request("http://localhost/projects/demo/preview");
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const servedHtml = await res.text();
|
||||
const diskHtml = readFileSync(indexPath, "utf-8");
|
||||
|
||||
// Extract ids from served HTML and disk HTML
|
||||
const servedIds = [...servedHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
|
||||
const diskIds = [...diskHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
|
||||
|
||||
const servedIds = idsOf(await res.text());
|
||||
expect(servedIds.length).toBeGreaterThanOrEqual(2);
|
||||
expect(servedIds).toEqual(diskIds);
|
||||
});
|
||||
|
||||
it("sub-comp route writes data-hf-id back to disk on first serve", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const projectDir = createProjectDir();
|
||||
const compPath = join(projectDir, "scene.html");
|
||||
writeFileSync(compPath, `<div class="clip" data-start="0" data-end="3">Hi</div>`);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(readFileSync(compPath, "utf-8")).toContain('data-hf-id="hf-');
|
||||
expect(servedIds).toEqual(idsOf(ensureHfIds(sourceHtml)));
|
||||
});
|
||||
|
||||
it("returns ByteString-safe stable and distinct ETags for percent-encoded CJK sub-comp paths", async () => {
|
||||
@@ -596,57 +648,35 @@ describe("hf-id surfacing in preview route", () => {
|
||||
expect(other.headers.get("ETag")).not.toBe(firstEtag);
|
||||
});
|
||||
|
||||
it("sub-comp served ids equal disk ids even when relative asset paths are rewritten", async () => {
|
||||
// Regression guard for the setTiming element_not_found divergence class:
|
||||
// the sub-comp route rewrites relative src/href BEFORE minting, so an
|
||||
// element with a relative asset path got a preview-only id that existed
|
||||
// nowhere in the raw file. Persisting ids from the RAW file first pins
|
||||
// them; the rewrite then carries the pinned ids through unchanged.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
it("sub-comp served ids equal the source's ids even when relative asset paths are rewritten", async () => {
|
||||
// Guards setTiming element_not_found: minting after the route rewrites src gives ids the source lacks.
|
||||
const projectDir = createProjectDir();
|
||||
const compPath = join(projectDir, "scene.html");
|
||||
writeFileSync(
|
||||
compPath,
|
||||
`<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`,
|
||||
);
|
||||
const raw = `<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`;
|
||||
writeFileSync(join(projectDir, "scene.html"), raw);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
|
||||
expect(res.status).toBe(200);
|
||||
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
|
||||
.map((m) => m[1])
|
||||
.sort();
|
||||
const diskIds = [...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
|
||||
.map((m) => m[1])
|
||||
.sort();
|
||||
const servedIds = idsOf(await res.text());
|
||||
expect(servedIds.length).toBeGreaterThanOrEqual(2); // div + img
|
||||
expect(servedIds).toEqual(diskIds);
|
||||
expect(servedIds).toEqual(idsOf(ensureHfIds(raw)));
|
||||
});
|
||||
|
||||
it("template-based sub-comp: inner ids persist to disk and match the served (unwrapped) ids", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
it("template-based sub-comp: the served (unwrapped) ids are the source's inner ids", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const compPath = join(projectDir, "test-minimal.html");
|
||||
writeFileSync(
|
||||
compPath,
|
||||
`<template data-composition-id="test-minimal"><div class="clip" data-start="0" data-end="3">Hello</div><div class="clip" data-start="3" data-end="6">World</div></template>`,
|
||||
);
|
||||
const raw = `<template data-composition-id="test-minimal"><div class="clip" data-start="0" data-end="3">Hello</div><div class="clip" data-start="3" data-end="6">World</div></template>`;
|
||||
writeFileSync(join(projectDir, "test-minimal.html"), raw);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
const res = await app.request("http://localhost/projects/demo/preview/comp/test-minimal.html");
|
||||
expect(res.status).toBe(200);
|
||||
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
const diskIds = [
|
||||
...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g),
|
||||
].map((m) => m[1]);
|
||||
expect(diskIds.length).toBe(2);
|
||||
for (const id of diskIds) expect(servedIds).toContain(id);
|
||||
const servedIds = idsOf(await res.text());
|
||||
const sourceIds = idsOf(ensureHfIds(raw));
|
||||
expect(sourceIds.length).toBe(2);
|
||||
for (const id of sourceIds) expect(servedIds).toContain(id);
|
||||
});
|
||||
|
||||
it("sub-comp route does NOT rewrite a non-HTML file on disk (GET must not corrupt assets)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const projectDir = createProjectDir();
|
||||
const svgPath = join(projectDir, "logo.svg");
|
||||
const svgBytes = `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>`;
|
||||
@@ -683,8 +713,7 @@ describe("hf-id surfacing in preview route", () => {
|
||||
expect(traversal.status).toBe(404);
|
||||
});
|
||||
|
||||
it("sub-comp route does NOT persist ids inside a plain <template> (runtime clone-source)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
it("a save does NOT stamp ids inside a plain <template> (runtime clone-source)", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const compPath = join(projectDir, "clones.html");
|
||||
writeFileSync(
|
||||
@@ -692,9 +721,19 @@ describe("hf-id surfacing in preview route", () => {
|
||||
`<div class="clip" data-start="0" data-end="3">stage</div><template><li class="row">item</li></template>`,
|
||||
);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
const res = await app.request("http://localhost/projects/demo/preview/comp/clones.html");
|
||||
expect(res.status).toBe(200);
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
const res = await app.request(
|
||||
"http://localhost/projects/demo/file-mutations/patch-element/clones.html",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: { selector: ".clip" },
|
||||
operations: [{ type: "inline-style", property: "opacity", value: "0.5" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(await res.json()).toMatchObject({ matched: true });
|
||||
const disk = readFileSync(compPath, "utf-8");
|
||||
expect(disk).toMatch(/<div[^>]*data-hf-id/); // stage div stamped
|
||||
expect(disk).not.toMatch(/<li[^>]*data-hf-id/); // clone-source untouched
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
STUDIO_MOTION_PATH,
|
||||
} from "../helpers/studioMotionRenderScript.js";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
|
||||
import { settledFileTag } from "../helpers/fileVersion.js";
|
||||
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
|
||||
import { injectPreviewVariables } from "../helpers/previewVariables.js";
|
||||
@@ -351,14 +350,11 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
previewVariables: Record<string, unknown> | null,
|
||||
builtKey: string,
|
||||
): Promise<string | null> {
|
||||
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
|
||||
const diskMain = resolveProjectMainHtml(project.dir, project.id);
|
||||
const normalizedDisk = diskMain
|
||||
? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
|
||||
: null;
|
||||
const normalizedDisk = diskMain ? ensureHfIds(diskMain.html) : null;
|
||||
|
||||
try {
|
||||
let bundled = await adapter.bundle(project.dir);
|
||||
let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
|
||||
let mainCompositionPath = "index.html";
|
||||
if (!bundled) {
|
||||
if (!diskMain) return null;
|
||||
@@ -386,11 +382,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
||||
}
|
||||
|
||||
// ensureHfIds runs after transformPreviewHtml in case the adapter injected
|
||||
// new elements. On the no-bundle path bundled=normalizedDisk (already tagged)
|
||||
// so this is idempotent. On the bundled path the bundler may return untagged
|
||||
// HTML (stale cache); because ids are content-keyed the minted ids will match
|
||||
// the ids already written to disk by persistHfIdsIfNeeded above.
|
||||
// Also covers elements the adapter injected; ids already present are kept.
|
||||
bundled = injectStudioPreviewAugmentations(
|
||||
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
|
||||
adapter,
|
||||
@@ -413,10 +405,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
// not the pre-request snapshot that may have been saved over.
|
||||
const fallback = resolveProjectMainHtml(project.dir, project.id);
|
||||
if (fallback) {
|
||||
const fallbackHtml = persistHfIdsIfNeeded(
|
||||
join(project.dir, fallback.compositionPath),
|
||||
fallback.html,
|
||||
);
|
||||
const fallbackHtml = ensureHfIds(fallback.html);
|
||||
let fallbackAugmented = injectStudioPreviewAugmentations(
|
||||
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
|
||||
adapter,
|
||||
@@ -477,27 +466,15 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
return c.html(html, 200, previewCacheHeaders(etag));
|
||||
});
|
||||
|
||||
/**
|
||||
* Pin hf-ids to the RAW sub-comp file before the build pipeline mutates
|
||||
* attributes (rewriteRelativePaths etc.) — minting is content-keyed over
|
||||
* attrs, so stamping only AFTER the rewrite mints preview-only ids that
|
||||
* exist nowhere in the source. Pinned ids ride through the rewrite
|
||||
* unchanged, keeping the served DOM, the disk file, and the studio SDK
|
||||
* session in one id space. Mirrors the main-preview route's
|
||||
* persistHfIdsIfNeeded call.
|
||||
*
|
||||
* Gated to composition files: the wildcard route serves any project path,
|
||||
* and stamping a non-HTML file (SVG, etc.) would corrupt it on disk.
|
||||
*
|
||||
* Returns the stamped content to thread into the build (so served ids match
|
||||
* the mint even when the disk write is skipped — read-only fs), undefined
|
||||
* for non-HTML paths, or null when the file vanished after the caller's
|
||||
* stat. stampFileHfIds does its validation, read, and write through one
|
||||
* file descriptor, so there is no check/read/write path gap to race.
|
||||
*/
|
||||
/** Ids minted from the raw file before the build rewrites attributes, so they match the source's;
|
||||
* in memory only, since a write here reaches the watcher as an outside edit. null: the file vanished. */
|
||||
function pinSubCompHfIds(compFile: string, compPath: string): string | undefined | null {
|
||||
if (!/\.html?$/i.test(compPath)) return undefined;
|
||||
return stampFileHfIds(compFile);
|
||||
try {
|
||||
return ensureHfIds(readFileSync(compFile, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-composition preview
|
||||
@@ -558,7 +535,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
// Assets are read-only and should mirror the renderer: permit a path that
|
||||
// is lexically inside the project even if an explicit project symlink
|
||||
// targets a shared directory outside it. Composition source files still
|
||||
// use resolveWithinProject because preview mutates their data-hf-id values.
|
||||
// use resolveWithinProject because saves write their data-hf-id values.
|
||||
const candidate = resolve(project.dir, subPath);
|
||||
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
|
||||
if (!file) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CanvasResolution } from "@hyperframes/parsers";
|
||||
import type { RegistryItem } from "@hyperframes/core";
|
||||
import type { BundleOptions } from "@hyperframes/core/compiler";
|
||||
import type { ProjectHistory } from "./history/projectHistory.js";
|
||||
|
||||
/** Resolved info about a single project. */
|
||||
@@ -113,8 +114,8 @@ export interface StudioApiAdapter {
|
||||
*/
|
||||
history?: (project: ResolvedProject) => Promise<ProjectHistory | null> | ProjectHistory | null;
|
||||
|
||||
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
|
||||
bundle(projectDir: string): Promise<string | null>;
|
||||
/** Bundle a project directory into a single HTML string, forwarding `options` over the host's own. */
|
||||
bundle(projectDir: string, options?: Pick<BundleOptions, "stampHfIds">): Promise<string | null>;
|
||||
|
||||
/** Optional: cached signature for project files that should invalidate preview frame caches. */
|
||||
getProjectSignature?: (projectDir: string) => string;
|
||||
|
||||
@@ -257,10 +257,10 @@ export function createViteAdapter(
|
||||
return { id, dir: realpathSync(projectDir) };
|
||||
},
|
||||
|
||||
async bundle(dir: string) {
|
||||
async bundle(dir, options) {
|
||||
const bundler = await getBundler();
|
||||
if (!bundler) return null;
|
||||
let html = await bundler(dir, PREVIEW_BUNDLE_OPTIONS);
|
||||
let html = await bundler(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options });
|
||||
html = html.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,
|
||||
|
||||
Reference in New Issue
Block a user