Skip to content

Commit 3196011

Browse files
juliusmarmingecursoragent
authored andcommitted
Add shared t3.json project configuration support (pingdotgg#4317)
(cherry picked from commit 1c9a6de)
1 parent f9c8f28 commit 3196011

22 files changed

Lines changed: 697 additions & 10 deletions

apps/marketing/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"typecheck": "astro check"
1111
},
1212
"dependencies": {
13+
"@t3tools/shared": "workspace:*",
1314
"astro": "^7.0.3"
1415
},
1516
"devDependencies": {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { APIRoute } from "astro";
2+
3+
import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile";
4+
5+
// Rendered at build time; published at https://t3.codes/schema/t3.json so
6+
// t3.json files can reference it via "$schema" for editor/LSP support.
7+
export const GET: APIRoute = () =>
8+
new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, {
9+
headers: { "Content-Type": "application/json" },
10+
});

apps/marketing/vercel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { VercelConfig } from "@vercel/config/v1";
22

33
export const config: VercelConfig = {
4-
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'",
4+
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
55
buildCommand: "vp run --filter @t3tools/marketing build",
66
outputDirectory: "dist",
77
};

apps/server/src/assets/AssetAccess.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
1111
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1212
import * as ServerConfig from "../config.ts";
1313
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
14+
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
1415
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1516
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
1617

@@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2021
const testLayer = Layer.mergeAll(
2122
configLayer,
2223
WorkspacePaths.layer,
23-
ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
24+
ProjectFaviconResolver.layer.pipe(
25+
Layer.provide(WorkspacePaths.layer),
26+
Layer.provide(T3ProjectFileLoader.layer),
27+
),
2428
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
2529
).pipe(Layer.provideMerge(NodeServices.layer));
2630

apps/server/src/project/ProjectFaviconResolver.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";
88

99
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1010
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
11+
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1112

1213
const TestLayer = Layer.empty.pipe(
13-
Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
14+
Layer.provideMerge(
15+
ProjectFaviconResolver.layer.pipe(
16+
Layer.provide(WorkspacePaths.layer),
17+
Layer.provide(T3ProjectFileLoader.layer),
18+
),
19+
),
1420
Layer.provideMerge(NodeServices.layer),
1521
);
1622

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

3844
const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
3945
ProjectFaviconResolver.make.pipe(
40-
Effect.provide(WorkspacePaths.layer),
46+
Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
4147
Effect.provideService(FileSystem.FileSystem, fileSystem),
4248
);
4349

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

