/** * ProjectFaviconResolver - Effect service contract for project icon discovery. * * Resolves a representative favicon or app icon file for a workspace by * checking common file locations and project source metadata. * * @module ProjectFaviconResolver */ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", "favicon.ico", "favicon.png", "public/favicon.svg", "public/favicon.ico", "public/favicon.png", "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico", "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", "assets/icon.svg", "assets/icon.png", "assets/logo.svg", "assets/logo.png", ".idea/icon.svg", ] as const; // Files that may contain a or icon metadata declaration. const ICON_SOURCE_FILES = [ "index.html", "public/index.html", "app/routes/__root.tsx", "src/routes/__root.tsx", "app/root.tsx", "src/root.tsx", "src/index.html", ] 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; const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( "ProjectFaviconResolutionError", { operation: Schema.Literals([ "normalize-workspace", "resolve-path", "stat-candidate", "read-source", ]), workspaceRoot: Schema.String, relativePath: Schema.optional(Schema.String), absolutePath: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`; } } /** Service tag for project favicon resolution. */ export class ProjectFaviconResolver extends Context.Service< ProjectFaviconResolver, { /** * Resolve a favicon or icon file path for the provided workspace root. * * Returns `null` when no candidate icon file can be found. */ readonly resolvePath: ( cwd: string, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; const objMatch = source.match(LINK_ICON_OBJ_RE); if (objMatch?.[1]) return objMatch[1]; return null; } const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( Effect.map(Option.some), Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), }), ); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, ): Effect.fn.Return { for (const relativePath of relativeCandidates) { const candidate = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath, }) .pipe( Effect.map(Option.some), Effect.catchTags({ WorkspacePathOutsideRootError: () => Effect.succeed( Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), ), }), ); if (Option.isNone(candidate)) { continue; } const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "stat-candidate", workspaceRoot: projectCwd, relativePath, absolutePath: candidate.value.absolutePath, cause, }), ), ); if (Option.isSome(stats) && stats.value.type === "File") { return candidate.value.absolutePath; } } return null; }); const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "normalize-workspace", workspaceRoot: cwd, cause, }), ), ); // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); if (existing) { return existing; } } for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { const sourcePath = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot: projectCwd, relativePath: sourceFile, }) .pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "resolve-path", workspaceRoot: projectCwd, relativePath: sourceFile, cause, }), ), ); const source = yield* optionOnNotFound( fileSystem.readFileString(sourcePath.absolutePath), ).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "read-source", workspaceRoot: projectCwd, relativePath: sourceFile, absolutePath: sourcePath.absolutePath, cause, }), ), ); if (Option.isNone(source)) { continue; } const href = extractIconHref(source.value); if (!href) { continue; } const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { return existing; } } return null; }); return ProjectFaviconResolver.of({ resolvePath }); }); export const layer = Layer.effect(ProjectFaviconResolver, make);