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(
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}