@hyperframes/studio 0.6.114 → 0.6.116
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.
- package/dist/assets/hyperframes-player-CNgCuEoe.js +425 -0
- package/dist/assets/{index-BR5v5xu2.js → index-9da2YwzA.js} +1 -1
- package/dist/assets/index-BvEhf7SS.css +1 -0
- package/dist/assets/{index-DApfviyW.js → index-iuaaor_Q.js} +90 -90
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/domEditingElement.ts +1 -1
- package/src/components/nle/NLELayout.tsx +1 -1
- package/src/components/panels/SlideshowPanel.tsx +13 -25
- package/src/components/panels/SlideshowSubPanels.tsx +11 -40
- package/src/player/lib/timelineDOM.ts +2 -1
- package/src/player/lib/timelineElementHelpers.ts +5 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -3
- package/src/test-setup.ts +6 -0
- package/src/utils/htmlAttrSafety.ts +90 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +54 -2
- package/src/utils/sdkCutoverParity.test.ts +113 -0
- package/dist/assets/hyperframes-player-C1qkW56w.js +0 -418
- package/dist/assets/index-DS7Kfzvb.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-iuaaor_Q.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BvEhf7SS.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.116",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"dompurify": "^3.2.4",
|
|
37
37
|
"marked": "^14.1.4",
|
|
38
38
|
"mediabunny": "^1.45.3",
|
|
39
|
-
"@hyperframes/core": "0.6.
|
|
40
|
-
"@hyperframes/
|
|
41
|
-
"@hyperframes/
|
|
39
|
+
"@hyperframes/core": "0.6.116",
|
|
40
|
+
"@hyperframes/sdk": "0.6.116",
|
|
41
|
+
"@hyperframes/player": "0.6.116"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/react": "19",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"vite": "^6.4.2",
|
|
53
53
|
"vitest": "^3.2.4",
|
|
54
54
|
"zustand": "^5.0.0",
|
|
55
|
-
"@hyperframes/producer": "0.6.
|
|
55
|
+
"@hyperframes/producer": "0.6.116"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"react": "19",
|
|
@@ -126,7 +126,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
126
126
|
if (doc) {
|
|
127
127
|
const found =
|
|
128
128
|
(layer.id ? doc.getElementById(layer.id) : null) ??
|
|
129
|
-
(layer.hfId ? doc.querySelector(`[data-hf-id="${layer.hfId}"]`) : null) ??
|
|
129
|
+
(layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
|
|
130
130
|
doc.getElementById(layer.key);
|
|
131
131
|
if (found instanceof HTMLElement) el = found;
|
|
132
132
|
}
|
|
@@ -242,7 +242,7 @@ export function findElementForSelection(
|
|
|
242
242
|
activeCompositionPath: string | null = null,
|
|
243
243
|
): HTMLElement | null {
|
|
244
244
|
if (selection.hfId) {
|
|
245
|
-
const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
|
|
245
|
+
const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
|
|
246
246
|
if (isHtmlElement(byHfId)) return byHfId;
|
|
247
247
|
}
|
|
248
248
|
|
|
@@ -189,7 +189,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
189
189
|
const doc = iframeRef_.current?.contentDocument;
|
|
190
190
|
if (doc) {
|
|
191
191
|
const host = doc.querySelector(
|
|
192
|
-
`[data-composition-id="${compId}"][data-composition-src]`,
|
|
192
|
+
`[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`,
|
|
193
193
|
);
|
|
194
194
|
if (host) {
|
|
195
195
|
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
|
@@ -57,7 +57,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
|
|
|
57
57
|
import {
|
|
58
58
|
toggleMainLineSlide,
|
|
59
59
|
reorderMainLineSlide,
|
|
60
|
-
reorderBranchSlide,
|
|
61
60
|
setSlideNotes,
|
|
62
61
|
addFragment,
|
|
63
62
|
removeFragment,
|
|
@@ -94,19 +93,24 @@ export function makeSlideshowNotesController(): NotesController {
|
|
|
94
93
|
let pending: Pending | null = null;
|
|
95
94
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
96
95
|
|
|
96
|
+
// Atomically swap the pending entry out and fire its persist (if any).
|
|
97
|
+
// Used by both the debounce timer's tail and the explicit flush() path.
|
|
98
|
+
const drainPending = (): void => {
|
|
99
|
+
const p = pending;
|
|
100
|
+
if (p === null) return;
|
|
101
|
+
pending = null;
|
|
102
|
+
p.persist(p.manifest).catch((err: unknown) => {
|
|
103
|
+
console.error("[slideshow] notes persist failed:", err);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
97
107
|
return {
|
|
98
108
|
schedule(manifest, persist, delayMs) {
|
|
99
109
|
if (timer !== null) clearTimeout(timer);
|
|
100
110
|
pending = { manifest, persist };
|
|
101
111
|
timer = setTimeout(() => {
|
|
102
112
|
timer = null;
|
|
103
|
-
|
|
104
|
-
if (p !== null) {
|
|
105
|
-
pending = null;
|
|
106
|
-
p.persist(p.manifest).catch((err: unknown) => {
|
|
107
|
-
console.error("[slideshow] notes persist failed:", err);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
113
|
+
drainPending();
|
|
110
114
|
}, delayMs);
|
|
111
115
|
return timer;
|
|
112
116
|
},
|
|
@@ -116,13 +120,7 @@ export function makeSlideshowNotesController(): NotesController {
|
|
|
116
120
|
clearTimeout(timer);
|
|
117
121
|
timer = null;
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
if (p !== null) {
|
|
121
|
-
pending = null;
|
|
122
|
-
p.persist(p.manifest).catch((err: unknown) => {
|
|
123
|
-
console.error("[slideshow] notes persist failed:", err);
|
|
124
|
-
});
|
|
125
|
-
}
|
|
123
|
+
drainPending();
|
|
126
124
|
},
|
|
127
125
|
|
|
128
126
|
cancel() {
|
|
@@ -293,15 +291,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
293
291
|
[applyManifest],
|
|
294
292
|
);
|
|
295
293
|
|
|
296
|
-
const handleReorderBranchSlide = useCallback(
|
|
297
|
-
(sequenceId: string, sceneId: string, dir: "up" | "down") => {
|
|
298
|
-
applyManifest(reorderBranchSlide(manifestRef.current, sequenceId, sceneId, dir)).catch(
|
|
299
|
-
() => {},
|
|
300
|
-
);
|
|
301
|
-
},
|
|
302
|
-
[applyManifest],
|
|
303
|
-
);
|
|
304
|
-
|
|
305
294
|
const handleSetNotes = useCallback(
|
|
306
295
|
(notes: string) => {
|
|
307
296
|
if (!selectedSceneId) return;
|
|
@@ -459,7 +448,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
459
448
|
selectedSceneId={selectedSceneId}
|
|
460
449
|
selectedSequenceId={selectedSequenceId}
|
|
461
450
|
onSelectBranchSlide={handleSelectBranchSlide}
|
|
462
|
-
onReorderBranchSlide={handleReorderBranchSlide}
|
|
463
451
|
/>
|
|
464
452
|
)}
|
|
465
453
|
|
|
@@ -216,7 +216,6 @@ export interface BranchTreeProps {
|
|
|
216
216
|
selectedSceneId: string | null;
|
|
217
217
|
selectedSequenceId: string | null;
|
|
218
218
|
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
|
|
219
|
-
onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
|
|
220
219
|
}
|
|
221
220
|
|
|
222
221
|
export function BranchTree({
|
|
@@ -229,7 +228,6 @@ export function BranchTree({
|
|
|
229
228
|
selectedSceneId,
|
|
230
229
|
selectedSequenceId,
|
|
231
230
|
onSelectBranchSlide,
|
|
232
|
-
onReorderBranchSlide,
|
|
233
231
|
}: BranchTreeProps) {
|
|
234
232
|
const [newLabel, setNewLabel] = useState("");
|
|
235
233
|
const inputId = useId();
|
|
@@ -280,7 +278,6 @@ export function BranchTree({
|
|
|
280
278
|
selectedSceneId={selectedSceneId}
|
|
281
279
|
selectedSequenceId={selectedSequenceId}
|
|
282
280
|
onSelectBranchSlide={onSelectBranchSlide}
|
|
283
|
-
onReorderBranchSlide={onReorderBranchSlide}
|
|
284
281
|
/>
|
|
285
282
|
))}
|
|
286
283
|
</div>
|
|
@@ -298,7 +295,6 @@ interface BranchItemProps {
|
|
|
298
295
|
selectedSceneId: string | null;
|
|
299
296
|
selectedSequenceId: string | null;
|
|
300
297
|
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
|
|
301
|
-
onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
|
|
302
298
|
}
|
|
303
299
|
|
|
304
300
|
function BranchItem({
|
|
@@ -310,7 +306,6 @@ function BranchItem({
|
|
|
310
306
|
selectedSceneId,
|
|
311
307
|
selectedSequenceId,
|
|
312
308
|
onSelectBranchSlide,
|
|
313
|
-
onReorderBranchSlide,
|
|
314
309
|
}: BranchItemProps) {
|
|
315
310
|
const [editing, setEditing] = useState(false);
|
|
316
311
|
const [draft, setDraft] = useState(seq.label);
|
|
@@ -362,8 +357,7 @@ function BranchItem({
|
|
|
362
357
|
</div>
|
|
363
358
|
<div className="flex flex-col gap-px pl-2">
|
|
364
359
|
{scenes.map((scene) => {
|
|
365
|
-
const
|
|
366
|
-
const assigned = branchPos !== -1;
|
|
360
|
+
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
|
|
367
361
|
const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id;
|
|
368
362
|
return (
|
|
369
363
|
<div
|
|
@@ -378,39 +372,16 @@ function BranchItem({
|
|
|
378
372
|
className="accent-studio-accent flex-shrink-0"
|
|
379
373
|
/>
|
|
380
374
|
{assigned ? (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
</button>
|
|
392
|
-
<span className="text-[9px] text-neutral-600 tabular-nums flex-shrink-0">
|
|
393
|
-
{branchPos + 1}/{seq.slides.length}
|
|
394
|
-
</span>
|
|
395
|
-
<button
|
|
396
|
-
type="button"
|
|
397
|
-
aria-label={`Move ${scene.label || scene.id} earlier in branch ${seq.label}`}
|
|
398
|
-
disabled={branchPos <= 0}
|
|
399
|
-
className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
|
|
400
|
-
onClick={() => onReorderBranchSlide(seq.id, scene.id, "up")}
|
|
401
|
-
>
|
|
402
|
-
▲
|
|
403
|
-
</button>
|
|
404
|
-
<button
|
|
405
|
-
type="button"
|
|
406
|
-
aria-label={`Move ${scene.label || scene.id} later in branch ${seq.label}`}
|
|
407
|
-
disabled={branchPos >= seq.slides.length - 1}
|
|
408
|
-
className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
|
|
409
|
-
onClick={() => onReorderBranchSlide(seq.id, scene.id, "down")}
|
|
410
|
-
>
|
|
411
|
-
▼
|
|
412
|
-
</button>
|
|
413
|
-
</>
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
aria-pressed={isSelected}
|
|
378
|
+
className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
|
|
379
|
+
isSelected ? "text-white" : "text-neutral-400"
|
|
380
|
+
}`}
|
|
381
|
+
onClick={() => onSelectBranchSlide(seq.id, scene.id)}
|
|
382
|
+
>
|
|
383
|
+
{scene.label || scene.id}
|
|
384
|
+
</button>
|
|
414
385
|
) : (
|
|
415
386
|
<span className="flex-1 truncate">{scene.label || scene.id}</span>
|
|
416
387
|
)}
|
|
@@ -123,7 +123,8 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
123
123
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
124
124
|
let resolvedSrc = clip.compositionSrc;
|
|
125
125
|
if (!resolvedSrc) {
|
|
126
|
-
hostEl =
|
|
126
|
+
hostEl =
|
|
127
|
+
doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
|
|
127
128
|
resolvedSrc =
|
|
128
129
|
hostEl?.getAttribute("data-composition-src") ??
|
|
129
130
|
hostEl?.getAttribute("data-composition-file") ??
|
|
@@ -163,13 +163,13 @@ export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
|
|
163
163
|
// ---------------------------------------------------------------------------
|
|
164
164
|
|
|
165
165
|
export function getTimelineElementSelector(el: Element): string | undefined {
|
|
166
|
-
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
|
166
|
+
if (isHtmlElement(el) && el.id) return `#${CSS.escape(el.id)}`;
|
|
167
167
|
const compId = el.getAttribute("data-composition-id");
|
|
168
|
-
if (compId) return `[data-composition-id="${compId}"]`;
|
|
168
|
+
if (compId) return `[data-composition-id="${CSS.escape(compId)}"]`;
|
|
169
169
|
if (isHtmlElement(el)) {
|
|
170
170
|
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
171
171
|
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
|
172
|
-
if (firstClass) return `.${firstClass}`;
|
|
172
|
+
if (firstClass) return `.${CSS.escape(firstClass)}`;
|
|
173
173
|
}
|
|
174
174
|
return undefined;
|
|
175
175
|
}
|
|
@@ -283,8 +283,8 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
|
|
|
283
283
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
284
284
|
return (
|
|
285
285
|
doc.getElementById(id) ??
|
|
286
|
-
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
|
287
|
-
doc.querySelector(`.${id}`) ??
|
|
286
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
|
|
287
|
+
doc.querySelector(`.${CSS.escape(id)}`) ??
|
|
288
288
|
null
|
|
289
289
|
);
|
|
290
290
|
}
|
|
@@ -295,7 +295,8 @@ export function buildMissingCompositionElements(
|
|
|
295
295
|
let start = parseFloat(startAttr);
|
|
296
296
|
if (isNaN(start)) {
|
|
297
297
|
const ref =
|
|
298
|
-
doc.getElementById(startAttr) ||
|
|
298
|
+
doc.getElementById(startAttr) ||
|
|
299
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
|
|
299
300
|
if (ref) {
|
|
300
301
|
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
301
302
|
let refStart = parseFloat(refStartAttr);
|
|
@@ -303,7 +304,7 @@ export function buildMissingCompositionElements(
|
|
|
303
304
|
if (isNaN(refStart)) {
|
|
304
305
|
const refRef =
|
|
305
306
|
doc.getElementById(refStartAttr) ||
|
|
306
|
-
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
307
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
|
|
307
308
|
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
308
309
|
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
309
310
|
const rrDur =
|
|
@@ -400,7 +401,7 @@ export function buildMissingCompositionElements(
|
|
|
400
401
|
// Find the matching DOM host by element id or composition id
|
|
401
402
|
const host =
|
|
402
403
|
doc.getElementById(existing.id) ??
|
|
403
|
-
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
404
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
|
|
404
405
|
if (!host) return existing;
|
|
405
406
|
const compSrc =
|
|
406
407
|
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts
|
|
2
|
+
const ALLOWED_HTML_ATTRS = new Set([
|
|
3
|
+
"id",
|
|
4
|
+
"class",
|
|
5
|
+
"style",
|
|
6
|
+
"title",
|
|
7
|
+
"name",
|
|
8
|
+
"for",
|
|
9
|
+
"type",
|
|
10
|
+
"lang",
|
|
11
|
+
"dir",
|
|
12
|
+
"translate",
|
|
13
|
+
"hidden",
|
|
14
|
+
"tabindex",
|
|
15
|
+
"draggable",
|
|
16
|
+
"contenteditable",
|
|
17
|
+
"role",
|
|
18
|
+
"slot",
|
|
19
|
+
"href",
|
|
20
|
+
"target",
|
|
21
|
+
"rel",
|
|
22
|
+
"src",
|
|
23
|
+
"srcset",
|
|
24
|
+
"sizes",
|
|
25
|
+
"alt",
|
|
26
|
+
"poster",
|
|
27
|
+
"loading",
|
|
28
|
+
"decoding",
|
|
29
|
+
"crossorigin",
|
|
30
|
+
"preload",
|
|
31
|
+
"autoplay",
|
|
32
|
+
"loop",
|
|
33
|
+
"muted",
|
|
34
|
+
"controls",
|
|
35
|
+
"playsinline",
|
|
36
|
+
"width",
|
|
37
|
+
"height",
|
|
38
|
+
"colspan",
|
|
39
|
+
"rowspan",
|
|
40
|
+
"scope",
|
|
41
|
+
"placeholder",
|
|
42
|
+
"value",
|
|
43
|
+
"min",
|
|
44
|
+
"max",
|
|
45
|
+
"step",
|
|
46
|
+
"pattern",
|
|
47
|
+
"required",
|
|
48
|
+
"disabled",
|
|
49
|
+
"readonly",
|
|
50
|
+
"checked",
|
|
51
|
+
"selected",
|
|
52
|
+
"multiple",
|
|
53
|
+
"accept",
|
|
54
|
+
"maxlength",
|
|
55
|
+
"minlength",
|
|
56
|
+
"rows",
|
|
57
|
+
"cols",
|
|
58
|
+
"wrap",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const URI_BEARING_ATTRS = new Set([
|
|
62
|
+
"src",
|
|
63
|
+
"href",
|
|
64
|
+
"action",
|
|
65
|
+
"formaction",
|
|
66
|
+
"poster",
|
|
67
|
+
"srcset",
|
|
68
|
+
"xlink:href",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
|
72
|
+
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
|
73
|
+
|
|
74
|
+
export function isAllowedHtmlAttribute(name: string): boolean {
|
|
75
|
+
const lower = name.toLowerCase();
|
|
76
|
+
if (lower.startsWith("on")) return false;
|
|
77
|
+
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
|
|
78
|
+
if (lower.startsWith("data-")) return true;
|
|
79
|
+
if (lower.startsWith("aria-")) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isSafeAttributeValue(name: string, value: string): boolean {
|
|
84
|
+
if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
|
|
87
|
+
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
@@ -98,6 +98,36 @@ describe("shouldUseSdkCutover", () => {
|
|
|
98
98
|
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
it("declines html-attribute ops with event handler names", () => {
|
|
102
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("declines html-attribute ops with disallowed attribute names", () => {
|
|
111
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("declines html-attribute ops with dangerous URI schemes", () => {
|
|
115
|
+
expect(
|
|
116
|
+
shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
|
|
117
|
+
).toBe(false);
|
|
118
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
|
|
119
|
+
false,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("declines html-attribute ops with dangerous data URIs", () => {
|
|
124
|
+
expect(
|
|
125
|
+
shouldUseSdkCutover(true, true, "hf-abc", [
|
|
126
|
+
htmlAttrOp("href", "data:text/html,<script>alert(1)</script>"),
|
|
127
|
+
]),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
101
131
|
it("returns true when ops mix all supported types", () => {
|
|
102
132
|
expect(
|
|
103
133
|
shouldUseSdkCutover(true, true, "hf-abc", [
|
|
@@ -205,6 +235,27 @@ describe("sdkCutoverPersist", () => {
|
|
|
205
235
|
});
|
|
206
236
|
});
|
|
207
237
|
|
|
238
|
+
it.each([
|
|
239
|
+
{ name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
|
|
240
|
+
{ name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
|
|
241
|
+
])("declines text-content cutover for $name", async ({ children }) => {
|
|
242
|
+
const deps = makeDeps();
|
|
243
|
+
const session = makeSession(true);
|
|
244
|
+
(session!.getElement as ReturnType<typeof vi.fn>).mockReturnValue({ children });
|
|
245
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
246
|
+
const result = await sdkCutoverPersist(
|
|
247
|
+
sel,
|
|
248
|
+
[textOp("Hello world")],
|
|
249
|
+
"before",
|
|
250
|
+
"/comp.html",
|
|
251
|
+
session,
|
|
252
|
+
deps,
|
|
253
|
+
);
|
|
254
|
+
expect(result).toBe(false);
|
|
255
|
+
expect(session!.dispatch).not.toHaveBeenCalled();
|
|
256
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
208
259
|
it("dispatches setAttribute for attribute op with data- prefix", async () => {
|
|
209
260
|
const deps = makeDeps();
|
|
210
261
|
const session = makeSession(true);
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|