@terrymooreii/sia 1.0.2 → 2.0.0

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.
Files changed (61) hide show
  1. package/_config.yml +33 -0
  2. package/bin/cli.js +51 -0
  3. package/defaults/includes/footer.njk +14 -0
  4. package/defaults/includes/header.njk +71 -0
  5. package/defaults/includes/pagination.njk +26 -0
  6. package/defaults/includes/tag-list.njk +11 -0
  7. package/defaults/layouts/base.njk +41 -0
  8. package/defaults/layouts/note.njk +25 -0
  9. package/defaults/layouts/page.njk +14 -0
  10. package/defaults/layouts/post.njk +43 -0
  11. package/defaults/pages/blog.njk +36 -0
  12. package/defaults/pages/feed.njk +28 -0
  13. package/defaults/pages/index.njk +60 -0
  14. package/defaults/pages/notes.njk +34 -0
  15. package/defaults/pages/tag.njk +41 -0
  16. package/defaults/pages/tags.njk +39 -0
  17. package/defaults/styles/main.css +1074 -0
  18. package/lib/assets.js +234 -0
  19. package/lib/build.js +260 -19
  20. package/lib/collections.js +191 -0
  21. package/lib/config.js +114 -0
  22. package/lib/content.js +323 -0
  23. package/lib/index.js +53 -18
  24. package/lib/init.js +555 -6
  25. package/lib/new.js +379 -41
  26. package/lib/server.js +257 -0
  27. package/lib/templates.js +249 -0
  28. package/package.json +30 -15
  29. package/readme.md +212 -63
  30. package/src/images/.gitkeep +3 -0
  31. package/src/notes/2024-12-17-first-note.md +6 -0
  32. package/src/pages/about.md +29 -0
  33. package/src/posts/2024-12-16-markdown-features.md +76 -0
  34. package/src/posts/2024-12-17-welcome-to-sia.md +78 -0
  35. package/src/posts/2024-12-17-welcome-to-static-forge.md +78 -0
  36. package/.prettierignore +0 -3
  37. package/.prettierrc +0 -8
  38. package/lib/helpers.js +0 -37
  39. package/lib/markdown.js +0 -33
  40. package/lib/parse.js +0 -100
  41. package/lib/readconfig.js +0 -18
  42. package/lib/rss.js +0 -63
  43. package/templates/siarc-template.js +0 -53
  44. package/templates/src/_partials/_footer.njk +0 -1
  45. package/templates/src/_partials/_head.njk +0 -35
  46. package/templates/src/_partials/_header.njk +0 -1
  47. package/templates/src/_partials/_layout.njk +0 -12
  48. package/templates/src/_partials/_nav.njk +0 -12
  49. package/templates/src/_partials/page.njk +0 -5
  50. package/templates/src/_partials/post.njk +0 -13
  51. package/templates/src/_partials/posts.njk +0 -19
  52. package/templates/src/assets/android-chrome-192x192.png +0 -0
  53. package/templates/src/assets/android-chrome-512x512.png +0 -0
  54. package/templates/src/assets/apple-touch-icon.png +0 -0
  55. package/templates/src/assets/favicon-16x16.png +0 -0
  56. package/templates/src/assets/favicon-32x32.png +0 -0
  57. package/templates/src/assets/favicon.ico +0 -0
  58. package/templates/src/assets/site.webmanifest +0 -19
  59. package/templates/src/content/index.md +0 -7
  60. package/templates/src/css/markdown.css +0 -1210
  61. package/templates/src/css/theme.css +0 -120
