@craft-ts/dev-tools 0.7.0-beta.13 → 0.7.0-beta.16

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 (89) hide show
  1. package/README.md +60 -10
  2. package/package.json +8 -2
  3. package/src/bin/agent-selector.d.ts +22 -0
  4. package/src/bin/agent-selector.js +100 -0
  5. package/src/bin/agent-selector.js.map +1 -0
  6. package/src/bin/craft-graph.js +74 -8
  7. package/src/bin/craft-graph.js.map +1 -1
  8. package/src/bin/craft.d.ts +22 -0
  9. package/src/bin/craft.js +231 -28
  10. package/src/bin/craft.js.map +1 -1
  11. package/src/eslint-rules/anchor-has-href.cjs +37 -3
  12. package/src/eslint-rules/craft-computed-name-match.cjs +26 -5
  13. package/src/eslint-rules/craft-name-match-utils.cjs +18 -9
  14. package/src/eslint-rules/hyperscript-walk.cjs +3 -3
  15. package/src/eslint-rules/index.cjs +46 -1
  16. package/src/eslint-rules/no-auth-token-in-local-storage.cjs +17 -0
  17. package/src/eslint-rules/no-craft-computed-side-effects.cjs +12 -5
  18. package/src/eslint-rules/no-craft-service-component-same-file.cjs +138 -0
  19. package/src/eslint-rules/no-effect-import-in-frontend.cjs +52 -0
  20. package/src/eslint-rules/no-effect-in-params.cjs +31 -1
  21. package/src/eslint-rules/no-explicit-effect-type.cjs +137 -0
  22. package/src/eslint-rules/no-free-has.cjs +47 -0
  23. package/src/eslint-rules/no-heading-level-skip.cjs +6 -6
  24. package/src/eslint-rules/no-raw-class.cjs +76 -0
  25. package/src/eslint-rules/no-raw-css-value.cjs +163 -0
  26. package/src/eslint-rules/no-raw-user-url.cjs +114 -0
  27. package/src/eslint-rules/no-trust-forwarded-headers.cjs +43 -0
  28. package/src/eslint-rules/no-unsafe-html.cjs +93 -0
  29. package/src/eslint-rules/no-unsafe-transfer-state.cjs +54 -0
  30. package/src/eslint-rules/prefer-craft-template-blocks.cjs +8 -8
  31. package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +57 -14
  32. package/src/eslint-rules/prefer-inline-effect-insertion.cjs +263 -0
  33. package/src/eslint-rules/prefer-inline-route-providers.cjs +138 -0
  34. package/src/eslint-rules/recommended-config.cjs +6 -0
  35. package/src/eslint-rules/require-reactive-template-bindings.cjs +8 -8
  36. package/src/eslint-rules/require-route-security-policy.cjs +87 -0
  37. package/src/eslint-rules/require-server-function-timeout.cjs +39 -0
  38. package/src/eslint-rules/security-config.cjs +14 -0
  39. package/src/eslint-rules/security-rule-utils.cjs +21 -0
  40. package/src/eslint-rules/style-file-boundary.cjs +75 -0
  41. package/src/eslint-rules/sync-effect-body.cjs +343 -0
  42. package/src/index.d.ts +4 -0
  43. package/src/index.js +4 -0
  44. package/src/index.js.map +1 -1
  45. package/src/scripts/architecture-graph.js +8 -2
  46. package/src/scripts/architecture-graph.js.map +1 -1
  47. package/src/scripts/create/create-project.d.ts +60 -1
  48. package/src/scripts/create/create-project.js +2082 -184
  49. package/src/scripts/create/create-project.js.map +1 -1
  50. package/src/scripts/data-flow-graph.d.ts +1 -1
  51. package/src/scripts/effect-dependency-graph.d.ts +1 -1
  52. package/src/scripts/security-check.d.ts +30 -0
  53. package/src/scripts/security-check.js +223 -0
  54. package/src/scripts/security-check.js.map +1 -0
  55. package/src/scripts/style-architecture.d.ts +56 -0
  56. package/src/scripts/style-architecture.js +142 -0
  57. package/src/scripts/style-architecture.js.map +1 -0
  58. package/src/scripts/style-graph.d.ts +99 -0
  59. package/src/scripts/style-graph.js +179 -0
  60. package/src/scripts/style-graph.js.map +1 -0
  61. package/src/scripts/style-report.d.ts +55 -0
  62. package/src/scripts/style-report.js +67 -0
  63. package/src/scripts/style-report.js.map +1 -0
  64. package/src/scripts/template/migrate-template.js +1 -1
  65. package/src/scripts/template/migrate-template.js.map +1 -1
  66. package/src/scripts/angular-brand-codemod.ts +0 -3985
  67. package/src/scripts/architecture/migrate-architecture.ts +0 -653
  68. package/src/scripts/architecture-graph.ts +0 -3136
  69. package/src/scripts/components/migrate-components.ts +0 -227
  70. package/src/scripts/components/migration-diagnostic.ts +0 -8
  71. package/src/scripts/create/create-project.ts +0 -849
  72. package/src/scripts/data-flow-graph.ts +0 -240
  73. package/src/scripts/dependency-graph.ts +0 -5908
  74. package/src/scripts/effect-dependency-graph.ts +0 -630
  75. package/src/scripts/migrate.ts +0 -162
  76. package/src/scripts/migration-workspace.ts +0 -93
  77. package/src/scripts/primitives/migrate-named-primitives.ts +0 -627
  78. package/src/scripts/primitives/migrate-primitive-generators.ts +0 -410
  79. package/src/scripts/primitives/migrate-primitives.ts +0 -655
  80. package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +0 -499
  81. package/src/scripts/primitives/migration-diagnostic.ts +0 -15
  82. package/src/scripts/routes/migrate-routes.ts +0 -895
  83. package/src/scripts/routes/migration-diagnostic.ts +0 -25
  84. package/src/scripts/routes/route-command.ts +0 -1807
  85. package/src/scripts/routes/verify-routes.ts +0 -1511
  86. package/src/scripts/services/config.ts +0 -60
  87. package/src/scripts/services/migrate-services.ts +0 -1348
  88. package/src/scripts/services/migration-diagnostic.ts +0 -28
  89. package/src/scripts/template/migrate-template.ts +0 -418
@@ -1,10 +1,21 @@
1
- import { existsSync } from 'node:fs';
2
- import { mkdir, readdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join, relative, resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
5
  import { runArchitectureMigration } from '../architecture/migrate-architecture.js';
5
- export const CRAFT_TS_STARTER_VERSION = '^0.7.0-beta.11';
6
+ export const CRAFT_TS_STARTER_VERSION = '^0.7.0-beta.15';
6
7
  export const EFFECT_V4_VERSION = '^4.0.0-rc.110';
7
8
  const DEFAULT_AGENTS = ['codex'];
9
+ const GENERATED_GITIGNORE = `node_modules/
10
+ dist/
11
+ coverage/
12
+ .vite/
13
+ playwright-report/
14
+ test-results/
15
+ .DS_Store
16
+ .references/*
17
+ !.references/manifest.json
18
+ `;
8
19
  const BASE_AGENT_SKILL = `---
9
20
  name: craft-ts-project
10
21
  description: Build and evolve a framework-independent CraftTS application with typed reactivity, routing, API boundaries, lint and architecture contracts. Use when adding or reviewing CraftTS state, queries, mutations, components, routes, tests, or project structure.
@@ -23,8 +34,27 @@ bootstrapCraft.
23
34
  for transport. Yield every Craft reader.
24
35
  3. Use craftComponent, craftRoutes, componentDeps and the route DI proof;
25
36
  do not introduce Angular decorators or inject().
26
- 4. Run the focused test, then npm run lint, npm run typecheck,
37
+ 4. Keep translations in src/i18n/catalog.ts. Add locales with
38
+ defineLocaleLike and project-specific tokens in project-tokens.ts.
39
+ @craft-ts/i18n is framework-independent; do not import Effect for plain
40
+ translations. Run npm run i18n:check and npm run i18n:test after changes.
41
+ 5. Keep visual rules in a *.style.ts sheet under src/app/ui/. A sheet may
42
+ import @craft-ts/style vocabulary and nothing else; the build plugin
43
+ evaluates it in Node. Static variation goes to a class the emitter wrote
44
+ at build time; dynamic variation goes through a typed custom property.
45
+ Never build a class string at render time and never bind class to a
46
+ template literal or a function: the template sets one constant class and a
47
+ data-* attribute, and the variant is an axis. A class assembled in the
48
+ browser is a visual state nothing recorded, which is what no-raw-class,
49
+ no-free-has, no-raw-css-value and style-file-boundary exist to prevent.
50
+ Components read theme variables, never palette tokens directly.
51
+ 6. Run the focused test, then npm run lint, npm run typecheck,
27
52
  npm run architecture, and npm run e2e when the browser flow changed.
53
+ 7. Keep the generated development surface enabled: 'npm run logs:server'
54
+ stores Craft 'Console.*' entries locally, 'npm run logs:mcp' exposes them
55
+ to an MCP client, and 'npm run registry:mcp' exposes the named page surface.
56
+ Do not replace 'Console.*' with raw 'console.*' when an entry must be
57
+ searchable through the log MCP server.
28
58
 
29
59
  ## Architecture tests
30
60
 
@@ -58,71 +88,212 @@ This project deliberately uses Effect v4 (effect@${EFFECT_V4_VERSION}) and
58
88
  Do not silently replace Effect v4 APIs with v3 examples. Confirm a symbol in
59
89
  the installed package before using it.
60
90
  `;
61
- const AGENTS_MD = (mode) => `# CraftTS project
91
+ function referenceAgentGuidance(config) {
92
+ if (!config || (!config.references.craftTs && !config.references.effectTs))
93
+ return '';
94
+ const root = config.workspace.kind === 'nx' ? '../../.references' : '.references';
95
+ const entries = [
96
+ config.references.craftTs ? `- CraftTS source: \`${root}/craft-ts\`` : '',
97
+ config.references.effectTs ? `- EffectTS source: \`${root}/effect-ts\`` : '',
98
+ ].filter(Boolean);
99
+ return `
100
+ ## Local source references
62
101
 
63
- This project was created with \`craft create\` in **${mode === 'effect' ? 'Effect v4' : 'plain CraftTS'}** mode.
102
+ The following repositories are cloned for agent context only:
103
+ ${entries.join('\n')}
104
+ Use them to inspect implementations, types, tests and examples when the
105
+ installed package or project documentation is not enough. The application must
106
+ always import CraftTS and EffectTS from the npm dependencies declared in
107
+ \`package.json\`; do not add TypeScript, Vite or package \`file:\` aliases to
108
+ these clones.
109
+ `;
110
+ }
111
+ function agentsMd(mode, config) {
112
+ const frontend = config?.frontendRuntime ?? (mode === 'effect' ? 'effect' : 'plain');
113
+ const backend = config?.backendRuntime ?? 'none';
114
+ const i18n = config?.i18n.enabled ?? true;
115
+ const designSystem = config?.designSystem !== 'none';
116
+ const typedCss = config?.typedCss ?? true;
117
+ const effectFrontend = frontend === 'effect';
118
+ const effectBackend = backend === 'effect';
119
+ const effect = effectFrontend || effectBackend;
120
+ const effectSkillPaths = (config?.agents ?? []).map((agent) => agent === 'codex'
121
+ ? '.agents/skills/craft-ts-effect-v4/SKILL.md'
122
+ : agent === 'cursor'
123
+ ? '.cursor/skills/craft-ts-project/SKILL.md'
124
+ : agent === 'claude-code'
125
+ ? '.claude/skills/craft-ts-effect-v4/SKILL.md'
126
+ : '.gemini/skills/craft-ts-effect-v4/SKILL.md');
127
+ const effectSkillLine = effectSkillPaths.length
128
+ ? `Read the Effect-specific guidance in ${effectSkillPaths.map((path) => `\`${path}\``).join(', ')}.`
129
+ : 'Use the Effect v4 guidance in the project documentation when adding Effect code.';
130
+ const verify = [
131
+ 'npm run lint',
132
+ 'npm run typecheck',
133
+ 'npm run typecheck-spec',
134
+ ...(i18n ? ['npm run i18n:check', 'npm run i18n:test'] : []),
135
+ ...(typedCss ? ['npm run style:check'] : []),
136
+ ...(backend !== 'none' ? ['npm run server:test'] : []),
137
+ ...(effect ? ['npm run effect-check'] : []),
138
+ 'npm test',
139
+ 'npm run architecture',
140
+ 'npm run typecheck-architecture',
141
+ 'npm run build',
142
+ ];
143
+ const effectGuidance = effect
144
+ ? `
145
+ ## Effect boundary
64
146
 
65
- Read \`.agents/skills/craft-ts-project/SKILL.md\` before changing application
66
- code. ${mode === 'effect' ? 'Effect-specific guidance is in `.agents/skills/craft-ts-effect-v4/SKILL.md`.' : ''}
147
+ ${effectFrontend ? '- Effect v4 is enabled in the browser; use `queryEffect` and the installed Craft Effect bridge.' : '- The browser runtime is plain; keep Effect imports out of browser components.'}
148
+ ${effectBackend ? '- Effect v4 is enabled on the backend; keep server Effects in `src/server/`, provide services through `Layer`, and run `npm run effect-check` after changes.' : '- The backend does not use Effect; do not add Effect dependencies or server Effects unless the runtime choice changes.'}
149
+ ${effectSkillLine}
150
+ `
151
+ : `
152
+ ## Effect boundary
67
153
 
68
- The architecture suite is a graph contract. Run \`npm run architecture\`;
69
- do not add a test per feature. Add a rule only for a recurring product-level
70
- dependency smell not already covered by the baseline helpers.
154
+ Effect is not selected in this starter. Keep the application on the plain
155
+ CraftTS runtime and do not add Effect imports or dependencies incidentally.
71
156
  `;
