create-foldkit-app 0.26.0 → 0.27.1

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.
Files changed (32) hide show
  1. package/README.md +12 -2
  2. package/dist/commands/create.js +28 -11
  3. package/dist/index.js +4 -1
  4. package/dist/rendering.js +20 -0
  5. package/dist/utils/files.js +20 -4
  6. package/dist/utils/packages.js +41 -12
  7. package/package.json +1 -1
  8. package/templates/package-managers/pnpm/pnpm-workspace.yaml +1 -0
  9. package/templates/rendering/ssg/README.md +61 -0
  10. package/templates/rendering/ssg/package.json +14 -0
  11. package/templates/rendering/ssg/scripts/build.mjs +40 -0
  12. package/templates/rendering/ssg/scripts/prerender.ts +71 -0
  13. package/templates/rendering/ssg/src/entry.server.ts +18 -0
  14. package/templates/rendering/ssg/src/entry.ts +28 -0
  15. package/templates/rendering/ssg/src/main.ts +176 -0
  16. package/templates/rendering/ssg/src/route.ts +20 -0
  17. package/templates/rendering/ssg/src/scene.test.ts +27 -0
  18. package/templates/rendering/ssg/src/vite-env.d.ts +19 -0
  19. package/templates/rendering/ssg/tsconfig.json +16 -0
  20. package/templates/rendering/ssg/vite.config.ts +17 -0
  21. package/templates/rendering/ssr/README.md +65 -0
  22. package/templates/rendering/ssr/package.json +14 -0
  23. package/templates/rendering/ssr/scripts/build.mjs +36 -0
  24. package/templates/rendering/ssr/server/main.ts +205 -0
  25. package/templates/rendering/ssr/src/cookie.ts +13 -0
  26. package/templates/rendering/ssr/src/entry.server.ts +50 -0
  27. package/templates/rendering/ssr/src/entry.ts +17 -0
  28. package/templates/rendering/ssr/src/main.ts +155 -0
  29. package/templates/rendering/ssr/src/scene.test.ts +43 -0
  30. package/templates/rendering/ssr/src/vite-env.d.ts +19 -0
  31. package/templates/rendering/ssr/tsconfig.json +16 -0
  32. package/templates/rendering/ssr/vite.config.ts +17 -0
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();
@@ -1,7 +1,7 @@
1
1
  import { Array, Effect, FileSystem, Match, Option, Path, Record, Ref, Schema, String, pipe, } from 'effect';
2
2
  import { HttpClient, HttpClientRequest, } from 'effect/unstable/http';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { devCommand, installCommand } from './packages.js';
4
+ import { devCommand, installCommand, runScriptCommand, } from './packages.js';
5
5
  const GITHUB_API_BASE_URL = 'https://api.github.com/repos/foldkit/foldkit/contents/examples';
