// fallow-ignore-file code-duplication // executeGsapMutationRecast and executeGsapMutationAcorn are intentionally // parallel — two writers, same switch-case interface. Structural duplication // is load-bearing (both paths must remain testable in isolation). import type { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, statSync, renameSync, readdirSync, } from "node:fs"; import { resolve, dirname, join } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isAudioFile } from "../helpers/mime.js"; import { generateWaveformCache } from "../helpers/waveform.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { isSafePath, resolveWithinProject } from "../helpers/safePath.js"; import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js"; import { findUnsafeDomPatchValues, findUnsafeMutationValues, type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "@hyperframes/parsers"; import { classifyPropertyGroup } from "@hyperframes/parsers/gsap-constants"; import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn"; import { unrollComputedTimeline } from "@hyperframes/parsers"; import { updateAnimationInScript, addAnimationToScript, removeAnimationFromScript, addKeyframeToScript, removeKeyframeFromScript, moveKeyframeInScript, resizeKeyframedTweenInScript, updateKeyframeInScript, convertToKeyframesFromScript, removeAllKeyframesFromScript, materializeKeyframesFromScript, unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, splitIntoPropertyGroupsFromScript, shiftPositionsInScript, scalePositionsInScript, dedupePositionWritesInScript, } from "@hyperframes/parsers/gsap-writer-acorn"; import { removeElementFromHtml, patchElementInHtml, probeElementInSource, splitElementInHtml, wrapElementsInHtml, unwrapElementsFromHtml, isHTMLElement, type PatchOperation, type ElementRebase, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; // ── Server cutover flag ───────────────────────────────────────────────────── /** * Mirror of the client STUDIO_SDK_CUTOVER_ENABLED flag for server-side writer * selection. When true, the acorn writer handles GSAP mutations; otherwise the * recast writer (gsapParser.ts) is used. Default false → recast. * * Enable with: STUDIO_SDK_CUTOVER_ENABLED=true (or =1) * Mirrors the client Vite env var name so one env switch flips both sides. */ function isAcornGsapWriterEnabled(): boolean { const val = process.env["STUDIO_SDK_CUTOVER_ENABLED"]; return val === "true" || val === "1"; } /** * Lazy-load gsapParser for write ops (recast-backed) — the default server writer. * The read path uses the browser-safe acorn parser; this loader is only needed * for the recast write path (the default when STUDIO_SDK_CUTOVER_ENABLED is off). */ async function loadGsapParser() { return import("@hyperframes/parsers/gsap-parser-recast"); } // ── Shared helpers ────────────────────────────────────────────────────────── /** * Resolve the project and file path from the request, validating safety. * Returns null (and sends an error response) if anything is invalid. */ interface RouteContext { req: { param: (name: string) => string; path: string; query: (name: string) => string | undefined; }; json: (data: unknown, status?: number) => Response; } /** Resolve project + safe absolute path for any project-scoped route. */ async function resolveProjectPath( c: RouteContext, adapter: StudioApiAdapter, pathPrefix: (projectId: string) => string, opts?: { mustExist?: boolean }, ) { const id = c.req.param("id"); const project = await adapter.resolveProject(id); if (!project) { return { error: c.json({ error: "not found" }, 404) } as const; } const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), "")); if (filePath.includes("\0")) { return { error: c.json({ error: "forbidden" }, 403) } as const; } const absPath = resolveWithinProject(project.dir, filePath); if (!absPath) { return { error: c.json({ error: "forbidden" }, 403) } as const; } if (opts?.mustExist && !existsSync(absPath)) { return { error: c.json({ error: "not found" }, 404) } as const; } return { project, filePath, absPath } as const; } function resolveProjectFile( c: RouteContext, adapter: StudioApiAdapter, opts?: { mustExist?: boolean }, ) { return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts); } function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) { return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`); } type MutationTarget = { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number; }; /** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */ function writeIfChanged( c: RouteContext, projectDir: string, filePath: string, absPath: string, original: string, next: string, ): Response { if (next === original) { return c.json({ ok: true, changed: false, content: original, path: filePath }); } const backup = snapshotBeforeWrite(projectDir, absPath); if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); writeFileSync(absPath, next, "utf-8"); return c.json({ ok: true, changed: true, content: next, path: filePath, backupPath: backupPathForResponse(projectDir, backup.backupPath), }); } function rejectUnsafeMutationValues( c: RouteContext, unsafeFields: UnsafeMutationValue[], ): Response { return c.json( { error: "mutation contains unsafe values", fields: unsafeFields.map((field) => field.path), unsafeValues: unsafeFields, }, 400, ); } /** * Parse the request body and validate that `target` is present. * Returns `{ error }` if missing, or `{ target, body }` for the full parsed body. */ async function parseMutationBody( c: RouteContext & { req: { json(): Promise } }, ): Promise<{ error: Response } | { target: MutationTarget; body: T }> { const body = (await (c.req as { json(): Promise }).json().catch(() => null)) as T | null; if (!body?.target) { return { error: c.json({ error: "target required" }, 400) }; } return { target: body.target, body }; } /** Ensure the parent directory of a path exists. */ function ensureDir(filePath: string) { const dir = dirname(filePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } /** * Generate a copy name: foo.html → foo (copy).html → foo (copy 2).html */ function generateCopyPath(projectDir: string, originalPath: string): string { const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : ""; const base = ext ? originalPath.slice(0, -ext.length) : originalPath; // If already a copy, increment the number const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/); const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base; let num = copyMatch ? (copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2) : 1; let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`; while (existsSync(resolve(projectDir, candidate))) { num++; candidate = `${cleanBase} (copy ${num})${ext}`; } return candidate; } /** * Walk a directory recursively and return all file paths matching a filter. */ function walkFiles(dir: string, filter: (name: string) => boolean): string[] { const results: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders") continue; results.push(...walkFiles(full, filter)); } else if (filter(entry.name)) { results.push(full); } } return results; } /** * After a rename, update all references to the old path in project files. * Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces. */ function updateReferences(projectDir: string, oldPath: string, newPath: string): number { const textFiles = walkFiles(projectDir, (name) => /\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name), ); let updatedCount = 0; for (const file of textFiles) { const content = readFileSync(file, "utf-8"); // Only replace full relative paths — never bare filenames, which can // corrupt unrelated content (e.g. "logo.png" inside "my-logo.png"). if (!content.includes(oldPath)) continue; const updated = content.split(oldPath).join(newPath); if (updated !== content) { writeFileSync(file, updated, "utf-8"); updatedCount++; } } return updatedCount; } // ── GSAP script extraction ────────────────────────────────────────────────── /** * Parse an HTML string with linkedom, locate the inline ``; const bootstrap = [ gsapCdn, "", ].join("\n"); if (html.includes("")) { html = html.replace("", `${bootstrap}\n`); } else { html += `\n${bootstrap}`; } block = extractGsapScriptBlock(html); } if ( !block && (body.type === "shift-positions" || body.type === "scale-positions" || body.type === "shift-positions-batch") ) { return c.json({ ok: true, changed: false, mutated: false, parsed: { animations: [], timelineVar: "tl", preamble: "", postamble: "" }, before: html, after: html, scriptText: "", path: res.filePath, backupPath: null, }); } if (!block) { return c.json({ error: "no GSAP script found in file" }, 400); } const respond = (data: unknown, status?: number) => // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge between generic status and Hono's literal union status ? c.json(data, status as any) : c.json(data); const result = await executeGsapMutation(body, block, respond); if (result instanceof Response) return result; let newScript = typeof result === "string" ? result : result.script; // Keep the "hold before first keyframe" sets in sync after any mutation that can // change a position tween's first keyframe or its existence. Without it, an // element snaps to its CSS base before the tween starts instead of holding its // first keyframe (the universal NLE behavior). if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) { const parser = await loadGsapParser(); newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); } const changed = newScript !== block.scriptText; const newHtml = changed ? block.replaceScript(newScript) : html; let backupPath: string | null = null; if (changed) { const backup = snapshotBeforeWrite(res.project.dir, res.absPath); if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); backupPath = backupPathForResponse(res.project.dir, backup.backupPath); writeFileSync(res.absPath, newHtml, "utf-8"); } const freshParsed = parseGsapScriptAcorn(newScript); const responsePayload: Record = { ok: true, changed, mutated: changed, parsed: freshParsed, before: html, after: newHtml, scriptText: newScript, path: res.filePath, backupPath, }; if (typeof result !== "string" && result.skippedSelectors.length > 0) { responsePayload.skippedSelectors = result.skippedSelectors; } return c.json(responsePayload); }); }