create-foldkit-app 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-foldkit-app
2
2
 
3
- Scaffolding CLI for new Foldkit applications. Creates a ready-to-run project with Vite, Tailwind CSS, TypeScript, [`@foldkit/vite-plugin`](https://www.npmjs.com/package/@foldkit/vite-plugin) for hot reloading with Model preservation, and your choice of starter example.
3
+ Scaffolding CLI for new Foldkit applications. Creates a ready-to-run project with Vite, Tailwind CSS, TypeScript, [`@foldkit/vite-plugin`](https://www.npmjs.com/package/@foldkit/vite-plugin) for hot reloading with Model preservation, your choice of rendering mode, and a starter example for SPA scaffolds.
4
4
 
5
5
  ## Usage
6
6
 
@@ -14,7 +14,17 @@ yarn create foldkit-app
14
14
  bun create foldkit-app
15
15
  ```
16
16
 
17
- The CLI prompts you for a project name, starter example, and package manager. Pass `--name`, `--example`, and/or `--package-manager` to skip the matching prompts.
17
+ The CLI prompts you for a project name, rendering mode, starter example, and package manager. Pass `--name`, `--rendering`, `--example`, and/or `--package-manager` to skip the matching prompts.
18
+
19
+ ## Rendering
20
+
21
+ | Mode | Description |
22
+ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
+ | `spa` | Render entirely in the browser. Starts from your choice of starter example below. |
24
+ | `ssg` | Prerender routes to static HTML at build time, then hydrate in the browser. Scaffolds a small routed app with a server entry and a prerender script. |
25
+ | `ssr` | Render each request on a Node server, then hydrate in the browser. Scaffolds a counter whose Flags come from the request, plus an Effect HttpServer host and a `start` script. |
26
+
27
+ The starter examples apply to `spa` rendering. The `ssg` and `ssr` modes scaffold their own starter apps.
18
28
 
19
29
  ## Examples
20
30
 
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { Console, Effect, FileSystem, Option, Path } from 'effect';
2
+ import { Console, Effect, FileSystem, Match, Option, Path, pipe } from 'effect';
3
3
  import { Prompt } from 'effect/unstable/cli';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { examples } from '../examples.js';
6
+ import { Scaffold, renderings } from '../rendering.js';
6
7
  import { createProject } from '../utils/files.js';
7
8
  import { devCommand, installDependencies, } from '../utils/packages.js';
8
9
  import { validateProjectName } from '../validateName.js';
@@ -14,6 +15,14 @@ const promptForName = Prompt.text({
14
15
  onSome: message => Effect.fail(message),
15
16
  }),
16
17
  });
18
+ const promptForRendering = Prompt.select({
19
+ message: 'Pick a rendering mode',
20
+ choices: renderings.map(({ value, title, description }) => ({
21
+ value,
22
+ title,
23
+ description,
24
+ })),
25
+ });
17
26
  const promptForExample = Prompt.autoComplete({
18
27
  message: 'Pick a starting example',
19
28
  choices: examples.map(({ value, title, description }) => ({
@@ -31,20 +40,28 @@ const promptForPackageManager = Prompt.select({
31
40
  { value: 'bun', title: 'bun' },
32
41
  ],
33
42
  });
43
+ const resolveScaffold = (rendering, maybeExample) => Match.value(rendering).pipe(Match.when('spa', () => pipe(maybeExample, Option.match({
44
+ onNone: () => promptForExample,
45
+ onSome: Effect.succeed,
46
+ }), Effect.map(example => Scaffold.Spa({ example })))), Match.when('ssg', () => Effect.succeed(Scaffold.Ssg())), Match.when('ssr', () => Effect.succeed(Scaffold.Ssr())), Match.exhaustive);
34
47
  const resolveInput = (input) => Effect.gen(function* () {
35
48
  const name = yield* Option.match(input.name, {
36
49
  onNone: () => promptForName,
37
50
  onSome: Effect.succeed,
38
51
  });
39
- const example = yield* Option.match(input.example, {
40
- onNone: () => promptForExample,
52
+ const rendering = yield* Option.match(input.rendering, {
53
+ onNone: () => promptForRendering,
41
54
  onSome: Effect.succeed,
42
55
  });
56
+ if (rendering !== 'spa' && Option.isSome(input.example)) {
57
+ yield* Effect.fail('The --example flag only applies to spa rendering.');
58
+ }
59
+ const scaffold = yield* resolveScaffold(rendering, input.example);
43
60
  const packageManager = yield* Option.match(input.packageManager, {
44
61
  onNone: () => promptForPackageManager,
45
62
  onSome: Effect.succeed,
46
63
  });
47
- return { name, example, packageManager };
64
+ return { name, scaffold, packageManager };
48
65
  });
49
66
  const validateProject = (name, projectPath, packageManager) => Effect.gen(function* () {
50
67
  const fs = yield* FileSystem.FileSystem;
@@ -60,16 +77,16 @@ const validateProject = (name, projectPath, packageManager) => Effect.gen(functi
60
77
  return yield* Effect.fail(`Package manager '${packageManager}' is not available. Please install it first.`);
61
78
  }
62
79
  });
63
- const setupProject = (name, projectPath, example, packageManager) => Effect.gen(function* () {
80
+ const setupProject = (name, projectPath, scaffold, packageManager) => Effect.gen(function* () {
64
81
  yield* Console.log(chalk.blue('🚀 Creating your Foldkit app...'));
65
82
  yield* Console.log('');
66
- yield* createProject(name, projectPath, example, packageManager);
83
+ yield* createProject(name, projectPath, scaffold, packageManager);
67
84
  yield* Console.log(chalk.green(`✅ Created project`));
68
85
  yield* Console.log('');
69
86
  });
70
- const installProjectDependencies = (projectPath, packageManager, example) => Effect.gen(function* () {
87
+ const installProjectDependencies = (projectPath, packageManager, scaffold) => Effect.gen(function* () {
71
88
  yield* Console.log(chalk.blue(`📦 Installing dependencies with ${packageManager}...`));
72
- yield* installDependencies(projectPath, packageManager, example);
89
+ yield* installDependencies(projectPath, packageManager, scaffold);
73
90
  yield* Console.log(chalk.green('✅ Dependencies installed'));
74
91
  yield* Console.log('');
75
92
  });
@@ -113,12 +130,12 @@ const displaySuccessMessage = (name, packageManager) => Effect.gen(function* ()
113
130
  yield* Console.log('');
114
131
  });
115
132
  export const create = (input) => Effect.gen(function* () {
116
- const { name, example, packageManager } = yield* resolveInput(input);
133
+ const { name, scaffold, packageManager } = yield* resolveInput(input);
117
134
  const path = yield* Path.Path;
118
135
  const projectPath = path.resolve(name);
119
136
  yield* validateProject(name, projectPath, packageManager);
120
- yield* setupProject(name, projectPath, example, packageManager);
121
- yield* installProjectDependencies(projectPath, packageManager, example);
137
+ yield* setupProject(name, projectPath, scaffold, packageManager);
138
+ yield* installProjectDependencies(projectPath, packageManager, scaffold);
122
139
  yield* displaySuccessMessage(name, packageManager);
123
140
  return name;
124
141
  });
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
6
6
  import { NodeRuntime, NodeServices, NodeStdio } from '@effect/platform-node';
7
7
  import { create as create_ } from './commands/create.js';
8
8
  import { EXAMPLE_VALUES } from './examples.js';
9
+ import { RENDERING_VALUES } from './rendering.js';
9
10
  import { validateProjectName } from './validateName.js';
10
11
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
11
12
  const packageJson = createRequire(import.meta.url)('../package.json');
@@ -14,7 +15,8 @@ const nameSchema = Schema.String.pipe(Schema.check(Schema.makeFilter(value => Op
14
15
  onSome: message => message,
15
16
  }))));
16
17
  const name = Flag.string('name').pipe(Flag.withAlias('n'), Flag.withDescription('The name of the project to create'), Flag.withSchema(nameSchema), Flag.optional);
17
- const example = Flag.choice('example', EXAMPLE_VALUES).pipe(Flag.withAlias('e'), Flag.withDescription("The example application to start from. Run with no flags for an interactive picker that shows each example's description."), Flag.optional);
18
+ const rendering = Flag.choice('rendering', RENDERING_VALUES).pipe(Flag.withAlias('r'), Flag.withDescription('How the application renders: spa renders entirely in the browser, ssg prerenders routes to static HTML at build time, ssr renders each request on a Node server'), Flag.optional);
19
+ const example = Flag.choice('example', EXAMPLE_VALUES).pipe(Flag.withAlias('e'), Flag.withDescription("The example application to start from with spa rendering. Run with no flags for an interactive picker that shows each example's description."), Flag.optional);
18
20
  const packageManager = Flag.choice('package-manager', [
19
21
  'pnpm',
20
22
  'npm',
@@ -23,6 +25,7 @@ const packageManager = Flag.choice('package-manager', [
23
25
  ]).pipe(Flag.withAlias('p'), Flag.withDescription('The package manager to use for installing dependencies'), Flag.optional);
24
26
  const create = Command.make('create', {
25
27
  name,
28
+ rendering,
26
29
  example,
27
30
  packageManager,
28
31
  }, create_).pipe(Command.withDescription('Create a new Foldkit application'));
@@ -0,0 +1,20 @@
1
+ import { Data } from 'effect';
2
+ export const RENDERING_VALUES = ['spa', 'ssg', 'ssr'];
3
+ export const renderings = [
4
+ {
5
+ value: 'spa',
6
+ title: 'SPA',
7
+ description: 'Render entirely in the browser',
8
+ },
9
+ {
10
+ value: 'ssg',
11
+ title: 'SSG',
12
+ description: 'Prerender routes to static HTML at build time, then hydrate in the browser',
13
+ },
14
+ {
15
+ value: 'ssr',
16
+ title: 'SSR',
17
+ description: 'Render each request on a Node server, then hydrate in the browser',
18
+ },
19
+ ];
20
+ export const Scaffold = Data.taggedEnum();
@@ -72,11 +72,27 @@ const createPackageManagerFiles = (projectPath, packageManager) => Effect.gen(fu
72
72
  const packageManagerFiles = yield* getPackageManagerFiles(packageManager);
73
73
  yield* createFiles(projectPath, packageManagerFiles);
74
74
  });
75
- export const createProject = (name, projectPath, example, packageManager) => Effect.gen(function* () {
75
+ const overlayRenderingFiles = (projectPath, directory) => Effect.gen(function* () {
76
+ const path = yield* Path.Path;
77
+ const templateRoot = yield* getTemplateRoot;
78
+ const renderingFiles = yield* getTemplateFiles(path.join(templateRoot, 'rendering', directory));
79
+ yield* createFiles(projectPath, renderingFiles);
80
+ });
81
+ const createRenderingFiles = (projectPath, scaffold) => Match.value(scaffold).pipe(Match.tagsExhaustive({
82
+ Spa: () => Effect.void,
83
+ Ssg: () => overlayRenderingFiles(projectPath, 'ssg'),
84
+ Ssr: () => overlayRenderingFiles(projectPath, 'ssr'),
85
+ }));
86
+ export const createProject = (name, projectPath, scaffold, packageManager) => Effect.gen(function* () {
76
87
  yield* createBaseFiles(projectPath);
88
+ yield* createRenderingFiles(projectPath, scaffold);
77
89
  yield* modifyBaseFiles(projectPath, name, packageManager);
78
90
  yield* createPackageManagerFiles(projectPath, packageManager);
79
- yield* createExampleFiles(projectPath, example);
91
+ yield* Match.value(scaffold).pipe(Match.tagsExhaustive({
92
+ Spa: ({ example }) => createExampleFiles(projectPath, example),
93
+ Ssg: () => Effect.void,
94
+ Ssr: () => Effect.void,
95
+ }));
80
96
  });
81
97
  export const applyPackageManager = (readme, packageManager) => pipe(readme, String.replace('{{installCommand}}', installCommand(packageManager)), String.replace('{{devCommand}}', devCommand(packageManager)));
82
98
  const modifyBaseFiles = (projectPath, name, packageManager) => Effect.gen(function* () {
@@ -1,4 +1,4 @@
1
- import { Array, Effect, FileSystem, Match, Order, Path, Record, Result, Schema, pipe, } from 'effect';
1
+ import { Array, Data, Effect, FileSystem, Match, Order, Path, Record, Result, Schema, pipe, } from 'effect';
2
2
  import { HttpClient, HttpClientRequest } from 'effect/unstable/http';
3
3
  import { spawn } from 'node:child_process';
4
4
  export const installCommand = (packageManager) => `${packageManager} install`;
@@ -38,15 +38,16 @@ const TEMPLATE_DEV_DEPENDENCIES = [
38
38
  'prettier',
39
39
  'vitest',
40
40
  ];
41
+ const SERVER_RENDERING_DEV_DEPENDENCIES = ['@types/node'];
41
42
  const isFoldkitPackage = (name) => name === 'foldkit' || name.startsWith(FOLDKIT_SCOPE_PREFIX);
42
- const Keep = (version) => ({ _tag: 'Keep', version });
43
- const Latest = { _tag: 'Latest' };
43
+ const UnresolvedSpec = Data.taggedEnum();
44
+ const { Keep, Latest } = UnresolvedSpec;
44
45
  const toUnresolvedSpec = (spec, name) => {
45
46
  if (spec.includes('workspace:')) {
46
- return isFoldkitPackage(name) ? Result.succeed(Latest) : Result.failVoid;
47
+ return isFoldkitPackage(name) ? Result.succeed(Latest()) : Result.failVoid;
47
48
  }
48
49
  else {
49
- return Result.succeed(Keep(spec));
50
+ return Result.succeed(Keep({ version: spec }));
50
51
  }
51
52
  };
52
53
  /**
@@ -58,14 +59,35 @@ const toUnresolvedSpec = (spec, name) => {
58
59
  export const buildUnresolvedDeps = (exampleDeps) => Record.filterMap(exampleDeps, toUnresolvedSpec);
59
60
  /**
60
61
  * Build the devDependency map for a scaffolded project by merging the always-on
61
- * template tooling with the example's own `devDependencies`. A concrete version
62
- * from the example wins over the template's latest marker for the same package.
62
+ * template tooling and any extra scaffold devDependencies with the example's
63
+ * own `devDependencies`. A concrete version from the example wins over a
64
+ * latest marker for the same package.
63
65
  */
64
- export const buildUnresolvedDevDeps = (exampleDevDeps) => {
65
- const templateSpecs = Record.fromIterableWith(TEMPLATE_DEV_DEPENDENCIES, name => [name, Latest]);
66
+ export const buildUnresolvedDevDeps = (exampleDevDeps, extraDevDependencies) => {
67
+ const templateSpecs = Record.fromIterableWith([...TEMPLATE_DEV_DEPENDENCIES, ...extraDevDependencies], name => [name, Latest()]);
66
68
  const exampleSpecs = Record.filterMap(exampleDevDeps, toUnresolvedSpec);
67
69
  return Record.union(templateSpecs, exampleSpecs, (_templateSpec, exampleSpec) => exampleSpec);
68
70
  };
71
+ /**
72
+ * The repo example whose `package.json` supplies a scaffold's dependency
73
+ * versions. An SPA scaffold reads from its chosen starter example; the SSG and
74
+ * SSR scaffolds read from the reference apps their overlay files mirror.
75
+ */
76
+ export const dependencyExample = (scaffold) => Match.value(scaffold).pipe(Match.tagsExhaustive({
77
+ Spa: ({ example }) => example,
78
+ Ssg: () => 'ssg',
79
+ Ssr: () => 'ssr',
80
+ }));
81
+ /**
82
+ * The devDependencies a scaffold needs beyond the template tooling and the
83
+ * example's own list. The server-rendered scaffolds ship Node build and host
84
+ * scripts, so they need `@types/node` to typecheck.
85
+ */
86
+ export const scaffoldDevDependencies = (scaffold) => Match.value(scaffold).pipe(Match.tagsExhaustive({
87
+ Spa: () => [],
88
+ Ssg: () => SERVER_RENDERING_DEV_DEPENDENCIES,
89
+ Ssr: () => SERVER_RENDERING_DEV_DEPENDENCIES,
90
+ }));
69
91
  const resolveLatestVersion = (name) => Effect.gen(function* () {
70
92
  const client = yield* HttpClient.HttpClient;
71
93
  const encodedName = name.replace('/', '%2F');
@@ -130,10 +152,10 @@ const runCommand = (command, args, cwd) => Effect.callback((resume) => {
130
152
  }
131
153
  });
132
154
  });
133
- export const installDependencies = (projectPath, packageManager, example) => Effect.gen(function* () {
134
- const examplePackageJson = yield* fetchExamplePackageJson(example);
155
+ export const installDependencies = (projectPath, packageManager, scaffold) => Effect.gen(function* () {
156
+ const examplePackageJson = yield* fetchExamplePackageJson(dependencyExample(scaffold));
135
157
  const dependencies = yield* resolveSpecs(buildUnresolvedDeps(examplePackageJson.dependencies));
136
- const devDependencies = yield* resolveSpecs(buildUnresolvedDevDeps(examplePackageJson.devDependencies));
158
+ const devDependencies = yield* resolveSpecs(buildUnresolvedDevDeps(examplePackageJson.devDependencies, scaffoldDevDependencies(scaffold)));
137
159
  yield* writeManifest(projectPath, sortDependencies(dependencies), sortDependencies(devDependencies));
138
160
  yield* runCommand(packageManager, ['install'], projectPath);
139
161
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-foldkit-app",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Create Foldkit applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,15 +12,15 @@
12
12
  "templates"
13
13
  ],
14
14
  "dependencies": {
15
- "@effect/platform-node": "4.0.0-rc.108",
16
- "@effect/platform-node-shared": "4.0.0-rc.108",
15
+ "@effect/platform-node": "4.0.0-rc.109",
16
+ "@effect/platform-node-shared": "4.0.0-rc.109",
17
17
  "chalk": "^5.6.2",
18
- "effect": "4.0.0-rc.108",
18
+ "effect": "4.0.0-rc.109",
19
19
  "rimraf": "^6.1.3",
20
20
  "typescript": "^6.0.3"
21
21
  },
22
22
  "devDependencies": {
23
- "@effect/vitest": "4.0.0-rc.108",
23
+ "@effect/vitest": "4.0.0-rc.109",
24
24
  "@types/node": "^25.9.3",
25
25
  "vitest": "^4.1.9"
26
26
  },
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build --outDir dist/client && vite build --ssr src/entry.server.ts --outDir dist/server && tsx scripts/prerender.ts",
8
+ "preview": "vite preview --outDir dist/client",
9
+ "typecheck": "tsc --noEmit",
10
+ "format": "prettier -w .",
11
+ "test": "vitest run",
12
+ "lint": "oxlint src scripts"
13
+ }
14
+ }
@@ -0,0 +1,71 @@
1
+ import { Console, Effect, FileSystem } from 'effect'
2
+ import { Server } from 'foldkit/experimental'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+
6
+ import { NodeRuntime, NodeServices } from '@effect/platform-node'
7
+
8
+ import type * as ServerEntry from '../src/entry.server'
9
+
10
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
11
+ const PROJECT_DIR = resolve(SCRIPT_DIR, '..')
12
+ const CLIENT_DIR = resolve(PROJECT_DIR, 'dist/client')
13
+ const SERVER_ENTRY_PATH = resolve(PROJECT_DIR, 'dist/server/entry.server.js')
14
+ const SITE_ORIGIN = 'https://example.com'
15
+
16
+ const loadServerEntry: Effect.Effect<typeof ServerEntry> = Effect.promise(
17
+ () => import(pathToFileURL(SERVER_ENTRY_PATH).href),
18
+ )
19
+
20
+ const outputFileFor = (path: string): string => {
21
+ const url = new URL(path, SITE_ORIGIN)
22
+ if (url.origin !== SITE_ORIGIN || url.pathname !== path) {
23
+ throw new Error(
24
+ `Cannot generate the non-normalized same-origin path "${path}".`,
25
+ )
26
+ }
27
+ return path === '/'
28
+ ? resolve(CLIENT_DIR, 'index.html')
29
+ : resolve(CLIENT_DIR, path.slice(1), 'index.html')
30
+ }
31
+
32
+ const program = Effect.gen(function* () {
33
+ const fs = yield* FileSystem.FileSystem
34
+ const template = yield* fs.readFileString(resolve(CLIENT_DIR, 'index.html'))
35
+ const serverEntry = yield* loadServerEntry
36
+
37
+ for (const path of serverEntry.prerenderPaths) {
38
+ const result = yield* Effect.promise(() =>
39
+ serverEntry.renderPage(new Request(`${SITE_ORIGIN}${path}`)),
40
+ )
41
+ if (result._tag === 'Responded') {
42
+ return yield* Effect.die(
43
+ new Error(
44
+ `Cannot write the complete Response returned while generating "${path}" to a static HTML file.`,
45
+ ),
46
+ )
47
+ }
48
+ if (result.status !== undefined && result.status !== 200) {
49
+ return yield* Effect.die(
50
+ new Error(
51
+ `Cannot preserve status ${result.status} while generating "${path}" as a static HTML file.`,
52
+ ),
53
+ )
54
+ }
55
+ if (result.headers !== undefined) {
56
+ return yield* Effect.die(
57
+ new Error(
58
+ `Cannot preserve response headers while generating "${path}" as a static HTML file.`,
59
+ ),
60
+ )
61
+ }
62
+ const html = Server.injectIntoTemplate(template, result.application)
63
+ const outputFile = outputFileFor(path)
64
+
65
+ yield* fs.makeDirectory(dirname(outputFile), { recursive: true })
66
+ yield* fs.writeFileString(outputFile, html)
67
+ yield* Console.log(`Generated ${path}`)
68
+ }
69
+ }).pipe(Effect.provide(NodeServices.layer))
70
+
71
+ NodeRuntime.runMain(program)
@@ -0,0 +1,18 @@
1
+ import { Effect } from 'effect'
2
+ import { Server } from 'foldkit/experimental'
3
+
4
+ import { init, view } from './main'
5
+
6
+ export const prerenderPaths: ReadonlyArray<string> = ['/', '/about']
7
+
8
+ export const renderPage = (request: Request): Promise<Server.EntryResult> =>
9
+ Effect.runPromise(
10
+ Effect.gen(function* () {
11
+ const renderedApplication = yield* Server.renderToString(
12
+ { routing: {}, init, view },
13
+ { url: request.url },
14
+ )
15
+
16
+ return Server.Rendered(renderedApplication)
17
+ }),
18
+ )
@@ -0,0 +1,28 @@
1
+ import { Runtime } from 'foldkit'
2
+
3
+ import {
4
+ ChangedUrl,
5
+ ClickedLink,
6
+ Message,
7
+ Model,
8
+ init,
9
+ update,
10
+ view,
11
+ } from './main'
12
+
13
+ const application = Runtime.makeApplication({
14
+ Model,
15
+ init,
16
+ update,
17
+ view,
18
+ container: document.getElementById('root'),
19
+ routing: {
20
+ onUrlRequest: request => ClickedLink({ request }),
21
+ onUrlChange: url => ChangedUrl({ url }),
22
+ },
23
+ devTools: {
24
+ Message,
25
+ },
26
+ })
27
+
28
+ Runtime.hydrate(application)
@@ -0,0 +1,176 @@
1
+ import { Effect, Match as M, Schema as S, pipe } from 'effect'
2
+ import { Command, Runtime } from 'foldkit'
3
+ import { type Document, type Html, type HtmlBuilder } from 'foldkit/html'
4
+ import { m } from 'foldkit/message'
5
+ import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
6
+ import { evo } from 'foldkit/struct'
7
+ import { Url, toString as urlToString } from 'foldkit/url'
8
+
9
+ import { AppRoute, aboutRouter, homeRouter, urlToAppRoute } from './route'
10
+
11
+ // MODEL
12
+
13
+ export const Model = S.Struct({
14
+ route: AppRoute,
15
+ count: S.Number,
16
+ })
17
+ export type Model = typeof Model.Type
18
+
19
+ // MESSAGE
20
+
21
+ export const ClickedIncrement = m('ClickedIncrement')
22
+ export const ClickedLink = m('ClickedLink', { request: UrlRequest })
23
+ export const ChangedUrl = m('ChangedUrl', { url: Url })
24
+ export const CompletedNavigateInternal = m('CompletedNavigateInternal')
25
+ export const CompletedLoadExternal = m('CompletedLoadExternal')
26
+
27
+ export const Message = S.Union([
28
+ ClickedIncrement,
29
+ ClickedLink,
30
+ ChangedUrl,
31
+ CompletedNavigateInternal,
32
+ CompletedLoadExternal,
33
+ ])
34
+ export type Message = typeof Message.Type
35
+
36
+ // INIT
37
+
38
+ export const init: Runtime.RoutingApplicationInit<Model, Message> = url => [
39
+ { route: urlToAppRoute(url), count: 0 },
40
+ [],
41
+ ]
42
+
43
+ // COMMAND
44
+
45
+ const NavigateInternal = Command.define('NavigateInternal', {
46
+ args: { url: S.String },
47
+ messages: [CompletedNavigateInternal],
48
+ execute: ({ url }) =>
49
+ pushUrl(url).pipe(Effect.as(CompletedNavigateInternal())),
50
+ })
51
+
52
+ const LoadExternal = Command.define('LoadExternal', {
53
+ args: { href: S.String },
54
+ messages: [CompletedLoadExternal],
55
+ execute: ({ href }) => load(href).pipe(Effect.as(CompletedLoadExternal())),
56
+ })
57
+
58
+ // UPDATE
59
+
60
+ type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
61
+ const withUpdateReturn = M.withReturnType<UpdateReturn>()
62
+
63
+ export const update = (model: Model, message: Message): UpdateReturn =>
64
+ M.value(message).pipe(
65
+ withUpdateReturn,
66
+ M.tagsExhaustive({
67
+ ClickedIncrement: () => [evo(model, { count: count => count + 1 }), []],
68
+ ClickedLink: ({ request }) =>
69
+ M.value(request).pipe(
70
+ withUpdateReturn,
71
+ M.tagsExhaustive({
72
+ Internal: ({ url }) => [
73
+ model,
74
+ [NavigateInternal({ url: urlToString(url) })],
75
+ ],
76
+ External: ({ href }) => [model, [LoadExternal({ href })]],
77
+ }),
78
+ ),
79
+ ChangedUrl: ({ url }) => [
80
+ evo(model, { route: () => urlToAppRoute(url) }),
81
+ [],
82
+ ],
83
+ CompletedNavigateInternal: () => [model, []],
84
+ CompletedLoadExternal: () => [model, []],
85
+ }),
86
+ )
87
+
88
+ // VIEW
89
+
90
+ const APP_NAME = 'Foldkit App'
91
+
92
+ const appendAppName = (page: string): string => `${page} | ${APP_NAME}`
93
+
94
+ const routeTitle = (route: AppRoute): string =>
95
+ pipe(
96
+ M.value(route),
97
+ M.tagsExhaustive({
98
+ Home: () => 'Home',
99
+ About: () => 'About',
100
+ NotFound: () => 'Not Found',
101
+ }),
102
+ appendAppName,
103
+ )
104
+
105
+ const navigationView = (h: HtmlBuilder<Message>): Html =>
106
+ h.nav(
107
+ [h.Class('flex gap-4')],
108
+ [
109
+ h.a([h.Href(homeRouter()), h.Class('underline')], ['Home']),
110
+ h.a([h.Href(aboutRouter()), h.Class('underline')], ['About']),
111
+ ],
112
+ )
113
+
114
+ const pageView = (model: Model, h: HtmlBuilder<Message>): Html =>
115
+ M.value(model.route).pipe(
116
+ M.tagsExhaustive({
117
+ Home: () =>
118
+ h.section(
119
+ [h.Class('grid gap-4')],
120
+ [
121
+ h.h1(
122
+ [h.Id('page-title'), h.Class('text-4xl font-bold')],
123
+ ['Statically generated home'],
124
+ ),
125
+ h.p(
126
+ [],
127
+ [
128
+ 'This route was rendered during the build and hydrated in place.',
129
+ ],
130
+ ),
131
+ h.button(
132
+ [
133
+ h.OnClick(ClickedIncrement()),
134
+ h.Class('w-fit bg-black px-4 py-2 text-white'),
135
+ ],
136
+ [`Count: ${model.count}`],
137
+ ),
138
+ ],
139
+ ),
140
+ About: () =>
141
+ h.section(
142
+ [h.Class('grid gap-4')],
143
+ [
144
+ h.h1(
145
+ [h.Id('page-title'), h.Class('text-4xl font-bold')],
146
+ ['Statically generated about page'],
147
+ ),
148
+ h.p(
149
+ [],
150
+ [
151
+ 'The same renderPage function produced this route in the same build.',
152
+ ],
153
+ ),
154
+ ],
155
+ ),
156
+ NotFound: ({ path }) =>
157
+ h.section(
158
+ [h.Class('grid gap-4')],
159
+ [
160
+ h.h1(
161
+ [h.Id('page-title'), h.Class('text-4xl font-bold')],
162
+ ['Not found'],
163
+ ),
164
+ h.p([], [`No statically generated page exists for ${path}.`]),
165
+ ],
166
+ ),
167
+ }),
168
+ )
169
+
170
+ export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
171
+ title: routeTitle(model.route),
172
+ body: h.main(
173
+ [h.Class('mx-auto grid min-h-screen max-w-3xl content-center gap-10 p-8')],
174
+ [navigationView(h), pageView(model, h)],
175
+ ),
176
+ })
@@ -0,0 +1,20 @@
1
+ import { Schema as S, pipe } from 'effect'
2
+ import { Route } from 'foldkit'
3
+ import { literal, r } from 'foldkit/route'
4
+
5
+ export const HomeRoute = r('Home')
6
+ export const AboutRoute = r('About')
7
+ export const NotFoundRoute = r('NotFound', { path: S.String })
8
+
9
+ export const AppRoute = S.Union([HomeRoute, AboutRoute, NotFoundRoute])
10
+ export type AppRoute = typeof AppRoute.Type
11
+
12
+ export const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))
13
+ export const aboutRouter = pipe(literal('about'), Route.mapTo(AboutRoute))
14
+
15
+ const routeParser = Route.oneOf(aboutRouter, homeRouter)
16
+
17
+ export const urlToAppRoute = Route.parseUrlWithFallback(
18
+ routeParser,
19
+ NotFoundRoute,
20
+ )
@@ -0,0 +1,27 @@
1
+ import { click, expect, given, role, scene, text } from 'foldkit/scene'
2
+ import { describe, test } from 'vitest'
3
+
4
+ import { Model, update, view } from './main'
5
+ import { HomeRoute } from './route'
6
+
7
+ const initialModel = Model.make({ route: HomeRoute(), count: 0 })
8
+
9
+ describe('view', () => {
10
+ test('renders the statically generated home page', () => {
11
+ scene(
12
+ { update, view },
13
+ given(initialModel),
14
+ expect(text('Statically generated home')).toExist(),
15
+ expect(role('button', { name: 'Count: 0' })).toExist(),
16
+ )
17
+ })
18
+
19
+ test('clicking the counter increments the count', () => {
20
+ scene(
21
+ { update, view },
22
+ given(initialModel),
23
+ click(role('button', { name: 'Count: 0' })),
24
+ expect(role('button', { name: 'Count: 1' })).toExist(),
25
+ )
26
+ })
27
+ })
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "isolatedModules": true,
13
+ "noEmit": true
14
+ },
15
+ "include": ["src/**/*", "scripts/**/*"]
16
+ }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ import { foldkit } from '@foldkit/vite-plugin'
4
+ import tailwindcss from '@tailwindcss/vite'
5
+
6
+ export default defineConfig({
7
+ plugins: [
8
+ tailwindcss(),
9
+ foldkit({
10
+ devToolsMcpPort: 9988,
11
+ ssr: { serverEntry: '/src/entry.server.ts' },
12
+ }),
13
+ ],
14
+ optimizeDeps: {
15
+ entries: ['src/entry.ts'],
16
+ },
17
+ })
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build --outDir dist/client && vite build --ssr server/main.ts --outDir dist/server",
8
+ "start": "node dist/server/main.js",
9
+ "typecheck": "tsc --noEmit",
10
+ "format": "prettier -w .",
11
+ "test": "vitest run",
12
+ "lint": "oxlint src server"
13
+ }
14
+ }
@@ -0,0 +1,129 @@
1
+ import { Config, Effect, FileSystem, Layer, Match as M, Option } from 'effect'
2
+ import {
3
+ Headers as HttpHeaders,
4
+ HttpServer,
5
+ HttpServerError,
6
+ HttpServerRequest,
7
+ HttpServerResponse,
8
+ HttpStaticServer,
9
+ } from 'effect/unstable/http'
10
+ import { Server } from 'foldkit/experimental'
11
+ import { createServer } from 'node:http'
12
+ import { dirname, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import {
16
+ NodeHttpPlatform,
17
+ NodeHttpServer,
18
+ NodeRuntime,
19
+ NodeServices,
20
+ } from '@effect/platform-node'
21
+
22
+ import { renderPage } from '../src/entry.server'
23
+
24
+ const PROJECT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
25
+ const CLIENT_DIR = resolve(PROJECT_DIR, 'dist/client')
26
+ const DEFAULT_PORT = 3000
27
+
28
+ const renderRequest = (
29
+ request: HttpServerRequest.HttpServerRequest,
30
+ template: string,
31
+ ) =>
32
+ Effect.gen(function* () {
33
+ const webRequest = yield* HttpServerRequest.toWeb(request)
34
+ const result = yield* Effect.promise(() => renderPage(webRequest))
35
+ return HttpServerResponse.fromWeb(Server.toResponse(template, result))
36
+ })
37
+
38
+ // NOTE: Vary: Accept keeps a shared cache from serving one client's
39
+ // representation to another when a static miss is answered by content
40
+ // negotiation. It is merged with any Vary the render already set, parsing
41
+ // Vary as field-name tokens so Accept-Language or Accept-Encoding is never
42
+ // mistaken for the Accept field.
43
+ const withVaryAccept = (
44
+ response: HttpServerResponse.HttpServerResponse,
45
+ ): HttpServerResponse.HttpServerResponse =>
46
+ HttpServerResponse.setHeader(
47
+ response,
48
+ 'vary',
49
+ Server.varyWithAccept(
50
+ Option.getOrUndefined(HttpHeaders.get('vary')(response.headers)),
51
+ ),
52
+ )
53
+
54
+ const isRouteNotFound = (error: HttpServerError.HttpServerError): boolean =>
55
+ error.reason._tag === 'RouteNotFound'
56
+
57
+ type RequestKind = 'Render' | 'StaticOrRender' | 'MethodNotAllowed'
58
+
59
+ // NOTE: `/` and `/index.html` (and the encoded paths that resolve to them)
60
+ // are application requests even though a file exists for them: the file on
61
+ // disk is the unfilled template, and serving it raw would hand the browser an
62
+ // unstamped shell that Runtime.hydrate refuses. Only GET and HEAD render; every
63
+ // other method (OPTIONS, POST, ...) is refused with 405 rather than rendered.
64
+ const requestKind = ({
65
+ method,
66
+ url,
67
+ }: HttpServerRequest.HttpServerRequest): RequestKind =>
68
+ M.value(method).pipe(
69
+ M.withReturnType<RequestKind>(),
70
+ M.whenOr('GET', 'HEAD', () =>
71
+ Server.resolvesToIndexHtml(url) ? 'Render' : 'StaticOrRender',
72
+ ),
73
+ M.orElse(() => 'MethodNotAllowed'),
74
+ )
75
+
76
+ const makeHandler = Effect.gen(function* () {
77
+ const fs = yield* FileSystem.FileSystem
78
+ const template = yield* fs.readFileString(resolve(CLIENT_DIR, 'index.html'))
79
+ const staticFiles = yield* HttpStaticServer.make({
80
+ root: CLIENT_DIR,
81
+ index: undefined,
82
+ })
83
+
84
+ // NOTE: a static miss is Accept-negotiated because a deep link into a client
85
+ // route has no file on disk but an HTML client should still get the app
86
+ // shell. It renders, anything else 404s, and both carry Vary: Accept.
87
+ const serveStaticOrRender = (request: HttpServerRequest.HttpServerRequest) =>
88
+ staticFiles.pipe(
89
+ Effect.catchIf(isRouteNotFound, () =>
90
+ Server.acceptsHtml(request.headers['accept'])
91
+ ? renderRequest(request, template).pipe(Effect.map(withVaryAccept))
92
+ : Effect.succeed(
93
+ withVaryAccept(HttpServerResponse.empty({ status: 404 })),
94
+ ),
95
+ ),
96
+ )
97
+
98
+ return HttpServerRequest.HttpServerRequest.use(request =>
99
+ M.value(requestKind(request)).pipe(
100
+ M.when('Render', () => renderRequest(request, template)),
101
+ M.when('StaticOrRender', () => serveStaticOrRender(request)),
102
+ M.when('MethodNotAllowed', () =>
103
+ Effect.succeed(
104
+ HttpServerResponse.setHeader(
105
+ HttpServerResponse.empty({ status: 405 }),
106
+ 'allow',
107
+ 'GET, HEAD',
108
+ ),
109
+ ),
110
+ ),
111
+ M.exhaustive,
112
+ ),
113
+ )
114
+ })
115
+
116
+ const Main = Layer.unwrap(
117
+ Effect.map(makeHandler, handler => HttpServer.serve(handler)),
118
+ ).pipe(
119
+ HttpServer.withLogAddress,
120
+ Layer.provide(
121
+ NodeHttpServer.layerConfig(createServer, {
122
+ port: Config.withDefault(Config.port('PORT'), DEFAULT_PORT),
123
+ }),
124
+ ),
125
+ Layer.provide(NodeHttpPlatform.layer),
126
+ Layer.provide(NodeServices.layer),
127
+ )
128
+
129
+ NodeRuntime.runMain(Layer.launch(Main))
@@ -0,0 +1,13 @@
1
+ import { Number as Number_, Option, Record, pipe } from 'effect'
2
+ import { Cookies } from 'effect/unstable/http'
3
+
4
+ export const COUNT_COOKIE = 'count'
5
+
6
+ export const readCountCookie = (cookieHeader: string): number =>
7
+ pipe(
8
+ Cookies.parseHeader(cookieHeader),
9
+ Record.get(COUNT_COOKIE),
10
+ Option.flatMap(Number_.parse),
11
+ Option.filter(Number.isSafeInteger),
12
+ Option.getOrElse(() => 0),
13
+ )
@@ -0,0 +1,33 @@
1
+ import { Effect } from 'effect'
2
+ import { Server } from 'foldkit/experimental'
3
+
4
+ import { readCountCookie } from './cookie'
5
+ import { Flags, init, view } from './main'
6
+
7
+ const flagsForRequest = (cookieHeader: string): Flags => ({
8
+ initialCount: readCountCookie(cookieHeader),
9
+ renderedAt: new Date().toISOString(),
10
+ renderedOn: 'Server',
11
+ })
12
+
13
+ // NOTE: the Flags built from this request are serialized into the rendered
14
+ // HTML and travel to the browser with it. The hydrating client reads them
15
+ // back and calls init with the exact values this render used; the client
16
+ // computes no Flags of its own.
17
+ export const renderPage = (request: Request): Promise<Server.EntryResult> =>
18
+ Effect.runPromise(
19
+ Effect.gen(function* () {
20
+ const renderedApplication = yield* Server.renderToString(
21
+ { Flags, init, view },
22
+ { flags: flagsForRequest(request.headers.get('cookie') ?? '') },
23
+ )
24
+
25
+ return Server.Rendered(renderedApplication, {
26
+ headers: {
27
+ 'cache-control': 'private, no-store',
28
+ vary: 'cookie',
29
+ 'x-content-type-options': 'nosniff',
30
+ },
31
+ })
32
+ }),
33
+ )
@@ -0,0 +1,17 @@
1
+ import { Runtime } from 'foldkit'
2
+
3
+ import { Flags, Message, Model, init, update, view } from './main'
4
+
5
+ const application = Runtime.makeApplication({
6
+ Model,
7
+ Flags,
8
+ init,
9
+ update,
10
+ view,
11
+ container: document.getElementById('root'),
12
+ devTools: {
13
+ Message,
14
+ },
15
+ })
16
+
17
+ Runtime.hydrate(application)
@@ -0,0 +1,155 @@
1
+ import { Effect, Match as M, Schema as S } from 'effect'
2
+ import { Command, Runtime } from 'foldkit'
3
+ import { type Document, type HtmlBuilder } from 'foldkit/html'
4
+ import { m } from 'foldkit/message'
5
+ import { evo } from 'foldkit/struct'
6
+
7
+ import { Button } from '@foldkit/ui'
8
+
9
+ import { COUNT_COOKIE } from './cookie'
10
+
11
+ // MODEL
12
+
13
+ export const Model = S.Struct({
14
+ count: S.Number,
15
+ renderedAt: S.String,
16
+ renderedOn: S.Literals(['Server', 'Client']),
17
+ })
18
+ export type Model = typeof Model.Type
19
+
20
+ // FLAGS
21
+
22
+ export const Flags = S.Struct({
23
+ initialCount: S.Number,
24
+ renderedAt: S.String,
25
+ renderedOn: S.Literals(['Server', 'Client']),
26
+ })
27
+ export type Flags = typeof Flags.Type
28
+
29
+ // MESSAGE
30
+
31
+ export const ClickedDecrement = m('ClickedDecrement')
32
+ export const ClickedIncrement = m('ClickedIncrement')
33
+ export const CompletedPersistCount = m('CompletedPersistCount')
34
+
35
+ export const Message = S.Union([
36
+ ClickedDecrement,
37
+ ClickedIncrement,
38
+ CompletedPersistCount,
39
+ ])
40
+ export type Message = typeof Message.Type
41
+
42
+ // UPDATE
43
+
44
+ export const update = (
45
+ model: Model,
46
+ message: Message,
47
+ ): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
48
+ M.value(message).pipe(
49
+ M.withReturnType<
50
+ readonly [Model, ReadonlyArray<Command.Command<Message>>]
51
+ >(),
52
+ M.tagsExhaustive({
53
+ ClickedDecrement: () => {
54
+ const nextCount = model.count - 1
55
+ return [
56
+ evo(model, { count: () => nextCount }),
57
+ [PersistCount({ count: nextCount })],
58
+ ]
59
+ },
60
+ ClickedIncrement: () => {
61
+ const nextCount = model.count + 1
62
+ return [
63
+ evo(model, { count: () => nextCount }),
64
+ [PersistCount({ count: nextCount })],
65
+ ]
66
+ },
67
+ CompletedPersistCount: () => [model, []],
68
+ }),
69
+ )
70
+
71
+ // COMMAND
72
+
73
+ const COUNT_COOKIE_MAX_AGE_SECONDS = 31536000
74
+
75
+ export const PersistCount = Command.define('PersistCount', {
76
+ args: { count: S.Number },
77
+ messages: [CompletedPersistCount],
78
+ execute: ({ count }) =>
79
+ Effect.try(() => {
80
+ document.cookie = `${COUNT_COOKIE}=${count}; path=/; max-age=${COUNT_COOKIE_MAX_AGE_SECONDS}`
81
+ }).pipe(
82
+ Effect.map(() => CompletedPersistCount()),
83
+ Effect.catch(() => Effect.succeed(CompletedPersistCount())),
84
+ ),
85
+ })
86
+
87
+ // INIT
88
+
89
+ export const init: Runtime.ApplicationInit<Model, Message, Flags> = flags => [
90
+ {
91
+ count: flags.initialCount,
92
+ renderedAt: flags.renderedAt,
93
+ renderedOn: flags.renderedOn,
94
+ },
95
+ [],
96
+ ]
97
+
98
+ // VIEW
99
+
100
+ export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
101
+ title: `Count ${model.count}`,
102
+ body: h.div(
103
+ [
104
+ h.Class(
105
+ 'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6',
106
+ ),
107
+ ],
108
+ [
109
+ h.h1(
110
+ [h.Class('text-2xl font-semibold text-gray-800')],
111
+ ['Server-rendered counter'],
112
+ ),
113
+ h.p(
114
+ [h.Id('count'), h.Class('text-6xl font-bold text-gray-800')],
115
+ [model.count.toString()],
116
+ ),
117
+ h.div(
118
+ [h.Class('flex flex-wrap justify-center gap-4')],
119
+ [
120
+ Button.view(
121
+ {
122
+ onClick: ClickedDecrement(),
123
+ toView: attributes =>
124
+ h.button([...attributes.button, h.Class(buttonStyle)], ['-']),
125
+ },
126
+ h,
127
+ ),
128
+ Button.view(
129
+ {
130
+ onClick: ClickedIncrement(),
131
+ toView: attributes =>
132
+ h.button([...attributes.button, h.Class(buttonStyle)], ['+']),
133
+ },
134
+ h,
135
+ ),
136
+ ],
137
+ ),
138
+ h.p(
139
+ [h.Id('provenance'), h.Class('text-sm text-gray-500')],
140
+ [`Rendered on the ${model.renderedOn} at ${model.renderedAt}`],
141
+ ),
142
+ h.p(
143
+ [h.Class('text-sm text-gray-500 max-w-md text-center')],
144
+ [
145
+ 'The count persists in a cookie. Reload the page and the server ' +
146
+ 'renders your latest count into the HTML before any JavaScript runs.',
147
+ ],
148
+ ),
149
+ ],
150
+ ),
151
+ })
152
+
153
+ // STYLE
154
+
155
+ const buttonStyle = 'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'
@@ -0,0 +1,43 @@
1
+ import { Command, click, expect, given, role, scene, text } from 'foldkit/scene'
2
+ import { describe, test } from 'vitest'
3
+
4
+ import {
5
+ CompletedPersistCount,
6
+ Model,
7
+ PersistCount,
8
+ update,
9
+ view,
10
+ } from './main'
11
+
12
+ const initialModel = Model.make({
13
+ count: 3,
14
+ renderedAt: '2026-07-26T00:00:00.000Z',
15
+ renderedOn: 'Server',
16
+ })
17
+
18
+ describe('view', () => {
19
+ test('renders the count and provenance line', () => {
20
+ scene(
21
+ { update, view },
22
+ given(initialModel),
23
+ expect(text('3')).toExist(),
24
+ expect(
25
+ text('Rendered on the Server at 2026-07-26T00:00:00.000Z'),
26
+ ).toExist(),
27
+ expect(role('button', { name: '+' })).toExist(),
28
+ expect(role('button', { name: '-' })).toExist(),
29
+ )
30
+ })
31
+
32
+ test('clicking + increments and persists the count', () => {
33
+ scene(
34
+ { update, view },
35
+ given(initialModel),
36
+ click(role('button', { name: '+' })),
37
+ expect(text('4')).toExist(),
38
+ Command.expectHas(PersistCount({ count: 4 })),
39
+ Command.resolve(PersistCount({ count: 4 }), CompletedPersistCount()),
40
+ Command.expectNone(),
41
+ )
42
+ })
43
+ })
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "isolatedModules": true,
13
+ "noEmit": true
14
+ },
15
+ "include": ["src/**/*", "server/**/*"]
16
+ }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ import { foldkit } from '@foldkit/vite-plugin'
4
+ import tailwindcss from '@tailwindcss/vite'
5
+
6
+ export default defineConfig({
7
+ plugins: [
8
+ tailwindcss(),
9
+ foldkit({
10
+ devToolsMcpPort: 9988,
11
+ ssr: { serverEntry: '/src/entry.server.ts' },
12
+ }),
13
+ ],
14
+ optimizeDeps: {
15
+ entries: ['src/entry.ts'],
16
+ },
17
+ })