Skip to content

Commit c396c88

Browse files
committed
simple favicon detection
1 parent 89ffcf4 commit c396c88

6 files changed

Lines changed: 639 additions & 259 deletions

File tree

apps/server/src/gitIgnore.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { runProcess } from "./processRunner";
2+
3+
const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
4+
5+
function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
6+
const parts = input.split("\0");
7+
if (parts.length === 0) return [];
8+
if (truncated && parts[parts.length - 1]?.length) {
9+
parts.pop();
10+
}
11+
return parts.filter((value) => value.length > 0);
12+
}
13+
14+
export async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
15+
const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
16+
cwd,
17+
allowNonZeroExit: true,
18+
timeoutMs: 5_000,
19+
maxBufferBytes: 4_096,
20+
}).catch(() => null);
21+
22+
return Boolean(
23+
insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
24+
);
25+
}
26+
27+
export async function filterGitIgnoredPaths(
28+
cwd: string,
29+
relativePaths: readonly string[],
30+
): Promise<string[]> {
31+
if (relativePaths.length === 0) {
32+
return [...relativePaths];
33+
}
34+
35+
const ignoredPaths = new Set<string>();
36+
let chunk: string[] = [];
37+
let chunkBytes = 0;
38+
39+
const flushChunk = async (): Promise<boolean> => {
40+
if (chunk.length === 0) {
41+
return true;
42+
}
43+
44+
const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
45+
cwd,
46+
allowNonZeroExit: true,
47+
timeoutMs: 20_000,
48+
maxBufferBytes: 16 * 1024 * 1024,
49+
outputMode: "truncate",
50+
stdin: `${chunk.join("\0")}\0`,
51+
}).catch(() => null);
52+
chunk = [];
53+
chunkBytes = 0;
54+
55+
if (!checkIgnore) {
56+
return false;
57+
}
58+
59+
// git-check-ignore exits with 1 when no paths match.
60+
if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
61+
return false;
62+
}
63+
64+
const matchedIgnoredPaths = splitNullSeparatedPaths(
65+
checkIgnore.stdout,
66+
Boolean(checkIgnore.stdoutTruncated),
67+
);
68+
for (const ignoredPath of matchedIgnoredPaths) {
69+
ignoredPaths.add(ignoredPath);
70+
}
71+
return true;
72+
};
73+
74+
for (const relativePath of relativePaths) {
75+
const relativePathBytes = Buffer.byteLength(relativePath) + 1;
76+
if (
77+
chunk.length > 0 &&
78+
chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES &&
79+
!(await flushChunk())
80+
) {
81+
return [...relativePaths];
82+
}
83+
84+
chunk.push(relativePath);
85+
chunkBytes += relativePathBytes;
86+
87+
if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) {
88+
return [...relativePaths];
89+
}
90+
}
91+
92+
if (!(await flushChunk())) {
93+
return [...relativePaths];
94+
}
95+
96+
if (ignoredPaths.size === 0) {
97+
return [...relativePaths];
98+
}
99+
100+
return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
101+
}
Lines changed: 172 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { execFileSync } from "node:child_process";
12
import fs from "node:fs";
23
import http from "node:http";
34
import os from "node:os";
45
import path from "node:path";
56

7+
import * as NodeServices from "@effect/platform-node/NodeServices";
8+
import { Effect } from "effect";
69
import { afterEach, describe, expect, it } from "vitest";
710
import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
811

@@ -20,14 +23,48 @@ function makeTempDir(prefix: string): string {
2023
return dir;
2124
}
2225

26+
function writeFile(filePath: string, contents: string): void {
27+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
28+
fs.writeFileSync(filePath, contents, "utf8");
29+
}
30+
31+
function makeUnreadable(filePath: string): void {
32+
fs.chmodSync(filePath, 0o000);
33+
}
34+
35+
function runGit(cwd: string, args: readonly string[]): void {
36+
execFileSync("git", args, {
37+
cwd,
38+
stdio: "ignore",
39+
env: {
40+
...process.env,
41+
GIT_AUTHOR_NAME: "Test User",
42+
GIT_AUTHOR_EMAIL: "test@example.com",
43+
GIT_COMMITTER_NAME: "Test User",
44+
GIT_COMMITTER_EMAIL: "test@example.com",
45+
},
46+
});
47+
}
48+
2349
async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
2450
const server = http.createServer((req, res) => {
2551
const url = new URL(req.url ?? "/", "http://127.0.0.1");
26-
if (tryHandleProjectFaviconRequest(url, res)) {
27-
return;
28-
}
29-
res.writeHead(404, { "Content-Type": "text/plain" });
30-
res.end("Not Found");
52+
void Effect.runPromise(
53+
Effect.gen(function* () {
54+
if (yield* tryHandleProjectFaviconRequest(url, res)) {
55+
return;
56+
}
57+
res.writeHead(404, { "Content-Type": "text/plain" });
58+
res.end("Not Found");
59+
}).pipe(Effect.provide(NodeServices.layer)),
60+
).catch((error) => {
61+
if (!res.headersSent) {
62+
res.writeHead(500, { "Content-Type": "text/plain" });
63+
}
64+
if (!res.writableEnded) {
65+
res.end(error instanceof Error ? error.message : "Unhandled error");
66+
}
67+
});
3168
});
3269

