Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,66 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
}),
);

it.effect("resolves an icon from a monorepo workspace package", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "apps/frontend/app/favicon.ico", "icon");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("apps/frontend/app/favicon.ico");
}),
);

it.effect("resolves icon hrefs from a workspace package source file", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"apps/web/index.html",
'<link rel="icon" href="/brand/logo.svg">',
);
yield* writeTextFile(cwd, "apps/web/public/brand/logo.svg", "<svg>brand</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("apps/web/public/brand/logo.svg");
}),
);

it.effect("prefers a root icon over a workspace package icon", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "apps/web/public/favicon.svg", "<svg>web</svg>");
yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("favicon.svg");
expect(resolved).not.toContain("apps");
}),
);

it.effect("prefers apps over packages", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "packages/ui/public/favicon.svg", "<svg>ui</svg>");
yield* writeTextFile(cwd, "apps/web/public/favicon.svg", "<svg>web</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("apps/web/public/favicon.svg");
}),
);

it.effect("returns null when no icon is present", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
Expand Down Expand Up @@ -194,6 +254,42 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
}),
);

it.effect("preserves non-missing workspace package listing failures", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const appsPath = path.join(cwd, "apps");
yield* writeTextFile(cwd, "apps/web/public/favicon.svg", "<svg>web</svg>");
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "readDirectory",
pathOrDescriptor: appsPath,
});
const resolver = yield* makeResolverWithFileSystem(
FileSystem.FileSystem.of({
...fileSystem,
readDirectory: (directoryPath, options) =>
directoryPath === appsPath
? Effect.fail(cause)
: fileSystem.readDirectory(directoryPath, options),
}),
);

const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "ProjectFaviconResolutionError",
operation: "list-packages",
workspaceRoot: cwd,
relativePath: "apps",
absolutePath: appsPath,
});
expect(error.cause).toBe(cause);
}),
);

it.effect("preserves icon source read failures", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
149 changes: 119 additions & 30 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* 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.
* checking common file locations and project source metadata. Monorepo roots
* (Turborepo and friends) keep their icons inside a workspace package, so the
* same locations are checked again in each `apps/*` and `packages/*` directory.
*
* @module ProjectFaviconResolver
*/
Expand Down Expand Up @@ -54,6 +56,9 @@ const ICON_SOURCE_FILES = [
"src/index.html",
] as const;

// Directories holding workspace packages in a conventional monorepo layout.
const MONOREPO_PACKAGE_DIRECTORIES = ["apps", "packages"] 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;
Expand All @@ -68,6 +73,7 @@ export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass<Proje
"resolve-path",
"stat-candidate",
"read-source",
"list-packages",
]),
workspaceRoot: Schema.String,
relativePath: Schema.optional(Schema.String),
Expand Down Expand Up @@ -120,9 +126,15 @@ export const make = Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

const resolveIconHref = (href: string): ReadonlyArray<string> => {
const withinPackage = (packageDir: string, relativePath: string) =>
packageDir ? `${packageDir}/${relativePath}` : relativePath;

const resolveIconHref = (packageDir: string, href: string): ReadonlyArray<string> => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
return [
withinPackage(packageDir, path.join("public", clean)),
withinPackage(packageDir, clean),
];
};

const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
Expand Down Expand Up @@ -166,48 +178,36 @@ export const make = Effect.gen(function* () {
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;
}
}

/**
* Check the well-known icon locations inside one directory of the project.
* `packageDir` is `""` for the project root, or a workspace-relative package
* directory such as `apps/web`.
*/
const findIconWithin = Effect.fn("ProjectFaviconResolver.findIconWithin")(function* (
projectCwd: string,
packageDir: string,
): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
for (const candidate of FAVICON_CANDIDATES) {
const existing = yield* findExistingFile(projectCwd, [candidate]);
const existing = yield* findExistingFile(projectCwd, [withinPackage(packageDir, candidate)]);
if (existing) {
return existing;
}
}

for (const sourceFile of ICON_SOURCE_FILES) {
const relativePath = withinPackage(packageDir, sourceFile);
const sourcePath = yield* workspacePaths
.resolveRelativePathWithinRoot({
workspaceRoot: projectCwd,
relativePath: sourceFile,
relativePath,
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "resolve-path",
workspaceRoot: projectCwd,
relativePath: sourceFile,
relativePath,
cause,
}),
),
Expand All @@ -220,7 +220,7 @@ export const make = Effect.gen(function* () {
new ProjectFaviconResolutionError({
operation: "read-source",
workspaceRoot: projectCwd,
relativePath: sourceFile,
relativePath,
absolutePath: sourcePath.absolutePath,
cause,
}),
Expand All @@ -233,7 +233,7 @@ export const make = Effect.gen(function* () {
if (!href) {
continue;
}
const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));
const existing = yield* findExistingFile(projectCwd, resolveIconHref(packageDir, href));
if (existing) {
return existing;
}
Expand All @@ -242,6 +242,95 @@ export const make = Effect.gen(function* () {
return null;
});

/**
* List the workspace package directories of a conventional monorepo, sorted
* within each parent. Parents that do not exist are skipped; other failures
* surface, matching how the well-known candidates are checked.
*/
const findPackageDirectories = Effect.fn("ProjectFaviconResolver.findPackageDirectories")(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
function* (
projectCwd: string,
): Effect.fn.Return<ReadonlyArray<string>, ProjectFaviconResolutionError> {
const directories: Array<string> = [];
for (const parent of MONOREPO_PACKAGE_DIRECTORIES) {
const parentPath = path.join(projectCwd, parent);
const entries = yield* optionOnNotFound(fileSystem.readDirectory(parentPath)).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "list-packages",
workspaceRoot: projectCwd,
relativePath: parent,
absolutePath: parentPath,
cause,
}),
),
);
if (Option.isNone(entries)) {
continue;
}
for (const entry of [...entries.value].sort()) {
const absolutePath = path.join(parentPath, entry);
const stats = yield* optionOnNotFound(fileSystem.stat(absolutePath)).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "list-packages",
workspaceRoot: projectCwd,
relativePath: `${parent}/${entry}`,
absolutePath,
cause,
}),
),
);
if (Option.isSome(stats) && stats.value.type === "Directory") {
directories.push(`${parent}/${entry}`);
}
}
}
return directories;
},
);

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;
}
}

const rootIcon = yield* findIconWithin(projectCwd, "");
if (rootIcon) {
return rootIcon;
}

// Monorepo roots rarely carry an icon of their own; fall back to the
// workspace packages, apps before packages.
for (const packageDir of yield* findPackageDirectories(projectCwd)) {
const packageIcon = yield* findIconWithin(projectCwd, packageDir);
if (packageIcon) {
return packageIcon;
}
}

return null;
});

return ProjectFaviconResolver.of({ resolvePath });
});

Expand Down
Loading