package/lib/new.js CHANGED
@@ -1,46 +1,384 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import config from './readconfig.js'
4
- import { mkdir } from './helpers.js'
5
- import slugify from 'slugify'
6
-
7
- const { app } = config
8
-
9
- export const create = (type, folder) => {
10
- if (!type || !folder) {
11
- console.log('Must specify a type and folder')
12
- process.exit(1)
13
- }
14
-
15
- const slugified = slugify(folder, {
16
- replacement: '-',
17
- lower: true,
18
- strict: true,
19
- locale: 'en',
20
- trim: true,
21
- })
22
-
23
- if (!['page', 'post'].includes(type)) {
24
- console.log('Invalid type. post or page')
25
- process.exit(1)
26
- }
27
-
28
- const date = new Date()
29
- const createdAt = date.toISOString()
30
-
31
- const template = `---
32
- created_at: ${createdAt}
33
- title: ${slugified}
34
- description:
35
- image:
36
- template: ${type}
1
+ import prompts from 'prompts';
2
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { loadConfig } from './config.js';
5
+ import { slugify } from './content.js';
6
+
7
+ /**
8
+ * Get current date in YYYY-MM-DD format
9
+ */
10
+ function getDateString() {
11
+ const now = new Date();
12
+ const year = now.getFullYear();
13
+ const month = String(now.getMonth() + 1).padStart(2, '0');
14
+ const day = String(now.getDate()).padStart(2, '0');
15
+ return `${year}-${month}-${day}`;
16
+ }
17
+
18
+ /**
19
+ * Get current ISO date string
20
+ */
21
+ function getISODate() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ /**
26
+ * Parse tags string into array
27
+ */
28
+ function parseTags(tagsInput) {
29
+ if (!tagsInput || tagsInput.trim() === '') {
30
+ return [];
31
+ }
32
+ return tagsInput
33
+ .split(',')
34
+ .map(tag => tag.trim())
35
+ .filter(tag => tag.length > 0);
36
+ }
37
+
38
+ /**
39
+ * Format tags for YAML
40
+ */
41
+ function formatTags(tags) {
42
+ if (!tags || tags.length === 0) {
43
+ return '[]';
44
+ }
45
+ return `[${tags.join(', ')}]`;
46
+ }
47
+
48
+ /**
49
+ * Create a new post
50
+ */
51
+ function createPost(config, options) {
52
+ const slug = slugify(options.title);
53
+ const date = getDateString();
54
+ const filename = `${date}-${slug}.md`;
55
+ const postsDir = join(config.inputDir, config.collections.posts?.path || 'posts');
56
+ const filePath = join(postsDir, filename);
57
+
58
+ // Ensure directory exists
59
+ if (!existsSync(postsDir)) {
60
+ mkdirSync(postsDir, { recursive: true });
61
+ }
62
+
63
+ let frontMatter = `---
64
+ title: "${options.title}"
65
+ date: ${getISODate()}
66
+ tags: ${formatTags(options.tags)}`;
67
+
68
+ if (options.excerpt) {
69
+ frontMatter += `\nexcerpt: "${options.excerpt}"`;
70
+ }
71
+
72
+ if (options.draft) {
73
+ frontMatter += `\ndraft: true`;
74
+ }
75
+
76
+ frontMatter += `\n---
77
+
78
+ ${options.content || 'Write your post content here...'}
79
+ `;
80
+
81
+ writeFileSync(filePath, frontMatter, 'utf-8');
82
+ return filePath;
83
+ }
84
+
85
+ /**
86
+ * Create a new page
87
+ */
88
+ function createPage(config, options) {
89
+ const slug = slugify(options.title);
90
+ const filename = `${slug}.md`;
91
+ const pagesDir = join(config.inputDir, config.collections.pages?.path || 'pages');
92
+ const filePath = join(pagesDir, filename);
93
+
94
+ // Ensure directory exists
95
+ if (!existsSync(pagesDir)) {
96
+ mkdirSync(pagesDir, { recursive: true });
97
+ }
98
+
99
+ let frontMatter = `---
100
+ title: "${options.title}"
101
+ layout: page`;
102
+
103
+ if (options.permalink) {
104
+ frontMatter += `\npermalink: ${options.permalink}`;
105
+ }
106
+
107
+ frontMatter += `\n---
108
+
109
+ ${options.content || 'Write your page content here...'}
110
+ `;
111
+
112
+ writeFileSync(filePath, frontMatter, 'utf-8');
113
+ return filePath;
114
+ }
115
+
116
+ /**
117
+ * Create a new note (short post)
118
+ */
119
+ function createNote(config, options) {
120
+ const slug = options.title ? slugify(options.title) : 'note';
121
+ const date = getDateString();
122
+ const timestamp = Date.now();
123
+ const filename = `${date}-${slug}-${timestamp}.md`;
124
+ const notesDir = join(config.inputDir, config.collections.notes?.path || 'notes');
125
+ const filePath = join(notesDir, filename);
126
+
127
+ // Ensure directory exists
128
+ if (!existsSync(notesDir)) {
129
+ mkdirSync(notesDir, { recursive: true });
130
+ }
131
+
132
+ const content = `---
133
+ date: ${getISODate()}
134
+ tags: ${formatTags(options.tags)}
37
135
  ---
38
136
 
39
- Add ${type} content here
137
+ ${options.content || options.title || 'Write your note here...'}
138
+ `;
139
+
140
+ writeFileSync(filePath, content, 'utf-8');
141
+ return filePath;
142
+ }
143
+
144
+ /**
145
+ * Prompt for post options
146
+ */
147
+ async function promptForPost(initialTitle) {
148
+ const answers = await prompts([
149
+ {
150
+ type: 'text',
151
+ name: 'title',
152
+ message: 'Post title:',
153
+ initial: initialTitle || '',
154
+ validate: value => value.length > 0 ? true : 'Title is required'
155
+ },
156
+ {
157
+ type: 'text',
158
+ name: 'tags',
159
+ message: 'Tags (comma-separated):',
160
+ initial: ''
161
+ },
162
+ {
163
+ type: 'text',
164
+ name: 'excerpt',
165
+ message: 'Excerpt (optional):',
166
+ initial: ''
167
+ },
168
+ {
169
+ type: 'confirm',
170
+ name: 'draft',
171
+ message: 'Save as draft?',
172
+ initial: true
173
+ }
174
+ ]);
175
+
176
+ if (!answers.title) {
177
+ return null;
178
+ }
179
+
180
+ return {
181
+ title: answers.title,
182
+ tags: parseTags(answers.tags),
183
+ excerpt: answers.excerpt || null,
184
+ draft: answers.draft
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Prompt for page options
190
+ */
191
+ async function promptForPage(initialTitle) {
192
+ const answers = await prompts([
193
+ {
194
+ type: 'text',
195
+ name: 'title',
196
+ message: 'Page title:',
197
+ initial: initialTitle || '',
198
+ validate: value => value.length > 0 ? true : 'Title is required'
199
+ },
200
+ {
201
+ type: 'text',
202
+ name: 'permalink',
203
+ message: 'Custom permalink (optional, e.g., /about/):',
204
+ initial: ''
205
+ }
206
+ ]);
40
207
 
41
- `
208
+ if (!answers.title) {
209
+ return null;
210
+ }
42
211
 
43
- const root = path.join(app.src, app.content, slugified)
44
- mkdir(root)
45
- fs.writeFileSync(path.join(root, `index.md`), template)
212
+ return {
213
+ title: answers.title,
214
+ permalink: answers.permalink || null
215
+ };
46
216
  }
