Skip to content

Commit 740a83a

Browse files
vanceingallsclaude
andauthored
feat(core): hf-id write-back to disk + serve-time surfacing (R7, Tasks 1-2) (#1289)
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1) * docs(core): document legacy-id round-trip in clip-model readback (R1 review) Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable, exact-match) by design — targeting uses exact [data-hf-id="…"] match and does not require the hf- shape; legacy values re-mint only at the R7 write-back. Not a bug. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(core): fix misleading legacy-id migration comment in htmlParser.ts The original comment said legacy data-hf-id values "are re-minted only once the R7 write-back persists freshly-minted ids to source" — which is incorrect. ensureHfIds skips elements that already carry data-hf-id, so legacy values (e.g. data-hf-id="my-title") persist indefinitely and are NOT automatically re-minted. Exact-match targeting still works correctly. Update comment to reflect actual behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(studio): sourcePatcher data-hf-id targeting (R1, T3) * fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review) Addresses Rames' review on #1271: execDataAttrPattern returned the first regex match without checking for a second. A duplicate id/data-hf-id in source (id drift) would silently patch one element and leave the other stale. Now warns when more than one element matches. By the mint contract it should never fire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(studio): pin hfId-is-authoritative-over-selector contract (R1, T3 review) Adds test: "hfId match is authoritative — selector is not used as a narrowing filter". When hfId matches element A and selector points at element B, findTagByTarget returns A without consulting selector as a narrowing filter. Pins the intended behaviour so a future refactor cannot silently start narrowing by selector. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(core): sourceMutation data-hf-id targeting (R1, T7) * test(core): update htmlParser baselines for R1 hf- id format Elements now get data-hf-id minted by ensureHfIds; parser reads data-hf-id as model id, so HTML id attrs are no longer the model id. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): data-hf-id survives id/selector patch (R1, T7) Locks the preservation guarantee the write-back design depends on: a Studio edit targeting by id or selector (it never sends hfId) must not strip an existing data-hf-id, or the stable handle is destroyed by the next edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): escape hfId in selector + warn on duplicate match (R1, T7 review) Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value injection guard) and warn when a hfId matches more than one element instead of silently patching an arbitrary one. Adds an injection-guard test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(core): previewAdapter contract failing tests (T10 spec for R7) * feat(core): hf-id write-back to disk + serve-time surfacing (R7, Task 1-2) * test(core): replace tautological stability tests with real disk tests for persistHfIdsIfNeeded Prior tests only exercised normalizeHfIds (pure function) and the existing pin guard in ensureHfIds — both pass on the parent commit without any Task 1 code. Replace with three tests that exercise the actual disk write-back: - writes data-hf-id to disk when source is untagged - does not rewrite disk when source is already tagged (idempotent) - returned id matches id written to disk (serve-time == persist-time invariant) These fail on the parent commit (persistHfIdsIfNeeded doesn't exist) and green after Task 1. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): route-level tests for data-hf-id surfacing and disk write-back (R7, Task 1-2) Two integration tests against the preview route (via Hono test harness): - served HTML carries data-hf-id on body elements (>= 2 matches for div+p) - disk file contains data-hf-id after first GET (write-back verified via readFileSync) These fail on the parent commit (no hfIdPersist wiring in preview.ts) and green after Task 1. Closes the verification gap flagged in review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 72e4f1a commit 740a83a

6 files changed

Lines changed: 176 additions & 10 deletions

File tree

packages/core/src/parsers/hfIds.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ describe("ensureHfIds", () => {
7777
expect(a).toMatch(/^hf-[a-z0-9]{4}$/);
7878
expect(b).toMatch(/^hf-[a-z0-9]{4}$/);
7979
});
80+
81+
// Post-persist stability: once data-hf-id is written back to source, edits
82+
// don't drift the id because the attribute is already present and pinned.
83+
it("pinned id survives text edit after first persist", () => {
84+
const raw = `<!doctype html><html><body><div>original text</div></body></html>`;
85+
const persisted = ensureHfIds(raw); // simulates write-back on first serve
86+
const [originalId] = ids(persisted);
87+
const edited = persisted.replace("original text", "edited text");
88+
expect(ids(ensureHfIds(edited))).toContain(originalId);
89+
});
90+
91+
it("pinned id survives attribute edit after first persist", () => {
92+
const raw = `<!doctype html><html><body><div class="old">text</div></body></html>`;
93+
const persisted = ensureHfIds(raw); // simulates write-back on first serve
94+
const [originalId] = ids(persisted);
95+
const edited = persisted.replace('class="old"', 'class="new"');
96+
expect(ids(ensureHfIds(edited))).toContain(originalId);
97+
});
8098
});
8199

