diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/core/src/parsers/hfIds.test.ts index f4125be8f..6001afe08 100644 --- a/packages/core/src/parsers/hfIds.test.ts +++ b/packages/core/src/parsers/hfIds.test.ts @@ -77,6 +77,24 @@ describe("ensureHfIds", () => { expect(a).toMatch(/^hf-[a-z0-9]{4}$/); expect(b).toMatch(/^hf-[a-z0-9]{4}$/); }); + + // Post-persist stability: once data-hf-id is written back to source, edits + // don't drift the id because the attribute is already present and pinned. + it("pinned id survives text edit after first persist", () => { + const raw = `
original text
`; + const persisted = ensureHfIds(raw); // simulates write-back on first serve + const [originalId] = ids(persisted); + const edited = persisted.replace("original text", "edited text"); + expect(ids(ensureHfIds(edited))).toContain(originalId); + }); + + it("pinned id survives attribute edit after first persist", () => { + const raw = `
text
`; + const persisted = ensureHfIds(raw); // simulates write-back on first serve + const [originalId] = ids(persisted); + const edited = persisted.replace('class="old"', 'class="new"'); + expect(ids(ensureHfIds(edited))).toContain(originalId); + }); }); // Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds diff --git a/packages/core/src/parsers/hfIds.ts b/packages/core/src/parsers/hfIds.ts index 6f005eeb7..7b1f9cff4 100644 --- a/packages/core/src/parsers/hfIds.ts +++ b/packages/core/src/parsers/hfIds.ts @@ -54,6 +54,19 @@ function contentKey(el: Element): string { 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 `` + * 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. + */ export function mintHfId(el: Element, assigned: Set): string { const key = contentKey(el); let id = toHfId(fnv1a(key)); diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts new file mode 100644 index 000000000..e0e0ab154 --- /dev/null +++ b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts @@ -0,0 +1,69 @@ +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 = `

hello

`; + 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 = `

hello

`; + const first = normalizeHfIds(raw).html; + const { html, changed } = normalizeHfIds(first); + expect(changed).toBe(false); + expect(html).toBe(first); + }); +}); + +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 = `
hello
`; + 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 = `
hello
`; + 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("returned id matches id written to disk (serve-time == persist-time invariant)", () => { + const raw = `text`; + const file = tmpFile(raw); + const result = persistHfIdsIfNeeded(file, raw); + const onDisk = readFileSync(file, "utf-8"); + expect(result).toBe(onDisk); + }); +}); diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/core/src/studio-api/helpers/hfIdPersist.ts new file mode 100644 index 000000000..8ec6bfdc4 --- /dev/null +++ b/packages/core/src/studio-api/helpers/hfIdPersist.ts @@ -0,0 +1,21 @@ +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 }; +} + +export function persistHfIdsIfNeeded(filePath: string, html: string): string { + const { html: normalized, changed } = normalizeHfIds(html); + if (changed) { + try { + writeFileSync(filePath, normalized, "utf-8"); + } catch { + // non-fatal — serve with ids even if persist fails + } + } + return normalized; +} diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index 54cd0a6c4..c2c6ca5b2 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -328,3 +328,36 @@ describe("registerPreviewRoutes", () => { expect(signature).toMatch(/^[a-f0-9]{24}$/); }); }); + +describe("hf-id surfacing in preview route", () => { + it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + `

text

`, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + const res = await app.request("http://localhost/projects/demo/preview"); + expect(res.status).toBe(200); + const html = await res.text(); + const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g); + // div and p both tagged + 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, + `
hello
`, + ); + 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-'); + }); +}); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index 30da87ba6..ad74ec2f8 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -11,6 +11,7 @@ import { createStudioMotionRenderBodyScript, STUDIO_MOTION_PATH, } from "../helpers/studioMotionRenderScript.js"; +import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js"; const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; const GSAP_CDN_VERSION = "3.15.0"; @@ -205,14 +206,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); } + // 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; + try { let bundled = await adapter.bundle(project.dir); let mainCompositionPath = "index.html"; if (!bundled) { - const main = resolveProjectMainHtml(project.dir, project.id); - if (!main) return c.text("not found", 404); - bundled = main.html; - mainCompositionPath = main.compositionPath; + if (!diskMain || normalizedDisk === null) return c.text("not found", 404); + bundled = normalizedDisk; + mainCompositionPath = diskMain.compositionPath; } // Inject runtime if not already present (check URL pattern and bundler attribute) @@ -233,21 +239,27 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi } bundled = injectStudioPreviewAugmentations( - await transformPreviewHtml(bundled, adapter, project, mainCompositionPath), + ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)), adapter, project.dir, mainCompositionPath, ); return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { - const main = resolveProjectMainHtml(project.dir, project.id); - if (main) { + if (diskMain && normalizedDisk !== null) { return c.html( injectStudioPreviewAugmentations( - await transformPreviewHtml(main.html, adapter, project, main.compositionPath), + ensureHfIds( + await transformPreviewHtml( + normalizedDisk, + adapter, + project, + diskMain.compositionPath, + ), + ), adapter, project.dir, - main.compositionPath, + diskMain.compositionPath, ), 200, previewCacheHeaders(etag), @@ -284,7 +296,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi const baseHref = `/api/projects/${project.id}/preview/`; let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref); if (!html) return c.text("not found", 404); - html = await transformPreviewHtml(html, adapter, project, compPath); + html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath)); return c.html( injectStudioPreviewAugmentations(html, adapter, project.dir, compPath), 200,