mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(core,studio): extract draft-marker constants + R7 code-review fixes (Task 4) (#1292)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4) Create draftMarkers.ts in core with 5 shared CSS custom property names and the gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core so all existing call sites are unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): address R7 code-review findings (C1–C14, P6–P7) - previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7); remove redundant GestureState.hfId field (C12); remove Array.from (C14); extract clearDraftProps/revertGesture helpers (C5/C6) - hfIdPersist: replace string-equality change detection with attribute count to avoid false-positive writes on single-quoted HTML (C1); re-read disk before write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11) - preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9); catch path re-reads disk fresh instead of using stale pre-request snapshot (C8) - hfIds.test.ts: replace tautological second stability test with cross-document content-keyed id stability test (P6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests - hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note; improve TOCTOU comment; pass err to console.warn - preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export) - previewAdapter: CSS.escape + inline fallback for non-browser environments; add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment - previewAdapter.test: rename atTime test to clarify adapter-does-not-seek; add nested-hf-root-without-id test; add resize→move prop-leak test; add revertDraft-after-commit no-op test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3) - preview.test: add "bundle returning untagged HTML gets same ids as disk" test — guards against id divergence when bundler reads a pre-write cache snapshot; content-keyed FNV1a minting ensures served ids == disk ids for same source HTML - preview.ts: comment the second ensureHfIds call explaining it's intentional for adapter-injected elements and idempotent on the no-bundle path (P3 from miguel) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(core): wire-contract comment on mintHfId + fallow suppressions (R7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1a23938bef
commit
3d7d7c0291
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Draft-marker constants shared between core's PreviewAdapter and Studio's
|
||||
* manual-edits code. CSS custom properties written during a drag gesture, plus
|
||||
* the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers.
|
||||
*/
|
||||
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
|
||||
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
|
||||
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
|
||||
export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
|
||||
export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";
|
||||
@@ -2,26 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";
|
||||
|
||||
describe("normalizeHfIds", () => {
|
||||
it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
|
||||
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
|
||||
const { html, changed } = normalizeHfIds(raw);
|
||||
expect(changed).toBe(true);
|
||||
expect(html).toContain('data-hf-id="hf-');
|
||||
const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
|
||||
expect(matches?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
|
||||
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
|
||||
const first = normalizeHfIds(raw).html;
|
||||
const { html, changed } = normalizeHfIds(first);
|
||||
expect(changed).toBe(false);
|
||||
expect(html).toBe(first);
|
||||
});
|
||||
});
|
||||
import { persistHfIdsIfNeeded } from "./hfIdPersist.js";
|
||||
|
||||
describe("persistHfIdsIfNeeded", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
@@ -59,6 +40,15 @@ describe("persistHfIdsIfNeeded", () => {
|
||||
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);
|
||||
@@ -66,4 +56,16 @@ describe("persistHfIdsIfNeeded", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { ensureHfIds } from "../../parsers/hfIds.js";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
export { ensureHfIds };
|
||||
|
||||
export function normalizeHfIds(html: string): { html: string; changed: boolean } {
|
||||
const normalized = ensureHfIds(html);
|
||||
return { html: normalized, changed: normalized !== html };
|
||||
}
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* 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 { html: normalized, changed } = normalizeHfIds(html);
|
||||
if (changed) {
|
||||
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 {
|
||||
writeFileSync(filePath, normalized, "utf-8");
|
||||
} catch {
|
||||
// non-fatal — serve with ids even if persist fails
|
||||
// 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) {
|
||||
writeFileSync(filePath, normalized, "utf-8");
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -64,11 +64,21 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
|
||||
expect(adapter.elementAtPoint(0, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips elements whose computed opacity is 0 at the given playhead time", () => {
|
||||
it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => {
|
||||
const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
|
||||
const adapter = adapterWith(() => elem);
|
||||
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => {
|
||||
const outerRoot = make("div", { "data-hf-root": "true" });
|
||||
const innerRoot = document.createElement("div");
|
||||
innerRoot.setAttribute("data-hf-root", "true");
|
||||
// no data-hf-id — no explicit id means no draggable target
|
||||
outerRoot.appendChild(innerRoot);
|
||||
const adapter = adapterWith(() => innerRoot);
|
||||
expect(adapter.elementAtPoint(0, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyDraft / revertDraft ───────────────────────────────────────────
|
||||
@@ -130,19 +140,44 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
|
||||
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
|
||||
});
|
||||
|
||||
it("resize → move switch clears width/height props — no cross-type prop leak", () => {
|
||||
const target = make("div", { "data-hf-id": "hf-aaaa" });
|
||||
const adapter = adapterWith(() => null);
|
||||
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
|
||||
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 });
|
||||
// move props set
|
||||
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px");
|
||||
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px");
|
||||
// resize props cleared by the auto-revert before re-apply
|
||||
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("");
|
||||
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("");
|
||||
});
|
||||
|
||||
it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => {
|
||||
const target = make("div", { "data-hf-id": "hf-aaaa" });
|
||||
target.style.setProperty("translate", "50px 0px");
|
||||
const adapter = adapterWith(() => null);
|
||||
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
|
||||
adapter.commitPreview();
|
||||
// simulate caller applying translate after commit
|
||||
target.style.setProperty("translate", "10px 0px");
|
||||
adapter.revertDraft(); // no gesture in flight — should be no-op
|
||||
expect(target.style.getPropertyValue("translate")).toBe("10px 0px");
|
||||
});
|
||||
|
||||
it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
|
||||
const adapter = adapterWith(() => null);
|
||||
expect(() => adapter.revertDraft()).not.toThrow();
|
||||
expect(() => adapter.revertDraft()).not.toThrow();
|
||||
});
|
||||
|
||||
it("elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call", () => {
|
||||
it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => {
|
||||
const elem = make("div", { "data-hf-id": "hf-zzzz" });
|
||||
const adapter = adapterWith(() => elem);
|
||||
expect(adapter.elementAtPoint(0, 0, { atTime: 0 })).toBe(elem);
|
||||
expect(adapter.elementAtPoint(0, 0)).toBe(elem);
|
||||
// simulates GSAP seeking to a time where the element is hidden
|
||||
elem.style.setProperty("opacity", "0");
|
||||
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
|
||||
expect(adapter.elementAtPoint(0, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
STUDIO_OFFSET_X_PROP,
|
||||
STUDIO_OFFSET_Y_PROP,
|
||||
STUDIO_WIDTH_PROP,
|
||||
STUDIO_HEIGHT_PROP,
|
||||
STUDIO_MANUAL_EDIT_GESTURE_ATTR,
|
||||
} from "./draftMarkers.js";
|
||||
|
||||
export type DraftPayload =
|
||||
| { type: "move"; hfId: string; dx: number; dy: number }
|
||||
| { type: "resize"; hfId: string; w: number; h: number };
|
||||
@@ -7,6 +15,11 @@ export type CommitPatch =
|
||||
| { type: "resize"; hfId: string; width: number; height: number };
|
||||
|
||||
export interface PreviewAdapter {
|
||||
/**
|
||||
* @param atTime - Caller hint only. The adapter reads current computed styles;
|
||||
* the caller must seek the GSAP timeline to `atTime` before invoking so that
|
||||
* GSAP-driven inline styles reflect the desired playhead position.
|
||||
*/
|
||||
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): Element | null;
|
||||
applyDraft(payload: DraftPayload): void;
|
||||
revertDraft(): void;
|
||||
@@ -15,7 +28,6 @@ export interface PreviewAdapter {
|
||||
}
|
||||
|
||||
interface GestureState {
|
||||
hfId: string;
|
||||
payload: DraftPayload;
|
||||
originalTranslate: string | undefined;
|
||||
}
|
||||
@@ -27,24 +39,50 @@ export function createPreviewAdapter(
|
||||
let gesture: GestureState | null = null;
|
||||
|
||||
function findById(hfId: string): HTMLElement | null {
|
||||
return doc.querySelector(`[data-hf-id="${hfId}"]`) as HTMLElement | null;
|
||||
// CSS.escape is available in browsers; hf-ids are always hf-[a-z0-9]+ so
|
||||
// no escaping is strictly needed, but be safe in non-browser environments.
|
||||
const escaped =
|
||||
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
||||
? CSS.escape(hfId)
|
||||
: hfId.replace(/([^\w-])/g, "\\$1");
|
||||
return doc.querySelector(`[data-hf-id="${escaped}"]`) as HTMLElement | null;
|
||||
}
|
||||
|
||||
function opacity(el: Element): number {
|
||||
function isVisible(el: Element): boolean {
|
||||
const view = doc.defaultView;
|
||||
if (!view) return 1;
|
||||
return parseFloat(view.getComputedStyle(el).opacity) || 0;
|
||||
if (!view) return true;
|
||||
const style = view.getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
const op = parseFloat(style.opacity);
|
||||
// NaN (empty string from environments with no CSS cascade) → treat as visible.
|
||||
// 0.01 threshold: sub-1% opacity is not user-targetable in drag gestures.
|
||||
return Number.isNaN(op) || op >= 0.01;
|
||||
}
|
||||
|
||||
function clearDraftProps(target: HTMLElement): void {
|
||||
target.style.removeProperty(STUDIO_OFFSET_X_PROP);
|
||||
target.style.removeProperty(STUDIO_OFFSET_Y_PROP);
|
||||
target.style.removeProperty(STUDIO_WIDTH_PROP);
|
||||
target.style.removeProperty(STUDIO_HEIGHT_PROP);
|
||||
target.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
||||
}
|
||||
|
||||
function revertGesture(target: HTMLElement, state: GestureState): void {
|
||||
clearDraftProps(target);
|
||||
if (state.originalTranslate !== undefined) {
|
||||
target.style.setProperty("translate", state.originalTranslate);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elementAtPoint(x, y, _opts) {
|
||||
elementAtPoint(x, y, _perCallOpts) {
|
||||
const hit = opts?.resolvePoint?.(x, y) ?? null;
|
||||
if (!hit) return null;
|
||||
|
||||
let el: Element | null = hit;
|
||||
while (el && el !== doc.body) {
|
||||
if (el.hasAttribute("data-hf-id")) {
|
||||
return opacity(el) === 0 ? null : (el as HTMLElement);
|
||||
return isVisible(el) ? (el as HTMLElement) : null;
|
||||
}
|
||||
// data-hf-root without data-hf-id = outermost stage root — stop
|
||||
if (el.hasAttribute("data-hf-root")) return null;
|
||||
@@ -54,64 +92,63 @@ export function createPreviewAdapter(
|
||||
},
|
||||
|
||||
applyDraft(payload) {
|
||||
// Auto-revert any in-flight gesture before starting a new one so no
|
||||
// element is left with orphaned draft CSS props or the gesture marker.
|
||||
if (gesture) {
|
||||
const prev = findById(gesture.payload.hfId);
|
||||
if (prev) revertGesture(prev, gesture);
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
const target = findById(payload.hfId);
|
||||
if (!target) return;
|
||||
|
||||
const originalTranslate = target.style.getPropertyValue("translate") || undefined;
|
||||
gesture = { hfId: payload.hfId, payload, originalTranslate };
|
||||
target.setAttribute("data-hf-studio-manual-edit-gesture", "true");
|
||||
gesture = { payload, originalTranslate };
|
||||
target.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "true");
|
||||
|
||||
if (payload.type === "move") {
|
||||
target.style.setProperty("--hf-studio-offset-x", `${payload.dx}px`);
|
||||
target.style.setProperty("--hf-studio-offset-y", `${payload.dy}px`);
|
||||
target.style.setProperty(STUDIO_OFFSET_X_PROP, `${payload.dx}px`);
|
||||
target.style.setProperty(STUDIO_OFFSET_Y_PROP, `${payload.dy}px`);
|
||||
} else {
|
||||
target.style.setProperty("--hf-studio-width", `${payload.w}px`);
|
||||
target.style.setProperty("--hf-studio-height", `${payload.h}px`);
|
||||
target.style.setProperty(STUDIO_WIDTH_PROP, `${payload.w}px`);
|
||||
target.style.setProperty(STUDIO_HEIGHT_PROP, `${payload.h}px`);
|
||||
}
|
||||
},
|
||||
|
||||
revertDraft() {
|
||||
if (!gesture) return;
|
||||
const target = findById(gesture.hfId);
|
||||
if (target) {
|
||||
target.style.removeProperty("--hf-studio-offset-x");
|
||||
target.style.removeProperty("--hf-studio-offset-y");
|
||||
target.style.removeProperty("--hf-studio-width");
|
||||
target.style.removeProperty("--hf-studio-height");
|
||||
target.removeAttribute("data-hf-studio-manual-edit-gesture");
|
||||
if (gesture.originalTranslate !== undefined) {
|
||||
target.style.setProperty("translate", gesture.originalTranslate);
|
||||
}
|
||||
}
|
||||
const target = findById(gesture.payload.hfId);
|
||||
if (target) revertGesture(target, gesture);
|
||||
gesture = null;
|
||||
},
|
||||
|
||||
commitPreview() {
|
||||
if (!gesture) return null;
|
||||
const { hfId, payload } = gesture;
|
||||
const { payload } = gesture;
|
||||
|
||||
const target = findById(hfId);
|
||||
if (target) {
|
||||
target.removeAttribute("data-hf-studio-manual-edit-gesture");
|
||||
}
|
||||
const target = findById(payload.hfId);
|
||||
if (target) clearDraftProps(target);
|
||||
gesture = null;
|
||||
|
||||
if (payload.type === "move") {
|
||||
return { type: "moveElement", hfId, dx: payload.dx, dy: payload.dy };
|
||||
return { type: "moveElement", hfId: payload.hfId, dx: payload.dx, dy: payload.dy };
|
||||
}
|
||||
return { type: "resize", hfId, width: payload.w, height: payload.h };
|
||||
return { type: "resize", hfId: payload.hfId, width: payload.w, height: payload.h };
|
||||
},
|
||||
|
||||
getElementTimings() {
|
||||
const result: Record<string, { start?: number; end?: number }> = {};
|
||||
for (const el of Array.from(doc.querySelectorAll("[data-hf-id]"))) {
|
||||
for (const el of doc.querySelectorAll("[data-hf-id]")) {
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (!hfId) continue;
|
||||
const s = el.getAttribute("data-start");
|
||||
const e = el.getAttribute("data-end");
|
||||
const sv = s !== null ? parseFloat(s) : NaN;
|
||||
const ev = e !== null ? parseFloat(e) : NaN;
|
||||
result[hfId] = {
|
||||
start: s !== null ? parseFloat(s) : undefined,
|
||||
end: e !== null ? parseFloat(e) : undefined,
|
||||
start: Number.isFinite(sv) ? sv : undefined,
|
||||
end: Number.isFinite(ev) ? ev : undefined,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
@@ -360,4 +361,33 @@ describe("hf-id surfacing in preview route", () => {
|
||||
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");
|
||||
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>`;
|
||||
writeFileSync(indexPath, sourceHtml);
|
||||
|
||||
const app = new Hono();
|
||||
// Bundler returns the same untagged source HTML (simulates stale cache read)
|
||||
registerPreviewRoutes(app, createAdapter(projectDir, { bundle: async () => sourceHtml }));
|
||||
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]);
|
||||
|
||||
expect(servedIds.length).toBeGreaterThanOrEqual(2);
|
||||
expect(servedIds).toEqual(diskIds);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
createStudioMotionRenderBodyScript,
|
||||
STUDIO_MOTION_PATH,
|
||||
} from "../helpers/studioMotionRenderScript.js";
|
||||
import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
|
||||
import { ensureHfIds } from "../../parsers/hfIds.js";
|
||||
import { persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
|
||||
|
||||
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
||||
const GSAP_CDN_VERSION = "3.15.0";
|
||||
@@ -195,6 +196,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
});
|
||||
|
||||
// Bundled composition preview
|
||||
// fallow-ignore-next-line complexity
|
||||
api.get("/projects/:id/preview", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
@@ -216,8 +218,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
let bundled = await adapter.bundle(project.dir);
|
||||
let mainCompositionPath = "index.html";
|
||||
if (!bundled) {
|
||||
if (!diskMain || normalizedDisk === null) return c.text("not found", 404);
|
||||
bundled = normalizedDisk;
|
||||
if (!diskMain) return c.text("not found", 404);
|
||||
bundled = normalizedDisk ?? diskMain.html;
|
||||
mainCompositionPath = diskMain.compositionPath;
|
||||
}
|
||||
|
||||
@@ -238,6 +240,11 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
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.
|
||||
bundled = injectStudioPreviewAugmentations(
|
||||
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
|
||||
adapter,
|
||||
@@ -246,20 +253,20 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
);
|
||||
return c.html(bundled, 200, previewCacheHeaders(etag));
|
||||
} catch {
|
||||
if (diskMain && normalizedDisk !== null) {
|
||||
// Re-read disk on bundle failure so we serve the latest file content,
|
||||
// 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,
|
||||
);
|
||||
return c.html(
|
||||
injectStudioPreviewAugmentations(
|
||||
ensureHfIds(
|
||||
await transformPreviewHtml(
|
||||
normalizedDisk,
|
||||
adapter,
|
||||
project,
|
||||
diskMain.compositionPath,
|
||||
),
|
||||
),
|
||||
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
|
||||
adapter,
|
||||
project.dir,
|
||||
diskMain.compositionPath,
|
||||
fallback.compositionPath,
|
||||
),
|
||||
200,
|
||||
previewCacheHeaders(etag),
|
||||
@@ -305,6 +312,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
});
|
||||
|
||||
// Static asset serving (with range request support for audio/video seeking)
|
||||
// fallow-ignore-next-line complexity
|
||||
api.get("/projects/:id/preview/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
Reference in New Issue
Block a user