Skip to content

Commit b952dc9

Browse files
authored
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
1 parent aec3c3b commit b952dc9

5 files changed

Lines changed: 154 additions & 4 deletions

File tree

packages/core/src/studio-api/helpers/safePath.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ export function isSafePath(base: string, resolved: string): boolean {
99

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

12+
/**
13+
* True when any directory segment of a relative path is a dot-directory or
14+
* node_modules. Projects that vendor tooling assets under dot-directories
15+
* (.hyperframes/, .cache/, …) ship example/preset HTML that must not surface
16+
* as project compositions or studio lint targets (#1384). The file tree is
17+
* deliberately not filtered — this only gates discovery.
18+
*/
19+
export function isInHiddenOrVendorDir(relPath: string): boolean {
20+
const segments = relPath.split("/");
21+
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
22+
}
23+
1224
/** Recursively walk a directory and return relative file paths. */
1325
export function walkDir(dir: string, prefix = ""): string[] {
1426
const files: string[] = [];
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { Hono } from "hono";
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { registerLintRoutes } from "./lint";
7+
import type { StudioApiAdapter } from "../types";
8+
9+
const tempDirs: string[] = [];
10+
11+
afterEach(() => {
12+
for (const dir of tempDirs.splice(0)) {
13+
rmSync(dir, { recursive: true, force: true });
14+
}
15+
});
16+
17+
// Project layout for #1384: one real composition plus vendored example HTML
18+
// inside a dot-directory that must not inflate the lint findings.
19+
function createProjectDir(): string {
20+
const projectDir = mkdtempSync(join(tmpdir(), "hf-lint-test-"));
21+
tempDirs.push(projectDir);
22+
writeFileSync(join(projectDir, "index.html"), "<html><body>real</body></html>");
23+
mkdirSync(join(projectDir, ".hyperframes"));
24+
writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "<html><body>junk</body></html>");
25+
return projectDir;
26+
}
27+
28+
// Every linted file reports one finding, so the response reveals exactly
29+
// which files were linted.
30+
function createAdapter(projectDir: string): StudioApiAdapter {
31+
return {
32+
listProjects: () => [],
33+
resolveProject: async (id: string) => ({ id, dir: projectDir }),
34+
bundle: async () => null,
35+
lint: async () => ({ findings: [{ severity: "warning", message: "finding" }] }),
36+
runtimeUrl: "/api/runtime.js",
37+
rendersDir: () => "/tmp/renders",
38+
startRender: () => ({
39+
id: "job-1",
40+
status: "rendering",
41+
progress: 0,
42+
outputPath: "/tmp/out.mp4",
43+
}),
44+
};
45+
}
46+
47+
describe("registerLintRoutes — dot-directory exclusion (#1384)", () => {
48+
it("does not lint HTML inside dot-directories", async () => {
49+
const projectDir = createProjectDir();
50+
const app = new Hono();
51+
registerLintRoutes(app, createAdapter(projectDir));
52+
53+
const response = await app.request("http://localhost/projects/demo/lint");
54+
const payload = (await response.json()) as { findings?: Array<{ file?: string }> };
55+
56+
expect(response.status).toBe(200);
57+
const lintedFiles = (payload.findings ?? []).map((f) => f.file);
58+
expect(lintedFiles).toContain("index.html");
59+
expect(lintedFiles).not.toContain(".hyperframes/preset.html");
60+
});
61+
});

packages/core/src/studio-api/routes/lint.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import type { Hono } from "hono";
22
import { readFileSync } from "node:fs";
33
import { join } from "node:path";
44
import type { StudioApiAdapter } from "../types.js";
5-
import { walkDir } from "../helpers/safePath.js";
5+
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
66

77
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
88
api.get("/projects/:id/lint", async (c) => {
99
const project = await adapter.resolveProject(c.req.param("id"));
1010
if (!project) return c.json({ error: "not found" }, 404);
1111
try {
12-
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
12+
const htmlFiles = walkDir(project.dir).filter(
13+
(f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f),
14+
);
1315
const allFindings: Array<{
1416
severity: string;
1517
message: string;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { Hono } from "hono";
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { registerProjectRoutes } from "./projects";
7+
import type { StudioApiAdapter } from "../types";
8+
9+
const tempDirs: string[] = [];
10+
11+
afterEach(() => {
12+
for (const dir of tempDirs.splice(0)) {
13+
rmSync(dir, { recursive: true, force: true });
14+
}
15+
});
16+
17+
const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></body></html>';
18+
19+
// Project layout for #1384: real compositions at the root and under
20+
// compositions/, plus vendored example HTML inside dot-directories that
21+
// must not surface as compositions.
22+
function createProjectDir(): string {
23+
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
24+
tempDirs.push(projectDir);
25+
writeFileSync(join(projectDir, "index.html"), COMPOSITION_HTML);
26+
mkdirSync(join(projectDir, "compositions"));
27+
writeFileSync(join(projectDir, "compositions", "scene.html"), COMPOSITION_HTML);
28+
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
29+
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), COMPOSITION_HTML);
30+
return projectDir;
31+
}
32+
33+
function createAdapter(projectDir: string): StudioApiAdapter {
34+
return {
35+
listProjects: () => [],
36+
resolveProject: async (id: string) => ({ id, dir: projectDir }),
37+
bundle: async () => null,
38+
lint: async () => ({ findings: [] }),
39+
runtimeUrl: "/api/runtime.js",
40+
rendersDir: () => "/tmp/renders",
41+
startRender: () => ({
42+
id: "job-1",
43+
status: "rendering",
44+
progress: 0,
45+
outputPath: "/tmp/out.mp4",
46+
}),
47+
};
48+
}
49+
50+
describe("registerProjectRoutes — composition discovery (#1384)", () => {
51+
it("excludes HTML inside dot-directories from compositions", async () => {
52+
const projectDir = createProjectDir();
53+
const app = new Hono();
54+
registerProjectRoutes(app, createAdapter(projectDir));
55+
56+
const response = await app.request("http://localhost/projects/demo");
57+
const payload = (await response.json()) as { compositions?: string[] };
58+
59+
expect(response.status).toBe(200);
60+
expect(payload.compositions).toContain("index.html");
61+
expect(payload.compositions).toContain("compositions/scene.html");
62+
expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html");
63+
});
64+
65+
it("keeps dot-directory files visible in the file tree", async () => {
66+
const projectDir = createProjectDir();
67+
const app = new Hono();
68+
registerProjectRoutes(app, createAdapter(projectDir));
69+
70+
const response = await app.request("http://localhost/projects/demo");
71+
const payload = (await response.json()) as { files?: string[] };
72+
73+
expect(payload.files).toContain(".hyperframes/examples/preset.html");
74+
});
75+
});

packages/core/src/studio-api/routes/projects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { readFile } from "node:fs/promises";
22
import { join } from "node:path";
33
import type { Hono } from "hono";
44
import type { StudioApiAdapter } from "../types.js";
5-
import { walkDir } from "../helpers/safePath.js";
5+
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
66

77
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
88

99
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
10-
const htmlFiles = files.filter((f) => f.endsWith(".html"));
10+
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
1111
const checks = await Promise.all(
1212
htmlFiles.map(async (f) => {
1313
try {

0 commit comments

Comments
 (0)