157
+ return `# CraftTS project
158
+
159
+ This project was created with \`craft create\`. Treat this file as the project
160
+ guide for coding agents: it records the selected runtime and feature surfaces.
161
+
162
+ ## Selected configuration
163
+
164
+ - Frontend runtime: **${frontend}**
165
+ - Backend runtime: **${backend}**
166
+ - Type-safe i18n: **${i18n ? 'enabled' : 'disabled'}**
167
+ - Design system: **${designSystem ? 'enabled' : 'disabled'}**
168
+ - Typed CSS: **${typedCss ? 'enabled' : 'disabled'}**
169
+
170
+ Read \`.agents/skills/craft-ts-project/SKILL.md\` before changing application
171
+ code. ${i18n ? 'Translation keys live in `src/i18n/`; run `npm run i18n:check` and `npm run i18n:test` after changes.' : 'This starter has no i18n surface; do not add translation files unless the project configuration changes.'}
172
+ ${effectGuidance}
173
+
174
+ ## Workflow
175
+
176
+ Use Craft primitives and yield every Craft reader. Keep the browser, server and
177
+ transport boundaries aligned with the selected runtimes. The architecture
178
+ suite is a graph contract: run \`npm run architecture\` after structural
179
+ changes, and do not add a test per feature or a rule for a smell already
180
+ covered by the baseline helpers.
181
+
182
+ ## Verification
183
+
184
+ Run the checks relevant to this generated configuration:
185
+
186
+ ${verify.map((command) => `- \`${command}\``).join('\n')}
187
+ ${referenceAgentGuidance(config)}`;
188
+ }
189
+ const AGENTS_MD = agentsMd;
72
190
  function json(value) {
73
191
  return `${JSON.stringify(value, null, 2)}\n`;
74
192
  }
75
193
  function packageJson(context) {
76
- const effect = context.mode === 'effect';
194
+ const effectFrontend = context.config.frontendRuntime === 'effect';
195
+ const effectBackend = context.config.backendRuntime === 'effect';
196
+ const hasEffect = effectFrontend || effectBackend;
197
+ const hasI18n = context.config.i18n.enabled;
198
+ const hasTypedCss = context.config.typedCss;
199
+ const hasServer = context.config.backendRuntime !== 'none';
200
+ const packageVersion = context.packageVersion ?? CRAFT_TS_STARTER_VERSION;
201
+ const craftPackage = () => packageVersion;
202
+ const effectPackage = EFFECT_V4_VERSION;
77
203
  return json({
78
204
  name: context.projectName,
79
205
  private: true,
80
206
  type: 'module',
81
207
  engines: { node: '>=20.19.0' },
82
208
  scripts: {
83
- dev: 'vite --host 127.0.0.1',
209
+ dev: 'node scripts/dev.mjs',
84
210
  build: 'vite build',
85
211
  lint: 'eslint .',
86
- typecheck: 'tsc -p tsconfig.app.json --noEmit',
212
+ typecheck: 'node scripts/typecheck.mjs',
87
213
  'typecheck-spec': 'tsc -p tsconfig.spec.json --noEmit',
214
+ ...(hasI18n
215
+ ? { 'i18n:check': 'craft i18n check', 'i18n:test': 'craft i18n test' }
216
+ : {}),
88
217
  test: 'vitest run --config vitest.config.ts',
89
218
  e2e: 'playwright test',
90
- ...(effect
219
+ 'logs:server': 'craft-ts-log-server',
220
+ 'logs:mcp': 'craft-ts-log-mcp',
221
+ 'registry:mcp': 'craft-ts-registry-mcp',
222
+ ...(hasEffect
91
223
  ? {
92
224
  'effect-check': 'node scripts/run-effect-tsgo.mjs diagnostics --project tsconfig.effect.json --severity error,warning',
93
225
  }
94
226
  : {}),
227
+ ...(hasTypedCss ? { 'style:check': 'node scripts/style-check.mjs' } : {}),
228
+ ...(hasServer
229
+ ? {
230
+ 'server:test': 'vitest run --config vitest.server.config.ts',
231
+ }
232
+ : {}),
233
+ ...(context.config.references.craftTs ||
234
+ context.config.references.effectTs
235
+ ? {
236
+ 'update:references': 'node scripts/update-references.mjs',
237
+ ...(context.config.references.craftTs
238
+ ? { 'update:craft-ts': 'node scripts/update-craft-ts.mjs' }
239
+ : {}),
240
+ ...(context.config.references.effectTs
241
+ ? { 'update:effect-ts': 'node scripts/update-effect-ts.mjs' }
242
+ : {}),
243
+ }
244
+ : {}),
95
245
  architecture: 'vitest run --config vitest.architecture.config.ts',
96
246
  'typecheck-architecture': 'tsc -p tsconfig.architecture.json --noEmit',
97
247
  },
98
248
  dependencies: {
99
- '@craft-ts/component': CRAFT_TS_STARTER_VERSION,
100
- '@craft-ts/core': CRAFT_TS_STARTER_VERSION,
101
- ...(effect ? { '@craft-ts/effect': CRAFT_TS_STARTER_VERSION, effect: EFFECT_V4_VERSION } : {}),
249
+ '@craft-ts/component': craftPackage(),
250
+ '@craft-ts/core': craftPackage(),
251
+ ...(hasI18n ? { '@craft-ts/i18n': craftPackage() } : {}),
252
+ ...(hasTypedCss ? { '@craft-ts/style': craftPackage() } : {}),
253
+ ...(hasEffect && hasI18n
254
+ ? { '@craft-ts/i18n-effect': craftPackage() }
255
+ : {}),
256
+ ...(hasEffect
257
+ ? { '@craft-ts/effect': craftPackage(), effect: effectPackage }
258
+ : {}),
102
259
  },
103
260
  devDependencies: {
104
- '@craft-ts/dev-tools': CRAFT_TS_STARTER_VERSION,
105
- '@craft-ts/mcp': CRAFT_TS_STARTER_VERSION,
261
+ '@craft-ts/dev-tools': craftPackage(),
262
+ '@craft-ts/mcp': craftPackage(),
263
+ '@craft-ts/function-registry-mcp': craftPackage(),
264
+ '@craft-ts/log-mcp': craftPackage(),
265
+ '@craft-ts/log-server': craftPackage(),
266
+ ...(hasEffect ? { effect: effectPackage } : {}),
267
+ ...(hasTypedCss
268
+ ? { '@craft-ts/style-testing': craftPackage() }
269
+ : {}),
106
270
  '@playwright/test': '^1.52.0',
107
271
  '@types/node': '^22.0.0',
108
- ...(effect
272
+ 'aria-query': '^5.3.2',
273
+ jsdom: '^27.1.0',
274
+ rxjs: '^7.8.0',
275
+ tslib: '^2.3.0',
276
+ ...(hasEffect
109
277
  ? {
110
278
  '@effect/tsgo': '^0.24.3',
111
279
  '@typescript/native': 'npm:typescript@7.0.2',
112
280
  }
113
281
  : {}),
114
282
  '@eslint/js': '^9.0.0',
115
- 'eslint': '^9.0.0',
283
+ eslint: '^9.0.0',
116
284
  'eslint-config-prettier': '^10.0.0',
117
285
  'eslint-plugin-playwright': '^2.0.0',
118
- 'typescript': '^6.0.3',
286
+ typescript: '^6.0.3',
119
287
  'typescript-eslint': '^8.0.0',
120
- 'vite': '^8.0.0',
121
- 'vitest': '^4.0.0',
288
+ vite: '^8.0.0',
289
+ vitest: '^4.0.0',
122
290
  },
123
291
  });
124
292
  }
125
293
  function tsconfig(context) {
294
+ const hasEffect = context.config.frontendRuntime === 'effect' ||
295
+ context.config.backendRuntime === 'effect';
296
+ const hasServer = context.config.backendRuntime !== 'none';
126
297
  return json({
127
298
  compilerOptions: {
128
299
  target: 'ES2022',
@@ -140,7 +311,8 @@ function tsconfig(context) {
140
311
  { path: './tsconfig.app.json' },
141
312
  { path: './tsconfig.spec.json' },
142
313
  { path: './tsconfig.architecture.json' },
143
- ...(context.mode === 'effect' ? [{ path: './tsconfig.effect.json' }] : []),
314
+ ...(hasEffect ? [{ path: './tsconfig.effect.json' }] : []),
315
+ ...(hasServer ? [{ path: './tsconfig.server.json' }] : []),
144
316
  ],
145
317
  });
146
318
  }
@@ -159,17 +331,186 @@ const tsconfigSpec = `{
159
331
  }\n`;
160
332
  const tsconfigEffect = `{
161
333
  "extends": "./tsconfig.json",
162
- "compilerOptions": { "noEmit": true },
334
+ "compilerOptions": {
335
+ "noEmit": true,
336
+ "plugins": [{
337
+ "name": "@effect/language-service",
338
+ "diagnostics": true,
339
+ "diagnosticsName": true,
340
+ "overrides": [{
341
+ "include": ["src/**/*.ts"],
342
+ "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
343
+ "options": {
344
+ "diagnosticSeverity": {
345
+ "floatingEffect": "error",
346
+ "missingEffectContext": "error",
347
+ "missingEffectError": "error",
348
+ "missingLayerContext": "error",
349
+ "missingReturnYieldStar": "error",
350
+ "missingStarInYieldEffectGen": "error",
351
+ "outdatedApi": "error",
352
+ "unsafeEffectTypeAssertion": "error",
353
+ "asyncFunction": "warning",
354
+ "newPromise": "warning",
355
+ "nodeBuiltinImport": "warning",
356
+ "preferSchemaOverJson": "warning"
357
+ }
358
+ }
359
+ }]
360
+ }]
361
+ },
163
362
  "include": ["src/**/*.ts"],
164
363
  "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
165
364
  }\n`;
166
- const viteConfig = `import { defineConfig } from 'vite';
365
+ function viteConfig(context) {
366
+ const typedCss = context.config.typedCss;
367
+ const hasServer = context.config.backendRuntime !== 'none';
368
+ const styleImport = "import { craftStyle } from '@craft-ts/style/vite';";
369
+ const styleAliasEntries = ` '@craft-ts/style': resolvePath(import.meta.dirname, 'node_modules/@craft-ts/style/src/index.js')`;
370
+ const stylePlugin = typedCss
371
+ ? ` craftStyle({ dumpPath: '.craft/style-graph.json', alias: {
372
+ ${styleAliasEntries}
373
+ } }),`
374
+ : '';
375
+ return `import { readFileSync } from 'node:fs';
376
+ ${typedCss ? "import { resolve as resolvePath } from 'node:path';\n" : ''}
377
+ import { defineConfig, type ViteDevServer } from 'vite';
378
+ ${typedCss ? styleImport : ''}
379
+
380
+ const typecheckStatusPath = new URL('./.craft/typecheck-status.json', import.meta.url);
381
+ const starterPort = Number(process.env.CRAFT_STARTER_PORT ?? 4173);
382
+
383
+ function readTypecheckStatus(): { status: 'running' | 'passed' | 'failed' } {
384
+ try {
385
+ const value = JSON.parse(readFileSync(typecheckStatusPath, 'utf8')) as { status?: string };
386
+ if (value.status === 'running' || value.status === 'passed' || value.status === 'failed') {
387
+ return { status: value.status };
388
+ }
389
+ } catch {
390
+ // The type-check process may not have written its first status yet.
391
+ }
392
+ return { status: 'running' };
393
+ }
394
+
395
+ function craftTypecheckStatusPlugin() {
396
+ return {
397
+ name: 'craft-typecheck-status',
398
+ configureServer(server: ViteDevServer) {
399
+ server.middlewares.use('/__craft/typecheck', (_request, response) => {
400
+ response.statusCode = 200;
401
+ response.setHeader('content-type', 'application/json');
402
+ response.setHeader('cache-control', 'no-store');
403
+ response.end(JSON.stringify(readTypecheckStatus()));
404
+ });
405
+ },
406
+ };
407
+ }
408
+
409
+ ${hasServer
410
+ ? `function serverFunctionsPlugin() {
411
+ return {
412
+ name: 'craft-starter-server-functions',
413
+ async configureServer(server: ViteDevServer) {
414
+ const module = await server.ssrLoadModule('/src/server/node-http.ts') as typeof import('./src/server/node-http');
415
+ server.middlewares.use('/__server-functions', (request, response) => {
416
+ void module.handleRequest(request, response).catch((error: unknown) => {
417
+ if (response.headersSent) {
418
+ response.destroy();
419
+ return;
420
+ }
421
+ response.statusCode = 500;
422
+ response.end('Internal Server Error');
423
+ console.error(error);
424
+ });
425
+ });
426
+ },
427
+ };
428
+ }`
429
+ : ''}
167
430
 
168
431
  export default defineConfig({
169
- server: { host: '127.0.0.1', port: 4173 },
432
+ plugins: [
433
+ craftTypecheckStatusPlugin(),
434
+ ${typedCss
435
+ ? ` // Evaluates every *.style.ts in Node and emits the generated sheet.
436
+ ${stylePlugin}`
437
+ : ''}
438
+ ${hasServer ? ' serverFunctionsPlugin(),' : ''}
439
+ ],
440
+ server: {
441
+ host: '127.0.0.1',
442
+ port: starterPort,
443
+ forwardConsole: true,
444
+ },
170
445
  build: { target: 'es2022' },
171
446
  });
172
447
  `;
448
+ }
449
+ const tsconfigServer = `{
450
+ "extends": "./tsconfig.json",
451
+ "compilerOptions": { "types": ["node"], "noEmit": true },
452
+ "include": ["src/server/**/*.ts", "src/**/*.fn-serveur.ts", "src/**/*.mw-serveur.ts"],
453
+ "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
454
+ }
455
+ `;
456
+ function typecheckScript(context) {
457
+ const projects = [
458
+ 'tsconfig.app.json',
459
+ ...(context.config.backendRuntime !== 'none'
460
+ ? ['tsconfig.server.json']
461
+ : []),
462
+ ];
463
+ return `import { mkdirSync, writeFileSync } from 'node:fs';
464
+ import { spawnSync } from 'node:child_process';
465
+ import { dirname, resolve } from 'node:path';
466
+
467
+ const projectRoot = resolve(import.meta.dirname, '..');
468
+ const statusPath = resolve(projectRoot, '.craft/typecheck-status.json');
469
+ const nonBlocking = process.argv.includes('--non-blocking');
470
+
471
+ function writeStatus(status) {
472
+ mkdirSync(dirname(statusPath), { recursive: true });
473
+ writeFileSync(statusPath, JSON.stringify({ status, updatedAt: new Date().toISOString() }) + '\\n');
474
+ }
475
+
476
+ writeStatus('running');
477
+ const projects = ${JSON.stringify(projects)};
478
+ let exitCode = 0;
479
+ for (const project of projects) {
480
+ const result = spawnSync(resolve(projectRoot, 'node_modules/.bin/tsc'), [
481
+ '-p', project, '--noEmit', '--pretty', 'false',
482
+ ], { cwd: projectRoot, stdio: 'inherit' });
483
+ if (result.status !== 0) exitCode = result.status ?? 1;
484
+ }
485
+ writeStatus(exitCode === 0 ? 'passed' : 'failed');
486
+ process.exitCode = exitCode === 0 || nonBlocking ? 0 : exitCode;
487
+ `;
488
+ }
489
+ const devScript = `import { spawn } from 'node:child_process';
490
+ import { resolve } from 'node:path';
491
+
492
+ const projectRoot = resolve(import.meta.dirname, '..');
493
+ const processes = [
494
+ spawn(process.execPath, [resolve(projectRoot, 'node_modules/vite/bin/vite.js'), '--host', '127.0.0.1'], {
495
+ cwd: projectRoot,
496
+ stdio: 'inherit',
497
+ }),
498
+ spawn(process.execPath, [resolve(projectRoot, 'scripts/typecheck.mjs'), '--non-blocking'], {
499
+ cwd: projectRoot,
500
+ stdio: 'inherit',
501
+ }),
502
+ ];
503
+
504
+ const stop = () => {
505
+ for (const child of processes) child.kill('SIGTERM');
506
+ };
507
+ process.once('SIGINT', stop);
508
+ process.once('SIGTERM', stop);
509
+ processes[0].once('exit', (code) => {
510
+ stop();
511
+ process.exitCode = code ?? 1;
512
+ });
513
+ `;
173
514
  const effectTsgoRunner = `import { chmodSync, existsSync } from 'node:fs';
174
515
  import { spawnSync } from 'node:child_process';
175
516
  import { resolve } from 'node:path';
@@ -194,6 +535,11 @@ const result = spawnSync(effectTsgoPath, process.argv.slice(2), {
194
535
  if (result.error) throw result.error;
195
536
  process.exitCode = result.status ?? 1;
196
537
  `;
538
+ const styleCheckScript = `import { existsSync } from 'node:fs';
539
+ const root = import.meta.dirname + '/..';
540
+ if (!existsSync(root + '/vite.config.ts')) throw new Error('Missing Vite configuration');
541
+ console.log('Typed CSS configuration present.');
542
+ `;
197
543
  const vitestConfig = `import { defineConfig } from 'vitest/config';
198
544
 
199
545
  export default defineConfig({
@@ -205,7 +551,7 @@ export default defineConfig({
205
551
  },
206
552
  });
207
553
  `;
208
- const eslintConfig = `import js from '@eslint/js';
554
+ const eslintConfig = (effect, backendEffect = false) => `import js from '@eslint/js';
209
555
  import prettier from 'eslint-config-prettier';
210
556
  import playwright from 'eslint-plugin-playwright';
211
557
  import tseslint from 'typescript-eslint';
