| 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 | */ |
| 9 | import * as Cache from "effect/Cache"; |
| 10 | import * as Context from "effect/Context"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as Path from "effect/Path"; |
| 19 | import * as PlatformError from "effect/PlatformError"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | |
| 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | import * 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. |
| 28 | const FAVICON_CACHE_CAPACITY = 512; |
| 29 | const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); |
| 30 | const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); |
| 31 | |
| 32 | function faviconCacheKey(cwd: string, faviconPath?: string): string { |
| 33 | return `${faviconPath ?? ""}\0${cwd}`; |
| 34 | } |
| 35 | |
| 36 | function 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. |
| 50 | const 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. |
| 77 | const 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. |
| 91 | const LINK_ICON_HTML_RE = |
| 92 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; |
| 93 | const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; |
| 94 | const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; |
| 95 | |
| 96 | export 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 | |
| 116 | const isWorkspaceRootNotExistsError = Schema.is(WorkspacePaths.WorkspaceRootNotExistsError); |
| 117 | |
| 118 | /** True when the workspace root itself is gone, e.g. a moved or deleted checkout. */ |
| 119 | export const isMissingWorkspaceRoot = (error: ProjectFaviconResolutionError): boolean => |
| 120 | error.operation === "normalize-workspace" && isWorkspaceRootNotExistsError(error.cause); |
| 121 | |
| 122 | /** Service tag for project favicon resolution. */ |
| 123 | export 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 | |
| 138 | function 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 | |
| 151 | const 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 | |
| 162 | export 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 | |
| 377 | export const layer = Layer.effect(ProjectFaviconResolver, make); |