217
+
218
+ /**
219
+ * Prompt for note options
220
+ */
221
+ async function promptForNote(initialContent) {
222
+ const answers = await prompts([
223
+ {
224
+ type: 'text',
225
+ name: 'content',
226
+ message: 'Note content:',
227
+ initial: initialContent || '',
228
+ validate: value => value.length > 0 ? true : 'Content is required'
229
+ },
230
+ {
231
+ type: 'text',
232
+ name: 'tags',
233
+ message: 'Tags (comma-separated):',
234
+ initial: ''
235
+ }
236
+ ]);
237
+
238
+ if (!answers.content) {
239
+ return null;
240
+ }
241
+
242
+ return {
243
+ title: answers.content.substring(0, 50),
244
+ content: answers.content,
245
+ tags: parseTags(answers.tags)
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Prompt for content type
251
+ */
252
+ async function promptForType() {
253
+ const answer = await prompts({
254
+ type: 'select',
255
+ name: 'type',
256
+ message: 'What would you like to create?',
257
+ choices: [
258
+ { title: 'Post', description: 'A blog post with title, date, and tags', value: 'post' },
259
+ { title: 'Page', description: 'A static page (e.g., About, Contact)', value: 'page' },
260
+ { title: 'Note', description: 'A short note or quick thought', value: 'note' }
261
+ ],
262
+ initial: 0
263
+ });
264
+
265
+ return answer.type;
266
+ }
267
+
268
+ /**
269
+ * New command handler for CLI
270
+ */
271
+ export async function newCommand(type, title, cmdOptions = {}) {
272
+ const config = loadConfig(process.cwd());
273
+
274
+ // Quick mode - skip prompts if type and title provided
275
+ const quickMode = cmdOptions.quick || false;
276
+
277
+ // If quick mode with all required args, skip prompts
278
+ if (quickMode && type && title) {
279
+ let filePath;
280
+ const tags = cmdOptions.tags ? parseTags(cmdOptions.tags) : [];
281
+
282
+ switch (type.toLowerCase()) {
283
+ case 'post':
284
+ filePath = createPost(config, {
285
+ title,
286
+ tags,
287
+ draft: cmdOptions.draft !== false,
288
+ excerpt: null
289
+ });
290
+ console.log(`\n✨ Created new post: ${filePath}`);
291
+ if (cmdOptions.draft !== false) {
292
+ console.log(' šŸ“‹ Saved as draft - remove "draft: true" when ready to publish.');
293
+ }
294
+ break;
295
+
296
+ case 'page':
297
+ filePath = createPage(config, { title, permalink: null });
298
+ console.log(`\n✨ Created new page: ${filePath}`);
299
+ break;
300
+
301
+ case 'note':
302
+ filePath = createNote(config, { title, content: title, tags });
303
+ console.log(`\n✨ Created new note: ${filePath}`);
304
+ break;
305
+
306
+ default:
307
+ console.error(`\nāŒ Unknown content type: ${type}`);
308
+ console.log(' Available types: post, page, note\n');
309
+ process.exit(1);
310
+ }
311
+ console.log('');
312
+ return;
313
+ }
314
+
315
+ console.log('\nšŸ“ Create new content\n');
316
+
317
+ // If no type provided, prompt for it
318
+ let contentType = type;
319
+ if (!contentType) {
320
+ contentType = await promptForType();
321
+ if (!contentType) {
322
+ console.log('\nāŒ Cancelled.\n');
323
+ return;
324
+ }
325
+ }
326
+
327
+ let filePath;
328
+ let options;
329
+
330
+ switch (contentType.toLowerCase()) {
331
+ case 'post':
332
+ options = await promptForPost(title);
333
+ if (!options) {
334
+ console.log('\nāŒ Cancelled.\n');
335
+ return;
336
+ }
337
+ // Apply command line options if provided
338
+ if (cmdOptions.tags) {
339
+ options.tags = [...options.tags, ...parseTags(cmdOptions.tags)];
340
+ }
341
+ if (cmdOptions.draft !== undefined) {
342
+ options.draft = cmdOptions.draft;
343
+ }
344
+ filePath = createPost(config, options);
345
+ console.log(`\n✨ Created new post: ${filePath}`);
346
+ if (options.draft) {
347
+ console.log(' šŸ“‹ Saved as draft - remove "draft: true" when ready to publish.');
348
+ }
349
+ break;
350
+
351
+ case 'page':
352
+ options = await promptForPage(title);
353
+ if (!options) {
354
+ console.log('\nāŒ Cancelled.\n');
355
+ return;
356
+ }
357
+ filePath = createPage(config, options);
358
+ console.log(`\n✨ Created new page: ${filePath}`);
359
+ break;
360
+
361
+ case 'note':
362
+ options = await promptForNote(title);
363
+ if (!options) {
364
+ console.log('\nāŒ Cancelled.\n');
365
+ return;
366
+ }
367
+ // Apply command line options if provided
368
+ if (cmdOptions.tags) {
369
+ options.tags = [...(options.tags || []), ...parseTags(cmdOptions.tags)];
370
+ }
371
+ filePath = createNote(config, options);
372
+ console.log(`\n✨ Created new note: ${filePath}`);
373
+ break;
374
+
375
+ default:
376
+ console.error(`\nāŒ Unknown content type: ${contentType}`);
377
+ console.log(' Available types: post, page, note\n');
378
+ process.exit(1);
379
+ }
380
+
381
+ console.log('');
382
+ }
383
+
384
+ export default { newCommand };
package/lib/server.js ADDED
@@ -0,0 +1,257 @@
1
+ import { createServer } from 'http';
2
+ import { readFileSync, existsSync, statSync } from 'fs';
3
+ import { join, extname } from 'path';
4
+ import { WebSocketServer } from 'ws';
5
+ import chokidar from 'chokidar';
6
+ import { loadConfig } from './config.js';
7
+ import { build } from './build.js';
8
+
9
+ // MIME types for common file extensions
10
+ const MIME_TYPES = {
11
+ '.html': 'text/html',
12
+ '.css': 'text/css',
13
+ '.js': 'application/javascript',
14
+ '.json': 'application/json',
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif',
19
+ '.svg': 'image/svg+xml',
20
+ '.webp': 'image/webp',
21
+ '.ico': 'image/x-icon',
22
+ '.woff': 'font/woff',
23
+ '.woff2': 'font/woff2',
24
+ '.ttf': 'font/ttf',
25
+ '.xml': 'application/xml',
26
+ '.txt': 'text/plain',
27
+ '.pdf': 'application/pdf'
28
+ };
29
+
30
+ // Live reload script to inject into HTML pages
31
+ const LIVE_RELOAD_SCRIPT = `
32
+ <script>
33
+ (function() {
34
+ const ws = new WebSocket('ws://' + window.location.hostname + ':{{WS_PORT}}');
35
+ ws.onmessage = function(event) {
36
+ if (event.data === 'reload') {
37
+ console.log('[Sia] Reloading...');
38
+ window.location.reload();
39
+ }
40
+ };
41
+ ws.onopen = function() {
42
+ console.log('[Sia] Live reload connected');
43
+ };
44
+ ws.onclose = function() {
45
+ console.log('[Sia] Live reload disconnected');
46
+ // Try to reconnect
47
+ setTimeout(function() {
48
+ window.location.reload();
49
+ }, 1000);
50
+ };
51
+ })();
52
+ </script>
53
+ `;
54
+
55
+ /**
56
+ * Inject live reload script into HTML
57
+ */
58
+ function injectLiveReload(html, wsPort) {
59
+ const script = LIVE_RELOAD_SCRIPT.replace('{{WS_PORT}}', wsPort);
60
+ return html.replace('</body>', script + '</body>');
61
+ }
62
+
63
+ /**
64
+ * Create the HTTP server
65
+ */
66
+ function createHttpServer(config, wsPort) {
67
+ const server = createServer((req, res) => {
68
+ // Parse URL
69
+ let urlPath = req.url.split('?')[0];
70
+
71
+ // Handle root and trailing slashes
72
+ if (urlPath.endsWith('/')) {
73
+ urlPath += 'index.html';
74
+ }
75
+
76
+ // Build the file path
77
+ let filePath = join(config.outputDir, urlPath);
78
+
79
+ // Check if file exists, try adding .html
80
+ if (!existsSync(filePath)) {
81
+ const htmlPath = filePath + '.html';
82
+ if (existsSync(htmlPath)) {
83
+ filePath = htmlPath;
84
+ } else {
85
+ // Try index.html in directory
86
+ const indexPath = join(filePath, 'index.html');
87
+ if (existsSync(indexPath)) {
88
+ filePath = indexPath;
89
+ }
90
+ }
91
+ }
92
+
93
+ // 404 if file still doesn't exist
94
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
95
+ res.writeHead(404, { 'Content-Type': 'text/html' });
96
+ res.end('<h1>404 - Not Found</h1>');
97
+ console.log(` 404 ${req.url}`);
98
+ return;
99
+ }
100
+
101
+ // Get MIME type
102
+ const ext = extname(filePath).toLowerCase();
103
+ const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
104
+
105
+ // Read and serve file
106
+ try {
107
+ let content = readFileSync(filePath);
108
+
109
+ // Inject live reload script into HTML files
110
+ if (ext === '.html') {
111
+ content = injectLiveReload(content.toString(), wsPort);
112
+ }
113
+
114
+ res.writeHead(200, { 'Content-Type': mimeType });
115
+ res.end(content);
116
+ console.log(` 200 ${req.url}`);
117
+ } catch (err) {
118
+ res.writeHead(500, { 'Content-Type': 'text/html' });
119
+ res.end('<h1>500 - Internal Server Error</h1>');
120
+ console.error(` 500 ${req.url}: ${err.message}`);
121
+ }
122
+ });
123
+
124
+ return server;
125
+ }
126
+
127
+ /**
128
+ * Create WebSocket server for live reload
129
+ */
130
+ function createWsServer(port) {
131
+ const wss = new WebSocketServer({ port });
132
+
133
+ wss.on('connection', (ws) => {
134
+ console.log('šŸ”Œ Live reload client connected');
135
+ });
136
+
137
+ return wss;
138
+ }
139
+
140
+ /**
141
+ * Notify all connected clients to reload
142
+ */
143
+ function notifyReload(wss) {
144
+ wss.clients.forEach((client) => {
145
+ if (client.readyState === 1) { // WebSocket.OPEN
146
+ client.send('reload');
147
+ }
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Set up file watcher
153
+ */
154
+ function setupWatcher(config, wss) {
155
+ // Watch source files and config
156
+ const watchPaths = [
157
+ config.inputDir,
158
+ config.layoutsDir,
159
+ config.includesDir,
160
+ join(config.rootDir, '_config.yml'),
161
+ join(config.rootDir, '_config.json'),
162
+ join(config.rootDir, 'styles')
163
+ ].filter(p => existsSync(p));
164
+
165
+ const watcher = chokidar.watch(watchPaths, {
166
+ ignored: /(^|[\/\\])\../, // Ignore dot files
167
+ persistent: true,
168
+ ignoreInitial: true
169
+ });
170
+
171
+ let rebuildTimeout = null;
172
+
173
+ const triggerRebuild = async (event, path) => {
174
+ // Debounce rapid changes
175
+ if (rebuildTimeout) {
176
+ clearTimeout(rebuildTimeout);
177
+ }
178
+
179
+ rebuildTimeout = setTimeout(async () => {
180
+ console.log(`\nšŸ“ ${event}: ${path}`);
181
+ console.log('šŸ”„ Rebuilding...\n');
182
+
183
+ try {
184
+ await build({ clean: false, rootDir: config.rootDir });
185
+ notifyReload(wss);
186
+ } catch (err) {
187
+ console.error('āŒ Rebuild failed:', err.message);
188
+ }
189
+ }, 100);
190
+ };
191
+
192
+ watcher.on('change', (path) => triggerRebuild('Changed', path));
193
+ watcher.on('add', (path) => triggerRebuild('Added', path));
194
+ watcher.on('unlink', (path) => triggerRebuild('Removed', path));
195
+
196
+ return watcher;
197
+ }
198
+
199
+ /**
200
+ * Start the development server
201
+ */
202
+ export async function startServer(options = {}) {
203
+ const config = loadConfig(options.rootDir || process.cwd());
204
+ const httpPort = parseInt(options.port) || config.server.port || 3000;
205
+ const wsPort = httpPort + 1;
206
+
207
+ console.log('\n⚔ Sia - Development Server\n');
208
+
209
+ // Initial build
210
+ await build({ clean: true, rootDir: config.rootDir });
211
+
212
+ // Create servers
213
+ const httpServer = createHttpServer(config, wsPort);
214
+ const wss = createWsServer(wsPort);
215
+
216
+ // Start HTTP server
217
+ httpServer.listen(httpPort, () => {
218
+ console.log(`\nšŸš€ Server running at http://localhost:${httpPort}`);
219
+ console.log(`šŸ”„ Live reload on ws://localhost:${wsPort}`);
220
+ console.log('\nšŸ‘€ Watching for changes...\n');
221
+ });
222
+
223
+ // Set up file watcher
224
+ const watcher = setupWatcher(config, wss);
225
+
226
+ // Handle shutdown
227
+ const shutdown = () => {
228
+ console.log('\n\nšŸ‘‹ Shutting down...');
229
+ watcher.close();
230
+ wss.close();
231
+ httpServer.close();
232
+ process.exit(0);
233
+ };
234
+
235
+ process.on('SIGINT', shutdown);
236
+ process.on('SIGTERM', shutdown);
237
+
238
+ return { httpServer, wss, watcher };
239
+ }
240
+
241
+ /**
242
+ * Dev command handler for CLI
243
+ */
244
+ export async function devCommand(options) {
245
+ try {
246
+ await startServer({
247
+ port: options.port,
248
+ rootDir: process.cwd()
249
+ });
250
+ } catch (err) {
251
+ console.error('āŒ Server failed to start:', err.message);
252
+ process.exit(1);
253
+ }
254
+ }
255
+
256
+ export default { startServer, devCommand };
257
+