@@ -217,6 +563,21 @@ export default tseslint.config(
217
563
  ...tseslint.configs.recommended,
218
564
  {
219
565
  files: ['src/**/*.ts'],
566
+ ignores: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
567
+ plugins: { 'craft-ts': craftRules },
568
+ rules: {
569
+ ...craftRules.configs.security.rules,
570
+ },
571
+ },
572
+ {
573
+ files: ['src/**/*.ts'],
574
+ ignores: [
575
+ 'src/server/**/*.ts',
576
+ 'src/**/*.fn-serveur.ts',
577
+ 'src/**/*.mw-serveur.ts',
578
+ 'src/**/*.spec.ts',
579
+ 'src/**/*.test.ts',
580
+ ],
220
581
  plugins: { 'craft-ts': craftRules },
221
582
  languageOptions: {
222
583
  parserOptions: {
@@ -225,12 +586,19 @@ export default tseslint.config(
225
586
  },
226
587
  },
227
588
  rules: {
228
- ...craftRules.configs.recommended.rules,
589
+ ...craftRules.configs.${effect ? 'effect' : 'recommended'}.rules,
229
590
  '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
591
+ ${effect ? '' : " 'craft-ts/no-effect-import-in-frontend': 'error',"}
230
592
  },
231
593
  },
232
594
  {
233
595
  files: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
596
+ languageOptions: {
597
+ parserOptions: {
598
+ project: ['./tsconfig.spec.json'],
599
+ tsconfigRootDir: import.meta.dirname,
600
+ },
601
+ },
234
602
  rules: {
235
603
  'craft-ts/no-async-await': 'off',
236
604
  'craft-ts/no-throw': 'off',
@@ -239,6 +607,22 @@ export default tseslint.config(
239
607
  'craft-ts/no-ephemeral-template-form-state': 'off',
240
608
  },
241
609
  },
610
+ ${backendEffect
611
+ ? ` {
612
+ files: ['src/server/**/*.ts', 'src/**/*.fn-serveur.ts', 'src/**/*.mw-serveur.ts'],
613
+ ignores: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
614
+ plugins: { 'craft-ts': craftRules },
615
+ languageOptions: {
616
+ parserOptions: {
617
+ project: ['./tsconfig.server.json'],
618
+ tsconfigRootDir: import.meta.dirname,
619
+ },
620
+ },
621
+ rules: {
622
+ ...craftRules.configs.effect.rules,
623
+ },
624
+ },`
625
+ : ''}
242
626
  {
243
627
  files: ['e2e/**/*.ts'],
244
628
  ...playwright.configs['flat/recommended'],
@@ -252,8 +636,9 @@ export default tseslint.config(
252
636
  prettier,
253
637
  );
254
638
  `;
255
- const indexHtml = `<!doctype html>
256
- <html lang="en">
639
+ function indexHtml(locale) {
640
+ return `<!doctype html>
641
+ <html lang="${locale}" dir="${/^ar|he|fa|ur/.test(locale) ? 'rtl' : 'ltr'}">
257
642
  <head>
258
643
  <meta charset="UTF-8" />
259
644
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -265,83 +650,550 @@ const indexHtml = `<!doctype html>
265
650
  </body>
266
651
  </html>
267
652
  `;
268
- const styles = `:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f7f8fb; }
653
+ }
654
+ /**
655
+ * What has to stay global, and nothing else.
656
+ *
657
+ * The card, the muted note and the error message moved to
658
+ * src/app/ui/ui.style.ts, where they are typed and enumerable. What is left is
659
+ * the part a sheet cannot own: element defaults, the focus ring, the
660
+ * locale-driven typography variables the i18n harness writes, and the dev-only
661
+ * type-check indicator, which is built imperatively and never renders through a
662
+ * component.
663
+ */
664
+ const styles = `:root {
665
+ --craft-font-family: Inter, ui-sans-serif, system-ui, sans-serif;
666
+ --craft-font-size-heading: 2rem;
667
+ --craft-font-size-body: 1rem;
668
+ --craft-font-size-label: .875rem;
669
+ --craft-font-size-button: .9375rem;
670
+ --craft-font-size-caption: .75rem;
671
+ --craft-line-height-heading: 1.2;
672
+ --craft-line-height-body: 1.5;
673
+ --craft-line-height-label: 1.35;
674
+ --craft-line-height-button: 1.35;
675
+ --craft-line-height-caption: 1.35;
676
+ font-family: var(--craft-font-family); color: #172033; background: #f7f8fb;
677
+ }
269
678
  * { box-sizing: border-box; }
270
679
  body { margin: 0; min-width: 320px; }
271
680
  a { color: #2457d6; }
272
681
  main { max-width: 860px; margin: 0 auto; padding: 3rem 1.25rem; }
273
- nav { display: flex; gap: 1rem; padding: 1rem 1.25rem; background: white; border-bottom: 1px solid #e3e7ef; }
274
- .card { padding: 1.5rem; background: white; border: 1px solid #e3e7ef; border-radius: .75rem; box-shadow: 0 8px 30px #1720330d; }
275
- .muted { color: #5c677d; }
276
- .error { color: #b42318; }
682
+ nav { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.25rem; background: white; border-bottom: 1px solid #e3e7ef; }
683
+ .starter-experimental-badge { display: inline-flex; align-items: center; margin-left: auto; padding: .25rem .55rem; border: 1px solid #f0c36d; border-radius: 999px; color: #7a4b00; background: #fff8e6; font-size: .75rem; font-weight: 600; line-height: 1.2; white-space: nowrap; }
277
684
  button:focus-visible, a:focus-visible { outline: 3px solid #7aa2ff; outline-offset: 3px; }
685
+ .heading { font-size: var(--craft-font-size-heading); line-height: var(--craft-line-height-heading); font-weight: 700; }
686
+ body, .body { font-size: var(--craft-font-size-body); line-height: var(--craft-line-height-body); }
687
+ label, .label { font-size: var(--craft-font-size-label); line-height: var(--craft-line-height-label); font-weight: 600; }
688
+ button, .button { font: inherit; font-size: var(--craft-font-size-button); line-height: var(--craft-line-height-button); }
689
+ .caption { font-size: var(--craft-font-size-caption); line-height: var(--craft-line-height-caption); }
690
+ .craft-typecheck-indicator { position: fixed; top: .75rem; right: .75rem; z-index: 9999; display: inline-flex; align-items: center; gap: .4rem; max-width: min(24rem, calc(100vw - 1.5rem)); padding: .35rem .4rem .35rem .55rem; border: 1px solid #bfdbfe; border-radius: .45rem; color: #1e3a8a; background: #eff6ff; box-shadow: 0 4px 12px #1e3a8a1f; font-size: .7rem; font-weight: 600; }
691
+ .craft-typecheck-indicator::before { width: .55rem; height: .55rem; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; content: ''; animation: craft-typecheck-spin .8s linear infinite; }
692
+ .craft-typecheck-indicator[data-status='failed'] { border-color: #fecaca; color: #991b1b; background: #fef2f2; box-shadow: 0 4px 12px #991b1b1f; }
693
+ .craft-typecheck-indicator[data-status='failed']::before { border: 0; content: '⚠'; animation: none; font-size: .8rem; }
694
+ .craft-typecheck-indicator__dismiss { display: inline-grid; width: 1.25rem; height: 1.25rem; padding: 0; border: 0; border-radius: .25rem; color: currentColor; background: transparent; cursor: pointer; font: inherit; font-size: 1rem; line-height: 1; place-items: center; }
695
+ .craft-typecheck-indicator__dismiss:hover, .craft-typecheck-indicator__dismiss:focus-visible { background: #991b1b1a; outline: 2px solid currentColor; outline-offset: 1px; }
696
+ @keyframes craft-typecheck-spin { to { transform: rotate(360deg); } }
697
+ `;
698
+ function stylesFor(context) {
699
+ if (context.config.designSystem === 'none' || context.config.typedCss)
700
+ return styles;
701
+ return `${styles}
702
+ .starter-theme { min-height: 100vh; }
703
+ .starter-card { padding: 1.25rem; border: 1px solid #e3e7ef; border-radius: .75rem; background: #fff; }
704
+ .starter-note { color: #5c677d; }
705
+ .starter-message { color: #b42318; }
706
+ `;
707
+ }
708
+ const projectTokensTs = `import { dateLong, money, number } from '@craft-ts/i18n';
709
+
710
+ export const orderCount = number('count');
711
+ export const orderAmount = money('amount', undefined, { currency: 'EUR', minimumFractionDigits: 2 });
712
+ export const orderDate = dateLong('date');
713
+ `;
714
+ const typographyTs = `export const typography = {
715
+ family: 'Inter, ui-sans-serif, system-ui, sans-serif',
716
+ fallback: 'system-ui, sans-serif',
717
+ roles: {
718
+ heading: { fontSize: '2rem', lineHeight: '1.2', fontWeight: 700 },
719
+ body: { fontSize: '1rem', lineHeight: '1.5', fontWeight: 400 },
720
+ label: { fontSize: '.875rem', lineHeight: '1.35', fontWeight: 600 },
721
+ button: { fontSize: '.9375rem', lineHeight: '1.35', fontWeight: 600 },
722
+ caption: { fontSize: '.75rem', lineHeight: '1.35', fontWeight: 400 },
723
+ },
724
+ } as const;
725
+
726
+ export type TypographyRole = keyof typeof typography.roles;
727
+ `;
728
+ const STARTER_PLURAL_CATEGORIES = {
729
+ ar: ['zero', 'one', 'two', 'few', 'many', 'other'],
730
+ cy: ['zero', 'one', 'two', 'few', 'many', 'other'],
731
+ ga: ['one', 'two', 'few', 'many', 'other'],
732
+ pl: ['one', 'few', 'many', 'other'],
733
+ ru: ['one', 'few', 'many', 'other'],
734
+ uk: ['one', 'few', 'many', 'other'],
735
+ cs: ['one', 'few', 'many', 'other'],
736
+ sk: ['one', 'few', 'many', 'other'],
737
+ sl: ['one', 'two', 'few', 'other'],
738
+ ja: ['other'],
739
+ zh: ['other'],
740
+ fr: ['one', 'other'],
741
+ en: ['one', 'other'],
742
+ };
743
+ function starterPluralCategories(locales) {
744
+ return [
745
+ ...new Set(locales.flatMap((locale) => STARTER_PLURAL_CATEGORIES[locale.toLowerCase().split('-')[0] ?? ''] ?? ['one', 'other'])),
746
+ ];
747
+ }
748
+ function i18nCatalogTs(locales) {
749
+ const categories = starterPluralCategories(locales);
750
+ const branches = categories
751
+ .map((category) => ` ${category}: msg\`\${orderCount} ${category === 'one' ? 'item' : 'items'}\`,`)
752
+ .join('\n');
753
+ return `import { defineCatalog, msg, plural } from '@craft-ts/i18n';
754
+ import { orderAmount, orderCount, orderDate } from './project-tokens';
755
+
756
+ // Add application keys here. Every locale is checked against this shape.
757
+ export const baseCatalog = defineCatalog({
758
+ order: {
759
+ summary: msg\`Order ${'${'}orderAmount} on ${'${'}orderDate}: ${'${'}orderCount} item(s).\`,
760
+ items: plural(orderCount, {
761
+ ${branches}
762
+ }),
763
+ },
764
+ });
765
+ `;
766
+ }
767
+ function i18nLocaleTs(locale, index, locales) {
768
+ const isFrench = locale.toLowerCase().startsWith('fr');
769
+ const categories = starterPluralCategories(locales);
770
+ const branches = categories
771
+ .map((category) => {
772
+ const word = isFrench
773
+ ? category === 'one'
774
+ ? 'article'
775
+ : 'articles'
776
+ : category === 'one'
777
+ ? 'item'
778
+ : 'items';
779
+ return ` ${category}: msg\`\${orderCount} ${word}\`,`;
780
+ })
781
+ .join('\n');
782
+ if (index === 0) {
783
+ return `import { defineLocale } from '@craft-ts/i18n';
784
+ import { baseCatalog } from '../catalog';
785
+
786
+ export const ${locale.replace(/[^a-zA-Z0-9]/g, '')} = defineLocale('${locale}', baseCatalog);
787
+ `;
788
+ }
789
+ const summary = isFrench
790
+ ? 'Le ${orderDate}, la commande contient ${orderCount} article(s) pour ${orderAmount}.'
791
+ : 'Order ${orderAmount} on ${orderDate}: ${orderCount} item(s).';
792
+ return `import { defineLocaleLike, msg, plural } from '@craft-ts/i18n';
793
+ import { orderAmount, orderCount, orderDate } from '../project-tokens';
794
+ import { ${locales[0].replace(/[^a-zA-Z0-9]/g, '')} } from './${locales[0]}';
795
+
796
+ export const ${locale.replace(/[^a-zA-Z0-9]/g, '')} = defineLocaleLike(${locales[0].replace(/[^a-zA-Z0-9]/g, '')}, '${locale}', {
797
+ order: {
798
+ summary: msg\`${summary}\`,
799
+ items: plural(orderCount, {
800
+ ${branches}
801
+ }),
802
+ },
803
+ });
804
+ `;
805
+ }
806
+ function i18nRuntimeTs(context) {
807
+ const localeImports = context.locales
808
+ .map((locale) => `import { ${locale.replace(/[^a-zA-Z0-9]/g, '')} } from './locales/${locale}';`)
809
+ .join('\n');
810
+ const localeValues = context.locales
811
+ .map((locale) => locale.replace(/[^a-zA-Z0-9]/g, ''))
812
+ .join(', ');
813
+ return `import { createI18nRuntime } from '@craft-ts/i18n';
814
+ ${localeImports}
815
+
816
+ export const locales = [${localeValues}] as const;
817
+
818
+ export const i18n = createI18nRuntime<typeof locales>({
819
+ locales,
820
+ defaultLocale: '${context.defaultLocale}',
821
+ strict: ${context.i18nStrict},
822
+ });
823
+
824
+ export function setLocale(locale: typeof i18n.locale extends () => infer Id ? Id : never): void {
825
+ i18n.setLocale(locale);
826
+ if (typeof document !== 'undefined') {
827
+ document.documentElement.lang = locale;
828
+ document.documentElement.dir = /^ar|he|fa|ur/.test(locale) ? 'rtl' : 'ltr';
829
+ }
830
+ }
831
+
832
+ const requestedLocale = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('locale') : null;
833
+ if (requestedLocale && [${context.locales.map((locale) => `'${locale}'`).join(', ')}].includes(requestedLocale)) {
834
+ setLocale(requestedLocale as Parameters<typeof setLocale>[0]);
835
+ }
836
+ `;
837
+ }
838
+ const i18nIndexTs = `export * from './catalog';
839
+ export * from './project-tokens';
840
+ export * from './runtime';
841
+ export * from './typography';
842
+ `;
843
+ const effectI18nTs = `import { translateEffect as translateEffectRaw } from '@craft-ts/i18n-effect';
844
+ import type { TranslationKey, TranslationParams } from '@craft-ts/i18n';
845
+ import { locales } from './runtime';
846
+
847
+ type AppLocales = typeof locales;
848
+
849
+ export const translateEffect = <Key extends TranslationKey<AppLocales[number]>>(
850
+ key: Key,
851
+ ...params: keyof TranslationParams<AppLocales[number], Key & string> extends never
852
+ ? [params?: TranslationParams<AppLocales[number], Key & string>]
853
+ : [params: TranslationParams<AppLocales[number], Key & string>]
854
+ ) => translateEffectRaw<AppLocales, Key>(key, ...params);
855
+ `;
856
+ const effectLayerTs = `import { provideI18nRuntime } from '@craft-ts/i18n-effect';
857
+ import { i18n } from './runtime';
858
+
859
+ export const i18nLayer = provideI18nRuntime(i18n);
860
+ `;
861
+ /**
862
+ * The starter's design system, in one sheet.
863
+ *
864
+ * It is deliberately small — a palette, one state axis, one set of typed
865
+ * variables, three classes — but it is the real thing: every value is a token,
866
+ * the variant is an attribute rather than a class string, and the whole file is
867
+ * evaluated in Node by the build plugin.
868
+ *
869
+ * A `*.style.ts` may import vocabulary and nothing else. `style-file-boundary`
870
+ * enforces it, and that is exactly what makes importing this file in Node safe:
871
+ * there is no application code in it to run.
872
+ */
873
+ const uiStyleTs = `import {
874
+ bg,
875
+ borderColor,
876
+ borderStyle,
877
+ borderWidth,
878
+ color,
879
+ craftStyles,
880
+ cssVars,
881
+ darkOf,
882
+ defineStateAxis,
883
+ definePalette,
884
+ display,
885
+ kind,
886
+ lineWidth,
887
+ p,
888
+ radii,
889
+ radius,
890
+ scheme,
891
+ set,
892
+ space,
893
+ when,
894
+ } from '@craft-ts/style';
895
+
896
+ // ─── palette ────────────────────────────────────────────────────────────────
897
+ // Every token carries both of its values; the group it sits in gives it a role.
898
+
899
+ export const ui = definePalette({
900
+ surface: {
901
+ page: { light: '#f7f8fb', dark: '#0b0d11' },
902
+ raised: { light: '#ffffff', dark: '#151922' },
903
+ },
904
+ text: {
905
+ strong: { light: '#172033', dark: '#f2f4f8' },
906
+ muted: { light: '#5c677d', dark: '#98a2b3' },
907
+ },
908
+ border: {
909
+ subtle: { light: '#e3e7ef', dark: '#232936' },
910
+ },
911
+ accent: {
912
+ info: { light: '#2457d6', dark: '#7aa2ff' },
913
+ danger: { light: '#b42318', dark: '#ff6b6b' },
914
+ },
915
+ });
916
+
917
+ // ─── axes ───────────────────────────────────────────────────────────────────
918
+ // A component may only vary along an axis declared here. The point carries the
919
+ // driver that reaches it, so a state the matrix enumerates is a state a test
920
+ // can produce.
921
+
922
+ /** Drives \`data-tone\` on the element that carries it. */
923
+ export const tone = defineStateAxis('tone', ['neutral', 'danger']);
924
+
925
+ // ─── theme ──────────────────────────────────────────────────────────────────
926
+
927
+ /**
928
+ * \`inherits: true\` belongs to theme variables and to nothing else: they are
929
+ * set once on a wrapper and read by everything below. The default, \`false\`,
930
+ * is right for a variable an element both sets and reads on itself.
931
+ */
932
+ const themed = { inherits: true } as const;
933
+
934
+ export const theme = cssVars('app', {
935
+ ink: kind.color(ui.text.strong, themed),
936
+ inkMuted: kind.color(ui.text.muted, themed),
937
+ raised: kind.color(ui.surface.raised, themed),
938
+ border: kind.color(ui.border.subtle, themed),
939
+ accent: kind.color(ui.accent.info, themed),
940
+ });
941
+
942
+ /** Dark mode is one rule, here — not one rule per component. */
943
+ export const appTheme = craftStyles('appTheme', {
944
+ root: [
945
+ set(theme.ink, ui.text.strong),
946
+ set(theme.inkMuted, ui.text.muted),
947
+ set(theme.raised, ui.surface.raised),
948
+ set(theme.border, ui.border.subtle),
949
+ set(theme.accent, ui.accent.info),
950
+ when(scheme.dark, [
951
+ set(theme.ink, darkOf(ui.text.strong)),
952
+ set(theme.inkMuted, darkOf(ui.text.muted)),
953
+ set(theme.raised, darkOf(ui.surface.raised)),
954
+ set(theme.border, darkOf(ui.border.subtle)),
955
+ set(theme.accent, darkOf(ui.accent.info)),
956
+ ]),
957
+ ],
958
+ });
959
+
960
+ // ─── classes ────────────────────────────────────────────────────────────────
961
+ // A component reads theme variables, never palette tokens: that indirection is
962
+ // what keeps dark mode above, in one place.
963
+
964
+ export const surface = craftStyles('appSurface', {
965
+ card: [
966
+ display.block,
967
+ p(space(5)),
968
+ bg(theme.raised),
969
+ color(theme.ink),
970
+ borderWidth(lineWidth.hairline),
971
+ borderStyle.solid,
972
+ borderColor(theme.border),
973
+ radius(radii.md),
974
+ ],
975
+ note: [color(theme.inkMuted)],
976
+ /**
977
+ * One class, two states. The template sets \`data-tone\`; nothing here
978
+ * produces a name a template has to assemble.
979
+ */
980
+ message: [
981
+ color(theme.accent),
982
+ when(tone.danger, [set(theme.accent, ui.accent.danger)]),
983
+ ],
984
+ });
985
+ `;
986
+ const uiPlainTs = `export const appTheme = { root: 'starter-theme' } as const;
987
+ export const surface = {
988
+ card: 'starter-card',
989
+ note: 'starter-note',
990
+ message: 'starter-message',
991
+ } as const;
278
992
  `;
279
- const mainTs = `import { bootstrapCraft } from '@craft-ts/component';
993
+ function uiComponentsTs(context) {
994
+ const styleImport = context.config.typedCss
995
+ ? "import { surface } from './ui.style';"
996
+ : "import { surface } from './ui';";
997
+ return `import { button, craftComponent, div, p } from '@craft-ts/component';
998
+ ${styleImport}
999
+
1000
+ export const Stack = craftComponent('Stack', {}, () => ({}), () => div({ class: surface.card }, []));
1001
+ export const Card = craftComponent('Card', {}, () => ({}), () => div({ class: surface.card }, []));
1002
+ export const Button = craftComponent('Button', {}, () => ({}), () => button('continue', { class: surface.card }, 'Continue'));
1003
+ export const Alert = craftComponent('Alert', {}, () => ({}), () => p({ class: surface.message, 'data-tone': 'danger' }, 'Alert'));
1004
+ `;
1005
+ }
1006
+ function mainTs(context) {
1007
+ return `import { bootstrapCraft } from '@craft-ts/component';
280
1008
  import { appConfig } from './app/app.config';
1009
+ import { startCraftTypecheckIndicator } from './dev-typecheck-indicator';
281
1010
  import './styles.css';
1011
+ ${context.config.typedCss ? "import 'virtual:craft-style.css';" : ''}
282
1012
 
1013
+ startCraftTypecheckIndicator();
283
1014
  bootstrapCraft({ config: appConfig });
284
1015
  `;
285
- const appTs = `import {
1016
+ }
1017
+ const typecheckIndicatorTs = `/// <reference types="vite/client" />
1018
+
1019
+ /* eslint-disable craft-ts/prefer-browser-boundaries, craft-ts/no-direct-temporal-globals, craft-ts/prefer-craft-http-transport, craft-ts/no-async-await -- Dev-server bootstrap adapter, intentionally outside the Craft component tree. */
1020
+ export function startCraftTypecheckIndicator(): void {
1021
+ if (!import.meta.env.DEV) return;
1022
+
1023
+ const indicator = document.createElement('div');
1024
+ const message = document.createElement('span');
1025
+ const dismiss = document.createElement('button');
1026
+
1027
+ indicator.className = 'craft-typecheck-indicator';
1028
+ indicator.setAttribute('role', 'status');
1029
+ indicator.setAttribute('aria-live', 'polite');
1030
+ message.textContent = 'Type checking in progress…';
1031
+ dismiss.type = 'button';
1032
+ dismiss.className = 'craft-typecheck-indicator__dismiss';
1033
+ dismiss.setAttribute('aria-label', 'Dismiss type-check warning');
1034
+ dismiss.title = 'Dismiss';
1035
+ dismiss.textContent = '×';
1036
+ dismiss.hidden = true;
1037
+ indicator.append(message, dismiss);
1038
+ document.body.append(indicator);
1039
+
1040
+ let dismissed = false;
1041
+ dismiss.addEventListener('click', () => {
1042
+ dismissed = true;
1043
+ indicator.remove();
1044
+ });
1045
+
1046
+ const poll = async (): Promise<void> => {
1047
+ try {
1048
+ const response = await fetch('/__craft/typecheck', { cache: 'no-store' });
1049
+ const payload = (await response.json()) as { status?: 'running' | 'passed' | 'failed' };
1050
+ if (dismissed) return;
1051
+ if (payload.status === 'passed') {
1052
+ indicator.remove();
1053
+ return;
1054
+ }
1055
+ if (payload.status === 'failed') {
1056
+ indicator.dataset['status'] = 'failed';
1057
+ message.textContent = 'Type checking failed — app is still running';
1058
+ dismiss.hidden = false;
1059
+ return;
1060
+ }
1061
+ } catch {
1062
+ // Keep polling while Vite is starting.
1063
+ }
1064
+ window.setTimeout(() => void poll(), 250);
1065
+ };
1066
+ void poll();
1067
+ }
1068
+ `;
1069
+ function appTs(context) {
1070
+ const designSystem = context.config.designSystem !== 'none';
1071
+ const uiImport = designSystem
1072
+ ? `import { appTheme } from './ui/${context.config.typedCss ? 'ui.style' : 'ui'}';`
1073
+ : '';
1074
+ const themeOpen = designSystem ? 'div({ class: appTheme.root }, [' : 'div([';
1075
+ const themeClose = designSystem ? '])' : '])';
1076
+ return `import {
286
1077
  a,
287
1078
  CraftRouterOutlet,
288
1079
  craftComponent,
289
1080
  div,
290
1081
  main,
291
1082
  nav,
1083
+ span,
292
1084
  } from '@craft-ts/component';
293
1085
  import { CraftRouterLink } from '@craft-ts/core';
1086
+ ${uiImport}
294
1087
 
295
1088
  export const App = craftComponent(
296
1089
  'App',
297
1090
  {},
298
1091
  () => ({}),
299
1092
  () =>
300
- div([
1093
+ ${themeOpen}
301
1094
  nav([
302
- a('home', { craftRouterLink: { to: '' } }, 'Home').pipe(CraftRouterLink),
303
- a('about', { craftRouterLink: { to: 'about' } }, 'About').pipe(CraftRouterLink),
1095
+ a('home', {}, 'Home').pipe(CraftRouterLink({ to: '' })),
1096
+ a('services', {}, 'Services').pipe(CraftRouterLink({ to: 'services' })),
1097
+ a('about', {}, 'About').pipe(CraftRouterLink({ to: 'about' })),
1098
+ span({ class: 'starter-experimental-badge' }, 'Experimental · feedback welcome'),
304
1099
  ]),
305
1100
  main(CraftRouterOutlet()),
306
- ]),
1101
+ ${themeClose},
307
1102
  );
308
1103
  `;
309
- const routesTs = `import { loadCraftComponent } from '@craft-ts/component';
1104
+ }
1105
+ function routesTs(context) {
1106
+ const httpErrorHandler = context.config.frontendRuntime === 'plain'
1107
+ ? ` HttpError: craftExceptionHandler(function* ({ globalError }) {
1108
+ return globalError();
1109
+ }),
1110
+ `
1111
+ : '';
1112
+ const welcomeErrorHandler = context.config.frontendRuntime === 'effect'
1113
+ ? ` ${context.config.backendRuntime === 'none' ? 'WelcomeApiError' : 'EffectFailure'}: craftExceptionHandler(function* ({ globalError }) {
1114
+ return globalError();
1115
+ }),
1116
+ `
1117
+ : '';
1118
+ const backendEffectErrorHandler = context.config.frontendRuntime === 'plain' &&
1119
+ context.config.backendRuntime === 'effect'
1120
+ ? ` StarterRepositoryError: craftExceptionHandler(function* ({ globalError }) {
1121
+ return globalError();
1122
+ }),
1123
+ `
1124
+ : '';
1125
+ return `/* eslint-disable require-yield -- Route exception outcomes are synchronous by design. */
1126
+ import { loadCraftComponent } from '@craft-ts/component';
310
1127
  import {
311
1128
  assertExhaustiveRouteExceptions,
1129
+ craftExceptionHandler,
1130
+ craftRoute,
312
1131
  craftRoutes,
313
1132
  type CanRun,
314
- type CraftRouter,
315
- type ValidateCascadeRoutesFile,
1133
+ type ComponentDepsOf,
1134
+ type RouteCheckedDI,
316
1135
  } from '@craft-ts/core';
317
1136
 
318
1137
  export const { appRoutes } = craftRoutes('app', [
319
- {
320
- path: '',
1138
+ craftRoute('', {
321
1139
  ...loadCraftComponent(({ withRetry }) =>
322
1140
  withRetry(import('./home-page')).then(
323
1141
  (module: typeof import('./home-page')) => module.default,
324
1142
  ),
325
1143
  ),
326
- },
327
- {
328
- path: 'about',
1144
+ }, {
1145
+ ${httpErrorHandler}
1146
+ ${backendEffectErrorHandler}
1147
+ ${welcomeErrorHandler} }),
1148
+ craftRoute('about', {
329
1149
  ...loadCraftComponent(({ withRetry }) =>
330
1150
  withRetry(import('./about-page')).then(
331
1151
  (module: typeof import('./about-page')) => module.default,
332
1152
  ),
333
1153
  ),
334
- },
1154
+ }, {
1155
+ HttpError: craftExceptionHandler(function* ({ globalError }) {
1156
+ return globalError();
1157
+ }),
1158
+ }),
1159
+ craftRoute('services', {
1160
+ ...loadCraftComponent(({ withRetry }) =>
1161
+ withRetry(import('./services-page')).then(
1162
+ (module: typeof import('./services-page')) => module.default,
1163
+ ),
1164
+ ),
1165
+ }, {
1166
+ HttpError: craftExceptionHandler(function* ({ globalError }) {
1167
+ return globalError();
1168
+ }),
1169
+ }),
335
1170
  ]);
336
1171
 
337
1172
  assertExhaustiveRouteExceptions(appRoutes);
338
1173
 
339
- type _CheckAppDI = ValidateCascadeRoutesFile<
1174
+ type _CheckHomePageDI = RouteCheckedDI<
1175
+ ComponentDepsOf<(typeof import('./home-page'))['default']>,
1176
+ 'CraftRouter',
1177
+ never,
1178
+ 'component: home-page'
1179
+ >;
1180
+ type _CanRunHomePage = CanRun<_CheckHomePageDI>;
1181
+
1182
+ type _CheckAboutPageDI = RouteCheckedDI<
1183
+ ComponentDepsOf<(typeof import('./about-page'))['default']>,
1184
+ 'CraftRouter',
1185
+ never,
1186
+ 'component: about-page'
1187
+ >;
1188
+ type _CanRunAboutPage = CanRun<_CheckAboutPageDI>;
1189
+
1190
+ type _CheckServicesPageDI = RouteCheckedDI<
1191
+ ComponentDepsOf<(typeof import('./services-page'))['default']>,
1192
+ 'CraftRouter',
340
1193
  never,
341
- CraftRouter,
342
- typeof appRoutes
1194
+ 'component: services-page'
343
1195
  >;
344
- type _CanRunAppDI = CanRun<_CheckAppDI>;
1196
+ type _CanRunServicesPage = CanRun<_CheckServicesPageDI>;
345
1197
 
346
1198
  declare module '@craft-ts/core' {
347
1199
  interface CraftRouterRoutesRegistry {
@@ -349,20 +1201,43 @@ declare module '@craft-ts/core' {
349
1201
  }
350
1202
  }
351
1203
  `;
352
- const appConfigTs = `import { provideCraftRootComponent } from '@craft-ts/component';
353
- import { craftAppConfig, provideCraftRouter } from '@craft-ts/core';
1204
+ }
1205
+ function plainAppConfig(context) {
1206
+ const serverTransport = context.config.backendRuntime !== 'none'
1207
+ ? ' provideDefaultServerFunctionTransport(),\n'
1208
+ : '';
1209
+ return `import { provideCraftRootComponent } from '@craft-ts/component';
1210
+ import {
1211
+ craftAppConfig,
1212
+ provideCraftDevTools,
1213
+ provideCraftRouter,
1214
+ ${context.config.backendRuntime !== 'none' ? ' provideDefaultServerFunctionTransport,\n' : ''}
1215
+ } from '@craft-ts/core';
354
1216
  import { App } from './app';
355
1217
  import { appRoutes } from './app.routes';
356
1218
 
1219
+ const developmentProviders = import.meta.env.DEV ? provideCraftDevTools() : [];
1220
+
357
1221
  export const appConfig = craftAppConfig({
358
1222
  routingDeps: appRoutes.META_PATHS,
359
1223
  providers: [
1224
+ ...developmentProviders,
360
1225
  provideCraftRootComponent(App),
361
1226
  provideCraftRouter(appRoutes.toRoutes()),
362
- ],
1227
+ ${serverTransport} ],
363
1228
  });
364
1229
  `;
365
- const apiTs = `import { CraftHttpClient } from '@craft-ts/core';
1230
+ }
1231
+ function apiTs(context) {
1232
+ if (context.config.backendRuntime !== 'none') {
1233
+ return `import { getStarterMessage } from '../starter.fn-client';
1234
+ export type { StarterResponse as WelcomeResponse } from '../starter.fn-client';
1235
+ export function loadWelcome() {
1236
+ return getStarterMessage({ filter: 'starter' });
1237
+ }
1238
+ `;
1239
+ }
1240
+ return `import { craftSleep } from '@craft-ts/core';
366
1241
 
367
1242
  export type WelcomeResponse = {
368
1243
  readonly title: string;
@@ -370,38 +1245,542 @@ export type WelcomeResponse = {
370
1245
  };
371
1246
 
372
1247
  export function* loadWelcome() {
373
- return yield* CraftHttpClient.get(({ response }) => ({
374
- url: '/api/welcome',
375
- success: response<WelcomeResponse>(),
376
- }));
1248
+ // No backend is configured: simulate the typed API boundary locally.
1249
+ yield* craftSleep(10);
1250
+ return {
1251
+ title: 'Local welcome',
1252
+ body: 'This response is local because no backend is configured.',
1253
+ };
377
1254
  }
378
1255
  `;
379
- const aboutPageTs = `import { craftComponent, div, heading, p } from '@craft-ts/component';
1256
+ }
1257
+ function serverFiles(context) {
1258
+ if (context.config.backendRuntime === 'none')
1259
+ return {};
1260
+ const effect = context.config.backendRuntime === 'effect';
1261
+ const fnServer = effect
1262
+ ? `import { serverFunction, type ServerFunctionSuccess } from '@craft-ts/core';
1263
+ import { Effect, Schema } from 'effect';
1264
+ import { StarterRepository } from './server/repository';
1265
+ import { starterMiddleware } from './starter.mw-serveur';
1266
+
1267
+ const inputSchema = Schema.toStandardSchemaV1(Schema.Struct({ filter: Schema.String }));
1268
+ const outputSchema = Schema.toStandardSchemaV1(Schema.Struct({ title: Schema.String, body: Schema.String }));
1269
+
1270
+ export const getStarterMessage = serverFunction(
1271
+ 'starter.welcome', inputSchema, { exposure: 'client', output: outputSchema },
1272
+ ).use(starterMiddleware).handler(({ input }) => Effect.gen(function* () {
1273
+ const repository = yield* StarterRepository;
1274
+ return yield* repository.welcome(input.filter);
1275
+ })).exposeErrors({
1276
+ StarterRepositoryError: (errorPayload) => ({
1277
+ code: 'STARTER_REPOSITORY_FAILURE',
1278
+ status: 503,
1279
+ payload: { filter: errorPayload.filter, message: errorPayload.message },
1280
+ }),
1281
+ });
1282
+
1283
+ export type StarterResponse = ServerFunctionSuccess<typeof getStarterMessage>;
1284
+ `
1285
+ : `import { flatMapContext, mapContext, portableServerFunction, type SchemaOutput, type ServerFunctionContractOutput, type ServerFunctionSuccess } from '@craft-ts/core';
1286
+ import type { StandardSchemaV1 } from '@craft-ts/core';
1287
+ import { StarterRepository } from './server/repository';
1288
+
1289
+ type Input = { readonly filter: string };
1290
+ const inputSchema: StandardSchemaV1<Input, Input> = { '~standard': { version: 1, vendor: 'craft-starter', types: undefined,
1291
+ validate(value: unknown) { return typeof value === 'object' && value !== null && typeof (value as Input).filter === 'string'
1292
+ ? { value: value as Input } : { issues: [{ message: 'filter must be a string' }] }; } } };
1293
+ const outputSchema: StandardSchemaV1<{ readonly title: string; readonly body: string }, { readonly title: string; readonly body: string }> = { '~standard': { version: 1, vendor: 'craft-starter', types: undefined,
1294
+ validate(value: unknown) { return typeof value === 'object' && value !== null && typeof (value as { title?: unknown }).title === 'string' && typeof (value as { body?: unknown }).body === 'string'
1295
+ ? { value: value as { readonly title: string; readonly body: string } } : { issues: [{ message: 'welcome response must contain title and body' }] }; } } };
1296
+
1297
+ export const getStarterMessage = portableServerFunction('starter.welcome', inputSchema, { exposure: 'client', output: outputSchema })
1298
+ .pipe(
1299
+ mapContext(({ input }) => ({ normalizedFilter: input.filter.trim() })),
1300
+ flatMapContext(() => StarterRepository.welcome()),
1301
+ )
1302
+ .handler(async ({ context }) => context.value as SchemaOutput<typeof outputSchema>)
1303
+ .exposeErrors({});
1304
+
1305
+ export type StarterResponse = ServerFunctionContractOutput<typeof getStarterMessage['contract']>;
1306
+ `;
1307
+ const repository = effect
1308
+ ? `import { Context, Data, Effect, Layer } from 'effect';
1309
+
1310
+ export type StarterRepositoryShape = {
1311
+ readonly welcome: (filter: string) => Effect.Effect<
1312
+ { readonly title: string; readonly body: string },
1313
+ StarterRepositoryError
1314
+ >;
1315
+ };
1316
+ export class StarterRepository extends Context.Service<StarterRepository, StarterRepositoryShape>()('starter/StarterRepository') {}
1317
+ export class StarterRepositoryError extends Data.TaggedError('StarterRepositoryError')<{
1318
+ readonly filter: string;
1319
+ readonly message: string;
1320
+ }> {}
1321
+
1322
+ export const StarterRepositoryLive = Layer.succeed(StarterRepository, {
1323
+ welcome: (filter) => Effect.tryPromise({
1324
+ try: () => filter === 'error'
1325
+ ? Promise.reject(new Error('The starter repository failed.'))
1326
+ : Promise.resolve({ title: 'Hello from the server', body: 'Effect server function: ' + filter }),
1327
+ catch: (cause) => new StarterRepositoryError({
1328
+ filter,
1329
+ message: cause instanceof Error ? cause.message : String(cause),
1330
+ }),
1331
+ }),
1332
+ });
1333
+ `
1334
+ : `export const StarterRepository = {
1335
+ welcome: async () => ({ value: { title: 'Hello from the server', body: 'Promise server function works.' } }),
1336
+ };
1337
+ `;
1338
+ const application = effect
1339
+ ? `import { createServer, type Server } from '@craft-ts/core';
1340
+ import { executeEffect } from '@craft-ts/effect';
1341
+ ${context.config.i18n.enabled ? "import { Layer } from 'effect';\n" : ''}
1342
+ import { getStarterMessage } from '../starter.fn-serveur';
1343
+ import { StarterRepositoryLive } from './repository';
1344
+ ${context.config.i18n.enabled ? "import { serverI18nLayer } from './i18n';\n" : ''}
1345
+
1346
+ export const runtimeLayer = ${context.config.i18n.enabled ? 'Layer.mergeAll(StarterRepositoryLive, serverI18nLayer)' : 'StarterRepositoryLive'};
1347
+
1348
+ export function createApplication(layer = runtimeLayer): Server {
1349
+ return createServer({
1350
+ functions: [getStarterMessage],
1351
+ execute: executeEffect(layer).run,
1352
+ runtimeOptions: {
1353
+ maxBodyBytes: 1_000_000,
1354
+ maxOutputBytes: 1_000_000,
1355
+ timeoutMs: 10_000,
1356
+ },
1357
+ });
1358
+ }
1359
+
1360
+ export const application = createApplication();
1361
+ `
1362
+ : `import { createServer, type Server } from '@craft-ts/core';
1363
+ import { getStarterMessage } from '../starter.fn-serveur';
1364
+
1365
+ export function createApplication(): Server {
1366
+ return createServer({
1367
+ functions: [getStarterMessage],
1368
+ runtimeOptions: {
1369
+ maxBodyBytes: 1_000_000,
1370
+ maxOutputBytes: 1_000_000,
1371
+ timeoutMs: 10_000,
1372
+ },
1373
+ });
1374
+ }
1375
+
1376
+ export const application = createApplication();
1377
+ `;
1378
+ const nodeHttp = `/* eslint-disable craft-ts/no-async-await -- The Node adapter is an async platform boundary. */
1379
+ import { application } from './application';
1380
+ import type { IncomingMessage, ServerResponse } from 'node:http';
1381
+
1382
+ export async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
1383
+ const abortController = new AbortController();
1384
+ const abort = () => abortController.abort();
1385
+ const close = () => {
1386
+ if (!request.complete) abort();
1387
+ };
1388
+ request.once('aborted', abort);
1389
+ request.once('close', close);
1390
+ try {
1391
+ const webResponse = await application.handle(toWebRequest(request, abortController.signal));
1392
+ await writeWebResponse(webResponse, response, request.method === 'HEAD');
1393
+ } finally {
1394
+ request.off('aborted', abort);
1395
+ request.off('close', close);
1396
+ }
1397
+ }
1398
+
1399
+ function toWebRequest(request: IncomingMessage, signal: AbortSignal): Request {
1400
+ const headers = new Headers();
1401
+ for (const [name, value] of Object.entries(request.headers)) {
1402
+ if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value);
1403
+ }
1404
+ const method = request.method ?? 'GET';
1405
+ const hasBody = method !== 'GET' && method !== 'HEAD';
1406
+ return new Request(
1407
+ 'http://' + (request.headers.host ?? '127.0.0.1') + (request.url ?? '/'),
1408
+ {
1409
+ method,
1410
+ headers,
1411
+ signal,
1412
+ ...(hasBody ? { body: request as unknown as BodyInit, duplex: 'half' } : {}),
1413
+ } as RequestInit,
1414
+ );
1415
+ }
1416
+
1417
+ async function writeWebResponse(
1418
+ webResponse: Response,
1419
+ response: ServerResponse,
1420
+ head: boolean,
1421
+ ): Promise<void> {
1422
+ response.statusCode = webResponse.status;
1423
+ webResponse.headers.forEach((value, name) => response.setHeader(name, value));
1424
+ if (head || webResponse.body === null) {
1425
+ response.end();
1426
+ return;
1427
+ }
1428
+ response.end(Buffer.from(await webResponse.arrayBuffer()));
1429
+ }
1430
+ `;
1431
+ const client = `import { createServerFunctionClient, craftUnique, type ServerFunctionClient } from '@craft-ts/core';
1432
+ import type { getStarterMessage as ServerGetStarterMessage, StarterResponse } from './starter.fn-serveur';
1433
+
1434
+ export type { StarterResponse };
1435
+ const starterMessageTransport = createServerFunctionClient<typeof ServerGetStarterMessage>(craftUnique('starter.welcome'));
1436
+ export const getStarterMessage = starterMessageTransport as ServerFunctionClient<typeof ServerGetStarterMessage, StarterResponse>;
1437
+ `;
1438
+ const compatibilityServer = `export { application, createApplication } from './application';
1439
+ ${effect ? 'export { runtimeLayer } from \'./application\';\n' : ''}export { handleRequest } from './node-http';
1440
+ `;
1441
+ return {
1442
+ 'src/server/repository.ts': repository,
1443
+ 'src/server/application.ts': application,
1444
+ 'src/server/node-http.ts': nodeHttp,
1445
+ 'src/server/server.ts': compatibilityServer,
1446
+ 'src/starter.fn-serveur.ts': fnServer,
1447
+ 'src/starter.fn-client.ts': client,
1448
+ ...(effect && context.config.i18n.enabled
1449
+ ? {
1450
+ 'src/server/i18n.ts': "import { provideI18nRuntime } from '@craft-ts/i18n-effect';\nimport { i18n } from '../i18n/runtime';\n\nexport const serverI18nLayer = provideI18nRuntime(i18n);\n",
1451
+ }
1452
+ : {}),
1453
+ ...(effect
1454
+ ? {
1455
+ 'src/starter.mw-serveur.ts': "import { Effect } from 'effect';\nimport { effectServerMiddleware } from '@craft-ts/effect';\n\nexport const starterMiddleware = effectServerMiddleware('starter.middleware', () =>\n Effect.gen(function* () {\n yield* Effect.log('starter middleware executed');\n return { value: undefined };\n }),\n);\n",
1456
+ }
1457
+ : {}),
1458
+ 'vitest.server.config.ts': `import { defineConfig } from 'vitest/config';
1459
+ export default defineConfig({ test: { name: 'craft-starter-server', globals: true, environment: 'node', include: ['src/server/**/*.spec.ts'] } });
1460
+ `,
1461
+ 'src/server/server.spec.ts': serverSpec(context),
1462
+ };
1463
+ }
1464
+ function serverSpec(context) {
1465
+ const effectFailure = context.config.backendRuntime === 'effect'
1466
+ ? `
1467
+ it('exposes the typed Effect repository failure', async () => {
1468
+ const response = await application.handle(new Request('http://127.0.0.1/__server-functions', {
1469
+ method: 'POST',
1470
+ headers: { 'content-type': 'application/json' },
1471
+ body: JSON.stringify({ id: 'starter.welcome', input: { filter: 'error' } }),
1472
+ }));
1473
+ expect(response.status).toBe(503);
1474
+ await expect(response.json()).resolves.toMatchObject({
1475
+ error: { _tag: 'StarterRepositoryError', code: 'STARTER_REPOSITORY_FAILURE', filter: 'error' },
1476
+ });
1477
+ });
1478
+ `
1479
+ : '';
1480
+ return `import { createServer as createNodeServer } from 'node:http';
1481
+ import { describe, expect, it } from 'vitest';
1482
+ import { createServer, serverFunction, type StandardSchemaV1 } from '@craft-ts/core';
1483
+ import { application, handleRequest } from './server';
1484
+
1485
+ const invalidInputSchema: StandardSchemaV1<unknown, unknown> = {
1486
+ '~standard': { version: 1, vendor: 'craft-starter-test', types: undefined,
1487
+ validate(value: unknown) { return { value }; } },
1488
+ };
1489
+ const invalidOutputSchema: StandardSchemaV1<{ readonly required: string }, { readonly required: string }> = {
1490
+ '~standard': { version: 1, vendor: 'craft-starter-test', types: undefined,
1491
+ validate(value: unknown) {
1492
+ return typeof value === 'object' && value !== null && typeof (value as { required?: unknown }).required === 'string'
1493
+ ? { value: value as { readonly required: string } }
1494
+ : { issues: [{ message: 'required must be a string' }] };
1495
+ } },
1496
+ };
1497
+ const invalidOutput = serverFunction(
1498
+ 'starter.invalid-output',
1499
+ invalidInputSchema,
1500
+ { exposure: 'server', output: invalidOutputSchema },
1501
+ ).handler(() => ({ required: 123 })).exposeErrors({});
1502
+
1503
+ describe('server function registry', () => {
1504
+ it('invokes the starter function through the registry', async () => {
1505
+ await expect(application.invoke('starter.welcome', { filter: 'Ada' })).resolves.toMatchObject({
1506
+ title: 'Hello from the server',
1507
+ });
1508
+ });
1509
+
1510
+ it('rejects invalid input', async () => {
1511
+ await expect(application.invoke('starter.welcome', { filter: 123 })).rejects.toThrow(
1512
+ 'CRAFT_SERVER_FUNCTION_INPUT_INVALID',
1513
+ );
1514
+ });
1515
+
1516
+ it('rejects invalid output', async () => {
1517
+ const server = createServer({ functions: [invalidOutput] });
1518
+ await expect(server.invoke('starter.invalid-output', undefined)).rejects.toThrow(
1519
+ 'CRAFT_SERVER_FUNCTION_OUTPUT_INVALID',
1520
+ );
1521
+ });
1522
+ ${effectFailure}
1523
+
1524
+ it('serves a real HTTP request through the Node adapter', async () => {
1525
+ const nodeServer = createNodeServer((request, response) => {
1526
+ void handleRequest(request, response).catch((error: unknown) => {
1527
+ if (!response.headersSent) response.statusCode = 500;
1528
+ response.end('Internal Server Error');
1529
+ throw error;
1530
+ });
1531
+ });
1532
+ await new Promise<void>((resolve) => nodeServer.listen(0, '127.0.0.1', resolve));
1533
+ const address = nodeServer.address();
1534
+ if (!address || typeof address === 'string') throw new Error('Server did not start.');
1535
+ try {
1536
+ const response = await fetch('http://127.0.0.1:' + address.port + '/__server-functions', {
1537
+ method: 'POST',
1538
+ headers: { 'content-type': 'application/json' },
1539
+ body: JSON.stringify({ id: 'starter.welcome', input: { filter: 'Ada' } }),
1540
+ });
1541
+ expect(response.status).toBe(200);
1542
+ await expect(response.json()).resolves.toMatchObject({ title: 'Hello from the server' });
1543
+ } finally {
1544
+ await new Promise<void>((resolve, reject) => nodeServer.close((error) => error ? reject(error) : resolve()));
1545
+ }
1546
+ });
1547
+
1548
+ it('rejects unsupported method and content type', async () => {
1549
+ await expect(application.handle(new Request('http://127.0.0.1/__server-functions'))).resolves.toHaveProperty('status', 405);
1550
+ await expect(application.handle(new Request('http://127.0.0.1/__server-functions', {
1551
+ method: 'POST',
1552
+ headers: { 'content-type': 'text/plain' },
1553
+ body: '{}',
1554
+ }))).resolves.toHaveProperty('status', 415);
1555
+ });
1556
+ });
1557
+ `;
1558
+ }
1559
+ function referenceFiles(context) {
1560
+ if (!context.config.references.craftTs && !context.config.references.effectTs)
1561
+ return {};
1562
+ const manifest = {
1563
+ schemaVersion: 1,
1564
+ mode: 'context',
1565
+ effectEnabled: context.config.frontendRuntime === 'effect' ||
1566
+ context.config.backendRuntime === 'effect',
1567
+ };
1568
+ if (context.config.references.craftTs)
1569
+ manifest['craftTs'] = {
1570
+ url: 'https://github.com/craft-ts/craft-ts.git',
1571
+ requestedRef: context.config.references.craftTsRef,
1572
+ resolvedSha: '',
1573
+ path: '.references/craft-ts',
1574
+ };
1575
+ if (context.config.references.effectTs)
1576
+ manifest['effectTs'] = {
1577
+ url: 'https://github.com/Effect-TS/effect.git',
1578
+ requestedRef: context.config.references.effectTsRef,
1579
+ resolvedSha: '',
1580
+ path: '.references/effect-ts',
1581
+ };
1582
+ const resolver = "import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path';\nexport function resolveReferenceManifest(root) { return JSON.parse(readFileSync(join(root, '.references/manifest.json'), 'utf8')); }\nexport function resolveReferencePath(root, name) { const manifest = resolveReferenceManifest(root); return manifest[name] ? resolve(root, manifest[name].path) : undefined; }\n";
1583
+ const updater = `import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path';
1584
+ const root = resolve(import.meta.dirname, '..');
1585
+ const manifestPath = join(root, '.references/manifest.json');
1586
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1587
+ for (const [name, entry] of Object.entries(manifest).filter(([key, value]) => !['schemaVersion', 'mode', 'effectEnabled'].includes(key) && value && value.path)) {
1588
+ const path = resolve(root, entry.path);
1589
+ if (!existsSync(join(path, '.git'))) throw new Error('Missing reference clone: ' + path);
1590
+ if (execFileSync('git', ['status', '--short'], { cwd: path, encoding: 'utf8' }).trim()) throw new Error('Modified reference: ' + path);
1591
+ execFileSync('git', ['fetch', '--depth', '1', 'origin', entry.requestedRef], { cwd: path, stdio: 'inherit' });
1592
+ execFileSync('git', ['checkout', '--detach', 'FETCH_HEAD'], { cwd: path, stdio: 'inherit' });
1593
+ entry.resolvedSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim();
1594
+ }
1595
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\\n');
1596
+ `;
1597
+ return {
1598
+ '.gitignore': GENERATED_GITIGNORE,
1599
+ '.references/manifest.json': json(manifest),
1600
+ 'scripts/reference-resolver.mjs': resolver,
1601
+ 'scripts/update-references.mjs': updater,
1602
+ ...(context.config.references.craftTs
1603
+ ? { 'scripts/update-craft-ts.mjs': updater }
1604
+ : {}),
1605
+ ...(context.config.references.effectTs
1606
+ ? { 'scripts/update-effect-ts.mjs': updater }
1607
+ : {}),
1608
+ };
1609
+ }
1610
+ function cloneReferenceIfRequested(root, config) {
1611
+ const entries = [
1612
+ config.references.craftTs
1613
+ ? [
1614
+ 'https://github.com/craft-ts/craft-ts.git',
1615
+ config.references.craftTsRef,
1616
+ '.references/craft-ts',
1617
+ ]
1618
+ : undefined,
1619
+ config.references.effectTs
1620
+ ? [
1621
+ 'https://github.com/Effect-TS/effect.git',
1622
+ config.references.effectTsRef,
1623
+ '.references/effect-ts',
1624
+ ]
1625
+ : undefined,
1626
+ ].filter((entry) => entry !== undefined);
1627
+ for (const [url, ref, relativePath] of entries) {
1628
+ const target = join(root, relativePath);
1629
+ if (!existsSync(join(target, '.git'))) {
1630
+ mkdirSync(dirname(target), { recursive: true });
1631
+ execFileSync('git', ['clone', '--depth', '1', '--branch', ref, url, target], { cwd: root, stdio: 'inherit' });
1632
+ }
1633
+ }
1634
+ const manifestPath = join(root, '.references', 'manifest.json');
1635
+ if (existsSync(manifestPath)) {
1636
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1637
+ for (const entry of Object.values(manifest)) {
1638
+ if (!entry.path)
1639
+ continue;
1640
+ const target = resolve(root, entry.path);
1641
+ entry.resolvedSha = execFileSync('git', ['rev-parse', 'HEAD'], {
1642
+ cwd: target,
1643
+ encoding: 'utf8',
1644
+ }).trim();
1645
+ }
1646
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
1647
+ }
1648
+ }
1649
+ function ensureGitignore(root) {
1650
+ const gitignorePath = join(root, '.gitignore');
1651
+ if (!existsSync(gitignorePath)) {
1652
+ writeFileSync(gitignorePath, GENERATED_GITIGNORE, 'utf8');
1653
+ return;
1654
+ }
1655
+ const existing = readFileSync(gitignorePath, 'utf8');
1656
+ const existingLines = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
1657
+ const missing = GENERATED_GITIGNORE.trimEnd()
1658
+ .split('\n')
1659
+ .filter((line) => !existingLines.has(line));
1660
+ if (missing.length > 0) {
1661
+ writeFileSync(gitignorePath, `${existing.trimEnd()}\n\n# CraftTS generated project\n${missing.join('\n')}\n`, 'utf8');
1662
+ }
1663
+ }
1664
+ function initialiseGitRepository(root) {
1665
+ if (existsSync(join(root, '.git')))
1666
+ return;
1667
+ try {
1668
+ const parentRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
1669
+ cwd: root,
1670
+ encoding: 'utf8',
1671
+ stdio: ['ignore', 'pipe', 'ignore'],
1672
+ }).trim();
1673
+ if (parentRoot && resolve(parentRoot) !== resolve(root))
1674
+ return;
1675
+ }
1676
+ catch {
1677
+ // The destination is not inside an existing Git worktree.
1678
+ }
1679
+ execFileSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
1680
+ }
1681
+ function aboutPageTs(context) {
1682
+ const designSystem = context.config.designSystem !== 'none';
1683
+ const surfaceImport = designSystem
1684
+ ? `import { surface } from './ui/${context.config.typedCss ? 'ui.style' : 'ui'}';\n`
1685
+ : '';
1686
+ const card = designSystem ? '{ class: surface.card }, ' : '';
1687
+ const translated = context.config.i18n.enabled && context.config.frontendRuntime === 'effect'
1688
+ ? "p('i18n: ' + i18n.t('order.summary', { amount: 1234.5, count: 2, date: Date.UTC(2026, 0, 15) })),"
1689
+ : context.config.i18n.enabled
1690
+ ? "p('i18n: ' + i18n.t('order.summary', { amount: 1234.5, count: 2, date: Date.UTC(2026, 0, 15) })),"
1691
+ : "p('This page proves that Craft routing and lazy component loading are wired.'),";
1692
+ const i18nImport = context.config.i18n.enabled
1693
+ ? "import { i18n } from '../i18n';\n"
1694
+ : '';
1695
+ return `import { craftComponent, div, heading, p } from '@craft-ts/component';
1696
+ ${surfaceImport}${i18nImport}
380
1697
 
381
1698
  export const AboutPage = craftComponent(
382
1699
  'AboutPage',
383
1700
  {},
384
1701
  () => ({}),
385
1702
  () =>
386
- div({ class: 'card' }, [
1703
+ div(${card}[
387
1704
  heading('About this starter'),
1705
+ ${translated}
388
1706
  p('This page proves that Craft routing and lazy component loading are wired.'),
389
1707
  ]),
390
1708
  );
391
1709
 
392
1710
  export default AboutPage;
393
1711
  `;
394
- const plainHomePageTs = `import {
1712
+ }
1713
+ function servicesPageTs(context) {
1714
+ const designSystem = context.config.designSystem !== 'none';
1715
+ const uiImport = designSystem
1716
+ ? `import { surface } from './ui/${context.config.typedCss ? 'ui.style' : 'ui'}';\n`
1717
+ : '';
1718
+ const card = designSystem ? '{ class: surface.card }, ' : '';
1719
+ const i18n = context.config.i18n.enabled && context.config.frontendRuntime !== 'effect'
1720
+ ? "import { i18n } from '../i18n';\n"
1721
+ : '';
1722
+ const effectI18n = context.config.frontendRuntime === 'effect' && context.config.i18n.enabled
1723
+ ? "import { i18n } from '../i18n';\n"
1724
+ : '';
1725
+ const body = context.config.frontendRuntime === 'effect'
1726
+ ? `p('Effect service: Context.Service + Layer + provideLayer are installed at app scope.'),
1727
+ ${context.config.i18n.enabled ? "p('i18n: ' + i18n.t('order.items', { count: 2 }))," : "p('The service contract is isolated from the page.'),"}`
1728
+ : `p(function* () { return 'Plain service: ' + (yield* StarterService()).label; }),
1729
+ ${context.config.i18n.enabled ? "p('i18n: ' + i18n.t('order.items', { count: 2 }))," : "p('The service is resolved through Craft DI.'),"}`;
1730
+ const service = context.config.frontendRuntime === 'effect'
1731
+ ? ''
1732
+ : `import { craftService } from '@craft-ts/core';
1733
+
1734
+ const { StarterService } = craftService({ name: 'StarterService', providedIn: 'global' }, function* () {
1735
+ return { label: 'resolved through Craft DI' };
1736
+ });
1737
+ `;
1738
+ const lintComment = context.config.frontendRuntime === 'effect' ? '' : '/* eslint-disable require-yield -- Synchronous DI factory is intentional in this starter. */\n';
1739
+ return `${lintComment}import { craftComponent, div, heading, p } from '@craft-ts/component';
1740
+ ${uiImport}${i18n}${effectI18n}${service}
1741
+ export const ServicesPage = craftComponent(
1742
+ 'ServicesPage',
1743
+ {},
1744
+ () => ({}),
1745
+ () => div(${card}[
1746
+ heading('Services'),
1747
+ ${body}
1748
+ ]),
1749
+ );
1750
+
1751
+ export default ServicesPage;
1752
+ `;
1753
+ }
1754
+ function plainHomePageTs(context) {
1755
+ const designSystem = context.config.designSystem !== 'none';
1756
+ const surfaceImport = designSystem
1757
+ ? `import { surface } from './ui/${context.config.typedCss ? 'ui.style' : 'ui'}';\n`
1758
+ : '';
1759
+ const card = designSystem ? '{ class: surface.card }, ' : '';
1760
+ const note = designSystem ? '{ class: surface.note }, ' : '';
1761
+ const message = designSystem
1762
+ ? "{ class: surface.message, 'data-tone': 'danger' }, "
1763
+ : '';
1764
+ const i18n = context.config.i18n.enabled
1765
+ ? "import { i18n } from '../i18n';\n"
1766
+ : '';
1767
+ const summary = context.config.i18n.enabled
1768
+ ? "p('i18n: ' + i18n.t('order.summary', { amount: 1234.5, count: 2, date: Date.UTC(2026, 0, 15) })),"
1769
+ : "p('A framework-independent starter with a typed API boundary.'),";
1770
+ const loadLoader = context.config.backendRuntime === 'none'
1771
+ ? 'function* () { return yield* loadWelcome(); }'
1772
+ : '() => loadWelcome()';
1773
+ return `import {
395
1774
  craftComponent,
396
1775
  div,
397
1776
  heading,
398
- ifBlock,
1777
+ ifNode,
399
1778
  p,
400
1779
  span,
401
1780
  } from '@craft-ts/component';
402
1781
  import { craftComputed, query } from '@craft-ts/core';
1782
+ ${i18n}${surfaceImport}
403
1783
  import { loadWelcome } from './api';
404
-
405
1784
  export const HomePage = craftComponent(
406
1785
  'HomePage',
407
1786
  {},
@@ -410,9 +1789,7 @@ export const HomePage = craftComponent(
410
1789
  'welcomeQuery',
411
1790
  {
412
1791
  params: () => true,
413
- loader: function* () {
414
- return yield* loadWelcome();
415
- },
1792
+ loader: ${loadLoader},
416
1793
  },
417
1794
  ({ resource }) => ({
418
1795
  hasWelcome: craftComputed('hasWelcome', () => resource.hasValue()),
@@ -421,29 +1798,51 @@ export const HomePage = craftComponent(
421
1798
  return { welcomeQuery };
422
1799
  },
423
1800
  ({ welcomeQuery }) =>
424
- div({ class: 'card' }, [
1801
+ div(${card}[
425
1802
  heading('Welcome to CraftTS'),
426
- p('A framework-independent starter with a typed API boundary.'),
427
- ifBlock(welcomeQuery.isLoading, () => p({ class: 'muted' }, 'Loading API…')),
428
- ifBlock(welcomeQuery.hasWelcome, () =>
1803
+ ${summary}
1804
+ ifNode(welcomeQuery.isLoading, () => p(${note}'Loading API…')),
1805
+ ifNode(welcomeQuery.hasWelcome, () =>
429
1806
  div([
430
1807
  p(function* () {
431
- return 'API title: ' + (yield* welcomeQuery.value()).title;
1808
+ return 'API title: ' + String((yield* welcomeQuery.value())?.title);
432
1809
  }),
433
1810
  p(function* () {
434
- return 'API body: ' + (yield* welcomeQuery.value()).body;
1811
+ return 'API body: ' + String((yield* welcomeQuery.value())?.body);
435
1812
  }),
436
1813
  ]),
437
1814
  ),
438
- ifBlock(welcomeQuery.hasException, () =>
439
- p({ class: 'error' }, [span('The API request failed. Check the network tab.')]),
1815
+ ifNode(welcomeQuery.hasException, () =>
1816
+ p(${message}[
1817
+ span('The API request failed. Check the network tab.'),
1818
+ ]),
440
1819
  ),
441
1820
  ]),
442
1821
  );
443
1822
 
444
1823
  export default HomePage;
445
1824
  `;
446
- const effectDomainTs = `/* eslint-disable craft-ts/prefer-craft-http-transport, craft-ts/no-async-await, craft-ts/no-throw -- Effect owns this typed transport boundary. */
1825
+ }
1826
+ function effectDomainTs(context) {
1827
+ const loadImplementation = context.config.backendRuntime === 'none'
1828
+ ? ` load: () => Effect.gen(function* () {
1829
+ // No backend is configured: simulate the typed API boundary locally.
1830
+ yield* Effect.sleep('10 millis');
1831
+ return {
1832
+ title: 'Local welcome',
1833
+ body: 'This response is local because no backend is configured.',
1834
+ };
1835
+ }),`
1836
+ : ` load: () =>
1837
+ Effect.tryPromise({
1838
+ try: async () => {
1839
+ const response = await fetch('/api/welcome');
1840
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1841
+ return (await response.json()) as WelcomeResponse;
1842
+ },
1843
+ catch: (cause) => new WelcomeApiError({ message: String(cause) }),
1844
+ }),`;
1845
+ return `/* eslint-disable craft-ts/prefer-craft-http-transport, craft-ts/no-async-await, craft-ts/no-throw -- Effect owns this typed transport boundary. */
447
1846
  import { Context, Data, Effect, Layer } from 'effect';
448
1847
 
449
1848
  export type WelcomeResponse = {
@@ -465,39 +1864,49 @@ export class WelcomeRepositoryService extends Context.Service<
465
1864
  >()('starter/WelcomeRepository') {}
466
1865
 
467
1866
  export const WelcomeRepositoryLive = Layer.succeed(WelcomeRepositoryService, {
468
- load: () =>
469
- Effect.tryPromise({
470
- try: async () => {
471
- const response = await fetch('/api/welcome');
472
- if (!response.ok) throw new Error('HTTP ' + response.status);
473
- return (await response.json()) as WelcomeResponse;
474
- },
475
- catch: (cause) => new WelcomeApiError({ message: String(cause) }),
476
- }),
1867
+ ${loadImplementation}
477
1868
  });
478
1869
 
479
- export const loadWelcome = (): Effect.Effect<
480
- WelcomeResponse,
481
- WelcomeApiError,
482
- WelcomeRepositoryService
483
- > =>
484
- Effect.gen(function* () {
485
- const repository = yield* WelcomeRepositoryService;
486
- return yield* repository.load();
487
- });
1870
+ export const loadWelcome = () => Effect.gen(function* () {
1871
+ const repository = yield* WelcomeRepositoryService;
1872
+ return yield* repository.load();
1873
+ });
488
1874
  `;
489
- const effectHomePageTs = `import {
1875
+ }
1876
+ function effectHomePageTs(context) {
1877
+ const designSystem = context.config.designSystem !== 'none';
1878
+ const surfaceImport = designSystem
1879
+ ? `import { surface } from './ui/${context.config.typedCss ? 'ui.style' : 'ui'}';\n`
1880
+ : '';
1881
+ const card = designSystem ? '{ class: surface.card }, ' : '';
1882
+ const note = designSystem ? '{ class: surface.note }, ' : '';
1883
+ const message = designSystem
1884
+ ? "{ class: surface.message, 'data-tone': 'danger' }, "
1885
+ : '';
1886
+ const i18nImports = context.config.i18n.enabled
1887
+ ? "import { i18n } from '../i18n';\n"
1888
+ : '';
1889
+ const summary = context.config.i18n.enabled
1890
+ ? "p('i18n: ' + i18n.t('order.summary', { amount: 1234.5, count: 2, date: Date.UTC(2026, 0, 15) })),"
1891
+ : "p('The page uses queryEffect over a typed repository Layer.'),";
1892
+ const effectLoader = context.config.backendRuntime === 'none'
1893
+ ? 'loadWelcome()'
1894
+ : 'Effect.tryPromise({ try: () => loadWelcome(), catch: (cause) => new Error(String(cause)) })';
1895
+ const effectErrorTag = context.config.backendRuntime === 'none'
1896
+ ? 'WelcomeApiError'
1897
+ : 'EffectFailure';
1898
+ return `import {
490
1899
  craftComponent,
491
1900
  div,
492
1901
  heading,
493
- ifBlock,
1902
+ ifNode,
494
1903
  p,
495
1904
  span,
496
1905
  } from '@craft-ts/component';
497
- import { craftComputed } from '@craft-ts/core';
498
- import { queryEffect } from '@craft-ts/effect';
499
- import { loadWelcome, type WelcomeApiError } from './domain';
500
-
1906
+ import { computedEffect, queryEffect, SyncOp } from '@craft-ts/effect';
1907
+ import { Effect } from 'effect';
1908
+ ${i18nImports}${surfaceImport}
1909
+ import { loadWelcome } from './api';
501
1910
  export const HomePage = craftComponent(
502
1911
  'HomePage',
503
1912
  {},
@@ -506,12 +1915,19 @@ export const HomePage = craftComponent(
506
1915
  'welcomeQuery',
507
1916
  {
508
1917
  method: (request: boolean) => request,
509
- loader: () => loadWelcome(),
1918
+ loader: () => ${effectLoader},
510
1919
  },
511
1920
  ({ resource, exceptions }) => ({
512
- hasWelcome: craftComputed('hasWelcome', () => resource.hasValue()),
513
- errorMessage: craftComputed('errorMessage', function* () {
514
- return (yield* exceptions()).loader?.message ?? 'Unknown API error';
1921
+ hasWelcome: computedEffect('hasWelcome', () => Effect.gen(function* () {
1922
+ yield* SyncOp;
1923
+ return resource.hasValue();
1924
+ })),
1925
+ errorMessage: computedEffect('errorMessage', function* () {
1926
+ const loaderError = (yield* exceptions()).loader;
1927
+ return Effect.flatMap(
1928
+ SyncOp,
1929
+ () => Effect.succeed(loaderError?.${effectErrorTag}?.message ?? 'Unknown API error'),
1930
+ );
515
1931
  }),
516
1932
  }),
517
1933
  );
@@ -519,22 +1935,24 @@ export const HomePage = craftComponent(
519
1935
  return { welcomeQuery };
520
1936
  },
521
1937
  ({ welcomeQuery }) =>
522
- div({ class: 'card' }, [
1938
+ div(${card}[
523
1939
  heading('Welcome to CraftTS + Effect v4'),
524
- p('The page uses queryEffect over a typed repository Layer.'),
525
- ifBlock(welcomeQuery.isLoading, () => p({ class: 'muted' }, 'Loading API…')),
526
- ifBlock(welcomeQuery.hasWelcome, () =>
1940
+ ${summary}
1941
+ ifNode(welcomeQuery.isLoading, () => p(${note}'Loading API…')),
1942
+ ifNode(welcomeQuery.hasWelcome, () =>
527
1943
  div([
528
1944
  p(function* () {
529
- return 'API title: ' + (yield* welcomeQuery.value()).title;
1945
+ const welcome = yield* welcomeQuery.value();
1946
+ return 'API title: ' + (welcome && 'title' in welcome ? welcome.title : '');
530
1947
  }),
531
1948
  p(function* () {
532
- return 'API body: ' + (yield* welcomeQuery.value()).body;
1949
+ const welcome = yield* welcomeQuery.value();
1950
+ return 'API body: ' + (welcome && 'body' in welcome ? welcome.body : '');
533
1951
  }),
534
1952
  ]),
535
1953
  ),
536
- ifBlock(welcomeQuery.hasException, () =>
537
- p({ class: 'error' }, [
1954
+ ifNode(welcomeQuery.hasException, () =>
1955
+ p(${message}[
538
1956
  span(function* () {
539
1957
  return 'API error: ' + (yield* welcomeQuery.errorMessage());
540
1958
  }),
@@ -543,50 +1961,68 @@ export const HomePage = craftComponent(
543
1961
  ]),
544
1962
  );
545
1963
 
546
- export type HomePageError = WelcomeApiError;
1964
+ export type HomePageError = unknown;
547
1965
 
548
1966
  export default HomePage;
549
1967
  `;
550
- const plainAppConfig = appConfigTs;
551
- const effectAppConfig = `import { provideCraftRootComponent } from '@craft-ts/component';
1968
+ }
1969
+ function effectAppConfig(context) {
1970
+ const i18n = context.config.i18n.enabled;
1971
+ return `import { provideCraftRootComponent } from '@craft-ts/component';
552
1972
  import {
553
1973
  craftAppConfig,
554
1974
  provideAppInitializer,
1975
+ provideCraftDevTools,
555
1976
  provideCraftRouter,
1977
+ ${context.config.backendRuntime !== 'none' ? ' provideDefaultServerFunctionTransport,\n' : ''}
556
1978
  } from '@craft-ts/core';
557
1979
  import {
558
1980
  installCraftEffectBridge,
559
1981
  provideLayer,
560
1982
  } from '@craft-ts/effect';
1983
+ ${i18n ? "import { Layer } from 'effect';" : ''}
561
1984
  import { App } from './app';
562
1985
  import { appRoutes } from './app.routes';
1986
+ ${i18n ? "import { i18nLayer } from '../i18n/effect-layer';" : ''}
563
1987
  import {
564
1988
  WelcomeRepositoryLive,
565
1989
  } from './domain';
566
1990
 
567
- const effectProviders = [provideLayer(WelcomeRepositoryLive)] as const;
1991
+ const effectProviders = provideLayer(${i18n ? 'Layer.mergeAll(WelcomeRepositoryLive, i18nLayer)' : 'WelcomeRepositoryLive'});
1992
+ const developmentProviders = import.meta.env.DEV ? provideCraftDevTools() : [];
568
1993
 
569
1994
  export const appConfig = craftAppConfig({
570
1995
  routingDeps: appRoutes.META_PATHS,
571
1996
  providers: [
1997
+ ...developmentProviders,
572
1998
  provideCraftRootComponent(App),
573
1999
  provideCraftRouter(appRoutes.toRoutes()),
574
- ...effectProviders,
2000
+ ${context.config.backendRuntime !== 'none' ? ' provideDefaultServerFunctionTransport(),\n' : ''}
2001
+ effectProviders,
575
2002
  provideAppInitializer(() => installCraftEffectBridge()),
576
2003
  ],
577
2004
  });
578
2005
 
579
- // effectProviders is a typed tuple: removing the Layer makes the app config
580
- // fail at the provider boundary before the application can boot.
2006
+ // Keep all application layers inside one provider: provideLayer replaces the
2007
+ // level when registered twice, so separate providers would drop one layer.
581
2008
  `;
2009
+ }
582
2010
  function unitTestTs(context) {
583
2011
  const effect = context.mode === 'effect';
2012
+ const hasServer = context.config.backendRuntime !== 'none';
2013
+ const expectedTitle = hasServer ? 'Hello from the API' : 'Local welcome';
2014
+ const serverMock = hasServer
2015
+ ? `vi.mock('../starter.fn-client', () => ({
2016
+ getStarterMessage: vi.fn().mockResolvedValue({ title: 'Hello from the API', body: 'Server function works.' }),
2017
+ }));
2018
+ `
2019
+ : '';
584
2020
  return `// @vitest-environment jsdom
585
2021
  import { mountCraftComponent } from '@craft-ts/component';
586
2022
  import { TestBed, ɵInjector as Injector } from '@craft-ts/core';
587
2023
  ${effect ? "import { installCraftEffectBridge, provideLayer } from '@craft-ts/effect';\nimport { WelcomeRepositoryLive } from './domain';" : ''}
588
2024
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
589
- import { HomePage } from './home-page';
2025
+ ${serverMock}import { HomePage } from './home-page';
590
2026
 
591
2027
  describe('HomePage', () => {
592
2028
  ${effect ? 'let disposeBridge: () => void;' : ''}
@@ -594,11 +2030,6 @@ describe('HomePage', () => {
594
2030
  TestBed.resetTestingModule();
595
2031
  document.body.replaceChildren();
596
2032
  ${effect ? 'disposeBridge = installCraftEffectBridge();' : ''}
597
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
598
- new Response(JSON.stringify({ title: 'Hello from the API', body: 'Typed transport works.' }), {
599
- headers: { 'content-type': 'application/json' },
600
- }),
601
- ));
602
2033
  });
603
2034
 
604
2035
  afterEach(() => {
@@ -613,55 +2044,238 @@ describe('HomePage', () => {
613
2044
  const injector = ${effect ? 'TestBed.rootInjector.createChild([provideLayer(WelcomeRepositoryLive)])' : 'TestBed.rootInjector'};
614
2045
  const mounted = mountCraftComponent(HomePage, host, injector as unknown as Injector);
615
2046
  TestBed.tick();
616
- await vi.waitFor(() => expect(host.textContent).toContain('Hello from the API'));
2047
+ await vi.waitFor(() => expect(host.textContent).toContain('${expectedTitle}'));
617
2048
  mounted.destroy();
618
2049
  });
619
2050
  });
620
2051
  `;
621
2052
  }
622
- const e2eTestTs = `import { expect, test } from '@playwright/test';
2053
+ function e2eTestTs(context) {
2054
+ const hasServer = context.config.backendRuntime !== 'none';
2055
+ const expectedTitle = hasServer ? 'Hello from the server' : 'Local welcome';
2056
+ const routeMock = hasServer ? '' : '';
2057
+ return `import { expect, test } from '@playwright/test';
623
2058
 
624
- test.beforeEach(async ({ page }) => {
625
- await page.route('**/api/welcome', async (route) =>
626
- route.fulfill({
627
- status: 200,
628
- contentType: 'application/json',
629
- body: JSON.stringify({ title: 'Hello from the API', body: 'E2E transport works.' }),
630
- }),
631
- );
632
- });
2059
+ ${routeMock}
633
2060
 
634
2061
  test('loads the API page and navigates to About', async ({ page }) => {
635
2062
  await page.goto('/');
636
2063
  await expect(page.getByRole('heading', { name: /Welcome to CraftTS/ })).toBeVisible();
637
- await expect(page.getByText('Hello from the API')).toBeVisible();
2064
+ await expect(page.getByText('${expectedTitle}')).toBeVisible();
2065
+ await page.getByRole('link', { name: 'Services' }).click();
2066
+ await expect(page.getByRole('heading', { name: 'Services' })).toBeVisible();
638
2067
  await page.getByRole('link', { name: 'About' }).click();
639
2068
  await expect(page.getByRole('heading', { name: 'About this starter' })).toBeVisible();
640
2069
  });
641
2070
  `;
2071
+ }
2072
+ function i18nE2eTestTs(context) {
2073
+ return `import { expect, test } from '@playwright/test';
2074
+
2075
+ const locales = ${JSON.stringify(context.locales)} as const;
2076
+
2077
+ for (const locale of locales) {
2078
+ test(\`renders the real starter page without layout overflow in \${locale}\`, async ({ page }) => {
2079
+ await page.goto('/?locale=' + locale);
2080
+ await page.evaluate(async () => { await document.fonts.ready; });
2081
+ await expect(page.locator('html')).toHaveAttribute('lang', locale);
2082
+ await expect(page.getByText('i18n:')).toBeVisible();
2083
+ const layout = await page.evaluate(() => ({
2084
+ viewport: document.documentElement.clientWidth,
2085
+ body: document.body.scrollWidth,
2086
+ rootFont: getComputedStyle(document.documentElement).fontSize,
2087
+ bodyFont: getComputedStyle(document.body).fontFamily,
2088
+ bodyLineHeight: getComputedStyle(document.body).lineHeight,
2089
+ }));
2090
+ expect(layout.body).toBeLessThanOrEqual(layout.viewport);
2091
+ expect(layout.rootFont).toBeTruthy();
2092
+ expect(layout.bodyFont).toContain('system-ui');
2093
+ expect(layout.bodyLineHeight).not.toBe('normal');
2094
+ });
2095
+ }
2096
+ `;
2097
+ }
642
2098
  const playwrightConfig = `import { defineConfig, devices } from '@playwright/test';
643
2099
 
2100
+ const starterPort = Number(process.env.CRAFT_STARTER_PORT ?? 4173);
2101
+
644
2102
  export default defineConfig({
645
2103
  testDir: './e2e',
646
- use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
2104
+ use: { baseURL: 'http://127.0.0.1:' + starterPort, ...devices['Desktop Chrome'] },
647
2105
  webServer: {
648
2106
  command: 'npm run dev',
649
- url: 'http://127.0.0.1:4173',
650
- reuseExistingServer: !process.env.CI,
2107
+ url: 'http://127.0.0.1:' + starterPort,
2108
+ reuseExistingServer: false,
651
2109
  },
652
2110
  });
653
2111
  `;
2112
+ const mcpConfig = `{
2113
+ "mcpServers": {
2114
+ "craft-ts": { "command": "npx", "args": ["craft-ts-mcp"] },
2115
+ "craft-ts-logs": { "command": "npx", "args": ["craft-ts-log-mcp"] },
2116
+ "craft-ts-registry": { "command": "npx", "args": ["craft-ts-registry-mcp"] }
2117
+ }
2118
+ }
2119
+ `;
2120
+ function githubWorkflow(context) {
2121
+ const effect = context.config.frontendRuntime === 'effect' ||
2122
+ context.config.backendRuntime === 'effect';
2123
+ const i18n = context.config.i18n.enabled;
2124
+ const typedCss = context.config.typedCss;
2125
+ const server = context.config.backendRuntime !== 'none';
2126
+ return `name: CI
2127
+
2128
+ on:
2129
+ push:
2130
+ branches: [main]
2131
+ pull_request:
2132
+
2133
+ permissions:
2134
+ contents: read
2135
+
2136
+ jobs:
2137
+ quality:
2138
+ runs-on: ubuntu-latest
2139
+ steps:
2140
+ - uses: actions/checkout@v4
2141
+ - uses: actions/setup-node@v4
2142
+ with:
2143
+ node-version: 20.19.0
2144
+ cache: npm
2145
+ - run: npm ci
2146
+ - run: npm run lint
2147
+ - run: npm run typecheck
2148
+ - run: npm run typecheck-spec
2149
+ ${i18n ? ' - run: npm run i18n:check\n - run: npm run i18n:test\n' : ''}${typedCss ? ' - run: npm run style:check\n' : ''}${server ? ' - run: npm run server:test\n' : ''}
2150
+ ${effect ? ' - run: npm run effect-check\n' : ''} - run: npm test
2151
+ - run: npm run architecture
2152
+ - run: npm run typecheck-architecture
2153
+ - run: npm run build
2154
+ `;
2155
+ }
654
2156
  function readme(context) {
655
- const effect = context.mode === 'effect';
656
- return `# ${context.projectName}\n\nA framework-independent CraftTS starter generated by craft create.\n\nThis project includes:\n\n- a routed Craft component page;\n- a typed CraftHttpClient API boundary${effect ? ' behind an Effect v4 repository service and Layer' : ''};\n- flat-config ESLint with Craft rules;\n- unit tests, graph-wide architecture tests and a Playwright E2E flow.\n\n## Start\n\nnpm install\nnpm run dev\n\n## Verify\n\nnpm run lint\nnpm run typecheck\nnpm run typecheck-spec\n${effect ? 'npm run effect-check\n' : ''}npm test\nnpm run architecture\nnpm run e2e\nnpm run build\n\n### Architecture tests\n\narchitecture/ is the dependency-graph contract. It runs without booting the\nbrowser and catches duplicate HTTP ownership, dependency cycles, declarative\ngraph violations, unnamed interactive elements and missing route DI proofs.\nRun it after structural changes. Add a rule only for a recurring product\ninvariant that the baseline helpers do not cover; do not add one test per\nfeature.\n\nThe generated catalog is refreshed by architecture/load-graph.ts; do not edit\narchitecture/catalog.ts by hand.\n`;
2157
+ const effect = context.config.frontendRuntime === 'effect' ||
2158
+ context.config.backendRuntime === 'effect';
2159
+ const i18n = context.config.i18n.enabled;
2160
+ const typedCss = context.config.typedCss;
2161
+ const designSystem = context.config.designSystem !== 'none';
2162
+ const server = context.config.backendRuntime !== 'none';
2163
+ const references = context.config.references.craftTs || context.config.references.effectTs;
2164
+ const referencePath = context.config.workspace.kind === 'nx'
2165
+ ? '../../.references'
2166
+ : '.references';
2167
+ return [
2168
+ `# ${context.projectName}`,
2169
+ '',
2170
+ 'A framework-independent CraftTS starter generated by craft create.',
2171
+ '',
2172
+ 'This project includes:',
2173
+ '',
2174
+ '- a routed Craft component page;',
2175
+ `- a ${server ? 'server-function' : 'typed CraftHttpClient'} API boundary${effect ? ' with an Effect v4 repository service and Layer' : ''};`,
2176
+ '- three lazy routes: `/`, `/services` and `/about`;',
2177
+ ...(designSystem
2178
+ ? [
2179
+ `- a ${typedCss ? 'typed CSS' : 'plain CSS'} design-system composition;`,
2180
+ ]
2181
+ : []),
2182
+ '- development logs forwarded from Craft Console.* to a local JSONL server;',
2183
+ '- MCP configuration for Craft guidance, logs and the browser page surface;',
2184
+ '- flat-config ESLint with Craft rules;',
2185
+ '- unit tests, graph-wide architecture tests and a Playwright E2E flow.',
2186
+ ...(i18n
2187
+ ? [
2188
+ '- a type-safe i18n catalogue with locale parity, semantic tokens and strict plural checks.',
2189
+ ]
2190
+ : []),
2191
+ ...(references
2192
+ ? [
2193
+ `- cloned CraftTS/EffectTS source references for coding agents under \`${referencePath}/\`; run \`npm run update:references\` to refresh them.`,
2194
+ ]
2195
+ : []),
2196
+ '',
2197
+ '## Start',
2198
+ '',
2199
+ 'npm install',
2200
+ 'npm run logs:server',
2201
+ 'npm run registry:mcp',
2202
+ 'npm run dev',
2203
+ '',
2204
+ 'The browser type-check runs beside Vite. Its status is visible in the page;',
2205
+ 'the same command writes the status used by CI. Raw console.* calls are not',
2206
+ 'forwarded to the log MCP server: use Craft Console.* for searchable entries.',
2207
+ '',
2208
+ '## MCP',
2209
+ '',
2210
+ 'The generated .mcp.json registers the Craft documentation server, the local',
2211
+ 'log reader and the browser registry/page server. Start logs:server and',
2212
+ 'registry:mcp when using the corresponding MCP tools.',
2213
+ '',
2214
+ '## Verify',
2215
+ '',
2216
+ 'npm run lint',
2217
+ 'npm run typecheck',
2218
+ 'npm run typecheck-spec',
2219
+ ...(i18n ? ['npm run i18n:check', 'npm run i18n:test'] : []),
2220
+ ...(typedCss ? ['npm run style:check'] : []),
2221
+ ...(server ? ['npm run server:test'] : []),
2222
+ ...(effect ? ['npm run effect-check'] : []),
2223
+ 'npm test',
2224
+ 'npm run architecture',
2225
+ 'npm run typecheck-architecture',
2226
+ 'npm run e2e',
2227
+ 'npm run build',
2228
+ '',
2229
+ ...(i18n
2230
+ ? [
2231
+ '## i18n',
2232
+ '',
2233
+ '',
2234
+ 'Add or change translation keys in src/i18n/catalog.ts. Add a locale under',
2235
+ 'src/i18n/locales/ with defineLocaleLike so keys, parameters and token kinds',
2236
+ 'stay aligned. Define application tokens in project-tokens.ts with',
2237
+ 'defineToken or defineTokenFactory; the plain i18n package does not require',
2238
+ 'Effect. Strict plural categories and locale parity are checked by',
2239
+ 'npm run i18n:check. Use npm run i18n:test for runtime and page fixtures.',
2240
+ ]
2241
+ : []),
2242
+ ...(references
2243
+ ? [
2244
+ '## References',
2245
+ '',
2246
+ `The \`${referencePath}/manifest.json\` file records the requested refs and resolved SHAs.`,
2247
+ 'The cloned repositories are read-only context for coding agents; the application always uses the npm dependencies declared in package.json.',
2248
+ 'Do not add file: dependencies or TypeScript/Vite aliases to the clones.',
2249
+ 'Run `npm run update:references` after reviewing local changes in a clone.',
2250
+ ]
2251
+ : []),
2252
+ '',
2253
+ '### Architecture tests',
2254
+ '',
2255
+ 'architecture/ is the dependency-graph contract. It runs without booting the',
2256
+ 'browser and catches duplicate HTTP ownership, dependency cycles, declarative',
2257
+ 'graph violations, unnamed interactive elements and missing route DI proofs.',
2258
+ 'Run it after structural changes. Add a rule only for a recurring product',
2259
+ 'invariant that the baseline helpers do not cover; do not add one test per',
2260
+ 'feature.',
2261
+ '',
2262
+ 'The generated catalog is refreshed by architecture/load-graph.ts; do not edit',
2263
+ 'architecture/catalog.ts by hand.',
2264
+ '',
2265
+ ].join('\n');
657
2266
  }
658
- function agentFiles(mode, agent) {
659
- const skill = `${BASE_AGENT_SKILL}${mode === 'effect' ? `\n${EFFECT_AGENT_SKILL}` : ''}`;
2267
+ function agentFiles(mode, agent, config) {
2268
+ const effectEnabled = config?.frontendRuntime === 'effect' ||
2269
+ config?.backendRuntime === 'effect' ||
2270
+ mode === 'effect';
2271
+ const baseSkill = config?.i18n.enabled === false
2272
+ ? BASE_AGENT_SKILL.replace(/4\. Keep translations[\s\S]*?5\. Keep visual/, '5. Keep visual')
2273
+ : BASE_AGENT_SKILL;
2274
+ const skill = `${baseSkill}${effectEnabled ? `\n${EFFECT_AGENT_SKILL}` : ''}${referenceAgentGuidance(config)}`;
660
2275
  if (agent === 'codex') {
661
2276
  return {
662
- 'AGENTS.md': AGENTS_MD(mode),
663
2277
  '.agents/skills/craft-ts-project/SKILL.md': skill,
664
- ...(mode === 'effect'
2278
+ ...(effectEnabled
665
2279
  ? { '.agents/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
666
2280
  : {}),
667
2281
  };
@@ -674,48 +2288,138 @@ function agentFiles(mode, agent) {
674
2288
  }
675
2289
  if (agent === 'claude-code') {
676
2290
  return {
677
- 'CLAUDE.md': AGENTS_MD(mode),
2291
+ 'CLAUDE.md': AGENTS_MD(mode, config),
678
2292
  '.claude/skills/craft-ts-project/SKILL.md': skill,
679
- ...(mode === 'effect'
2293
+ ...(effectEnabled
680
2294
  ? { '.claude/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
681
2295
  : {}),
682
2296
  };
683
2297
  }
684
2298
  return {
685
- 'GEMINI.md': AGENTS_MD(mode),
2299
+ 'GEMINI.md': AGENTS_MD(mode, config),
686
2300
  '.gemini/skills/craft-ts-project/SKILL.md': skill,
687
- ...(mode === 'effect'
2301
+ ...(effectEnabled
688
2302
  ? { '.gemini/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
689
2303
  : {}),
690
2304
  };
691
2305
  }
692
2306
  function templates(context) {
693
- const effect = context.mode === 'effect';
694
- return {
2307
+ const effect = context.config.frontendRuntime === 'effect';
2308
+ const hasEffect = effect || context.config.backendRuntime === 'effect';
2309
+ const hasServer = context.config.backendRuntime !== 'none';
2310
+ const hasI18n = context.config.i18n.enabled;
2311
+ const hasDesignSystem = context.config.designSystem !== 'none';
2312
+ const typedCss = context.config.typedCss;
2313
+ const localeFiles = Object.fromEntries(hasI18n
2314
+ ? context.locales.map((locale, index) => [
2315
+ `src/i18n/locales/${locale}.ts`,
2316
+ i18nLocaleTs(locale, index, context.locales),
2317
+ ])
2318
+ : []);
2319
+ const files = {
695
2320
  'package.json': packageJson(context),
2321
+ 'AGENTS.md': AGENTS_MD(context.mode, context.config),
2322
+ '.gitignore': GENERATED_GITIGNORE,
696
2323
  'tsconfig.json': tsconfig(context),
697
2324
  'tsconfig.app.json': tsconfigApp,
698
2325
  'tsconfig.spec.json': tsconfigSpec,
699
- ...(effect ? { 'tsconfig.effect.json': tsconfigEffect } : {}),
700
- ...(effect ? { 'scripts/run-effect-tsgo.mjs': effectTsgoRunner } : {}),
701
- 'vite.config.ts': viteConfig,
2326
+ ...(hasEffect
2327
+ ? {
2328
+ 'tsconfig.effect.json': tsconfigEffect,
2329
+ 'scripts/run-effect-tsgo.mjs': effectTsgoRunner,
2330
+ }
2331
+ : {}),
2332
+ ...(typedCss ? { 'scripts/style-check.mjs': styleCheckScript } : {}),
2333
+ ...(hasServer ? { 'tsconfig.server.json': tsconfigServer } : {}),
2334
+ 'scripts/typecheck.mjs': typecheckScript(context),
2335
+ 'scripts/dev.mjs': devScript,
2336
+ 'vite.config.ts': viteConfig(context),
702
2337
  'vitest.config.ts': vitestConfig,
703
- 'eslint.config.mjs': eslintConfig,
2338
+ 'eslint.config.mjs': eslintConfig(effect, context.config.backendRuntime === 'effect'),
704
2339
  'playwright.config.ts': playwrightConfig,
705
- 'index.html': indexHtml,
2340
+ 'index.html': indexHtml(context.defaultLocale),
2341
+ '.mcp.json': mcpConfig,
2342
+ '.github/workflows/ci.yml': githubWorkflow(context),
706
2343
  'README.md': readme(context),
707
- 'src/main.ts': mainTs,
708
- 'src/styles.css': styles,
709
- 'src/types.d.ts': '/// <reference types="vite/client" />\n',
710
- 'src/app/app.ts': appTs,
711
- 'src/app/app.config.ts': effect ? effectAppConfig : plainAppConfig,
712
- 'src/app/app.routes.ts': routesTs,
713
- ...(effect ? { 'src/app/api.ts': "export { loadWelcome } from './domain';\nexport type { WelcomeResponse } from './domain';\n" } : { 'src/app/api.ts': apiTs }),
714
- ...(effect ? { 'src/app/domain.ts': effectDomainTs, 'src/app/home-page.ts': effectHomePageTs } : { 'src/app/home-page.ts': plainHomePageTs }),
715
- 'src/app/about-page.ts': aboutPageTs,
2344
+ 'src/main.ts': mainTs(context),
2345
+ 'src/dev-typecheck-indicator.ts': typecheckIndicatorTs,
2346
+ 'src/styles.css': stylesFor(context),
2347
+ ...(hasI18n
2348
+ ? {
2349
+ 'src/i18n/catalog.ts': i18nCatalogTs(context.locales),
2350
+ 'src/i18n/project-tokens.ts': projectTokensTs,
2351
+ 'src/i18n/runtime.ts': i18nRuntimeTs(context),
2352
+ 'src/i18n/typography.ts': typographyTs,
2353
+ 'src/i18n/index.ts': i18nIndexTs,
2354
+ }
2355
+ : {}),
2356
+ ...localeFiles,
2357
+ ...(effect && hasI18n
2358
+ ? {
2359
+ 'src/i18n/effect.ts': effectI18nTs,
2360
+ ...(effect ? { 'src/i18n/effect-layer.ts': effectLayerTs } : {}),
2361
+ }
2362
+ : {}),
2363
+ 'src/types.d.ts': '/// <reference types="vite/client" />\n' +
2364
+ (typedCss
2365
+ ? "\n// Served by the craftStyle plugin; it has no file on disk to resolve.\ndeclare module 'virtual:craft-style.css';\n"
2366
+ : ''),
2367
+ 'src/app/app.ts': appTs(context),
2368
+ 'src/app/app.config.ts': effect
2369
+ ? effectAppConfig(context)
2370
+ : plainAppConfig(context),
2371
+ 'src/app/app.routes.ts': routesTs(context),
2372
+ 'src/app/api.ts': effect && !hasServer
2373
+ ? "export { loadWelcome } from './domain';\nexport type { WelcomeResponse } from './domain';\n"
2374
+ : apiTs(context),
2375
+ ...(effect
2376
+ ? {
2377
+ 'src/app/domain.ts': effectDomainTs(context),
2378
+ 'src/app/home-page.ts': effectHomePageTs(context),
2379
+ }
2380
+ : { 'src/app/home-page.ts': plainHomePageTs(context) }),
2381
+ 'src/app/about-page.ts': aboutPageTs(context),
2382
+ 'src/app/services-page.ts': servicesPageTs(context),
716
2383
  'src/app/home-page.spec.ts': unitTestTs(context),
717
- 'e2e/starter.spec.ts': e2eTestTs,
2384
+ 'e2e/starter.spec.ts': e2eTestTs(context),
2385
+ ...(hasI18n ? { 'e2e/i18n.spec.ts': i18nE2eTestTs(context) } : {}),
718
2386
  };
2387
+ if (hasDesignSystem) {
2388
+ files[`src/app/ui/${typedCss ? 'ui.style.ts' : 'ui.ts'}`] = typedCss
2389
+ ? uiStyleTs
2390
+ : uiPlainTs;
2391
+ files['src/app/ui/components.ts'] = uiComponentsTs(context);
2392
+ }
2393
+ Object.assign(files, serverFiles(context));
2394
+ return files;
2395
+ }
2396
+ function nxProjectJson(context) {
2397
+ const projectRoot = `apps/${context.config.workspace.projectName}`;
2398
+ const run = (command) => ({
2399
+ executor: 'nx:run-commands',
2400
+ options: {
2401
+ command: `npm run ${context.config.workspace.projectName}:${command}`,
2402
+ cwd: '.',
2403
+ },
2404
+ });
2405
+ return json({
2406
+ name: context.config.workspace.projectName,
2407
+ $schema: '../../node_modules/nx/schemas/project-schema.json',
2408
+ sourceRoot: `${projectRoot}/src`,
2409
+ projectType: 'application',
2410
+ targets: {
2411
+ typecheck: run('typecheck'),
2412
+ test: run('test'),
2413
+ architecture: run('architecture'),
2414
+ 'typecheck-architecture': run('typecheck-architecture'),
2415
+ build: run('build'),
2416
+ e2e: run('e2e'),
2417
+ ...(context.config.references.craftTs ||
2418
+ context.config.references.effectTs
2419
+ ? { 'update-references': run('update:references') }
2420
+ : {}),
2421
+ },
2422
+ });
719
2423
  }
720
2424
  function normaliseAgent(agent) {
721
2425
  const value = agent.trim().toLowerCase();
@@ -736,27 +2440,217 @@ export function parseCreateAgents(value) {
736
2440
  return [];
737
2441
  return [...new Set(value.split(',').map(normaliseAgent))];
738
2442
  }
739
- export async function createCraftProject(options) {
740
- const rootDir = resolve(options.rootDir ?? process.cwd());
741
- const directory = resolve(rootDir, options.directory);
742
- const projectName = directory
2443
+ function projectNameForDirectory(directory) {
2444
+ return (directory
743
2445
  .split(/[\\/]/)
744
2446
  .at(-1)
745
- ?.replace(/[^a-zA-Z0-9._-]/g, '-') || 'craft-app';
746
- const mode = options.mode ?? 'plain';
747
- const agents = options.agents ?? DEFAULT_AGENTS;
748
- if (existsSync(directory) && !options.force && (await readdir(directory)).length > 0) {
2447
+ ?.replace(/[^a-zA-Z0-9._-]/g, '-') || 'craft-app');
2448
+ }
2449
+ /**
2450
+ * Turns CLI, legacy and interactive values into the one renderer contract.
2451
+ * Keeping this pure is intentional: prompts and filesystem writes live at the
2452
+ * edges, while all contradictions are caught before a single file is written.
2453
+ */
2454
+ export function normalizeCreateOptions(options) {
2455
+ const requestedRootDir = resolve(options.rootDir ?? process.cwd());
2456
+ const rootDir = !options.rootDir && isAbsolute(options.directory)
2457
+ ? dirname(resolve(options.directory))
2458
+ : requestedRootDir;
2459
+ const requestedMode = options.mode;
2460
+ let frontendRuntime = options.frontendRuntime ?? requestedMode ?? 'plain';
2461
+ let backendRuntime = options.backendRuntime ?? 'none';
2462
+ if (options.effectScope) {
2463
+ const scoped = {
2464
+ none: ['plain', 'none'],
2465
+ frontend: ['effect', 'none'],
2466
+ backend: ['plain', 'effect'],
2467
+ both: ['effect', 'effect'],
2468
+ };
2469
+ const selectedScope = scoped[options.effectScope];
2470
+ if (!selectedScope)
2471
+ throw new Error(`Unknown effect scope "${options.effectScope}".`);
2472
+ const [scopedFrontend, scopedBackend] = selectedScope;
2473
+ if (options.frontendRuntime && options.frontendRuntime !== scopedFrontend) {
2474
+ throw new Error('--effect-scope conflicts with --frontend-runtime.');
2475
+ }
2476
+ if (options.backendRuntime && options.backendRuntime !== scopedBackend) {
2477
+ throw new Error('--effect-scope conflicts with --backend-runtime.');
2478
+ }
2479
+ frontendRuntime = scopedFrontend;
2480
+ backendRuntime = scopedBackend;
2481
+ }
2482
+ if (frontendRuntime === 'effect' &&
2483
+ requestedMode === 'plain' &&
2484
+ options.frontendRuntime === undefined) {
2485
+ frontendRuntime = 'plain';
2486
+ }
2487
+ if (!['plain', 'effect'].includes(frontendRuntime)) {
2488
+ throw new Error(`Unknown frontend runtime "${frontendRuntime}".`);
2489
+ }
2490
+ if (!['none', 'promise', 'effect'].includes(backendRuntime)) {
2491
+ throw new Error(`Unknown backend runtime "${backendRuntime}".`);
2492
+ }
2493
+ if (options.i18n !== undefined &&
2494
+ !['strict', 'loose', 'none'].includes(options.i18n)) {
2495
+ throw new Error(`Unknown i18n mode "${options.i18n}".`);
2496
+ }
2497
+ if (options.designSystem !== undefined &&
2498
+ !['none', 'basic'].includes(options.designSystem)) {
2499
+ throw new Error(`Unknown design system "${options.designSystem}".`);
2500
+ }
2501
+ if (options.workspace !== undefined &&
2502
+ !['standalone', 'nx'].includes(options.workspace)) {
2503
+ throw new Error(`Unknown workspace "${options.workspace}".`);
2504
+ }
2505
+ const i18nEnabled = options.i18nEnabled ?? options.i18n !== 'none';
2506
+ const hasExplicitLocaleOptions = options.locales !== undefined || options.defaultLocale !== undefined;
2507
+ if (!i18nEnabled && hasExplicitLocaleOptions) {
2508
+ throw new Error('--locales and --default-locale require i18n to be enabled.');
2509
+ }
2510
+ const locales = i18nEnabled
2511
+ ? [...new Set(options.locales ?? ['en-US', 'fr-FR'])]
2512
+ : [];
2513
+ if (i18nEnabled && locales.length === 0) {
2514
+ throw new Error('At least one i18n locale is required.');
2515
+ }
2516
+ const defaultLocale = options.defaultLocale ?? locales[0] ?? 'en-US';
2517
+ if (i18nEnabled && !locales.includes(defaultLocale)) {
2518
+ throw new Error(`Default locale "${defaultLocale}" is not in --locales.`);
2519
+ }
2520
+ const references = options.references ?? 'none';
2521
+ if (!['none', 'craft-ts', 'all'].includes(references)) {
2522
+ throw new Error(`Unknown references selection "${references}".`);
2523
+ }
2524
+ if (options.referenceMode !== undefined &&
2525
+ options.referenceMode !== 'context') {
2526
+ throw new Error(`Reference mode "${options.referenceMode}" is no longer supported; cloned references are context only and npm packages remain the runtime dependencies.`);
2527
+ }
2528
+ const craftTs = options.cloneCraftTs ?? (references === 'craft-ts' || references === 'all');
2529
+ const effectTs = options.cloneEffectTs ?? references === 'all';
2530
+ if (effectTs && frontendRuntime !== 'effect' && backendRuntime !== 'effect') {
2531
+ throw new Error('EffectTS references require an Effect frontend or backend runtime.');
2532
+ }
2533
+ const directory = resolve(rootDir, options.directory);
2534
+ const workspaceKind = options.workspace ??
2535
+ (existsSync(join(rootDir, 'nx.json')) ? 'nx' : 'standalone');
2536
+ const projectName = projectNameForDirectory(directory);
2537
+ return {
2538
+ ...(requestedMode ? { mode: requestedMode } : {}),
2539
+ frontendRuntime,
2540
+ backendRuntime,
2541
+ workspace: { kind: workspaceKind, projectName, rootDir },
2542
+ i18n: {
2543
+ enabled: i18nEnabled,
2544
+ locales,
2545
+ defaultLocale,
2546
+ validation: options.i18n === 'loose' ? 'loose' : 'strict',
2547
+ },
2548
+ designSystem: options.designSystem ?? 'basic',
2549
+ typedCss: options.typedCss ?? true,
2550
+ references: {
2551
+ craftTs,
2552
+ effectTs,
2553
+ mode: 'context',
2554
+ craftTsRef: options.craftTsRef ?? 'main',
2555
+ effectTsRef: options.effectTsRef ?? 'main',
2556
+ },
2557
+ agents: options.agents ?? DEFAULT_AGENTS,
2558
+ };
2559
+ }
2560
+ export async function createCraftProject(options) {
2561
+ const config = normalizeCreateOptions(options);
2562
+ const rootDir = config.workspace.rootDir;
2563
+ const directory = resolve(rootDir, options.directory);
2564
+ const mode = config.frontendRuntime;
2565
+ const agents = config.agents;
2566
+ const context = {
2567
+ projectName: config.workspace.projectName,
2568
+ config,
2569
+ mode,
2570
+ locales: config.i18n.locales,
2571
+ defaultLocale: config.i18n.defaultLocale,
2572
+ i18nStrict: config.i18n.validation === 'strict',
2573
+ packageVersion: options.packageVersion ?? process.env['CRAFT_RELEASE_VERSION'],
2574
+ };
2575
+ if (existsSync(directory) &&
2576
+ !options.force &&
2577
+ (await readdir(directory)).length > 0) {
749
2578
  throw new Error(`Refusing to create in non-empty directory: ${directory}. Use --force to merge.`);
750
2579
  }
751
2580
  const files = {
752
- ...templates({ projectName, mode }),
753
- ...agents.reduce((all, agent) => Object.assign(all, agentFiles(mode, agent)), {}),
2581
+ ...templates(context),
2582
+ ...referenceFiles(context),
2583
+ ...agents.reduce((all, agent) => Object.assign(all, agentFiles(mode, agent, config)), {}),
754
2584
  };
2585
+ if (config.workspace.kind === 'nx') {
2586
+ delete files['package.json'];
2587
+ delete files['.references/manifest.json'];
2588
+ delete files['.gitignore'];
2589
+ delete files['scripts/reference-resolver.mjs'];
2590
+ delete files['scripts/update-references.mjs'];
2591
+ delete files['scripts/update-craft-ts.mjs'];
2592
+ delete files['scripts/update-effect-ts.mjs'];
2593
+ files['project.json'] = nxProjectJson({
2594
+ ...context,
2595
+ config: {
2596
+ ...config,
2597
+ workspace: {
2598
+ ...config.workspace,
2599
+ projectName: config.workspace.projectName,
2600
+ },
2601
+ },
2602
+ });
2603
+ }
755
2604
  await Promise.all(Object.entries(files).map(async ([file, contents]) => {
756
2605
  const target = join(directory, file);
2606
+ if (file === '.gitignore' && existsSync(target))
2607
+ return;
757
2608
  await mkdir(dirname(target), { recursive: true });
758
2609
  await writeFile(target, contents, 'utf8');
759
2610
  }));
2611
+ if (config.workspace.kind === 'nx') {
2612
+ const rootPackagePath = join(rootDir, 'package.json');
2613
+ const rootPackage = existsSync(rootPackagePath)
2614
+ ? JSON.parse(await readFile(rootPackagePath, 'utf8'))
2615
+ : {
2616
+ name: config.workspace.projectName,
2617
+ private: true,
2618
+ type: 'module',
2619
+ };
2620
+ const generatedPackage = JSON.parse(packageJson(context));
2621
+ rootPackage.dependencies = {
2622
+ ...rootPackage.dependencies,
2623
+ ...generatedPackage.dependencies,
2624
+ };
2625
+ rootPackage.devDependencies = {
2626
+ ...rootPackage.devDependencies,
2627
+ ...generatedPackage.devDependencies,
2628
+ };
2629
+ rootPackage.scripts = {
2630
+ ...rootPackage.scripts,
2631
+ ...Object.fromEntries(Object.entries(generatedPackage.scripts ?? {}).map(([name]) => [
2632
+ `${config.workspace.projectName}:${name}`,
2633
+ `npm --prefix ${relative(rootDir, directory)} run ${name.replace(/:/g, '\\:')}`,
2634
+ ])),
2635
+ };
2636
+ await writeFile(rootPackagePath, json(rootPackage), 'utf8');
2637
+ const nxJsonPath = join(rootDir, 'nx.json');
2638
+ if (!existsSync(nxJsonPath))
2639
+ await writeFile(nxJsonPath, json({ plugins: [] }), 'utf8');
2640
+ const rootReferenceFiles = referenceFiles(context);
2641
+ await Promise.all(Object.entries(rootReferenceFiles).map(async ([file, contents]) => {
2642
+ const target = join(rootDir, file);
2643
+ if (file === '.gitignore' && existsSync(target))
2644
+ return;
2645
+ await mkdir(dirname(target), { recursive: true });
2646
+ await writeFile(target, contents, 'utf8');
2647
+ }));
2648
+ }
2649
+ const gitRoot = config.workspace.kind === 'nx' ? rootDir : directory;
2650
+ ensureGitignore(gitRoot);
2651
+ if (config.references.craftTs || config.references.effectTs) {
2652
+ cloneReferenceIfRequested(config.workspace.kind === 'nx' ? rootDir : directory, config);
2653
+ }
760
2654
  // Reuse the same deterministic scaffold as migration, but with a standalone
761
2655
  // project root. It also adds architecture/typecheck scripts to package.json.
762
2656
  await runArchitectureMigration({
@@ -764,9 +2658,13 @@ export async function createCraftProject(options) {
764
2658
  tsConfigFilePath: join(directory, 'tsconfig.app.json'),
765
2659
  write: true,
766
2660
  });
2661
+ initialiseGitRepository(gitRoot);
767
2662
  return {
768
2663
  directory,
769
2664
  mode,
2665
+ frontendRuntime: config.frontendRuntime,
2666
+ backendRuntime: config.backendRuntime,
2667
+ config,
770
2668
  agents,
771
2669
  changedFiles: Object.keys(files).map((file) => relative(directory, join(directory, file))),
772
2670
  };