Skip to content
Source
Sign inSign up

ProjectFaviconResolver.ts

359 lines11.5 KB

1import * as Cache from "effect/Cache";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";
14​
15import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
16import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
17​
18const FAVICON_CACHE_CAPACITY = 512;
19const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);
20const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1);
21​
22function faviconCacheKey(cwd: string, faviconPath?: string): string {
23 return `${faviconPath ?? ""}\0${cwd}`;
25​
26function parseFaviconCacheKey(key: string): {
27 readonly cwd: string;
28 readonly faviconPath?: string;
29} {
30 const separatorIndex = key.indexOf("\0");
31 if (separatorIndex === -1) {
32 return { cwd: key };
33 }
34 const faviconPath = key.slice(0, separatorIndex);
35 const cwd = key.slice(separatorIndex + 1);
36 return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath };
38​
39const FAVICON_CANDIDATES = [
40 "favicon.svg",
41 "favicon.ico",
42 "favicon.png",
43 "public/favicon.svg",
44 "public/favicon.ico",
45 "public/favicon.png",
46 "app/favicon.ico",
47 "app/favicon.png",
48 "app/icon.svg",
49 "app/icon.png",
50 "app/icon.ico",
51 "src/favicon.ico",
52 "src/favicon.svg",
53 "src/app/favicon.ico",
54 "src/app/icon.svg",
55 "src/app/icon.png",
56 "assets/icon.svg",
57 "assets/icon.png",
58 "assets/logo.svg",
59 "assets/logo.png",
60 ".idea/icon.svg",
61 "Resources/AppIcon.icns",
62 "AppIcon.icns",
63] as const;
64​
65const ICON_SOURCE_FILES = [
66 "index.html",
67 "public/index.html",
68 "app/routes/__root.tsx",
69 "src/routes/__root.tsx",
70 "app/root.tsx",
71 "src/root.tsx",
72 "src/index.html",
73] as const;
74​
75// Anchor HTML at <link and scan brace-free metadata runs to avoid quadratic regex rescans.
76const LINK_ICON_HTML_RE =
77 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
78const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
79const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;
80​
81export class ProjectFaviconResolutionError extends Schema.TaggedError<ProjectFaviconResolutionError>()(
82 "ProjectFaviconResolutionError",
83 {
84 operation: Schema.Literals([
85 "normalize-workspace",
86 "resolve-path",
87 "stat-candidate",
88 "read-source",
89 ]),
90 workspaceRoot: Schema.String,
91 relativePath: Schema.optional(Schema.String),
92 absolutePath: Schema.optional(Schema.String),
93 cause: Schema.Defect(),
94 },
95) {
96 override get message(): string {
97 return `Failed to resolve project favicon during ${this.operation} for workspace ${this.workspaceRoot}.`;
98 }
100​
101const isWorkspaceRootNotExistsError = Schema.is(WorkspacePaths.WorkspaceRootNotExistsError);
102​
103export const isMissingWorkspaceRoot = (error: ProjectFaviconResolutionError): boolean =>
104 error.operation === "normalize-workspace" && isWorkspaceRootNotExistsError(error.cause);
105​
106export class ProjectFaviconResolver extends Context.Service<
107 ProjectFaviconResolver,
108 {
109 readonly resolvePath: (
110 cwd: string,
111 faviconPath?: string,
112 ) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
113 /** Forgets cached lookups for a workspace whose files changed, such as a finished clone. */
114 readonly invalidate: (cwd: string) => Effect.Effect<void>;
115 }
116>()("t3/project/ProjectFaviconResolver") {}
117​
118function extractIconHref(source: string): string | null {
119 const htmlMatch = source.match(LINK_ICON_HTML_RE);
120 if (htmlMatch?.[1]) return htmlMatch[1];
121 for (const run of source.split("}")) {
122 if (!ICON_REL_RE.test(run)) continue;
123 const hrefMatch = run.match(ICON_HREF_RE);
124 if (hrefMatch?.[1]) return hrefMatch[1];
125 }
126 return null;
128​
129export const optionOnNotFound = <A, R>(
130 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
131): Effect.Effect<Option.Option<A>, PlatformError.PlatformError, R> =>
132 effect.pipe(
133 Effect.asSome,
134 Effect.catchTags({
135 PlatformError: (error) =>
136 error.reason._tag === "NotFound" ? Effect.succeed(Option.none<A>()) : Effect.fail(error),
137 }),
138 );
139​
140export const make = Effect.gen(function* () {
141 const fileSystem = yield* FileSystem.FileSystem;
142 const path = yield* Path.Path;
143 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
144 const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
145​
146 const resolveIconHref = (href: string): ReadonlyArray<string> => {
147 // Browsers strip surrounding whitespace from URL attributes; file paths keep theirs.
148 const clean = href.trim().replace(/^\//, "");
149 return [path.join("public", clean), clean];
150 };
151​
152 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
153 projectCwd: string,
154 relativeCandidates: ReadonlyArray<string>,
155 candidateScope: "workspace" | "filesystem",
156 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
157 for (const relativePath of relativeCandidates) {
158 const candidate = yield* (
159 candidateScope === "filesystem" && path.isAbsolute(relativePath)
160 ? Effect.succeed({ absolutePath: relativePath, relativePath })
161 : workspacePaths.resolveRelativePathWithinRoot({
162 workspaceRoot: projectCwd,
163 relativePath,
164 })
165 ).pipe(
166 Effect.asSome,
167 Effect.catchTags({
168 WorkspacePathOutsideRootError: () =>
169 Effect.succeed(
170 Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(),
171 ),
172 }),
173 );
174 if (Option.isNone(candidate)) {
175 continue;
176 }
177 const stats = yield* optionOnNotFound(fileSystem.stat(candidate.value.absolutePath)).pipe(
178 Effect.mapError(
179 (cause) =>
180 new ProjectFaviconResolutionError({
181 operation: "stat-candidate",
182 workspaceRoot: projectCwd,
183 relativePath,
184 absolutePath: candidate.value.absolutePath,
185 cause,
186 }),
187 ),
188 );
189 if (Option.isSome(stats) && stats.value.type === "File") {
190 return candidate.value.absolutePath;
191 }
192 }
193 return null;
194 });
195​
196 const findIconFromSource = Effect.fn("ProjectFaviconResolver.findIconFromSource")(function* (
197 projectCwd: string,
198 sourceFile: string,
199 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
200 const sourcePath = yield* workspacePaths
201 .resolveRelativePathWithinRoot({
202 workspaceRoot: projectCwd,
203 relativePath: sourceFile,
204 })
205 .pipe(
206 Effect.mapError(
207 (cause) =>
208 new ProjectFaviconResolutionError({
209 operation: "resolve-path",
210 workspaceRoot: projectCwd,
211 relativePath: sourceFile,
212 cause,
213 }),
214 ),
215 );
216 const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe(
217 Effect.mapError(
218 (cause) =>
219 new ProjectFaviconResolutionError({
220 operation: "read-source",
221 workspaceRoot: projectCwd,
222 relativePath: sourceFile,
223 absolutePath: sourcePath.absolutePath,
224 cause,
225 }),
226 ),
227 );
228 if (Option.isNone(source)) {
229 return null;
230 }
231 const href = extractIconHref(source.value);
232 if (!href) {
233 return null;
234 }
235 return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace");
236 });
237​
238 // Probe concurrently but join in list order so lower-ranked probes cannot delay a hit.
239 // A failure surfaces only if no earlier probe found a file; scope exit interrupts remaining probes.
240 const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* <A>(
241 items: ReadonlyArray<A>,
242 probe: (item: A) => Effect.Effect<string | null, ProjectFaviconResolutionError>,
243 ) {
244 const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item)));
245 for (const fiber of fibers) {
246 const found = yield* Fiber.join(fiber);
247 if (found) {
248 return found;
249 }
250 }
251 return null;
252 }, Effect.scoped);
253​
254 const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* (
255 cwd: string,
256 faviconPath?: string,
257 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
258 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
259 Effect.mapError(
260 (cause) =>
261 new ProjectFaviconResolutionError({
262 operation: "normalize-workspace",
263 workspaceRoot: cwd,
264 cause,
265 }),
266 ),
267 );
268 if (faviconPath !== undefined) {
269 const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem");
270 if (existing) {
271 return existing;
272 }
273 }
274​
275 const projectFile = yield* projectFileLoader.load(projectCwd);
276 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
277 const existing = yield* findExistingFile(
278 projectCwd,
279 [projectFile.value.iconPath],
280 "workspace",
281 );
282 if (existing) {
283 return existing;
284 }
285 }
286​
287 const wellKnown = yield* firstInOrder(FAVICON_CANDIDATES, (candidate) =>
288 findExistingFile(projectCwd, [candidate], "workspace"),
289 );
290 if (wellKnown) {
291 return wellKnown;
292 }
293​
294 return yield* firstInOrder(ICON_SOURCE_FILES, (sourceFile) =>
295 findIconFromSource(projectCwd, sourceFile),
296 );
297 });
298​
299 const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>(
300 (key) => {
301 const { cwd, faviconPath } = parseFaviconCacheKey(key);
302 return resolvePathUncached(cwd, faviconPath);
303 },
304 {
305 capacity: FAVICON_CACHE_CAPACITY,
306 timeToLive: Exit.match({
307 onSuccess: (value: string | null) =>
308 value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL,
309 onFailure: (cause) =>
310 Option.exists(Cause.findErrorOption(cause), isMissingWorkspaceRoot)
311 ? FAVICON_NEGATIVE_CACHE_TTL
312 : Duration.zero,
313 }),
314 },
315 );
316​
317 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
318 "ProjectFaviconResolver.resolvePath",
319 )(function* (cwd, faviconPath) {
320 const key = faviconCacheKey(cwd, faviconPath);
321 const cached = yield* Cache.get(faviconCache, key);
322 if (cached === null) {
323 return null;
324 }
325​
326 const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe(
327 Effect.mapError(
328 (cause) =>
329 new ProjectFaviconResolutionError({
330 operation: "stat-candidate",
331 workspaceRoot: cwd,
332 absolutePath: cached,
333 cause,
334 }),
335 ),
336 );
337 if (Option.isSome(stats) && stats.value.type === "File") {
338 return cached;
339 }
340​
341 yield* Cache.invalidate(faviconCache, key);
342 return yield* Cache.get(faviconCache, key);
343 });
344​
345 const invalidate: ProjectFaviconResolver["Service"]["invalidate"] = (cwd) =>
346 Cache.keys(faviconCache).pipe(
347 Effect.flatMap((keys) =>
348 Effect.forEach(
349 Array.from(keys).filter((key) => parseFaviconCacheKey(key).cwd === cwd),
350 (key) => Cache.invalidate(faviconCache, key),
351 { discard: true },
352 ),
353 ),
354 );
355​
356 return ProjectFaviconResolver.of({ resolvePath, invalidate });
357});
358​
359export const layer = Layer.effect(ProjectFaviconResolver, make);