82100
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds

packages/core/src/parsers/hfIds.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ function contentKey(el: Element): string {
5454
return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`;
5555
}
5656

57+
/**
58+
* Collision tiebreak for byte-identical siblings: document-order dup counter
59+
* (`hash(key#N)`). This IS order-dependent — two identical `<span></span>`
60+
* get different ids based on which comes first in the DOM. This is unavoidable:
61+
* unique ids for byte-identical elements require a positional signal.
62+
*
63+
* Why this is safe in practice: once `ensureHfIds` write-back persists
64+
* `data-hf-id` to source the attribute is physically bound to its element.
65+
* Reordering identical siblings carries the attribute along → zero
66+
* order-dependence post-persist. `ensureHfIds` skips pinned elements
67+
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
68+
* never re-exposes the ordering after first persist.
69+
*/
5770
export function mintHfId(el: Element, assigned: Set<string>): string {
5871
const key = contentKey(el);
5972
let id = toHfId(fnv1a(key));
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";
6+
7+
describe("normalizeHfIds", () => {
8+
it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
9+
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
10+
const { html, changed } = normalizeHfIds(raw);
11+
expect(changed).toBe(true);
12+
expect(html).toContain('data-hf-id="hf-');
13+
const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
14+
expect(matches?.length).toBeGreaterThanOrEqual(2);
15+
});
16+
17+
it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
18+
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
19+
const first = normalizeHfIds(raw).html;
20+
const { html, changed } = normalizeHfIds(first);
21+
expect(changed).toBe(false);
22+
expect(html).toBe(first);
23+
});
24+
});
25+
26+
describe("persistHfIdsIfNeeded", () => {
27+
const tmpDirs: string[] = [];
28+
29+
afterEach(() => {
30+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
31+
tmpDirs.length = 0;
32+
});
33+
34+
function tmpFile(content: string): string {
35+
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
36+
tmpDirs.push(dir);
37+
const file = join(dir, "index.html");
38+
writeFileSync(file, content, "utf-8");
39+
return file;
40+
}
41+
42+
it("writes data-hf-id to disk when source is untagged", () => {
43+
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
44+
const file = tmpFile(raw);
45+
const returned = persistHfIdsIfNeeded(file, raw);
46+
expect(returned).toContain('data-hf-id="hf-');
47+
const onDisk = readFileSync(file, "utf-8");
48+
expect(onDisk).toContain('data-hf-id="hf-');
49+
expect(onDisk).toBe(returned);
50+
});
51+
52+
it("does not rewrite disk when source is already tagged", () => {
53+
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
54+
const file = tmpFile(raw);
55+
const tagged = persistHfIdsIfNeeded(file, raw);
56+
const diskAfterFirst = readFileSync(file, "utf-8");
57+
const returned2 = persistHfIdsIfNeeded(file, tagged);
58+
expect(returned2).toBe(tagged);
59+
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
60+
});
61+
62+
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
63+
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
64+
const file = tmpFile(raw);
65+
const result = persistHfIdsIfNeeded(file, raw);
66+
const onDisk = readFileSync(file, "utf-8");
67+
expect(result).toBe(onDisk);
68+
});
69+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ensureHfIds } from "../../parsers/hfIds.js";
2+
import { writeFileSync } from "node:fs";
3+
4+
export { ensureHfIds };
5+
6+
export function normalizeHfIds(html: string): { html: string; changed: boolean } {
7+
const normalized = ensureHfIds(html);
8+
return { html: normalized, changed: normalized !== html };
9+
}
10+
11+
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
12+
const { html: normalized, changed } = normalizeHfIds(html);
13+
if (changed) {
14+
try {
15+
writeFileSync(filePath, normalized, "utf-8");
16+
} catch {
17+
// non-fatal — serve with ids even if persist fails
18+
}
19+
}
20+
return normalized;
21+
}

