Skip to content

Commit 584b7bd

Browse files
authored
Support absolute project icon URLs and more image formats (#452)
- Allow project icons to point at external image URLs and preserve query/hash on local paths - Expand favicon discovery and serving to cover common image types - Update settings copy and icon editor placeholders to reflect URL support
1 parent b440c45 commit 584b7bd

7 files changed

Lines changed: 186 additions & 15 deletions

File tree

apps/server/src/projectFaviconRoute.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
99
interface HttpResponse {
1010
statusCode: number;
1111
contentType: string | null;
12+
location: string | null;
1213
body: string;
1314
}
1415

@@ -61,11 +62,16 @@ async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise
6162
}
6263
}
6364

64-
async function request(baseUrl: string, pathname: string): Promise<HttpResponse> {
65-
const response = await fetch(`${baseUrl}${pathname}`);
65+
async function request(
66+
baseUrl: string,
67+
pathname: string,
68+
init?: Pick<RequestInit, "redirect">,
69+
): Promise<HttpResponse> {
70+
const response = await fetch(`${baseUrl}${pathname}`, init);
6671
return {
6772
statusCode: response.status,
6873
contentType: response.headers.get("content-type"),
74+
location: response.headers.get("location"),
6975
body: await response.text(),
7076
};
7177
}
@@ -113,6 +119,19 @@ describe("tryHandleProjectFaviconRequest", () => {
113119
});
114120
});
115121

