From 12e6e2bc01036a83a3d24b26a32c4063a4c43de1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 27 Jul 2026 14:48:24 +0200 Subject: [PATCH 1/2] feat(server): expand project favicon resolution with PWA manifest and directory scan Adds PWA manifest.json/site.webmanifest icon parsing with size-aware selection and a directory-scan fallback over common asset folders. Includes tests for manifest and directory-scan detection paths. --- .../project/ProjectFaviconResolver.test.ts | 71 +++++ .../src/project/ProjectFaviconResolver.ts | 254 ++++++++++++++++++ 2 files changed, 325 insertions(+) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a50..0b62e8b40120 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -256,5 +256,76 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toContain("public/brand/logo.svg"); }), ); + + it.effect("resolves icon from PWA manifest.json", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }], + }), + ); + yield* writeTextFile(cwd, "public/icon-512.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/icon-512.png"); + }), + ); + + it.effect("prefers larger manifest icon by declared size", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + }), + ); + yield* writeTextFile(cwd, "public/icon-192.png", "png"); + yield* writeTextFile(cwd, "public/icon-512.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/icon-512.png"); + }), + ); + + it.effect("falls back to directory scan for common asset folders", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("directory scan respects name and extension priority", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + yield* writeTextFile(cwd, "images/favicon.ico", "ico"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/favicon.ico"); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630b..2d0e6665c92d 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -54,6 +54,47 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; +// Manifest files that may declare PWA icons. +const MANIFEST_SOURCE_FILES = [ + "manifest.json", + "public/manifest.json", + "site.webmanifest", + "public/site.webmanifest", +] as const; + +// Directories scanned as a fallback for common icon filenames. +const IMAGE_DIR_CANDIDATES = [ + "", + "public", + "app", + "src", + "src/app", + "assets", + "src/assets", + "assets/icons", + "assets/icon", + "static", + "resources", + "images", + "img", + "media", + "app-icon", + ".idea", +] as const; + +const IMAGE_NAME_CANDIDATES = [ + "favicon", + "icon", + "logo", + "apple-touch-icon", + "app-icon", + "icon-rounded", + "brand", + "app", +] as const; + +const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".ico"] as const; + // Matches tags or object-like icon metadata where rel/href can appear in any order. const LINK_ICON_HTML_RE = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; @@ -68,6 +109,8 @@ export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => @@ -125,6 +174,8 @@ export const make = Effect.gen(function* () { return [path.join("public", clean), clean]; }; + const toPosixRelativePath = (input: string): string => input.replaceAll("\\", "/"); + const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, @@ -166,6 +217,197 @@ export const make = Effect.gen(function* () { return null; }); + const findBestIconInDirectory = Effect.fn("ProjectFaviconResolver.findBestIconInDirectory")( + function* ( + projectCwd: string, + dir: string, + candidatePriority: ReadonlyMap< + string, + { readonly dirIdx: number; readonly nameIdx: number; readonly extIdx: number } + >, + ): Effect.fn.Return< + { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null, + ProjectFaviconResolutionError + > { + const absoluteDir = path.resolve(projectCwd, dir); + const relativeToRoot = toPosixRelativePath(path.relative(projectCwd, absoluteDir)); + if ( + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return null; + } + + const entries = yield* optionOnNotFound(fileSystem.readDirectory(absoluteDir)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "scan-directory", + workspaceRoot: projectCwd, + relativePath: dir, + absolutePath: absoluteDir, + cause, + }), + ), + ); + if (Option.isNone(entries)) return null; + + let best: { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null = null; + + for (const entry of entries.value) { + const priority = candidatePriority.get(entry); + if (!priority) continue; + + const absolutePath = path.join(absoluteDir, entry); + const relativePath = dir ? path.join(dir, entry) : entry; + const stats = yield* optionOnNotFound(fileSystem.stat(absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: projectCwd, + relativePath, + absolutePath, + cause, + }), + ), + ); + if (Option.isNone(stats) || stats.value.type !== "File") continue; + + if ( + !best || + priority.dirIdx < best.priority.dirIdx || + (priority.dirIdx === best.priority.dirIdx && priority.nameIdx < best.priority.nameIdx) || + (priority.dirIdx === best.priority.dirIdx && + priority.nameIdx === best.priority.nameIdx && + priority.extIdx < best.priority.extIdx) + ) { + best = { absolutePath, relativePath, priority }; + } + } + + return best; + }, + ); + + const findIconByDirectoryScan = Effect.fn("ProjectFaviconResolver.findIconByDirectoryScan")( + function* (projectCwd: string): Effect.fn.Return { + const candidatePriority = new Map< + string, + { readonly dirIdx: number; readonly nameIdx: number; readonly extIdx: number } + >(); + IMAGE_DIR_CANDIDATES.forEach((candidateDir, dirIdx) => { + IMAGE_NAME_CANDIDATES.forEach((name, nameIdx) => { + IMAGE_EXTENSIONS.forEach((ext, extIdx) => { + candidatePriority.set(`${name}${ext}`, { dirIdx, nameIdx, extIdx }); + }); + }); + }); + + let best: { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null = null; + + for (const candidateDir of IMAGE_DIR_CANDIDATES) { + const match = yield* findBestIconInDirectory(projectCwd, candidateDir, candidatePriority); + if (!match) continue; + if ( + !best || + match.priority.dirIdx < best.priority.dirIdx || + (match.priority.dirIdx === best.priority.dirIdx && + match.priority.nameIdx < best.priority.nameIdx) || + (match.priority.dirIdx === best.priority.dirIdx && + match.priority.nameIdx === best.priority.nameIdx && + match.priority.extIdx < best.priority.extIdx) + ) { + best = match; + } + } + + return best ? best.absolutePath : null; + }, + ); + + const resolveManifestIcon = Effect.fn("ProjectFaviconResolver.resolveManifestIcon")(function* ( + projectCwd: string, + relativePath: string, + ): Effect.fn.Return { + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath, + cause, + }), + ), + ); + const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-manifest", + workspaceRoot: projectCwd, + relativePath, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) return null; + + let manifest: { icons?: ReadonlyArray<{ src?: string; sizes?: string }> }; + try { + manifest = JSON.parse(source.value) as typeof manifest; + } catch { + return null; + } + if (!Array.isArray(manifest.icons)) return null; + + let bestIcon: string | null = null; + let bestSize = -1; + for (const icon of manifest.icons) { + if (typeof icon.src !== "string") continue; + const existing = yield* findExistingFile(projectCwd, resolveIconHref(icon.src)); + if (!existing) continue; + const size = parseIconSize(icon.sizes); + if (size > bestSize) { + bestIcon = existing; + bestSize = size; + } + } + return bestIcon; + }); + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { @@ -239,6 +481,18 @@ export const make = Effect.gen(function* () { } } + for (const manifestFile of MANIFEST_SOURCE_FILES) { + const existing = yield* resolveManifestIcon(projectCwd, manifestFile); + if (existing) { + return existing; + } + } + + const scanned = yield* findIconByDirectoryScan(projectCwd); + if (scanned) { + return scanned; + } + return null; }); From 709dbded05943262f68439ddfb9c3d17601cdd70 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 27 Jul 2026 15:20:19 +0200 Subject: [PATCH 2/2] fix(server): address favicon resolver review findings - parseIconSize now scans every size token and returns the largest - candidatePriority keys include the directory so directory order wins - resolveManifestIcon tolerates valid JSON null/non-object manifests - directory scan skips unreadable directories instead of aborting --- .../project/ProjectFaviconResolver.test.ts | 83 +++++++++++++++++++ .../src/project/ProjectFaviconResolver.ts | 48 ++++------- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 0b62e8b40120..0fddb10826fc 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -327,5 +327,88 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toContain("images/favicon.ico"); }), ); + + it.effect("directory scan prefers earlier directory over later better name", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand.svg", "svg"); + yield* writeTextFile(cwd, "images/favicon.svg", "svg"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand.svg"); + expect(resolved).not.toContain("images/favicon.svg"); + }), + ); + + it.effect("directory scan continues after an unreadable directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + const publicDir = path.join(cwd, "public"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + pathOrDescriptor: publicDir, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (dirPath, options) => + dirPath === publicDir + ? Effect.fail(cause) + : fileSystem.readDirectory(dirPath, options), + }), + ); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("manifest with JSON null falls back to directory scan", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "manifest.json", "null"); + yield* writeTextFile(cwd, "images/icon.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("manifest multi-size string picks largest dimension", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [ + { src: "/icon-192.png", sizes: "192x192 512x512", type: "image/png" }, + { src: "/icon-256.png", sizes: "256x256", type: "image/png" }, + ], + }), + ); + yield* writeTextFile(cwd, "public/icon-192.png", "png"); + yield* writeTextFile(cwd, "public/icon-256.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("icon-192.png"); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2d0e6665c92d..867dac42133e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -148,8 +148,14 @@ function extractIconHref(source: string): string | null { function parseIconSize(sizes: string | undefined): number { if (!sizes) return 0; - const match = sizes.match(/\b(\d+)x\d+\b/); - return match ? Number(match[1]) : 0; + let maxSize = 0; + for (const part of sizes.split(/\s+/)) { + const match = part.match(/^(\d+)x\d+$/); + if (match) { + maxSize = Math.max(maxSize, Number(match[1])); + } + } + return maxSize; } const optionOnNotFound = ( @@ -247,18 +253,7 @@ export const make = Effect.gen(function* () { return null; } - const entries = yield* optionOnNotFound(fileSystem.readDirectory(absoluteDir)).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "scan-directory", - workspaceRoot: projectCwd, - relativePath: dir, - absolutePath: absoluteDir, - cause, - }), - ), - ); + const entries = yield* Effect.option(fileSystem.readDirectory(absoluteDir)); if (Option.isNone(entries)) return null; let best: { @@ -272,23 +267,13 @@ export const make = Effect.gen(function* () { } | null = null; for (const entry of entries.value) { - const priority = candidatePriority.get(entry); + const key = dir ? `${dir}/${entry}` : entry; + const priority = candidatePriority.get(key); if (!priority) continue; const absolutePath = path.join(absoluteDir, entry); const relativePath = dir ? path.join(dir, entry) : entry; - const stats = yield* optionOnNotFound(fileSystem.stat(absolutePath)).pipe( - Effect.mapError( - (cause) => - new ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot: projectCwd, - relativePath, - absolutePath, - cause, - }), - ), - ); + const stats = yield* Effect.option(fileSystem.stat(absolutePath)); if (Option.isNone(stats) || stats.value.type !== "File") continue; if ( @@ -316,7 +301,8 @@ export const make = Effect.gen(function* () { IMAGE_DIR_CANDIDATES.forEach((candidateDir, dirIdx) => { IMAGE_NAME_CANDIDATES.forEach((name, nameIdx) => { IMAGE_EXTENSIONS.forEach((ext, extIdx) => { - candidatePriority.set(`${name}${ext}`, { dirIdx, nameIdx, extIdx }); + const key = candidateDir ? `${candidateDir}/${name}${ext}` : `${name}${ext}`; + candidatePriority.set(key, { dirIdx, nameIdx, extIdx }); }); }); }); @@ -385,12 +371,14 @@ export const make = Effect.gen(function* () { ); if (Option.isNone(source)) return null; - let manifest: { icons?: ReadonlyArray<{ src?: string; sizes?: string }> }; + let parsed: unknown; try { - manifest = JSON.parse(source.value) as typeof manifest; + parsed = JSON.parse(source.value); } catch { return null; } + if (!parsed || typeof parsed !== "object") return null; + const manifest = parsed as { icons?: ReadonlyArray<{ src?: string; sizes?: string }> }; if (!Array.isArray(manifest.icons)) return null; let bestIcon: string | null = null;