Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,14 @@ describe("AssetAccess", () => {
const updatedFavicon = "<svg>b</svg>";
expect(updatedFavicon).toHaveLength(initialFavicon.length);
yield* fileSystem.writeFileString(faviconPath, initialFavicon);
yield* fileSystem.writeFileString(path.join(root, "t3.json"), '{ "accentColor": "#1688f0" }');
const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);

const faviconResult = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
});
expect(faviconResult.sourcePath).toBe("favicon.svg");
expect(faviconResult.projectAccent).toBe("#1688f0");
expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
expect(
yield* issueAssetUrl({
Expand Down Expand Up @@ -427,6 +429,7 @@ describe("AssetAccess", () => {
cause: platformCause,
});
const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
resolveAccent: () => Effect.succeed(null),
resolvePath: () => Effect.fail(resolutionCause),
});

Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AssetResource } from "@t3tools/contracts";
import type { AssetResource, ProjectAccent } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetPreviewTypeValidationError,
Expand Down Expand Up @@ -195,6 +195,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
let claims: AssetClaims;
let fileName: string;
let sourcePath: string | undefined;
let projectAccent: ProjectAccent | undefined;

switch (input.resource._tag) {
case "workspace-file": {
Expand Down Expand Up @@ -306,6 +307,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
),
);
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
projectAccent = yield* faviconResolver.resolveAccent(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconResolutionError({
resource: input.resource,
cause,
}),
),
Effect.map((color) => color ?? undefined),
);
const faviconPath = yield* faviconResolver
.resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
.pipe(
Expand Down Expand Up @@ -424,6 +435,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
expiresAt,
...(sourcePath !== undefined ? { sourcePath } : {}),
...(projectAccent !== undefined ? { projectAccent } : {}),
};
});

Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
);

it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
describe("resolveAccent", () => {
it.effect("reads the validated t3.json accent color", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "#1688f0" }');

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toBe("#1688f0");
}),
);

it.effect("reads exact accent colors for every row state", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"t3.json",
'{ "accentColor": { "idle": "#071525", "hover": "#102b46", "selected": "#173b60" } }',
);

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toEqual({
idle: "#071525",
hover: "#102b46",
selected: "#173b60",
});
}),
);

it.effect("returns null when the project file is missing or invalid", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;

const missingAccentColor = yield* resolver.resolveAccent(cwd);
expect(missingAccentColor).toBeNull();
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "blue" }');
const invalidAccentColor = yield* resolver.resolveAccent(cwd);
expect(invalidAccentColor).toBeNull();
}),
);
});

describe("resolvePath", () => {
it.effect("prefers well-known favicon files", () =>
Effect.gen(function* () {
Expand Down
37 changes: 26 additions & 11 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @module ProjectFaviconResolver
*/
import type { ProjectAccent } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -96,6 +97,9 @@ export class ProjectFaviconResolver extends Context.Service<
cwd: string,
faviconPath?: string,
) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
readonly resolveAccent: (
cwd: string,
) => Effect.Effect<ProjectAccent | null, ProjectFaviconResolutionError>;
}
>()("t3/project/ProjectFaviconResolver") {}

Expand Down Expand Up @@ -129,6 +133,18 @@ export const make = Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

const normalizeWorkspaceRoot = (cwd: string) =>
workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);

const resolveIconHref = (href: string): ReadonlyArray<string> => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
Expand Down Expand Up @@ -181,16 +197,7 @@ export const make = Effect.gen(function* () {
const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd, faviconPath) {
const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
// 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) {
Expand Down Expand Up @@ -267,7 +274,15 @@ export const make = Effect.gen(function* () {
return null;
});

return ProjectFaviconResolver.of({ resolvePath });
const resolveAccent: ProjectFaviconResolver["Service"]["resolveAccent"] = Effect.fn(
"ProjectFaviconResolver.resolveAccent",
)(function* (cwd) {
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
const projectFile = yield* projectFileLoader.load(projectCwd);
return Option.isSome(projectFile) ? (projectFile.value.accentColor ?? null) : null;
});

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

export const layer = Layer.effect(ProjectFaviconResolver, make);
12 changes: 10 additions & 2 deletions apps/web/src/assets/assetUrls.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import type { AssetResource, EnvironmentId, ProjectAccent } from "@t3tools/contracts";
import { AsyncResult } from "effect/unstable/reactivity";
import { useMemo } from "react";

Expand All @@ -12,7 +12,12 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
export type AssetUrlState =
| { readonly _tag: "Loading" }
| { readonly _tag: "Failure" }
| { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string };
| {
readonly _tag: "Success";
readonly url: string;
readonly sourcePath?: string;
readonly projectAccent?: ProjectAccent;
};

export function useAssetUrlState(
environmentId: EnvironmentId,
Expand All @@ -38,6 +43,9 @@ export function useAssetUrlState(
_tag: "Success",
url,
...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
...(result.value.projectAccent !== undefined
? { projectAccent: result.value.projectAccent }
: {}),
};
}

Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ vi.mock("../assets/assetUrls", () => ({
},
}));

import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFavicon, projectAccentFromAsset } from "./ProjectFavicon";

type ProjectFaviconImageProps = {
readonly cacheKey: string;
Expand Down Expand Up @@ -143,4 +143,25 @@ describe("ProjectFavicon", () => {
path: "brand/icon.svg",
});
});

it("reads simple and advanced accents from the shared project asset", () => {
expect(
projectAccentFromAsset({
_tag: "Success",
url: testState.faviconUrl,
projectAccent: "#1688f0",
}),
).toBe("#1688f0");
expect(
projectAccentFromAsset({
_tag: "Success",
url: testState.faviconUrl,
projectAccent: {
idle: "#071525",
hover: "#102b46",
selected: "#173b60",
},
}),
).toEqual({ idle: "#071525", hover: "#102b46", selected: "#173b60" });
});
});
27 changes: 24 additions & 3 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { EnvironmentId, ProjectAccent } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
Expand All @@ -11,14 +11,29 @@ import { cn } from "~/lib/utils";

const loadedProjectFaviconSrcs = new Map<string, string>();

export function ProjectFavicon(input: {
interface ProjectFaviconInput {
environmentId: EnvironmentId;
cwd: string;
faviconPath?: string | null | undefined;
className?: string | undefined;
fallbackIcon?: ComponentType<{ className?: string }>;
}) {
}

export function ProjectFavicon(input: ProjectFaviconInput) {
const state = useProjectFaviconAsset(input);
return renderProjectFavicon(input, state);
}

export function ProjectFaviconFromAsset(
input: ProjectFaviconInput & { readonly state: ReturnType<typeof useProjectFaviconAsset> },
) {
return renderProjectFavicon(input, input.state);
}

function renderProjectFavicon(
input: ProjectFaviconInput,
state: ReturnType<typeof useProjectFaviconAsset>,
) {
const src = state._tag === "Success" ? state.url : null;
const FallbackIcon = input.fallbackIcon ?? FolderIcon;

Expand Down Expand Up @@ -51,6 +66,12 @@ export function useProjectFaviconAsset(input: {
});
}

export function projectAccentFromAsset(
state: ReturnType<typeof useProjectFaviconAsset>,
): ProjectAccent | null {
return state._tag === "Success" ? (state.projectAccent ?? null) : null;
}

function ProjectFaviconFallback({
className,
icon: Icon,
Expand Down
Loading
Loading