1/**
2 * ProjectFaviconResolver - Effect service contract for project icon discovery.
3 *
4 * Resolves a representative favicon or app icon file for a workspace by
5 * checking common file locations and project source metadata.
6 *
7 * @module ProjectFaviconResolver
8 */
9import * as Cache from "effect/Cache";
10import * as Context from "effect/Context";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as PlatformError from "effect/PlatformError";
20import * as Schema from "effect/Schema";
21
22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
24
25// Resolution probes 23 well-known paths plus 7 source files, so a miss
26// costs ~30 filesystem probes. AssetAccess resolves on every project-favicon
27// asset URL, and a project's icon does not move, so the answer is cached.
28const FAVICON_CACHE_CAPACITY = 512;
29const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);
30const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1);
31
32function faviconCacheKey(cwd: string, faviconPath?: string): string {
33 return `${faviconPath ?? ""}\0${cwd}`;
34}
35
36function parseFaviconCacheKey(key: string): {
37 readonly cwd: string;
38 readonly faviconPath?: string;
39} {
40 const separatorIndex = key.indexOf("\0");
41 if (separatorIndex === -1) {
42 return { cwd: key };
43 }
44 const faviconPath = key.slice(0, separatorIndex);
45 const cwd = key.slice(separatorIndex + 1);
46 return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath };
47}
48
49// Well-known favicon paths checked in order.
50const FAVICON_CANDIDATES = [
51 "favicon.svg",
52 "favicon.ico",
53 "favicon.png",
54 "public/favicon.svg",
55 "public/favicon.ico",
56 "public/favicon.png",
57 "app/favicon.ico",
58 "app/favicon.png",
59 "app/icon.svg",
60 "app/icon.png",
61 "app/icon.ico",
62 "src/favicon.ico",
63 "src/favicon.svg",
64 "src/app/favicon.ico",
65 "src/app/icon.svg",
66 "src/app/icon.png",
67 "assets/icon.svg",
68 "assets/icon.png",
69 "assets/logo.svg",
70 "assets/logo.png",
71 ".idea/icon.svg",
72 "Resources/AppIcon.icns",
73 "AppIcon.icns",
74] as const;
75
76// Files that may contain a <link rel="icon"> or icon metadata declaration.
77const ICON_SOURCE_FILES = [
78 "index.html",
79 "public/index.html",
80 "app/routes/__root.tsx",
81 "src/routes/__root.tsx",
82 "app/root.tsx",
83 "src/root.tsx",
84 "src/index.html",
85] as const;
86
87// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
88// The tag pattern is anchored on `<link`, so it only starts at real candidates. Object metadata
89// is matched by scanning brace-free runs instead of by one combined pattern: an unanchored
90// pattern restarts at every offset and rescans forward, which is quadratic on large sources.
91const LINK_ICON_HTML_RE =
92 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
93const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
94const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
95
96export class ProjectFaviconResolutionError extends Schema.TaggedError<ProjectFaviconResolutionError>()(
97 "ProjectFaviconResolutionError",
98 {
99 operation: Schema.Literals([
100 "normalize-workspace",
101 "resolve-path",
102 "stat-candidate",
103 "read-source",
104 ]),
105 workspaceRoot: Schema.String,
106 relativePath: Schema.optional(Schema.String),
107 absolutePath: Schema.optional(Schema.String),
108 cause: Schema.Defect(),
109 },
110) {
111 override get message(): string {
112 return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`;
113 }
114}
115
116const isWorkspaceRootNotExistsError = Schema.is(WorkspacePaths.WorkspaceRootNotExistsError);
117
118/** True when the workspace root itself is gone, e.g. a moved or deleted checkout. */
119export const isMissingWorkspaceRoot = (error: ProjectFaviconResolutionError): boolean =>
120 error.operation === "normalize-workspace" && isWorkspaceRootNotExistsError(error.cause);
121
122/** Service tag for project favicon resolution. */
123export class ProjectFaviconResolver extends Context.Service<
124 ProjectFaviconResolver,
125 {
126 /**
127 * Resolve a favicon or icon file path for the provided workspace root.
128 *
129 * Returns `null` when no candidate icon file can be found.
130 */
131 readonly resolvePath: (
132 cwd: string,
133 faviconPath?: string,
134 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
135 }
136>()("t3/project/ProjectFaviconResolver") {}
137
138function extractIconHref(source: string): string | null {
139 const htmlMatch = source.match(LINK_ICON_HTML_RE);
140 if (htmlMatch?.[1]) return htmlMatch[1];
141 // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel`
142 // but no href falls through to the next one rather than ending the search.
143 for (const run of source.split("}")) {
144 if (!ICON_REL_RE.test(run)) continue;
145 const hrefMatch = run.match(ICON_HREF_RE);
146 if (hrefMatch?.[1]) return hrefMatch[1];
147 }
148 return null;
149}
150
151const optionOnNotFound = <A, R>(
152 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
153): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
154 effect.pipe(
155 Effect.asSome,
156 Effect.catchTags({
157 PlatformError: (error) =>
158 error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : Effect.fail(error),
159 }),
160 );
161
162export const make = Effect.gen(function* () {
163 const fileSystem = yield* FileSystem.FileSystem;
164 const path = yield* Path.Path;
165 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
166 const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
167
168 const resolveIconHref = (href: string): ReadonlyArray<string> => {
169 const clean = href.replace(/^\//, "");
170 return [path.join("public", clean), clean];
171 };
172
173 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
174 projectCwd: string,
175 relativeCandidates: ReadonlyArray<string>,
176 candidateScope: "workspace" | "filesystem",
177 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
178 for (const relativePath of relativeCandidates) {
179 const candidate = yield* (
180 candidateScope === "filesystem" && path.isAbsolute(relativePath)
181 ? Effect.succeed({ absolutePath: relativePath, relativePath })
182 : workspacePaths.resolveRelativePathWithinRoot({
183 workspaceRoot: projectCwd,
184 relativePath,
185 })
186 ).pipe(
187 Effect.asSome,
188 Effect.catchTags({
189 WorkspacePathOutsideRootError: () =>
190 Effect.succeed(
191 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
192 ),
193 }),
194 );
195 if (Option.isNone(candidate)) {
196 continue;
197 }
198 const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe(
199 Effect.mapError(
200 (cause) =>
201 new ProjectFaviconResolutionError({
202 operation: "stat-candidate",
203 workspaceRoot: projectCwd,
204 relativePath,
205 absolutePath: candidate.value.absolutePath,
206 cause,
207 }),
208 ),
209 );
210 if (Option.isSome(stats) && stats.value.type === "File") {
211 return candidate.value.absolutePath;
212 }
213 }
214 return null;
215 });
216
217 // Reads one source file and resolves the icon it declares, if any.
218 const findIconFromSource = Effect.fn("ProjectFaviconResolver.findIconFromSource")(function* (
219 projectCwd: string,
220 sourceFile: string,
221 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
222 const sourcePath = yield* workspacePaths
223 .resolveRelativePathWithinRoot({
224 workspaceRoot: projectCwd,
225 relativePath: sourceFile,
226 })
227 .pipe(
228 Effect.mapError(
229 (cause) =>
230 new ProjectFaviconResolutionError({
231 operation: "resolve-path",
232 workspaceRoot: projectCwd,
233 relativePath: sourceFile,
234 cause,
235 }),
236 ),
237 );
238 const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe(
239 Effect.mapError(
240 (cause) =>
241 new ProjectFaviconResolutionError({
242 operation: "read-source",
243 workspaceRoot: projectCwd,
244 relativePath: sourceFile,
245 absolutePath: sourcePath.absolutePath,
246 cause,
247 }),
248 ),
249 );
250 if (Option.isNone(source)) {
251 return null;
252 }
253 const href = extractIconHref(source.value);
254 if (!href) {
255 return null;
256 }
257 return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
258 });
259
260 // Starts every probe at once and returns the first hit in list order. A miss
261 // costs one round of filesystem latency instead of one per probe, which
262 // matters when the disk is slow. Fibers are joined in order, so a hit returns
263 // without waiting for lower-ranked probes (the scope interrupts them), and a
264 // failure surfaces only when no earlier probe found a file.
265 const firstInOrder = <A>(
266 items: ReadonlyArray<A>,
267 probe: (item: A) => Effect.Effect<string | null, ProjectFaviconResolutionError>,
268 ) =>
269 Effect.gen(function* () {
270 const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item)));
271 for (const fiber of fibers) {
272 const found = yield* Fiber.join(fiber);
273 if (found) {
274 return found;
275 }
276 }
277 return null;
278 }).pipe(Effect.scoped);
279
280 const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* (
281 cwd: string,
282 faviconPath?: string,
283 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
284 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
285 Effect.mapError(
286 (cause) =>
287 new ProjectFaviconResolutionError({
288 operation: "normalize-workspace",
289 workspaceRoot: cwd,
290 cause,
291 }),
292 ),
293 );
294 // A grouped project's saved path can be absent from one checkout. Use it
295 // where it exists and retain automatic discovery for the other checkouts.
296 if (faviconPath !== undefined) {
297 const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem");
298 if (existing) {
299 return existing;
300 }
301 }
302
303 // A t3.json iconPath takes precedence over the well-known locations.
304 const projectFile = yield* projectFileLoader.load(projectCwd);
305 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
306 const existing = yield* findExistingFile(
307 projectCwd,
308 [projectFile.value.iconPath],
309 "workspace",
310 );
311 if (existing) {
312 return existing;
313 }
314 }
315
316 const wellKnown = yield* firstInOrder(FAVICON_CANDIDATES, (candidate) =>
317 findExistingFile(projectCwd, [candidate], "workspace"),
318 );
319 if (wellKnown) {
320 return wellKnown;
321 }
322
323 return yield* firstInOrder(ICON_SOURCE_FILES, (sourceFile) =>
324 findIconFromSource(projectCwd, sourceFile),
325 );
326 });
327
328 const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>(
329 (key) => {
330 const { cwd, faviconPath } = parseFaviconCacheKey(key);
331 return resolvePathUncached(cwd, faviconPath);
332 },
333 {
334 capacity: FAVICON_CACHE_CAPACITY,
335 timeToLive: Exit.match({
336 onSuccess: (value: string | null) =>
337 value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL,
338 onFailure: () => Duration.zero,
339 }),
340 },
341 );
342
343 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
344 "ProjectFaviconResolver.resolvePath",
345 )(function* (cwd, faviconPath) {
346 const key = faviconCacheKey(cwd, faviconPath);
347 const cached = yield* Cache.get(faviconCache, key);
348 if (cached === null) {
349 return null;
350 }
351
352 // A hit still confirms the file with one stat rather than the ~20 probes a
353 // full walk costs, so a deleted icon falls back at once instead of after
354 // the TTL.
355 const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe(
356 Effect.mapError(
357 (cause) =>
358 new ProjectFaviconResolutionError({
359 operation: "stat-candidate",
360 workspaceRoot: cwd,
361 absolutePath: cached,
362 cause,
363 }),
364 ),
365 );
366 if (Option.isSome(stats) && stats.value.type === "File") {
367 return cached;
368 }
369
370 yield* Cache.invalidate(faviconCache, key);
371 return yield* Cache.get(faviconCache, key);
372 });
373
374 return ProjectFaviconResolver.of({ resolvePath });
375});
376
377export const layer = Layer.effect(ProjectFaviconResolver, make);