fix(studio): match style attributes with explicit quote boundaries (#3712)

* fix(studio): match style attributes with explicit quote boundaries

* fix(studio): apply quote boundaries to active source writers
This commit is contained in:
James Russo
2026-09-05 14:50:27 -04:00
committed by GitHub
parent e9250fcc45
commit 7d7003aa64
6 changed files with 86 additions and 8 deletions
@@ -2,6 +2,26 @@ import { describe, expect, it } from "vitest";
import { patchMediaColorGradingInHtml } from "./colorGradingScopePatch";
describe("patchMediaColorGradingInHtml", () => {
it.each(['"', "'"])("replaces and removes long grading values quoted with %s", (quote) => {
const otherQuote = quote === '"' ? "'" : '"';
for (const value of [
"",
"line1\nline2",
"a".repeat(100_000),
`data-color-grading=${otherQuote}a`.repeat(10_000),
]) {
const html = `<img alt="kept"\tDATA-COLOR-GRADING=${quote}${value}${quote} />`;
expect(patchMediaColorGradingInHtml(html, "new")).toEqual({
html: '<img alt="kept" data-color-grading="new" />',
count: 1,
});
expect(patchMediaColorGradingInHtml(html, null)).toEqual({
html: '<img alt="kept" />',
count: 1,
});
}
});
it("adds color grading to video and image tags only", () => {
const { html, count } = patchMediaColorGradingInHtml(
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
@@ -1,5 +1,5 @@
const MEDIA_TAG_RE = /<\s*(video|img)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(["'])([\s\S]*?)\1/i;
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(?:"[^"]*"|'[^']*')/i;
const IGNORED_HTML_RANGE_RE = /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
interface TextRange {
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { mergeStyleIntoTag } from "./htmlEditor";
describe("mergeStyleIntoTag", () => {
it.each([
['<div style="color: red; opacity: 1">', '<div style="color: red; opacity: 0.5">'],
["<div style='color: red; opacity: 1'>", "<div style='color: red; opacity: 0.5'>"],
['<div style="">', '<div style="opacity: 0.5">'],
["<div style=''>", "<div style='opacity: 0.5'>"],
['<div style="color:\nred">', '<div style="color: red; opacity: 0.5">'],
["<div style='font-family: \"a\"'>", "<div style='font-family: \"a\"; opacity: 0.5'>"],
["<div style=\"font-family: 'a'\">", "<div style=\"font-family: 'a'; opacity: 0.5\">"],
[
"<div style=\"color:red\" style='color:blue'>",
"<div style=\"color: red; opacity: 0.5\" style='color:blue'>",
],
['<div STYLE="color:red">', '<div STYLE="color:red" style="opacity: 0.5">'],
['<div style = "color:red">', '<div style = "color:red" style="opacity: 0.5">'],
['<div data-style="color:red">', '<div data-style="color: red; opacity: 0.5">'],
["<img/>", '<img style="opacity: 0.5"/>'],
['<div style="color:red>', '<div style="color:red style="opacity: 0.5">'],
])("preserves quote and source behavior (case %#)", (tag, expected) => {
expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(expected);
expect(mergeStyleIntoTag(tag, " \n")).toBe(tag);
});
it.each(['"', "'"])("handles long values delimited by %s", (quote) => {
const value = "a".repeat(100_000);
const tag = `<div style=${quote}--label:${value}${quote}>`;
expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(
`<div style=${quote}--label: ${value}; opacity: 0.5${quote}>`,
);
const opener = `style=${quote}a`;
expect(mergeStyleIntoTag(`<div style=${quote}${opener.repeat(10_000)}>`, "opacity: 0.5")).toBe(
`<div style=${quote}opacity: 0.5${quote}a${opener.repeat(9_999)}>`,
);
const unterminated = `<div style=${quote}${value}>`;
expect(mergeStyleIntoTag(unterminated, "opacity: 0.5")).toBe(
`<div style=${quote}${value} style="opacity: 0.5">`,
);
});
});
+4 -4
View File
@@ -29,13 +29,13 @@ export function mergeStyleIntoTag(tag: string, newStyles: string): string {
const incoming = parseStyleString(newStyles);
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
// Match each quote-delimited form explicitly, including multi-line values.
const styleAttrRe = /style=(")([^"]*)"|style=(')([^']*)'/;
const match = tag.match(styleAttrRe);
if (match) {
const quote = match[1];
const existing = parseStyleString(match[2]);
const quote = match[1] ?? match[3];
const existing = parseStyleString(match[2] ?? match[4]);
const merged = { ...existing, ...incoming };
const serialized = Object.entries(merged)
.map(([k, v]) => `${k}: ${v}`)
@@ -8,6 +8,22 @@ import {
} from "./sourcePatcher";
describe("applyPatchByTarget", () => {
it.each(['"', "'"])("patches long and multiline styles quoted with %s", (quote) => {
const otherQuote = quote === '"' ? "'" : '"';
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
for (const value of [
"",
"line1\nline2",
"a".repeat(100_000),
`style=${otherQuote}a`.repeat(10_000),
]) {
const html = `<img id="hero" class="hero" style=${quote}--label:${value}${quote} />`;
const expected = `<img id="hero" class="hero" style=${quote}--label: ${value}; opacity: 0.5${quote} />`;
expect(applyPatch(html, "hero", op)).toBe(expected);
expect(applyPatchByTarget(html, { selector: ".hero" }, op)).toBe(expected);
}
});
it("updates a composition host by data-composition-id selector", () => {
const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
+3 -3
View File
@@ -184,10 +184,10 @@ function patchInlineStyleInTag(
if (!tag) return html;
// Check if there's an existing style attribute
const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
const styleMatch = /\bstyle=(")([^"]*)"|\bstyle=(')([^']*)'/.exec(tag);
if (styleMatch) {
const existingStyle = styleMatch[2];
const quote = styleMatch[1];
const existingStyle = styleMatch[2] ?? styleMatch[4];
const quote = styleMatch[1] ?? styleMatch[3];
// Parse existing properties
const props = new Map<string, string>();
for (const part of splitInlineStyleDeclarations(existingStyle)) {