65+
it.effect("prefers a t3.json iconPath over well-known files", () =>
66+
Effect.gen(function* () {
67+
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
68+
const cwd = yield* makeTempDir;
69+
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
70+
yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
71+
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
72+
73+
const resolved = yield* resolver.resolvePath(cwd);
74+
75+
expect(resolved).not.toBeNull();
76+
expect(resolved).toContain("brand/mark.svg");
77+
}),
78+
);
79+
80+
it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
81+
Effect.gen(function* () {
82+
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
83+
const cwd = yield* makeTempDir;
84+
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
85+
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
86+
87+
const resolved = yield* resolver.resolvePath(cwd);
88+
89+
expect(resolved).not.toBeNull();
90+
expect(resolved).toContain("favicon.svg");
91+
}),
92+
);
93+
94+
it.effect("ignores invalid t3.json files", () =>
95+
Effect.gen(function* () {
96+
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
97+
const cwd = yield* makeTempDir;
98+
yield* writeTextFile(cwd, "t3.json", "{ not json");
99+
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
100+
101+
const resolved = yield* resolver.resolvePath(cwd);
102+
103+
expect(resolved).not.toBeNull();
104+
expect(resolved).toContain("favicon.svg");
105+
}),
106+
);
107+
108+
it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
109+
Effect.gen(function* () {
110+
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
111+
const parent = yield* makeTempDir;
112+
const cwd = `${parent}/app`;
113+
yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
114+
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');
115+
116+
const resolved = yield* resolver.resolvePath(cwd);
117+
118+
expect(resolved).toBeNull();
119+
}),
120+
);
121+
59122
it.effect("resolves icon hrefs from project source files", () =>
60123
Effect.gen(function* () {
61124
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;

apps/server/src/project/ProjectFaviconResolver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";
1616
import * as Schema from "effect/Schema";
1717

1818
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
19+
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1920

2021
// Well-known favicon paths checked in order.
2122
const FAVICON_CANDIDATES = [
@@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {
117118
const fileSystem = yield* FileSystem.FileSystem;
118119
const path = yield* Path.Path;
119120
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
121+
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
120122

121123
const resolveIconHref = (href: string): ReadonlyArray<string> => {
122124
const clean = href.replace(/^\//, "");
@@ -197,6 +199,15 @@ export const make = Effect.gen(function* () {
197199
}),
198200
),
199201
);
202+
// A t3.json iconPath takes precedence over the well-known locations.
203+
const projectFile = yield* projectFileLoader.load(projectCwd);
204+
if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
205+
const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]);
206+
if (existing) {
207+
return existing;
208+
}
209+
}
210+
200211
for (const candidate of FAVICON_CANDIDATES) {
201212
const existing = yield* findExistingFile(projectCwd, [candidate]);
202213
if (existing) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { it, describe, expect } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Layer from "effect/Layer";
6+
import * as Option from "effect/Option";
7+
import * as Path from "effect/Path";
8+
9+
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
10+
11+
const TestLayer = Layer.empty.pipe(
12+
Layer.provideMerge(T3ProjectFileLoader.layer),
13+
Layer.provideMerge(NodeServices.layer),
14+
);
15+
16+
const makeTempDir = Effect.gen(function* () {
17+
const fileSystem = yield* FileSystem.FileSystem;
18+
return yield* fileSystem.makeTempDirectoryScoped({
19+
prefix: "t3code-project-file-",
20+
});
21+
});
22+
23+
const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) {
24+
const fileSystem = yield* FileSystem.FileSystem;
25+
const path = yield* Path.Path;
26+
yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie);
27+
});
28+
29+
it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
30+
describe("load", () => {
31+
it.effect("loads and decodes a valid t3.json", () =>
32+
Effect.gen(function* () {
33+
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
34+
const cwd = yield* makeTempDir;
35+
yield* writeProjectFile(
36+
cwd,
37+
`{
38+
// JSONC is tolerated
39+
"iconPath": "assets/logo.svg",
40+
"scripts": [{ "name": "Dev", "command": "pnpm dev" }],
41+
}`,
42+
);
43+
44+
const loaded = yield* loader.load(cwd);
45+
46+
expect(Option.isSome(loaded)).toBe(true);
47+
if (Option.isSome(loaded)) {
48+
expect(loaded.value.iconPath).toBe("assets/logo.svg");
49+
expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
50+
}
51+
}),
52+
);
53+
54+
it.effect("returns none when t3.json is missing", () =>
55+
Effect.gen(function* () {
56+
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
57+
const cwd = yield* makeTempDir;
58+
59+
const loaded = yield* loader.load(cwd);
60+
61+
expect(Option.isNone(loaded)).toBe(true);
62+
}),
63+
);
64+
65+
it.effect("returns none for malformed JSON without failing", () =>
66+
Effect.gen(function* () {
67+
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
68+
const cwd = yield* makeTempDir;
69+
yield* writeProjectFile(cwd, "{ not json");
70+
71+
const loaded = yield* loader.load(cwd);
72+
73+
expect(Option.isNone(loaded)).toBe(true);
74+
}),
75+
);
76+
77+
it.effect("returns none for schema-invalid files without failing", () =>
78+
Effect.gen(function* () {
79+
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
80+
const cwd = yield* makeTempDir;
81+
yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');
82+
83+
const loaded = yield* loader.load(cwd);
84+
85+
expect(Option.isNone(loaded)).toBe(true);
86+
}),
87+
);
88+
});
89+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
3+
* project file from a workspace root.
4+
*
5+
* Loading is best-effort: a missing file resolves to `Option.none`, and
6+
* unreadable or invalid files are logged and treated as absent so callers
7+
* can fall back to their defaults.
8+
*
9+
* @module T3ProjectFileLoader
10+
*/
11+
import * as Context from "effect/Context";
12+
import * as Effect from "effect/Effect";
13+
import * as FileSystem from "effect/FileSystem";
14+
import * as Layer from "effect/Layer";
15+
import * as Option from "effect/Option";
16+
import * as Path from "effect/Path";
17+
import * as Schema from "effect/Schema";
18+
19+
import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
20+
import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
21+
22+
const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);
23+
24+
export class T3ProjectFileLoadError extends Schema.TaggedErrorClass<T3ProjectFileLoadError>()(
25+
"T3ProjectFileLoadError",
26+
{
27+
operation: Schema.Literals(["read", "decode"]),
28+
workspaceRoot: Schema.String,
29+
filePath: Schema.String,
30+
cause: Schema.Defect(),
31+
},
32+
) {
33+
override get message(): string {
34+
return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
35+
}
36+
}
37+
38+
/** Service tag for t3.json project file loading. */
39+
export class T3ProjectFileLoader extends Context.Service<
40+
T3ProjectFileLoader,
41+
{
42+
/**
43+
* Load and decode `t3.json` at the workspace root.
44+
*
45+
* Never fails: missing, unreadable, or invalid files resolve to
46+
* `Option.none` (invalid files are logged as warnings).
47+
*/
48+
readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
49+
}
50+
>()("t3/project/T3ProjectFileLoader") {}
51+
52+
const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
53+
Effect.logWarning(error).pipe(
54+
Effect.annotateLogs({
55+
operation: error.operation,
56+
workspaceRoot: error.workspaceRoot,
57+
filePath: error.filePath,
58+
errorTag: error._tag,
59+
}),
60+
);
61+
62+
export const make = Effect.gen(function* () {
63+
const fileSystem = yield* FileSystem.FileSystem;
64+
const path = yield* Path.Path;
65+
66+
const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
67+
function* (workspaceRoot) {
68+
const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
69+
const raw = yield* fileSystem.readFileString(filePath).pipe(
70+
Effect.map(Option.some),
71+
Effect.catchTags({
72+
PlatformError: (error) =>
73+
error.reason._tag === "NotFound"
74+
? Effect.succeed(Option.none<string>())
75+
: logT3ProjectFileLoadError(
76+
new T3ProjectFileLoadError({
77+
operation: "read",
78+
workspaceRoot,
79+
filePath,
80+
cause: error,
81+
}),
82+
).pipe(Effect.as(Option.none<string>())),
83+
}),
84+
);
85+
if (Option.isNone(raw)) {
86+
return Option.none<T3ProjectFile>();
87+
}
88+
return yield* decodeT3ProjectFileJson(raw.value).pipe(
89+
Effect.map(Option.some),
90+
Effect.catchTags({
91+
SchemaError: (error) =>
92+
logT3ProjectFileLoadError(
93+
new T3ProjectFileLoadError({
94+
operation: "decode",
95+
workspaceRoot,
96+
filePath,
97+
cause: error,
98+
}),
99+
).pipe(Effect.as(Option.none<T3ProjectFile>())),
100+
}),
101+
);
102+
},
103+
);
104+
105+
return T3ProjectFileLoader.of({ load });
106+
});
107+
108+
export const layer = Layer.effect(T3ProjectFileLoader, make);

apps/server/src/server.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts";
9292
import * as PortScanner from "./preview/PortScanner.ts";
9393
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
9494
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
95+
import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
9596
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
9697
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
9798
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
@@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {
501502
Layer.provide(WorkspacePaths.layer),
502503
Layer.provide(workspaceEntriesLayer),
503504
),
504-
ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
505+
ProjectFaviconResolver.layer.pipe(
506+
Layer.provide(WorkspacePaths.layer),
507+
Layer.provide(T3ProjectFileLoader.layer),
508+
),
505509
);
506510
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
507511
Layer.provideMerge(vcsDriverRegistryLayer),

0 commit comments

Comments
 (0)