@hyperframes/studio 0.6.114 → 0.6.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry";
8
8
  import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
9
9
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
10
10
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
11
+ import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
11
12
 
12
13
  const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
13
14
  "inline-style",
@@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
52
53
  return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
53
54
  }
54
55
 
56
+ // ─── html-attribute safety ───────────────────────────────────────────────────
57
+
58
+ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
59
+ return ops.some(
60
+ (op) =>
61
+ op.type === "html-attribute" &&
62
+ (!isAllowedHtmlAttribute(op.property) ||
63
+ (op.value !== null && !isSafeAttributeValue(op.property, op.value))),
64
+ );
65
+ }
66
+
67
+ function hasTextContentOp(ops: PatchOperation[]): boolean {
68
+ return ops.some((op) => op.type === "text-content");
69
+ }
70
+
71
+ function targetChildren(target: unknown): unknown[] | null {
72
+ if (!target || typeof target !== "object" || !("children" in target)) return null;
73
+ const children = (target as { children?: unknown }).children;
74
+ return Array.isArray(children) ? children : null;
75
+ }
76
+
77
+ function elementTag(element: unknown): string | null {
78
+ if (!element || typeof element !== "object" || !("tag" in element)) return null;
79
+ const tag = (element as { tag?: unknown }).tag;
80
+ return typeof tag === "string" ? tag.toLowerCase() : null;
81
+ }
82
+
83
+ // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
84
+ // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
85
+ // HTMLElement` — that runtime check catches the same set, but we can't use it
86
+ // here because `target` is a plain SDK object, not a DOM Element. If linkedom
87
+ // (or a future parser) surfaces additional foreign-content elements as
88
+ // non-HTMLElement, add them here.
89
+ const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
90
+
91
+ function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
92
+ if (!hasTextContentOp(ops)) return false;
93
+ const children = targetChildren(target);
94
+ if (!children) return false;
95
+ // Legacy patch-element replaces the whole element for multi-child targets and
96
+ // for single non-HTML children. The SDK text patch stream stores a scalar
97
+ // inverse, so those shapes cannot be made both byte-identical and undo-safe
98
+ // here. Let the server path remain authoritative for them.
99
+ if (children.length > 1) return true;
100
+ const tag = elementTag(children[0]);
101
+ return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
102
+ }
103
+
55
104
  export function shouldUseSdkCutover(
56
105
  flagEnabled: boolean,
57
106
  hasSession: boolean,
@@ -64,7 +113,8 @@ export function shouldUseSdkCutover(
64
113
  !!hfId &&
65
114
  ops.length > 0 &&
66
115
  ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
67
- !ops.some(mapsToReservedAttr)
116
+ !ops.some(mapsToReservedAttr) &&
117
+ !hasUnsafeHtmlAttributeOp(ops)
68
118
  );
69
119
  }
70
120
 
