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
1 change: 1 addition & 0 deletions apps/marketing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "astro check"
},
"dependencies": {
"@t3tools/shared": "workspace:*",
"astro": "^7.0.3"
},
"devDependencies": {
Expand Down
10 changes: 10 additions & 0 deletions apps/marketing/src/pages/schema/t3.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { APIRoute } from "astro";

import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile";

// Rendered at build time; published at https://t3.codes/schema/t3.json so
// t3.json files can reference it via "$schema" for editor/LSP support.
export const GET: APIRoute = () =>
new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, {
headers: { "Content-Type": "application/json" },
});
2 changes: 1 addition & 1 deletion apps/marketing/vercel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { VercelConfig } from "@vercel/config/v1";

export const config: VercelConfig = {
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'",
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
buildCommand: "vp run --filter @t3tools/marketing build",
outputDirectory: "dist",
};
6 changes: 5 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";

Expand All @@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
const testLayer = Layer.mergeAll(
configLayer,
WorkspacePaths.layer,
ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
).pipe(Layer.provideMerge(NodeServices.layer));

Expand Down
67 changes: 65 additions & 2 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";

import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

const TestLayer = Layer.empty.pipe(
Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
Layer.provideMerge(
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
),
Layer.provideMerge(NodeServices.layer),
);

Expand All @@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (

const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
ProjectFaviconResolver.make.pipe(
Effect.provide(WorkspacePaths.layer),
Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);

Expand All @@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
}),
);

it.effect("prefers a t3.json iconPath over well-known files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

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

expect(resolved).not.toBeNull();
expect(resolved).toContain("brand/mark.svg");
}),
);

it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

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

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

it.effect("ignores invalid t3.json files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", "{ not json");
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

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

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

it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const parent = yield* makeTempDir;
const cwd = `${parent}/app`;
yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');

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

expect(resolved).toBeNull();
}),
);

it.effect("resolves icon hrefs from project source files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 = [
Expand Down Expand Up @@ -117,6 +118,7 @@ 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(/^\//, "");
Expand Down Expand Up @@ -177,6 +179,15 @@ export const make = Effect.gen(function* () {
}),
),
);
// 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;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

for (const candidate of FAVICON_CANDIDATES) {
const existing = yield* findExistingFile(projectCwd, [candidate]);
if (existing) {
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/project/T3ProjectFileLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
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 T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

const TestLayer = Layer.empty.pipe(
Layer.provideMerge(T3ProjectFileLoader.layer),
Layer.provideMerge(NodeServices.layer),
);

const makeTempDir = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-project-file-",
});
});

const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie);
});

it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
describe("load", () => {
it.effect("loads and decodes a valid t3.json", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(
cwd,
`{
// JSONC is tolerated
"iconPath": "assets/logo.svg",
"scripts": [{ "name": "Dev", "command": "pnpm dev" }],
}`,
);

const loaded = yield* loader.load(cwd);

expect(Option.isSome(loaded)).toBe(true);
if (Option.isSome(loaded)) {
expect(loaded.value.iconPath).toBe("assets/logo.svg");
expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
}
}),
);

it.effect("returns none when t3.json is missing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);

it.effect("returns none for malformed JSON without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, "{ not json");

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);

it.effect("returns none for schema-invalid files without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);
});
});
108 changes: 108 additions & 0 deletions apps/server/src/project/T3ProjectFileLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
* project file from a workspace root.
*
* Loading is best-effort: a missing file resolves to `Option.none`, and
* unreadable or invalid files are logged and treated as absent so callers
* can fall back to their defaults.
*
* @module T3ProjectFileLoader
*/
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 Schema from "effect/Schema";

import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";

const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);

export class T3ProjectFileLoadError extends Schema.TaggedErrorClass<T3ProjectFileLoadError>()(
"T3ProjectFileLoadError",
{
operation: Schema.Literals(["read", "decode"]),
workspaceRoot: Schema.String,
filePath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
}
}

/** Service tag for t3.json project file loading. */
export class T3ProjectFileLoader extends Context.Service<
T3ProjectFileLoader,
{
/**
* Load and decode `t3.json` at the workspace root.
*
* Never fails: missing, unreadable, or invalid files resolve to
* `Option.none` (invalid files are logged as warnings).
*/
readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
}
>()("t3/project/T3ProjectFileLoader") {}

const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
Effect.logWarning(error).pipe(
Effect.annotateLogs({
operation: error.operation,
workspaceRoot: error.workspaceRoot,
filePath: error.filePath,
errorTag: error._tag,
}),
);

export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
function* (workspaceRoot) {
const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
const raw = yield* fileSystem.readFileString(filePath).pipe(
Effect.map(Option.some),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound"
? Effect.succeed(Option.none<string>())
: logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "read",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isNone(raw)) {
return Option.none<T3ProjectFile>();
}
return yield* decodeT3ProjectFileJson(raw.value).pipe(
Effect.map(Option.some),
Effect.catchTags({
SchemaError: (error) =>
logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "decode",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<T3ProjectFile>())),
}),
);
},
);

return T3ProjectFileLoader.of({ load });
});

export const layer = Layer.effect(T3ProjectFileLoader, make);
6 changes: 5 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
Expand Down Expand Up @@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {
Layer.provide(WorkspacePaths.layer),
Layer.provide(workspaceEntriesLayer),
),
ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
);
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
Layer.provideMerge(vcsDriverRegistryLayer),
Expand Down
Loading
Loading