mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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"]);
|
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. */
|
/** Recursively walk a directory and return relative file paths. */
|
||||||
export function walkDir(dir: string, prefix = ""): string[] {
|
export function walkDir(dir: string, prefix = ""): string[] {
|
||||||
const files: 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 { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { StudioApiAdapter } from "../types.js";
|
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 {
|
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||||
api.get("/projects/:id/lint", async (c) => {
|
api.get("/projects/:id/lint", async (c) => {
|
||||||
const project = await adapter.resolveProject(c.req.param("id"));
|
const project = await adapter.resolveProject(c.req.param("id"));
|
||||||
if (!project) return c.json({ error: "not found" }, 404);
|
if (!project) return c.json({ error: "not found" }, 404);
|
||||||
try {
|
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<{
|
const allFindings: Array<{
|
||||||
severity: string;
|
severity: string;
|
||||||
message: 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 { join } from "node:path";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import type { StudioApiAdapter } from "../types.js";
|
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*=/;
|
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
|
||||||
|
|
||||||
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
|
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(
|
const checks = await Promise.all(
|
||||||
htmlFiles.map(async (f) => {
|
htmlFiles.map(async (f) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user