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

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