packages/core/src/studio-api/routes/preview.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,36 @@ describe("registerPreviewRoutes", () => {
328328
expect(signature).toMatch(/^[a-f0-9]{24}$/);
329329
});
330330
});
331+
332+
describe("hf-id surfacing in preview route", () => {
333+
it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
334+
const projectDir = createProjectDir();
335+
writeFileSync(
336+
join(projectDir, "index.html"),
337+
`<!doctype html><html><head></head><body><div class="card"><p>text</p></div></body></html>`,
338+
);
339+
const app = new Hono();
340+
registerPreviewRoutes(app, createAdapter(projectDir));
341+
const res = await app.request("http://localhost/projects/demo/preview");
342+
expect(res.status).toBe(200);
343+
const html = await res.text();
344+
const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
345+
// div and p both tagged
346+
expect(ids?.length).toBeGreaterThanOrEqual(2);
347+
});
348+
349+
it("writes data-hf-id back to disk on first serve", async () => {
350+
const { readFileSync } = await import("node:fs");
351+
const projectDir = createProjectDir();
352+
const indexPath = join(projectDir, "index.html");
353+
writeFileSync(
354+
indexPath,
355+
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
356+
);
357+
const app = new Hono();
358+
registerPreviewRoutes(app, createAdapter(projectDir));
359+
await app.request("http://localhost/projects/demo/preview");
360+
const onDisk = readFileSync(indexPath, "utf-8");
361+
expect(onDisk).toContain('data-hf-id="hf-');
362+
});
363+
});

packages/core/src/studio-api/routes/preview.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createStudioMotionRenderBodyScript,
1212
STUDIO_MOTION_PATH,
1313
} from "../helpers/studioMotionRenderScript.js";
14+
import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
1415

1516
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
1617
const GSAP_CDN_VERSION = "3.15.0";
@@ -205,14 +206,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
205206
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
206207
}
207208

209+
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
210+
const diskMain = resolveProjectMainHtml(project.dir, project.id);
211+
const normalizedDisk = diskMain
212+
? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
213+
: null;
214+
208215
try {
209216
let bundled = await adapter.bundle(project.dir);
210217
let mainCompositionPath = "index.html";
211218
if (!bundled) {
212-
const main = resolveProjectMainHtml(project.dir, project.id);
213-
if (!main) return c.text("not found", 404);
214-
bundled = main.html;
215-
mainCompositionPath = main.compositionPath;
219+
if (!diskMain || normalizedDisk === null) return c.text("not found", 404);
220+
bundled = normalizedDisk;
221+
mainCompositionPath = diskMain.compositionPath;
216222
}
217223

218224
// Inject runtime if not already present (check URL pattern and bundler attribute)
@@ -233,21 +239,27 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
233239
}
234240

235241
bundled = injectStudioPreviewAugmentations(
236-
await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
242+
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
237243
adapter,
238244
project.dir,
239245
mainCompositionPath,
240246
);
241247
return c.html(bundled, 200, previewCacheHeaders(etag));
242248
} catch {
243-
const main = resolveProjectMainHtml(project.dir, project.id);
244-
if (main) {
249+
if (diskMain && normalizedDisk !== null) {
245250
return c.html(
246251
injectStudioPreviewAugmentations(
247-
await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
252+
ensureHfIds(
253+
await transformPreviewHtml(
254+
normalizedDisk,
255+
adapter,
256+
project,
257+
diskMain.compositionPath,
258+
),
259+
),
248260
adapter,
249261
project.dir,
250-
main.compositionPath,
262+
diskMain.compositionPath,
251263
),
252264
200,
253265
previewCacheHeaders(etag),
@@ -284,7 +296,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
284296
const baseHref = `/api/projects/${project.id}/preview/`;
285297
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
286298
if (!html) return c.text("not found", 404);
287-
html = await transformPreviewHtml(html, adapter, project, compPath);
299+
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
288300
return c.html(
289301
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
290302
200,

0 commit comments

Comments
 (0)