/** * 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 Cache from "effect/Cache";import * as Context from "effect/Context";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";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"; // Resolution walks up to 12 well-known paths plus 7 source files, so a miss// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon// asset URL, and a project's icon does not move, so the answer is cached.const FAVICON_CACHE_CAPACITY = 512;const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); function faviconCacheKey(cwd: string, faviconPath?: string): string { return `${faviconPath ?? ""}\0${cwd}`;} function parseFaviconCacheKey(key: string): { readonly cwd: string; readonly faviconPath?: string;} { const separatorIndex = key.indexOf("\0"); if (separatorIndex === -1) { return { cwd: key }; } const faviconPath = key.slice(0, separatorIndex); const cwd = key.slice(separatorIndex + 1); return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath };} // 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 <link rel="icon"> 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 <link ...> tags or object-like icon metadata where rel/href can appear in any order.// The tag pattern is anchored on `<link`, so it only starts at real candidates. Object metadata// is matched by scanning brace-free runs instead of by one combined pattern: an unanchored// pattern restarts at every offset and rescans forward, which is quadratic on large sources.const LINK_ICON_HTML_RE = /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; export class ProjectFaviconResolutionError extends Schema.TaggedError<ProjectFaviconResolutionError>()( "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, faviconPath?: string, ) => Effect.Effect<string | null, ProjectFaviconResolutionError>; }>()("t3/project/ProjectFaviconResolver") {} function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel` // but no href falls through to the next one rather than ending the search. for (const run of source.split("}")) { if (!ICON_REL_RE.test(run)) continue; const hrefMatch = run.match(ICON_HREF_RE); if (hrefMatch?.[1]) return hrefMatch[1]; } return null;} const optionOnNotFound = <A, R>( effect: Effect.Effect<A, PlatformError.PlatformError, R>,): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> => effect.pipe( Effect.map(Option.some), Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : 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<string> => { const clean = href.replace(/^\//, ""); return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray<string>, candidateScope: "workspace" | "filesystem", ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> { for (const relativePath of relativeCandidates) { const candidate = yield* ( candidateScope === "filesystem" && path.isAbsolute(relativePath) ? Effect.succeed({ absolutePath: relativePath, relativePath }) : 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 resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( cwd: string, faviconPath?: string, ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "normalize-workspace", workspaceRoot: cwd, cause, }), ), ); // A grouped project's saved path can be absent from one checkout. Use it // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem"); if (existing) { return existing; } } // 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], "workspace", ); if (existing) { return existing; } } for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); 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), "workspace"); if (existing) { return existing; } } return null; }); const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>( (key) => { const { cwd, faviconPath } = parseFaviconCacheKey(key); return resolvePathUncached(cwd, faviconPath); }, { capacity: FAVICON_CACHE_CAPACITY, timeToLive: Exit.match({ onSuccess: (value: string | null) => value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL, onFailure: () => Duration.zero, }), }, ); const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd, faviconPath) { const key = faviconCacheKey(cwd, faviconPath); const cached = yield* Cache.get(faviconCache, key); if (cached === null) { return null; } // A hit still confirms the file with one stat rather than the ~20 probes a // full walk costs, so a deleted icon falls back at once instead of after // the TTL. const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe( Effect.mapError( (cause) => new ProjectFaviconResolutionError({ operation: "stat-candidate", workspaceRoot: cwd, absolutePath: cached, cause, }), ), ); if (Option.isSome(stats) && stats.value.type === "File") { return cached; } yield* Cache.invalidate(faviconCache, key); return yield* Cache.get(faviconCache, key); }); return ProjectFaviconResolver.of({ resolvePath });}); export const layer = Layer.effect(ProjectFaviconResolver, make);
/**
* 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 Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
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";
// Resolution walks up to 12 well-known paths plus 7 source files, so a miss
// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon
// asset URL, and a project's icon does not move, so the answer is cached.
const FAVICON_CACHE_CAPACITY = 512;
const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);
const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1);
function faviconCacheKey(cwd: string, faviconPath?: string): string {
return `${faviconPath ?? ""}\0${cwd}`;
}
function parseFaviconCacheKey(key: string): {
readonly cwd: string;
readonly faviconPath?: string;
} {
const separatorIndex = key.indexOf("\0");
if (separatorIndex === -1) {
return { cwd: key };
}
const faviconPath = key.slice(0, separatorIndex);
const cwd = key.slice(separatorIndex + 1);
return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath };
}
// 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 <link rel="icon"> 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 <link ...> tags or object-like icon metadata where rel/href can appear in any order.
// The tag pattern is anchored on `<link`, so it only starts at real candidates. Object metadata
// is matched by scanning brace-free runs instead of by one combined pattern: an unanchored
// pattern restarts at every offset and rescans forward, which is quadratic on large sources.
const LINK_ICON_HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
export class ProjectFaviconResolutionError extends Schema.TaggedError<ProjectFaviconResolutionError>()(
"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,
faviconPath?: string,
) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
}
>()("t3/project/ProjectFaviconResolver") {}
function extractIconHref(source: string): string | null {
const htmlMatch = source.match(LINK_ICON_HTML_RE);
if (htmlMatch?.[1]) return htmlMatch[1];
// Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel`
// but no href falls through to the next one rather than ending the search.
for (const run of source.split("}")) {
if (!ICON_REL_RE.test(run)) continue;
const hrefMatch = run.match(ICON_HREF_RE);
if (hrefMatch?.[1]) return hrefMatch[1];
}
return null;
}
const optionOnNotFound = <A, R>(
effect: Effect.Effect<A, PlatformError.PlatformError, R>,
): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
effect.pipe(
Effect.map(Option.some),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : 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<string> => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
};
const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
projectCwd: string,
relativeCandidates: ReadonlyArray<string>,
candidateScope: "workspace" | "filesystem",
): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
for (const relativePath of relativeCandidates) {
const candidate = yield* (
candidateScope === "filesystem" && path.isAbsolute(relativePath)
? Effect.succeed({ absolutePath: relativePath, relativePath })
: 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 resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* (
cwd: string,
faviconPath?: string,
): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);
// A grouped project's saved path can be absent from one checkout. Use it
// where it exists and retain automatic discovery for the other checkouts.
if (faviconPath !== undefined) {
const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem");
if (existing) {
return existing;
}
}
// 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],
"workspace",
);
if (existing) {
return existing;
}
}
for (const candidate of FAVICON_CANDIDATES) {
const existing = yield* findExistingFile(projectCwd, [candidate], "workspace");
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), "workspace");
if (existing) {
return existing;
}
}
return null;
});
const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>(
(key) => {
const { cwd, faviconPath } = parseFaviconCacheKey(key);
return resolvePathUncached(cwd, faviconPath);
},
{
capacity: FAVICON_CACHE_CAPACITY,
timeToLive: Exit.match({
onSuccess: (value: string | null) =>
value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL,
onFailure: () => Duration.zero,
}),
},
);
const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd, faviconPath) {
const key = faviconCacheKey(cwd, faviconPath);
const cached = yield* Cache.get(faviconCache, key);
if (cached === null) {
return null;
}
// A hit still confirms the file with one stat rather than the ~20 probes a
// full walk costs, so a deleted icon falls back at once instead of after
// the TTL.
const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "stat-candidate",
workspaceRoot: cwd,
absolutePath: cached,
cause,
}),
),
);
if (Option.isSome(stats) && stats.value.type === "File") {
return cached;
}
yield* Cache.invalidate(faviconCache, key);
return yield* Cache.get(faviconCache, key);
});
return ProjectFaviconResolver.of({ resolvePath });
});
export const layer = Layer.effect(ProjectFaviconResolver, make);