diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts
index 21bc515aa..05e3cc6ce 100644
--- a/apps/server/scripts/cli.ts
+++ b/apps/server/scripts/cli.ts
@@ -40,6 +40,11 @@ interface PublishIconBackup {
readonly backupPath: string;
}
+const PROJECT_FAVICON_ASSET_FILE_NAMES = [
+ "project-favicon-default.svg",
+ "project-favicon-svelte.svg",
+] as const;
+
const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(function* (
repoRoot: string,
serverDir: string,
@@ -113,6 +118,32 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")
yield* Effect.log("[cli] Applied development icon overrides to dist/client");
});
+const copyProjectFaviconAssets = Effect.fn("copyProjectFaviconAssets")(function* (
+ serverDir: string,
+) {
+ const path = yield* Path.Path;
+ const fs = yield* FileSystem.FileSystem;
+ const sourceDir = path.join(serverDir, "src/assets");
+ const targetDir = path.join(serverDir, "dist/assets");
+
+ yield* fs.makeDirectory(targetDir, { recursive: true });
+
+ for (const fileName of PROJECT_FAVICON_ASSET_FILE_NAMES) {
+ const sourcePath = path.join(sourceDir, fileName);
+ const targetPath = path.join(targetDir, fileName);
+
+ if (!(yield* fs.exists(sourcePath))) {
+ return yield* new CliError({
+ message: `Missing project favicon asset: ${sourcePath}`,
+ });
+ }
+
+ yield* fs.copyFile(sourcePath, targetPath);
+ }
+
+ yield* Effect.log("[cli] Copied project favicon assets into dist/assets");
+});
+
// ---------------------------------------------------------------------------
// build subcommand
// ---------------------------------------------------------------------------
@@ -139,6 +170,7 @@ const buildCmd = Command.make(
shell: process.platform === "win32",
})`bun tsdown`,
);
+ yield* copyProjectFaviconAssets(serverDir);
const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");
@@ -177,7 +209,12 @@ const publishCmd = Command.make(
const backupPath = `${packageJsonPath}.bak`;
// Assert build assets exist
- for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) {
+ for (const relPath of [
+ "dist/index.mjs",
+ "dist/client/index.html",
+ "dist/assets/project-favicon-default.svg",
+ "dist/assets/project-favicon-svelte.svg",
+ ]) {
const abs = path.join(serverDir, relPath);
if (!(yield* fs.exists(abs))) {
return yield* new CliError({
diff --git a/apps/server/src/assets/project-favicon-default.svg b/apps/server/src/assets/project-favicon-default.svg
new file mode 100644
index 000000000..83fabee2d
--- /dev/null
+++ b/apps/server/src/assets/project-favicon-default.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/server/src/assets/project-favicon-svelte.svg b/apps/server/src/assets/project-favicon-svelte.svg
new file mode 100644
index 000000000..6810494ee
--- /dev/null
+++ b/apps/server/src/assets/project-favicon-svelte.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/apps/server/src/assets/project-favicons.af b/apps/server/src/assets/project-favicons.af
new file mode 100644
index 000000000..d868ff02f
Binary files /dev/null and b/apps/server/src/assets/project-favicons.af differ
diff --git a/apps/server/src/projectFaviconAssets.ts b/apps/server/src/projectFaviconAssets.ts
new file mode 100644
index 000000000..68969b02b
--- /dev/null
+++ b/apps/server/src/projectFaviconAssets.ts
@@ -0,0 +1,13 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const ASSETS_DIR = path.join(CURRENT_DIR, "assets");
+
+function readSvgAsset(fileName: string): string {
+ return fs.readFileSync(path.join(ASSETS_DIR, fileName), "utf8");
+}
+
+export const DEFAULT_PROJECT_FAVICON_SVG = readSvgAsset("project-favicon-default.svg");
+export const SVELTE_PROJECT_FAVICON_SVG = readSvgAsset("project-favicon-svelte.svg");
diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts
index a346e513e..89af13f80 100644
--- a/apps/server/src/projectFaviconRoute.test.ts
+++ b/apps/server/src/projectFaviconRoute.test.ts
@@ -4,6 +4,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
+import {
+ DEFAULT_PROJECT_FAVICON_SVG,
+ SVELTE_PROJECT_FAVICON_SVG,
+} from "./projectFaviconAssets";
import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
interface HttpResponse {
@@ -136,6 +140,27 @@ describe("tryHandleProjectFaviconRequest", () => {
});
});
+ it("resolves SvelteKit asset placeholders from src/app.html", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-sveltekit-assets-");
+ const iconPath = path.join(projectDir, "static", "favicon.png");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectDir, "src", "app.html"),
+ '',
+ "utf8",
+ );
+ fs.writeFileSync(iconPath, "sveltekit-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/png");
+ expect(response.body).toBe("sveltekit-favicon");
+ });
+ });
+
it("resolves object-style icon metadata when href appears before rel", async () => {
const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
@@ -165,7 +190,125 @@ describe("tryHandleProjectFaviconRequest", () => {
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(DEFAULT_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("returns the Svelte fallback when svelte.config.ts exists and no favicon exists", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-config-");
+ fs.writeFileSync(path.join(projectDir, "svelte.config.ts"), "export default {};\n", "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).toBe(SVELTE_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("returns the Svelte fallback when package.json declares svelte", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-package-");
+ fs.writeFileSync(
+ path.join(projectDir, "package.json"),
+ JSON.stringify({ dependencies: { svelte: "^5.0.0" } }),
+ "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.body).toBe(SVELTE_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("returns the Svelte fallback when package.json declares @sveltejs/kit", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-kit-package-");
+ fs.writeFileSync(
+ path.join(projectDir, "package.json"),
+ JSON.stringify({ devDependencies: { "@sveltejs/kit": "^2.0.0" } }),
+ "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.body).toBe(SVELTE_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("returns the Svelte fallback when src/app.html exists", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-app-html-");
+ fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectDir, "src", "app.html"),
+ "
%sveltekit.body%
",
+ "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.body).toBe(SVELTE_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("returns the Svelte fallback when a .svelte-kit directory exists", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-kit-dir-");
+ fs.mkdirSync(path.join(projectDir, ".svelte-kit"), { recursive: true });
+
+ 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.body).toBe(SVELTE_PROJECT_FAVICON_SVG);
+ });
+ });
+
+ it("prefers a real favicon over the Svelte fallback", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-real-favicon-");
+ fs.writeFileSync(path.join(projectDir, "svelte.config.ts"), "export default {};\n", "utf8");
+ fs.writeFileSync(path.join(projectDir, "favicon.svg"), "", "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.body).toBe("");
+ });
+ });
+
+ it("prefers a resolved icon href over the Svelte fallback", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-svelte-href-");
+ const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.writeFileSync(path.join(projectDir, "svelte.config.ts"), "export default {};\n", "utf8");
+ fs.writeFileSync(
+ path.join(projectDir, "index.html"),
+ '',
+ );
+ fs.writeFileSync(iconPath, "", "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.body).toBe("");
+ });
+ });
+
+ it("keeps the generic fallback for non-Svelte projects", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-generic-fallback-");
+ fs.writeFileSync(path.join(projectDir, "package.json"), "{not valid json", "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.body).toBe(DEFAULT_PROJECT_FAVICON_SVG);
});
});
});
diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts
index cf234ad89..6fdfe6824 100644
--- a/apps/server/src/projectFaviconRoute.ts
+++ b/apps/server/src/projectFaviconRoute.ts
@@ -1,6 +1,11 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
+import {
+ DEFAULT_PROJECT_FAVICON_SVG,
+ SVELTE_PROJECT_FAVICON_SVG,
+} from "./projectFaviconAssets";
+import { detectProjectIconFallback } from "./projectIconFallback";
const FAVICON_MIME_TYPES: Record = {
".png": "image/png",
@@ -9,8 +14,6 @@ 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",
@@ -39,6 +42,7 @@ const FAVICON_CANDIDATES = [
const ICON_SOURCE_FILES = [
"index.html",
"public/index.html",
+ "src/app.html",
"app/routes/__root.tsx",
"src/routes/__root.tsx",
"app/root.tsx",
@@ -61,8 +65,18 @@ function extractIconHref(source: string): string | null {
}
function resolveIconHref(projectCwd: string, href: string): string[] {
+ const sveltekitAssetsPrefix = "%sveltekit.assets%/";
+ if (href.startsWith(sveltekitAssetsPrefix)) {
+ const clean = href.slice(sveltekitAssetsPrefix.length);
+ return [path.join(projectCwd, "static", clean), path.join(projectCwd, clean)];
+ }
+
const clean = href.replace(/^\//, "");
- return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
+ return [
+ path.join(projectCwd, "public", clean),
+ path.join(projectCwd, "static", clean),
+ path.join(projectCwd, clean),
+ ];
}
function isPathWithinProject(projectCwd: string, candidatePath: string): boolean {
@@ -88,11 +102,29 @@ function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
}
function serveFallbackFavicon(res: http.ServerResponse): void {
+ serveInlineSvg(DEFAULT_PROJECT_FAVICON_SVG, res);
+}
+
+function serveInlineSvg(svg: string, res: http.ServerResponse): void {
res.writeHead(200, {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=3600",
});
- res.end(FALLBACK_FAVICON_SVG);
+ res.end(svg);
+}
+
+function serveDetectedFallback(projectCwd: string, res: http.ServerResponse): void {
+ void detectProjectIconFallback(projectCwd)
+ .then((fallbackKind) => {
+ if (fallbackKind === "svelte") {
+ serveInlineSvg(SVELTE_PROJECT_FAVICON_SVG, res);
+ return;
+ }
+ serveFallbackFavicon(res);
+ })
+ .catch(() => {
+ serveFallbackFavicon(res);
+ });
}
export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean {
@@ -128,7 +160,7 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
const trySourceFiles = (index: number): void => {
if (index >= ICON_SOURCE_FILES.length) {
- serveFallbackFavicon(res);
+ serveDetectedFallback(projectCwd, res);
return;
}
const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!);
diff --git a/apps/server/src/projectIconFallback.ts b/apps/server/src/projectIconFallback.ts
new file mode 100644
index 000000000..7727f26e4
--- /dev/null
+++ b/apps/server/src/projectIconFallback.ts
@@ -0,0 +1,85 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+export type ProjectIconFallbackKind = "svelte";
+
+const SVELTE_MARKER_FILES = [
+ "svelte.config.js",
+ "svelte.config.ts",
+ "svelte.config.mjs",
+ "svelte.config.cjs",
+] as const;
+const SVELTE_PACKAGE_MARKERS = ["svelte", "@sveltejs/kit"] as const;
+
+interface PackageJsonLike {
+ dependencies?: Record;
+ devDependencies?: Record;
+ peerDependencies?: Record;
+}
+
+async function isFile(filePath: string): Promise {
+ try {
+ return (await fs.stat(filePath)).isFile();
+ } catch {
+ return false;
+ }
+}
+
+async function isDirectory(directoryPath: string): Promise {
+ try {
+ return (await fs.stat(directoryPath)).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function hasPackageMarker(dependencies: unknown): boolean {
+ if (!dependencies || typeof dependencies !== "object") {
+ return false;
+ }
+
+ return SVELTE_PACKAGE_MARKERS.some((packageName) =>
+ Object.prototype.hasOwnProperty.call(dependencies, packageName),
+ );
+}
+
+async function hasSveltePackageMarker(projectCwd: string): Promise {
+ const packageJsonPath = path.join(projectCwd, "package.json");
+
+ let packageJson: PackageJsonLike;
+ try {
+ packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageJsonLike;
+ } catch {
+ return false;
+ }
+
+ return (
+ hasPackageMarker(packageJson.dependencies) ||
+ hasPackageMarker(packageJson.devDependencies) ||
+ hasPackageMarker(packageJson.peerDependencies)
+ );
+}
+
+export async function detectProjectIconFallback(
+ projectCwd: string,
+): Promise {
+ for (const markerFile of SVELTE_MARKER_FILES) {
+ if (await isFile(path.join(projectCwd, markerFile))) {
+ return "svelte";
+ }
+ }
+
+ if (await isDirectory(path.join(projectCwd, ".svelte-kit"))) {
+ return "svelte";
+ }
+
+ if (await isFile(path.join(projectCwd, "src", "app.html"))) {
+ return "svelte";
+ }
+
+ if (await hasSveltePackageMarker(projectCwd)) {
+ return "svelte";
+ }
+
+ return null;
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 8b68c3b80..d65a3ab9b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -204,22 +204,33 @@ function getServerHttpOrigin(): string {
const serverHttpOrigin = getServerHttpOrigin();
function ProjectFavicon({ cwd }: { cwd: string }) {
- const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
-
const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
+ const [imageState, setImageState] = useState<{
+ src: string;
+ status: "loading" | "loaded" | "error";
+ }>({
+ src: "",
+ status: "loading",
+ });
- if (status === "error") {
- return ;
- }
+ const status = imageState.src === src ? imageState.status : "loading";
return (
-
setStatus("loaded")}
- onError={() => setStatus("error")}
- />
+
+ {status === "error" ? (
+
+ ) : null}
+
setImageState({ src, status: "loaded" })}
+ onError={() => setImageState({ src, status: "error" })}
+ />
+
);
}