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