mdboard 1.1.0 → 1.3.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.
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mdboard — Project Dashboard Server
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.
9
+ *
10
+ * Usage:
11
+ * mdboard --project /path/to/workspace --port 3333
12
+ */
13
+
14
+ const http = require('http');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const { loadConfig, deepMerge } = require('../core/config');
19
+ const { createModel, scanSource, computeProgress, computeAgentProps, updateMarkdownFile, mergeResults,
20
+ createTask, createMilestone, createEpic, createSprint, createNote, archiveItem } = require('../core/scanner');
21
+ const { scanAgentConfigs } = require('../core/agent-scanner');
22
+ const { setupWatchers, closeWatchers } = require('./watcher');
23
+ const { handleApi } = require('./api');
24
+ const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
25
+ const { registerProject, getHistory, isValidProject } = require('../core/history');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // CLI argument parsing
29
+ // ---------------------------------------------------------------------------
30
+ const args = process.argv.slice(2);
31
+ let projectDir = process.cwd();
32
+ let port = 3333;
33
+ let workspacePath = process.env.MDBOARD_WORKSPACE || null;
34
+
35
+ for (let i = 0; i < args.length; i++) {
36
+ switch (args[i]) {
37
+ case '--project':
38
+ projectDir = path.resolve(args[++i] || '.');
39
+ break;
40
+ case '--port':
41
+ port = parseInt(args[++i], 10) || 3333;
42
+ break;
43
+ case '--config':
44
+ i++;
45
+ break;
46
+ case '--workspace':
47
+ workspacePath = path.resolve(args[++i] || '.');
48
+ break;
49
+ case 'init':
50
+ case '-h': case '--help':
51
+ case '-v': case '--version':
52
+ case 'help': case 'cache':
53
+ break;
54
+ }
55
+ }
56
+
57
+ let projectPath = path.join(projectDir, 'project');
58
+ const boardDir = path.resolve(__dirname, '..', '..');
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Load configuration
62
+ // ---------------------------------------------------------------------------
63
+ let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
64
+
65
+ function reloadConfig() {
66
+ config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Workspace
71
+ // ---------------------------------------------------------------------------
72
+ let workspace = loadWorkspace(projectDir, workspacePath);
73
+ let sources = workspace ? workspace.sources : [];
74
+ let syncInterval = null;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // SSE clients
78
+ // ---------------------------------------------------------------------------
79
+ const sseClients = new Set();
80
+
81
+ function broadcast(data) {
82
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
83
+ for (const res of sseClients) {
84
+ try { res.write(payload); } catch { sseClients.delete(res); }
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Model & scanning — sourceStates tracks per-source state
90
+ // ---------------------------------------------------------------------------
91
+ let model = createModel();
92
+ const sourceStates = new Map();
93
+ let aiSuggestions = null;
94
+
95
+ function refreshAiSuggestions() {
96
+ try { aiSuggestions = scanAgentConfigs(projectDir); }
97
+ catch { aiSuggestions = { skills: [], agents: [], mcps: [], commands: [], context: [] }; }
98
+ }
99
+
100
+ function loadSourceConfig(source) {
101
+ // Try to load per-source mdboard.json
102
+ if (!source._repoRoot) return config;
103
+ const candidates = [
104
+ path.join(source._resolvedPath, 'mdboard.json'),
105
+ path.join(source._repoRoot, 'mdboard.json'),
106
+ ];
107
+ for (const p of candidates) {
108
+ try {
109
+ const raw = fs.readFileSync(p, 'utf-8');
110
+ const srcCfg = JSON.parse(raw);
111
+ return deepMerge(config, srcCfg);
112
+ } catch { continue; }
113
+ }
114
+ return config;
115
+ }
116
+
117
+ function scanAll() {
118
+ model = createModel();
119
+ sourceStates.clear();
120
+
121
+ if (workspace && sources.length > 0) {
122
+ // Multi-source mode
123
+ const results = [];
124
+ for (const source of sources) {
125
+ const sourcePath = source._resolvedPath;
126
+ if (!sourcePath) continue;
127
+
128
+ const readonly = source.readonly != null ? source.readonly : (source.type === 'remote');
129
+ const meta = {
130
+ name: source.name,
131
+ label: source.label || source.name,
132
+ color: source.color || null,
133
+ type: source.type,
134
+ readonly,
135
+ };
136
+
137
+ // Load per-source config
138
+ const srcConfig = loadSourceConfig(source);
139
+ const result = scanSource(sourcePath, srcConfig, meta);
140
+
141
+ // Add author info if showAuthor is enabled
142
+ if (workspace.settings && workspace.settings.showAuthor) {
143
+ const repoRoot = source._repoRoot;
144
+ if (repoRoot) {
145
+ for (const task of result.tasks) {
146
+ task._author = getGitAuthor(repoRoot, task._file);
147
+ }
148
+ }
149
+ }
150
+
151
+ // Store per-source state
152
+ sourceStates.set(source.name, {
153
+ model: result,
154
+ config: srcConfig,
155
+ sourcePath,
156
+ repoRoot: source._repoRoot,
157
+ source,
158
+ });
159
+
160
+ results.push(result);
161
+ }
162
+ mergeResults(model, results);
163
+ } else {
164
+ // Legacy single-source mode
165
+ const result = scanSource(projectPath, config, {});
166
+ mergeResults(model, [result]);
167
+ }
168
+
169
+ computeProgress(model, config.completedStatus);
170
+ computeAgentProps(model);
171
+ refreshAiSuggestions();
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // File update helper — resolves correct projectPath per source
176
+ // ---------------------------------------------------------------------------
177
+ function updateFile(sourceName, relFile, updates) {
178
+ if (sourceName && sources.length > 0) {
179
+ const source = sources.find(s => s.name === sourceName);
180
+ if (source && source._resolvedPath) {
181
+ return updateMarkdownFile(source._resolvedPath, relFile, updates);
182
+ }
183
+ }
184
+ return updateMarkdownFile(projectPath, relFile, updates);
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Remote sync
189
+ // ---------------------------------------------------------------------------
190
+ async function doSyncRemotes() {
191
+ if (!workspace || sources.length === 0) return;
192
+
193
+ const remoteSources = sources.filter(s => s.type === 'remote');
194
+ if (remoteSources.length === 0) return;
195
+
196
+ await syncAllRemotes(remoteSources);
197
+ scanAll();
198
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
199
+ }
200
+
201
+ function startSyncInterval() {
202
+ if (syncInterval) clearInterval(syncInterval);
203
+ if (!workspace) return;
204
+
205
+ const refreshSeconds = (workspace.settings && workspace.settings.refresh) || 300;
206
+ const remoteSources = sources.filter(s => s.type === 'remote');
207
+ if (remoteSources.length === 0) return;
208
+
209
+ syncInterval = setInterval(() => {
210
+ doSyncRemotes().catch(err => {
211
+ console.warn(' Warning: remote sync failed:', err.message);
212
+ });
213
+ }, refreshSeconds * 1000);
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Static file serving
218
+ // ---------------------------------------------------------------------------
219
+ const MIME_TYPES = {
220
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
221
+ '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
222
+ '.ico': 'image/x-icon', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
223
+ '.gif': 'image/gif', '.webp': 'image/webp',
224
+ };
225
+
226
+ function findCustomCss() {
227
+ const candidates = [
228
+ path.join(projectPath, 'mdboard.css'),
229
+ path.join(projectDir, 'mdboard.css'),
230
+ path.join(os.homedir(), '.config', 'mdboard', 'mdboard.css'),
231
+ ];
232
+ for (const p of candidates) {
233
+ if (fs.existsSync(p)) return p;
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function serveStatic(req, res) {
239
+ const url = new URL(req.url, `http://localhost:${port}`);
240
+
241
+ if (url.pathname === '/mdboard.css') {
242
+ const cssPath = findCustomCss();
243
+ if (cssPath) {
244
+ try {
245
+ const content = fs.readFileSync(cssPath);
246
+ res.writeHead(200, { 'Content-Type': 'text/css' });
247
+ res.end(content);
248
+ return;
249
+ } catch { /* Fall through */ }
250
+ }
251
+ res.writeHead(204);
252
+ res.end();
253
+ return;
254
+ }
255
+
256
+ if (url.pathname === '/logo') {
257
+ if (config.logo && !config.logo.startsWith('http')) {
258
+ const logoPath = path.resolve(projectDir, config.logo);
259
+ if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
260
+ try {
261
+ const logoContent = fs.readFileSync(logoPath);
262
+ const logoExt = path.extname(logoPath);
263
+ const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
264
+ res.writeHead(200, { 'Content-Type': logoMime });
265
+ res.end(logoContent);
266
+ return;
267
+ } catch { /* fall through */ }
268
+ }
269
+ }
270
+ res.writeHead(404);
271
+ res.end('Not found');
272
+ return;
273
+ }
274
+
275
+ let filePath = path.join(boardDir, 'index.html');
276
+ if (!filePath.startsWith(boardDir)) {
277
+ res.writeHead(403);
278
+ res.end('Forbidden');
279
+ return;
280
+ }
281
+
282
+ const ext = path.extname(filePath);
283
+ const contentType = MIME_TYPES[ext] || 'text/plain';
284
+
285
+ try {
286
+ const content = fs.readFileSync(filePath);
287
+ res.writeHead(200, { 'Content-Type': contentType });
288
+ res.end(content);
289
+ } catch {
290
+ res.writeHead(404);
291
+ res.end('Not found');
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // HTTP server
297
+ // ---------------------------------------------------------------------------
298
+ // ---------------------------------------------------------------------------
299
+ // CRUD helpers — resolve source path and delegate to scanner
300
+ // ---------------------------------------------------------------------------
301
+ function createItemFn(sourceName, collection, data) {
302
+ let sourcePath = projectPath;
303
+ let cfg = config;
304
+
305
+ if (sourceName && sources.length > 0) {
306
+ const source = sources.find(s => s.name === sourceName);
307
+ if (source && source._resolvedPath) {
308
+ sourcePath = source._resolvedPath;
309
+ const state = sourceStates.get(sourceName);
310
+ if (state) cfg = state.config;
311
+ }
312
+ }
313
+
314
+ // Use ALL items across sources to compute next ID — prevents cross-source collisions
315
+ switch (collection) {
316
+ case 'tasks':
317
+ return createTask(sourcePath, cfg, data, model.tasks);
318
+ case 'milestones':
319
+ return createMilestone(sourcePath, cfg, data, model.milestones);
320
+ case 'epics':
321
+ return createEpic(sourcePath, cfg, data, model.epics);
322
+ case 'sprints':
323
+ return createSprint(sourcePath, cfg, data, model.sprints);
324
+ case 'notes':
325
+ return createNote(sourcePath, data, model.notes);
326
+ default:
327
+ throw new Error('Unknown collection: ' + collection);
328
+ }
329
+ }
330
+
331
+ function archiveItemFn(sourceName, item) {
332
+ let sourcePath = projectPath;
333
+
334
+ if (sourceName && sources.length > 0) {
335
+ const source = sources.find(s => s.name === sourceName);
336
+ if (source && source._resolvedPath) {
337
+ sourcePath = source._resolvedPath;
338
+ }
339
+ }
340
+
341
+ return archiveItem(sourcePath, item);
342
+ }
343
+
344
+ const apiCtx = {
345
+ get model() { return model; },
346
+ get config() { return config; },
347
+ port,
348
+ get projectDir() { return projectDir; },
349
+ get sources() { return sources; },
350
+ get sourceStates() { return sourceStates; },
351
+ sseClients,
352
+ broadcast,
353
+ updateFile,
354
+ rescanAll: () => scanAll(),
355
+ syncRemotes: doSyncRemotes,
356
+ createItem: createItemFn,
357
+ archiveItem: archiveItemFn,
358
+ getAiSuggestions: () => aiSuggestions || { skills: [], agents: [], mcps: [], commands: [], context: [] },
359
+ get hasProject() {
360
+ return fs.existsSync(path.join(projectDir, 'project')) ||
361
+ (workspace && sources.length > 0);
362
+ },
363
+ loadProject: loadProject,
364
+ getProjectHistory: () => getHistory(projectDir),
365
+ };
366
+
367
+ const server = http.createServer(async (req, res) => {
368
+ try {
369
+ if (req.url.startsWith('/api/')) {
370
+ await handleApi(req, res, apiCtx);
371
+ } else {
372
+ serveStatic(req, res);
373
+ }
374
+ } catch (err) {
375
+ if (!res.headersSent) {
376
+ res.writeHead(500, { 'Content-Type': 'application/json' });
377
+ res.end(JSON.stringify({ error: 'Internal server error' }));
378
+ }
379
+ }
380
+ });
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Startup
384
+ // ---------------------------------------------------------------------------
385
+ if (!workspace && !fs.existsSync(projectPath)) {
386
+ console.warn(`\n Warning: project/ directory not found at ${projectPath}`);
387
+ console.warn(' Run `mdboard init` to scaffold a new project.\n');
388
+ }
389
+
390
+ scanAll();
391
+
392
+ // Setup watchers
393
+ function buildWatcherOpts() {
394
+ const pDirs = [];
395
+ const rDirs = [projectDir];
396
+ if (workspace && sources.length > 0) {
397
+ for (const s of sources) {
398
+ if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
399
+ pDirs.push(s._resolvedPath);
400
+ }
401
+ }
402
+ } else if (fs.existsSync(projectPath)) {
403
+ pDirs.push(projectPath);
404
+ }
405
+ return {
406
+ projectDirs: pDirs,
407
+ rootDirs: rDirs,
408
+ onScan: () => scanAll(),
409
+ onConfigReload: () => reloadConfig(),
410
+ onBroadcast: (data) => broadcast(data),
411
+ workspacePath: workspace ? workspace._path : null,
412
+ onWorkspaceChange: () => {
413
+ workspace = loadWorkspace(projectDir, workspacePath);
414
+ sources = workspace ? workspace.sources : [];
415
+ scanAll();
416
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
417
+ },
418
+ };
419
+ }
420
+
421
+ let activeWatchers = setupWatchers(buildWatcherOpts());
422
+
423
+ // Register current project in history
424
+ if (isValidProject(projectDir)) {
425
+ const projName = (model.project && model.project.name) || path.basename(projectDir);
426
+ registerProject(projectDir, projName);
427
+ }
428
+
429
+ /**
430
+ * Load a different project directory dynamically.
431
+ *
432
+ * @param {string} newProjectDir - Absolute path to the new project
433
+ */
434
+ function loadProject(newProjectDir) {
435
+ // 1. Close existing watchers
436
+ closeWatchers(activeWatchers);
437
+
438
+ // 2. Stop sync interval
439
+ if (syncInterval) clearInterval(syncInterval);
440
+ syncInterval = null;
441
+
442
+ // 3. Update paths
443
+ projectDir = path.resolve(newProjectDir);
444
+ projectPath = path.join(projectDir, 'project');
445
+ workspacePath = null;
446
+
447
+ // 4. Reload config
448
+ config = loadConfig(projectDir);
449
+
450
+ // 5. Reload workspace
451
+ workspace = loadWorkspace(projectDir, null);
452
+ sources = workspace ? workspace.sources : [];
453
+
454
+ // 6. Rescan
455
+ scanAll();
456
+
457
+ // 7. Setup new watchers
458
+ activeWatchers = setupWatchers(buildWatcherOpts());
459
+
460
+ // 8. Restart remote sync
461
+ doSyncRemotes().catch(() => {});
462
+ startSyncInterval();
463
+
464
+ // 9. Register in history
465
+ const projName = (model.project && model.project.name) || path.basename(projectDir);
466
+ registerProject(projectDir, projName);
467
+
468
+ // 10. Broadcast to SSE clients
469
+ broadcast({ type: 'project-changed', projectDir });
470
+ }
471
+
472
+ // Initial async sync + start periodic sync
473
+ doSyncRemotes().catch(() => {});
474
+ startSyncInterval();
475
+
476
+ server.listen(port, () => {
477
+ const sourceInfo = workspace && sources.length > 0
478
+ ? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
479
+ : '';
480
+ const watchCount = activeWatchers.length;
481
+ console.log(`
482
+ mdboard — Project Dashboard
483
+ Project: ${projectDir}
484
+ Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
485
+ ${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
486
+ `);
487
+ });
488
+
489
+ server.on('error', (err) => {
490
+ if (err.code === 'EADDRINUSE') {
491
+ console.error(`\n Error: Port ${port} is already in use. Use --port <number> to pick a different port.\n`);
492
+ process.exit(1);
493
+ }
494
+ throw err;
495
+ });
496
+
497
+ process.on('SIGINT', () => {
498
+ console.log('\n Shutting down...');
499
+ if (syncInterval) clearInterval(syncInterval);
500
+ closeWatchers(activeWatchers);
501
+ for (const client of sseClients) {
502
+ try { client.end(); } catch { /* ignore */ }
503
+ }
504
+ server.close(() => process.exit(0));
505
+ });
506
+
507
+ process.on('SIGTERM', () => {
508
+ if (syncInterval) clearInterval(syncInterval);
509
+ closeWatchers(activeWatchers);
510
+ server.close(() => process.exit(0));
511
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * mdboard — File watcher
3
+ *
4
+ * Watches project directories for changes to .md, config, and CSS files.
5
+ * Supports watching multiple directories for multi-source workspaces.
6
+ */
7
+
8
+ const fs = require('fs');
9
+
10
+ const watchTimers = new Map();
11
+
12
+ /**
13
+ * Setup recursive watcher on a directory for .md, mdboard.json, and mdboard.css changes.
14
+ *
15
+ * @param {string} dir - Directory to watch recursively
16
+ * @param {object} callbacks - { onChange(filename), onConfigChange(), onCssChange(filename) }
17
+ * @returns {fs.FSWatcher|null}
18
+ */
19
+ function watchDir(dir, callbacks) {
20
+ if (!fs.existsSync(dir)) return null;
21
+
22
+ try {
23
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
24
+ if (!filename) return;
25
+
26
+ const isMd = filename.endsWith('.md');
27
+ const isConfig = filename === 'mdboard.json' || filename.endsWith('/mdboard.json');
28
+ const isCss = filename === 'mdboard.css' || filename.endsWith('/mdboard.css');
29
+
30
+ if (!isMd && !isConfig && !isCss) return;
31
+
32
+ const key = dir + ':' + filename;
33
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
34
+
35
+ watchTimers.set(key, setTimeout(() => {
36
+ watchTimers.delete(key);
37
+ if (isConfig && callbacks.onConfigChange) callbacks.onConfigChange();
38
+ if (callbacks.onChange) callbacks.onChange(filename, isCss);
39
+ }, 200));
40
+ });
41
+ return watcher;
42
+ } catch {
43
+ console.warn(' Warning: file watching unavailable for ' + dir);
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Watch a single file for changes (e.g., workspace.json).
50
+ *
51
+ * @param {string} filePath - Absolute path to file
52
+ * @param {function} onChange - Callback on change
53
+ * @returns {fs.FSWatcher|null}
54
+ */
55
+ function watchFile(filePath, onChange) {
56
+ if (!fs.existsSync(filePath)) return null;
57
+
58
+ try {
59
+ const watcher = fs.watch(filePath, (eventType) => {
60
+ const key = 'file:' + filePath;
61
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
62
+
63
+ watchTimers.set(key, setTimeout(() => {
64
+ watchTimers.delete(key);
65
+ if (onChange) onChange();
66
+ }, 200));
67
+ });
68
+ return watcher;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
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
+
93
+ /**
94
+ * Setup watchers for project directories and workspace-level config.
95
+ *
96
+ * @param {object} opts
97
+ * @param {string[]} opts.projectDirs - List of project/ directories to watch recursively
98
+ * @param {string[]} opts.rootDirs - List of root directories to watch for mdboard.json/css
99
+ * @param {function} opts.onScan - Called when files change and a rescan is needed
100
+ * @param {function} opts.onConfigReload - Called when mdboard.json changes
101
+ * @param {function} opts.onBroadcast - Called with event data to broadcast via SSE
102
+ * @param {string} [opts.workspacePath] - Path to workspace.json to watch
103
+ * @param {function} [opts.onWorkspaceChange] - Called when workspace.json changes
104
+ * @returns {Array<fs.FSWatcher|null>} - Array of active watchers
105
+ */
106
+ function setupWatchers(opts) {
107
+ const { projectDirs, rootDirs, onScan, onConfigReload, onBroadcast, workspacePath, onWorkspaceChange } = opts;
108
+ const watchers = [];
109
+
110
+ // Watch each project directory recursively
111
+ for (const dir of projectDirs) {
112
+ const w = watchDir(dir, {
113
+ onConfigChange: () => {
114
+ if (onConfigReload) onConfigReload();
115
+ },
116
+ onChange: (filename, isCss) => {
117
+ if (onScan) onScan();
118
+ const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
119
+ if (isCss) eventData.cssReload = true;
120
+ if (onBroadcast) onBroadcast(eventData);
121
+ },
122
+ });
123
+ if (w) watchers.push(w);
124
+ }
125
+
126
+ // Watch root directories for workspace-level mdboard.json and mdboard.css
127
+ for (const dir of rootDirs) {
128
+ try {
129
+ const w = fs.watch(dir, (eventType, filename) => {
130
+ if (!filename) return;
131
+ if (filename !== 'mdboard.json' && filename !== 'mdboard.css') return;
132
+
133
+ const key = 'root:' + dir + ':' + filename;
134
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
135
+
136
+ watchTimers.set(key, setTimeout(() => {
137
+ watchTimers.delete(key);
138
+ if (filename === 'mdboard.json') {
139
+ if (onConfigReload) onConfigReload();
140
+ if (onScan) onScan();
141
+ }
142
+ const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
143
+ if (filename === 'mdboard.css') eventData.cssReload = true;
144
+ if (onBroadcast) onBroadcast(eventData);
145
+ }, 200));
146
+ });
147
+ watchers.push(w);
148
+ } catch {
149
+ // Non-critical
150
+ }
151
+ }
152
+
153
+ // Watch workspace.json
154
+ if (workspacePath && onWorkspaceChange) {
155
+ const w = watchFile(workspacePath, onWorkspaceChange);
156
+ if (w) watchers.push(w);
157
+ }
158
+
159
+ return watchers;
160
+ }
161
+
162
+ module.exports = { setupWatchers, closeWatchers, watchDir, watchFile };