Skip to content

Commit b936f17

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

File tree

6 files changed

+592
-259
lines changed

6 files changed

+592
-259
lines changed

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: 143 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,44 @@ 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 runGit(cwd: string, args: readonly string[]): void {
32+
execFileSync("git", args, {
33+
cwd,
34+
stdio: "ignore",
35+
env: {
36+
...process.env,
37+
GIT_AUTHOR_NAME: "Test User",
38+
GIT_AUTHOR_EMAIL: "test@example.com",
39+
GIT_COMMITTER_NAME: "Test User",
40+
GIT_COMMITTER_EMAIL: "test@example.com",
41+
},
42+
});
43+
}
44+
2345
async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
2446
const server = http.createServer((req, res) => {
2547
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");
48+
void Effect.runPromise(
49+
Effect.gen(function* () {
50+
if (yield* tryHandleProjectFaviconRequest(url, res)) {
51+
return;
52+
}
53+
res.writeHead(404, { "Content-Type": "text/plain" });
54+
res.end("Not Found");
55+
}).pipe(Effect.provide(NodeServices.layer)),
56+
).catch((error) => {
57+
if (!res.headersSent) {
58+
res.writeHead(500, { "Content-Type": "text/plain" });
59+
}
60+
if (!res.writableEnded) {
61+
res.end(error instanceof Error ? error.message : "Unhandled error");
62+
}
63+
});
3164
});
3265

3366
await new Promise<void>((resolve, reject) => {
@@ -70,6 +103,22 @@ async function request(baseUrl: string, pathname: string): Promise<HttpResponse>
70103
};
71104
}
72105

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

88137
it("serves a well-known favicon file from the project root", async () => {
89138
const projectDir = makeTempDir("t3code-favicon-route-root-");
90-
fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8");
139+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>");
140+
141+
await withRouteServer(async (baseUrl) => {
142+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>favicon</svg>");
143+
});
144+
});
145+
146+
it.each([
147+
{
148+
name: "resolves icon link when href appears before rel in HTML",
149+
prefix: "t3code-favicon-route-html-order-",
150+
sourcePath: ["index.html"],
151+
sourceContents: '<link href="/brand/logo.svg" rel="icon">',
152+
iconPath: ["public", "brand", "logo.svg"],
153+
expectedBody: "<svg>brand-html-order</svg>",
154+
},
155+
{
156+
name: "resolves object-style icon metadata when href appears before rel",
157+
prefix: "t3code-favicon-route-obj-order-",
158+
sourcePath: ["src", "root.tsx"],
159+
sourceContents: 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
160+
iconPath: ["public", "brand", "obj.svg"],
161+
expectedBody: "<svg>brand-obj-order</svg>",
162+
},
163+
])("$name", async ({ prefix, sourcePath, sourceContents, iconPath, expectedBody }) => {
164+
const projectDir = makeTempDir(prefix);
165+
writeFile(path.join(projectDir, ...sourcePath), sourceContents);
166+
writeFile(path.join(projectDir, ...iconPath), expectedBody);
91167

92168
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>");
169+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), expectedBody);
98170
});
99171
});
100172

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"),
173+
it("serves a fallback favicon when no icon exists", async () => {
174+
const projectDir = makeTempDir("t3code-favicon-route-fallback-");
175+
176+
await withRouteServer(async (baseUrl) => {
177+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
178+
});
179+
});
180+
181+
it("finds a nested app favicon from source metadata when cwd is a monorepo root", async () => {
182+
const projectDir = makeTempDir("t3code-favicon-route-monorepo-source-");
183+
writeFile(
184+
path.join(projectDir, "apps", "frontend", "index.html"),
107185
'<link rel="icon" href="/brand/logo.svg">',
108186
);
109-
fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
187+
writeFile(
188+
path.join(projectDir, "apps", "frontend", "public", "brand", "logo.svg"),
189+
"<svg>nested-app</svg>",
190+
);
110191

111192
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>");
193+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>nested-app</svg>");
117194
});
118195
});
119196

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">',
197+
it("skips nested search roots that workspace entries ignore", async () => {
198+
const projectDir = makeTempDir("t3code-favicon-route-ignored-search-root-");
199+
writeFile(path.join(projectDir, ".next", "public", "favicon.svg"), "<svg>ignored-next</svg>");
200+
201+
await withRouteServer(async (baseUrl) => {
202+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
203+
});
204+
});
205+
206+
it("prefers a root favicon over nested workspace matches", async () => {
207+
const projectDir = makeTempDir("t3code-favicon-route-root-priority-");
208+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>root-first</svg>");
209+
writeFile(path.join(projectDir, "apps", "frontend", "public", "favicon.ico"), "nested-ico");
210+
211+
await withRouteServer(async (baseUrl) => {
212+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>root-first</svg>");
213+
});
214+
});
215+
216+
it("skips a gitignored nested app directory", async () => {
217+
const projectDir = makeTempDir("t3code-favicon-route-gitignored-app-");
218+
runGit(projectDir, ["init"]);
219+
writeFile(path.join(projectDir, ".gitignore"), "apps/frontend/\n");
220+
writeFile(
221+
path.join(projectDir, "apps", "frontend", "public", "favicon.svg"),
222+
"<svg>ignored-app</svg>",
127223
);
128-
fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
129224

130225
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>");
226+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
136227
});
137228
});
138229

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",
230+
it("skips a gitignored root favicon and falls through to a nested app", async () => {
231+
const projectDir = makeTempDir("t3code-favicon-route-gitignored-root-");
232+
runGit(projectDir, ["init"]);
233+
writeFile(path.join(projectDir, ".gitignore"), "/favicon.svg\n");
234+
writeFile(path.join(projectDir, "favicon.svg"), "<svg>ignored-root</svg>");
235+
writeFile(
236+
path.join(projectDir, "apps", "frontend", "public", "favicon.svg"),
237+
"<svg>nested-kept</svg>",
148238
);
149-
fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8");
150239

151240
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>");
241+
expectSvgResponse(await requestProjectFavicon(baseUrl, projectDir), "<svg>nested-kept</svg>");
157242
});
158243
});
159244

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

163252
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"');
253+
expectFallbackSvgResponse(await requestProjectFavicon(baseUrl, projectDir));
169254
});
170255
});
171256
});

0 commit comments

Comments
 (0)