From abb095ba2ce9397a97a6f173113359a395ed41ee Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 11:16:43 +0200 Subject: [PATCH 1/4] feat(domain): add ts-object-field contribution and namespaceImport to schemas --- packages/domain/src/Catalog.ts | 24 ++++++++++++++++++++++++ packages/domain/src/Plan.ts | 10 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index f50c4dc..095f6a1 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -169,9 +169,32 @@ export const Contribution = Schema.TaggedUnion({ moduleSpecifier: Schema.String, namedImports: Schema.optional(Schema.Array(Schema.String)), defaultImport: Schema.optional(Schema.String), + namespaceImport: Schema.optional(Schema.String), }), }, + /** + * TypeScript object field - adds a property to an object literal argument + * of a function call on a named variable. + * + * Used for composing Schema.Struct fields, Match.tagsExhaustive cases, etc. + */ + "ts-object-field": { + path: Schema.String, + targetVariable: Schema.String, + functionName: Schema.String, + field: Schema.String, + value: Schema.String, + import: Schema.optional( + Schema.Struct({ + moduleSpecifier: Schema.String, + namedImports: Schema.optional(Schema.Array(Schema.String)), + defaultImport: Schema.optional(Schema.String), + namespaceImport: Schema.optional(Schema.String), + }), + ), + }, + /** * JSX slot injection - inserts content at a named slot marker in a file. * @@ -188,6 +211,7 @@ export const Contribution = Schema.TaggedUnion({ moduleSpecifier: Schema.String, namedImports: Schema.optional(Schema.Array(Schema.String)), defaultImport: Schema.optional(Schema.String), + namespaceImport: Schema.optional(Schema.String), }), ), }, diff --git a/packages/domain/src/Plan.ts b/packages/domain/src/Plan.ts index e509cac..d79cfdf 100644 --- a/packages/domain/src/Plan.ts +++ b/packages/domain/src/Plan.ts @@ -73,6 +73,7 @@ export const TsAddImportOp = Schema.TaggedStruct("ts-add-import", { moduleSpecifier: Schema.String, namedImports: Schema.optional(Schema.Array(Schema.String)), defaultImport: Schema.optional(Schema.String), + namespaceImport: Schema.optional(Schema.String), typeOnly: Schema.optional(Schema.Boolean), }); @@ -90,6 +91,14 @@ export const TsAppendCallArgOp = Schema.TaggedStruct("ts-append-call-arg", { argument: Schema.String, }); +export const TsObjectFieldOp = Schema.TaggedStruct("ts-object-field", { + fileType: Schema.tag("typescript"), + targetVariable: Schema.String, + functionName: Schema.String, + field: Schema.String, + value: Schema.String, +}); + export const TsJsxSlotOp = Schema.TaggedStruct("ts-jsx-slot", { fileType: Schema.tag("typescript"), slotId: Schema.String, @@ -105,6 +114,7 @@ export const CompositionOperation = Schema.Union([ TsAddImportOp, TsAddReexportOp, TsAppendCallArgOp, + TsObjectFieldOp, TsJsxSlotOp, ]).pipe(Schema.toTaggedUnion("fileType")); From 46b0a31964992b0fef72fe7b9023a7965121d27d Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 11:16:53 +0200 Subject: [PATCH 2/4] feat(scaffold): implement ts-object-field and curried call-arg composition --- .../src/service/apply/TypeScriptComposer.ts | 114 +++++++++++++++++- .../blueprint/BlueprintService.test.ts | 12 +- .../src/service/plan/ContributionResolver.ts | 9 ++ .../scaffold/src/service/plan/PlanAssessor.ts | 47 +++++++- .../scaffold/src/service/plan/PlanRenderer.ts | 3 + .../scaffold/src/service/plan/PlanService.ts | 67 ++++++++++ 6 files changed, 243 insertions(+), 9 deletions(-) diff --git a/packages/scaffold/src/service/apply/TypeScriptComposer.ts b/packages/scaffold/src/service/apply/TypeScriptComposer.ts index 2298f0f..6ac4c78 100644 --- a/packages/scaffold/src/service/apply/TypeScriptComposer.ts +++ b/packages/scaffold/src/service/apply/TypeScriptComposer.ts @@ -4,6 +4,7 @@ import type { TsAddReexportOp, TsAppendCallArgOp, TsJsxSlotOp, + TsObjectFieldOp, } from "@repo/domain/Plan"; import { Array as Arr, @@ -57,6 +58,9 @@ export class TypeScriptComposer extends Context.Service()( Match.tag("ts-append-call-arg", (appendOp) => applyTsAppendCallArg(sourceFile, appendOp), ), + Match.tag("ts-object-field", (fieldOp) => + applyTsObjectField(sourceFile, fieldOp), + ), Match.tag("ts-jsx-slot", (slotOp) => Effect.sync(() => applyTsJsxSlot(sourceFile, slotOp)), ), @@ -96,6 +100,7 @@ const applyTsAddImport = ( isTypeOnly: op.typeOnly ?? false, ...(op.namedImports && { namedImports: [...op.namedImports] }), ...(op.defaultImport && { defaultImport: op.defaultImport }), + ...(op.namespaceImport && { namespaceImport: op.namespaceImport }), }); }, onSome: (decl) => { @@ -235,7 +240,14 @@ const applyTsAppendCallArg = Effect.fn("applyTsAppendCallArg")(function* ( // First check if initializer itself is the call expression if (initializer.isKind(SyntaxKind.CallExpression)) { if (isMatchingCall(initializer)) { - appendToCallOrArray(initializer, op.argument); + // Check if this is a curried call: the matching call might be the callee + // of an outer call expression (e.g., Subscription.aggregate()()) + const parent = initializer.getParent(); + if (parent && parent.isKind(SyntaxKind.CallExpression)) { + appendToCallOrArray(parent, op.argument); + } else { + appendToCallOrArray(initializer, op.argument); + } return; } } @@ -247,6 +259,16 @@ const applyTsAppendCallArg = Effect.fn("applyTsAppendCallArg")(function* ( for (const call of callExpressions) { if (isMatchingCall(call)) { + // Check if the matching call is the callee of a parent call (curried pattern) + const parent = call.getParent(); + if (parent && parent.isKind(SyntaxKind.CallExpression)) { + const parentCall = parent as CallExpression; + // Verify the parent's expression is our matching call + if (parentCall.getExpression() === call) { + appendToCallOrArray(parentCall, op.argument); + return; + } + } appendToCallOrArray(call, op.argument); return; } @@ -258,6 +280,96 @@ const applyTsAppendCallArg = Effect.fn("applyTsAppendCallArg")(function* ( }); }); +// ============================================================================= +// Object Field Injection +// ============================================================================= + +const applyTsObjectField = Effect.fn("applyTsObjectField")(function* ( + sourceFile: SourceFile, + op: typeof TsObjectFieldOp.Type, +) { + // Find variable declaration with the target name + const variableDeclaration = sourceFile.getVariableDeclaration( + op.targetVariable, + ); + + if (!variableDeclaration) { + return yield* new TargetNotFoundError({ + targetVariable: op.targetVariable, + functionName: op.functionName, + }); + } + + const initializer = variableDeclaration.getInitializer(); + if (!initializer) { + return yield* new TargetNotFoundError({ + targetVariable: op.targetVariable, + functionName: op.functionName, + }); + } + + // Helper to check if a call expression matches the function name + const isMatchingCall = (call: CallExpression): boolean => { + const expression = call.getExpression(); + + if (expression.isKind(SyntaxKind.PropertyAccessExpression)) { + if (expression.getText() === op.functionName) return true; + } + + if (expression.isKind(SyntaxKind.Identifier)) { + const name = expression.getText(); + if (name === op.functionName || op.functionName.endsWith(`.${name}`)) + return true; + } + + return false; + }; + + const addFieldToCall = (call: CallExpression): boolean => { + const args = call.getArguments(); + // Find the first object literal argument + const objectArg = args.find((arg) => + arg.isKind(SyntaxKind.ObjectLiteralExpression), + ); + + if (!objectArg) return false; + + const objectLiteral = objectArg.asKindOrThrow( + SyntaxKind.ObjectLiteralExpression, + ); + + // Check if field already exists + const existingProp = objectLiteral.getProperty(op.field); + if (existingProp) return true; + + // Add the new property + objectLiteral.addPropertyAssignment({ + name: op.field, + initializer: op.value, + }); + return true; + }; + + // First check if initializer itself is the call expression + if (initializer.isKind(SyntaxKind.CallExpression)) { + if (isMatchingCall(initializer) && addFieldToCall(initializer)) return; + } + + // Search descendants for the call + const callExpressions = initializer.getDescendantsOfKind( + SyntaxKind.CallExpression, + ); + + for (const call of callExpressions) { + if (isMatchingCall(call) && addFieldToCall(call)) return; + } + + return yield* new TargetNotFoundError({ + targetVariable: op.targetVariable, + functionName: op.functionName, + }); +}); + // ============================================================================= // JSX Slot Injection // ============================================================================= diff --git a/packages/scaffold/src/service/blueprint/BlueprintService.test.ts b/packages/scaffold/src/service/blueprint/BlueprintService.test.ts index f7d50e7..8a58d96 100644 --- a/packages/scaffold/src/service/blueprint/BlueprintService.test.ts +++ b/packages/scaffold/src/service/blueprint/BlueprintService.test.ts @@ -277,9 +277,7 @@ describe("BlueprintService", () => { kind: TargetKind.make("client-react"), name: "app", }), - modules: [ - { id: ModuleId.make("config-typescript-react-vite") }, - ], + modules: [{ id: ModuleId.make("config-typescript-vite") }], }, ], }); @@ -292,13 +290,13 @@ describe("BlueprintService", () => { kind: TargetKind.make("client-react"), name: "app", }).toKey(), - ModuleId.make("config-typescript-react-vite"), + ModuleId.make("config-typescript-vite"), ), ), ).toMatchObject({ _tag: "attached-module", targetId: "apps/client-react-app", - moduleId: "config-typescript-react-vite", + moduleId: "config-typescript-vite", }); }), ); @@ -321,13 +319,13 @@ describe("BlueprintService", () => { blueprint, toAttachedModuleNodeId( identity.toKey(), - ModuleId.make("config-typescript-react-vite"), + ModuleId.make("config-typescript-vite"), ), ), ).toMatchObject({ _tag: "attached-module", targetId: "apps/client-react-required", - moduleId: "config-typescript-react-vite", + moduleId: "config-typescript-vite", }); }), ); diff --git a/packages/scaffold/src/service/plan/ContributionResolver.ts b/packages/scaffold/src/service/plan/ContributionResolver.ts index 0c31ef9..e1122a4 100644 --- a/packages/scaffold/src/service/plan/ContributionResolver.ts +++ b/packages/scaffold/src/service/plan/ContributionResolver.ts @@ -125,6 +125,15 @@ const resolveContributionTokens = ( argument: c.argument, import: c.import, }), + "ts-object-field": (c): typeof Contribution.Type => + Contribution.cases["ts-object-field"].make({ + path: resolveString(c.path), + targetVariable: c.targetVariable, + functionName: c.functionName, + field: c.field, + value: c.value, + import: c.import, + }), "jsx-slot": (c): typeof Contribution.Type => Contribution.cases["jsx-slot"].make({ path: resolveString(c.path), diff --git a/packages/scaffold/src/service/plan/PlanAssessor.ts b/packages/scaffold/src/service/plan/PlanAssessor.ts index dbd8e19..3cde64f 100644 --- a/packages/scaffold/src/service/plan/PlanAssessor.ts +++ b/packages/scaffold/src/service/plan/PlanAssessor.ts @@ -35,9 +35,25 @@ export type PlanningIntentComposition = { readonly moduleSpecifier: string; readonly namedImports: ReadonlyArray | undefined; readonly defaultImport: string | undefined; + readonly namespaceImport: string | undefined; }; }; +export type PlanningIntentObjectField = { + readonly targetVariable: string; + readonly functionName: string; + readonly field: string; + readonly value: string; + readonly import: + | { + readonly moduleSpecifier: string; + readonly namedImports: ReadonlyArray | undefined; + readonly defaultImport: string | undefined; + readonly namespaceImport: string | undefined; + } + | undefined; +}; + export type PlanningIntentJsxSlot = { readonly slotId: string; readonly content: string; @@ -46,6 +62,7 @@ export type PlanningIntentJsxSlot = { readonly moduleSpecifier: string; readonly namedImports: ReadonlyArray | undefined; readonly defaultImport: string | undefined; + readonly namespaceImport: string | undefined; } | undefined; }; @@ -58,6 +75,7 @@ export type PlanningIntentPath = { readonly scripts: ReadonlyArray<{ name: string; value: string }>; readonly barrelExports: ReadonlyArray<{ exportPath: string }>; readonly compositions: ReadonlyArray; + readonly objectFields: ReadonlyArray; readonly jsxSlots: ReadonlyArray; readonly tsconfig: | { @@ -193,6 +211,7 @@ function toCompositionOperations( moduleSpecifier: composition.import.moduleSpecifier, namedImports: composition.import.namedImports, defaultImport: composition.import.defaultImport, + namespaceImport: composition.import.namespaceImport, }); // Append argument to the function call @@ -205,6 +224,29 @@ function toCompositionOperations( }); } + // Object fields -> ts-add-import (optional) + ts-object-field + for (const objectField of planningPath.objectFields) { + if (objectField.import) { + operations.push({ + _tag: "ts-add-import", + fileType: "typescript", + moduleSpecifier: objectField.import.moduleSpecifier, + namedImports: objectField.import.namedImports, + defaultImport: objectField.import.defaultImport, + namespaceImport: objectField.import.namespaceImport, + }); + } + + operations.push({ + _tag: "ts-object-field", + fileType: "typescript", + targetVariable: objectField.targetVariable, + functionName: objectField.functionName, + field: objectField.field, + value: objectField.value, + }); + } + // JSX slots -> ts-add-import (optional) + ts-jsx-slot for (const jsxSlot of planningPath.jsxSlots) { if (jsxSlot.import) { @@ -214,6 +256,7 @@ function toCompositionOperations( moduleSpecifier: jsxSlot.import.moduleSpecifier, namedImports: jsxSlot.import.namedImports, defaultImport: jsxSlot.import.defaultImport, + namespaceImport: jsxSlot.import.namespaceImport, }); } @@ -242,7 +285,9 @@ function assessPlanningPath({ planningPath.scripts.length > 0; const hasBarrelExports = planningPath.barrelExports.length > 0; const hasTsconfig = planningPath.tsconfig !== undefined; - const hasCompositions = planningPath.compositions.length > 0; + const hasCompositions = + planningPath.compositions.length > 0 || + planningPath.objectFields.length > 0; return Match.value({ hasContents, diff --git a/packages/scaffold/src/service/plan/PlanRenderer.ts b/packages/scaffold/src/service/plan/PlanRenderer.ts index 1093743..d97c8d7 100644 --- a/packages/scaffold/src/service/plan/PlanRenderer.ts +++ b/packages/scaffold/src/service/plan/PlanRenderer.ts @@ -92,6 +92,9 @@ const renderOperation = ( Match.tag("ts-append-call-arg", (o) => { return `In \`${path}\`, find \`const ${o.targetVariable} = ${o.functionName}(...)\` and append \`${o.argument}\` as an additional argument`; }), + Match.tag("ts-object-field", (o) => { + return `In \`${path}\`, find \`const ${o.targetVariable} = ${o.functionName}({...})\` and add field \`${o.field}\``; + }), Match.tag("ts-jsx-slot", (o) => { return `In \`${path}\`, inject content at slot \`@slot:${o.slotId}\``; }), diff --git a/packages/scaffold/src/service/plan/PlanService.ts b/packages/scaffold/src/service/plan/PlanService.ts index 7f655ba..0edfd22 100644 --- a/packages/scaffold/src/service/plan/PlanService.ts +++ b/packages/scaffold/src/service/plan/PlanService.ts @@ -193,8 +193,28 @@ export const PlanningIntentEntry = Schema.TaggedUnion({ Schema.Undefined, ]), defaultImport: Schema.Union([Schema.String, Schema.Undefined]), + namespaceImport: Schema.Union([Schema.String, Schema.Undefined]), }), }, + tsObjectField: { + path: Schema.String, + targetVariable: Schema.String, + functionName: Schema.String, + field: Schema.String, + value: Schema.String, + import: Schema.Union([ + Schema.Struct({ + moduleSpecifier: Schema.String, + namedImports: Schema.Union([ + Schema.Array(Schema.String), + Schema.Undefined, + ]), + defaultImport: Schema.Union([Schema.String, Schema.Undefined]), + namespaceImport: Schema.Union([Schema.String, Schema.Undefined]), + }), + Schema.Undefined, + ]), + }, jsxSlot: { path: Schema.String, slotId: Schema.String, @@ -207,6 +227,7 @@ export const PlanningIntentEntry = Schema.TaggedUnion({ Schema.Undefined, ]), defaultImport: Schema.Union([Schema.String, Schema.Undefined]), + namespaceImport: Schema.Union([Schema.String, Schema.Undefined]), }), Schema.Undefined, ]), @@ -250,9 +271,29 @@ const toPlanningIntentEntries = ( moduleSpecifier: c.import.moduleSpecifier, namedImports: c.import.namedImports, defaultImport: c.import.defaultImport, + namespaceImport: c.import.namespaceImport, }, }), ], + "ts-object-field": ( + c, + ): ReadonlyArray => [ + PlanningIntentEntry.cases.tsObjectField.make({ + path: c.path, + targetVariable: c.targetVariable, + functionName: c.functionName, + field: c.field, + value: c.value, + import: c.import + ? { + moduleSpecifier: c.import.moduleSpecifier, + namedImports: c.import.namedImports, + defaultImport: c.import.defaultImport, + namespaceImport: c.import.namespaceImport, + } + : undefined, + }), + ], "jsx-slot": (c): ReadonlyArray => [ PlanningIntentEntry.cases.jsxSlot.make({ path: c.path, @@ -263,6 +304,7 @@ const toPlanningIntentEntries = ( moduleSpecifier: c.import.moduleSpecifier, namedImports: c.import.namedImports, defaultImport: c.import.defaultImport, + namespaceImport: c.import.namespaceImport, } : undefined, }), @@ -291,6 +333,9 @@ const derivePlanningIntentPath = ({ const tsCallArgEntries = entries.filter( PlanningIntentEntry.guards.tsCallArg, ); + const tsObjectFieldEntries = entries.filter( + PlanningIntentEntry.guards.tsObjectField, + ); const jsxSlotEntries = entries.filter(PlanningIntentEntry.guards.jsxSlot); const resolveContents = () => @@ -369,6 +414,7 @@ const derivePlanningIntentPath = ({ ...emptyPackageJsonFields, barrelExports: [], compositions: [], + objectFields: [], jsxSlots: [], tsconfig: isConflictOnModify ? { path, contents } : undefined, } satisfies PlanningIntentPath; @@ -382,6 +428,7 @@ const derivePlanningIntentPath = ({ ...(yield* resolvePackageJsonFields()), barrelExports: [], compositions: [], + objectFields: [], jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -401,6 +448,7 @@ const derivePlanningIntentPath = ({ errorMessage: `Conflicting barrel export outcomes for ${path}.`, }), compositions: [], + objectFields: [], jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -419,6 +467,13 @@ const derivePlanningIntentPath = ({ argument: entry.argument, import: entry.import, })), + objectFields: Arr.map(tsObjectFieldEntries, (entry) => ({ + targetVariable: entry.targetVariable, + functionName: entry.functionName, + field: entry.field, + value: entry.value, + import: entry.import, + })), jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -432,6 +487,7 @@ const derivePlanningIntentPath = ({ ...emptyPackageJsonFields, barrelExports: [], compositions: [], + objectFields: [], jsxSlots: Arr.map(jsxSlotEntries, (entry) => ({ slotId: entry.slotId, content: entry.content, @@ -449,6 +505,7 @@ const derivePlanningIntentPath = ({ ...(yield* resolvePackageJsonFields()), barrelExports: [], compositions: [], + objectFields: [], jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -467,6 +524,13 @@ const derivePlanningIntentPath = ({ argument: entry.argument, import: entry.import, })), + objectFields: Arr.map(tsObjectFieldEntries, (entry) => ({ + targetVariable: entry.targetVariable, + functionName: entry.functionName, + field: entry.field, + value: entry.value, + import: entry.import, + })), jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -486,6 +550,7 @@ const derivePlanningIntentPath = ({ errorMessage: `Conflicting barrel export outcomes for ${path}.`, }), compositions: [], + objectFields: [], jsxSlots: [], tsconfig: undefined, } satisfies PlanningIntentPath; @@ -499,6 +564,7 @@ const derivePlanningIntentPath = ({ ...emptyPackageJsonFields, barrelExports: [], compositions: [], + objectFields: [], jsxSlots: Arr.map(jsxSlotEntries, (entry) => ({ slotId: entry.slotId, content: entry.content, @@ -577,6 +643,7 @@ const toPlanningIntentFamily = PlanningIntentEntry.match({ packageJsonEntry: () => "packageJson" as const, barrelExport: () => "barrel" as const, tsCallArg: () => "tsCallArg" as const, + tsObjectField: () => "tsCallArg" as const, jsxSlot: () => "jsxSlot" as const, }) satisfies (entry: typeof PlanningIntentEntry.Type) => PlanningIntentFamily; From c53608f5e9eeb7d147b80cc3900e4cd05bca7537 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 11:17:00 +0200 Subject: [PATCH 3/4] feat(catalog): add client-foldkit target, modules, and content templates --- .changeset/client-foldkit-scaffold.md | 14 + .../registry/content/client-foldkit-api.ts | 218 ++++++ .../registry/content/client-foldkit-chat.ts | 669 ++++++++++++++++++ .../registry/content/client-foldkit-rpc.ts | 233 ++++++ .../content/client-foldkit-websocket.ts | 418 +++++++++++ .../src/registry/content/client-foldkit.ts | 451 ++++++++++++ .../catalog/src/registry/moduleRegistry.ts | 2 + .../src/registry/modules/client-foldkit.ts | 496 +++++++++++++ .../catalog/src/registry/modules/config.ts | 7 +- .../catalog/src/registry/targetRegistry.ts | 125 +++- 10 files changed, 2630 insertions(+), 3 deletions(-) create mode 100644 .changeset/client-foldkit-scaffold.md create mode 100644 packages/catalog/src/registry/content/client-foldkit-api.ts create mode 100644 packages/catalog/src/registry/content/client-foldkit-chat.ts create mode 100644 packages/catalog/src/registry/content/client-foldkit-rpc.ts create mode 100644 packages/catalog/src/registry/content/client-foldkit-websocket.ts create mode 100644 packages/catalog/src/registry/content/client-foldkit.ts create mode 100644 packages/catalog/src/registry/modules/client-foldkit.ts diff --git a/.changeset/client-foldkit-scaffold.md b/.changeset/client-foldkit-scaffold.md new file mode 100644 index 0000000..8094fd6 --- /dev/null +++ b/.changeset/client-foldkit-scaffold.md @@ -0,0 +1,14 @@ +--- +"stack-effect": minor +--- + +add `client-foldkit` target kind. + +Includes four feature modules: + +- `http-api-foldkit-client` +- `http-rpc-foldkit-client` +- `ws-presence-foldkit-client` +- `chat-foldkit-client` + +also added new deterministic AST-based composition via new `ts-object-field` and `namespaceImport` contribution primitives. diff --git a/packages/catalog/src/registry/content/client-foldkit-api.ts b/packages/catalog/src/registry/content/client-foldkit-api.ts new file mode 100644 index 0000000..c981ce7 --- /dev/null +++ b/packages/catalog/src/registry/content/client-foldkit-api.ts @@ -0,0 +1,218 @@ +export const foldkitRestFeatureContents = `import { ApiResponse } from "@repo/domain/Api"; +import { Effect, Match, Schema } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, +} from "effect/unstable/http"; +import { Command } from "foldkit"; +import type { Html } from "foldkit/html"; +import { html } from "foldkit/html"; +import { m } from "foldkit/message"; +import { ts } from "foldkit/schema"; +import { evo } from "foldkit/struct"; + +const SERVER_URL = "http://localhost:9000"; + +// MODEL + +const ApiInit = ts("ApiInit"); +const ApiLoading = ts("ApiLoading"); +const ApiSuccess = ts("ApiSuccess", { data: ApiResponse }); +const ApiFailure = ts("ApiFailure", { error: Schema.String }); + +export const ApiAsyncResult = Schema.Union([ + ApiInit, + ApiLoading, + ApiSuccess, + ApiFailure, +]); +export type ApiAsyncResult = typeof ApiAsyncResult.Type; + +export const Model = Schema.Struct({ + api: ApiAsyncResult, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const ClickedFetchHello = m("ClickedFetchHello"); +export const SucceededFetchHello = m("SucceededFetchHello", { + data: ApiResponse, +}); +export const FailedFetchHello = m("FailedFetchHello", { error: Schema.String }); + +export const Message = Schema.Union([ + ClickedFetchHello, + SucceededFetchHello, + FailedFetchHello, +]); +export type Message = typeof Message.Type; + +// GOT MESSAGE (parent wrapper) + +export const GotMessage = m("GotRestMessage", { message: Message }); + +// INIT + +export const init = (): readonly [ + Model, + ReadonlyArray>, +] => [{ api: ApiInit() }, []]; + +// UPDATE + +export const update = (model: Model, message: Message) => + Match.valueTags(message, { + ClickedFetchHello: () => + [evo(model, { api: () => ApiLoading() }), [FetchHello({})]] as const, + SucceededFetchHello: ({ data }) => + [evo(model, { api: () => ApiSuccess({ data }) }), []] as const, + FailedFetchHello: ({ error }) => + [evo(model, { api: () => ApiFailure({ error }) }), []] as const, + }); + +// COMMAND + +export const FetchHello = Command.define( + "FetchHello", + {}, + SucceededFetchHello, + FailedFetchHello, +)(() => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(\`\${SERVER_URL}/hello\`); + const response = yield* client.execute(request); + + if (response.status !== 200) { + return yield* Effect.fail( + FailedFetchHello({ error: \`HTTP \${response.status}\` }), + ); + } + + const json = yield* response.json; + const data = yield* Schema.decodeUnknownEffect(ApiResponse)(json); + return SucceededFetchHello({ data }); + }).pipe( + Effect.catchTag("FailedFetchHello", (error) => Effect.succeed(error)), + Effect.catch(() => + Effect.succeed(FailedFetchHello({ error: "Failed to fetch API" })), + ), + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.provide(FetchHttpClient.layer), + ), +); + +// VIEW + +export const view = ( + model: Model, + toParentMessage: (message: Message) => ParentMessage, +): Html => { + const h = html(); + + return h.div( + [h.Class("flex h-full min-h-0 flex-col gap-4")], + [ + h.div( + [ + h.Class( + "rounded-lg border bg-card text-card-foreground shadow-sm h-auto", + ), + ], + [ + h.div( + [h.Class("flex flex-col space-y-1.5 p-6 border-b border-border")], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight")], + ["REST API"], + ), + ], + ), + h.div( + [h.Class("p-6 pt-4")], + [ + h.button( + [ + h.Class( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full", + ), + h.OnClick(toParentMessage(ClickedFetchHello())), + ], + ["Call REST API"], + ), + ], + ), + ], + ), + Match.valueTags(model.api, { + ApiInit: () => + h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["REST API Response"], + ), + h.p( + [h.Class("text-sm text-muted-foreground")], + ["Click the button above to test the REST API"], + ), + ], + ), + ApiLoading: () => + h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["REST API Response"], + ), + h.p([h.Class("text-sm text-muted-foreground")], ["Loading..."]), + ], + ), + ApiSuccess: ({ data }) => + h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["REST API Response"], + ), + h.pre([], [h.code([], [JSON.stringify(data, null, 2)])]), + ], + ), + ApiFailure: ({ error }) => + h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["REST API Response"], + ), + h.p([h.Class("text-sm text-destructive")], [error]), + ], + ), + }), + ], + ); +}; +`; diff --git a/packages/catalog/src/registry/content/client-foldkit-chat.ts b/packages/catalog/src/registry/content/client-foldkit-chat.ts new file mode 100644 index 0000000..afaba5c --- /dev/null +++ b/packages/catalog/src/registry/content/client-foldkit-chat.ts @@ -0,0 +1,669 @@ +export const foldkitChatClientContents = `import { ChatRpc } from "@repo/domain/ChatRpc"; +import { Context, Layer } from "effect"; +import { + RpcClient as EffectRpcClient, + RpcClientError, +} from "effect/unstable/rpc"; +import { RpcProtocolLive } from "./rpc-client"; + +type ChatRpcClient = EffectRpcClient.FromGroup< + typeof ChatRpc, + RpcClientError.RpcClientError +>; + +export class ChatClient extends Context.Service()( + "ChatClient", +) {} + +export const ChatClientLive = Layer.effect( + ChatClient, + EffectRpcClient.make(ChatRpc), +).pipe(Layer.provide(RpcProtocolLive)); +`; + +export const foldkitChatFeatureContents = `import { + type ChatMessage, + ChatStreamPart, + MessageSegment, + type ToolCall, +} from "@repo/domain/Chat"; +import { Effect, Match, Option, Schema, Stream } from "effect"; +import { Command, Subscription } from "foldkit"; +import type { Html } from "foldkit/html"; +import { html } from "foldkit/html"; +import { m } from "foldkit/message"; +import { evo } from "foldkit/struct"; +import { ChatClient, ChatClientLive } from "../services/chat-client"; + +// MODEL + +const ChatMessageSchema = Schema.Struct({ + role: Schema.Literals(["user", "assistant", "system"]), + content: Schema.String, + segments: Schema.Option(Schema.Array(MessageSegment)), +}); + +const ChatStateSchema = Schema.Literals([ + "idle", + "streaming", + "complete", + "error", +]); + +export const Model = Schema.Struct({ + chatInput: Schema.String, + chatHistory: Schema.Array(ChatMessageSchema), + chatState: ChatStateSchema, + chatSegments: Schema.Array(MessageSegment), + chatThinking: Schema.Option(Schema.String), + chatCurrentIteration: Schema.Option(Schema.Number), + chatError: Schema.Option(Schema.String), + chatStreaming: Schema.Boolean, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const UpdatedChatInput = m("UpdatedChatInput", { value: Schema.String }); +export const SubmittedChatMessage = m("SubmittedChatMessage"); +export const ReceivedChatPart = m("ReceivedChatPart", { + part: ChatStreamPart, +}); +export const CompletedChatStream = m("CompletedChatStream"); +export const FailedChatStream = m("FailedChatStream", { error: Schema.String }); + +export const Message = Schema.Union([ + UpdatedChatInput, + SubmittedChatMessage, + ReceivedChatPart, + CompletedChatStream, + FailedChatStream, +]); +export type Message = typeof Message.Type; + +// GOT MESSAGE (parent wrapper) + +export const GotMessage = m("GotChatMessage", { message: Message }); + +// INIT + +export const init = (): readonly [ + Model, + ReadonlyArray>, +] => [ + { + chatInput: "", + chatHistory: [], + chatState: "idle", + chatSegments: [], + chatThinking: Option.none(), + chatCurrentIteration: Option.none(), + chatError: Option.none(), + chatStreaming: false, + }, + [], +]; + +// HELPERS + +type MutableSegment = + | { _tag: "text"; content: string; isComplete: boolean } + | { _tag: "tool-call"; tool: ToolCall }; + +const applyChatPart = ( + model: Model, + part: typeof ChatStreamPart.Type, +): Model => { + const segments: Array = [...model.chatSegments]; + + return Match.valueTags(part, { + "text-delta": ({ delta }) => { + const last = segments[segments.length - 1]; + if (last?._tag === "text" && !last.isComplete) { + return evo(model, { + chatSegments: () => [ + ...segments.slice(0, -1), + { + _tag: "text" as const, + content: last.content + delta, + isComplete: false, + }, + ], + }); + } + return evo(model, { + chatSegments: () => [ + ...segments, + { _tag: "text" as const, content: delta, isComplete: false }, + ], + }); + }, + "text-complete": () => { + const last = segments[segments.length - 1]; + if (last?._tag === "text" && !last.isComplete) { + return evo(model, { + chatSegments: () => [ + ...segments.slice(0, -1), + { ...last, isComplete: true }, + ], + }); + } + return model; + }, + thinking: ({ message }) => + evo(model, { chatThinking: () => Option.some(message) }), + "iteration-start": ({ iteration }) => + evo(model, { chatCurrentIteration: () => Option.some(iteration) }), + "iteration-end": () => + evo(model, { chatCurrentIteration: () => Option.none() }), + "tool-call-start": ({ id, name }) => + evo(model, { + chatSegments: () => [ + ...segments, + { + _tag: "tool-call" as const, + tool: { + id, + name, + arguments: null, + argumentsText: "", + status: "proposed" as const, + }, + }, + ], + }), + "tool-call-delta": ({ id, argumentsDelta }) => + evo(model, { + chatSegments: () => + segments.map((seg) => + seg._tag === "tool-call" && seg.tool.id === id + ? { + ...seg, + tool: { + ...seg.tool, + argumentsText: seg.tool.argumentsText + argumentsDelta, + }, + } + : seg, + ), + }), + "tool-call-complete": ({ id, arguments: args }) => + evo(model, { + chatSegments: () => + segments.map((seg) => + seg._tag === "tool-call" && seg.tool.id === id + ? { ...seg, tool: { ...seg.tool, arguments: args } } + : seg, + ), + }), + "tool-execution-start": ({ id }) => + evo(model, { + chatSegments: () => + segments.map((seg) => + seg._tag === "tool-call" && seg.tool.id === id + ? { + ...seg, + tool: { ...seg.tool, status: "executing" as const }, + } + : seg, + ), + }), + "tool-execution-complete": ({ id, success, result }) => + evo(model, { + chatSegments: () => + segments.map((seg) => + seg._tag === "tool-call" && seg.tool.id === id + ? { + ...seg, + tool: { + ...seg.tool, + status: success + ? ("complete" as const) + : ("failed" as const), + result, + success, + }, + } + : seg, + ), + }), + finish: () => + evo(model, { + chatState: () => "complete" as const, + chatStreaming: () => false, + }), + error: ({ message }) => + evo(model, { + chatState: () => "error" as const, + chatStreaming: () => false, + chatError: () => Option.some(message), + }), + }); +}; + +const extractTextFromSegments = ( + segments: ReadonlyArray, +): string => + segments + .filter( + (seg): seg is typeof MessageSegment.Type & { _tag: "text" } => + seg._tag === "text", + ) + .map((seg) => seg.content) + .join(""); + +// UPDATE + +export const update = ( + model: Model, + message: Message, +): readonly [Model, ReadonlyArray>] => { + return Match.valueTags(message, { + UpdatedChatInput: ({ value }) => + [evo(model, { chatInput: () => value }), []] as const, + SubmittedChatMessage: () => { + const trimmed = model.chatInput.trim(); + if (trimmed === "") return [model, []] as const; + + return [ + evo(model, { + chatInput: () => "", + chatHistory: (prev) => [ + ...prev, + { + role: "user" as const, + content: trimmed, + segments: Option.none(), + }, + ], + chatState: () => "streaming" as const, + chatStreaming: () => true, + chatSegments: () => [], + chatThinking: () => Option.none(), + chatCurrentIteration: () => Option.none(), + chatError: () => Option.none(), + }), + [], + ] as const; + }, + ReceivedChatPart: ({ part }) => [applyChatPart(model, part), []] as const, + CompletedChatStream: () => + [ + evo(model, { + chatState: () => "complete" as const, + chatStreaming: () => false, + chatHistory: (prev) => [ + ...prev, + { + role: "assistant" as const, + content: extractTextFromSegments(model.chatSegments), + segments: Option.some([...model.chatSegments]), + }, + ], + chatSegments: () => [], + chatThinking: () => Option.none(), + chatCurrentIteration: () => Option.none(), + }), + [], + ] as const, + FailedChatStream: ({ error }) => + [ + evo(model, { + chatState: () => "error" as const, + chatStreaming: () => false, + chatError: () => Option.some(error), + }), + [], + ] as const, + }); +}; + +// SUBSCRIPTION + +export const subscriptions = Subscription.make()((entry) => ({ + chatStream: entry( + { isStreaming: Schema.Boolean, messagesJson: Schema.String }, + { + modelToDependencies: (model) => ({ + isStreaming: model.chatStreaming, + messagesJson: model.chatStreaming + ? JSON.stringify( + model.chatHistory.map((msg) => ({ + role: msg.role, + content: msg.content, + })), + ) + : "[]", + }), + dependenciesToStream: ({ isStreaming, messagesJson }) => + isStreaming + ? Effect.gen(function* () { + const client = yield* ChatClient; + const messages: Array = JSON.parse(messagesJson); + return client.chat({ messages }).pipe( + Stream.map((part) => ReceivedChatPart({ part })), + Stream.concat(Stream.make(CompletedChatStream())), + Stream.catch(() => + Stream.make( + FailedChatStream({ error: "Chat stream failed" }), + ), + ), + ); + }).pipe(Stream.unwrap, Stream.provide(ChatClientLive)) + : Stream.empty, + }, + ), +})); + +// VIEW + +export const view = ( + model: Model, + toParentMessage: (message: Message) => ParentMessage, +): Html => { + const h = html(); + const isStreaming = model.chatState === "streaming"; + const isDisabled = isStreaming; + + return h.div( + [ + h.Class( + "rounded-lg border bg-card text-card-foreground shadow-sm h-full w-full flex flex-col", + ), + ], + [ + h.div( + [ + h.Class( + "flex flex-col space-y-1.5 p-6 border-b border-border flex-row items-center justify-between", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight")], + ["Chat (RPC)"], + ), + h.div( + [h.Class("flex gap-2")], + [ + ...(isStreaming + ? [ + h.span( + [ + h.Class( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold border-border bg-secondary text-[0.65rem] uppercase tracking-[0.2em] text-secondary-foreground", + ), + ], + ["Streaming"], + ), + ] + : []), + ...Option.match(model.chatCurrentIteration, { + onNone: (): Array => [], + onSome: (iteration) => [ + h.span( + [ + h.Class( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold border-border bg-secondary text-[0.65rem] uppercase tracking-[0.2em] text-secondary-foreground", + ), + ], + [\`Iteration \${iteration}\`], + ), + ], + }), + ...(model.chatState === "error" + ? [ + h.span( + [ + h.Class( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-destructive text-destructive-foreground text-[0.65rem] uppercase tracking-[0.2em]", + ), + ], + ["Error"], + ), + ] + : []), + ], + ), + ], + ), + h.div( + [h.Class("flex-1 min-h-0 overflow-y-auto px-4")], + [ + h.div( + [h.Class("space-y-4 py-4")], + [ + ...emptyStateView(h, model), + ...historyView(h, model), + ...streamingSegmentsView(h, model), + ...thinkingView(h, model), + ...errorView(h, model), + ], + ), + ], + ), + h.div( + [h.Class("flex items-center p-6 border-t border-border")], + [ + h.form( + [ + h.Class("flex w-full gap-2"), + h.OnSubmit(toParentMessage(SubmittedChatMessage())), + ], + [ + h.input([ + h.Type("text"), + h.Value(model.chatInput), + h.Placeholder("Send a message"), + h.OnInput((value) => + toParentMessage(UpdatedChatInput({ value })), + ), + h.Class( + "flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + ), + ...(isDisabled ? [h.Disabled(true)] : []), + ]), + h.button( + [ + h.Class( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2", + ), + h.OnClick(toParentMessage(SubmittedChatMessage())), + ...(isDisabled || model.chatInput.trim() === "" + ? [h.Disabled(true)] + : []), + ], + ["Send"], + ), + ], + ), + ], + ), + ], + ); +}; + +// VIEW HELPERS + +const emptyStateView = ( + h: ReturnType>, + model: Model, +): Array => { + if (model.chatHistory.length > 0 || model.chatSegments.length > 0) return []; + return [ + h.div( + [ + h.Class( + "flex flex-col items-start gap-2 rounded-none border border-border bg-muted/50 px-4 py-6 text-xs text-muted-foreground", + ), + ], + [ + h.p( + [h.Class("text-[0.65rem] uppercase tracking-[0.28em]")], + ["Empty channel"], + ), + h.p( + [h.Class("text-xs text-foreground")], + ["Start with a clear request."], + ), + ], + ), + ]; +}; + +const historyView = ( + h: ReturnType>, + model: Model, +): Array => + model.chatHistory.map((msg) => + msg.role === "user" + ? h.div( + [h.Class("flex w-full flex-col gap-2 items-end mb-8")], + [ + h.div( + [ + h.Class( + "max-w-[85%] rounded-none border border-primary/40 bg-primary px-4 py-2 text-xs text-primary-foreground shadow-xs whitespace-break-spaces", + ), + ], + [msg.content], + ), + ], + ) + : h.div( + [h.Class("flex w-full flex-col gap-2 items-start")], + Option.match(msg.segments, { + onNone: () => [ + h.div([h.Class("w-full py-2 text-xs")], [msg.content]), + ], + onSome: (segs) => segmentsView(h, segs), + }), + ), + ); + +const streamingSegmentsView = ( + h: ReturnType>, + model: Model, +): Array => { + if (model.chatSegments.length === 0) return []; + if (model.chatState !== "streaming") return []; + return [ + h.div( + [h.Class("flex w-full flex-col gap-2 items-start")], + segmentsView(h, [...model.chatSegments]), + ), + ]; +}; + +const segmentsView = ( + h: ReturnType>, + segments: ReadonlyArray, +): Array => + segments.map((segment) => + segment._tag === "text" + ? h.div( + [h.Class("w-full py-2 text-xs whitespace-pre-wrap")], + [segment.content], + ) + : toolCallView(h, segment.tool), + ); + +const toolCallView = ( + h: ReturnType>, + tool: ToolCall, +): Html => { + const statusIcon = + tool.status === "executing" + ? "..." + : tool.status === "complete" + ? "ok" + : tool.status === "failed" + ? "!!" + : "?"; + + return h.div( + [ + h.Class( + "w-full rounded-none border border-border bg-muted/50 px-3 py-2 text-xs", + ), + ], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span([h.Class("font-mono text-muted-foreground")], [statusIcon]), + h.span([h.Class("font-medium")], [tool.name]), + h.span( + [ + h.Class( + \`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[0.6rem] font-semibold \${ + tool.status === "complete" + ? "bg-primary text-primary-foreground" + : tool.status === "failed" + ? "bg-destructive text-destructive-foreground" + : "bg-secondary text-secondary-foreground" + }\`, + ), + ], + [tool.status], + ), + ], + ), + ...(tool.result + ? [ + h.div( + [h.Class("mt-1 text-muted-foreground truncate max-w-full")], + [tool.result.slice(0, 200)], + ), + ] + : []), + ], + ); +}; + +const thinkingView = ( + h: ReturnType>, + model: Model, +): Array => + Option.match(model.chatThinking, { + onNone: () => [], + onSome: (thinking) => [ + h.div( + [h.Class("flex w-full flex-col gap-2 items-start")], + [ + h.div( + [ + h.Class( + "inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2.5 py-0.5 text-[0.65rem] font-medium uppercase tracking-[0.2em] text-muted-foreground", + ), + ], + [\`Thinking: \${thinking}\`], + ), + ], + ), + ], + }); + +const errorView = ( + h: ReturnType>, + model: Model, +): Array => + Option.match(model.chatError, { + onNone: () => [], + onSome: (error) => [ + h.div( + [ + h.Class( + "text-destructive text-xs p-3 border border-destructive/40 bg-destructive/10 rounded-none", + ), + ], + [ + h.div( + [h.Class("flex items-start gap-2")], + [h.span([h.Class("font-medium text-foreground")], [error])], + ), + ], + ), + ], + }); +`; diff --git a/packages/catalog/src/registry/content/client-foldkit-rpc.ts b/packages/catalog/src/registry/content/client-foldkit-rpc.ts new file mode 100644 index 0000000..29ddbb5 --- /dev/null +++ b/packages/catalog/src/registry/content/client-foldkit-rpc.ts @@ -0,0 +1,233 @@ +export const foldkitRpcClientContents = `import { EventRpc } from "@repo/domain/Rpc"; +import { Context, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { + RpcClient as EffectRpcClient, + RpcClientError, + RpcSerialization, +} from "effect/unstable/rpc"; + +const SERVER_URL = "http://localhost:9000"; + +type EventRpcClient = EffectRpcClient.FromGroup< + typeof EventRpc, + RpcClientError.RpcClientError +>; + +export class RpcClient extends Context.Service()( + "RpcClient", +) {} + +export const RpcProtocolLive = EffectRpcClient.layerProtocolHttp({ + url: \`\${SERVER_URL}/rpc\`, +}).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerNdjson), +); + +export const RpcClientLive = Layer.effect( + RpcClient, + EffectRpcClient.make(EventRpc), +).pipe(Layer.provide(RpcProtocolLive)); +`; + +export const foldkitTicksFeatureContents = `import { TickEvent } from "@repo/domain/Rpc"; +import { Effect, Match, Schema, Stream } from "effect"; +import { Command, Subscription } from "foldkit"; +import type { Html } from "foldkit/html"; +import { html } from "foldkit/html"; +import { m } from "foldkit/message"; +import { evo } from "foldkit/struct"; +import { RpcClient, RpcClientLive } from "../services/rpc-client"; + +// MODEL + +export const Model = Schema.Struct({ + ticksEnabled: Schema.Boolean, + tickProgress: Schema.String, + tickCount: Schema.Number, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const ClickedStartTicks = m("ClickedStartTicks"); +export const ClickedStopTicks = m("ClickedStopTicks"); +export const ReceivedTick = m("ReceivedTick", { event: TickEvent }); +export const FailedTickStream = m("FailedTickStream", { error: Schema.String }); + +export const Message = Schema.Union([ + ClickedStartTicks, + ClickedStopTicks, + ReceivedTick, + FailedTickStream, +]); +export type Message = typeof Message.Type; + +// GOT MESSAGE (parent wrapper) + +export const GotMessage = m("GotTicksMessage", { message: Message }); + +// INIT + +export const init = (): readonly [ + Model, + ReadonlyArray>, +] => [{ ticksEnabled: false, tickProgress: "", tickCount: 0 }, []]; + +// UPDATE + +export const update = ( + model: Model, + message: Message, +): readonly [Model, ReadonlyArray>] => + Match.valueTags(message, { + ClickedStartTicks: () => + [ + evo(model, { + ticksEnabled: () => true, + tickProgress: () => "", + tickCount: () => 0, + }), + [], + ] as const, + ClickedStopTicks: () => + [evo(model, { ticksEnabled: () => false }), []] as const, + ReceivedTick: ({ event }) => + [ + Match.valueTags(event, { + starting: () => evo(model, { tickProgress: () => "Starting..." }), + tick: () => + evo(model, { + tickProgress: (prev) => \`\${prev}.\`, + tickCount: (prev) => prev + 1, + }), + end: () => + evo(model, { + tickProgress: (prev) => \`\${prev} Done!\`, + ticksEnabled: () => false, + }), + }), + [], + ] as const, + FailedTickStream: ({ error }) => + [ + evo(model, { + ticksEnabled: () => false, + tickProgress: () => \`Error: \${error}\`, + }), + [], + ] as const, + }); + +// SUBSCRIPTION + +export const subscriptions = Subscription.make()((entry) => ({ + tickStream: entry( + { isEnabled: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isEnabled: model.ticksEnabled }), + dependenciesToStream: ({ isEnabled }) => + isEnabled + ? Effect.gen(function* () { + const client = yield* RpcClient; + return client.tick({ ticks: 10 }).pipe( + Stream.map((event) => ReceivedTick({ event })), + Stream.orDie, + ); + }).pipe(Stream.unwrap, Stream.provide(RpcClientLive)) + : Stream.empty, + }, + ), +})); + +// VIEW + +export const view = ( + model: Model, + toParentMessage: (message: Message) => ParentMessage, +): Html => { + const h = html(); + + return h.div( + [h.Class("flex h-full min-h-0 flex-col gap-4")], + [ + h.div( + [ + h.Class( + "rounded-lg border bg-card text-card-foreground shadow-sm h-auto", + ), + ], + [ + h.div( + [h.Class("flex flex-col space-y-1.5 p-6 border-b border-border")], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight")], + ["RPC API"], + ), + ], + ), + h.div( + [h.Class("p-6 pt-4")], + [ + h.button( + [ + h.Class( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full", + ), + h.OnClick(toParentMessage(ClickedStartTicks())), + ...(model.ticksEnabled ? [h.Disabled(true)] : []), + ], + ["Call RPC API"], + ), + ], + ), + ], + ), + model.tickCount > 0 || model.tickProgress + ? h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["RPC API Response"], + ), + h.pre( + [], + [ + h.code( + [], + [ + \`Event: \${model.ticksEnabled ? "tick" : "end"}\\nMessage: \${model.tickProgress}\`, + ], + ), + ], + ), + ], + ) + : h.div( + [ + h.Class( + "flex-1 rounded-lg border bg-card p-6 text-card-foreground shadow-sm", + ), + ], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight mb-4")], + ["RPC API Response"], + ), + h.p( + [h.Class("text-sm text-muted-foreground")], + ["Click the button above to test the RPC API"], + ), + ], + ), + ], + ); +}; +`; diff --git a/packages/catalog/src/registry/content/client-foldkit-websocket.ts b/packages/catalog/src/registry/content/client-foldkit-websocket.ts new file mode 100644 index 0000000..05c63d0 --- /dev/null +++ b/packages/catalog/src/registry/content/client-foldkit-websocket.ts @@ -0,0 +1,418 @@ +export const foldkitWsClientContents = `import { BrowserSocket } from "@effect/platform-browser"; +import { WebSocketRpc } from "@repo/domain/WebSocket"; +import { Context, Layer } from "effect"; +import { + RpcClient as EffectRpcClient, + RpcClientError, + RpcSerialization, +} from "effect/unstable/rpc"; + +const WS_URL = "ws://localhost:9000/ws"; + +type WsRpcClient = EffectRpcClient.FromGroup< + typeof WebSocketRpc, + RpcClientError.RpcClientError +>; + +export class WsClient extends Context.Service()( + "WsClient", +) {} + +const WsProtocolLive = EffectRpcClient.layerProtocolSocket({ + retryTransientErrors: true, +}).pipe( + Layer.provide(BrowserSocket.layerWebSocket(WS_URL)), + Layer.provide(RpcSerialization.layerNdjson), +); + +export const WsClientLive = Layer.effect( + WsClient, + EffectRpcClient.make(WebSocketRpc), +).pipe(Layer.provide(WsProtocolLive)); +`; + +export const foldkitPresenceFeatureContents = `import { ClientId, ClientStatus, WebSocketEvent } from "@repo/domain/WebSocket"; +import { + Array as Arr, + DateTime, + Effect, + Match, + Option, + Schema, + Stream, +} from "effect"; +import { Command, Subscription } from "foldkit"; +import type { Html } from "foldkit/html"; +import { html } from "foldkit/html"; +import { m } from "foldkit/message"; +import { evo } from "foldkit/struct"; +import { WsClient, WsClientLive } from "../services/ws-client"; + +// MODEL + +const PresenceClient = Schema.Struct({ + clientId: Schema.String, + status: Schema.String, + connectedAt: Schema.Number, +}); + +export const Model = Schema.Struct({ + presenceEnabled: Schema.Boolean, + presenceClients: Schema.Array(PresenceClient), + myClientId: Schema.Option(ClientId), + myStatus: Schema.String, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const ClickedConnectPresence = m("ClickedConnectPresence"); +export const ClickedDisconnectPresence = m("ClickedDisconnectPresence"); +export const ReceivedPresenceEvent = m("ReceivedPresenceEvent", { + event: WebSocketEvent, +}); +export const FailedPresenceStream = m("FailedPresenceStream", { + error: Schema.String, +}); +export const ClickedSetStatus = m("ClickedSetStatus", { + status: ClientStatus, +}); +export const SucceededSetStatus = m("SucceededSetStatus"); +export const FailedSetStatus = m("FailedSetStatus", { error: Schema.String }); + +export const Message = Schema.Union([ + ClickedConnectPresence, + ClickedDisconnectPresence, + ReceivedPresenceEvent, + FailedPresenceStream, + ClickedSetStatus, + SucceededSetStatus, + FailedSetStatus, +]); +export type Message = typeof Message.Type; + +// GOT MESSAGE (parent wrapper) + +export const GotMessage = m("GotPresenceMessage", { message: Message }); + +// INIT + +export const init = (): readonly [ + Model, + ReadonlyArray>, +] => [ + { + presenceEnabled: false, + presenceClients: [], + myClientId: Option.none(), + myStatus: "online", + }, + [], +]; + +// UPDATE + +const applyPresenceEvent = ( + model: Model, + event: typeof WebSocketEvent.Type, +): Model => + Match.valueTags(event, { + connected: ({ clientId, connectedAt }) => + evo(model, { + myClientId: () => Option.some(clientId), + myStatus: () => "online", + presenceClients: (clients) => [ + ...clients, + { + clientId, + status: "online", + connectedAt: DateTime.toEpochMillis(connectedAt), + }, + ], + }), + user_joined: ({ client }) => + evo(model, { + presenceClients: (clients) => [ + ...clients.filter((c) => c.clientId !== client.clientId), + { + clientId: client.clientId, + status: client.status, + connectedAt: DateTime.toEpochMillis(client.connectedAt), + }, + ], + }), + status_changed: ({ clientId, status }) => + evo(model, { + presenceClients: (clients) => + clients.map((c) => (c.clientId === clientId ? { ...c, status } : c)), + myStatus: () => + Option.match(model.myClientId, { + onNone: () => model.myStatus, + onSome: (myId) => (myId === clientId ? status : model.myStatus), + }), + }), + user_left: ({ clientId }) => + evo(model, { + presenceClients: (clients) => + clients.filter((c) => c.clientId !== clientId), + }), + }); + +export const update = (model: Model, message: Message) => { + return Match.valueTags(message, { + ClickedConnectPresence: () => + [evo(model, { presenceEnabled: () => true }), []] as const, + ClickedDisconnectPresence: () => + [ + evo(model, { + presenceEnabled: () => false, + presenceClients: () => [], + myClientId: () => Option.none(), + myStatus: () => "online", + }), + [], + ] as const, + ClickedSetStatus: ({ status }) => + [ + model, + Option.match(model.myClientId, { + onNone: () => [], + onSome: (clientId) => [SetStatus({ clientId, status })], + }), + ] as const, + ReceivedPresenceEvent: ({ event }) => + [applyPresenceEvent(model, event), []] as const, + FailedPresenceStream: () => + [ + evo(model, { + presenceEnabled: () => false, + presenceClients: () => [], + myClientId: () => Option.none(), + }), + [], + ] as const, + SucceededSetStatus: () => [model, []] as const, + FailedSetStatus: () => [model, []] as const, + }); +}; + +// COMMAND + +export const SetStatus = Command.define( + "SetStatus", + { clientId: ClientId, status: ClientStatus }, + SucceededSetStatus, + FailedSetStatus, +)(({ clientId, status }) => + Effect.gen(function* () { + const client = yield* WsClient; + yield* client.setStatus({ clientId, status }); + return SucceededSetStatus(); + }).pipe( + Effect.catch(() => + Effect.succeed(FailedSetStatus({ error: "Failed to set status" })), + ), + Effect.provide(WsClientLive), + ), +); + +// SUBSCRIPTION + +export const subscriptions = Subscription.make()((entry) => ({ + presenceStream: entry( + { isEnabled: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isEnabled: model.presenceEnabled }), + dependenciesToStream: ({ isEnabled }) => + isEnabled + ? Effect.gen(function* () { + const client = yield* WsClient; + return client.subscribe().pipe( + Stream.map((event) => ReceivedPresenceEvent({ event })), + Stream.orDie, + ); + }).pipe(Stream.unwrap, Stream.provide(WsClientLive)) + : Stream.empty, + }, + ), +})); + +// VIEW + +export const view = ( + model: Model, + toParentMessage: (message: Message) => ParentMessage, +): Html => { + const h = html(); + const isConnected = model.presenceEnabled; + + return h.div( + [ + h.Class( + "rounded-lg border bg-card text-card-foreground shadow-sm h-full", + ), + ], + [ + h.div( + [h.Class("flex flex-col space-y-1.5 p-6 border-b border-border")], + [ + h.div( + [h.Class("flex items-center justify-between gap-2")], + [ + h.div( + [], + [ + h.h3( + [h.Class("font-semibold leading-none tracking-tight")], + ["WebSocket Presence (RPC)"], + ), + h.p( + [h.Class("text-sm text-muted-foreground")], + ["Realtime status updates over RPC."], + ), + ], + ), + h.span( + [ + h.Class( + \`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors \${ + isConnected + ? "border-primary/30 bg-primary/10 text-primary" + : "border-secondary/50 bg-secondary text-secondary-foreground" + }\`, + ), + ], + [isConnected ? "connected" : "disconnected"], + ), + ], + ), + ], + ), + h.div( + [h.Class("p-6 flex flex-col gap-4")], + [ + h.div( + [h.Class("flex gap-2")], + [ + statusButton(h, toParentMessage, "Online", "online", isConnected), + statusButton(h, toParentMessage, "Away", "away", isConnected), + statusButton(h, toParentMessage, "Busy", "busy", isConnected), + ], + ), + isConnected + ? clientListView(h, model) + : h.button( + [ + h.Class( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full", + ), + h.OnClick(toParentMessage(ClickedConnectPresence())), + ], + ["Connect"], + ), + ], + ), + ], + ); +}; + +const statusButton = ( + h: ReturnType>, + toParentMessage: (message: Message) => ParentMessage, + label: string, + status: typeof ClientStatus.Type, + enabled: boolean, +): Html => + h.button( + [ + h.Class( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-11 px-8 flex-1", + ), + h.OnClick(toParentMessage(ClickedSetStatus({ status }))), + ...(!enabled ? [h.Disabled(true)] : []), + ], + [label], + ); + +const clientListView = ( + h: ReturnType>, + model: Model, +): Html => + h.div( + [h.Class("rounded-none border border-border bg-muted/50 p-3")], + [ + h.h4( + [ + h.Class( + "mb-2 font-medium text-foreground text-xs uppercase tracking-[0.2em]", + ), + ], + [\`Connected Clients (\${model.presenceClients.length})\`], + ), + Arr.match(model.presenceClients, { + onEmpty: () => + h.p( + [h.Class("text-muted-foreground text-xs")], + ["No clients connected"], + ), + onNonEmpty: (clients) => + h.ul( + [h.Class("space-y-1")], + clients.map((client) => { + const isMe = Option.match(model.myClientId, { + onNone: () => false, + onSome: (myId) => myId === client.clientId, + }); + + return h.li( + [h.Class("flex items-center gap-2 text-xs")], + [ + h.span( + [ + h.Class( + \`h-2 w-2 rounded-full \${statusDotColor(client.status)}\`, + ), + ], + [], + ), + h.span( + [h.Class("font-mono text-muted-foreground")], + [\`\${client.clientId.slice(0, 8)}...\`], + ), + h.span( + [h.Class("text-muted-foreground")], + [\`(\${client.status})\`], + ), + ...(isMe + ? [ + h.span( + [ + h.Class( + "text-primary text-[0.6rem] uppercase tracking-[0.2em]", + ), + ], + ["you"], + ), + ] + : []), + ], + ); + }), + ), + }), + ], + ); + +const statusDotColor = (status: string): string => { + switch (status) { + case "online": + return "bg-primary"; + case "away": + return "bg-secondary"; + case "busy": + return "bg-destructive"; + default: + return "bg-muted-foreground"; + } +}; +`; diff --git a/packages/catalog/src/registry/content/client-foldkit.ts b/packages/catalog/src/registry/content/client-foldkit.ts new file mode 100644 index 0000000..dc9c2a8 --- /dev/null +++ b/packages/catalog/src/registry/content/client-foldkit.ts @@ -0,0 +1,451 @@ +export const foldkitPackageJsonContents = `{ + "name": "{{packageName}}", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": {}, + "dependencies": { + "@effect/platform-browser": "4.0.0-beta.67", + "@fontsource-variable/jetbrains-mono": "^5.2.5", + "effect": "4.0.0-beta.67", + "foldkit": "^0.101.0", + "shadcn": "^4.1.0", + "tailwindcss": "^4.1.13", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@effect/language-service": "^0.85.1", + "@foldkit/vite-plugin": "^0.6.0", + "@repo/config-typescript": "workspace:*", + "@tailwindcss/vite": "^4.1.13", + "typescript": "6.0.2", + "vite": "^8.0.10", + "vitest": "^4.1.4" + } +} +`; + +export const foldkitIndexHtmlContents = ` + + + + + {{targetName}} + + + +
+ + + +`; + +export const foldkitThemeInitContents = `(function () { + const stored = localStorage.getItem("theme"); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (stored === "dark" || (!stored && prefersDark)) { + document.documentElement.classList.add("dark"); + } +})(); +`; + +export const foldkitViteConfigContents = `import tailwindcss from "@tailwindcss/vite"; +import foldkit from "@foldkit/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [foldkit(), tailwindcss()], + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + }, + }, +}); +`; + +export const foldkitTsconfigContents = `{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@repo/config-typescript/vite.json", + "compilerOptions": { + "outDir": "dist", + "paths": { + "@/*": ["./src/*"] + } + }, + "references": [{ "path": "./tsconfig.config.json" }], + "include": ["src", "test"], + "exclude": ["node_modules", "dist", "dist-node"] +} +`; + +export const foldkitTsconfigConfigContents = `{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@repo/config-typescript/base.json", + "compilerOptions": { + "composite": true, + "types": ["bun", "vite/client"], + "outDir": "dist-node" + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} +`; + +export const foldkitStylesContents = `@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/jetbrains-mono"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --font-heading: var(--font-mono); + --font-mono: "JetBrains Mono Variable", monospace; +} +`; + +export const foldkitEntryContents = `import { Runtime } from "foldkit"; + +import { init, Message, Model, subscriptions, update, view } from "./main"; + +const program = Runtime.makeProgram({ + Model, + init, + update, + view, + subscriptions, + container: document.getElementById("root"), + devTools: { + Message, + }, +}); + +Runtime.run(program); +`; + +export const foldkitMainContents = `import { Effect, Match as M, Schema as S } from "effect"; +import { Command, Runtime, Subscription } from "foldkit"; +import { type Document, html } from "foldkit/html"; + +import * as Theme from "./features/theme"; +import { Init, Views } from "./lib/compose"; + +// MODEL + +export const Model = S.Struct({ + theme: Theme.Model, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const Message = S.Union([ + Theme.GotMessage, +]); +export type Message = typeof Message.Type; + +// UPDATE + +export const update = (model: Model, message: Message) => + M.value(message).pipe( + M.withReturnType< + readonly [Model, ReadonlyArray>] + >(), + M.tagsExhaustive({ + GotThemeMessage: ({ message }) => { + const [nextChild, cmds] = Theme.update(model.theme, message); + const mappedCommands = cmds.map( + Command.mapEffect( + Effect.map((message) => Theme.GotMessage({ message })), + ), + ) as ReadonlyArray>; + return [{ ...model, theme: nextChild }, mappedCommands]; + }, + }), + ); + +// INIT + +export const init: Runtime.ProgramInit = () => + Init.compose( + Init.child(Theme, "theme", Theme.GotMessage), + ); + +// SUBSCRIPTIONS + +export const subscriptions = Subscription.aggregate()(); + +// VIEW + +export const view = (model: Model): Document => { + const h = html(); + + return { + title: "Foldkit Client", + body: h.div( + [ + h.Class( + "relative mx-auto flex min-h-screen max-w-6xl flex-col items-center justify-center gap-8 p-4 font-mono", + ), + ], + [ + Theme.view(model.theme, (msg) => Theme.GotMessage({ message: msg })), + + h.div( + [h.Class("text-center")], + [ + h.h1([h.Class("font-black text-5xl")], ["{{targetName}}"]), + h.p( + [h.Class("text-muted-foreground")], + ["A typesafe fullstack monorepo"], + ), + ], + ), + + h.div( + [ + h.Class( + "grid w-full grid-cols-1 gap-6 auto-rows-[30rem] lg:auto-rows-[22rem] lg:grid-cols-2", + ), + ], + Views.compose(), + ), + ], + ), + }; +}; +`; + +export const foldkitThemeFeatureContents = `import { Effect, Match, Schema } from "effect"; +import { Command } from "foldkit"; +import type { Html } from "foldkit/html"; +import { html } from "foldkit/html"; +import { m } from "foldkit/message"; +import { evo } from "foldkit/struct"; + +// MODEL + +const ThemeSchema = Schema.Literals(["Light", "Dark"]); +export type Theme = typeof ThemeSchema.Type; + +export const Model = Schema.Struct({ + theme: ThemeSchema, +}); +export type Model = typeof Model.Type; + +// MESSAGE + +export const ClickedToggleTheme = m("ClickedToggleTheme"); +export const CompletedSaveTheme = m("CompletedSaveTheme"); + +export const Message = Schema.Union([ClickedToggleTheme, CompletedSaveTheme]); +export type Message = typeof Message.Type; + +// GOT MESSAGE (parent wrapper) + +export const GotMessage = m("GotThemeMessage", { message: Message }); + +// INIT + +export const init = (): readonly [ + Model, + ReadonlyArray>, +] => { + const storedTheme = localStorage.getItem("theme"); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + const theme: Theme = + storedTheme === "dark" || (!storedTheme && prefersDark) ? "Dark" : "Light"; + + return [{ theme }, [ApplyTheme({ theme })]]; +}; + +// UPDATE + +export const update = (model: Model, message: Message) => + Match.valueTags(message, { + ClickedToggleTheme: () => { + const next: Theme = model.theme === "Dark" ? "Light" : "Dark"; + return [ + evo(model, { theme: () => next }), + [ApplyTheme({ theme: next }), SaveTheme({ theme: next })], + ] as const; + }, + CompletedSaveTheme: () => [model, []] as const, + }); + +// COMMAND + +const resolveTheme = (theme: Theme): boolean => theme === "Dark"; + +export const SaveTheme = Command.define( + "SaveTheme", + { theme: ThemeSchema }, + CompletedSaveTheme, + CompletedSaveTheme, +)(({ theme }) => + Effect.sync(() => { + localStorage.setItem("theme", theme === "Dark" ? "dark" : "light"); + return CompletedSaveTheme(); + }), +); + +export const ApplyTheme = Command.define( + "ApplyTheme", + { theme: ThemeSchema }, + CompletedSaveTheme, + CompletedSaveTheme, +)(({ theme }) => + Effect.sync(() => { + document.documentElement.classList.toggle("dark", resolveTheme(theme)); + return CompletedSaveTheme(); + }), +); + +// VIEW + +export const view = ( + model: Model, + toParentMessage: (message: Message) => ParentMessage, +): Html => { + const h = html(); + const isDark = model.theme === "Dark"; + + return h.div( + [h.Class("absolute right-4 top-4")], + [ + h.div( + [ + h.Class( + "rounded-lg border bg-card p-2 text-card-foreground shadow-sm", + ), + ], + [ + h.div( + [h.Class("gap-2 flex items-center justify-between px-2")], + [ + h.label([h.Attribute("for", "theme-toggle")], ["Theme"]), + h.button( + [ + h.Class( + \`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors \${isDark ? "bg-primary" : "bg-input"}\`, + ), + h.OnClick(toParentMessage(ClickedToggleTheme())), + h.Attribute("role", "switch"), + h.Attribute("aria-checked", isDark ? "true" : "false"), + h.Id("theme-toggle"), + ], + [ + h.span( + [ + h.Class( + \`pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform \${isDark ? "translate-x-4" : "translate-x-0"}\`, + ), + ], + [], + ), + ], + ), + ], + ), + ], + ), + ], + ); +}; +`; + +export const foldkitComposeContents = `import { Array as Arr, Effect, Record } from "effect"; +import type { Html } from "foldkit/html"; + +/** + * Composition helpers for foldkit TEA architecture. + * Used by the scaffold to compose child features into the root main.ts. + */ + +// ============================================================================= +// Init Composition +// ============================================================================= + +/** Structural shape of a Command without the conditional type indirection. */ +type AnyCommand = Readonly<{ + name: string; + args?: Record; + effect: Effect.Effect; +}>; + +export const Init = { + child: ( + mod: { + readonly init: () => readonly [Model, ReadonlyArray>]; + }, + key: string, + toParentMessage: (input: { message: Message }) => ParentMessage, + ) => { + const [model, cmds] = mod.init(); + const commands: ReadonlyArray> = Arr.map( + cmds, + (cmd): AnyCommand => ({ + name: cmd.name, + ...(cmd.args !== undefined ? { args: cmd.args } : {}), + effect: Effect.map(cmd.effect, (message) => + toParentMessage({ message }), + ), + }), + ); + return { key, model, commands }; + }, + + compose: < + ParentModel extends Record, + ParentMessage, + Children extends ReadonlyArray<{ + readonly key: string; + readonly model: unknown; + readonly commands: ReadonlyArray>; + }>, + >( + ...children: Children + ) => { + const model = Record.fromIterableWith(children, (child) => [ + child.key, + child.model, + ]) as ParentModel; + const commands = Arr.flatMap(children, (child) => child.commands); + return [model, commands] as const; + }, +}; + +// ============================================================================= +// View Composition +// ============================================================================= + +export const Views = { + compose: (...children: ReadonlyArray): ReadonlyArray => children, +}; +`; diff --git a/packages/catalog/src/registry/moduleRegistry.ts b/packages/catalog/src/registry/moduleRegistry.ts index 6864d87..c97b01c 100644 --- a/packages/catalog/src/registry/moduleRegistry.ts +++ b/packages/catalog/src/registry/moduleRegistry.ts @@ -1,6 +1,7 @@ import type { ModuleDefinition } from "@repo/domain/Catalog"; import { cliModules } from "./modules/cli"; import { clientModules } from "./modules/client"; +import { clientFoldkitModules } from "./modules/client-foldkit"; import { configModules } from "./modules/config"; import { domainModules } from "./modules/domain"; import { initModules } from "./modules/init"; @@ -13,6 +14,7 @@ export const moduleRegistry: ReadonlyArray = [ ...domainModules, ...serverModules, ...clientModules, + ...clientFoldkitModules, ...packageModules, ...cliModules, ]; diff --git a/packages/catalog/src/registry/modules/client-foldkit.ts b/packages/catalog/src/registry/modules/client-foldkit.ts new file mode 100644 index 0000000..cccbedd --- /dev/null +++ b/packages/catalog/src/registry/modules/client-foldkit.ts @@ -0,0 +1,496 @@ +import { + type ModuleDefinition, + ModuleId, + TargetIdentity, + TargetKind, +} from "@repo/domain/Catalog"; +import { foldkitRestFeatureContents } from "../content/client-foldkit-api"; +import { + foldkitChatClientContents, + foldkitChatFeatureContents, +} from "../content/client-foldkit-chat"; +import { + foldkitRpcClientContents, + foldkitTicksFeatureContents, +} from "../content/client-foldkit-rpc"; +import { + foldkitPresenceFeatureContents, + foldkitWsClientContents, +} from "../content/client-foldkit-websocket"; + +const foldkitKind = TargetKind.make("client-foldkit"); +const domainTarget = new TargetIdentity({ + kind: TargetKind.make("package"), + name: "domain", +}); + +/** + * Helper to generate the update case body for a foldkit child feature module. + */ +const updateCaseValue = (namespace: string, modelField: string) => + `({ message }) => { + const [nextChild, cmds] = ${namespace}.update(model.${modelField}, message); + const mappedCommands = cmds.map( + Command.mapEffect( + Effect.map((message) => ${namespace}.GotMessage({ message })), + ), + ) as ReadonlyArray>; + return [{ ...model, ${modelField}: nextChild }, mappedCommands]; +}`; + +/** + * Client Foldkit modules - TEA-based frontend features + */ +export const clientFoldkitModules: ReadonlyArray = + [ + { + id: ModuleId.make("http-api-foldkit-client"), + title: "HTTP API Client (Foldkit)", + description: "REST API client with Command pattern for Foldkit", + supportedOn: [{ _tag: "kind", kind: foldkitKind }], + dependencies: [ + { + _tag: "required-module", + target: domainTarget, + moduleId: ModuleId.make("domain-api"), + }, + ], + implies: [ + { + targetKind: TargetKind.make("server"), + moduleId: ModuleId.make("http-api-server"), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/features/rest.ts", + contents: foldkitRestFeatureContents, + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "dependencies", + name: "@repo/domain", + value: "workspace:*", + }, + // Model composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Model", + functionName: "S.Struct", + field: "rest", + value: "Rest.Model", + import: { + moduleSpecifier: "./features/rest", + namespaceImport: "Rest", + }, + }, + // Message composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Message", + functionName: "S.Union", + argument: "Rest.GotMessage", + import: { + moduleSpecifier: "./features/rest", + namespaceImport: "Rest", + }, + }, + // Update composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "update", + functionName: "M.tagsExhaustive", + field: "GotRestMessage", + value: updateCaseValue("Rest", "rest"), + import: { + moduleSpecifier: "./features/rest", + namespaceImport: "Rest", + }, + }, + // Init composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "init", + functionName: "Init.compose", + argument: `Init.child(Rest, "rest", Rest.GotMessage)`, + import: { + moduleSpecifier: "./features/rest", + namespaceImport: "Rest", + }, + }, + // View composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "view", + functionName: "Views.compose", + argument: `Rest.view(model.rest, (msg) => Rest.GotMessage({ message: msg }))`, + import: { + moduleSpecifier: "./features/rest", + namespaceImport: "Rest", + }, + }, + ], + }, + { + id: ModuleId.make("http-rpc-foldkit-client"), + title: "HTTP RPC Client (Foldkit)", + description: "RPC streaming client with Subscription pattern for Foldkit", + supportedOn: [{ _tag: "kind", kind: foldkitKind }], + dependencies: [ + { + _tag: "required-module", + target: domainTarget, + moduleId: ModuleId.make("domain-rpc"), + }, + ], + implies: [ + { + targetKind: TargetKind.make("server"), + moduleId: ModuleId.make("http-rpc-server"), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/features/ticks.ts", + contents: foldkitTicksFeatureContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/services/rpc-client.ts", + contents: foldkitRpcClientContents, + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "dependencies", + name: "@repo/domain", + value: "workspace:*", + }, + // Model composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Model", + functionName: "S.Struct", + field: "ticks", + value: "Ticks.Model", + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + // Message composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Message", + functionName: "S.Union", + argument: "Ticks.GotMessage", + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + // Update composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "update", + functionName: "M.tagsExhaustive", + field: "GotTicksMessage", + value: updateCaseValue("Ticks", "ticks"), + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + // Init composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "init", + functionName: "Init.compose", + argument: `Init.child(Ticks, "ticks", Ticks.GotMessage)`, + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + // Subscription composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "subscriptions", + functionName: "Subscription.aggregate", + argument: `Subscription.lift(Ticks.subscriptions)({ + toChildModel: (model) => model.ticks, + toParentMessage: (message) => Ticks.GotMessage({ message }), + })`, + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + // View composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "view", + functionName: "Views.compose", + argument: `Ticks.view(model.ticks, (msg) => Ticks.GotMessage({ message: msg }))`, + import: { + moduleSpecifier: "./features/ticks", + namespaceImport: "Ticks", + }, + }, + ], + }, + { + id: ModuleId.make("ws-presence-foldkit-client"), + title: "WebSocket Presence (Foldkit)", + description: "Real-time presence UI with WebSocket RPC for Foldkit", + supportedOn: [{ _tag: "kind", kind: foldkitKind }], + dependencies: [ + { + _tag: "required-module", + target: domainTarget, + moduleId: ModuleId.make("domain-websocket"), + }, + ], + implies: [ + { + targetKind: TargetKind.make("server"), + moduleId: ModuleId.make("ws-presence-server"), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/features/presence.ts", + contents: foldkitPresenceFeatureContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/services/ws-client.ts", + contents: foldkitWsClientContents, + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "dependencies", + name: "@repo/domain", + value: "workspace:*", + }, + // Model composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Model", + functionName: "S.Struct", + field: "presence", + value: "Presence.Model", + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + // Message composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Message", + functionName: "S.Union", + argument: "Presence.GotMessage", + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + // Update composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "update", + functionName: "M.tagsExhaustive", + field: "GotPresenceMessage", + value: updateCaseValue("Presence", "presence"), + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + // Init composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "init", + functionName: "Init.compose", + argument: `Init.child(Presence, "presence", Presence.GotMessage)`, + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + // Subscription composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "subscriptions", + functionName: "Subscription.aggregate", + argument: `Subscription.lift(Presence.subscriptions)({ + toChildModel: (model) => model.presence, + toParentMessage: (message) => Presence.GotMessage({ message }), + })`, + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + // View composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "view", + functionName: "Views.compose", + argument: `Presence.view(model.presence, (msg) => Presence.GotMessage({ message: msg }))`, + import: { + moduleSpecifier: "./features/presence", + namespaceImport: "Presence", + }, + }, + ], + }, + { + id: ModuleId.make("chat-foldkit-client"), + title: "Chat Client (Foldkit)", + description: "AI chat UI with streaming and tool calls for Foldkit", + supportedOn: [{ _tag: "kind", kind: foldkitKind }], + dependencies: [ + { + _tag: "required-module", + target: domainTarget, + moduleId: ModuleId.make("domain-chat"), + }, + { + _tag: "required-module", + target: domainTarget, + moduleId: ModuleId.make("domain-rpc"), + }, + ], + implies: [ + { + targetKind: TargetKind.make("server"), + moduleId: ModuleId.make("chat-server"), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/features/chat.ts", + contents: foldkitChatFeatureContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/services/chat-client.ts", + contents: foldkitChatClientContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/services/rpc-client.ts", + contents: foldkitRpcClientContents, + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "dependencies", + name: "@repo/domain", + value: "workspace:*", + }, + // Model composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Model", + functionName: "S.Struct", + field: "chat", + value: "Chat.Model", + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + // Message composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "Message", + functionName: "S.Union", + argument: "Chat.GotMessage", + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + // Update composition + { + _tag: "ts-object-field", + path: "{{targetPath}}/src/main.ts", + targetVariable: "update", + functionName: "M.tagsExhaustive", + field: "GotChatMessage", + value: updateCaseValue("Chat", "chat"), + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + // Init composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "init", + functionName: "Init.compose", + argument: `Init.child(Chat, "chat", Chat.GotMessage)`, + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + // Subscription composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "subscriptions", + functionName: "Subscription.aggregate", + argument: `Subscription.lift(Chat.subscriptions)({ + toChildModel: (model) => model.chat, + toParentMessage: (message) => Chat.GotMessage({ message }), + })`, + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + // View composition + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/main.ts", + targetVariable: "view", + functionName: "Views.compose", + argument: `Chat.view(model.chat, (msg) => Chat.GotMessage({ message: msg }))`, + import: { + moduleSpecifier: "./features/chat", + namespaceImport: "Chat", + }, + }, + ], + }, + ]; diff --git a/packages/catalog/src/registry/modules/config.ts b/packages/catalog/src/registry/modules/config.ts index 1567d9f..e8782ee 100644 --- a/packages/catalog/src/registry/modules/config.ts +++ b/packages/catalog/src/registry/modules/config.ts @@ -10,11 +10,14 @@ import { configTypescriptViteContents } from "../content/client"; */ export const configModules: ReadonlyArray = [ { - id: ModuleId.make("config-typescript-react-vite"), + id: ModuleId.make("config-typescript-vite"), title: "Config TypeScript Vite", description: "Vite TypeScript preset for client applications", visibility: "internal", - supportedOn: [{ _tag: "kind", kind: TargetKind.make("client-react") }], + supportedOn: [ + { _tag: "kind", kind: TargetKind.make("client-react") }, + { _tag: "kind", kind: TargetKind.make("client-foldkit") }, + ], dependencies: [], contributions: [ { diff --git a/packages/catalog/src/registry/targetRegistry.ts b/packages/catalog/src/registry/targetRegistry.ts index 0a010d9..e496cb0 100644 --- a/packages/catalog/src/registry/targetRegistry.ts +++ b/packages/catalog/src/registry/targetRegistry.ts @@ -23,6 +23,19 @@ import { clientViteConfigContents, clientViteEnvContents, } from "./content/client"; +import { + foldkitComposeContents, + foldkitEntryContents, + foldkitIndexHtmlContents, + foldkitMainContents, + foldkitPackageJsonContents, + foldkitStylesContents, + foldkitThemeFeatureContents, + foldkitThemeInitContents, + foldkitTsconfigConfigContents, + foldkitTsconfigContents, + foldkitViteConfigContents, +} from "./content/client-foldkit"; import { configTypescriptBaseContents, configTypescriptPackageJsonContents, @@ -81,7 +94,7 @@ export const targetRegistry: ReadonlyArray = [ kind: TargetKind.make("client-react"), title: "Client React Application", description: "A frontend application built with React", - requiredModules: [ModuleId.make("config-typescript-react-vite")], + requiredModules: [ModuleId.make("config-typescript-vite")], contributions: [ // Files { @@ -207,6 +220,116 @@ export const targetRegistry: ReadonlyArray = [ ], }, + { + kind: TargetKind.make("client-foldkit"), + title: "Client Foldkit Application", + description: "A frontend application built with Foldkit (Elm Architecture)", + requiredModules: [ModuleId.make("config-typescript-vite")], + contributions: [ + // Files + { + _tag: "file", + path: "{{targetPath}}/package.json", + contents: foldkitPackageJsonContents, + }, + { + _tag: "file", + path: "{{targetPath}}/index.html", + contents: foldkitIndexHtmlContents, + }, + { + _tag: "file", + path: "{{targetPath}}/public/theme-init.js", + contents: foldkitThemeInitContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/entry.ts", + contents: foldkitEntryContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/main.ts", + contents: foldkitMainContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/features/theme.ts", + contents: foldkitThemeFeatureContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/lib/compose.ts", + contents: foldkitComposeContents, + }, + { + _tag: "file", + path: "{{targetPath}}/src/styles.css", + contents: foldkitStylesContents, + }, + { + _tag: "file", + path: "{{targetPath}}/vite.config.ts", + contents: foldkitViteConfigContents, + }, + { + _tag: "file", + path: "{{targetPath}}/tsconfig.config.json", + contents: foldkitTsconfigConfigContents, + }, + // TSConfig (conflict on modify) + { + _tag: "file", + path: "{{targetPath}}/tsconfig.json", + contents: foldkitTsconfigContents, + conflictOnModify: true, + }, + // Scripts + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "build", + value: "vite build", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "dev", + value: "vite --host --port 5174 --clearScreen false", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "test", + value: "vitest run", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "type-check", + value: "tsc --noEmit", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "preview", + value: "vite preview", + }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "scripts", + name: "clean", + value: "git clean -xdf .cache .turbo dist node_modules", + }, + ], + }, + { kind: TargetKind.make("server"), title: "Server Application", From a3cc22b43ae686d91354dbfcfb1fd7b5de62fb90 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 11:17:06 +0200 Subject: [PATCH 4/4] test: add client-foldkit matrix and add tests --- apps/cli/e2e/add.test.ts | 104 +++++++++++++++++++++ apps/cli/e2e/matrix.test.ts | 180 +++++++++++++++++++++--------------- 2 files changed, 210 insertions(+), 74 deletions(-) diff --git a/apps/cli/e2e/add.test.ts b/apps/cli/e2e/add.test.ts index 64e3610..f6bb027 100644 --- a/apps/cli/e2e/add.test.ts +++ b/apps/cli/e2e/add.test.ts @@ -211,5 +211,109 @@ describe("add", () => { }).pipe(Effect.provide(CLI.layer)), { timeout: 90_000 }, ); + + it.effect( + "client-foldkit target scaffolds with rest module when server exists", + () => + Effect.gen(function* () { + const cli = yield* CLI; + const root = `${cli.workdir}/foldkit-test`; + + yield* cli.run( + "init", + "foldkit-test", + "--yes", + "--root", + cli.workdir, + ); + yield* cli.expectExitCode(0); + + // Add domain-api (required by server and foldkit rest) + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + "package/domain", + "--modules", + "domain-api", + ); + yield* cli.expectExitCode(0); + + // Add server (satisfies implication) + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + "server/api", + "--modules", + "http-api-server", + ); + yield* cli.expectExitCode(0); + + // Add client-foldkit with rest module + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + "client-foldkit/app", + "--modules", + "http-api-foldkit-client", + ); + yield* cli.expectExitCode(0); + + yield* cli.withinProject("foldkit-test", function* (project) { + yield* project.expectFileExists( + "apps/client-foldkit-app/package.json", + ); + yield* project.expectFileExists( + "apps/client-foldkit-app/src/main.ts", + ); + yield* project.expectFileExists( + "apps/client-foldkit-app/src/features/rest.ts", + ); + yield* project.expectTypeCheckPasses(); + }); + }).pipe(Effect.provide(CLI.layer)), + { timeout: 120_000 }, + ); + + it.effect( + "rejects client-foldkit cross-target implications in non-interactive mode", + () => + Effect.gen(function* () { + const cli = yield* CLI; + const root = `${cli.workdir}/foldkit-impl`; + + yield* cli.run( + "init", + "foldkit-impl", + "--yes", + "--root", + cli.workdir, + ); + yield* cli.expectExitCode(0); + + // http-api-foldkit-client implies http-api-server — rejected non-interactively + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + "client-foldkit/app", + "--modules", + "http-api-foldkit-client", + ); + yield* cli.expectExitCode(1); + yield* cli.expectOutputContaining("implies"); + }).pipe(Effect.provide(CLI.layer)), + { timeout: 30_000 }, + ); }); }); diff --git a/apps/cli/e2e/matrix.test.ts b/apps/cli/e2e/matrix.test.ts index c0b909d..19d5031 100644 --- a/apps/cli/e2e/matrix.test.ts +++ b/apps/cli/e2e/matrix.test.ts @@ -1,7 +1,7 @@ import { describe, layer } from "@effect/vitest"; import { CatalogService } from "@repo/catalog"; import { TargetKind } from "@repo/domain/Catalog"; -import { Array as Arr, Effect } from "effect"; +import { Effect } from "effect"; import { CLI } from "./harness"; // --------------------------------------------------------------------------- @@ -26,6 +26,7 @@ interface MatrixEntry { const defaultTargetNames = new Map([ ["server", "api"], ["client-react", "web"], + ["client-foldkit", "app"], ["cli", "app"], ["package", "domain"], // fallback; overridden by identity modules ]); @@ -105,7 +106,9 @@ const matrix = Effect.runSync( // Separate entries that have cross-target implications (client modules) const singleTargetEntries = matrix.filter( - (e) => !e.target.startsWith("client-react"), + (e) => + !e.target.startsWith("client-react") && + !e.target.startsWith("client-foldkit"), ); // --------------------------------------------------------------------------- @@ -115,29 +118,43 @@ const singleTargetEntries = matrix.filter( const buildFullStackMatrix = Effect.gen(function* () { const catalog = yield* CatalogService; - const clientModules = yield* catalog.getSupportedModules( + const clientKinds = [ TargetKind.make("client-react"), - ); - - return Arr.map(clientModules, (clientMod) => { - const implies = clientMod.implies ?? []; - if (implies.length === 0) return null; - - const serverModuleIds = implies - .filter((imp) => imp.targetKind === "server") - .map((imp) => imp.moduleId); - - if (serverModuleIds.length > 0) { - return { - label: `full-stack: ${clientMod.id} → server [${serverModuleIds.join(", ")}]`, - serverTarget: `server/${defaultTargetNames.get("server")}`, - serverModules: serverModuleIds, - clientTarget: `client-react/${defaultTargetNames.get("client-react")}`, - clientModules: [clientMod.id], - }; + TargetKind.make("client-foldkit"), + ] as const; + + const results: Array<{ + label: string; + serverTarget: string; + serverModules: ReadonlyArray; + clientTarget: string; + clientModules: ReadonlyArray; + }> = []; + + for (const kind of clientKinds) { + const clientModules = yield* catalog.getSupportedModules(kind); + + for (const clientMod of clientModules) { + const implies = clientMod.implies ?? []; + if (implies.length === 0) continue; + + const serverModuleIds = implies + .filter((imp) => imp.targetKind === "server") + .map((imp) => imp.moduleId); + + if (serverModuleIds.length > 0) { + results.push({ + label: `full-stack: ${clientMod.id} → server [${serverModuleIds.join(", ")}]`, + serverTarget: `server/${defaultTargetNames.get("server")}`, + serverModules: serverModuleIds, + clientTarget: `${kind}/${defaultTargetNames.get(kind)}`, + clientModules: [clientMod.id], + }); + } } - return null; - }).filter((entry) => entry !== null); + } + + return results; }); const fullStackMatrix = Effect.runSync( @@ -241,56 +258,71 @@ describe("matrix", () => { }); layer(CLI.layer)("full-stack all client modules together", (it) => { - it.effect( - "all client modules with all server dependencies", - () => - Effect.gen(function* () { - const cli = yield* CLI; - const name = "matrix-fullstack-all"; - const root = `${cli.workdir}/${name}`; - - // Init - yield* cli.run("init", name, "--yes", "--root", cli.workdir); - yield* cli.expectExitCode(0); - - // Add all server modules - const allServerModules = [ - ...new Set(fullStackMatrix.flatMap((e) => e.serverModules)), - ]; - yield* cli.run( - "add", - "--yes", - "--root", - root, - "--target", - `server/${defaultTargetNames.get("server")}`, - "--modules", - allServerModules.join(","), - ); - yield* cli.expectExitCode(0); - - // Add all client modules - const allClientModules = [ - ...new Set(fullStackMatrix.flatMap((e) => e.clientModules)), - ]; - yield* cli.run( - "add", - "--yes", - "--root", - root, - "--target", - `client-react/${defaultTargetNames.get("client-react")}`, - "--modules", - allClientModules.join(","), - ); - yield* cli.expectExitCode(0); - - // Validate - yield* cli.withinProject(name, function* (project) { - yield* project.expectTypeCheckPasses(); - }); - }).pipe(Effect.provide(CLI.layer)), - { timeout: 180_000 }, - ); + // Group full-stack entries by client target kind + const byClientKind = new Map< + string, + { serverModules: Set; clientModules: Set } + >(); + for (const entry of fullStackMatrix) { + const kind = entry.clientTarget; + if (!byClientKind.has(kind)) { + byClientKind.set(kind, { + serverModules: new Set(), + clientModules: new Set(), + }); + } + const group = byClientKind.get(kind)!; + for (const m of entry.serverModules) group.serverModules.add(m); + for (const m of entry.clientModules) group.clientModules.add(m); + } + + for (const [clientTarget, group] of byClientKind) { + const kindSlug = clientTarget.replace("/", "-"); + it.effect( + `all ${clientTarget} modules with server dependencies`, + () => + Effect.gen(function* () { + const cli = yield* CLI; + const name = `matrix-fullstack-all-${kindSlug}`; + const root = `${cli.workdir}/${name}`; + + // Init + yield* cli.run("init", name, "--yes", "--root", cli.workdir); + yield* cli.expectExitCode(0); + + // Add all server modules + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + `server/${defaultTargetNames.get("server")}`, + "--modules", + [...group.serverModules].join(","), + ); + yield* cli.expectExitCode(0); + + // Add all client modules + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + clientTarget, + "--modules", + [...group.clientModules].join(","), + ); + yield* cli.expectExitCode(0); + + // Validate + yield* cli.withinProject(name, function* (project) { + yield* project.expectTypeCheckPasses(); + }); + }).pipe(Effect.provide(CLI.layer)), + { timeout: 180_000 }, + ); + } }); });