mdboard 1.2.0 → 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 (40) hide show
  1. package/bin.js +130 -44
  2. package/index.html +3321 -1195
  3. package/package.json +10 -11
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +338 -0
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +175 -0
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +180 -0
  28. package/src/core/config.js +551 -0
  29. package/src/core/history.js +146 -0
  30. package/src/core/scanner.js +521 -0
  31. package/{workspace.js → src/core/workspace.js} +0 -15
  32. package/{yaml.js → src/core/yaml.js} +5 -1
  33. package/src/server/api.js +616 -0
  34. package/{server.js → src/server/server.js} +180 -132
  35. package/{watcher.js → src/server/watcher.js} +40 -9
  36. package/api.js +0 -752
  37. package/config.js +0 -73
  38. package/defaults.json +0 -43
  39. package/init.js +0 -109
  40. package/scanner.js +0 -491
@@ -2,25 +2,25 @@
2
2
  /**
3
3
  * mdboard — Project Dashboard Server
4
4
  *
5
- * Zero-dependency Node.js server that reads markdown project management
6
- * files and serves a visual dashboard + JSON API.
7
- *
8
- * Supports multi-repo workspaces via workspace.json.
5
+ * Config-driven server that reads markdown project management files
6
+ * and serves a JSON API. No hardcoded entity names.
9
7
  *
10
8
  * Usage:
11
9
  * mdboard --project /path/to/workspace --port 3333
12
10
  */
13
11
 
12
+ 'use strict';
13
+
14
14
  const http = require('http');
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
- const { loadConfig, deepMerge } = require('./config');
19
- const { createModel, scanSource, computeProgress, updateMarkdownFile, mergeResults,
20
- createTask, createMilestone, createEpic, createSprint, archiveItem } = require('./scanner');
21
- const { setupWatchers } = require('./watcher');
18
+ const { loadConfig, deepMerge } = require('../core/config');
19
+ const { createModel, scanSource, computeProgress, updateEntity, createEntity, deleteEntity, mergeResults } = require('../core/scanner');
20
+ const { setupWatchers, closeWatchers } = require('./watcher');
22
21
  const { handleApi } = require('./api');
23
- const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('./workspace');
22
+ const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
23
+ const { registerProject, getHistory, isValidProject } = require('../core/history');
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // CLI argument parsing
@@ -52,11 +52,11 @@ for (let i = 0; i < args.length; i++) {
52
52
  }
53
53
  }
54
54
 
55
- const projectPath = path.join(projectDir, 'project');
56
- const boardDir = __dirname;
55
+ let projectPath = path.join(projectDir, 'project');
56
+ const boardDir = path.resolve(__dirname, '..', '..');
57
57
 
58
58
  // ---------------------------------------------------------------------------
59
- // Load configuration
59
+ // Load configuration (new config engine)
60
60
  // ---------------------------------------------------------------------------
61
61
  let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
62
62
 