3370
await new Promise<void>((resolve, reject) => {
@@ -70,6 +107,22 @@ async function request(baseUrl: string, pathname: string): Promise<HttpResponse>
70107
};
71108
}
72109

110+
function requestProjectFavicon(baseUrl: string, projectDir: string): Promise<HttpResponse> {
111+
return request(baseUrl, `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`);
112+
}
113+
114+
function expectSvgResponse(response: HttpResponse, expectedBody: string): void {
115+
expect(response.statusCode).toBe(200);
116+
expect(response.contentType).toContain("image/svg+xml");
117+
expect(response.body).toBe(expectedBody);
118+
}
119+
120+
function expectFallbackSvgResponse(response: HttpResponse): void {
121+
expect(response.statusCode).toBe(200);
122+
expect(response.contentType).toContain("image/svg+xml");
123+
expect(response.body).toContain('data-fallback="project-favicon"');
124+
}
125+
73126
describe("tryHandleProjectFaviconRequest", () => {
74127
afterEach(() => {
75128
for (const dir of tempDirs.splice(0, tempDirs.length)) {
@@ -87,85 +140,146 @@ describe("tryHandleProjectFaviconRequest", () => {
87140

88141
it("serves a well-known favicon file from the project root", async () => {
89142
const projectDir = makeTempDir("t3code-favicon-route-root-");
90-
fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8");
143+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>");
91144

92145
await withRouteServer(async (baseUrl) => {
93-
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
94-
const response = await request(baseUrl, pathname);
95-
expect(response.statusCode).toBe(200);
96-
expect(response.contentType).toContain("image/svg+xml");
97-
expect(response.body).toBe("<svg>favicon</svg>");
146+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>favicon</svg>");
98147
});
99148
});
100149

101-
it("resolves icon href from source files when no well-known favicon exists", async () => {
102-
const projectDir = makeTempDir("t3code-favicon-route-source-");
103-
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
104-
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
105-
fs.writeFileSync(
106-
path.join(projectDir, "index.html"),
150+
it.each([
151+
{
152+
name: "resolves icon link when href appears before rel in HTML",
153+
prefix: "t3code-favicon-route-html-order-",
154+
sourcePath: ["index.html"],
155+
sourceContents: '<link href="/brand/logo.svg" rel="icon">',
156+
iconPath: ["public", "brand", "logo.svg"],
157+
expectedBody: "<svg>brand-html-order</svg>",
158+
},
159+
{
160+
name: "resolves object-style icon metadata when href appears before rel",
161+
prefix: "t3code-favicon-route-obj-order-",
162+
sourcePath: ["src", "root.tsx"],
163+
sourceContents: 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
164+
iconPath: ["public", "brand", "obj.svg"],
165+
expectedBody: "<svg>brand-obj-order</svg>",
166+
},
167+
])("$name", async ({ prefix, sourcePath, sourceContents, iconPath, expectedBody }) => {
168+
const projectDir = makeTempDir(prefix);
169+
writeFile(path.join(projectDir, ...sourcePath), sourceContents);
170+
writeFile(path.join(projectDir, ...iconPath), expectedBody);
171+
172+
await withRouteServer(async (baseUrl) => {
173+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), expectedBody);
174+
});
175+
});
176+
177+
it("serves a fallback favicon when no icon exists", async () => {
178+
const projectDir = makeTempDir("t3code-favicon-route-fallback-");
179+
180+
await withRouteServer(async (baseUrl) => {
181+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
182+
});
183+
});
184+
185+
it("treats unreadable favicon probes as misses and continues searching", async () => {
186+
const projectDir = makeTempDir("t3code-favicon-route-unreadable-probes-");
187+
const unreadableFaviconPath = path.join(projectDir, "favicon.svg");
188+
writeFile(unreadableFaviconPath, "<svg>blocked-root</svg>");
189+
makeUnreadable(unreadableFaviconPath);
190+
const unreadableSourcePath = path.join(projectDir, "index.html");
191+
writeFile(unreadableSourcePath, '<link rel="icon" href="/brand/blocked.svg">');
192+
makeUnreadable(unreadableSourcePath);
193+
writeFile(
194+
path.join(projectDir, "src", "root.tsx"),
195+
'const links = [{ rel: "icon", href: "/brand/readable.svg" }];',
196+
);
197+
writeFile(
198+
path.join(projectDir, "public", "brand", "readable.svg"),
199+
"<svg>readable-from-source</svg>",
200+
);
201+
202+
await withRouteServer(async (baseUrl) => {
203+
expectSvgResponse(
204+
await requestProjectFavicon(baseUrl, projectDir),
205+
"<svg>readable-from-source</svg>",
206+
);
207+
});
208+
});
209+
210+
it("finds a nested app favicon from source metadata when cwd is a monorepo root", async () => {
211+
const projectDir = makeTempDir("t3code-favicon-route-monorepo-source-");
212+
writeFile(
213+
path.join(projectDir, "apps", "frontend", "index.html"),
107214
'<link rel="icon" href="/brand/logo.svg">',
108215
);
109-
fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
216+
writeFile(
217+
path.join(projectDir, "apps", "frontend", "public", "brand", "logo.svg"),
218+
"<svg>nested-app</svg>",
219+
);
110220

111221
await withRouteServer(async (baseUrl) => {
112-
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
113-
const response = await request(baseUrl, pathname);
114-
expect(response.statusCode).toBe(200);
115-
expect(response.contentType).toContain("image/svg+xml");
116-
expect(response.body).toBe("<svg>brand</svg>");
222+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>nested-app</svg>");
117223
});
118224
});
119225

120-
it("resolves icon link when href appears before rel in HTML", async () => {
121-
const projectDir = makeTempDir("t3code-favicon-route-html-order-");
122-
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
123-
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
124-
fs.writeFileSync(
125-
path.join(projectDir, "index.html"),
126-
'<link href="/brand/logo.svg" rel="icon">',
226+
it("skips nested search roots that workspace entries ignore", async () => {
227+
const projectDir = makeTempDir("t3code-favicon-route-ignored-search-root-");
228+
writeFile(path.join(projectDir, ".next", "public", "favicon.svg"), "<svg>ignored-next</svg>");
229+
230+
await withRouteServer(async (baseUrl) => {
231+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
232+
});
233+
});
234+
235+
it("prefers a root favicon over nested workspace matches", async () => {
236+
const projectDir = makeTempDir("t3code-favicon-route-root-priority-");
237+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>root-first</svg>");
238+
writeFile(path.join(projectDir, "apps", "frontend", "public", "favicon.ico"), "nested-ico");
239+
240+
await withRouteServer(async (baseUrl) => {
241+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>root-first</svg>");
242+
});
243+
});
244+
245+
it("skips a gitignored nested app directory", async () => {
246+
const projectDir = makeTempDir("t3code-favicon-route-gitignored-app-");
247+
runGit(projectDir, ["init"]);
248+
writeFile(path.join(projectDir, ".gitignore"), "apps/frontend/\n");
249+
writeFile(
250+
path.join(projectDir, "apps", "frontend", "public", "favicon.svg"),
251+
"<svg>ignored-app</svg>",
127252
);
128-
fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
129253

130254
await withRouteServer(async (baseUrl) => {
131-
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
132-
const response = await request(baseUrl, pathname);
133-
expect(response.statusCode).toBe(200);
134-
expect(response.contentType).toContain("image/svg+xml");
135-
expect(response.body).toBe("<svg>brand-html-order</svg>");
255+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
136256
});
137257
});
138258

139-
it("resolves object-style icon metadata when href appears before rel", async () => {
140-
const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
141-
const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
142-
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
143-
fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
144-
fs.writeFileSync(
145-
path.join(projectDir, "src", "root.tsx"),
146-
'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
147-
"utf8",
259+
it("skips a gitignored root favicon and falls through to a nested app", async () => {
260+
const projectDir = makeTempDir("t3code-favicon-route-gitignored-root-");
261+
runGit(projectDir, ["init"]);
262+
writeFile(path.join(projectDir, ".gitignore"), "/favicon.svg\n");
263+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>ignored-root</svg>");
264+
writeFile(
265+
path.join(projectDir, "apps", "frontend", "public", "favicon.svg"),
266+
"<svg>nested-kept</svg>",
148267
);
149-
fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8");
150268

151269
await withRouteServer(async (baseUrl) => {
152-
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
153-
const response = await request(baseUrl, pathname);
154-
expect(response.statusCode).toBe(200);
155-
expect(response.contentType).toContain("image/svg+xml");
156-
expect(response.body).toBe("<svg>brand-obj-order</svg>");
270+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>nested-kept</svg>");
157271
});
158272
});
159273

160-
it("serves a fallback favicon when no icon exists", async () => {
161-
const projectDir = makeTempDir("t3code-favicon-route-fallback-");
274+
it("skips a gitignored source file when resolving icon metadata", async () => {
275+
const projectDir = makeTempDir("t3code-favicon-route-gitignored-source-");
276+
runGit(projectDir, ["init"]);
277+
writeFile(path.join(projectDir, ".gitignore"), "index.html\n");
278+
writeFile(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">');
279+
writeFile(path.join(projectDir, "public", "brand", "logo.svg"), "<svg>ignored-source</svg>");
162280

163281
await withRouteServer(async (baseUrl) => {
164-
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
165-
const response = await request(baseUrl, pathname);
166-
expect(response.statusCode).toBe(200);
167-
expect(response.contentType).toContain("image/svg+xml");
168-
expect(response.body).toContain('data-fallback="project-favicon"');
282+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
169283
});
170284
});
171285
});

0 commit comments

Comments
 (0)