| author | |
| committer | |
| log | 1c9a6de26eb41f77dbf2f6039427f50f2e9f8b39 |
| tree | 1721e9037ccd3b589af0710cd32e1c4281b6fc76 |
| parent | fbd77420f27c1322d49726dd927861b8b5903494 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 697 insertions(+), 10 deletions(-)
apps/marketing/package.json+1| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | "typecheck": "astro check" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | "@t3tools/shared": "workspace:*", | |
| 13 | 14 | "astro": "^7.0.3" |
| 14 | 15 | }, |
| 15 | 16 | "devDependencies": { |
apps/marketing/src/pages/schema/t3.json.ts created+10| ... | ... | @@ -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+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { VercelConfig } from "@vercel/config/v1"; |
| 2 | 2 | |
| 3 | 3 | 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...'", | |
| 5 | 5 | buildCommand: "vp run --filter @t3tools/marketing build", |
| 6 | 6 | outputDirectory: "dist", |
| 7 | 7 | }; |
apps/server/src/assets/AssetAccess.test.ts+5-1| ... | ... | @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | 13 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 14 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | |
| 14 | 15 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 15 | 16 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; |
| 16 | 17 | |
| ... | ... | @@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 20 | 21 | const testLayer = Layer.mergeAll( |
| 21 | 22 | configLayer, |
| 22 | 23 | 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 | ), | |
| 24 | 28 | ServerSecretStore.layer.pipe(Layer.provide(configLayer)), |
| 25 | 29 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 26 | 30 |
apps/server/src/project/ProjectFaviconResolver.test.ts+65-2| ... | ... | @@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError"; |
| 8 | 8 | |
| 9 | 9 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 10 | 10 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| 11 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; | |
| 11 | 12 | |
| 12 | 13 | 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 | ), | |
| 14 | 20 | Layer.provideMerge(NodeServices.layer), |
| 15 | 21 | ); |
| 16 | 22 | |
| ... | ... | @@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 37 | 43 | |
| 38 | 44 | const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => |
| 39 | 45 | ProjectFaviconResolver.make.pipe( |
| 40 | Effect.provide(WorkspacePaths.layer), | |
| 46 | Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]), | |
| 41 | 47 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 42 | 48 | ); |
| 43 | 49 | |
| ... | ... | @@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { |
| 56 | 62 | }), |
| 57 | 63 | ); |
| 58 | 64 | |
| 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 | ||
| 59 | 122 | it.effect("resolves icon hrefs from project source files", () => |
| 60 | 123 | Effect.gen(function* () { |
| 61 | 124 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
apps/server/src/project/ProjectFaviconResolver.ts+11| ... | ... | @@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 16 | 16 | import * as Schema from "effect/Schema"; |
| 17 | 17 | |
| 18 | 18 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 19 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; | |
| 19 | 20 | |
| 20 | 21 | // Well-known favicon paths checked in order. |
| 21 | 22 | const FAVICON_CANDIDATES = [ |
| ... | ... | @@ -117,6 +118,7 @@ export const make = Effect.gen(function* () { |
| 117 | 118 | const fileSystem = yield* FileSystem.FileSystem; |
| 118 | 119 | const path = yield* Path.Path; |
| 119 | 120 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 121 | const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; | |
| 120 | 122 | |
| 121 | 123 | const resolveIconHref = (href: string): ReadonlyArray<string> => { |
| 122 | 124 | const clean = href.replace(/^\//, ""); |
| ... | ... | @@ -177,6 +179,15 @@ export const make = Effect.gen(function* () { |
| 177 | 179 | }), |
| 178 | 180 | ), |
| 179 | 181 | ); |
| 182 | // A t3.json iconPath takes precedence over the well-known locations. | |
| 183 | const projectFile = yield* projectFileLoader.load(projectCwd); | |
| 184 | if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { | |
| 185 | const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); | |
| 186 | if (existing) { | |
| 187 | return existing; | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 180 | 191 | for (const candidate of FAVICON_CANDIDATES) { |
| 181 | 192 | const existing = yield* findExistingFile(projectCwd, [candidate]); |
| 182 | 193 | if (existing) { |
apps/server/src/project/T3ProjectFileLoader.test.ts created+89| ... | ... | @@ -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 | }); |
apps/server/src/project/T3ProjectFileLoader.ts created+108| ... | ... | @@ -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+5-1| ... | ... | @@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts"; |
| 92 | 92 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 93 | 93 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 94 | 94 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 95 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | |
| 95 | 96 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 96 | 97 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 97 | 98 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| ... | ... | @@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: { |
| 501 | 502 | Layer.provide(WorkspacePaths.layer), |
| 502 | 503 | Layer.provide(workspaceEntriesLayer), |
| 503 | 504 | ), |
| 504 | ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), | |
| 505 | ProjectFaviconResolver.layer.pipe( | |
| 506 | Layer.provide(WorkspacePaths.layer), | |
| 507 | Layer.provide(T3ProjectFileLoader.layer), | |
| 508 | ), | |
| 505 | 509 | ); |
| 506 | 510 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( |
| 507 | 511 | Layer.provideMerge(vcsDriverRegistryLayer), |
apps/server/src/server.ts+2| ... | ... | @@ -54,6 +54,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 54 | 54 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 55 | 55 | import * as ServerSettings from "./serverSettings.ts"; |
| 56 | 56 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 57 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | |
| 57 | 58 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 58 | 59 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 59 | 60 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| ... | ... | @@ -265,6 +266,7 @@ const WorkspaceLayerLive = Layer.mergeAll( |
| 265 | 266 | |
| 266 | 267 | const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( |
| 267 | 268 | Layer.provide(WorkspacePaths.layer), |
| 269 | Layer.provide(T3ProjectFileLoader.layer), | |
| 268 | 270 | ); |
| 269 | 271 | |
| 270 | 272 | const AuthLayerLive = EnvironmentAuth.layer.pipe( |
apps/web/src/components/ProjectScriptsControl.tsx+98-1| ... | ... | @@ -2,6 +2,7 @@ import type { |
| 2 | 2 | ProjectScript, |
| 3 | 3 | ProjectScriptIcon, |
| 4 | 4 | ResolvedKeybindingsConfig, |
| 5 | T3ProjectFileScript, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { |
| 7 | 8 | isAtomCommandInterrupted, |
| ... | ... | @@ -11,6 +12,7 @@ import { |
| 11 | 12 | import { |
| 12 | 13 | BugIcon, |
| 13 | 14 | ChevronDownIcon, |
| 15 | DownloadIcon, | |
| 14 | 16 | FlaskConicalIcon, |
| 15 | 17 | HammerIcon, |
| 16 | 18 | ListChecksIcon, |
| ... | ... | @@ -54,7 +56,16 @@ import { |
| 54 | 56 | import { Group, GroupSeparator } from "./ui/group"; |
| 55 | 57 | import { Input } from "./ui/input"; |
| 56 | 58 | import { Label } from "./ui/label"; |
| 57 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | |
| 59 | import { | |
| 60 | Menu, | |
| 61 | MenuGroup, | |
| 62 | MenuGroupLabel, | |
| 63 | MenuItem, | |
| 64 | MenuPopup, | |
| 65 | MenuSeparator, | |
| 66 | MenuShortcut, | |
| 67 | MenuTrigger, | |
| 68 | } from "./ui/menu"; | |
| 58 | 69 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 59 | 70 | import { Switch } from "./ui/switch"; |
| 60 | 71 | import { Textarea } from "./ui/textarea"; |
| ... | ... | @@ -98,8 +109,12 @@ export interface NewProjectScriptInput { |
| 98 | 109 | |
| 99 | 110 | export type ProjectScriptActionResult = AtomCommandResult<void, unknown>; |
| 100 | 111 | |
| 112 | const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; | |
| 113 | ||
| 101 | 114 | interface ProjectScriptsControlProps { |
| 102 | 115 | scripts: ReadonlyArray<ProjectScript>; |
| 116 | /** Scripts declared in the project's checked-in t3.json, offered for import. */ | |
| 117 | fileScripts?: ReadonlyArray<T3ProjectFileScript>; | |
| 103 | 118 | keybindings: ResolvedKeybindingsConfig; |
| 104 | 119 | preferredScriptId?: string | null; |
| 105 | 120 | onRunScript: (script: ProjectScript) => void; |
| ... | ... | @@ -113,6 +128,7 @@ interface ProjectScriptsControlProps { |
| 113 | 128 | |
| 114 | 129 | export default function ProjectScriptsControl({ |
| 115 | 130 | scripts, |
| 131 | fileScripts = NO_FILE_SCRIPTS, | |
| 116 | 132 | keybindings, |
| 117 | 133 | preferredScriptId = null, |
| 118 | 134 | onRunScript, |
| ... | ... | @@ -141,6 +157,18 @@ export default function ProjectScriptsControl({ |
| 141 | 157 | } |
| 142 | 158 | return primaryProjectScript(scripts); |
| 143 | 159 | }, [preferredScriptId, scripts]); |
| 160 | const importableScripts = useMemo( | |
| 161 | () => | |
| 162 | fileScripts.filter( | |
| 163 | (fileScript) => | |
| 164 | !scripts.some( | |
| 165 | (script) => | |
| 166 | script.command === fileScript.command || | |
| 167 | script.name.toLowerCase() === fileScript.name.toLowerCase(), | |
| 168 | ), | |
| 169 | ), | |
| 170 | [fileScripts, scripts], | |
| 171 | ); | |
| 144 | 172 | const isEditing = editingScriptId !== null; |
| 145 | 173 | const dropdownItemClassName = |
| 146 | 174 | "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; |
| ... | ... | @@ -247,6 +275,57 @@ export default function ProjectScriptsControl({ |
| 247 | 275 | void onDeleteScript(editingScriptId); |
| 248 | 276 | }, [editingScriptId, onDeleteScript]); |
| 249 | 277 | |
| 278 | const importFileScript = async (fileScript: T3ProjectFileScript) => { | |
| 279 | const payload: NewProjectScriptInput = { | |
| 280 | name: fileScript.name, | |
| 281 | command: fileScript.command, | |
| 282 | icon: fileScript.icon ?? "play", | |
| 283 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, | |
| 284 | keybinding: null, | |
| 285 | previewUrl: fileScript.previewUrl ?? null, | |
| 286 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, | |
| 287 | }; | |
| 288 | const result = await onAddScript(payload); | |
| 289 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 290 | // Surface the failure through the regular add dialog, prefilled so the | |
| 291 | // user can adjust and retry. | |
| 292 | const error = squashAtomCommandFailure(result); | |
| 293 | setEditingScriptId(null); | |
| 294 | setName(payload.name); | |
| 295 | setCommand(payload.command); | |
| 296 | setIcon(payload.icon); | |
| 297 | setIconPickerOpen(false); | |
| 298 | setRunOnWorktreeCreate(payload.runOnWorktreeCreate); | |
| 299 | setKeybinding(""); | |
| 300 | setPreviewUrl(payload.previewUrl ?? ""); | |
| 301 | setAutoOpenPreview(payload.autoOpenPreview); | |
| 302 | setValidationError(error instanceof Error ? error.message : "Failed to import action."); | |
| 303 | setDialogOpen(true); | |
| 304 | } | |
| 305 | }; | |
| 306 | ||
| 307 | const importMenuItems = importableScripts.length > 0 && ( | |
| 308 | <> | |
| 309 | {primaryScript && <MenuSeparator />} | |
| 310 | <MenuGroup> | |
| 311 | <MenuGroupLabel>From t3.json</MenuGroupLabel> | |
| 312 | {importableScripts.map((fileScript) => ( | |
| 313 | <MenuItem | |
| 314 | key={`${fileScript.name} ${fileScript.command}`} | |
| 315 | className={dropdownItemClassName} | |
| 316 | onClick={() => void importFileScript(fileScript)} | |
| 317 | > | |
| 318 | <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" /> | |
| 319 | <span className="truncate">{fileScript.name}</span> | |
| 320 | <MenuShortcut className="ms-auto"> | |
| 321 | <DownloadIcon className="size-3.5" aria-label="Import" /> | |
| 322 | </MenuShortcut> | |
| 323 | </MenuItem> | |
| 324 | ))} | |
| 325 | </MenuGroup> | |
| 326 | </> | |
| 327 | ); | |
| 328 | ||
| 250 | 329 | return ( |
| 251 | 330 | <> |
| 252 | 331 | {primaryScript ? ( |
| ... | ... | @@ -320,6 +399,7 @@ export default function ProjectScriptsControl({ |
| 320 | 399 | </MenuItem> |
| 321 | 400 | ); |
| 322 | 401 | })} |
| 402 | {importMenuItems} | |
| 323 | 403 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> |
| 324 | 404 | <PlusIcon className="size-4" /> |
| 325 | 405 | Add action |
| ... | ... | @@ -327,6 +407,23 @@ export default function ProjectScriptsControl({ |
| 327 | 407 | </MenuPopup> |
| 328 | 408 | </Menu> |
| 329 | 409 | </Group> |
| 410 | ) : importableScripts.length > 0 ? ( | |
| 411 | <Menu highlightItemOnHover={false}> | |
| 412 | <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}> | |
| 413 | <PlusIcon className="size-3.5" /> | |
| 414 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | |
| 415 | Add action | |
| 416 | </span> | |
| 417 | <ChevronDownIcon className="size-3.5" /> | |
| 418 | </MenuTrigger> | |
| 419 | <MenuPopup align="end"> | |
| 420 | {importMenuItems} | |
| 421 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> | |
| 422 | <PlusIcon className="size-4" /> | |
| 423 | Add action | |
| 424 | </MenuItem> | |
| 425 | </MenuPopup> | |
| 426 | </Menu> | |
| 330 | 427 | ) : ( |
| 331 | 428 | <Tooltip> |
| 332 | 429 | <TooltipTrigger |
apps/web/src/components/chat/ChatHeader.tsx+6| ... | ... | @@ -16,6 +16,7 @@ import ProjectScriptsControl, { |
| 16 | 16 | } from "../ProjectScriptsControl"; |
| 17 | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 18 | 18 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 19 | import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; | |
| 19 | 20 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 20 | 21 | import { cn } from "~/lib/utils"; |
| 21 | 22 | |
| ... | ... | @@ -74,6 +75,10 @@ export const ChatHeader = memo(function ChatHeader({ |
| 74 | 75 | onDeleteProjectScript, |
| 75 | 76 | }: ChatHeaderProps) { |
| 76 | 77 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 78 | const fileScripts = useT3ProjectFileScripts( | |
| 79 | activeThreadEnvironmentId, | |
| 80 | activeProjectScripts ? activeProjectCwd : null, | |
| 81 | ); | |
| 77 | 82 | const showOpenInPicker = shouldShowOpenInPicker({ |
| 78 | 83 | activeProjectName, |
| 79 | 84 | activeThreadEnvironmentId, |
| ... | ... | @@ -126,6 +131,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 126 | 131 | {activeProjectScripts && ( |
| 127 | 132 | <ProjectScriptsControl |
| 128 | 133 | scripts={activeProjectScripts} |
| 134 | fileScripts={fileScripts} | |
| 129 | 135 | keybindings={keybindings} |
| 130 | 136 | preferredScriptId={preferredScriptId} |
| 131 | 137 | onRunScript={onRunProjectScript} |
apps/web/src/hooks/useT3ProjectFileScripts.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import { | |
| 2 | T3_PROJECT_FILE_NAME, | |
| 3 | type EnvironmentId, | |
| 4 | type T3ProjectFileScript, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile"; | |
| 7 | import * as Exit from "effect/Exit"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { useMemo } from "react"; | |
| 10 | ||
| 11 | import { useProjectFileQuery } from "~/components/files/projectFilesQueryState"; | |
| 12 | ||
| 13 | const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); | |
| 14 | ||
| 15 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; | |
| 16 | ||
| 17 | /** | |
| 18 | * Scripts declared in the project's checked-in `t3.json`, offered in the | |
| 19 | * scripts menu for import. Missing, truncated, or invalid files resolve to | |
| 20 | * an empty list. | |
| 21 | */ | |
| 22 | export function useT3ProjectFileScripts( | |
| 23 | environmentId: EnvironmentId, | |
| 24 | cwd: string | null, | |
| 25 | ): ReadonlyArray<T3ProjectFileScript> { | |
| 26 | const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null); | |
| 27 | const contents = query.data && !query.data.truncated ? query.data.contents : null; | |
| 28 | return useMemo(() => { | |
| 29 | if (contents === null) return NO_SCRIPTS; | |
| 30 | const decoded = decodeT3ProjectFile(contents); | |
| 31 | if (Exit.isFailure(decoded)) return NO_SCRIPTS; | |
| 32 | return decoded.value.scripts ?? NO_SCRIPTS; | |
| 33 | }, [contents]); | |
| 34 | } |
favicon.svg deleted-4| ... | ... | @@ -1,4 +0,0 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
packages/contracts/src/index.ts+1| ... | ... | @@ -18,6 +18,7 @@ export * from "./git.ts"; |
| 18 | 18 | export * from "./vcs.ts"; |
| 19 | 19 | export * from "./sourceControl.ts"; |
| 20 | 20 | export * from "./orchestration.ts"; |
| 21 | export * from "./t3ProjectFile.ts"; | |
| 21 | 22 | export * from "./editor.ts"; |
| 22 | 23 | export * from "./project.ts"; |
| 23 | 24 | export * from "./filesystem.ts"; |
packages/contracts/src/t3ProjectFile.test.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { T3ProjectFile } from "./t3ProjectFile.ts"; | |
| 5 | ||
| 6 | const decode = Schema.decodeUnknownSync(T3ProjectFile); | |
| 7 | ||
| 8 | describe("T3ProjectFile", () => { | |
| 9 | it("decodes a full project file", () => { | |
| 10 | const decoded = decode({ | |
| 11 | $schema: "https://t3.codes/schema/t3.json", | |
| 12 | iconPath: "assets/logo.svg", | |
| 13 | scripts: [ | |
| 14 | { | |
| 15 | name: "Dev", | |
| 16 | command: "pnpm dev", | |
| 17 | icon: "play", | |
| 18 | runOnWorktreeCreate: false, | |
| 19 | previewUrl: "http://localhost:3000", | |
| 20 | autoOpenPreview: true, | |
| 21 | }, | |
| 22 | { name: "Test", command: "pnpm test" }, | |
| 23 | ], | |
| 24 | }); | |
| 25 | ||
| 26 | expect(decoded.iconPath).toBe("assets/logo.svg"); | |
| 27 | expect(decoded.scripts).toHaveLength(2); | |
| 28 | expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" }); | |
| 29 | }); | |
| 30 | ||
| 31 | it("decodes an empty object and ignores unknown fields", () => { | |
| 32 | expect(decode({})).toEqual({}); | |
| 33 | expect(decode({ futureField: true })).toEqual({}); | |
| 34 | }); | |
| 35 | ||
| 36 | it("trims icon paths and script fields", () => { | |
| 37 | const decoded = decode({ | |
| 38 | iconPath: " assets/logo.svg ", | |
| 39 | scripts: [{ name: " Dev ", command: " pnpm dev " }], | |
| 40 | }); | |
| 41 | ||
| 42 | expect(decoded.iconPath).toBe("assets/logo.svg"); | |
| 43 | expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" }); | |
| 44 | }); | |
| 45 | ||
| 46 | it("rejects scripts without a command", () => { | |
| 47 | expect(() => decode({ scripts: [{ name: "Dev" }] })).toThrow(); | |
| 48 | }); | |
| 49 | ||
| 50 | it("rejects unknown script icons", () => { | |
| 51 | expect(() => | |
| 52 | decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }), | |
| 53 | ).toThrow(); | |
| 54 | }); | |
| 55 | }); |
packages/contracts/src/t3ProjectFile.ts created+88| ... | ... | @@ -0,0 +1,88 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 3 | ||
| 4 | import { ProjectScriptIcon } from "./orchestration.ts"; | |
| 5 | ||
| 6 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ | |
| 7 | export const T3_PROJECT_FILE_NAME = "t3.json"; | |
| 8 | ||
| 9 | /** Public URL of the published JSON Schema for {@link T3ProjectFile}. */ | |
| 10 | export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json"; | |
| 11 | ||
| 12 | const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512; | |
| 13 | const T3_PROJECT_FILE_MAX_SCRIPTS = 50; | |
| 14 | ||
| 15 | // Annotations go on the encoded (string) side so they survive into the | |
| 16 | // published JSON Schema; decoding still trims and re-validates non-emptiness. | |
| 17 | const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => { | |
| 18 | const annotated = Schema.String.annotate(annotations); | |
| 19 | const encoded = | |
| 20 | maxLength === undefined | |
| 21 | ? annotated.check(Schema.isNonEmpty()) | |
| 22 | : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength)); | |
| 23 | return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim())); | |
| 24 | }; | |
| 25 | ||
| 26 | export const T3ProjectFileScript = Schema.Struct({ | |
| 27 | name: trimmedNonEmpty({ | |
| 28 | description: "Display name for the script, shown in the T3 Code scripts menu.", | |
| 29 | }), | |
| 30 | command: trimmedNonEmpty({ | |
| 31 | description: "Shell command executed in a T3 Code terminal at the project root.", | |
| 32 | }), | |
| 33 | icon: Schema.optionalKey( | |
| 34 | ProjectScriptIcon.annotate({ | |
| 35 | description: 'Icon shown next to the script in the scripts menu. Defaults to "play".', | |
| 36 | }), | |
| 37 | ), | |
| 38 | runOnWorktreeCreate: Schema.optionalKey( | |
| 39 | Schema.Boolean.annotate({ | |
| 40 | description: | |
| 41 | "When true, the script runs automatically after a worktree is created for a new thread.", | |
| 42 | }), | |
| 43 | ), | |
| 44 | previewUrl: Schema.optionalKey( | |
| 45 | trimmedNonEmpty({ | |
| 46 | description: | |
| 47 | "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.", | |
| 48 | }), | |
| 49 | ), | |
| 50 | autoOpenPreview: Schema.optionalKey( | |
| 51 | Schema.Boolean.annotate({ | |
| 52 | description: | |
| 53 | "When true, automatically open the preview panel at `previewUrl` the moment the script starts.", | |
| 54 | }), | |
| 55 | ), | |
| 56 | }).annotate({ | |
| 57 | description: "A project script that team members can import into T3 Code.", | |
| 58 | }); | |
| 59 | export type T3ProjectFileScript = typeof T3ProjectFileScript.Type; | |
| 60 | ||
| 61 | export const T3ProjectFile = Schema.Struct({ | |
| 62 | $schema: Schema.optionalKey( | |
| 63 | Schema.String.annotate({ | |
| 64 | description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`, | |
| 65 | }), | |
| 66 | ), | |
| 67 | iconPath: Schema.optionalKey( | |
| 68 | trimmedNonEmpty( | |
| 69 | { | |
| 70 | description: | |
| 71 | 'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.', | |
| 72 | }, | |
| 73 | T3_PROJECT_FILE_PATH_MAX_LENGTH, | |
| 74 | ), | |
| 75 | ), | |
| 76 | scripts: Schema.optionalKey( | |
| 77 | Schema.Array(T3ProjectFileScript) | |
| 78 | .annotate({ | |
| 79 | description: "Project scripts shared with everyone who opens this repository in T3 Code.", | |
| 80 | }) | |
| 81 | .check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)), | |
| 82 | ), | |
| 83 | }).annotate({ | |
| 84 | title: "T3 project file", | |
| 85 | description: | |
| 86 | "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.", | |
| 87 | }); | |
| 88 | export type T3ProjectFile = typeof T3ProjectFile.Type; |
packages/shared/package.json+4| ... | ... | @@ -87,6 +87,10 @@ |
| 87 | 87 | "types": "./src/projectScripts.ts", |
| 88 | 88 | "import": "./src/projectScripts.ts" |
| 89 | 89 | }, |
| 90 | "./t3ProjectFile": { | |
| 91 | "types": "./src/t3ProjectFile.ts", | |
| 92 | "import": "./src/t3ProjectFile.ts" | |
| 93 | }, | |
| 90 | 94 | "./orchestrationTiming": { |
| 91 | 95 | "types": "./src/orchestrationTiming.ts", |
| 92 | 96 | "import": "./src/orchestrationTiming.ts" |
packages/shared/src/t3ProjectFile.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { buildT3ProjectFileJsonSchema, T3ProjectFileFromJson } from "./t3ProjectFile.ts"; | |
| 5 | ||
| 6 | const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson); | |
| 7 | ||
| 8 | describe("buildT3ProjectFileJsonSchema", () => { | |
| 9 | it("emits a draft 2020-12 schema with the published $id", () => { | |
| 10 | const schema = buildT3ProjectFileJsonSchema(); | |
| 11 | ||
| 12 | expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); | |
| 13 | expect(schema.$id).toBe("https://t3.codes/schema/t3.json"); | |
| 14 | expect(schema.type).toBe("object"); | |
| 15 | expect(schema.additionalProperties).toBe(false); | |
| 16 | }); | |
| 17 | ||
| 18 | it("documents every supported field", () => { | |
| 19 | const schema = buildT3ProjectFileJsonSchema() as { | |
| 20 | properties: Record< | |
| 21 | string, | |
| 22 | { | |
| 23 | description?: string; | |
| 24 | items?: { properties: Record<string, unknown>; required: ReadonlyArray<string> }; | |
| 25 | } | |
| 26 | >; | |
| 27 | required?: ReadonlyArray<string>; | |
| 28 | }; | |
| 29 | ||
| 30 | expect(Object.keys(schema.properties).sort()).toEqual(["$schema", "iconPath", "scripts"]); | |
| 31 | expect(schema.required).toBeUndefined(); | |
| 32 | expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); | |
| 33 | ||
| 34 | const script = schema.properties.scripts?.items; | |
| 35 | expect(script?.required).toEqual(["name", "command"]); | |
| 36 | expect(Object.keys(script?.properties ?? {}).sort()).toEqual([ | |
| 37 | "autoOpenPreview", | |
| 38 | "command", | |
| 39 | "icon", | |
| 40 | "name", | |
| 41 | "previewUrl", | |
| 42 | "runOnWorktreeCreate", | |
| 43 | ]); | |
| 44 | }); | |
| 45 | ||
| 46 | it("stays JSON-serializable", () => { | |
| 47 | const schema = buildT3ProjectFileJsonSchema(); | |
| 48 | expect(JSON.parse(JSON.stringify(schema))).toEqual(schema); | |
| 49 | }); | |
| 50 | }); | |
| 51 | ||
| 52 | describe("T3ProjectFileFromJson", () => { | |
| 53 | it("decodes lenient JSONC with comments and trailing commas", () => { | |
| 54 | const decoded = decodeJson(`{ | |
| 55 | // team scripts | |
| 56 | "iconPath": "assets/logo.svg", | |
| 57 | "scripts": [ | |
| 58 | { "name": "Dev", "command": "pnpm dev", }, | |
| 59 | ], | |
| 60 | }`); | |
| 61 | ||
| 62 | expect(decoded.iconPath).toBe("assets/logo.svg"); | |
| 63 | expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" }); | |
| 64 | }); | |
| 65 | ||
| 66 | it("fails on malformed JSON", () => { | |
| 67 | expect(() => decodeJson("{ not json")).toThrow(); | |
| 68 | }); | |
| 69 | }); |
packages/shared/src/t3ProjectFile.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { fromLenientJson } from "./schemaJson.ts"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Codec between the raw `t3.json` file contents (lenient JSONC string) and the | |
| 9 | * decoded {@link T3ProjectFile}. | |
| 10 | */ | |
| 11 | export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile); | |
| 12 | ||
| 13 | /** | |
| 14 | * Build the publishable JSON Schema document for `t3.json` (draft 2020-12). | |
| 15 | * | |
| 16 | * Served from the marketing site at {@link T3_PROJECT_FILE_SCHEMA_URL} so | |
| 17 | * editors get LSP support via a `$schema` reference. | |
| 18 | */ | |
| 19 | export function buildT3ProjectFileJsonSchema(): Record<string, unknown> { | |
| 20 | const document = Schema.toJsonSchemaDocument(T3ProjectFile); | |
| 21 | const jsonSchema: Record<string, unknown> = { | |
| 22 | $schema: "https://json-schema.org/draft/2020-12/schema", | |
| 23 | $id: T3_PROJECT_FILE_SCHEMA_URL, | |
| 24 | ...document.schema, | |
| 25 | }; | |
| 26 | if (document.definitions && Object.keys(document.definitions).length > 0) { | |
| 27 | jsonSchema.$defs = document.definitions; | |
| 28 | } | |
| 29 | return jsonSchema; | |
| 30 | } |
pnpm-lock.yaml+3| ... | ... | @@ -172,6 +172,9 @@ importers: |
| 172 | 172 | |
| 173 | 173 | apps/marketing: |
| 174 | 174 | dependencies: |
| 175 | '@t3tools/shared': | |
| 176 | specifier: workspace:* | |
| 177 | version: link:../../packages/shared | |
| 175 | 178 | astro: |
| 176 | 179 | specifier: ^7.0.3 |
| 177 | 180 | version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) |
t3.json created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | { | |
| 2 | "$schema": "https://t3.codes/schema/t3.json", | |
| 3 | "iconPath": "assets/dev/blueprint-web-apple-touch-180.png", | |
| 4 | "scripts": [ | |
| 5 | { | |
| 6 | "name": "Setup Worktree", | |
| 7 | "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env", | |
| 8 | "icon": "configure", | |
| 9 | "runOnWorktreeCreate": true | |
| 10 | } | |
| 11 | ] | |
| 12 | } |