6
6
  const getTemplateRoot = Effect.gen(function* () {
7
7
  const path = yield* Path.Path;
@@ -72,13 +72,29 @@ 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
- export const applyPackageManager = (readme, packageManager) => pipe(readme, String.replace('{{installCommand}}', installCommand(packageManager)), String.replace('{{devCommand}}', devCommand(packageManager)));
97
+ export const applyPackageManager = (readme, packageManager) => pipe(readme, String.replaceAll('{{installCommand}}', installCommand(packageManager)), String.replaceAll('{{devCommand}}', devCommand(packageManager)), String.replaceAll('{{buildCommand}}', runScriptCommand(packageManager, 'build')), String.replaceAll('{{previewCommand}}', runScriptCommand(packageManager, 'preview')), String.replaceAll('{{startCommand}}', runScriptCommand(packageManager, 'start')));
82
98
  const modifyBaseFiles = (projectPath, name, packageManager) => Effect.gen(function* () {
83
99
  const fs = yield* FileSystem.FileSystem;
84
100
  const path = yield* Path.Path;
@@ -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`;
@@ -9,6 +9,13 @@ const DEV_COMMANDS = {
9
9
  bun: 'bun dev',
10
10
  };
11
11
  export const devCommand = (packageManager) => DEV_COMMANDS[packageManager];
12
+ const RUN_SCRIPT_PREFIXES = {
13
+ pnpm: 'pnpm',
14
+ npm: 'npm run',
15
+ yarn: 'yarn',
16
+ bun: 'bun run',
17
+ };
18
+ export const runScriptCommand = (packageManager, script) => `${RUN_SCRIPT_PREFIXES[packageManager]} ${script}`;
12
19
  const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/foldkit/foldkit/main/examples';
13
20
  const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
14
21
  const FOLDKIT_SCOPE_PREFIX = '@foldkit/';
@@ -38,15 +45,16 @@ const TEMPLATE_DEV_DEPENDENCIES = [
38
45
  'prettier',
39
46
  'vitest',
40
47
  ];
48
+ const SERVER_RENDERING_DEV_DEPENDENCIES = ['@types/node'];
41
49
  const isFoldkitPackage = (name) => name === 'foldkit' || name.startsWith(FOLDKIT_SCOPE_PREFIX);
42
- const Keep = (version) => ({ _tag: 'Keep', version });
43
- const Latest = { _tag: 'Latest' };
50
+ const UnresolvedSpec = Data.taggedEnum();
51
+ const { Keep, Latest } = UnresolvedSpec;
44
52
  const toUnresolvedSpec = (spec, name) => {
45
53
  if (spec.includes('workspace:')) {
46
- return isFoldkitPackage(name) ? Result.succeed(Latest) : Result.failVoid;
54
+ return isFoldkitPackage(name) ? Result.succeed(Latest()) : Result.failVoid;
47
55
  }
48
56
  else {
49
- return Result.succeed(Keep(spec));
57
+ return Result.succeed(Keep({ version: spec }));
50
58
  }
51
59
  };
52
60
  /**
@@ -58,14 +66,35 @@ const toUnresolvedSpec = (spec, name) => {
58
66
  export const buildUnresolvedDeps = (exampleDeps) => Record.filterMap(exampleDeps, toUnresolvedSpec);
59
67
  /**
60
68
  * 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.
69
+ * template tooling and any extra scaffold devDependencies with the example's
70
+ * own `devDependencies`. A concrete version from the example wins over a
71
+ * latest marker for the same package.
63
72
  */
64
- export const buildUnresolvedDevDeps = (exampleDevDeps) => {
65
- const templateSpecs = Record.fromIterableWith(TEMPLATE_DEV_DEPENDENCIES, name => [name, Latest]);
73
+ export const buildUnresolvedDevDeps = (exampleDevDeps, extraDevDependencies) => {
74
+ const templateSpecs = Record.fromIterableWith([...TEMPLATE_DEV_DEPENDENCIES, ...extraDevDependencies], name => [name, Latest()]);
66
75
  const exampleSpecs = Record.filterMap(exampleDevDeps, toUnresolvedSpec);
67
76
  return Record.union(templateSpecs, exampleSpecs, (_templateSpec, exampleSpec) => exampleSpec);
68
77
  };
78
+ /**
79
+ * The repo example whose `package.json` supplies a scaffold's dependency
80
+ * versions. An SPA scaffold reads from its chosen starter example; the SSG and
81
+ * SSR scaffolds read from the reference apps their overlay files mirror.
82
+ */
83
+ export const dependencyExample = (scaffold) => Match.value(scaffold).pipe(Match.tagsExhaustive({
84
+ Spa: ({ example }) => example,
85
+ Ssg: () => 'ssg',
86
+ Ssr: () => 'ssr',
87
+ }));
88
+ /**
89
+ * The devDependencies a scaffold needs beyond the template tooling and the
90
+ * example's own list. The server-rendered scaffolds ship Node build and host
91
+ * scripts, so they need `@types/node` to typecheck.
92
+ */
93
+ export const scaffoldDevDependencies = (scaffold) => Match.value(scaffold).pipe(Match.tagsExhaustive({
94
+ Spa: () => [],
95
+ Ssg: () => SERVER_RENDERING_DEV_DEPENDENCIES,
96
+ Ssr: () => SERVER_RENDERING_DEV_DEPENDENCIES,
97
+ }));
69
98
  const resolveLatestVersion = (name) => Effect.gen(function* () {
70
99
  const client = yield* HttpClient.HttpClient;
71
100
  const encodedName = name.replace('/', '%2F');
@@ -130,10 +159,10 @@ const runCommand = (command, args, cwd) => Effect.callback((resume) => {
130
159
  }
131
160
  });
132
161
  });
133
- export const installDependencies = (projectPath, packageManager, example) => Effect.gen(function* () {
134
- const examplePackageJson = yield* fetchExamplePackageJson(example);
162
+ export const installDependencies = (projectPath, packageManager, scaffold) => Effect.gen(function* () {
163
+ const examplePackageJson = yield* fetchExamplePackageJson(dependencyExample(scaffold));
135
164
  const dependencies = yield* resolveSpecs(buildUnresolvedDeps(examplePackageJson.dependencies));
136
- const devDependencies = yield* resolveSpecs(buildUnresolvedDevDeps(examplePackageJson.devDependencies));
165
+ const devDependencies = yield* resolveSpecs(buildUnresolvedDevDeps(examplePackageJson.devDependencies, scaffoldDevDependencies(scaffold)));
137
166
  yield* writeManifest(projectPath, sortDependencies(dependencies), sortDependencies(devDependencies));
138
167
  yield* runCommand(packageManager, ['install'], projectPath);
139
168
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-foldkit-app",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Create Foldkit applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,2 +1,3 @@
1
1
  allowBuilds:
2
+ esbuild: true
2
3
  msgpackr-extract: false
@@ -0,0 +1,61 @@
1
+ # My Foldkit App
2
+
3
+ A statically generated Foldkit application built with Effect.
4
+
5
+ ## Getting Started
6
+
7
+ ```bash
8
+ {{installCommand}}
9
+ {{devCommand}}
10
+ ```
11
+
12
+ ## Building and previewing
13
+
14
+ ```bash
15
+ {{buildCommand}}
16
+ {{previewCommand}}
17
+ ```
18
+
19
+ The build script runs `scripts/build.mjs`, which builds the client bundle, builds
20
+ the server bundle, prerenders every path `src/entry.server.ts` lists, and gives
21
+ all three steps the same build id.
22
+
23
+ ## The build id
24
+
25
+ The build id does not make hydration correct. It makes hydration refuse when it
26
+ would otherwise be incorrect.
27
+
28
+ The generated page carries the id, and the client bundle carries its own copy.
29
+ Hydration compares the two before it reads the Flags payload or adopts DOM.
30
+ When they differ, startup stops and the document body is marked `inert`,
31
+ `aria-hidden`, and `data-foldkit-refused`. A nondismissable modal shield covers
32
+ its controls and existing top-layer content, then takes focus without closing
33
+ author-owned dialogs. Nothing moves, so no custom element reconnects and no
34
+ frame reloads.
35
+
36
+ Without that check, stale HTML from an earlier deployment can be hydrated by the
37
+ newer client. Where the old markup happens to line up with the new markup,
38
+ an input the old page called `email` can be adopted for whatever the new build
39
+ puts in that position, carrying what the visitor typed into it.
40
+
41
+ The comparison happens when a client boots against a page. A tab whose client
42
+ is already running when a deployment lands is not rechecked.
43
+
44
+ `scripts/build.mjs` takes care of this: it produces one id per build and passes
45
+ it to every step. Supply `FOLDKIT_BUILD_ID` when those steps run in separate
46
+ jobs, or when you want the served id to name a deployment you can look up later:
47
+
48
+ ```bash
49
+ FOLDKIT_BUILD_ID="$CI_DEPLOYMENT_ID" {{buildCommand}}
50
+ ```
51
+
52
+ The id is public HTML and must never contain a secret or be derived from one.
53
+ Every step of one deployment must share an id. By contrast, two deployments
54
+ must never share one. Reusing an id produces no warning: the ids agree, so
55
+ hydration proceeds. When in doubt, leave `FOLDKIT_BUILD_ID` unset and let the
56
+ build script generate one.
57
+
58
+ ## Learn More
59
+
60
+ - [Foldkit Documentation](https://github.com/foldkit/foldkit)
61
+ - [Effect Documentation](https://effect.website)
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "node scripts/build.mjs",
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,40 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ // NOTE: one id names this build, and every command below is given that same
5
+ // id, so the client bundle and the server bundle of a deployment agree on which
6
+ // deployment they are. `renderToString` stamps it on the prerendered pages and
7
+ // `Runtime.hydrate` compares it before adopting any DOM, so a page left over
8
+ // from an earlier deployment is refused and contained rather than reconciled
9
+ // against a client that no longer means the same thing by it.
10
+ //
11
+ // The id is published in the HTML every visitor receives. It identifies a
12
+ // deployment and is never a credential, so keep secrets out of it. Set
13
+ // FOLDKIT_BUILD_ID to name builds from a value the deployment already has, such
14
+ // as a commit or a release tag; without one, each build takes a fresh id.
15
+ // NOTE: `??` alone would take an empty FOLDKIT_BUILD_ID as a real value, and
16
+ // the plugin treats empty as absent, so the build would compile no id at all and
17
+ // fail later at the render. Empty is unset here too.
18
+ const supplied = process.env.FOLDKIT_BUILD_ID
19
+ const buildId =
20
+ supplied === undefined || supplied === '' ? randomUUID() : supplied
21
+
22
+ const steps = [
23
+ ['vite', ['build', '--outDir', 'dist/client']],
24
+ [
25
+ 'vite',
26
+ ['build', '--ssr', 'src/entry.server.ts', '--outDir', 'dist/server'],
27
+ ],
28
+ ['tsx', ['scripts/prerender.ts']],
29
+ ]
30
+
31
+ for (const [command, args] of steps) {
32
+ const { status } = spawnSync(command, args, {
33
+ stdio: 'inherit',
34
+ shell: process.platform === 'win32',
35
+ env: { ...process.env, FOLDKIT_BUILD_ID: buildId },
36
+ })
37
+ if (status !== 0) {
38
+ process.exit(status ?? 1)
39
+ }
40
+ }
@@ -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, buildId: import.meta.env.FOLDKIT_BUILD_ID },
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, { buildId: import.meta.env.FOLDKIT_BUILD_ID })