1use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
7use anyhow::Result;
8use std::path::Path;
9use std::path::PathBuf;
10
11pub 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(
23 PathBuf::from("package.json"),
24 self.generate_package_json(&kebab_name),
25 ));
26
27 files.push(ScaffoldedFile::new(
29 PathBuf::from("tsconfig.json"),
30 self.generate_tsconfig(),
31 ));
32
33 files.push(ScaffoldedFile::new(
35 PathBuf::from("vitest.config.ts"),
36 self.generate_vitest_config(),
37 ));
38
39 files.push(ScaffoldedFile::new(
41 PathBuf::from(".gitignore"),
42 self.generate_gitignore(),
43 ));
44
45 files.push(ScaffoldedFile::new(
47 PathBuf::from("README.md"),
48 self.generate_readme(&kebab_name),
49 ));
50
51 files.push(ScaffoldedFile::new(PathBuf::from("src/app.ts"), self.generate_app_ts()));
53
54 files.push(ScaffoldedFile::new(
56 PathBuf::from("src/server.ts"),
57 self.generate_server_ts(),
58 ));
59
60 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 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}