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:
Miguel Ángel
2026-06-19 15:37:10 -04:00
committed by GitHub
parent 4e32c5e0fe
commit 758eda995c
12 changed files with 534 additions and 119 deletions
+6 -10
View File
@@ -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,
+67 -4
View File
@@ -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.
+46
View File
@@ -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 ─────────────────────────────────────────────────────────
+5 -12
View File
@@ -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)) {
+1 -1
View File
@@ -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;