diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 538f0632ff..f367e97a86 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -18,6 +18,7 @@ import { validateHyperframeHtmlContract } from "./staticGuard"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; import { readDeclaredDefaults } from "../runtime/getVariables"; import { inlineSubCompositions } from "./inlineSubCompositions"; +import { queryByAttr } from "../utils/cssSelector"; import { isSafePath, resolveWithinProject } from "../safePath.js"; import { HF_COLOR_GRADING_ATTR } from "../colorGrading"; @@ -277,7 +278,7 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s } function cssAttributeSelector(attr: string, value: string): string { - return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; + return `[${attr}="${value}"]`; } function uniqueCompositionId(baseId: string, index: number): string { @@ -624,7 +625,7 @@ export interface BundleOptions { */ function ensureExternalScriptTag(doc: Document, src: string): void { - if (doc.querySelector(`script[src="${src}"]`)) return; + if (queryByAttr(doc, "src", src, "script")) return; const el = doc.createElement("script"); el.setAttribute("src", src); doc.body.appendChild(el); @@ -825,7 +826,7 @@ export async function bundleToSingleHtml( continue; } } - if (!document.querySelector(`script[src="${extSrc}"]`)) { + if (!queryByAttr(document, "src", extSrc, "script")) { const extScript = document.createElement("script"); extScript.setAttribute("src", extSrc); document.body.appendChild(extScript); @@ -857,7 +858,7 @@ export async function bundleToSingleHtml( const hostIdentity = hostIdentityByElement.get(host); const runtimeCompId = hostIdentity?.runtimeCompositionId || compId; const innerDoc = parseHTMLContent(templateHtml); - const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`); + const innerRoot = queryByAttr(innerDoc, "data-composition-id", compId); const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; const runtimeScope = runtimeCompId ? cssAttributeSelector("data-composition-id", runtimeCompId) diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index a48e25ff8e..f6e0b6167e 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -13,6 +13,7 @@ import { rewriteCssAssetUrls, rewriteInlineStyleAssetUrls, } from "./rewriteSubCompPaths"; +import { queryByAttr } from "../utils/cssSelector"; import { scopeCssToComposition, wrapInlineScriptWithErrorBoundary, @@ -225,7 +226,7 @@ export function inlineSubCompositions( // Find the inner composition root const innerRoot = compId - ? contentDoc.querySelector(`[data-composition-id="${compId}"]`) + ? queryByAttr(contentDoc, "data-composition-id", compId) : contentDoc.querySelector("[data-composition-id]"); const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || ""; const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f38c7e052..e5905103b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,7 @@ export { rewriteCssAssetUrls, } from "./compiler/rewriteSubCompPaths"; export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths"; +export { queryByAttr } from "./utils/cssSelector"; export { decodeUrlPathVariants } from "./utils/urlPath"; export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif"; export { diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 5a90825446..e84597c343 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -13,6 +13,7 @@ import type { import { validateCompositionGsap } from "./gsapSerialize"; import { ensureHfIds } from "./hfIds.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; +import { queryByAttr } from "../utils/cssSelector"; import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; import type { ValidationResult } from "../core.types"; @@ -519,7 +520,7 @@ export function updateElementInHtml( const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); - const el = doc.getElementById(elementId) || doc.querySelector(`[data-name="${elementId}"]`); + const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId); if (!el) return html; if (updates.startTime !== undefined) { diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index 2cf4d4c347..0a34763cb0 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -97,11 +97,11 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule { const htmlEl = el as HTMLElement; if (htmlEl.id) return `#${htmlEl.id}`; const compositionId = el.getAttribute("data-composition-id"); - if (compositionId) return `[data-composition-id="${compositionId}"]`; + if (compositionId) return `[data-composition-id="${CSS.escape(compositionId)}"]`; const compositionSrc = el.getAttribute("data-composition-src"); - if (compositionSrc) return `[data-composition-src="${compositionSrc}"]`; + if (compositionSrc) return `[data-composition-src="${CSS.escape(compositionSrc)}"]`; const track = el.getAttribute("data-track-index"); - if (track) return `[data-track-index="${track}"]`; + if (track) return `[data-track-index="${CSS.escape(track)}"]`; const tag = el.tagName.toLowerCase(); const parent = el.parentElement; if (!parent) return tag; diff --git a/packages/core/src/utils/cssSelector.ts b/packages/core/src/utils/cssSelector.ts new file mode 100644 index 0000000000..df58c28c3c --- /dev/null +++ b/packages/core/src/utils/cssSelector.ts @@ -0,0 +1,14 @@ +// ponytail: queries DOM by exact attribute match without interpolating +// the value into a selector string — zero injection surface. +export function queryByAttr( + root: ParentNode, + attr: string, + value: string, + tag?: string, +): Element | null { + const selector = tag ? `${tag}[${attr}]` : `[${attr}]`; + for (const el of root.querySelectorAll(selector)) { + if (el.getAttribute(attr) === value) return el; + } + return null; +} diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 6bbb7196d8..510421c796 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -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; } diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts index c3f36822c3..ba3645fc54 100644 --- a/packages/studio/src/components/editor/domEditingElement.ts +++ b/packages/studio/src/components/editor/domEditingElement.ts @@ -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; } diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index cddd3e4e11..825fe9d577 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -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") ?? diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index df3e9898bf..cf1533fd36 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -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 ); } diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 30fae0295e..7d2e65b092 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -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");