Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 6 additions & 4 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -277,7 +278,8 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
}

function cssAttributeSelector(attr: string, value: string): string {
return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[${attr}="${escaped}"]`;
}

function uniqueCompositionId(baseId: string, index: number): string {
Expand Down Expand Up @@ -624,7 +626,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);
Expand Down Expand Up @@ -825,7 +827,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);
Expand Down Expand Up @@ -857,7 +859,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)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import {
scopeCssToComposition,
wrapInlineScriptWithErrorBoundary,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/runtime/picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/utils/cssSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from "bun:test";
import { queryByAttr } from "./cssSelector";

function makeDoc(html: string) {
const { parseHTML } = require("linkedom");
const { document } = parseHTML(`<html><body>${html}</body></html>`);
return document;
}

describe("queryByAttr", () => {
it("finds element by exact attribute match", () => {
const doc = makeDoc('<div data-id="abc"></div>');
const el = queryByAttr(doc, "data-id", "abc");
expect(el).not.toBeNull();
expect(el!.getAttribute("data-id")).toBe("abc");
});

it("returns null when no match", () => {
const doc = makeDoc('<div data-id="abc"></div>');
expect(queryByAttr(doc, "data-id", "xyz")).toBeNull();
});

it("handles values with double quotes", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", 'has"quote');
expect(queryByAttr(doc, "data-id", 'has"quote')).toBe(el);
});

it("handles values with backslashes", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "has\\backslash");
expect(queryByAttr(doc, "data-id", "has\\backslash")).toBe(el);
});

it("handles values with closing bracket", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "has]bracket");
expect(queryByAttr(doc, "data-id", "has]bracket")).toBe(el);
});

it("handles injection attempt", () => {
const doc = makeDoc('<div data-id="safe"></div>');
const el = doc.querySelector("div")!;
el.setAttribute("data-id", '"][data-evil]');
expect(queryByAttr(doc, "data-id", '"][data-evil]')).toBe(el);
expect(queryByAttr(doc, "data-id", "safe")).toBeNull();
});

it("filters by tag when provided", () => {
const doc = makeDoc('<div data-src="a.js"></div><script data-src="a.js"></script>');
const el = queryByAttr(doc, "data-src", "a.js", "script");
expect(el).not.toBeNull();
expect(el!.tagName.toLowerCase()).toBe("script");
});

it("returns null when tag filter excludes match", () => {
const doc = makeDoc('<div data-src="a.js"></div>');
expect(queryByAttr(doc, "data-src", "a.js", "script")).toBeNull();
});

it("handles values with newlines", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "line1\nline2");
expect(queryByAttr(doc, "data-id", "line1\nline2")).toBe(el);
});

it("handles values with leading digits", () => {
const doc = makeDoc("<div></div>");
const el = doc.querySelector("div")!;
el.setAttribute("data-id", "123abc");
expect(queryByAttr(doc, "data-id", "123abc")).toBe(el);
});
});
14 changes: 14 additions & 0 deletions packages/core/src/utils/cssSelector.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/domEditingElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/lib/timelineDOM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ??
Expand Down
10 changes: 5 additions & 5 deletions packages/studio/src/player/lib/timelineElementHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
);
}
Expand Down
7 changes: 4 additions & 3 deletions packages/studio/src/player/lib/timelineIframeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,15 +295,16 @@ 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);
// Recursively resolve one level of reference for the ref's own start
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 =
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/test-setup.ts
Original file line number Diff line number Diff line change
@@ -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");
}
1 change: 1 addition & 0 deletions packages/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,6 @@ export default defineConfig({
},
test: {
exclude: ["data/**", "node_modules/**"],
setupFiles: ["src/test-setup.ts"],
},
});
Loading