mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(sdk,studio): restore DOM edit cutover parity (#1565)
- Add splitStyleDeclarations with quote/paren-aware CSS parsing - Fix backslash escape handling inside quoted CSS string values - Close html-attribute safety gap in SDK cutover (event handlers, dangerous URIs) - Consolidate HTML attribute safety constants to core/utils/htmlAttrSafety.ts - Extract NON_HTML_CHILD_TAGS set for foreign-content decline gate - Add sdkCutoverParity test corpus (shorthand/longhand, mixed batches)
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
|
||||
import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js";
|
||||
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
||||
|
||||
// Tags that carry no editable content and must not enter the element tree.
|
||||
@@ -27,14 +27,10 @@ const EXCLUDED_TAGS = new Set([
|
||||
]);
|
||||
|
||||
// Snapshot text is TRIMMED for display (markup indentation produces noisy
|
||||
// whitespace text nodes). setText writes verbatim — engine getOwnText/setOwnText
|
||||
// operate on raw text. el.text is a display value, not a round-trip identity.
|
||||
function ownText(el: Element): string | null {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
const trimmed = text.trim();
|
||||
// whitespace text nodes). The raw text target is shared with setText so shadow
|
||||
// value checks and dispatch serialization use the same DOM target.
|
||||
function snapshotText(el: Element): string | null {
|
||||
const trimmed = getOwnText(el).trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
@@ -147,7 +143,7 @@ function buildElement(
|
||||
inlineStyles,
|
||||
classNames,
|
||||
attributes,
|
||||
text: ownText(el),
|
||||
text: snapshotText(el),
|
||||
start,
|
||||
duration,
|
||||
trackIndex,
|
||||
|
||||
@@ -138,14 +138,58 @@ function toKebab(prop: string): string {
|
||||
}
|
||||
|
||||
/** Parse style attribute string → camelCase map (custom props kept as-is). */
|
||||
interface StyleDeclarationScan {
|
||||
depth: number;
|
||||
quote: "'" | '"' | null;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void {
|
||||
if (scan.quote) {
|
||||
if (ch === "\\" && next) {
|
||||
scan.skip = true;
|
||||
return;
|
||||
}
|
||||
if (ch === scan.quote) scan.quote = null;
|
||||
return;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
scan.quote = ch;
|
||||
return;
|
||||
}
|
||||
if (ch === "(") scan.depth++;
|
||||
else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1);
|
||||
}
|
||||
|
||||
function splitStyleDeclarations(style: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false };
|
||||
let start = 0;
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
if (scan.skip) {
|
||||
scan.skip = false;
|
||||
continue;
|
||||
}
|
||||
const ch = style[i] ?? "";
|
||||
if (ch === ";" && scan.depth === 0 && scan.quote === null) {
|
||||
declarations.push(style.slice(start, i));
|
||||
start = i + 1;
|
||||
} else {
|
||||
advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? "");
|
||||
}
|
||||
}
|
||||
declarations.push(style.slice(start));
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of styleAttr.split(";")) {
|
||||
for (const decl of splitStyleDeclarations(styleAttr)) {
|
||||
const idx = decl.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const rawProp = decl.slice(0, idx).trim();
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
if (!rawProp || !value) continue;
|
||||
if (!rawProp) continue;
|
||||
result[toCamel(rawProp)] = value;
|
||||
}
|
||||
return result;
|
||||
@@ -185,8 +229,21 @@ export function setElementStyles(el: Element, updates: Record<string, string | n
|
||||
|
||||
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read only direct (non-descendant) text node content. */
|
||||
function isHTMLElementTarget(el: Element): boolean {
|
||||
const HTMLElementCtor = el.ownerDocument.defaultView?.HTMLElement;
|
||||
if (HTMLElementCtor) return el instanceof HTMLElementCtor;
|
||||
return "style" in el;
|
||||
}
|
||||
|
||||
function resolveSingleChildTextTarget(el: Element): Element | null {
|
||||
const inner = el.children.length === 1 ? el.firstElementChild : null;
|
||||
return inner && isHTMLElementTarget(inner) ? inner : null;
|
||||
}
|
||||
|
||||
/** Read the text target used by SDK setText. */
|
||||
export function getOwnText(el: Element): string {
|
||||
const singleChild = resolveSingleChildTextTarget(el);
|
||||
if (singleChild) return singleChild.textContent ?? "";
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
@@ -194,8 +251,14 @@ export function getOwnText(el: Element): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Replace only direct text nodes — preserves child elements. */
|
||||
/** Replace the SDK text target without destroying multi-child element structure. */
|
||||
export function setOwnText(el: Element, text: string): void {
|
||||
const singleChild = resolveSingleChildTextTarget(el);
|
||||
if (singleChild) {
|
||||
singleChild.textContent = text;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = el.ownerDocument;
|
||||
const children = Array.from(el.childNodes);
|
||||
// Track original position of the first text node so we restore there, not at firstChild.
|
||||
|
||||
@@ -221,6 +221,37 @@ describe("setText", () => {
|
||||
expect(result.forward[0]?.value).toBe("Added");
|
||||
});
|
||||
|
||||
it("matches legacy single-child text targeting", () => {
|
||||
const parsed = parseMutable(
|
||||
'<button data-hf-id="hf-target"><span data-hf-id="hf-child">Old</span></button>',
|
||||
);
|
||||
const result = applyOp(parsed, { type: "setText", target: "hf-target", value: "New" });
|
||||
expect(serializeDocument(parsed)).toContain(
|
||||
'<button data-hf-id="hf-target"><span data-hf-id="hf-child">New</span></button>',
|
||||
);
|
||||
expect(result.inverse[0]?.value).toBe("Old");
|
||||
});
|
||||
|
||||
it("preserves parent text when the legacy target is a single child", () => {
|
||||
const parsed = parseMutable(
|
||||
'<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">Old</span></div>',
|
||||
);
|
||||
applyOp(parsed, { type: "setText", target: "hf-target", value: "New" });
|
||||
expect(serializeDocument(parsed)).toContain(
|
||||
'<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">New</span></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-HTML single children out of the child text shortcut", () => {
|
||||
const parsed = parseMutable(
|
||||
'<div data-hf-id="hf-target"><svg data-hf-id="hf-child"><text>Old</text></svg></div>',
|
||||
);
|
||||
applyOp(parsed, { type: "setText", target: "hf-target", value: "New" });
|
||||
const html = serializeDocument(parsed);
|
||||
expect(html).toContain('<svg data-hf-id="hf-child"><text');
|
||||
expect(html).toContain("Old</text></svg>New</div>");
|
||||
});
|
||||
|
||||
it("override-set key maps correctly", () => {
|
||||
expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text");
|
||||
});
|
||||
@@ -695,6 +726,21 @@ describe("setElementStyles key normalization", () => {
|
||||
setElementStyles(el, { "transform-origin": "top left" });
|
||||
expect(getElementStyles(el).transformOrigin).toBe("top left");
|
||||
});
|
||||
|
||||
it("preserves semicolon-bearing CSS values when updating another property", () => {
|
||||
const el = elWith("background: url(data:image/svg+xml;utf8,<svg></svg>); color: red");
|
||||
setElementStyles(el, { color: "blue" });
|
||||
expect(el.getAttribute("style")).toContain("background: url(data:image/svg+xml;utf8");
|
||||
expect(el.getAttribute("style")).toContain("color: blue");
|
||||
});
|
||||
|
||||
it("handles escaped quotes inside CSS string values", () => {
|
||||
const el = elWith("color: red");
|
||||
el.setAttribute("style", 'content: "a\\";b"; color: red');
|
||||
setElementStyles(el, { color: "blue" });
|
||||
expect(getElementStyles(el).content).toBe('"a\\";b"');
|
||||
expect(getElementStyles(el).color).toBe("blue");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setVariableValue ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,11 @@ import {
|
||||
} from "@hyperframes/core/gsap-writer-acorn";
|
||||
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||
import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
|
||||
import {
|
||||
URI_BEARING_ATTRS,
|
||||
DANGEROUS_URI_SCHEMES,
|
||||
DANGEROUS_DATA_URI,
|
||||
} from "@hyperframes/core/html-attr-safety";
|
||||
|
||||
export interface MutationResult {
|
||||
forward: JsonPatchOp[];
|
||||
@@ -102,18 +107,6 @@ const RESERVED_ATTRS = new Set([
|
||||
"data-hold-fill",
|
||||
]);
|
||||
|
||||
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
||||
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
||||
const URI_BEARING_ATTRS = new Set([
|
||||
"src",
|
||||
"href",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"srcset",
|
||||
"xlink:href",
|
||||
]);
|
||||
|
||||
function validateSetAttribute(name: string, value: string | null): void {
|
||||
const lower = name.toLowerCase();
|
||||
if (RESERVED_ATTRS.has(lower)) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface HyperFramesElement {
|
||||
readonly classNames: readonly string[];
|
||||
/** All attributes except style, class, and data-hf-* (those are model-level) */
|
||||
readonly attributes: Readonly<Record<string, string>>;
|
||||
/** Direct text node content (not descendant text) */
|
||||
/** Display text for the SDK setText target, not a full descendant-text snapshot. */
|
||||
readonly text: string | null;
|
||||
// Timing — null when element has no data-start
|
||||
readonly start: number | null;
|
||||
|
||||
Reference in New Issue
Block a user