diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index 34a430d..ac61dee 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -153,15 +153,53 @@ describe("tryHandleProjectFaviconRequest", () => { }); }); - it("serves a fallback favicon when no icon exists", async () => { - const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + it("resolves a favicon from a web app inside apps/", async () => { + const projectDir = makeTempDir("t3code-favicon-route-apps-root-"); + const appDir = path.join(projectDir, "apps", "web"); + const iconPath = path.join(appDir, "public", "favicon.ico"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.writeFileSync(iconPath, "web-app-favicon", "utf8"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(200); + expect(response.contentType).toContain("image/x-icon"); + expect(response.body).toBe("web-app-favicon"); + }); + }); + + it("searches apps/ recursively for nested web apps", async () => { + const projectDir = makeTempDir("t3code-favicon-route-apps-recursive-"); + const appDir = path.join(projectDir, "apps", "client", "site"); + const iconPath = path.join(appDir, "public", "brand", "logo.svg"); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.mkdirSync(path.join(appDir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(appDir, "index.html"), + '', + "utf8", + ); + fs.writeFileSync(iconPath, "nested-brand", "utf8"); await withRouteServer(async (baseUrl) => { const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; const response = await request(baseUrl, pathname); expect(response.statusCode).toBe(200); expect(response.contentType).toContain("image/svg+xml"); - expect(response.body).toContain('data-fallback="project-favicon"'); + expect(response.body).toBe("nested-brand"); + }); + }); + + it("returns 204 when no favicon exists", async () => { + const projectDir = makeTempDir("t3code-favicon-route-fallback-"); + + await withRouteServer(async (baseUrl) => { + const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; + const response = await request(baseUrl, pathname); + expect(response.statusCode).toBe(204); + expect(response.contentType).toBeNull(); + expect(response.body).toBe(""); }); }); }); diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cf234ad..c20ff75 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import fsPromises from "node:fs/promises"; import http from "node:http"; import path from "node:path"; @@ -9,16 +10,18 @@ const FAVICON_MIME_TYPES: Record = { ".ico": "image/x-icon", }; -const FALLBACK_FAVICON_SVG = ``; - // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", "favicon.ico", "favicon.png", + "favicon-32x32.png", + "favicon-16x16.png", "public/favicon.svg", "public/favicon.ico", "public/favicon.png", + "public/favicon-32x32.png", + "public/favicon-16x16.png", "app/favicon.ico", "app/favicon.png", "app/icon.svg", @@ -52,6 +55,28 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +const IGNORED_APP_SEARCH_DIRECTORIES = new Set([ + ".git", + ".next", + ".turbo", + "build", + "dist", + "node_modules", + "out", +]); +const APP_ROOT_MARKER_DIRECTORIES = new Set(["app", "public", "src"]); +const APP_ROOT_MARKER_FILES = new Set(["index.html", "package.json"]); +const APP_ROOT_MARKER_FILE_PREFIXES = [ + "angular.", + "astro.config.", + "next.config.", + "nuxt.config.", + "remix.config.", + "svelte.config.", + "vite.config.", +]; +const APPS_SEARCH_MAX_DEPTH = 4; + function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; @@ -70,6 +95,134 @@ function isPathWithinProject(projectCwd: string, candidatePath: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +async function isFile(filePath: string): Promise { + try { + const stats = await fsPromises.stat(filePath); + return stats.isFile(); + } catch { + return false; + } +} + +async function resolveFaviconFromProjectRoot(projectCwd: string): Promise { + for (const relativeCandidate of FAVICON_CANDIDATES) { + const candidatePath = path.join(projectCwd, relativeCandidate); + if (!isPathWithinProject(projectCwd, candidatePath)) { + continue; + } + if (await isFile(candidatePath)) { + return candidatePath; + } + } + + for (const relativeSourceFile of ICON_SOURCE_FILES) { + const sourceFilePath = path.join(projectCwd, relativeSourceFile); + let content: string; + try { + content = await fsPromises.readFile(sourceFilePath, "utf8"); + } catch { + continue; + } + + const href = extractIconHref(content); + if (!href) { + continue; + } + + for (const resolvedPath of resolveIconHref(projectCwd, href)) { + if (!isPathWithinProject(projectCwd, resolvedPath)) { + continue; + } + if (await isFile(resolvedPath)) { + return resolvedPath; + } + } + } + + return null; +} + +function looksLikeWebAppRoot(entries: readonly fs.Dirent[]): boolean { + for (const entry of entries) { + if (entry.isDirectory() && APP_ROOT_MARKER_DIRECTORIES.has(entry.name)) { + return true; + } + if (!entry.isFile()) { + continue; + } + if (APP_ROOT_MARKER_FILES.has(entry.name)) { + return true; + } + if (APP_ROOT_MARKER_FILE_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) { + return true; + } + } + return false; +} + +async function collectNestedAppRoots( + directoryPath: string, + depth: number, + roots: string[], +): Promise { + let entries: fs.Dirent[]; + try { + entries = await fsPromises.readdir(directoryPath, { withFileTypes: true }); + } catch { + return; + } + + const sortedEntries = entries.toSorted((left, right) => left.name.localeCompare(right.name)); + if (depth > 0 && looksLikeWebAppRoot(sortedEntries)) { + roots.push(directoryPath); + return; + } + + if (depth >= APPS_SEARCH_MAX_DEPTH) { + return; + } + + for (const entry of sortedEntries) { + if ( + !entry.isDirectory() || + IGNORED_APP_SEARCH_DIRECTORIES.has(entry.name) || + entry.name.startsWith(".") + ) { + continue; + } + await collectNestedAppRoots(path.join(directoryPath, entry.name), depth + 1, roots); + } +} + +async function resolveProjectFaviconPath(projectCwd: string): Promise { + const faviconFromRoot = await resolveFaviconFromProjectRoot(projectCwd); + if (faviconFromRoot) { + return faviconFromRoot; + } + + const appsDirectoryPath = path.join(projectCwd, "apps"); + let appsDirectoryStats: fs.Stats; + try { + appsDirectoryStats = await fsPromises.stat(appsDirectoryPath); + } catch { + return null; + } + if (!appsDirectoryStats.isDirectory()) { + return null; + } + + const nestedAppRoots: string[] = []; + await collectNestedAppRoots(appsDirectoryPath, 0, nestedAppRoots); + for (const appRoot of nestedAppRoots) { + const faviconFromApp = await resolveFaviconFromProjectRoot(appRoot); + if (faviconFromApp) { + return faviconFromApp; + } + } + + return null; +} + function serveFaviconFile(filePath: string, res: http.ServerResponse): void { const ext = path.extname(filePath).toLowerCase(); const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; @@ -87,12 +240,9 @@ function serveFaviconFile(filePath: string, res: http.ServerResponse): void { }); } -function serveFallbackFavicon(res: http.ServerResponse): void { - res.writeHead(200, { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=3600", - }); - res.end(FALLBACK_FAVICON_SVG); +function serveMissingFavicon(res: http.ServerResponse): void { + res.writeHead(204, { "Cache-Control": "no-store" }); + res.end(); } export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { @@ -107,65 +257,17 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons return true; } - const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { - if (index >= paths.length) { - onExhausted(); - return; - } - const candidate = paths[index]!; - if (!isPathWithinProject(projectCwd, candidate)) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryResolvedPaths(paths, index + 1, onExhausted); + void resolveProjectFaviconPath(projectCwd) + .then((faviconPath) => { + if (!faviconPath) { + serveMissingFavicon(res); return; } - serveFaviconFile(candidate, res); - }); - }; - - const trySourceFiles = (index: number): void => { - if (index >= ICON_SOURCE_FILES.length) { - serveFallbackFavicon(res); - return; - } - const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); - fs.readFile(sourceFile, "utf8", (err, content) => { - if (err) { - trySourceFiles(index + 1); - return; - } - const href = extractIconHref(content); - if (!href) { - trySourceFiles(index + 1); - return; - } - const candidates = resolveIconHref(projectCwd, href); - tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); - }); - }; - - const tryCandidates = (index: number): void => { - if (index >= FAVICON_CANDIDATES.length) { - trySourceFiles(0); - return; - } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); - if (!isPathWithinProject(projectCwd, candidate)) { - tryCandidates(index + 1); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryCandidates(index + 1); - return; - } - serveFaviconFile(candidate, res); + serveFaviconFile(faviconPath, res); + }) + .catch(() => { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Failed to resolve favicon"); }); - }; - - tryCandidates(0); return true; } diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 83389c0..6693437 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,5 +1,5 @@ -import { FolderIcon } from "lucide-react"; -import { useState } from "react"; +import { FolderIcon, type LucideIcon } from "lucide-react"; +import { useEffect, useState } from "react"; import { cn } from "../lib/utils"; function getServerHttpOrigin(): string { @@ -25,13 +25,19 @@ export function ProjectFavicon(props: { cwd: string; className?: string; fallbackClassName?: string; + fallbackIcon?: LucideIcon; }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(props.cwd)}`; + const FallbackIcon = props.fallbackIcon ?? FolderIcon; + + useEffect(() => { + setStatus("loading"); + }, [src]); if (status === "error") { return ( - ); @@ -46,7 +52,10 @@ export function ProjectFavicon(props: { status === "loading" && "hidden", props.className, )} - onLoad={() => setStatus("loaded")} + onLoad={(event) => { + const image = event.currentTarget; + setStatus(image.naturalWidth > 0 || image.naturalHeight > 0 ? "loaded" : "error"); + }} onError={() => setStatus("error")} /> ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e0792fa..6ebc5e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -82,6 +82,7 @@ import { SidebarContextMenu, type SidebarContextMenuEntry, } from "./SidebarContextMenu"; +import { ProjectFavicon } from "./ProjectFavicon"; import { SidebarContent, SidebarFooter, @@ -462,8 +463,11 @@ const SidebarProjectSection = memo(function SidebarProjectSection(props: { }); }} > -