122+
it("redirects to an explicit absolute icon override when provided", async () => {
123+
const projectDir = makeTempDir("okcode-favicon-route-absolute-override-");
124+
const remoteIconUrl = "https://cdn.example.com/assets/project-icon.gif?size=64";
125+
126+
await withRouteServer(async (baseUrl) => {
127+
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&icon=${encodeURIComponent(remoteIconUrl)}`;
128+
const response = await request(baseUrl, pathname, { redirect: "manual" });
129+
expect(response.statusCode).toBe(302);
130+
expect(response.location).toBe(remoteIconUrl);
131+
expect(response.body).toBe("");
132+
});
133+
});
134+
116135
it("resolves icon href from source files when no well-known favicon exists", async () => {
117136
const projectDir = makeTempDir("okcode-favicon-route-source-");
118137
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
@@ -132,6 +151,42 @@ describe("tryHandleProjectFaviconRequest", () => {
132151
});
133152
});
134153

154+
it("redirects to an absolute icon href discovered from source files", async () => {
155+
const projectDir = makeTempDir("okcode-favicon-route-absolute-source-");
156+
const remoteIconUrl = "https://cdn.example.com/brand/logo.jpeg?version=42";
157+
fs.writeFileSync(
158+
path.join(projectDir, "index.html"),
159+
`<link rel="icon" href="${remoteIconUrl}">`,
160+
);
161+
162+
await withRouteServer(async (baseUrl) => {
163+
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
164+
const response = await request(baseUrl, pathname, { redirect: "manual" });
165+
expect(response.statusCode).toBe(302);
166+
expect(response.location).toBe(remoteIconUrl);
167+
expect(response.body).toBe("");
168+
});
169+
});
170+
171+
it("resolves a local icon href discovered from source files with query params", async () => {
172+
const projectDir = makeTempDir("okcode-favicon-route-source-query-");
173+
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
174+
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
175+
fs.writeFileSync(
176+
path.join(projectDir, "index.html"),
177+
'<link rel="icon" href="/brand/logo.svg?v=123#cache-bust">',
178+
);
179+
fs.writeFileSync(iconPath, "<svg>brand-query</svg>", "utf8");
180+
181+
await withRouteServer(async (baseUrl) => {
182+
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
183+
const response = await request(baseUrl, pathname);
184+
expect(response.statusCode).toBe(200);
185+
expect(response.contentType).toContain("image/svg+xml");
186+
expect(response.body).toBe("<svg>brand-query</svg>");
187+
});
188+
});
189+
135190
it("resolves icon link when href appears before rel in HTML", async () => {
136191
const projectDir = makeTempDir("okcode-favicon-route-html-order-");
137192
const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
@@ -172,6 +227,19 @@ describe("tryHandleProjectFaviconRequest", () => {
172227
});
173228
});
174229

230+
it("serves common image types such as jpeg with the correct content type", async () => {
231+
const projectDir = makeTempDir("okcode-favicon-route-jpeg-");
232+
fs.writeFileSync(path.join(projectDir, "favicon.jpeg"), "jpeg-bits", "utf8");
233+
234+
await withRouteServer(async (baseUrl) => {
235+
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
236+
const response = await request(baseUrl, pathname);
237+
expect(response.statusCode).toBe(200);
238+
expect(response.contentType).toContain("image/jpeg");
239+
expect(response.body).toBe("jpeg-bits");
240+
});
241+
});
242+
175243
it("serves a fallback favicon when no icon exists", async () => {
176244
const projectDir = makeTempDir("okcode-favicon-route-fallback-");
177245

apps/server/src/projectFaviconRoute.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import path from "node:path";
44
import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons";
55

66
const FAVICON_MIME_TYPES: Record<string, string> = {
7+
".avif": "image/avif",
8+
".bmp": "image/bmp",
9+
".gif": "image/gif",
10+
".jpeg": "image/jpeg",
711
".png": "image/png",
812
".jpg": "image/jpeg",
913
".svg": "image/svg+xml",
14+
".webp": "image/webp",
1015
".ico": "image/x-icon",
1116
};
1217

@@ -25,9 +30,10 @@ const ICON_SOURCE_FILES = [
2530

2631
// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
2732
const LINK_ICON_HTML_RE =
28-
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
33+
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"']+))[^>]*>/i;
2934
const LINK_ICON_OBJ_RE =
30-
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
35+
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"']+))[^}]*/i;
36+
const ICON_URL_SCHEME_RE = /^[a-z][a-z\d+.-]*:/i;
3137

3238
function extractIconHref(source: string): string | null {
3339
const htmlMatch = source.match(LINK_ICON_HTML_RE);
@@ -37,8 +43,36 @@ function extractIconHref(source: string): string | null {
3743
return null;
3844
}
3945

40-
function resolveIconHref(projectCwd: string, href: string): string[] {
41-
const clean = href.replace(/^\//, "");
46+
function resolveExternalIconUrl(href: string): string | null {
47+
const trimmed = href.trim();
48+
if (trimmed.length === 0) return null;
49+
if (trimmed.startsWith("//")) return trimmed;
50+
try {
51+
const url = new URL(trimmed);
52+
if (url.protocol === "http:" || url.protocol === "https:") {
53+
return url.toString();
54+
}
55+
} catch {
56+
return null;
57+
}
58+
return null;
59+
}
60+
61+
function hasUnsupportedIconScheme(href: string): boolean {
62+
const trimmed = href.trim();
63+
return trimmed.length > 0 && !trimmed.startsWith("//") && ICON_URL_SCHEME_RE.test(trimmed);
64+
}
65+
66+
function stripHrefSearchAndHash(href: string): string {
67+
const queryIndex = href.indexOf("?");
68+
const hashIndex = href.indexOf("#");
69+
const cutIndex =
70+
queryIndex === -1 ? hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
71+
return (cutIndex === -1 ? href : href.slice(0, cutIndex)).trim();
72+
}
73+
74+
function resolveLocalIconHref(projectCwd: string, href: string): string[] {
75+
const clean = stripHrefSearchAndHash(href).replace(/^\//, "");
4276
return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
4377
}
4478

@@ -72,6 +106,14 @@ function serveFallbackFavicon(res: http.ServerResponse): void {
72106
res.end(FALLBACK_FAVICON_SVG);
73107
}
74108

109+
function redirectToFaviconUrl(iconUrl: string, res: http.ServerResponse): void {
110+
res.writeHead(302, {
111+
Location: iconUrl,
112+
"Cache-Control": "public, max-age=3600",
113+
});
114+
res.end();
115+
}
116+
75117
export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean {
76118
if (url.pathname !== "/api/project-favicon") {
77119
return false;
@@ -86,7 +128,16 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
86128

87129
const overrideIconPath = url.searchParams.get("icon");
88130
if (overrideIconPath) {
89-
const candidates = resolveIconHref(projectCwd, overrideIconPath);
131+
const externalIconUrl = resolveExternalIconUrl(overrideIconPath);
132+
if (externalIconUrl) {
133+
redirectToFaviconUrl(externalIconUrl, res);
134+
return true;
135+
}
136+
if (hasUnsupportedIconScheme(overrideIconPath)) {
137+
serveFallbackFavicon(res);
138+
return true;
139+
}
140+
const candidates = resolveLocalIconHref(projectCwd, overrideIconPath);
90141
const serveOverrideOrFallback = (index: number): void => {
91142
if (index >= candidates.length) {
92143
serveFallbackFavicon(res);
@@ -144,7 +195,16 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
144195
trySourceFiles(index + 1);
145196
return;
146197
}
147-
const candidates = resolveIconHref(projectCwd, href);
198+
const externalIconUrl = resolveExternalIconUrl(href);
199+
if (externalIconUrl) {
200+
redirectToFaviconUrl(externalIconUrl, res);
201+
return;
202+
}
203+
if (hasUnsupportedIconScheme(href)) {
204+
trySourceFiles(index + 1);
205+
return;
206+
}
207+
const candidates = resolveLocalIconHref(projectCwd, href);
148208
tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1));
149209
});
150210
};

apps/web/src/components/ProjectIconEditorDialog.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ export function ProjectIconEditorDialog({
104104
<DialogHeader>
105105
<DialogTitle>Project icon</DialogTitle>
106106
<DialogDescription>
107-
Set a path relative to the project root. Leave it blank to fall back to the detected
108-
favicon or icon file.
107+
Set a path relative to the project root or an absolute image URL. Leave it blank to fall
108+
back to the detected favicon or icon file.
109109
</DialogDescription>
110110
</DialogHeader>
111111

@@ -142,7 +142,9 @@ export function ProjectIconEditorDialog({
142142
draftWasTouchedRef.current = true;
143143
setDraft(event.target.value);
144144
}}
145-
placeholder={suggestedIconPath ?? "public/favicon.svg"}
145+
placeholder={
146+
suggestedIconPath ?? "public/favicon.svg or https://example.com/icon.png"
147+
}
146148
autoComplete="off"
147149
spellCheck={false}
148150
/>

apps/web/src/lib/projectIcons.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "./pro
66
describe("project icon helpers", () => {
77
it("normalizes icon paths by trimming and treating blanks as null", () => {
88
expect(normalizeProjectIconPath(" public/icon.svg ")).toBe("public/icon.svg");
9+
expect(normalizeProjectIconPath(" https://cdn.example.com/icon.gif ")).toBe(
10+
"https://cdn.example.com/icon.gif",
11+
);
912
expect(normalizeProjectIconPath(" ")).toBeNull();
1013
expect(normalizeProjectIconPath(null)).toBeNull();
1114
});

apps/web/src/routes/_chat.settings.index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ function SettingsRouteView() {
18961896

18971897
<SettingsRow
18981898
title="Project icon"
1899-
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root, such as `public/icon.svg`."
1899+
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root or an absolute image URL, such as `public/icon.svg` or `https://example.com/icon.png`."
19001900
status={
19011901
selectedProject ? (
19021902
<span className="inline-flex items-center gap-2 text-[11px] text-muted-foreground">
@@ -1930,7 +1930,7 @@ function SettingsRouteView() {
19301930
setProjectIconDraft(selectedProject?.iconPath ?? "");
19311931
}
19321932
}}
1933-
placeholder="public/icon.svg"
1933+
placeholder="public/icon.svg or https://example.com/icon.png"
19341934
className="w-full sm:w-64"
19351935
aria-label="Project icon path"
19361936
disabled={!selectedProject}

apps/web/src/routes/_chat.settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3499,7 +3499,7 @@ function SettingsRouteView() {
34993499

35003500
<SettingsRow
35013501
title="Project icon"
3502-
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root, such as `public/icon.svg`."
3502+
description="Override the icon shown next to this project in the sidebar. Use a path relative to the project root or an absolute image URL, such as `public/icon.svg` or `https://example.com/icon.png`."
35033503
status={
35043504
selectedProject ? (
35053505
<span className="inline-flex items-center gap-2 text-[11px] text-muted-foreground">
@@ -3533,7 +3533,7 @@ function SettingsRouteView() {
35333533
setProjectIconDraft(selectedProject?.iconPath ?? "");
35343534
}
35353535
}}
3536-
placeholder="public/icon.svg"
3536+
placeholder="public/icon.svg or https://example.com/icon.png"
35373537
className="w-full sm:w-64"
35383538
aria-label="Project icon path"
35393539
disabled={!selectedProject}

packages/shared/src/projectIcons.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,61 @@ export const PROJECT_ICON_FALLBACK_CANDIDATES = [
22
"favicon.svg",
33
"favicon.ico",
44
"favicon.png",
5+
"favicon.jpg",
6+
"favicon.jpeg",
7+
"favicon.gif",
8+
"favicon.webp",
59
"public/favicon.svg",
610
"public/favicon.ico",
711
"public/favicon.png",
12+
"public/favicon.jpg",
13+
"public/favicon.jpeg",
14+
"public/favicon.gif",
15+
"public/favicon.webp",
816
"app/favicon.ico",
917
"app/favicon.png",
18+
"app/favicon.jpg",
19+
"app/favicon.jpeg",
20+
"app/favicon.gif",
21+
"app/favicon.webp",
1022
"app/icon.svg",
1123
"app/icon.png",
24+
"app/icon.jpg",
25+
"app/icon.jpeg",
26+
"app/icon.gif",
27+
"app/icon.webp",
1228
"app/icon.ico",
1329
"src/favicon.ico",
1430
"src/favicon.svg",
31+
"src/favicon.png",
32+
"src/favicon.jpg",
33+
"src/favicon.jpeg",
34+
"src/favicon.gif",
35+
"src/favicon.webp",
1536
"src/app/favicon.ico",
37+
"src/app/favicon.png",
38+
"src/app/favicon.jpg",
39+
"src/app/favicon.jpeg",
40+
"src/app/favicon.gif",
41+
"src/app/favicon.webp",
1642
"src/app/icon.svg",
1743
"src/app/icon.png",
44+
"src/app/icon.jpg",
45+
"src/app/icon.jpeg",
46+
"src/app/icon.gif",
47+
"src/app/icon.webp",
1848
"assets/icon.svg",
1949
"assets/icon.png",
50+
"assets/icon.jpg",
51+
"assets/icon.jpeg",
52+
"assets/icon.gif",
53+
"assets/icon.webp",
2054
"assets/logo.svg",
2155
"assets/logo.png",
56+
"assets/logo.jpg",
57+
"assets/logo.jpeg",
58+
"assets/logo.gif",
59+
"assets/logo.webp",
2260
] as const;
2361

2462
export const PROJECT_ICON_DISCOVERY_QUERIES = ["favicon", "icon", "logo"] as const;

0 commit comments

Comments
 (0)