@@ -86,11 +86,10 @@ function broadcast(data) {
86
86
  // ---------------------------------------------------------------------------
87
87
  // Model & scanning — sourceStates tracks per-source state
88
88
  // ---------------------------------------------------------------------------
89
- let model = createModel();
89
+ let model = createModel(config);
90
90
  const sourceStates = new Map();
91
91
 
92
92
  function loadSourceConfig(source) {
93
- // Try to load per-source mdboard.json
94
93
  if (!source._repoRoot) return config;
95
94
  const candidates = [
96
95
  path.join(source._resolvedPath, 'mdboard.json'),
@@ -107,11 +106,10 @@ function loadSourceConfig(source) {
107
106
  }
108
107
 
109
108
  function scanAll() {
110
- model = createModel();
109
+ model = createModel(config);
111
110
  sourceStates.clear();
112
111
 
113
112
  if (workspace && sources.length > 0) {
114
- // Multi-source mode
115
113
  const results = [];
116
114
  for (const source of sources) {
117
115
  const sourcePath = source._resolvedPath;
@@ -126,21 +124,21 @@ function scanAll() {
126
124
  readonly,
127
125
  };
128
126
 
129
- // Load per-source config
130
127
  const srcConfig = loadSourceConfig(source);
131
128
  const result = scanSource(sourcePath, srcConfig, meta);
132
129
 
133
- // Add author info if showAuthor is enabled
134
130
  if (workspace.settings && workspace.settings.showAuthor) {
135
131
  const repoRoot = source._repoRoot;
136
132
  if (repoRoot) {
137
- for (const task of result.tasks) {
138
- task._author = getGitAuthor(repoRoot, task._file);
133
+ const leafType = getLeafType(srcConfig);
134
+ if (leafType && result.entities[leafType]) {
135
+ for (const item of result.entities[leafType]) {
136
+ item._author = getGitAuthor(repoRoot, item._file);
137
+ }
139
138
  }
140
139
  }
141
140
  }
142
141
 
143
- // Store per-source state
144
142
  sourceStates.set(source.name, {
145
143
  model: result,
146
144
  config: srcConfig,
@@ -153,25 +151,59 @@ function scanAll() {
153
151
  }
154
152
  mergeResults(model, results);
155
153
  } else {
156
- // Legacy single-source mode
157
154
  const result = scanSource(projectPath, config, {});
158
155
  mergeResults(model, [result]);
159
156
  }
160
157
 
161
- computeProgress(model, config.completedStatus);
158
+ computeProgress(model, config);
159
+ }
160
+
161
+ function getLeafType(cfg) {
162
+ const { flattenHierarchy } = require('../core/config');
163
+ const flat = flattenHierarchy(cfg);
164
+ const h = flat.filter(e => !e.standalone);
165
+ return h.length > 0 ? h[h.length - 1].type : null;
162
166
  }
163
167
 
164
168
  // ---------------------------------------------------------------------------
165
- // File update helper — resolves correct projectPath per source
169
+ // File update helper
166
170
  // ---------------------------------------------------------------------------
167
171
  function updateFile(sourceName, relFile, updates) {
172
+ let sourcePath = projectPath;
173
+ if (sourceName && sources.length > 0) {
174
+ const source = sources.find(s => s.name === sourceName);
175
+ if (source && source._resolvedPath) sourcePath = source._resolvedPath;
176
+ }
177
+ return updateEntity(sourcePath, relFile, updates);
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // CRUD helpers — dynamic, uses entityType not collection name
182
+ // ---------------------------------------------------------------------------
183
+ function createItemFn(sourceName, entityType, data) {
184
+ let sourcePath = projectPath;
185
+ let cfg = config;
186
+
168
187
  if (sourceName && sources.length > 0) {
169
188
  const source = sources.find(s => s.name === sourceName);
170
189
  if (source && source._resolvedPath) {
171
- return updateMarkdownFile(source._resolvedPath, relFile, updates);
190
+ sourcePath = source._resolvedPath;
191
+ const state = sourceStates.get(sourceName);
192
+ if (state) cfg = state.config;
172
193
  }
173
194
  }
174
- return updateMarkdownFile(projectPath, relFile, updates);
195
+
196
+ const existingItems = model.entities[entityType] || [];
197
+ return createEntity(sourcePath, cfg, entityType, data, existingItems);
198
+ }
199
+
200
+ function deleteItemFn(sourceName, item) {
201
+ let sourcePath = projectPath;
202
+ if (sourceName && sources.length > 0) {
203
+ const source = sources.find(s => s.name === sourceName);
204
+ if (source && source._resolvedPath) sourcePath = source._resolvedPath;
205
+ }
206
+ return deleteEntity(sourcePath, item);
175
207
  }
176
208
 
177
209
  // ---------------------------------------------------------------------------
@@ -179,7 +211,6 @@ function updateFile(sourceName, relFile, updates) {
179
211
  // ---------------------------------------------------------------------------
180
212
  async function doSyncRemotes() {
181
213
  if (!workspace || sources.length === 0) return;
182
-
183
214
  const remoteSources = sources.filter(s => s.type === 'remote');
184
215
  if (remoteSources.length === 0) return;
185
216
 
@@ -225,6 +256,15 @@ function findCustomCss() {
225
256
  return null;
226
257
  }
227
258
 
259
+ // Cache index.html at startup
260
+ let _indexHtmlCache = null;
261
+ function getIndexHtml() {
262
+ if (!_indexHtmlCache) {
263
+ try { _indexHtmlCache = fs.readFileSync(path.join(boardDir, 'index.html')); } catch { /* */ }
264
+ }
265
+ return _indexHtmlCache;
266
+ }
267
+
228
268
  function serveStatic(req, res) {
229
269
  const url = new URL(req.url, `http://localhost:${port}`);
230
270
 
@@ -243,108 +283,57 @@ function serveStatic(req, res) {
243
283
  return;
244
284
  }
245
285
 
246
- if (url.pathname === '/logo') {
247
- if (config.logo && !config.logo.startsWith('http')) {
248
- const logoPath = path.resolve(projectDir, config.logo);
249
- if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
250
- try {
251
- const logoContent = fs.readFileSync(logoPath);
252
- const logoExt = path.extname(logoPath);
253
- const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
254
- res.writeHead(200, { 'Content-Type': logoMime });
255
- res.end(logoContent);
256
- return;
257
- } catch { /* fall through */ }
258
- }
259
- }
260
- res.writeHead(404);
261
- res.end('Not found');
262
- return;
263
- }
264
-
265
- let filePath = path.join(boardDir, 'index.html');
266
- if (!filePath.startsWith(boardDir)) {
267
- res.writeHead(403);
268
- res.end('Forbidden');
269
- return;
270
- }
271
-
272
- const ext = path.extname(filePath);
273
- const contentType = MIME_TYPES[ext] || 'text/plain';
274
-
275
- try {
276
- const content = fs.readFileSync(filePath);
277
- res.writeHead(200, { 'Content-Type': contentType });
286
+ const content = getIndexHtml();
287
+ if (content) {
288
+ res.writeHead(200, { 'Content-Type': 'text/html' });
278
289
  res.end(content);
279
- } catch {
290
+ } else {
280
291
  res.writeHead(404);
281
292
  res.end('Not found');
282
293
  }
283
294
  }
284
295
 
285
296
  // ---------------------------------------------------------------------------
286
- // HTTP server
287
- // ---------------------------------------------------------------------------
288
- // ---------------------------------------------------------------------------
289
- // CRUD helpers — resolve source path and delegate to scanner
297
+ // API context
290
298
  // ---------------------------------------------------------------------------
291
- function createItemFn(sourceName, collection, data) {
292
- let sourcePath = projectPath;
293
- let cfg = config;
294
-
295
- if (sourceName && sources.length > 0) {
296
- const source = sources.find(s => s.name === sourceName);
297
- if (source && source._resolvedPath) {
298
- sourcePath = source._resolvedPath;
299
- const state = sourceStates.get(sourceName);
300
- if (state) cfg = state.config;
301
- }
302
- }
303
-
304
- // Use ALL items across sources to compute next ID — prevents cross-source collisions
305
- switch (collection) {
306
- case 'tasks':
307
- return createTask(sourcePath, cfg, data, model.tasks);
308
- case 'milestones':
309
- return createMilestone(sourcePath, cfg, data, model.milestones);
310
- case 'epics':
311
- return createEpic(sourcePath, cfg, data, model.epics);
312
- case 'sprints':
313
- return createSprint(sourcePath, cfg, data, model.sprints);
314
- default:
315
- throw new Error('Unknown collection: ' + collection);
316
- }
317
- }
318
-
319
- function archiveItemFn(sourceName, item) {
320
- let sourcePath = projectPath;
321
-
322
- if (sourceName && sources.length > 0) {
323
- const source = sources.find(s => s.name === sourceName);
324
- if (source && source._resolvedPath) {
325
- sourcePath = source._resolvedPath;
326
- }
327
- }
328
-
329
- return archiveItem(sourcePath, item);
330
- }
331
-
332
299
  const apiCtx = {
333
300
  get model() { return model; },
334
301
  get config() { return config; },
335
302
  port,
336
- projectDir,
303
+ get projectDir() { return projectDir; },
337
304
  get sources() { return sources; },
338
305
  get sourceStates() { return sourceStates; },
339
306
  sseClients,
340
307
  broadcast,
341
308
  updateFile,
342
309
  rescanAll: () => scanAll(),
310
+ reloadConfig: () => reloadConfig(),
343
311
  syncRemotes: doSyncRemotes,
344
312
  createItem: createItemFn,
345
- archiveItem: archiveItemFn,
313
+ deleteItem: deleteItemFn,
314
+ get hasProject() {
315
+ return fs.existsSync(path.join(projectDir, 'project')) ||
316
+ (workspace && sources.length > 0);
317
+ },
318
+ loadProject: loadProject,
319
+ getProjectHistory: () => getHistory(projectDir),
320
+ get workspacePath() {
321
+ return workspace ? workspace._path : (workspacePath || path.join(projectDir, 'workspace.json'));
322
+ },
323
+ onWorkspaceChange() {
324
+ workspace = loadWorkspace(projectDir, workspacePath);
325
+ sources = workspace ? workspace.sources : [];
326
+ closeWatchers(activeWatchers);
327
+ scanAll();
328
+ activeWatchers = setupWatchers(buildWatcherOpts());
329
+ doSyncRemotes().catch(() => {});
330
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
331
+ },
346
332
  };
347
333
 
334
+ // ---------------------------------------------------------------------------
335
+ // HTTP server
336
+ // ---------------------------------------------------------------------------
348
337
  const server = http.createServer(async (req, res) => {
349
338
  try {
350
339
  if (req.url.startsWith('/api/')) {
@@ -371,33 +360,71 @@ if (!workspace && !fs.existsSync(projectPath)) {
371
360
  scanAll();
372
361
 
373
362
  // Setup watchers
374
- const projectDirs = [];
375
- const rootDirs = [projectDir];
376
-
377
- if (workspace && sources.length > 0) {
378
- for (const s of sources) {
379
- if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
380
- projectDirs.push(s._resolvedPath);
363
+ function buildWatcherOpts() {
364
+ const pDirs = [];
365
+ const rDirs = [projectDir];
366
+ if (workspace && sources.length > 0) {
367
+ for (const s of sources) {
368
+ if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
369
+ pDirs.push(s._resolvedPath);
370
+ }
381
371
  }
372
+ } else if (fs.existsSync(projectPath)) {
373
+ pDirs.push(projectPath);
382
374
  }
383
- } else if (fs.existsSync(projectPath)) {
384
- projectDirs.push(projectPath);
375
+ return {
376
+ projectDirs: pDirs,
377
+ rootDirs: rDirs,
378
+ onScan: () => scanAll(),
379
+ onConfigReload: () => reloadConfig(),
380
+ onBroadcast: (data) => broadcast(data),
381
+ workspacePath: workspace ? workspace._path : null,
382
+ onWorkspaceChange: () => {
383
+ workspace = loadWorkspace(projectDir, workspacePath);
384
+ sources = workspace ? workspace.sources : [];
385
+ scanAll();
386
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
387
+ },
388
+ };
385
389
  }
386
390
 
387
- setupWatchers({
388
- projectDirs,
389
- rootDirs,
390
- onScan: () => scanAll(),
391
- onConfigReload: () => reloadConfig(),
392
- onBroadcast: (data) => broadcast(data),
393
- workspacePath: workspace ? workspace._path : null,
394
- onWorkspaceChange: () => {
395
- workspace = loadWorkspace(projectDir, workspacePath);
396
- sources = workspace ? workspace.sources : [];
397
- scanAll();
398
- broadcast({ type: 'update', timestamp: new Date().toISOString() });
399
- },
400
- });
391
+ let activeWatchers = setupWatchers(buildWatcherOpts());
392
+
393
+ // Register current project in history
394
+ if (isValidProject(projectDir)) {
395
+ const projName = (model.project && model.project.name) || path.basename(projectDir);
396
+ registerProject(projectDir, projName);
397
+ }
398
+
399
+ /**
400
+ * Load a different project directory dynamically.
401
+ */
402
+ function loadProject(newProjectDir) {
403
+ closeWatchers(activeWatchers);
404
+ if (syncInterval) clearInterval(syncInterval);
405
+ syncInterval = null;
406
+
407
+ projectDir = path.resolve(newProjectDir);
408
+ projectPath = path.join(projectDir, 'project');
409
+ workspacePath = null;
410
+
411
+ // Clear startup preset so each project resolves its own
412
+ delete process.env.MDBOARD_PRESET;
413
+
414
+ config = loadConfig(projectDir);
415
+ workspace = loadWorkspace(projectDir, null);
416
+ sources = workspace ? workspace.sources : [];
417
+
418
+ scanAll();
419
+ activeWatchers = setupWatchers(buildWatcherOpts());
420
+
421
+ doSyncRemotes().catch(() => {});
422
+ startSyncInterval();
423
+
424
+ const projName = (model.project && model.project.name) || path.basename(projectDir);
425
+ registerProject(projectDir, projName);
426
+ broadcast({ type: 'project-changed', projectDir });
427
+ }
401
428
 
402
429
  // Initial async sync + start periodic sync
403
430
  doSyncRemotes().catch(() => {});
@@ -407,11 +434,27 @@ server.listen(port, () => {
407
434
  const sourceInfo = workspace && sources.length > 0
408
435
  ? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
409
436
  : '';
437
+ const watchCount = activeWatchers.length;
438
+
439
+ // Resolve leaf type for tips
440
+ const { flattenHierarchy } = require('../core/config');
441
+ const flat = flattenHierarchy(config);
442
+ const hierarchy = flat.filter(e => !e.standalone);
443
+ const leafType = hierarchy.length > 0 ? hierarchy[hierarchy.length - 1].type : null;
444
+
410
445
  console.log(`
411
446
  mdboard — Project Dashboard
412
447
  Project: ${projectDir}
413
- Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
414
- ${projectDirs.length > 0 ? '\n Watching ' + projectDirs.length + ' director' + (projectDirs.length === 1 ? 'y' : 'ies') + ' for changes...' : ''}
448
+ Server: http://localhost:${port}${config._entitiesPath ? '\n Config: ' + config._entitiesPath : ''}${sourceInfo}
449
+ ${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
450
+ Open in browser:
451
+ http://localhost:${port}
452
+
453
+ Quick start:
454
+ mdboard config Setup preferences & AI skill
455
+ mdboard preset list Switch methodology (scrum, kanban, ...)
456
+ mdboard skill Add AI skill to your IDE${leafType ? '\n mdboard create ' + leafType + ' "title" Create a new ' + leafType : ''}
457
+ mdboard --help Show all commands
415
458
  `);
416
459
  });
417
460
 
@@ -426,6 +469,7 @@ server.on('error', (err) => {
426
469
  process.on('SIGINT', () => {
427
470
  console.log('\n Shutting down...');
428
471
  if (syncInterval) clearInterval(syncInterval);
472
+ closeWatchers(activeWatchers);
429
473
  for (const client of sseClients) {
430
474
  try { client.end(); } catch { /* ignore */ }
431
475
  }
@@ -434,5 +478,9 @@ process.on('SIGINT', () => {
434
478
 
435
479
  process.on('SIGTERM', () => {
436
480
  if (syncInterval) clearInterval(syncInterval);
481
+ closeWatchers(activeWatchers);
437
482
  server.close(() => process.exit(0));
438
483
  });
484
+
485
+ // Export for programmatic use in tests
486
+ module.exports = { server, scanAll, apiCtx };
@@ -14,12 +14,13 @@ const watchTimers = new Map();
14
14
  *
15
15
  * @param {string} dir - Directory to watch recursively
16
16
  * @param {object} callbacks - { onChange(filename), onConfigChange(), onCssChange(filename) }
17
+ * @returns {fs.FSWatcher|null}
17
18
  */
18
19
  function watchDir(dir, callbacks) {
19
- if (!fs.existsSync(dir)) return;
20
+ if (!fs.existsSync(dir)) return null;
20
21
 
21
22
  try {
22
- fs.watch(dir, { recursive: true }, (eventType, filename) => {
23
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
23
24
  if (!filename) return;
24
25
 
25
26
  const isMd = filename.endsWith('.md');
@@ -37,8 +38,10 @@ function watchDir(dir, callbacks) {
37
38
  if (callbacks.onChange) callbacks.onChange(filename, isCss);
38
39
  }, 200));
39
40
  });
41
+ return watcher;
40
42
  } catch {
41
43
  console.warn(' Warning: file watching unavailable for ' + dir);
44
+ return null;
42
45
  }
43
46
  }
44
47
 
@@ -47,12 +50,13 @@ function watchDir(dir, callbacks) {
47
50
  *
48
51
  * @param {string} filePath - Absolute path to file
49
52
  * @param {function} onChange - Callback on change
53
+ * @returns {fs.FSWatcher|null}
50
54
  */
51
55
  function watchFile(filePath, onChange) {
52
- if (!fs.existsSync(filePath)) return;
56
+ if (!fs.existsSync(filePath)) return null;
53
57
 
54
58
  try {
55
- fs.watch(filePath, (eventType) => {
59
+ const watcher = fs.watch(filePath, (eventType) => {
56
60
  const key = 'file:' + filePath;
57
61
  if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
58
62
 
@@ -61,11 +65,31 @@ function watchFile(filePath, onChange) {
61
65
  if (onChange) onChange();
62
66
  }, 200));
63
67
  });
68
+ return watcher;
64
69
  } catch {
65
- // Non-critical
70
+ return null;
66
71
  }
67
72
  }
68
73
 
74
+ /**
75
+ * Close all FSWatcher instances and clear pending timers.
76
+ *
77
+ * @param {Array<fs.FSWatcher|null>} watchers - Array of watchers to close
78
+ */
79
+ function closeWatchers(watchers) {
80
+ if (!watchers) return;
81
+ for (const w of watchers) {
82
+ if (w) {
83
+ try { w.close(); } catch { /* ignore */ }
84
+ }
85
+ }
86
+ // Clear all pending debounce timers
87
+ for (const [key, timer] of watchTimers) {
88
+ clearTimeout(timer);
89
+ }
90
+ watchTimers.clear();
91
+ }
92
+
69
93
  /**
70
94
  * Setup watchers for project directories and workspace-level config.
71
95
  *
@@ -77,13 +101,15 @@ function watchFile(filePath, onChange) {
77
101
  * @param {function} opts.onBroadcast - Called with event data to broadcast via SSE
78
102
  * @param {string} [opts.workspacePath] - Path to workspace.json to watch
79
103
  * @param {function} [opts.onWorkspaceChange] - Called when workspace.json changes
104
+ * @returns {Array<fs.FSWatcher|null>} - Array of active watchers
80
105
  */
81
106
  function setupWatchers(opts) {
82
107
  const { projectDirs, rootDirs, onScan, onConfigReload, onBroadcast, workspacePath, onWorkspaceChange } = opts;
108
+ const watchers = [];
83
109
 
84
110
  // Watch each project directory recursively
85
111
  for (const dir of projectDirs) {
86
- watchDir(dir, {
112
+ const w = watchDir(dir, {
87
113
  onConfigChange: () => {
88
114
  if (onConfigReload) onConfigReload();
89
115
  },
@@ -94,12 +120,13 @@ function setupWatchers(opts) {
94
120
  if (onBroadcast) onBroadcast(eventData);
95
121
  },
96
122
  });
123
+ if (w) watchers.push(w);
97
124
  }
98
125
 
99
126
  // Watch root directories for workspace-level mdboard.json and mdboard.css
100
127
  for (const dir of rootDirs) {
101
128
  try {
102
- fs.watch(dir, (eventType, filename) => {
129
+ const w = fs.watch(dir, (eventType, filename) => {
103
130
  if (!filename) return;
104
131
  if (filename !== 'mdboard.json' && filename !== 'mdboard.css') return;
105
132
 
@@ -117,6 +144,7 @@ function setupWatchers(opts) {
117
144
  if (onBroadcast) onBroadcast(eventData);
118
145
  }, 200));
119
146
  });
147
+ watchers.push(w);
120
148
  } catch {
121
149
  // Non-critical
122
150
  }
@@ -124,8 +152,11 @@ function setupWatchers(opts) {
124
152
 
125
153
  // Watch workspace.json
126
154
  if (workspacePath && onWorkspaceChange) {
127
- watchFile(workspacePath, onWorkspaceChange);
155
+ const w = watchFile(workspacePath, onWorkspaceChange);
156
+ if (w) watchers.push(w);
128
157
  }
158
+
159
+ return watchers;
129
160
  }
130
161
 
131
- module.exports = { setupWatchers, watchDir, watchFile };
162
+ module.exports = { setupWatchers, closeWatchers, watchDir, watchFile };