248 lines · 7.6 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 Context from "effect/Context";import * as Effect from "effect/Effect";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"; // 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.const LINK_ICON_HTML_RE =  /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;const LINK_ICON_OBJ_RE =  /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass<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,    ) => 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];  const objMatch = source.match(LINK_ICON_OBJ_RE);  if (objMatch?.[1]) return objMatch[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>,  ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {    for (const relativePath of relativeCandidates) {      const candidate = yield* 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 resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(    "ProjectFaviconResolver.resolvePath",  )(function* (cwd) {    const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(      Effect.mapError(        (cause) =>          new ProjectFaviconResolutionError({            operation: "normalize-workspace",            workspaceRoot: cwd,            cause,          }),      ),    );    // 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]);      if (existing) {        return existing;      }    }     for (const candidate of FAVICON_CANDIDATES) {      const existing = yield* findExistingFile(projectCwd, [candidate]);      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));      if (existing) {        return existing;      }    }     return null;  });   return ProjectFaviconResolver.of({ resolvePath });}); export const layer = Layer.effect(ProjectFaviconResolver, make);