Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b6bbb5f
fix(studio): size the selection box by the transform the element actu…
miguel-heygen Aug 7, 2026
34383b1
fix(studio): drag by the movement the element actually makes, not the…
miguel-heygen Aug 8, 2026
ce40911
fix(studio): shift-click adds the element under the pointer, not the …
miguel-heygen Aug 8, 2026
7f548d1
fix(studio): keep every element a marquee caught, not just the first
miguel-heygen Aug 8, 2026
cb43ebf
fix(studio): stop a group selection from erasing itself on the timeline
miguel-heygen Aug 8, 2026
00fdd42
chore(studio): trace what moves a dragged group and when
miguel-heygen Aug 8, 2026
65fa491
chore(studio): name the path that clears a selection after a group move
miguel-heygen Aug 8, 2026
59cfa75
fix(studio): losing one member of a group no longer deselects all of it
miguel-heygen Aug 8, 2026
34e5884
feat(studio): carry a multi-selection in the URL, and name the member…
miguel-heygen Aug 8, 2026
cecc650
fix(studio): stop snapping from moving a selection you have not dragg…
miguel-heygen Aug 8, 2026
f84b3d8
fix(studio): a dropped group stays selected
miguel-heygen Aug 8, 2026
0e7cd9b
feat(studio): marquee from anywhere on the canvas, including outside …
miguel-heygen Aug 8, 2026
6fc7026
refactor(studio): keep the selection files under the size cap
miguel-heygen Aug 9, 2026
dbe13f0
fix(studio): stop a group drag from jumping one element back
miguel-heygen Aug 8, 2026
f4825c5
perf(studio): commit a group drag in one request
miguel-heygen Aug 8, 2026
5ad192c
chore(studio): name whoever puts the pre-resize size back
miguel-heygen Aug 8, 2026
b45b06d
fix(studio): hold a resized element's size while the timeline is rebuilt
miguel-heygen Aug 8, 2026
a9e69c9
refactor(studio): keep the resize files under the size cap
miguel-heygen Aug 9, 2026
556250e
docs(studio): fold the resize note into the size-reapply comment
miguel-heygen Aug 9, 2026
5ac9e7a
feat(core): sanitize rich text on the way into a composition
miguel-heygen Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
"types": "./dist/utils/htmlAttrSafety.d.ts",
"environments": ["browser", "bun", "node"]
},
"./rich-text-sanitize": {
"source": "./src/utils/richTextSanitize.ts",
"runtime": "./dist/utils/richTextSanitize.js",
"types": "./dist/utils/richTextSanitize.d.ts",
"environments": ["browser", "bun", "node"]
},
"./composition-contract": {
"source": "./src/compositionContract.ts",
"runtime": "./dist/compositionContract.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"import": "./src/utils/htmlAttrSafety.ts",
"types": "./src/utils/htmlAttrSafety.ts"
},
"./rich-text-sanitize": {
"bun": "./src/utils/richTextSanitize.ts",
"node": "./dist/utils/richTextSanitize.js",
"import": "./src/utils/richTextSanitize.ts",
"types": "./src/utils/richTextSanitize.ts"
},
"./composition-contract": {
"bun": "./src/compositionContract.ts",
"node": "./dist/compositionContract.js",
Expand Down Expand Up @@ -326,6 +332,10 @@
"import": "./dist/utils/htmlAttrSafety.js",
"types": "./dist/utils/htmlAttrSafety.d.ts"
},
"./rich-text-sanitize": {
"import": "./dist/utils/richTextSanitize.js",
"types": "./dist/utils/richTextSanitize.d.ts"
},
"./composition-contract": {
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
Expand Down
193 changes: 193 additions & 0 deletions packages/core/src/utils/richTextSanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { isRichTextFormattingTag, sanitizeRichTextChildren } from "./richTextSanitize";

// Both parsers, every case. The browser runs this against a live element and
// the server runs it against linkedom, and the whole point of one shared module
// is that the two cannot disagree about what may be written to a file.
const PARSERS: Array<[string, (html: string) => Element]> = [
[
"jsdom",
(html) => {
const host = document.createElement("div");
host.innerHTML = html;
return host;
},
],
[
"linkedom",
(html) => {
const { document: doc } = parseHTML(`<!DOCTYPE html><html><body></body></html>`);
const host = doc.createElement("div");
host.innerHTML = html;
return host as unknown as Element;
},
],
];

function clean(html: string, parse: (html: string) => Element): string {
const host = parse(html);
sanitizeRichTextChildren(host);
return host.innerHTML;
}

describe.each(PARSERS)("sanitizeRichTextChildren (%s)", (_name, parse) => {
it("keeps a styled span, which is the whole point", () => {
expect(clean('<span style="color: red">hi</span>', parse)).toBe(
'<span style="color: red">hi</span>',
);
});

it("keeps plain text untouched", () => {
expect(clean("just words", parse)).toBe("just words");
});

it("keeps nested formatting and its nesting", () => {
expect(clean('<b><span style="color: red">x</span></b>', parse)).toBe(
'<b><span style="color: red">x</span></b>',
);
});

it("keeps a line break", () => {
expect(clean("a<br>b", parse)).toContain("<br>");
});

it("removes a script and does not leave its source as visible text", () => {
const out = clean("<script>alert(1)</script>keep", parse);
expect(out).not.toContain("script");
expect(out).not.toContain("alert");
expect(out).toContain("keep");
});

it("strips an event handler from a tag it otherwise keeps", () => {
const out = clean('<span onclick="steal()" style="color: red">x</span>', parse);
expect(out).not.toContain("onclick");
expect(out).toContain("color: red");
});

it("strips every attribute that is neither style nor an identity", () => {
const out = clean('<span id="a" class="b" data-x="c" style="color: red">x</span>', parse);
expect(out).not.toContain("id=");
expect(out).not.toContain("class=");
expect(out).not.toContain("data-x");
expect(out).toContain("color: red");
});

// The design panel tracks each text layer by this. Stripping it left the
// panel unable to match a layer to its source after any inline style edit.
it("keeps the attributes a text layer is tracked by", () => {
const out = clean(
'<span data-hf-text-key="child:1" data-hf-id="hf-abc" style="color: red">x</span>',
parse,
);
expect(out).toContain('data-hf-text-key="child:1"');
expect(out).toContain('data-hf-id="hf-abc"');
});

it("drops an identity attribute whose value is not a bare token", () => {
const out = clean(`<span data-hf-text-key='a" onload="alert(1)'>x</span>`, parse);
expect(out).not.toContain("onload");
expect(out).not.toContain("data-hf-text-key");
});

// These are what the design panel writes onto those same spans. Sanitizing
// them away did not stop a text edit changing layout, it deleted the layout
// the user had already set: colouring one word dropped a sibling's size.
it("keeps the typography the design panel authors on a text layer", () => {
const out = clean(
'<span style="font-family: Inter; font-size: 48px; letter-spacing: -1px; line-height: 1.2">x</span>',
parse,
);
expect(out).toContain("font-family: Inter");
expect(out).toContain("font-size: 48px");
expect(out).toContain("letter-spacing: -1px");
expect(out).toContain("line-height: 1.2");
});

it("still refuses a value that reaches outside the stylesheet", () => {
const out = clean(`<span style="font-family: url(http://x/f.woff)">x</span>`, parse);
expect(out).not.toContain("url(");
});

it("unwraps a tag that is not formatting, keeping its words in place", () => {
expect(clean("before<div>middle</div>after", parse)).toBe("beforemiddleafter");
});

it("unwraps deeply and keeps the formatting found inside", () => {
const out = clean('<div><p><span style="color: red">deep</span></p></div>', parse);
expect(out).toBe('<span style="color: red">deep</span>');
});

it("keeps only the allowlisted style properties", () => {
const out = clean('<span style="color: red; position: fixed; z-index: 99">x</span>', parse);
expect(out).toContain("color: red");
expect(out).not.toContain("position");
expect(out).not.toContain("z-index");
});

it("keeps every property the allowlist names", () => {
const style =
"color: red; background-color: blue; font-weight: 700; font-style: italic; text-decoration-line: underline";
const out = clean(`<span style="${style}">x</span>`, parse);
for (const property of [
"color",
"background-color",
"font-weight",
"font-style",
"text-decoration-line",
]) {
expect(out).toContain(property);
}
});

it("rejects a value that smuggles a url or a script in", () => {
const out = clean(
'<span style="background-color: url(javascript:alert(1)); color: red">x</span>',
parse,
);
expect(out).not.toContain("javascript");
expect(out).not.toContain("url(");
expect(out).toContain("color: red");
});

it("drops the style attribute entirely when nothing in it survives", () => {
expect(clean('<span style="position: fixed">x</span>', parse)).toBe("<span>x</span>");
});

it("keeps a value carrying a function with its own separators", () => {
const out = clean('<span style="color: rgb(1, 2, 3); font-style: italic">x</span>', parse);
expect(out).toContain("rgb(1, 2, 3)");
expect(out).toContain("font-style: italic");
});

it("removes a comment, which is neither text nor formatting", () => {
expect(clean("a<!-- note -->b", parse)).toBe("ab");
});

it("leaves an empty element alone", () => {
expect(clean("", parse)).toBe("");
});

it("does not produce unbalanced markup from an unclosed tag", () => {
const out = clean('<span style="color: red">open', parse);
expect(out).toBe('<span style="color: red">open</span>');
});
});

describe("isRichTextFormattingTag", () => {
it("names the tags an inline edit may contain", () => {
for (const tag of ["SPAN", "B", "STRONG", "I", "EM", "U", "BR"]) {
expect(isRichTextFormattingTag(tag)).toBe(true);
}
});

it("is case-insensitive, since the two parsers disagree about case", () => {
expect(isRichTextFormattingTag("span")).toBe(true);
});

it("says no to anything structural", () => {
for (const tag of ["DIV", "P", "H1", "IMG", "SCRIPT", "A"]) {
expect(isRichTextFormattingTag(tag)).toBe(false);
}
});
});
Loading
Loading