diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5791bf8f..0a13203f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,71 @@ jobs: - run: bun install --frozen-lockfile - run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci + studio-load-smoke: + name: "Studio: load smoke" + needs: [changes] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + lfs: true + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run --cwd packages/core build:hyperframes-runtime + - name: Start studio and check for runtime errors + run: | + # Start the studio dev server in the background + bun run --filter '@hyperframes/studio' dev -- --port 5199 & + SERVER_PID=$! + + # Wait for the server to be ready (up to 20s) + for i in $(seq 1 40); do + if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi + sleep 0.5 + done + + if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then + echo "FAIL: studio dev server did not start" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + # Load the studio in headless Chrome and capture console errors + # puppeteer is a dependency of @hyperframes/producer; resolve from there + cd packages/producer + node --input-type=module <<'SMOKE_EOF' + import puppeteer from "puppeteer"; + const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + const page = await browser.newPage(); + const errors = []; + page.on("pageerror", (err) => errors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 }); + await new Promise((r) => setTimeout(r, 3000)); + await browser.close(); + const fatal = errors.filter( + (e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"), + ); + if (fatal.length > 0) { + console.error("FAIL: studio had runtime errors on load:"); + for (const e of fatal) console.error(" •", e); + process.exit(1); + } + console.log("PASS: studio loaded without runtime errors"); + SMOKE_EOF + + kill $SERVER_PID 2>/dev/null || true + smoke-global-install: name: "Smoke: global install" needs: [changes, build] diff --git a/.oxlintrc.json b/.oxlintrc.json index 0fc2d5f8c..3c14d747d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,5 +4,5 @@ "correctness": "error" }, "plugins": ["react", "typescript"], - "ignorePatterns": ["dist/", "coverage/", "node_modules/"] + "ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"] } diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 4362bb956..ddd9a1ab6 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene; expect(errorSpy).not.toHaveBeenCalled(); }); + it("uses compound selector when authored root is the scoped element itself", () => { + const scoped = scopeCssToComposition( + "#chrome-overlay-root { --primary: #FFDC8B; }", + "chrome-overlay", + undefined, + "chrome-overlay-root", + { compoundAuthoredRoot: true }, + ); + + // Both attributes are on the same element after inlining, so the selector + // must be compound (no space) to match. + expect(scoped).toContain( + '[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]', + ); + expect(scoped).not.toContain( + '[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]', + ); + }); + + it("uses compound selector for authored root with descendant combinators", () => { + const scoped = scopeCssToComposition( + "#chrome-overlay-root .chrome { display: flex; }", + "chrome-overlay", + undefined, + "chrome-overlay-root", + { compoundAuthoredRoot: true }, + ); + + // The authored root part is compound with scope, .chrome is a descendant + expect(scoped).toContain( + '[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome', + ); + expect(scoped).not.toMatch( + /\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/, + ); + }); + + it("still uses descendant selector for non-root selectors with authoredRootId", () => { + const scoped = scopeCssToComposition( + ".child-element { color: red; }", + "chrome-overlay", + undefined, + "chrome-overlay-root", + ); + + // Regular child selectors still get a descendant combinator (space) + expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element'); + }); + it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => { const scoped = scopeCssToComposition( `#intro { background: #111; } diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index f6fb503d9..876ab59bc 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -101,6 +101,7 @@ function scopeSelector( scope: string, compositionId: string, authoredRootId?: string | null, + compoundAuthoredRoot?: boolean, ): string { const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId); const selectorWithoutRootTiming = normalizeCompositionRootSelector( @@ -120,6 +121,15 @@ function scopeSelector( } const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? ""; const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? ""; + if (compoundAuthoredRoot) { + const authoredRootAttr = authoredRootId + ? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]` + : null; + if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) { + const rest = trimmed.slice(authoredRootAttr.length); + return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`; + } + } return `${leading}${scope} ${trimmed}${trailing}`; } @@ -158,6 +168,7 @@ export function scopeCssToComposition( compositionId: string, scopeSelectorOverride?: string, authoredRootId?: string | null, + options?: { compoundAuthoredRoot?: boolean }, ): string { const trimmedCompositionId = compositionId.trim(); if (!css || !trimmedCompositionId) return css; @@ -169,7 +180,13 @@ export function scopeCssToComposition( root.walkRules((rule) => { if (isInsideGlobalAtRule(rule)) return; rule.selectors = rule.selectors.map((selector) => - scopeSelector(selector, scope, trimmedCompositionId, authoredRootId), + scopeSelector( + selector, + scope, + trimmedCompositionId, + authoredRootId, + options?.compoundAuthoredRoot, + ), ); }); diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index 7e91f165e..dd3693a3e 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -151,4 +151,35 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => { expect(host.getAttribute("data-composition-id")).toBe("intro"); }); + + it("producer path: scoped CSS matches host element when both attributes coexist", () => { + const document = makeHostDocument("intro"); + const host = document.querySelector('[data-composition-src="intro.html"]')!; + + const result = inlineSubCompositions(document, [host], { + resolveHtml: () => SUB_COMP_HTML, + parseHtml: (html) => parseHTML(html).document, + compoundAuthoredRoot: true, + }); + + // After inlining, the host has both data-composition-id and data-hf-authored-id. + // CSS selectors targeting the root must be compound (no space) so they match + // when both attributes are on the same element. + expect(host.getAttribute("data-composition-id")).toBe("intro"); + expect(host.getAttribute("data-hf-authored-id")).toBe("intro"); + + const scopedCss = result.styles.join("\n"); + + // Root-only selector: must be compound + expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/); + // Must NOT have a descendant combinator between the two attribute selectors + expect(scopedCss).not.toMatch( + /\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/, + ); + + // Descendant selector: compound root + space + child + expect(scopedCss).toMatch( + /\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/, + ); + }); }); diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 3ff1a437a..6d2137564 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions { */ flattenInnerRoot?: (innerRoot: Element) => Element; + /** + * When true, CSS selectors targeting the authored root use a compound + * selector (`[scope][root]`) instead of a descendant (`[scope] [root]`). + * Enable this in the producer path where the inner root merges onto + * the host element via innerHTML — both attributes end up on the same + * element and a descendant selector won't match. + */ + compoundAuthoredRoot?: boolean; + /** * Read declared variable defaults from a sub-composition's `` element. * The bundler passes `readDeclaredDefaults`; the producer can omit this. @@ -139,6 +148,7 @@ export function inlineSubCompositions( hostIdentityMap, rewriteInlineStyles = false, flattenInnerRoot, + compoundAuthoredRoot, readVariableDefaults, parseHostVariables, buildScopeSelector = defaultBuildScopeSelector, @@ -211,7 +221,9 @@ export function inlineSubCompositions( const css = rewriteCssAssetUrls(s.textContent || "", src); styles.push( scopeCompId - ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId) + ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, { + compoundAuthoredRoot: compoundAuthoredRoot === true, + }) : css, ); } @@ -228,7 +240,9 @@ export function inlineSubCompositions( const css = rewriteCssAssetUrls(s.textContent || "", src); styles.push( scopeCompId - ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId) + ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, { + compoundAuthoredRoot: compoundAuthoredRoot === true, + }) : css, ); s.remove(); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 407d948a5..a26a2119a 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { removeElementFromHtml } from "./sourceMutation.js"; +import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js"; describe("removeElementFromHtml", () => { it("removes a self-closing element by id", () => { @@ -28,3 +28,223 @@ describe("removeElementFromHtml", () => { expect(removeElementFromHtml(html, { id: "photo" })).toBe(`
`); }); }); + +describe("patchElementInHtml", () => { + const FIXTURE = ` +
+
+
+ HyperFrames +
+
+
Hello World
+
+`; + + it("patches inline style by id", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "color", value: "red" }, + ]); + + expect(result).toMatch(/color:\s*red/); + expect(result).toContain('id="hero"'); + }); + + it("patches inline style by class selector", () => { + const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [ + { type: "inline-style", property: "font-size", value: "72px" }, + ]); + + expect(result).toMatch(/font-size:\s*72px/); + }); + + it("patches data attribute", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "attribute", property: "hf-studio-path-offset", value: "true" }, + ]); + + expect(result).toContain('data-hf-studio-path-offset="true"'); + }); + + it("patches html attribute", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "title", value: "greeting" }, + ]); + + expect(result).toContain('title="greeting"'); + }); + + it("patches text content", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "text-content", property: "", value: "New Title" }, + ]); + + expect(result).toContain("New Title"); + expect(result).not.toContain("Hello World"); + }); + + it("applies multiple operations in one call", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "color", value: "blue" }, + { type: "inline-style", property: "font-size", value: "96px" }, + { type: "attribute", property: "hf-studio-path-offset", value: "true" }, + ]); + + expect(result).toMatch(/color:\s*blue/); + expect(result).toMatch(/font-size:\s*96px/); + expect(result).toContain('data-hf-studio-path-offset="true"'); + }); + + it("finds element by composition-id selector", () => { + const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [ + { type: "inline-style", property: "opacity", value: "0.5" }, + ]); + + expect(result).toMatch(/opacity:\s*0\.5/); + }); + + it("finds element by class with selectorIndex", () => { + const html = `
A
B
`; + const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [ + { type: "text-content", property: "", value: "Changed" }, + ]); + + expect(result).toContain("A"); + expect(result).toContain("Changed"); + expect(result).not.toContain(">B<"); + }); + + it("returns unchanged html when target not found", () => { + const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [ + { type: "inline-style", property: "color", value: "red" }, + ]); + + expect(result).toBe(FIXTURE); + }); + + it("removes inline style when value is null", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "font-size", value: null }, + ]); + + expect(result).not.toContain("font-size"); + }); + + it("removes attribute when value is null", () => { + const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [ + { type: "html-attribute", property: "data-composition-src", value: null }, + ]); + + expect(result).not.toContain("data-composition-src"); + }); + + it("patches fragment html without doctype", () => { + const fragment = `
Title
`; + const result = patchElementInHtml(fragment, { id: "card" }, [ + { type: "inline-style", property: "padding", value: "16px" }, + ]); + + expect(result).toMatch(/padding:\s*16px/); + }); + + it("rejects event handler attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "onload", value: "fetch('/evil')" }, + ]); + + expect(result).not.toContain("onload"); + expect(result).not.toContain("fetch"); + }); + + it("rejects javascript: URLs in src", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "src", value: "javascript:alert(1)" }, + ]); + + expect(result).not.toContain("javascript:"); + }); + + it("allows aria-* and data-* attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "aria-label", value: "greeting" }, + { type: "html-attribute", property: "data-custom", value: "test" }, + ]); + + expect(result).toContain('aria-label="greeting"'); + expect(result).toContain('data-custom="test"'); + }); + + it("rejects srcdoc and formaction attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "srcdoc", value: "" }, + { type: "html-attribute", property: "formaction", value: "javascript:void(0)" }, + ]); + + expect(result).not.toContain("srcdoc"); + expect(result).not.toContain("formaction"); + }); + + it("rejects on* event handlers regardless of casing", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "onClick", value: "alert(1)" }, + { type: "html-attribute", property: "ONERROR", value: "alert(2)" }, + { type: "html-attribute", property: "onmouseover", value: "alert(3)" }, + ]); + + expect(result).not.toContain("alert"); + }); + + it("rejects data:text/html URIs in src", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { + type: "html-attribute", + property: "src", + value: "data:text/html,", + }, + ]); + + expect(result).not.toContain("data:text/html"); + }); + + it("allows safe href values", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "href", value: "https://example.com" }, + ]); + + expect(result).toContain('href="https://example.com"'); + }); + + it("rejects javascript: in href", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "href", value: "javascript:alert(1)" }, + ]); + + expect(result).not.toContain("javascript:"); + }); + + it("allows legitimate form and media attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "placeholder", value: "Enter text" }, + { type: "html-attribute", property: "target", value: "_blank" }, + { type: "html-attribute", property: "rel", value: "noopener" }, + { type: "html-attribute", property: "srcset", value: "img-2x.png 2x" }, + ]); + + expect(result).toContain('placeholder="Enter text"'); + expect(result).toContain('target="_blank"'); + expect(result).toContain('rel="noopener"'); + expect(result).toContain("srcset"); + }); + + it("rejects unknown/dangerous attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "xmlns", value: "http://evil.com" }, + { type: "html-attribute", property: "background", value: "http://evil.com/bg.js" }, + { type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" }, + ]); + + expect(result).not.toContain("xmlns"); + expect(result).not.toContain("background="); + expect(result).not.toContain("dynsrc"); + }); +}); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 2be500ebd..97f1a3899 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -54,3 +54,148 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg element.remove(); return wrappedFragment ? document.body.innerHTML || "" : document.toString(); } + +function isHTMLElement(el: Element): boolean { + const HTMLEl = el.ownerDocument.defaultView?.HTMLElement; + return HTMLEl ? el instanceof HTMLEl : "style" in el; +} + +export interface PatchOperation { + type: "inline-style" | "attribute" | "html-attribute" | "text-content"; + property: string; + value: string | null; +} + +const ALLOWED_HTML_ATTRS = new Set([ + // Identity & structure + "id", + "class", + "style", + "title", + "name", + "for", + "type", + // Internationalization + "lang", + "dir", + "translate", + // Interaction + "hidden", + "tabindex", + "draggable", + "contenteditable", + // Accessibility + "role", + "slot", + // Links & navigation + "href", + "target", + "rel", + // Media + "src", + "srcset", + "sizes", + "alt", + "poster", + "loading", + "decoding", + "crossorigin", + "preload", + "autoplay", + "loop", + "muted", + "controls", + "playsinline", + // Layout + "width", + "height", + "colspan", + "rowspan", + "scope", + // Form + "placeholder", + "value", + "min", + "max", + "step", + "pattern", + "required", + "disabled", + "readonly", + "checked", + "selected", + "multiple", + "accept", + "maxlength", + "minlength", + "rows", + "cols", + "wrap", +]); + +const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; +const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; + +function isAllowedHtmlAttribute(name: string): boolean { + const lower = name.toLowerCase(); + if (lower.startsWith("on")) return false; + if (ALLOWED_HTML_ATTRS.has(lower)) return true; + if (lower.startsWith("data-")) return true; + if (lower.startsWith("aria-")) return true; + return false; +} + +const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]); + +function isSafeAttributeValue(name: string, value: string): boolean { + if (URI_ATTRS.has(name.toLowerCase())) { + const trimmed = value.trim(); + if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false; + if (DANGEROUS_DATA_URI.test(trimmed)) return false; + } + return true; +} + +export function patchElementInHtml( + source: string, + target: SourceMutationTarget, + operations: PatchOperation[], +): string { + const { document, wrappedFragment } = parseSourceDocument(source); + const el = findTargetElement(document, target); + if (!el || !isHTMLElement(el)) return source; + const htmlEl = el as unknown as HTMLElement; + + for (const op of operations) { + switch (op.type) { + case "inline-style": + if (op.value != null) { + htmlEl.style.setProperty(op.property, op.value); + } else { + htmlEl.style.removeProperty(op.property); + } + break; + case "attribute": + if (op.value != null) { + htmlEl.setAttribute(`data-${op.property}`, op.value); + } else { + htmlEl.removeAttribute(`data-${op.property}`); + } + break; + case "html-attribute": + if (!isAllowedHtmlAttribute(op.property)) break; + if (op.value != null) { + if (!isSafeAttributeValue(op.property, op.value)) break; + htmlEl.setAttribute(op.property, op.value); + } else { + htmlEl.removeAttribute(op.property); + } + break; + case "text-content": + if (op.value != null) htmlEl.textContent = op.value; + break; + } + } + + return wrappedFragment ? document.body.innerHTML || "" : document.toString(); +} diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 732f2fd3e..aa870cf5d 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js"; import { generateWaveformCache } from "../helpers/waveform.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { isSafePath } from "../helpers/safePath.js"; -import { removeElementFromHtml } from "../helpers/sourceMutation.js"; +import { + removeElementFromHtml, + patchElementInHtml, + type PatchOperation, +} from "../helpers/sourceMutation.js"; // ── Shared helpers ────────────────────────────────────────────────────────── @@ -236,6 +240,45 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return c.json({ ok: true, changed: true, content: patchedContent }); }); + api.post("/projects/:id/file-mutations/patch-element/*", async (c) => { + const id = c.req.param("id"); + const project = await adapter.resolveProject(id); + if (!project) return c.json({ error: "not found" }, 404); + + const filePath = decodeURIComponent( + c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""), + ); + if (filePath.includes("\0")) { + return c.json({ error: "forbidden" }, 403); + } + + const absPath = resolve(project.dir, filePath); + if (!isSafePath(project.dir, absPath)) { + return c.json({ error: "forbidden" }, 403); + } + const body = (await c.req.json().catch(() => null)) as { + target?: { id?: string | null; selector?: string; selectorIndex?: number }; + operations?: PatchOperation[]; + } | null; + if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) { + return c.json({ error: "target and operations required" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const patchedContent = patchElementInHtml(originalContent, body.target, body.operations); + if (patchedContent === originalContent) { + return c.json({ ok: true, changed: false, content: originalContent }); + } + + writeFileSync(absPath, patchedContent, "utf-8"); + return c.json({ ok: true, changed: true, content: patchedContent }); + }); + // ── Rename / Move ── api.patch("/projects/:id/files/*", async (c) => { diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index cd2ba50e3..30da87ba6 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -111,6 +111,33 @@ function injectStudioMotionScript( ); } +const GSAP_CDN_FALLBACK_SCRIPT = ``; + +function injectGsapCdnFallback(html: string): string { + if (html.includes("data-hf-gsap-fallback")) return html; + if (html.includes("")) return html.replace("", "" + GSAP_CDN_FALLBACK_SCRIPT); + return GSAP_CDN_FALLBACK_SCRIPT + html; +} + function injectStudioPreviewAugmentations( html: string, adapter: StudioApiAdapter, @@ -118,7 +145,9 @@ function injectStudioPreviewAugmentations( activeCompositionPath: string, ): string { return injectStudioMotionScript( - injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + injectGsapCdnFallback( + injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + ), projectDir, activeCompositionPath, ); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 5894a3e58..0e91f5322 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -575,6 +575,7 @@ function inlineSubCompositions( }, parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document, scriptErrorLabel: "[Compiler] Composition script failed", + compoundAuthoredRoot: true, }, ); @@ -834,6 +835,23 @@ export interface CompileForRenderOptions { failClosedFontFetch?: boolean; } +const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/"; + +function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string { + return html.replace( + /(]*\bsrc=["'])([^"']*gsap[^"']*\/dist\/([^"']+))(["'][^>]*>)/gi, + (full, prefix, src, file, suffix) => { + if (/^https?:\/\//i.test(src)) return full; + const absPath = resolve(projectDir, src); + if (existsSync(absPath)) return full; + console.log( + `[Compiler] Rewriting missing gsap script to CDN: ${src} → ${GSAP_CDN_BASE}${file}`, + ); + return `${prefix}${GSAP_CDN_BASE}${file}${suffix}`; + }, + ); +} + /** * Compile an HTML composition project into a single self-contained HTML string * with all media metadata resolved. @@ -844,7 +862,7 @@ export async function compileForRender( downloadDir: string, options: CompileForRenderOptions = {}, ): Promise { - const rawHtml = readFileSync(htmlPath, "utf-8"); + const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir); const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile( rawHtml, projectDir, diff --git a/packages/producer/tests/sub-composition-video/output/output.mp4 b/packages/producer/tests/sub-composition-video/output/output.mp4 index e6725885e..fb053d81b 100644 --- a/packages/producer/tests/sub-composition-video/output/output.mp4 +++ b/packages/producer/tests/sub-composition-video/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2ca0d92ccf9740ad67c4ea46f50f65c3e3cefac88a07a171bd0fdcaf865efe0 -size 13513666 +oid sha256:36ef88d84340f4ab2aabc17b702d09139fb6614475a4497f1995fd7be539dede +size 12350905 diff --git a/packages/producer/tests/wysiwyg-subcomp-css/meta.json b/packages/producer/tests/wysiwyg-subcomp-css/meta.json new file mode 100644 index 000000000..796217263 --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/meta.json @@ -0,0 +1,12 @@ +{ + "name": "wysiwyg-subcomp-css", + "description": "Verifies sub-composition CSS is correctly scoped and applied in rendered output. Regression test for compound-selector bug where [data-composition-id] [data-hf-authored-id] used a descendant combinator (space) instead of compound (no space) when both attributes coexist on the same host element after inlining.", + "tags": ["regression", "css-scoping", "sub-compositions", "wysiwyg"], + "minPsnr": 25, + "maxFrameFailures": 2, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 0, + "renderConfig": { + "fps": 30 + } +} diff --git a/packages/producer/tests/wysiwyg-subcomp-css/output/output.mp4 b/packages/producer/tests/wysiwyg-subcomp-css/output/output.mp4 new file mode 100644 index 000000000..7987d6729 --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2ee4eefee44ae1ec3e957c3a4a03b72edcd4e44b375965edf3334e3212de644 +size 9290 diff --git a/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html b/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html new file mode 100644 index 000000000..eb18811bf --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/src/compositions/overlay.html @@ -0,0 +1,47 @@ + diff --git a/packages/producer/tests/wysiwyg-subcomp-css/src/index.html b/packages/producer/tests/wysiwyg-subcomp-css/src/index.html new file mode 100644 index 000000000..8410f4bab --- /dev/null +++ b/packages/producer/tests/wysiwyg-subcomp-css/src/index.html @@ -0,0 +1,35 @@ + + + + + + + +
+
+
+
+ + diff --git a/packages/studio/src/components/StudioErrorBoundary.tsx b/packages/studio/src/components/StudioErrorBoundary.tsx new file mode 100644 index 000000000..4a1deb6de --- /dev/null +++ b/packages/studio/src/components/StudioErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { trackStudioEvent } from "../utils/studioTelemetry"; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +export class StudioErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[Studio] Uncaught error:", error, info.componentStack); + trackStudioEvent("crash", { + error_message: error.message, + error_name: error.name, + component_stack: info.componentStack?.slice(0, 500) ?? null, + }); + } + + render() { + if (!this.state.error) return this.props.children; + + return ( +
+
Something went wrong
+
+ {this.state.error.message} +
+ +
+ ); + } +} diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index ee4c5ea66..1a7cf8c28 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers"; import { useStudioContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useDomEditContext } from "../contexts/DomEditContext"; +import { trackStudioEvent } from "../utils/studioTelemetry"; export interface StudioHeaderProps { captureFrameHref: string; @@ -165,7 +166,10 @@ export function StudioHeader({