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
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

597 changes: 130 additions & 467 deletions packages/cli/src/server/studioServer.ts

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./studio-api": {
"import": "./src/studio-api/index.ts",
"types": "./src/studio-api/index.ts"
}
},
"publishConfig": {
"access": "public",
Expand All @@ -45,7 +49,11 @@
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./studio-api": {
"import": "./dist/studio-api/index.js",
"types": "./dist/studio-api/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
Expand Down Expand Up @@ -81,6 +89,14 @@
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"hono": {
"optional": true
}
},
"optionalDependencies": {
"cheerio": "^1.2.0",
"esbuild": "^0.25.12"
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/studio-api/createStudioApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Hono } from "hono";
import type { StudioApiAdapter } from "./types.js";
import { registerProjectRoutes } from "./routes/projects.js";
import { registerFileRoutes } from "./routes/files.js";
import { registerPreviewRoutes } from "./routes/preview.js";
import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";

/**
* Create a Hono sub-app with all studio API routes.
*
* Both the vite dev server and CLI embedded server mount this app
* under /api, each providing their own adapter for host-specific behavior.
*/
export function createStudioApi(adapter: StudioApiAdapter): Hono {
const api = new Hono();

registerProjectRoutes(api, adapter);
registerFileRoutes(api, adapter);
registerPreviewRoutes(api, adapter);
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);

return api;
}
31 changes: 31 additions & 0 deletions packages/core/src/studio-api/helpers/mime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".txt": "text/plain",
".md": "text/markdown",
};

export function getMimeType(path: string): string {
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
25 changes: 25 additions & 0 deletions packages/core/src/studio-api/helpers/safePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { resolve, sep, join } from "node:path";
import { readdirSync } from "node:fs";

/** Reject paths that escape the project directory. */
export function isSafePath(base: string, resolved: string): boolean {
const norm = resolve(base) + sep;
return resolved.startsWith(norm) || resolved === resolve(base);
}

const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);

/** Recursively walk a directory and return relative file paths. */
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
files.push(rel);
}
}
return files;
}
64 changes: 64 additions & 0 deletions packages/core/src/studio-api/helpers/subComposition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

/**
* Build a standalone HTML page for a sub-composition.
*
* Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
* Lottie, reset styles, runtime) are preserved — instead of building a minimal
* page from scratch that would miss important scripts/styles.
*/
export function buildSubCompositionHtml(
projectDir: string,
compPath: string,
runtimeUrl: string,
baseHref?: string,
): string | null {
const compFile = join(projectDir, compPath);
if (!existsSync(compFile)) return null;

const rawComp = readFileSync(compFile, "utf-8");

// Extract content from <template> wrapper (compositions are always templates)
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
const content = templateMatch?.[1] ?? rawComp;

// Use the project's index.html <head> to preserve all dependencies
const indexPath = join(projectDir, "index.html");
let headContent = "";

if (existsSync(indexPath)) {
const indexHtml = readFileSync(indexPath, "utf-8");
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
headContent = headMatch?.[1] ?? "";
}

// Inject <base> for relative asset resolution (before other tags)
if (baseHref && !headContent.includes("<base")) {
headContent = `<base href="${baseHref}">\n${headContent}`;
}

// Ensure runtime is present (might differ from the one in index.html)
if (
!headContent.includes("hyperframe.runtime") &&
!headContent.includes("hyperframes-preview-runtime")
) {
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
}

// Fallback: if no index.html head was found, add minimal deps
if (!headContent.includes("gsap")) {
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
}

return `<!DOCTYPE html>
<html>
<head>
${headContent}
</head>
<body>
<script>window.__timelines=window.__timelines||{};</script>
${content}
</body>
</html>`;
}
5 changes: 5 additions & 0 deletions packages/core/src/studio-api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { createStudioApi } from "./createStudioApi.js";
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
36 changes: 36 additions & 0 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";

export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Read file content
api.get("/projects/:id/files/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
const file = resolve(project.dir, filePath);
if (!isSafePath(project.dir, file) || !existsSync(file)) {
return c.text("not found", 404);
}
const content = readFileSync(file, "utf-8");
return c.json({ filename: filePath, content });
});

// Write file content
api.put("/projects/:id/files/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
const file = resolve(project.dir, filePath);
if (!isSafePath(project.dir, file)) {
return c.json({ error: "forbidden" }, 403);
}
const dir = dirname(file);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const body = await c.req.text();
writeFileSync(file, body, "utf-8");
return c.json({ ok: true });
});
}
34 changes: 34 additions & 0 deletions packages/core/src/studio-api/routes/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Hono } from "hono";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { walkDir } from "../helpers/safePath.js";

export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/lint", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
try {
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
const allFindings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}> = [];
for (const file of htmlFiles) {
const content = readFileSync(join(project.dir, file), "utf-8");
const result = await adapter.lint(content, { filePath: file });
if (result?.findings) {
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
}
}
return c.json({ findings: allFindings });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Lint failed: ${msg}` }, 500);
}
});
}
87 changes: 87 additions & 0 deletions packages/core/src/studio-api/routes/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";

export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Bundled composition preview
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);

try {
let bundled = await adapter.bundle(project.dir);
if (!bundled) {
const indexPath = resolve(project.dir, "index.html");
if (!existsSync(indexPath)) return c.text("not found", 404);
bundled = readFileSync(indexPath, "utf-8");
}

// Inject runtime if not already present (check URL pattern and bundler attribute)
if (
!bundled.includes("hyperframe.runtime") &&
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled = bundled.includes("</body>")
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
: bundled + `\n${runtimeTag}`;
}

// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${project.id}/preview/`;
if (!bundled.includes("<base")) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}

return c.html(bundled);
} catch {
const file = resolve(project.dir, "index.html");
if (existsSync(file)) return c.html(readFileSync(file, "utf-8"));
return c.text("not found", 404);
}
});

// Sub-composition preview
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compFile = resolve(project.dir, compPath);
if (
!isSafePath(project.dir, compFile) ||
!existsSync(compFile) ||
!statSync(compFile).isFile()
) {
return c.text("not found", 404);
}
const baseHref = `/api/projects/${project.id}/preview/`;
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
return c.html(html);
});

// Static asset serving
api.get("/projects/:id/preview/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolve(project.dir, subPath);
if (!isSafePath(project.dir, file) || !existsSync(file) || !statSync(file).isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
const content = readFileSync(file, isText ? "utf-8" : undefined);
return new Response(content, {
headers: { "Content-Type": contentType },
});
});
}
Loading
Loading