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