Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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");
Expand Down Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/assets/project-favicon-default.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions apps/server/src/assets/project-favicon-svelte.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/server/src/assets/project-favicons.af
Binary file not shown.
13 changes: 13 additions & 0 deletions apps/server/src/projectFaviconAssets.ts
Original file line number Diff line number Diff line change
@@ -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");
Comment on lines +8 to +13
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readFileSync(...) at module import time will throw and crash the server process if the asset files are missing in the runtime filesystem (e.g., a build/publish pipeline forgets to copy dist/assets). Consider wrapping reads in a try/catch to throw a clearer error (including the expected path) or falling back to an inlined SVG string so the server can still boot.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, but we have a fallback on error

145 changes: 144 additions & 1 deletion apps/server/src/projectFaviconRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
'<link rel="icon" href="%sveltekit.assets%/favicon.png">',
"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");
Expand Down Expand Up @@ -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"),
"<div>%sveltekit.body%</div>",
"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"), "<svg>favicon-wins</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("<svg>favicon-wins</svg>");
});
});

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"),
'<link rel="icon" href="/brand/logo.svg">',
);
fs.writeFileSync(iconPath, "<svg>href-wins</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("<svg>href-wins</svg>");
});
});

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);
});
});
});
42 changes: 37 additions & 5 deletions apps/server/src/projectFaviconRoute.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".png": "image/png",
Expand All @@ -9,8 +14,6 @@ const FAVICON_MIME_TYPES: Record<string, string> = {
".ico": "image/x-icon",
};

const FALLBACK_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;

// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
"favicon.svg",
Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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]!);
Expand Down
Loading
Loading