fix(core,studio): escape user values in querySelector attribute selectors

Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and
use it (or CSS.escape for browser-side code) at all 12 sites that
previously interpolated raw user-authored values into querySelector
attribute selectors. A " in a composition ID, script src, or
data-start value would produce a malformed selector that throws.

Node-side (core compiler/parser): uses the shared cssAttrSelector.
Browser-side (runtime, studio): uses native CSS.escape().

Supersedes #1568 which fixed only the 3 bundler sites.
This commit is contained in:
Miguel Ángel
2026-06-19 15:47:28 -04:00
committed by GitHub
parent 758eda995c
commit c0ffdc0fb0
15 changed files with 126 additions and 21 deletions
@@ -126,7 +126,7 @@ export const LayersPanel = memo(function LayersPanel() {
if (doc) {
const found =
(layer.id ? doc.getElementById(layer.id) : null) ??
(layer.hfId ? doc.querySelector(`[data-hf-id="${layer.hfId}"]`) : null) ??
(layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
doc.getElementById(layer.key);
if (found instanceof HTMLElement) el = found;
}
@@ -242,7 +242,7 @@ export function findElementForSelection(
activeCompositionPath: string | null = null,
): HTMLElement | null {
if (selection.hfId) {
const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
if (isHtmlElement(byHfId)) return byHfId;
}
@@ -189,7 +189,7 @@ export const NLELayout = memo(function NLELayout({
const doc = iframeRef_.current?.contentDocument;
if (doc) {
const host = doc.querySelector(
`[data-composition-id="${compId}"][data-composition-src]`,
`[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`,
);
if (host) {
resolvedPath = host.getAttribute("data-composition-src") || undefined;
@@ -123,7 +123,8 @@ export function createTimelineElementFromManifestClip(params: {
if (clip.kind === "composition" && clip.compositionId) {
let resolvedSrc = clip.compositionSrc;
if (!resolvedSrc) {
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
hostEl =
doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
resolvedSrc =
hostEl?.getAttribute("data-composition-src") ??
hostEl?.getAttribute("data-composition-file") ??
@@ -163,13 +163,13 @@ export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
// ---------------------------------------------------------------------------
export function getTimelineElementSelector(el: Element): string | undefined {
if (isHtmlElement(el) && el.id) return `#${el.id}`;
if (isHtmlElement(el) && el.id) return `#${CSS.escape(el.id)}`;
const compId = el.getAttribute("data-composition-id");
if (compId) return `[data-composition-id="${compId}"]`;
if (compId) return `[data-composition-id="${CSS.escape(compId)}"]`;
if (isHtmlElement(el)) {
const classes = el.className.split(/\s+/).filter(Boolean);
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
if (firstClass) return `.${firstClass}`;
if (firstClass) return `.${CSS.escape(firstClass)}`;
}
return undefined;
}
@@ -283,8 +283,8 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
function findTimelineDomNode(doc: Document, id: string): Element | null {
return (
doc.getElementById(id) ??
doc.querySelector(`[data-composition-id="${id}"]`) ??
doc.querySelector(`.${id}`) ??
doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
doc.querySelector(`.${CSS.escape(id)}`) ??
null
);
}
@@ -295,7 +295,8 @@ export function buildMissingCompositionElements(
let start = parseFloat(startAttr);
if (isNaN(start)) {
const ref =
doc.getElementById(startAttr) || doc.querySelector(`[data-composition-id="${startAttr}"]`);
doc.getElementById(startAttr) ||
doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
if (ref) {
const refStartAttr = ref.getAttribute("data-start") ?? "0";
let refStart = parseFloat(refStartAttr);
@@ -303,7 +304,7 @@ export function buildMissingCompositionElements(
if (isNaN(refStart)) {
const refRef =
doc.getElementById(refStartAttr) ||
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
const rrCompId = refRef?.getAttribute("data-composition-id");
const rrDur =
@@ -400,7 +401,7 @@ export function buildMissingCompositionElements(
// Find the matching DOM host by element id or composition id
const host =
doc.getElementById(existing.id) ??
doc.querySelector(`[data-composition-id="${existing.id}"]`);
doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
if (!host) return existing;
const compSrc =
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
+6
View File
@@ -0,0 +1,6 @@
if (typeof globalThis.CSS === "undefined") {
(globalThis as Record<string, unknown>).CSS = {};
}
if (typeof CSS.escape !== "function") {
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
}