@@ -186,7 +236,9 @@ export async function sdkCutoverPersist(
186
236
  if (!sdkSession) return false;
187
237
  const hfId = selection.hfId;
188
238
  if (!hfId) return false;
189
- if (!sdkSession.getElement(hfId)) return false;
239
+ const target = sdkSession.getElement(hfId);
240
+ if (!target) return false;
241
+ if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
190
242
  if (wrongCompositionFile(deps, targetPath)) return false;
191
243
  try {
192
244
  const before = sdkSession.serialize();
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { openComposition } from "@hyperframes/sdk";
3
+ import { patchElementInHtml } from "../../../core/src/studio-api/helpers/sourceMutation.js";
4
+ import type { PatchOperation } from "./sourcePatcher";
5
+ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
6
+
7
+ const shell = (body: string) => `<!DOCTYPE html>
8
+ <html><head></head><body>${body}</body></html>`;
9
+
10
+ async function applySdkDomCutover(source: string, ops: PatchOperation[]): Promise<string> {
11
+ const session = await openComposition(source, { history: false });
12
+ session.batch(() => {
13
+ for (const op of patchOpsToSdkEditOps("hf-target", ops)) {
14
+ session.dispatch(op);
15
+ }
16
+ });
17
+ return session.serialize();
18
+ }
19
+
20
+ const cases: Array<{ name: string; source: string; ops: PatchOperation[] }> = [
21
+ {
22
+ name: "coalesces multi-property inline style including transform and custom props",
23
+ source: shell(
24
+ '<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old<span data-hf-id="hf-child">Child</span></div>',
25
+ ),
26
+ ops: [
27
+ { type: "inline-style", property: "transform", value: "translateX(10px)" },
28
+ { type: "inline-style", property: "transform-origin", value: "50% 50%" },
29
+ { type: "inline-style", property: "--x", value: "12px" },
30
+ ],
31
+ },
32
+ {
33
+ name: "removes one inline style from a multi-property declaration",
34
+ source: shell(
35
+ '<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old</div>',
36
+ ),
37
+ ops: [{ type: "inline-style", property: "opacity", value: null }],
38
+ },
39
+ {
40
+ name: "preserves semicolon-bearing CSS values when updating another style",
41
+ source: shell(
42
+ '<div data-hf-id="hf-target" style="background: url(data:image/svg+xml;utf8,<svg></svg>); color: red; opacity: 0.5">Old</div>',
43
+ ),
44
+ ops: [{ type: "inline-style", property: "color", value: "blue" }],
45
+ },
46
+ {
47
+ name: "sets direct text content",
48
+ source: shell('<div data-hf-id="hf-target">Old</div>'),
49
+ ops: [{ type: "text-content", property: "text", value: "New" }],
50
+ },
51
+ {
52
+ name: "sets text on the single child target used by the legacy path",
53
+ source: shell('<button data-hf-id="hf-target"><span data-hf-id="hf-child">Old</span></button>'),
54
+ ops: [{ type: "text-content", property: "text", value: "New" }],
55
+ },
56
+ {
57
+ name: "sets text on the single child target while preserving parent text",
58
+ source: shell('<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">Old</span></div>'),
59
+ ops: [{ type: "text-content", property: "text", value: "New" }],
60
+ },
61
+ {
62
+ name: "sets data attributes through attribute ops",
63
+ source: shell('<div data-hf-id="hf-target" data-old="1">Old</div>'),
64
+ ops: [{ type: "attribute", property: "mode", value: "hero" }],
65
+ },
66
+ {
67
+ name: "removes data attributes through attribute ops",
68
+ source: shell('<div data-hf-id="hf-target" data-mode="hero">Old</div>'),
69
+ ops: [{ type: "attribute", property: "mode", value: null }],
70
+ },
71
+ {
72
+ name: "sets allowed html attributes",
73
+ source: shell('<a data-hf-id="hf-target" href="https://example.com">Old</a>'),
74
+ ops: [{ type: "html-attribute", property: "aria-label", value: "Primary link" }],
75
+ },
76
+ {
77
+ name: "sets shorthand over existing longhands (inset over top/right/bottom/left)",
78
+ source: shell(
79
+ '<div data-hf-id="hf-target" style="top: 10px; right: 10px; bottom: 10px; left: 10px; opacity: 1">Old</div>',
80
+ ),
81
+ ops: [{ type: "inline-style", property: "inset", value: "0" }],
82
+ },
83
+ {
84
+ name: "sets longhand over existing shorthand (top over inset)",
85
+ source: shell('<div data-hf-id="hf-target" style="inset: 0; opacity: 1">Old</div>'),
86
+ ops: [{ type: "inline-style", property: "top", value: "10px" }],
87
+ },
88
+ {
89
+ name: "mixed-type batch: inline-style + text-content in one op list",
90
+ source: shell('<div data-hf-id="hf-target" style="color: red">Old</div>'),
91
+ ops: [
92
+ { type: "inline-style", property: "color", value: "blue" },
93
+ { type: "text-content", property: "text", value: "New" },
94
+ ],
95
+ },
96
+ {
97
+ name: "mixed-type batch: inline-style + attribute in one op list",
98
+ source: shell('<div data-hf-id="hf-target" style="opacity: 0.5" data-mode="default">Old</div>'),
99
+ ops: [
100
+ { type: "inline-style", property: "opacity", value: "1" },
101
+ { type: "attribute", property: "mode", value: "hero" },
102
+ ],
103
+ },
104
+ ];
105
+
106
+ describe("SDK cutover DOM serialization parity", () => {
107
+ it.each(cases)("$name", async ({ source, ops }) => {
108
+ const legacy = patchElementInHtml(source, { hfId: "hf-target" }, ops);
109
+ expect(legacy.matched).toBe(true);
110
+
111
+ await expect(applySdkDomCutover(source, ops)).resolves.toBe(legacy.html);
112
+ });
113
+ });