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"), "favicon-wins", "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("favicon-wins"); + }); + }); + + 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, "href-wins", "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("href-wins"); + }); + }); + + 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" })} + /> + ); }