import { Effect, FileSystem, Layer, Path } from "effect";
import {
ProjectFaviconResolver,
type ProjectFaviconResolverShape,
} from "../Services/ProjectFaviconResolver.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;
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;
}
export const makeProjectFaviconResolver = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const resolveIconHref = (projectCwd: string, href: string): string[] => {
const clean = href.replace(/^\//, "");
return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
};
const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => {
const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
};
const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
projectCwd: string,
candidates: ReadonlyArray,
): Effect.fn.Return {
for (const candidate of candidates) {
if (!isPathWithinProject(projectCwd, candidate)) {
continue;
}
const stats = yield* fileSystem
.stat(candidate)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (stats?.type === "File") {
return candidate;
}
}
return null;
});
const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd: string): Effect.fn.Return {
for (const candidate of FAVICON_CANDIDATES) {
const resolved = path.join(cwd, candidate);
const existing = yield* findExistingFile(cwd, [resolved]);
if (existing) {
return existing;
}
}
for (const sourceFile of ICON_SOURCE_FILES) {
const sourcePath = path.join(cwd, sourceFile);
const source = yield* fileSystem
.readFileString(sourcePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!source) {
continue;
}
const href = extractIconHref(source);
if (!href) {
continue;
}
const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href));
if (existing) {
return existing;
}
}
return null;
});
return {
resolvePath,
} satisfies ProjectFaviconResolverShape;
});
export const ProjectFaviconResolverLive = Layer.effect(
ProjectFaviconResolver,
makeProjectFaviconResolver,
);