347 lines · 11.3 KB · TypeScript
RawDownload
/** * 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);