mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(core): exclude dot-directories and node_modules from studio composition discovery and lint (#1385)
Projects that vendor tooling assets under dot-directories ended up with every example/preset HTML inside them listed and preview-rendered in the comps sidebar, and the studio Lint badge inflated with findings from files that are not part of the video. walkDir only skipped three exact names (.thumbnails, node_modules, .git), so any other dot-directory (.hyperframes/, .cache/, ...) was walked. Add an isInHiddenOrVendorDir helper that rejects paths with a dot-directory or node_modules segment and apply it to composition discovery and the studio lint route. The file tree is deliberately left unfiltered - this only gates discovery. Fixes #1384
This commit is contained in:
@@ -9,6 +9,18 @@ export function isSafePath(base: string, resolved: string): boolean {
|
||||
|
||||
const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
|
||||
|
||||
/**
|
||||
* True when any directory segment of a relative path is a dot-directory or
|
||||
* node_modules. Projects that vendor tooling assets under dot-directories
|
||||
* (.hyperframes/, .cache/, …) ship example/preset HTML that must not surface
|
||||
* as project compositions or studio lint targets (#1384). The file tree is
|
||||
* deliberately not filtered — this only gates discovery.
|
||||
*/
|
||||
export function isInHiddenOrVendorDir(relPath: string): boolean {
|
||||
const segments = relPath.split("/");
|
||||
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
|
||||
}
|
||||
|
||||
/** Recursively walk a directory and return relative file paths. */
|
||||
export function walkDir(dir: string, prefix = ""): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerLintRoutes } from "./lint";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Project layout for #1384: one real composition plus vendored example HTML
|
||||
// inside a dot-directory that must not inflate the lint findings.
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-lint-test-"));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), "<html><body>real</body></html>");
|
||||
mkdirSync(join(projectDir, ".hyperframes"));
|
||||
writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "<html><body>junk</body></html>");
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
// Every linted file reports one finding, so the response reveals exactly
|
||||
// which files were linted.
|
||||
function createAdapter(projectDir: string): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [{ severity: "warning", message: "finding" }] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/out.mp4",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerLintRoutes — dot-directory exclusion (#1384)", () => {
|
||||
it("does not lint HTML inside dot-directories", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const app = new Hono();
|
||||
registerLintRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/lint");
|
||||
const payload = (await response.json()) as { findings?: Array<{ file?: string }> };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const lintedFiles = (payload.findings ?? []).map((f) => f.file);
|
||||
expect(lintedFiles).toContain("index.html");
|
||||
expect(lintedFiles).not.toContain(".hyperframes/preset.html");
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,16 @@ 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";
|
||||
import { isInHiddenOrVendorDir, 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 htmlFiles = walkDir(project.dir).filter(
|
||||
(f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f),
|
||||
);
|
||||
const allFindings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerProjectRoutes } from "./projects";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></body></html>';
|
||||
|
||||
// Project layout for #1384: real compositions at the root and under
|
||||
// compositions/, plus vendored example HTML inside dot-directories that
|
||||
// must not surface as compositions.
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), COMPOSITION_HTML);
|
||||
mkdirSync(join(projectDir, "compositions"));
|
||||
writeFileSync(join(projectDir, "compositions", "scene.html"), COMPOSITION_HTML);
|
||||
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), COMPOSITION_HTML);
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function createAdapter(projectDir: string): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/out.mp4",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerProjectRoutes — composition discovery (#1384)", () => {
|
||||
it("excludes HTML inside dot-directories from compositions", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const app = new Hono();
|
||||
registerProjectRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo");
|
||||
const payload = (await response.json()) as { compositions?: string[] };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.compositions).toContain("index.html");
|
||||
expect(payload.compositions).toContain("compositions/scene.html");
|
||||
expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html");
|
||||
});
|
||||
|
||||
it("keeps dot-directory files visible in the file tree", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const app = new Hono();
|
||||
registerProjectRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo");
|
||||
const payload = (await response.json()) as { files?: string[] };
|
||||
|
||||
expect(payload.files).toContain(".hyperframes/examples/preset.html");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,12 @@ import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { walkDir } from "../helpers/safePath.js";
|
||||
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
|
||||
|
||||
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
|
||||
|
||||
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
|
||||
const htmlFiles = files.filter((f) => f.endsWith(".html"));
|
||||
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
|
||||
const checks = await Promise.all(
|
||||
htmlFiles.map(async (f) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user