Skip to main content

spikard_cli/init/
typescript.rs

1//! TypeScript Project Scaffolder
2//!
3//! Generates a minimal TypeScript project structure with Spikard integration.
4//! Follows modern TypeScript/Node.js conventions with strict typing and ESM module support.
5
6use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
7use anyhow::Result;
8use std::path::Path;
9use std::path::PathBuf;
10
11/// TypeScript/Node.js project scaffolder
12pub struct TypeScriptScaffolder;
13
14impl ProjectScaffolder for TypeScriptScaffolder {
15    #[allow(clippy::vec_init_then_push)]
16    fn scaffold(&self, _project_dir: &Path, project_name: &str) -> Result<Vec<ScaffoldedFile>> {
17        let kebab_name = project_name.replace('_', "-").to_lowercase();
18
19        let mut files = vec![];
20
21        // package.json
22        files.push(ScaffoldedFile::new(
23            PathBuf::from("package.json"),
24            self.generate_package_json(&kebab_name),
25        ));
26
27        // tsconfig.json
28        files.push(ScaffoldedFile::new(
29            PathBuf::from("tsconfig.json"),
30            self.generate_tsconfig(),
31        ));
32
33        // vitest.config.ts
34        files.push(ScaffoldedFile::new(
35            PathBuf::from("vitest.config.ts"),
36            self.generate_vitest_config(),
37        ));
38
39        // .gitignore
40        files.push(ScaffoldedFile::new(
41            PathBuf::from(".gitignore"),
42            self.generate_gitignore(),
43        ));
44
45        // README.md
46        files.push(ScaffoldedFile::new(
47            PathBuf::from("README.md"),
48            self.generate_readme(&kebab_name),
49        ));
50
51        // src/app.ts
52        files.push(ScaffoldedFile::new(PathBuf::from("src/app.ts"), self.generate_app_ts()));
53
54        // src/server.ts
55        files.push(ScaffoldedFile::new(
56            PathBuf::from("src/server.ts"),
57            self.generate_server_ts(),
58        ));
59
60        // tests/app.spec.ts
61        files.push(ScaffoldedFile::new(
62            PathBuf::from("tests/app.spec.ts"),
63            self.generate_app_spec_ts(),
64        ));
65
66        Ok(files)
67    }
68
69    fn next_steps(&self, project_name: &str) -> Vec<String> {
70        let kebab_name = project_name.replace('_', "-").to_lowercase();
71        vec![
72            format!("cd {}", kebab_name),
73            "pnpm install".to_string(),
74            "pnpm dev".to_string(),
75        ]
76    }
77}
78
79impl TypeScriptScaffolder {
80    fn generate_package_json(&self, kebab_name: &str) -> String {
81        let version = env!("CARGO_PKG_VERSION");
82        format!(
83            r#"{{
84	"name": "{kebab_name}",
85	"version": "0.0.1",
86	"type": "module",
87	"description": "Spikard TypeScript application",
88	"main": "dist/server.js",
89	"scripts": {{
90		"dev": "tsx src/server.ts",
91		"start": "node dist/server.js",
92		"build": "tsc",
93		"test": "vitest",
94		"test:run": "vitest run",
95		"lint": "biome check src tests",
96		"format": "biome format --write src tests"
97	}},
98	"dependencies": {{
99		"@spikard/node": "^{version}"
100	}},
101	"devDependencies": {{
102		"@biomejs/biome": "^1.9.4",
103		"@types/node": "^20.0.0",
104		"tsx": "^4.21.0",
105		"typescript": "^5.9.3",
106		"vitest": "^1.0.0"
107	}},
108	"engines": {{
109		"node": ">=20"
110	}}
111}}
112"#
113        )
114    }
115
116    fn generate_tsconfig(&self) -> String {
117        r#"{
118	"compilerOptions": {
119		"allowJs": true,
120		"allowSyntheticDefaultImports": true,
121		"alwaysStrict": true,
122		"baseUrl": ".",
123		"declaration": true,
124		"esModuleInterop": true,
125		"exactOptionalPropertyTypes": true,
126		"forceConsistentCasingInFileNames": true,
127		"incremental": true,
128		"isolatedModules": true,
129		"lib": ["ES2022"],
130		"module": "ESNext",
131		"moduleResolution": "bundler",
132		"noEmit": false,
133		"noImplicitAny": true,
134		"noUncheckedIndexedAccess": true,
135		"noUnusedLocals": true,
136		"noUnusedParameters": true,
137		"outDir": "dist",
138		"removeComments": true,
139		"resolveJsonModule": true,
140		"skipLibCheck": true,
141		"strict": true,
142		"strictBindCallApply": true,
143		"strictFunctionTypes": true,
144		"strictNullChecks": true,
145		"strictPropertyInitialization": true,
146		"target": "ES2022"
147	},
148	"include": ["src/**/*.ts"],
149	"exclude": ["node_modules", "dist", "tests"]
150}
151"#
152        .to_string()
153    }
154
155    fn generate_vitest_config(&self) -> String {
156        r"import { defineConfig } from 'vitest/config';
157
158export default defineConfig({
159	test: {
160		environment: 'node',
161		globals: true,
162		coverage: {
163			provider: 'v8',
164			reporter: ['text', 'json', 'html'],
165			exclude: [
166				'node_modules/',
167				'dist/',
168			],
169		},
170	},
171});
172"
173        .to_string()
174    }
175
176    fn generate_gitignore(&self) -> String {
177        r"# Dependencies
178node_modules/
179package-lock.json
180yarn.lock
181
182# Build output
183dist/
184*.tsbuildinfo
185*.js
186*.mjs
187*.cjs
188
189# Testing
190coverage/
191.vitest/
192
193# IDE
194.vscode/
195.idea/
196*.swp
197*.swo
198*~
199
200# Environment
201.env
202.env.local
203.env.*.local
204
205# OS
206.DS_Store
207Thumbs.db
208"
209        .to_string()
210    }
211
212    fn generate_readme(&self, kebab_name: &str) -> String {
213        format!(
214            r"# {kebab_name}
215
216A Spikard TypeScript application.
217
218## Requirements
219
220- Node.js 20+
221- pnpm 10+
222
223## Installation
224
225```bash
226pnpm install
227```
228
229## Development
230
231Start the development server with hot reload:
232
233```bash
234pnpm dev
235```
236
237The server will start on `http://127.0.0.1:8000`.
238
239## Building
240
241```bash
242pnpm build
243```
244
245## Testing
246
247Run tests:
248
249```bash
250pnpm test
251```
252
253Run tests once:
254
255```bash
256pnpm test:run
257```
258
259## Linting & Formatting
260
261Lint the code:
262
263```bash
264pnpm lint
265```
266
267Format the code:
268
269```bash
270pnpm format
271```
272
273## Next Steps
274
2751. Install dependencies: `pnpm install`
2762. Start development: `pnpm dev`
2773. Make requests to `http://localhost:8000` to verify
2784. Write your handlers in `src/app.ts`
2795. Add tests in `tests/`
280
281## Documentation
282
283- [Spikard Documentation](https://github.com/Goldziher/spikard)
284- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
285- [Node.js API](https://nodejs.org/api/)
286"
287        )
288    }
289
290    fn generate_app_ts(&self) -> String {
291        r"/**
292 * Basic Spikard TypeScript Application
293 *
294 * This example demonstrates a simple HTTP server with health check
295 * and echo endpoints using the Spikard Node.js bindings.
296 */
297
298import { Spikard, get, post, type HandlerFunction } from '@spikard/node';
299
300export const app = new Spikard();
301
302/**
303 * Root endpoint - returns welcome message
304 */
305const handleRoot: HandlerFunction = async () => {
306	return {
307		message: 'Hello from Spikard TypeScript!',
308		timestamp: new Date().toISOString(),
309	};
310};
311get('/')(handleRoot);
312
313/**
314 * Health check endpoint
315 */
316const handleHealth: HandlerFunction = async () => {
317	return {
318		status: 'healthy',
319		uptime: process.uptime(),
320		timestamp: new Date().toISOString(),
321	};
322};
323get('/health')(handleHealth);
324
325/**
326 * Echo endpoint - returns request body
327 */
328const handleEcho: HandlerFunction = async (req) => {
329	try {
330		const body = req.body ? req.json() : null;
331		return {
332			echoed: true,
333			body,
334			receivedAt: new Date().toISOString(),
335		};
336	} catch {
337		return {
338			error: 'Invalid JSON in request body',
339			code: 'invalid_body',
340		};
341	}
342};
343post('/echo')(handleEcho);
344
345"
346        .to_string()
347    }
348
349    fn generate_server_ts(&self) -> String {
350        r"import { app } from './app';
351
352console.log('Starting Spikard TypeScript server on http://0.0.0.0:8000');
353console.log('Press Ctrl+C to stop\n');
354
355app.run({ port: 8000, host: '0.0.0.0' });
356"
357        .to_string()
358    }
359
360    fn generate_app_spec_ts(&self) -> String {
361        r"import { describe, expect, it } from 'vitest';
362import { app } from '../src/app';
363
364describe('Spikard App', () => {
365	it('exports a configured app instance', () => {
366		expect(app).toBeDefined();
367		expect(typeof app.run).toBe('function');
368	});
369});
370"
371        .to_string()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use tempfile::TempDir;
379
380    #[test]
381    fn test_typescript_scaffold_creates_files() -> Result<()> {
382        let temp_dir = TempDir::new()?;
383        let scaffolder = TypeScriptScaffolder;
384        let files = scaffolder.scaffold(temp_dir.path(), "test_app")?;
385
386        assert!(!files.is_empty(), "Should create multiple files");
387
388        // Check expected files exist in the vec
389        let file_paths: Vec<_> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
390
391        assert!(file_paths.iter().any(|p| p == "package.json"));
392        assert!(file_paths.iter().any(|p| p == "tsconfig.json"));
393        assert!(file_paths.iter().any(|p| p == "vitest.config.ts"));
394        assert!(file_paths.iter().any(|p| p == ".gitignore"));
395        assert!(file_paths.iter().any(|p| p == "README.md"));
396        assert!(file_paths.iter().any(|p| p == "src/app.ts"));
397        assert!(file_paths.iter().any(|p| p == "tests/app.spec.ts"));
398
399        Ok(())
400    }
401
402    #[test]
403    fn test_typescript_scaffold_package_json_valid() -> Result<()> {
404        let temp_dir = TempDir::new()?;
405        let scaffolder = TypeScriptScaffolder;
406        let files = scaffolder.scaffold(temp_dir.path(), "my-app")?;
407
408        let pkg_json = files
409            .iter()
410            .find(|f| f.path.file_name().unwrap() == "package.json")
411            .unwrap();
412
413        assert!(pkg_json.content.contains("\"type\": \"module\""));
414        assert!(pkg_json.content.contains("@spikard/node"));
415        assert!(pkg_json.content.contains("vitest"));
416        assert!(pkg_json.content.contains("typescript"));
417
418        Ok(())
419    }
420
421    #[test]
422    fn test_typescript_scaffold_tsconfig_has_strict_mode() -> Result<()> {
423        let temp_dir = TempDir::new()?;
424        let scaffolder = TypeScriptScaffolder;
425        let files = scaffolder.scaffold(temp_dir.path(), "test-app")?;
426
427        let tsconfig = files
428            .iter()
429            .find(|f| f.path.file_name().unwrap() == "tsconfig.json")
430            .unwrap();
431
432        assert!(tsconfig.content.contains("\"strict\": true"));
433        assert!(tsconfig.content.contains("\"noImplicitAny\": true"));
434        assert!(tsconfig.content.contains("\"strictNullChecks\": true"));
435        assert!(tsconfig.content.contains("\"target\": \"ES2022\""));
436
437        Ok(())
438    }
439
440    #[test]
441    fn test_typescript_next_steps() {
442        let scaffolder = TypeScriptScaffolder;
443        let steps = scaffolder.next_steps("my_app");
444
445        assert!(!steps.is_empty());
446        assert!(steps[0].contains("my-app"));
447        assert!(steps.iter().any(|s| s.contains("pnpm install")));
448        assert!(steps.iter().any(|s| s.contains("pnpm dev")));
449    }
450
451    #[test]
452    #[allow(clippy::cmp_owned)]
453    fn test_typescript_app_ts_has_handlers() -> Result<()> {
454        let temp_dir = TempDir::new()?;
455        let scaffolder = TypeScriptScaffolder;
456        let files = scaffolder.scaffold(temp_dir.path(), "test")?;
457
458        let app_ts = files.iter().find(|f| f.path == PathBuf::from("src/app.ts")).unwrap();
459
460        assert!(app_ts.content.contains("Spikard"));
461        assert!(app_ts.content.contains("get('/')"));
462        assert!(app_ts.content.contains("get('/health')"));
463        assert!(app_ts.content.contains("post('/echo')"));
464
465        Ok(())
466    }
467}