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