@craft-ts/dev-tools 0.7.0-beta.13
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/LICENSE +21 -0
- package/README.md +154 -0
- package/generators.json +26 -0
- package/package.json +119 -0
- package/src/bin/craft-architecture-check.d.ts +2 -0
- package/src/bin/craft-architecture-check.js +51 -0
- package/src/bin/craft-architecture-check.js.map +1 -0
- package/src/bin/craft-brand.d.ts +2 -0
- package/src/bin/craft-brand.js +9 -0
- package/src/bin/craft-brand.js.map +1 -0
- package/src/bin/craft-graph.d.ts +2 -0
- package/src/bin/craft-graph.js +72 -0
- package/src/bin/craft-graph.js.map +1 -0
- package/src/bin/craft-migrate-architecture.d.ts +2 -0
- package/src/bin/craft-migrate-architecture.js +64 -0
- package/src/bin/craft-migrate-architecture.js.map +1 -0
- package/src/bin/craft-migrate-components.d.ts +2 -0
- package/src/bin/craft-migrate-components.js +67 -0
- package/src/bin/craft-migrate-components.js.map +1 -0
- package/src/bin/craft-migrate-primitives.d.ts +2 -0
- package/src/bin/craft-migrate-primitives.js +71 -0
- package/src/bin/craft-migrate-primitives.js.map +1 -0
- package/src/bin/craft-migrate-routes.d.ts +2 -0
- package/src/bin/craft-migrate-routes.js +77 -0
- package/src/bin/craft-migrate-routes.js.map +1 -0
- package/src/bin/craft-migrate-services.d.ts +2 -0
- package/src/bin/craft-migrate-services.js +75 -0
- package/src/bin/craft-migrate-services.js.map +1 -0
- package/src/bin/craft-migrate-template.d.ts +2 -0
- package/src/bin/craft-migrate-template.js +68 -0
- package/src/bin/craft-migrate-template.js.map +1 -0
- package/src/bin/craft-migrate.d.ts +2 -0
- package/src/bin/craft-migrate.js +91 -0
- package/src/bin/craft-migrate.js.map +1 -0
- package/src/bin/craft.d.ts +2 -0
- package/src/bin/craft.js +318 -0
- package/src/bin/craft.js.map +1 -0
- package/src/eslint-rules/anchor-has-href.cjs +35 -0
- package/src/eslint-rules/app-start-registry-match.cjs +355 -0
- package/src/eslint-rules/button-has-type.cjs +50 -0
- package/src/eslint-rules/control-has-accessible-name.cjs +56 -0
- package/src/eslint-rules/craft-component-name-match.cjs +8 -0
- package/src/eslint-rules/craft-computed-name-match.cjs +63 -0
- package/src/eslint-rules/craft-css-token-registry.cjs +17 -0
- package/src/eslint-rules/craft-css-var-naming.cjs +18 -0
- package/src/eslint-rules/craft-css-vars-contract.cjs +34 -0
- package/src/eslint-rules/craft-directive-name-match.cjs +8 -0
- package/src/eslint-rules/craft-method-name-match.cjs +8 -0
- package/src/eslint-rules/craft-name-match-utils.cjs +179 -0
- package/src/eslint-rules/craft-primitive-context.cjs +46 -0
- package/src/eslint-rules/craft-signal-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-source-name-match.cjs +8 -0
- package/src/eslint-rules/craft-styles-scope-safe.cjs +25 -0
- package/src/eslint-rules/css-rule-utils.cjs +109 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +562 -0
- package/src/eslint-rules/heading-has-content.cjs +31 -0
- package/src/eslint-rules/html-helpers.cjs +115 -0
- package/src/eslint-rules/hyperscript-walk.cjs +249 -0
- package/src/eslint-rules/iframe-has-title.cjs +26 -0
- package/src/eslint-rules/img-has-alt.cjs +28 -0
- package/src/eslint-rules/index.cjs +209 -0
- package/src/eslint-rules/index.d.cts +5 -0
- package/src/eslint-rules/label-has-associated-control.cjs +67 -0
- package/src/eslint-rules/no-async-await.cjs +46 -0
- package/src/eslint-rules/no-craft-computed-side-effects.cjs +301 -0
- package/src/eslint-rules/no-craft-use-in-template.cjs +89 -0
- package/src/eslint-rules/no-craft-use.cjs +42 -0
- package/src/eslint-rules/no-direct-temporal-globals.cjs +73 -0
- package/src/eslint-rules/no-effect-in-params.cjs +265 -0
- package/src/eslint-rules/no-effect-outside-loaders.cjs +3 -0
- package/src/eslint-rules/no-ephemeral-template-form-state.cjs +131 -0
- package/src/eslint-rules/no-hardcoded-design-values.cjs +15 -0
- package/src/eslint-rules/no-heading-level-skip.cjs +101 -0
- package/src/eslint-rules/no-imperative-craft-resource-trigger.cjs +291 -0
- package/src/eslint-rules/no-important-in-component-styles.cjs +12 -0
- package/src/eslint-rules/no-injection-token.cjs +173 -0
- package/src/eslint-rules/no-invalid-insertion-pipe.cjs +162 -0
- package/src/eslint-rules/no-manual-route-provider-list.cjs +85 -0
- package/src/eslint-rules/no-noninteractive-element-interactions.cjs +66 -0
- package/src/eslint-rules/no-positive-tabindex.cjs +44 -0
- package/src/eslint-rules/no-redundant-primitive-insertion.cjs +361 -0
- package/src/eslint-rules/no-render-writes.cjs +106 -0
- package/src/eslint-rules/no-throw.cjs +132 -0
- package/src/eslint-rules/no-transition-actions.cjs +133 -0
- package/src/eslint-rules/no-type-assertions-in-template.cjs +211 -0
- package/src/eslint-rules/no-widened-route-provider-context.cjs +39 -0
- package/src/eslint-rules/prefer-browser-boundaries.cjs +92 -0
- package/src/eslint-rules/prefer-craft-http-transport.cjs +103 -0
- package/src/eslint-rules/prefer-craft-template-blocks.cjs +492 -0
- package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +105 -0
- package/src/eslint-rules/prefer-named-html-helpers.cjs +33 -0
- package/src/eslint-rules/prefer-query-method-over-state-trigger.cjs +282 -0
- package/src/eslint-rules/prefer-relative-heading.cjs +43 -0
- package/src/eslint-rules/provide-host-name-match-component.cjs +455 -0
- package/src/eslint-rules/recommended-config.cjs +77 -0
- package/src/eslint-rules/require-assert-exhaustive-route-exceptions.cjs +259 -0
- package/src/eslint-rules/require-cascade-route-di-check.cjs +223 -0
- package/src/eslint-rules/require-child-route-mount-check.cjs +224 -0
- package/src/eslint-rules/require-component-monitoring.cjs +212 -0
- package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
- package/src/eslint-rules/require-craft-method-for-yieldable-callback.cjs +236 -0
- package/src/eslint-rules/require-craft-resource-trigger-yield.cjs +209 -0
- package/src/eslint-rules/require-effect-adapters.cjs +86 -0
- package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
- package/src/eslint-rules/require-focus-visible.cjs +36 -0
- package/src/eslint-rules/require-interactive-local-name.cjs +105 -0
- package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
- package/src/eslint-rules/require-outlet-heading-section.cjs +70 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +490 -0
- package/src/eslint-rules/require-primitive-context.cjs +156 -0
- package/src/eslint-rules/require-primitive-derived-property.cjs +598 -0
- package/src/eslint-rules/require-primitive-generator-unwrap.cjs +274 -0
- package/src/eslint-rules/require-reactive-template-bindings.cjs +305 -0
- package/src/eslint-rules/require-reduced-motion.cjs +38 -0
- package/src/eslint-rules/require-route-heading-outline.cjs +166 -0
- package/src/eslint-rules/require-yieldable-insertion-write.cjs +98 -0
- package/src/eslint-rules/require-yieldable-reactive-read.cjs +102 -0
- package/src/eslint-rules/require-yieldable-template-method.cjs +424 -0
- package/src/eslint-rules/role-has-required-aria.cjs +38 -0
- package/src/eslint-rules/server-function-client-match.cjs +283 -0
- package/src/eslint-rules/target-blank-noopener.cjs +31 -0
- package/src/eslint-rules/template-element-name-unique.cjs +124 -0
- package/src/eslint-rules/valid-aria.cjs +38 -0
- package/src/generators/route/compat.d.ts +3 -0
- package/src/generators/route/compat.js +6 -0
- package/src/generators/route/compat.js.map +1 -0
- package/src/generators/route/generator.d.ts +31 -0
- package/src/generators/route/generator.js +402 -0
- package/src/generators/route/generator.js.map +1 -0
- package/src/generators/route/schema.json +50 -0
- package/src/generators/route/split-schema.json +19 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/scripts/angular-brand-codemod.d.ts +139 -0
- package/src/scripts/angular-brand-codemod.js +2318 -0
- package/src/scripts/angular-brand-codemod.js.map +1 -0
- package/src/scripts/angular-brand-codemod.ts +3985 -0
- package/src/scripts/architecture/migrate-architecture.d.ts +14 -0
- package/src/scripts/architecture/migrate-architecture.js +534 -0
- package/src/scripts/architecture/migrate-architecture.js.map +1 -0
- package/src/scripts/architecture/migrate-architecture.ts +653 -0
- package/src/scripts/architecture-graph.d.ts +368 -0
- package/src/scripts/architecture-graph.js +2191 -0
- package/src/scripts/architecture-graph.js.map +1 -0
- package/src/scripts/architecture-graph.ts +3136 -0
- package/src/scripts/components/migrate-components.d.ts +20 -0
- package/src/scripts/components/migrate-components.js +163 -0
- package/src/scripts/components/migrate-components.js.map +1 -0
- package/src/scripts/components/migrate-components.ts +227 -0
- package/src/scripts/components/migration-diagnostic.d.ts +7 -0
- package/src/scripts/components/migration-diagnostic.js +2 -0
- package/src/scripts/components/migration-diagnostic.js.map +1 -0
- package/src/scripts/components/migration-diagnostic.ts +8 -0
- package/src/scripts/create/create-project.d.ts +20 -0
- package/src/scripts/create/create-project.js +783 -0
- package/src/scripts/create/create-project.js.map +1 -0
- package/src/scripts/create/create-project.ts +849 -0
- package/src/scripts/data-flow-graph.d.ts +36 -0
- package/src/scripts/data-flow-graph.js +186 -0
- package/src/scripts/data-flow-graph.js.map +1 -0
- package/src/scripts/data-flow-graph.ts +240 -0
- package/src/scripts/dependency-graph.d.ts +152 -0
- package/src/scripts/dependency-graph.js +4456 -0
- package/src/scripts/dependency-graph.js.map +1 -0
- package/src/scripts/dependency-graph.ts +5908 -0
- package/src/scripts/effect-dependency-graph.d.ts +89 -0
- package/src/scripts/effect-dependency-graph.js +487 -0
- package/src/scripts/effect-dependency-graph.js.map +1 -0
- package/src/scripts/effect-dependency-graph.ts +630 -0
- package/src/scripts/migrate.d.ts +37 -0
- package/src/scripts/migrate.js +98 -0
- package/src/scripts/migrate.js.map +1 -0
- package/src/scripts/migrate.ts +162 -0
- package/src/scripts/migration-workspace.d.ts +2 -0
- package/src/scripts/migration-workspace.js +67 -0
- package/src/scripts/migration-workspace.js.map +1 -0
- package/src/scripts/migration-workspace.ts +93 -0
- package/src/scripts/primitives/migrate-named-primitives.d.ts +50 -0
- package/src/scripts/primitives/migrate-named-primitives.js +487 -0
- package/src/scripts/primitives/migrate-named-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-named-primitives.ts +627 -0
- package/src/scripts/primitives/migrate-primitive-generators.d.ts +30 -0
- package/src/scripts/primitives/migrate-primitive-generators.js +309 -0
- package/src/scripts/primitives/migrate-primitive-generators.js.map +1 -0
- package/src/scripts/primitives/migrate-primitive-generators.ts +410 -0
- package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitives.js +427 -0
- package/src/scripts/primitives/migrate-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-primitives.ts +655 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.d.ts +23 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.js +382 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.js.map +1 -0
- package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +499 -0
- package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
- package/src/scripts/primitives/migration-diagnostic.js +2 -0
- package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
- package/src/scripts/primitives/migration-diagnostic.ts +15 -0
- package/src/scripts/routes/migrate-routes.d.ts +34 -0
- package/src/scripts/routes/migrate-routes.js +657 -0
- package/src/scripts/routes/migrate-routes.js.map +1 -0
- package/src/scripts/routes/migrate-routes.ts +895 -0
- package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
- package/src/scripts/routes/migration-diagnostic.js +2 -0
- package/src/scripts/routes/migration-diagnostic.js.map +1 -0
- package/src/scripts/routes/migration-diagnostic.ts +25 -0
- package/src/scripts/routes/route-command.d.ts +75 -0
- package/src/scripts/routes/route-command.js +1169 -0
- package/src/scripts/routes/route-command.js.map +1 -0
- package/src/scripts/routes/route-command.ts +1807 -0
- package/src/scripts/routes/verify-routes.d.ts +60 -0
- package/src/scripts/routes/verify-routes.js +1121 -0
- package/src/scripts/routes/verify-routes.js.map +1 -0
- package/src/scripts/routes/verify-routes.ts +1511 -0
- package/src/scripts/services/config.d.ts +2 -0
- package/src/scripts/services/config.js +38 -0
- package/src/scripts/services/config.js.map +1 -0
- package/src/scripts/services/config.ts +60 -0
- package/src/scripts/services/migrate-services.d.ts +29 -0
- package/src/scripts/services/migrate-services.js +924 -0
- package/src/scripts/services/migrate-services.js.map +1 -0
- package/src/scripts/services/migrate-services.ts +1348 -0
- package/src/scripts/services/migration-diagnostic.d.ts +8 -0
- package/src/scripts/services/migration-diagnostic.js +2 -0
- package/src/scripts/services/migration-diagnostic.js.map +1 -0
- package/src/scripts/services/migration-diagnostic.ts +28 -0
- package/src/scripts/template/migrate-template.d.ts +16 -0
- package/src/scripts/template/migrate-template.js +349 -0
- package/src/scripts/template/migrate-template.js.map +1 -0
- package/src/scripts/template/migrate-template.ts +418 -0
- package/src/template-migration.d.ts +1 -0
- package/src/template-migration.js +2 -0
- package/src/template-migration.js.map +1 -0
- package/src/testing.d.ts +19 -0
- package/src/testing.js +2 -0
- package/src/testing.js.map +1 -0
- package/src/tsconfig.codemod.json +13 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { runArchitectureMigration } from '../architecture/migrate-architecture.js';
|
|
5
|
+
|
|
6
|
+
export const CRAFT_TS_STARTER_VERSION = '^0.7.0-beta.11';
|
|
7
|
+
export const EFFECT_V4_VERSION = '^4.0.0-rc.110';
|
|
8
|
+
|
|
9
|
+
export type CreateAgent = 'codex' | 'cursor' | 'claude-code' | 'cloud-code';
|
|
10
|
+
export type CreateMode = 'effect' | 'plain';
|
|
11
|
+
|
|
12
|
+
export type CreateProjectOptions = {
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly rootDir?: string;
|
|
15
|
+
readonly mode?: CreateMode;
|
|
16
|
+
readonly agents?: readonly CreateAgent[];
|
|
17
|
+
readonly force?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CreateProjectResult = {
|
|
21
|
+
readonly directory: string;
|
|
22
|
+
readonly mode: CreateMode;
|
|
23
|
+
readonly agents: readonly CreateAgent[];
|
|
24
|
+
readonly changedFiles: readonly string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DEFAULT_AGENTS: readonly CreateAgent[] = ['codex'];
|
|
28
|
+
|
|
29
|
+
const BASE_AGENT_SKILL = `---
|
|
30
|
+
name: craft-ts-project
|
|
31
|
+
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.
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# CraftTS project
|
|
35
|
+
|
|
36
|
+
This project uses CraftTS without Angular. The renderer is @craft-ts/component,
|
|
37
|
+
the reactive runtime is @craft-ts/core, and the application entry point is
|
|
38
|
+
bootstrapCraft.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
1. Read the existing component, route and API boundary before editing.
|
|
43
|
+
2. Use query for server reads, mutation for writes, and CraftHttpClient
|
|
44
|
+
for transport. Yield every Craft reader.
|
|
45
|
+
3. Use craftComponent, craftRoutes, componentDeps and the route DI proof;
|
|
46
|
+
do not introduce Angular decorators or inject().
|
|
47
|
+
4. Run the focused test, then npm run lint, npm run typecheck,
|
|
48
|
+
npm run architecture, and npm run e2e when the browser flow changed.
|
|
49
|
+
|
|
50
|
+
## Architecture tests
|
|
51
|
+
|
|
52
|
+
The architecture/ suite is a graph-wide contract, not feature-level TDD. It
|
|
53
|
+
checks invariants such as one owner per HTTP endpoint, no dependency cycles,
|
|
54
|
+
declarative reactive code, named interactive elements and armed route DI.
|
|
55
|
+
Run it after each structural change. Do not add an architecture it() merely
|
|
56
|
+
because a new feature was added. Add a new rule only when a product invariant
|
|
57
|
+
must prevent a recurring graph smell and no baseline helper covers it.
|
|
58
|
+
|
|
59
|
+
The catalog is generated by architecture/load-graph.ts; never hand-edit
|
|
60
|
+
architecture/catalog.ts.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const EFFECT_AGENT_SKILL = `---
|
|
64
|
+
name: craft-ts-effect-v4
|
|
65
|
+
description: Build the Effect v4 integration of a framework-independent CraftTS project with typed services, Layers, queryEffect and Effect diagnostics. Use when editing an Effect-enabled starter or introducing Effect domain code.
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
# CraftTS + Effect v4
|
|
69
|
+
|
|
70
|
+
This project deliberately uses Effect v4 (effect@${EFFECT_V4_VERSION}) and
|
|
71
|
+
@craft-ts/effect. Keep the boundary explicit:
|
|
72
|
+
|
|
73
|
+
- domain operations return Effect.Effect and depend on Context.Service;
|
|
74
|
+
- production implementations are supplied by Layer;
|
|
75
|
+
- UI data loading uses queryEffect, never a component-side runPromise;
|
|
76
|
+
- installCraftEffectBridge() is installed once at bootstrap;
|
|
77
|
+
- run npm run effect-check after changing an Effect generator, service or
|
|
78
|
+
Layer, then run the architecture suite.
|
|
79
|
+
|
|
80
|
+
Do not silently replace Effect v4 APIs with v3 examples. Confirm a symbol in
|
|
81
|
+
the installed package before using it.
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const AGENTS_MD = (mode: CreateMode): string => `# CraftTS project
|
|
85
|
+
|
|
86
|
+
This project was created with \`craft create\` in **${mode === 'effect' ? 'Effect v4' : 'plain CraftTS'}** mode.
|
|
87
|
+
|
|
88
|
+
Read \`.agents/skills/craft-ts-project/SKILL.md\` before changing application
|
|
89
|
+
code. ${mode === 'effect' ? 'Effect-specific guidance is in `.agents/skills/craft-ts-effect-v4/SKILL.md`.' : ''}
|
|
90
|
+
|
|
91
|
+
The architecture suite is a graph contract. Run \`npm run architecture\`;
|
|
92
|
+
do not add a test per feature. Add a rule only for a recurring product-level
|
|
93
|
+
dependency smell not already covered by the baseline helpers.
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
type TemplateContext = {
|
|
97
|
+
readonly projectName: string;
|
|
98
|
+
readonly mode: CreateMode;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function json(value: unknown): string {
|
|
102
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function packageJson(context: TemplateContext): string {
|
|
106
|
+
const effect = context.mode === 'effect';
|
|
107
|
+
return json({
|
|
108
|
+
name: context.projectName,
|
|
109
|
+
private: true,
|
|
110
|
+
type: 'module',
|
|
111
|
+
engines: { node: '>=20.19.0' },
|
|
112
|
+
scripts: {
|
|
113
|
+
dev: 'vite --host 127.0.0.1',
|
|
114
|
+
build: 'vite build',
|
|
115
|
+
lint: 'eslint .',
|
|
116
|
+
typecheck: 'tsc -p tsconfig.app.json --noEmit',
|
|
117
|
+
'typecheck-spec': 'tsc -p tsconfig.spec.json --noEmit',
|
|
118
|
+
test: 'vitest run --config vitest.config.ts',
|
|
119
|
+
e2e: 'playwright test',
|
|
120
|
+
...(effect
|
|
121
|
+
? {
|
|
122
|
+
'effect-check':
|
|
123
|
+
'node scripts/run-effect-tsgo.mjs diagnostics --project tsconfig.effect.json --severity error,warning',
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
architecture: 'vitest run --config vitest.architecture.config.ts',
|
|
127
|
+
'typecheck-architecture':
|
|
128
|
+
'tsc -p tsconfig.architecture.json --noEmit',
|
|
129
|
+
},
|
|
130
|
+
dependencies: {
|
|
131
|
+
'@craft-ts/component': CRAFT_TS_STARTER_VERSION,
|
|
132
|
+
'@craft-ts/core': CRAFT_TS_STARTER_VERSION,
|
|
133
|
+
...(effect ? { '@craft-ts/effect': CRAFT_TS_STARTER_VERSION, effect: EFFECT_V4_VERSION } : {}),
|
|
134
|
+
},
|
|
135
|
+
devDependencies: {
|
|
136
|
+
'@craft-ts/dev-tools': CRAFT_TS_STARTER_VERSION,
|
|
137
|
+
'@craft-ts/mcp': CRAFT_TS_STARTER_VERSION,
|
|
138
|
+
'@playwright/test': '^1.52.0',
|
|
139
|
+
'@types/node': '^22.0.0',
|
|
140
|
+
...(effect
|
|
141
|
+
? {
|
|
142
|
+
'@effect/tsgo': '^0.24.3',
|
|
143
|
+
'@typescript/native': 'npm:typescript@7.0.2',
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
146
|
+
'@eslint/js': '^9.0.0',
|
|
147
|
+
'eslint': '^9.0.0',
|
|
148
|
+
'eslint-config-prettier': '^10.0.0',
|
|
149
|
+
'eslint-plugin-playwright': '^2.0.0',
|
|
150
|
+
'typescript': '^6.0.3',
|
|
151
|
+
'typescript-eslint': '^8.0.0',
|
|
152
|
+
'vite': '^8.0.0',
|
|
153
|
+
'vitest': '^4.0.0',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function tsconfig(context: TemplateContext): string {
|
|
159
|
+
return json({
|
|
160
|
+
compilerOptions: {
|
|
161
|
+
target: 'ES2022',
|
|
162
|
+
module: 'preserve',
|
|
163
|
+
moduleResolution: 'bundler',
|
|
164
|
+
strict: true,
|
|
165
|
+
noEmit: true,
|
|
166
|
+
isolatedModules: true,
|
|
167
|
+
verbatimModuleSyntax: true,
|
|
168
|
+
erasableSyntaxOnly: false,
|
|
169
|
+
skipLibCheck: true,
|
|
170
|
+
types: ['node'],
|
|
171
|
+
},
|
|
172
|
+
references: [
|
|
173
|
+
{ path: './tsconfig.app.json' },
|
|
174
|
+
{ path: './tsconfig.spec.json' },
|
|
175
|
+
{ path: './tsconfig.architecture.json' },
|
|
176
|
+
...(context.mode === 'effect' ? [{ path: './tsconfig.effect.json' }] : []),
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const tsconfigApp = `{
|
|
182
|
+
"extends": "./tsconfig.json",
|
|
183
|
+
"compilerOptions": { "types": ["vite/client"] },
|
|
184
|
+
"files": ["src/main.ts"],
|
|
185
|
+
"include": ["src/**/*.d.ts"],
|
|
186
|
+
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
187
|
+
}\n`;
|
|
188
|
+
|
|
189
|
+
const tsconfigSpec = `{
|
|
190
|
+
"extends": "./tsconfig.json",
|
|
191
|
+
"compilerOptions": { "types": ["node", "vitest/globals", "vite/client"] },
|
|
192
|
+
"include": ["src/**/*.ts", "src/**/*.d.ts", "e2e/**/*.ts"],
|
|
193
|
+
"exclude": ["src/main.ts"]
|
|
194
|
+
}\n`;
|
|
195
|
+
|
|
196
|
+
const tsconfigEffect = `{
|
|
197
|
+
"extends": "./tsconfig.json",
|
|
198
|
+
"compilerOptions": { "noEmit": true },
|
|
199
|
+
"include": ["src/**/*.ts"],
|
|
200
|
+
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
201
|
+
}\n`;
|
|
202
|
+
|
|
203
|
+
const viteConfig = `import { defineConfig } from 'vite';
|
|
204
|
+
|
|
205
|
+
export default defineConfig({
|
|
206
|
+
server: { host: '127.0.0.1', port: 4173 },
|
|
207
|
+
build: { target: 'es2022' },
|
|
208
|
+
});
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
const effectTsgoRunner = `import { chmodSync, existsSync } from 'node:fs';
|
|
212
|
+
import { spawnSync } from 'node:child_process';
|
|
213
|
+
import { resolve } from 'node:path';
|
|
214
|
+
|
|
215
|
+
const projectRoot = resolve(import.meta.dirname, '..');
|
|
216
|
+
const effectTsgoPath = resolve(projectRoot, 'node_modules/.bin/effect-tsgo');
|
|
217
|
+
if (process.platform !== 'win32') {
|
|
218
|
+
const nativeBinary = resolve(
|
|
219
|
+
projectRoot,
|
|
220
|
+
'node_modules',
|
|
221
|
+
'@effect',
|
|
222
|
+
'tsgo-' + process.platform + '-' + process.arch,
|
|
223
|
+
'lib',
|
|
224
|
+
'tsc',
|
|
225
|
+
);
|
|
226
|
+
if (existsSync(nativeBinary)) chmodSync(nativeBinary, 0o755);
|
|
227
|
+
}
|
|
228
|
+
const result = spawnSync(effectTsgoPath, process.argv.slice(2), {
|
|
229
|
+
cwd: projectRoot,
|
|
230
|
+
stdio: 'inherit',
|
|
231
|
+
});
|
|
232
|
+
if (result.error) throw result.error;
|
|
233
|
+
process.exitCode = result.status ?? 1;
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
const vitestConfig = `import { defineConfig } from 'vitest/config';
|
|
237
|
+
|
|
238
|
+
export default defineConfig({
|
|
239
|
+
test: {
|
|
240
|
+
name: 'craft-ts-app',
|
|
241
|
+
globals: true,
|
|
242
|
+
environment: 'jsdom',
|
|
243
|
+
include: ['src/**/*.spec.ts'],
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
`;
|
|
247
|
+
|
|
248
|
+
const eslintConfig = `import js from '@eslint/js';
|
|
249
|
+
import prettier from 'eslint-config-prettier';
|
|
250
|
+
import playwright from 'eslint-plugin-playwright';
|
|
251
|
+
import tseslint from 'typescript-eslint';
|
|
252
|
+
import craftRules from '@craft-ts/dev-tools/eslint-rules';
|
|
253
|
+
|
|
254
|
+
export default tseslint.config(
|
|
255
|
+
{ ignores: ['dist/**', 'node_modules/**', '**/architecture/catalog.ts'] },
|
|
256
|
+
js.configs.recommended,
|
|
257
|
+
...tseslint.configs.recommended,
|
|
258
|
+
{
|
|
259
|
+
files: ['src/**/*.ts'],
|
|
260
|
+
plugins: { 'craft-ts': craftRules },
|
|
261
|
+
languageOptions: {
|
|
262
|
+
parserOptions: {
|
|
263
|
+
project: ['./tsconfig.app.json', './tsconfig.spec.json'],
|
|
264
|
+
tsconfigRootDir: import.meta.dirname,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
rules: {
|
|
268
|
+
...craftRules.configs.recommended.rules,
|
|
269
|
+
'@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
files: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
|
274
|
+
rules: {
|
|
275
|
+
'craft-ts/no-async-await': 'off',
|
|
276
|
+
'craft-ts/no-throw': 'off',
|
|
277
|
+
'craft-ts/prefer-browser-boundaries': 'off',
|
|
278
|
+
'craft-ts/prefer-craft-template-blocks': 'off',
|
|
279
|
+
'craft-ts/no-ephemeral-template-form-state': 'off',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
files: ['e2e/**/*.ts'],
|
|
284
|
+
...playwright.configs['flat/recommended'],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
files: ['scripts/**/*.mjs'],
|
|
288
|
+
languageOptions: {
|
|
289
|
+
globals: { process: 'readonly' },
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
prettier,
|
|
293
|
+
);
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
const indexHtml = `<!doctype html>
|
|
297
|
+
<html lang="en">
|
|
298
|
+
<head>
|
|
299
|
+
<meta charset="UTF-8" />
|
|
300
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
301
|
+
<title>CraftTS starter</title>
|
|
302
|
+
</head>
|
|
303
|
+
<body>
|
|
304
|
+
<craft-root></craft-root>
|
|
305
|
+
<script type="module" src="/src/main.ts"></script>
|
|
306
|
+
</body>
|
|
307
|
+
</html>
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
const styles = `:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f7f8fb; }
|
|
311
|
+
* { box-sizing: border-box; }
|
|
312
|
+
body { margin: 0; min-width: 320px; }
|
|
313
|
+
a { color: #2457d6; }
|
|
314
|
+
main { max-width: 860px; margin: 0 auto; padding: 3rem 1.25rem; }
|
|
315
|
+
nav { display: flex; gap: 1rem; padding: 1rem 1.25rem; background: white; border-bottom: 1px solid #e3e7ef; }
|
|
316
|
+
.card { padding: 1.5rem; background: white; border: 1px solid #e3e7ef; border-radius: .75rem; box-shadow: 0 8px 30px #1720330d; }
|
|
317
|
+
.muted { color: #5c677d; }
|
|
318
|
+
.error { color: #b42318; }
|
|
319
|
+
button:focus-visible, a:focus-visible { outline: 3px solid #7aa2ff; outline-offset: 3px; }
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
const mainTs = `import { bootstrapCraft } from '@craft-ts/component';
|
|
323
|
+
import { appConfig } from './app/app.config';
|
|
324
|
+
import './styles.css';
|
|
325
|
+
|
|
326
|
+
bootstrapCraft({ config: appConfig });
|
|
327
|
+
`;
|
|
328
|
+
|
|
329
|
+
const appTs = `import {
|
|
330
|
+
a,
|
|
331
|
+
CraftRouterOutlet,
|
|
332
|
+
craftComponent,
|
|
333
|
+
div,
|
|
334
|
+
main,
|
|
335
|
+
nav,
|
|
336
|
+
} from '@craft-ts/component';
|
|
337
|
+
import { CraftRouterLink } from '@craft-ts/core';
|
|
338
|
+
|
|
339
|
+
export const App = craftComponent(
|
|
340
|
+
'App',
|
|
341
|
+
{},
|
|
342
|
+
() => ({}),
|
|
343
|
+
() =>
|
|
344
|
+
div([
|
|
345
|
+
nav([
|
|
346
|
+
a('home', { craftRouterLink: { to: '' } }, 'Home').pipe(CraftRouterLink),
|
|
347
|
+
a('about', { craftRouterLink: { to: 'about' } }, 'About').pipe(CraftRouterLink),
|
|
348
|
+
]),
|
|
349
|
+
main(CraftRouterOutlet()),
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
352
|
+
`;
|
|
353
|
+
|
|
354
|
+
const routesTs = `import { loadCraftComponent } from '@craft-ts/component';
|
|
355
|
+
import {
|
|
356
|
+
assertExhaustiveRouteExceptions,
|
|
357
|
+
craftRoutes,
|
|
358
|
+
type CanRun,
|
|
359
|
+
type CraftRouter,
|
|
360
|
+
type ValidateCascadeRoutesFile,
|
|
361
|
+
} from '@craft-ts/core';
|
|
362
|
+
|
|
363
|
+
export const { appRoutes } = craftRoutes('app', [
|
|
364
|
+
{
|
|
365
|
+
path: '',
|
|
366
|
+
...loadCraftComponent(({ withRetry }) =>
|
|
367
|
+
withRetry(import('./home-page')).then(
|
|
368
|
+
(module: typeof import('./home-page')) => module.default,
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
path: 'about',
|
|
374
|
+
...loadCraftComponent(({ withRetry }) =>
|
|
375
|
+
withRetry(import('./about-page')).then(
|
|
376
|
+
(module: typeof import('./about-page')) => module.default,
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
assertExhaustiveRouteExceptions(appRoutes);
|
|
383
|
+
|
|
384
|
+
type _CheckAppDI = ValidateCascadeRoutesFile<
|
|
385
|
+
never,
|
|
386
|
+
CraftRouter,
|
|
387
|
+
typeof appRoutes
|
|
388
|
+
>;
|
|
389
|
+
type _CanRunAppDI = CanRun<_CheckAppDI>;
|
|
390
|
+
|
|
391
|
+
declare module '@craft-ts/core' {
|
|
392
|
+
interface CraftRouterRoutesRegistry {
|
|
393
|
+
App: typeof appRoutes.META_PATHS;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
`;
|
|
397
|
+
|
|
398
|
+
const appConfigTs = `import { provideCraftRootComponent } from '@craft-ts/component';
|
|
399
|
+
import { craftAppConfig, provideCraftRouter } from '@craft-ts/core';
|
|
400
|
+
import { App } from './app';
|
|
401
|
+
import { appRoutes } from './app.routes';
|
|
402
|
+
|
|
403
|
+
export const appConfig = craftAppConfig({
|
|
404
|
+
routingDeps: appRoutes.META_PATHS,
|
|
405
|
+
providers: [
|
|
406
|
+
provideCraftRootComponent(App),
|
|
407
|
+
provideCraftRouter(appRoutes.toRoutes()),
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
`;
|
|
411
|
+
|
|
412
|
+
const apiTs = `import { CraftHttpClient } from '@craft-ts/core';
|
|
413
|
+
|
|
414
|
+
export type WelcomeResponse = {
|
|
415
|
+
readonly title: string;
|
|
416
|
+
readonly body: string;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
export function* loadWelcome() {
|
|
420
|
+
return yield* CraftHttpClient.get(({ response }) => ({
|
|
421
|
+
url: '/api/welcome',
|
|
422
|
+
success: response<WelcomeResponse>(),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
`;
|
|
426
|
+
|
|
427
|
+
const aboutPageTs = `import { craftComponent, div, heading, p } from '@craft-ts/component';
|
|
428
|
+
|
|
429
|
+
export const AboutPage = craftComponent(
|
|
430
|
+
'AboutPage',
|
|
431
|
+
{},
|
|
432
|
+
() => ({}),
|
|
433
|
+
() =>
|
|
434
|
+
div({ class: 'card' }, [
|
|
435
|
+
heading('About this starter'),
|
|
436
|
+
p('This page proves that Craft routing and lazy component loading are wired.'),
|
|
437
|
+
]),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
export default AboutPage;
|
|
441
|
+
`;
|
|
442
|
+
|
|
443
|
+
const plainHomePageTs = `import {
|
|
444
|
+
craftComponent,
|
|
445
|
+
div,
|
|
446
|
+
heading,
|
|
447
|
+
ifBlock,
|
|
448
|
+
p,
|
|
449
|
+
span,
|
|
450
|
+
} from '@craft-ts/component';
|
|
451
|
+
import { craftComputed, query } from '@craft-ts/core';
|
|
452
|
+
import { loadWelcome } from './api';
|
|
453
|
+
|
|
454
|
+
export const HomePage = craftComponent(
|
|
455
|
+
'HomePage',
|
|
456
|
+
{},
|
|
457
|
+
function* () {
|
|
458
|
+
const welcomeQuery = yield* query(
|
|
459
|
+
'welcomeQuery',
|
|
460
|
+
{
|
|
461
|
+
params: () => true,
|
|
462
|
+
loader: function* () {
|
|
463
|
+
return yield* loadWelcome();
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
({ resource }) => ({
|
|
467
|
+
hasWelcome: craftComputed('hasWelcome', () => resource.hasValue()),
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
return { welcomeQuery };
|
|
471
|
+
},
|
|
472
|
+
({ welcomeQuery }) =>
|
|
473
|
+
div({ class: 'card' }, [
|
|
474
|
+
heading('Welcome to CraftTS'),
|
|
475
|
+
p('A framework-independent starter with a typed API boundary.'),
|
|
476
|
+
ifBlock(welcomeQuery.isLoading, () => p({ class: 'muted' }, 'Loading API…')),
|
|
477
|
+
ifBlock(welcomeQuery.hasWelcome, () =>
|
|
478
|
+
div([
|
|
479
|
+
p(function* () {
|
|
480
|
+
return 'API title: ' + (yield* welcomeQuery.value()).title;
|
|
481
|
+
}),
|
|
482
|
+
p(function* () {
|
|
483
|
+
return 'API body: ' + (yield* welcomeQuery.value()).body;
|
|
484
|
+
}),
|
|
485
|
+
]),
|
|
486
|
+
),
|
|
487
|
+
ifBlock(welcomeQuery.hasException, () =>
|
|
488
|
+
p({ class: 'error' }, [span('The API request failed. Check the network tab.')]),
|
|
489
|
+
),
|
|
490
|
+
]),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
export default HomePage;
|
|
494
|
+
`;
|
|
495
|
+
|
|
496
|
+
const effectDomainTs = `/* eslint-disable craft-ts/prefer-craft-http-transport, craft-ts/no-async-await, craft-ts/no-throw -- Effect owns this typed transport boundary. */
|
|
497
|
+
import { Context, Data, Effect, Layer } from 'effect';
|
|
498
|
+
|
|
499
|
+
export type WelcomeResponse = {
|
|
500
|
+
readonly title: string;
|
|
501
|
+
readonly body: string;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
export class WelcomeApiError extends Data.TaggedError('WelcomeApiError')<{
|
|
505
|
+
readonly message: string;
|
|
506
|
+
}> {}
|
|
507
|
+
|
|
508
|
+
export type WelcomeRepository = {
|
|
509
|
+
readonly load: () => Effect.Effect<WelcomeResponse, WelcomeApiError>;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
export class WelcomeRepositoryService extends Context.Service<
|
|
513
|
+
WelcomeRepositoryService,
|
|
514
|
+
WelcomeRepository
|
|
515
|
+
>()('starter/WelcomeRepository') {}
|
|
516
|
+
|
|
517
|
+
export const WelcomeRepositoryLive = Layer.succeed(WelcomeRepositoryService, {
|
|
518
|
+
load: () =>
|
|
519
|
+
Effect.tryPromise({
|
|
520
|
+
try: async () => {
|
|
521
|
+
const response = await fetch('/api/welcome');
|
|
522
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
523
|
+
return (await response.json()) as WelcomeResponse;
|
|
524
|
+
},
|
|
525
|
+
catch: (cause) => new WelcomeApiError({ message: String(cause) }),
|
|
526
|
+
}),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
export const loadWelcome = (): Effect.Effect<
|
|
530
|
+
WelcomeResponse,
|
|
531
|
+
WelcomeApiError,
|
|
532
|
+
WelcomeRepositoryService
|
|
533
|
+
> =>
|
|
534
|
+
Effect.gen(function* () {
|
|
535
|
+
const repository = yield* WelcomeRepositoryService;
|
|
536
|
+
return yield* repository.load();
|
|
537
|
+
});
|
|
538
|
+
`;
|
|
539
|
+
|
|
540
|
+
const effectHomePageTs = `import {
|
|
541
|
+
craftComponent,
|
|
542
|
+
div,
|
|
543
|
+
heading,
|
|
544
|
+
ifBlock,
|
|
545
|
+
p,
|
|
546
|
+
span,
|
|
547
|
+
} from '@craft-ts/component';
|
|
548
|
+
import { craftComputed } from '@craft-ts/core';
|
|
549
|
+
import { queryEffect } from '@craft-ts/effect';
|
|
550
|
+
import { loadWelcome, type WelcomeApiError } from './domain';
|
|
551
|
+
|
|
552
|
+
export const HomePage = craftComponent(
|
|
553
|
+
'HomePage',
|
|
554
|
+
{},
|
|
555
|
+
function* () {
|
|
556
|
+
const welcomeQuery = yield* queryEffect(
|
|
557
|
+
'welcomeQuery',
|
|
558
|
+
{
|
|
559
|
+
method: (request: boolean) => request,
|
|
560
|
+
loader: () => loadWelcome(),
|
|
561
|
+
},
|
|
562
|
+
({ resource, exceptions }) => ({
|
|
563
|
+
hasWelcome: craftComputed('hasWelcome', () => resource.hasValue()),
|
|
564
|
+
errorMessage: craftComputed('errorMessage', function* () {
|
|
565
|
+
return (yield* exceptions()).loader?.message ?? 'Unknown API error';
|
|
566
|
+
}),
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
yield* welcomeQuery.call(true);
|
|
570
|
+
return { welcomeQuery };
|
|
571
|
+
},
|
|
572
|
+
({ welcomeQuery }) =>
|
|
573
|
+
div({ class: 'card' }, [
|
|
574
|
+
heading('Welcome to CraftTS + Effect v4'),
|
|
575
|
+
p('The page uses queryEffect over a typed repository Layer.'),
|
|
576
|
+
ifBlock(welcomeQuery.isLoading, () => p({ class: 'muted' }, 'Loading API…')),
|
|
577
|
+
ifBlock(welcomeQuery.hasWelcome, () =>
|
|
578
|
+
div([
|
|
579
|
+
p(function* () {
|
|
580
|
+
return 'API title: ' + (yield* welcomeQuery.value()).title;
|
|
581
|
+
}),
|
|
582
|
+
p(function* () {
|
|
583
|
+
return 'API body: ' + (yield* welcomeQuery.value()).body;
|
|
584
|
+
}),
|
|
585
|
+
]),
|
|
586
|
+
),
|
|
587
|
+
ifBlock(welcomeQuery.hasException, () =>
|
|
588
|
+
p({ class: 'error' }, [
|
|
589
|
+
span(function* () {
|
|
590
|
+
return 'API error: ' + (yield* welcomeQuery.errorMessage());
|
|
591
|
+
}),
|
|
592
|
+
]),
|
|
593
|
+
),
|
|
594
|
+
]),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
export type HomePageError = WelcomeApiError;
|
|
598
|
+
|
|
599
|
+
export default HomePage;
|
|
600
|
+
`;
|
|
601
|
+
|
|
602
|
+
const plainAppConfig = appConfigTs;
|
|
603
|
+
const effectAppConfig = `import { provideCraftRootComponent } from '@craft-ts/component';
|
|
604
|
+
import {
|
|
605
|
+
craftAppConfig,
|
|
606
|
+
provideAppInitializer,
|
|
607
|
+
provideCraftRouter,
|
|
608
|
+
} from '@craft-ts/core';
|
|
609
|
+
import {
|
|
610
|
+
installCraftEffectBridge,
|
|
611
|
+
provideLayer,
|
|
612
|
+
} from '@craft-ts/effect';
|
|
613
|
+
import { App } from './app';
|
|
614
|
+
import { appRoutes } from './app.routes';
|
|
615
|
+
import {
|
|
616
|
+
WelcomeRepositoryLive,
|
|
617
|
+
} from './domain';
|
|
618
|
+
|
|
619
|
+
const effectProviders = [provideLayer(WelcomeRepositoryLive)] as const;
|
|
620
|
+
|
|
621
|
+
export const appConfig = craftAppConfig({
|
|
622
|
+
routingDeps: appRoutes.META_PATHS,
|
|
623
|
+
providers: [
|
|
624
|
+
provideCraftRootComponent(App),
|
|
625
|
+
provideCraftRouter(appRoutes.toRoutes()),
|
|
626
|
+
...effectProviders,
|
|
627
|
+
provideAppInitializer(() => installCraftEffectBridge()),
|
|
628
|
+
],
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// effectProviders is a typed tuple: removing the Layer makes the app config
|
|
632
|
+
// fail at the provider boundary before the application can boot.
|
|
633
|
+
`;
|
|
634
|
+
|
|
635
|
+
function unitTestTs(context: TemplateContext): string {
|
|
636
|
+
const effect = context.mode === 'effect';
|
|
637
|
+
return `// @vitest-environment jsdom
|
|
638
|
+
import { mountCraftComponent } from '@craft-ts/component';
|
|
639
|
+
import { TestBed, ɵInjector as Injector } from '@craft-ts/core';
|
|
640
|
+
${effect ? "import { installCraftEffectBridge, provideLayer } from '@craft-ts/effect';\nimport { WelcomeRepositoryLive } from './domain';" : ''}
|
|
641
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
642
|
+
import { HomePage } from './home-page';
|
|
643
|
+
|
|
644
|
+
describe('HomePage', () => {
|
|
645
|
+
${effect ? 'let disposeBridge: () => void;' : ''}
|
|
646
|
+
beforeEach(() => {
|
|
647
|
+
TestBed.resetTestingModule();
|
|
648
|
+
document.body.replaceChildren();
|
|
649
|
+
${effect ? 'disposeBridge = installCraftEffectBridge();' : ''}
|
|
650
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
651
|
+
new Response(JSON.stringify({ title: 'Hello from the API', body: 'Typed transport works.' }), {
|
|
652
|
+
headers: { 'content-type': 'application/json' },
|
|
653
|
+
}),
|
|
654
|
+
));
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
afterEach(() => {
|
|
658
|
+
${effect ? 'disposeBridge();' : ''}
|
|
659
|
+
vi.unstubAllGlobals();
|
|
660
|
+
TestBed.resetTestingModule();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('renders the API result', async () => {
|
|
664
|
+
const host = document.createElement('div');
|
|
665
|
+
document.body.append(host);
|
|
666
|
+
const injector = ${effect ? 'TestBed.rootInjector.createChild([provideLayer(WelcomeRepositoryLive)])' : 'TestBed.rootInjector'};
|
|
667
|
+
const mounted = mountCraftComponent(HomePage, host, injector as unknown as Injector);
|
|
668
|
+
TestBed.tick();
|
|
669
|
+
await vi.waitFor(() => expect(host.textContent).toContain('Hello from the API'));
|
|
670
|
+
mounted.destroy();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const e2eTestTs = `import { expect, test } from '@playwright/test';
|
|
677
|
+
|
|
678
|
+
test.beforeEach(async ({ page }) => {
|
|
679
|
+
await page.route('**/api/welcome', async (route) =>
|
|
680
|
+
route.fulfill({
|
|
681
|
+
status: 200,
|
|
682
|
+
contentType: 'application/json',
|
|
683
|
+
body: JSON.stringify({ title: 'Hello from the API', body: 'E2E transport works.' }),
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('loads the API page and navigates to About', async ({ page }) => {
|
|
689
|
+
await page.goto('/');
|
|
690
|
+
await expect(page.getByRole('heading', { name: /Welcome to CraftTS/ })).toBeVisible();
|
|
691
|
+
await expect(page.getByText('Hello from the API')).toBeVisible();
|
|
692
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
693
|
+
await expect(page.getByRole('heading', { name: 'About this starter' })).toBeVisible();
|
|
694
|
+
});
|
|
695
|
+
`;
|
|
696
|
+
|
|
697
|
+
const playwrightConfig = `import { defineConfig, devices } from '@playwright/test';
|
|
698
|
+
|
|
699
|
+
export default defineConfig({
|
|
700
|
+
testDir: './e2e',
|
|
701
|
+
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
|
|
702
|
+
webServer: {
|
|
703
|
+
command: 'npm run dev',
|
|
704
|
+
url: 'http://127.0.0.1:4173',
|
|
705
|
+
reuseExistingServer: !process.env.CI,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
`;
|
|
709
|
+
|
|
710
|
+
function readme(context: TemplateContext): string {
|
|
711
|
+
const effect = context.mode === 'effect';
|
|
712
|
+
return `# ${context.projectName}\n\nA framework-independent CraftTS starter generated by craft create.\n\nThis project includes:\n\n- a routed Craft component page;\n- a typed CraftHttpClient API boundary${effect ? ' behind an Effect v4 repository service and Layer' : ''};\n- flat-config ESLint with Craft rules;\n- unit tests, graph-wide architecture tests and a Playwright E2E flow.\n\n## Start\n\nnpm install\nnpm run dev\n\n## Verify\n\nnpm run lint\nnpm run typecheck\nnpm run typecheck-spec\n${effect ? 'npm run effect-check\n' : ''}npm test\nnpm run architecture\nnpm run e2e\nnpm run build\n\n### Architecture tests\n\narchitecture/ is the dependency-graph contract. It runs without booting the\nbrowser and catches duplicate HTTP ownership, dependency cycles, declarative\ngraph violations, unnamed interactive elements and missing route DI proofs.\nRun it after structural changes. Add a rule only for a recurring product\ninvariant that the baseline helpers do not cover; do not add one test per\nfeature.\n\nThe generated catalog is refreshed by architecture/load-graph.ts; do not edit\narchitecture/catalog.ts by hand.\n`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function agentFiles(
|
|
716
|
+
mode: CreateMode,
|
|
717
|
+
agent: CreateAgent,
|
|
718
|
+
): Record<string, string> {
|
|
719
|
+
const skill = `${BASE_AGENT_SKILL}${mode === 'effect' ? `\n${EFFECT_AGENT_SKILL}` : ''}`;
|
|
720
|
+
if (agent === 'codex') {
|
|
721
|
+
return {
|
|
722
|
+
'AGENTS.md': AGENTS_MD(mode),
|
|
723
|
+
'.agents/skills/craft-ts-project/SKILL.md': skill,
|
|
724
|
+
...(mode === 'effect'
|
|
725
|
+
? { '.agents/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
|
|
726
|
+
: {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (agent === 'cursor') {
|
|
730
|
+
return {
|
|
731
|
+
'.cursor/skills/craft-ts-project/SKILL.md': skill,
|
|
732
|
+
'.cursor/rules/craft-ts.mdc': `---\ndescription: CraftTS project rules\nglobs: **/*.ts\nalwaysApply: true\n---\n\nRead .cursor/skills/craft-ts-project/SKILL.md before editing CraftTS code.\n`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (agent === 'claude-code') {
|
|
736
|
+
return {
|
|
737
|
+
'CLAUDE.md': AGENTS_MD(mode),
|
|
738
|
+
'.claude/skills/craft-ts-project/SKILL.md': skill,
|
|
739
|
+
...(mode === 'effect'
|
|
740
|
+
? { '.claude/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
|
|
741
|
+
: {}),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
'GEMINI.md': AGENTS_MD(mode),
|
|
746
|
+
'.gemini/skills/craft-ts-project/SKILL.md': skill,
|
|
747
|
+
...(mode === 'effect'
|
|
748
|
+
? { '.gemini/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
|
|
749
|
+
: {}),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function templates(context: TemplateContext): Record<string, string> {
|
|
754
|
+
const effect = context.mode === 'effect';
|
|
755
|
+
return {
|
|
756
|
+
'package.json': packageJson(context),
|
|
757
|
+
'tsconfig.json': tsconfig(context),
|
|
758
|
+
'tsconfig.app.json': tsconfigApp,
|
|
759
|
+
'tsconfig.spec.json': tsconfigSpec,
|
|
760
|
+
...(effect ? { 'tsconfig.effect.json': tsconfigEffect } : {}),
|
|
761
|
+
...(effect ? { 'scripts/run-effect-tsgo.mjs': effectTsgoRunner } : {}),
|
|
762
|
+
'vite.config.ts': viteConfig,
|
|
763
|
+
'vitest.config.ts': vitestConfig,
|
|
764
|
+
'eslint.config.mjs': eslintConfig,
|
|
765
|
+
'playwright.config.ts': playwrightConfig,
|
|
766
|
+
'index.html': indexHtml,
|
|
767
|
+
'README.md': readme(context),
|
|
768
|
+
'src/main.ts': mainTs,
|
|
769
|
+
'src/styles.css': styles,
|
|
770
|
+
'src/types.d.ts': '/// <reference types="vite/client" />\n',
|
|
771
|
+
'src/app/app.ts': appTs,
|
|
772
|
+
'src/app/app.config.ts': effect ? effectAppConfig : plainAppConfig,
|
|
773
|
+
'src/app/app.routes.ts': routesTs,
|
|
774
|
+
...(effect ? { 'src/app/api.ts': "export { loadWelcome } from './domain';\nexport type { WelcomeResponse } from './domain';\n" } : { 'src/app/api.ts': apiTs }),
|
|
775
|
+
...(effect ? { 'src/app/domain.ts': effectDomainTs, 'src/app/home-page.ts': effectHomePageTs } : { 'src/app/home-page.ts': plainHomePageTs }),
|
|
776
|
+
'src/app/about-page.ts': aboutPageTs,
|
|
777
|
+
'src/app/home-page.spec.ts': unitTestTs(context),
|
|
778
|
+
'e2e/starter.spec.ts': e2eTestTs,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function normaliseAgent(agent: string): CreateAgent {
|
|
783
|
+
const value = agent.trim().toLowerCase();
|
|
784
|
+
if (value === 'codex') return 'codex';
|
|
785
|
+
if (value === 'cursor') return 'cursor';
|
|
786
|
+
if (value === 'claude' || value === 'claude-code') return 'claude-code';
|
|
787
|
+
if (value === 'cloud-code' || value === 'cloudcode' || value === 'gemini') return 'cloud-code';
|
|
788
|
+
throw new Error(`Unknown agent "${agent}". Use codex, cursor, claude-code, or cloud-code.`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function parseCreateAgents(value: string | undefined): readonly CreateAgent[] {
|
|
792
|
+
if (!value) return DEFAULT_AGENTS;
|
|
793
|
+
if (value.trim() === '' || value.trim().toLowerCase() === 'none') return [];
|
|
794
|
+
return [...new Set(value.split(',').map(normaliseAgent))];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function createCraftProject(
|
|
798
|
+
options: CreateProjectOptions,
|
|
799
|
+
): Promise<CreateProjectResult> {
|
|
800
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
801
|
+
const directory = resolve(rootDir, options.directory);
|
|
802
|
+
const projectName = directory
|
|
803
|
+
.split(/[\\/]/)
|
|
804
|
+
.at(-1)
|
|
805
|
+
?.replace(/[^a-zA-Z0-9._-]/g, '-') || 'craft-app';
|
|
806
|
+
const mode = options.mode ?? 'plain';
|
|
807
|
+
const agents = options.agents ?? DEFAULT_AGENTS;
|
|
808
|
+
|
|
809
|
+
if (existsSync(directory) && !options.force && (await readdir(directory)).length > 0) {
|
|
810
|
+
throw new Error(`Refusing to create in non-empty directory: ${directory}. Use --force to merge.`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const files = {
|
|
814
|
+
...templates({ projectName, mode }),
|
|
815
|
+
...agents.reduce<Record<string, string>>(
|
|
816
|
+
(all, agent) => Object.assign(all, agentFiles(mode, agent)),
|
|
817
|
+
{},
|
|
818
|
+
),
|
|
819
|
+
};
|
|
820
|
+
await Promise.all(
|
|
821
|
+
Object.entries(files).map(async ([file, contents]) => {
|
|
822
|
+
const target = join(directory, file);
|
|
823
|
+
await mkdir(dirname(target), { recursive: true });
|
|
824
|
+
await writeFile(target, contents, 'utf8');
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
// Reuse the same deterministic scaffold as migration, but with a standalone
|
|
829
|
+
// project root. It also adds architecture/typecheck scripts to package.json.
|
|
830
|
+
await runArchitectureMigration({
|
|
831
|
+
rootDir: join(directory, 'src'),
|
|
832
|
+
tsConfigFilePath: join(directory, 'tsconfig.app.json'),
|
|
833
|
+
write: true,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
directory,
|
|
838
|
+
mode,
|
|
839
|
+
agents,
|
|
840
|
+
changedFiles: Object.keys(files).map((file) => relative(directory, join(directory, file))),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function createModeFromFlag(value: string | undefined): CreateMode | undefined {
|
|
845
|
+
if (!value) return undefined;
|
|
846
|
+
if (value === 'v4' || value === 'effect' || value === 'true') return 'effect';
|
|
847
|
+
if (value === 'none' || value === 'plain' || value === 'false') return 'plain';
|
|
848
|
+
throw new Error(`Unknown --effect value "${value}". Use v4 or none.`);
|
|
849
|
+
}
|